UNPKG

apploud-cli

Version:

CLI tool for Apploud container hosting management

1,440 lines (1,253 loc) • 50.9 kB
#!/usr/bin/env node const { program } = require('commander'); const { spawn, exec } = require('child_process'); const WebSocket = require('ws'); const fs = require('fs'); const path = require('path'); const axios = require('axios'); const os = require('os'); const chalk = require('chalk'); const Table = require('cli-table3'); const inquirer = require('inquirer'); // Get URLs from environment variables or use defaults const APP_URL = process.env.APP_URL || 'https://my.apploud.ir'; const WS_URL = process.env.WS_URL || 'wss://ws.apploud.ir'; // WebSocket server URL with secure protocol const apiBaseUrl = `${APP_URL}/api`; const serverUrl = `${WS_URL}`; const authApiUrl = `${apiBaseUrl}/cli`; // CLI-specific endpoint prefix const tokenFilePath = path.join(os.homedir(), '.apploud', 'auth_token.json'); // Ensure .apploud directory exists if (!fs.existsSync(path.join(os.homedir(), '.apploud'))) { fs.mkdirSync(path.join(os.homedir(), '.apploud'), { recursive: true }); } // Version and metadata display const packageJsonPath = path.join(__dirname, 'package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath)); const VERSION = packageJson.version; // ASCII art logo for welcome message const LOGO = ` _ _ _ ___ _ ___ /_\\ _ __ _ __ | | ___ _ _ __| | / __| | |_ _| //_\\\\| '_ \\| '_ \\| |/ _ \\| | | |/ _\` || | | | | | / _ \\ |_) | |_) | | (_) | |_| | (_| || |__| |___| | \\_/ \\_/ .__/| .__/|_|\\___/ \\__,_|\\__,_(_)___|_____|___| |_| |_| `; // Show welcome message with logo function showWelcome() { console.log(chalk.blue(LOGO)); console.log(chalk.bold(`Apploud CLI v${VERSION}`)); console.log(chalk.gray('šŸš€ Container hosting management made simple\n')); console.log(chalk.yellow('šŸ“‹ Quick Start:')); console.log(chalk.gray(' apploud login ') + chalk.cyan('- Authenticate with your account')); console.log(chalk.gray(' apploud instances ') + chalk.cyan('- List your containers')); console.log(chalk.gray(' apploud shell <name> ') + chalk.cyan('- Connect to a container')); console.log(chalk.gray(' apploud push <file> ') + chalk.cyan('- Upload files')); console.log(chalk.gray(' apploud pull <file> ') + chalk.cyan('- Download files')); console.log(''); console.log(chalk.gray('šŸ’” Run ') + chalk.white('apploud --help') + chalk.gray(' for all available commands')); console.log(''); } // Check if this is the first run if (!process.argv.slice(2).length && !process.env.HIDE_WELCOME) { showWelcome(); // Don't show duplicate help - welcome message is enough process.exit(0); } // Function to handle login and redirect to GitHub async function login() { try { console.log(chalk.blue('šŸš€ Initiating login process...')); // Ask the user which login method they prefer const { loginMethod } = await inquirer.prompt([ { type: 'list', name: 'loginMethod', message: 'Choose your login method:', choices: [ { name: 'Email and Password', value: 'password' }, { name: 'GitHub (Browser Authentication)', value: 'github' } ] } ]); if (loginMethod === 'password') { // Email/Password login flow const credentials = await inquirer.prompt([ { type: 'input', name: 'email', message: 'Enter your email:', validate: input => input.includes('@') ? true : 'Please enter a valid email address' }, { type: 'password', name: 'password', message: 'Enter your password:', mask: '*' } ]); console.log(chalk.gray(`API URL: ${authApiUrl}/login`)); // Request with email/password const response = await axios.post(`${authApiUrl}/login`, { auth_type: 'password', email: credentials.email, password: credentials.password }, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 15000, validateStatus: function (status) { return true; // Accept all status codes to handle them manually } }); if (response.status === 401) { console.log(chalk.red('āœ— Authentication failed: Invalid email or password')); process.exit(1); } if (response.status >= 400) { if (response.data && response.data.message) { throw new Error(`Server returned status ${response.status}: ${response.data.message}`); } else { throw new Error(`Server returned status ${response.status}`); } } // Save the token directly if (response.data.token) { fs.writeFileSync(tokenFilePath, JSON.stringify({ token: response.data.token })); console.log(chalk.green('āœ“ Authentication successful! Token captured and stored.')); if (response.data.user) { console.log(chalk.green(`Welcome, ${response.data.user.name || response.data.user.email}!`)); } return; } else { throw new Error('Invalid response from server - missing token'); } } else { // GitHub OAuth flow (original implementation) console.log(chalk.gray(`API URL: ${authApiUrl}/login`)); console.log(chalk.yellow('Starting GitHub authentication flow...')); // Step 1: Initiate the login flow const response = await axios.post(`${authApiUrl}/login`, { auth_type: 'github' }, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, timeout: 15000, validateStatus: function (status) { return true; // Accept all status codes to handle them manually } }); if (response.status >= 400) { if (response.status === 401) { console.log(chalk.yellow('āš ļø Authentication issue detected. Please check your Laravel API configuration.')); console.log(chalk.yellow('Run the debug utility for more info: node debug.js')); } if (response.data && response.data.message) { throw new Error(`Server returned status ${response.status}: ${response.data.message}`); } else { throw new Error(`Server returned status ${response.status}`); } } if (response.data.status === 'error') { throw new Error(response.data.message || 'Authentication failed'); } const redirectUrl = response.data.redirect_url; const tempToken = response.data.temp_token; if (!redirectUrl || !tempToken) { throw new Error('Invalid response from server - missing redirect URL or token'); } console.log(chalk.yellow('Redirecting to GitHub for authentication...')); console.log(chalk.blue('Please complete the authentication in your browser.')); console.log(chalk.gray('Authentication URL:', redirectUrl)); // Open the browser for authentication const open = (await import('open')).default; await open(redirectUrl); // Step 2: Poll for authentication status const token = await pollForToken(tempToken); if (token) { fs.writeFileSync(tokenFilePath, JSON.stringify({ token })); console.log(chalk.green('āœ“ Authentication successful! Token captured and stored.')); } else { console.log(chalk.red('āœ— Authentication failed or timed out.')); process.exit(1); } } } catch (error) { console.error(chalk.red(`Login failed: ${error.message}`)); if (error.response) { console.error(chalk.red(`Server response: ${JSON.stringify(error.response.data)}`)); } process.exit(1); } } // Updated polling function with better error handling and server log pattern matching async function pollForToken(tempToken) { const checkTokenUrl = `${apiBaseUrl}/cli/check-token?temp_token=${tempToken}`; const spinner = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā ']; let spinnerIdx = 0; let lastStatus = null; let stuckCounter = 0; let unauthenticatedCounter = 0; console.log('Waiting for GitHub authentication to complete...'); // First direct attempt - try to get the token immediately before polling starts try { const directResponse = await axios.get(checkTokenUrl, { validateStatus: () => true, timeout: 5000 }); // Check for the most common success pattern if (directResponse.data && directResponse.data.status === 'success' && directResponse.data.token) { console.log(chalk.green('āœ“ Found token in initial direct response')); return directResponse.data.token; } } catch (directError) { console.log(chalk.yellow('Initial token check failed, continuing with polling...')); } // From the server logs, we see it uses "cache_status":"authenticated" // Let's properly check for this pattern for (let i = 0; i < 120; i++) { // 4 minutes timeout (120 * 2 seconds) try { // Check authentication status using checkTokenUrl instead of statusUrl to avoid redirects const statusResponse = await axios.get(checkTokenUrl, { validateStatus: () => true, timeout: 5000 }); // Check both status and cache_status fields from server logs let status = 'unknown'; if (statusResponse.data && typeof statusResponse.data === 'object') { // Look for status in multiple potential locations based on server logs if (statusResponse.data.status) { status = statusResponse.data.status; } else if (statusResponse.data.cache_status) { status = statusResponse.data.cache_status; } } // Handle "Unauthenticated" error responses if (statusResponse.data.message === 'Unauthenticated.') { unauthenticatedCounter++; if (unauthenticatedCounter > 3) { throw new Error('Too many unauthenticated responses. Please try again.'); } await new Promise(resolve => setTimeout(resolve, 2000)); continue; } // Reset unauthenticated counter if we got a different response unauthenticatedCounter = 0; // Check if we're stuck in the same status if (lastStatus === status) { stuckCounter++; if (stuckCounter > 10) { throw new Error('Authentication process seems stuck. Please try again.'); } } else { stuckCounter = 0; } lastStatus = status; // IMPORTANT: Check for the success status and token first // This is the most direct and reliable pattern seen in the logs if (status === 'success' && statusResponse.data && statusResponse.data.token) { console.log(chalk.green('āœ“ Found token in success response')); return statusResponse.data.token; } // Just update spinner for normal polling process.stdout.write(`\r${chalk.yellow(`Waiting for authentication ${spinner[spinnerIdx]} (${i+1})`)} `); spinnerIdx = (spinnerIdx + 1) % spinner.length; // Check for both status and manual check for "authenticated" in the response if (status === 'authenticated' || (statusResponse.data && JSON.stringify(statusResponse.data).includes('authenticated'))) { process.stdout.write('\r' + chalk.green('Authentication successful! Getting token...') + '\n'); // From the logs, we can see the server is storing the token in the cache with this key if (statusResponse.data && statusResponse.data.token) { return statusResponse.data.token; } // Try getting the authenticated token directly try { const directAuthResponse = await axios.get(`${apiBaseUrl}/cli/auth?temp_token=${tempToken}`, { validateStatus: () => true, timeout: 5000 }); if (directAuthResponse.data && directAuthResponse.data.token) { return directAuthResponse.data.token; } } catch (authError) { // Continue with other methods } // If authenticated, get the actual token const tokenResponse = await axios.get(checkTokenUrl, { validateStatus: () => true, timeout: 5000 }); // First, try a direct approach based on the actual server response format try { // Simple extraction - check if the response has status "success" and a token property if (tokenResponse.data && tokenResponse.data.status === 'success' && tokenResponse.data.token) { return tokenResponse.data.token; } // Try all other potential token locations if the direct approach fails let token = null; if (tokenResponse.data.token) { token = tokenResponse.data.token; } else if (tokenResponse.data.access_token) { token = tokenResponse.data.access_token; } else if (typeof tokenResponse.data === 'string' && tokenResponse.data.length > 20) { // Sometimes token might be returned directly as string token = tokenResponse.data; } if (token) { return token; } } catch (extractError) { // Continue with other methods } // Try alternative direct approach try { const directTokenResponse = await axios.post(`${authApiUrl}/token`, { temp_token: tempToken }, { validateStatus: () => true, timeout: 5000 }); if (directTokenResponse.data && directTokenResponse.data.token) { return directTokenResponse.data.token; } } catch (tokenError) { // Continue with polling } } else if (status === 'error' || status === 'reset') { throw new Error(statusResponse.data.message || 'Authentication failed or was reset'); } } catch (error) { // Handle network errors but continue polling console.log(chalk.yellow(`Network error: ${error.message}. Retrying...`)); } // Wait 2 seconds before polling again await new Promise(resolve => setTimeout(resolve, 2000)); } console.error(chalk.red('\nAuthentication timeout. Please try again.')); return null; } // Function to get stored token function getToken() { if (fs.existsSync(tokenFilePath)) { const data = fs.readFileSync(tokenFilePath); try { return JSON.parse(data).token; } catch (e) { return null; } } return null; } // Function to refresh token if expired async function refreshToken() { try { const token = getToken(); if (!token) return null; const response = await axios.post(`${authApiUrl}/refresh`, {}, { headers: { 'Authorization': `Bearer ${token}` } }); if (response.data.token) { fs.writeFileSync(tokenFilePath, JSON.stringify({ token: response.data.token })); return response.data.token; } return null; } catch (error) { console.error(chalk.yellow('Token refresh failed')); return null; } } // Middleware to check authentication async function requireAuth() { let token = getToken(); if (!token) { console.log(chalk.red('No valid token found. Please log in.')); process.exit(1); } try { // Verify token validity await axios.get(`${authApiUrl}/verify`, { headers: { 'Authorization': `Bearer ${token}` } }); } catch (error) { if (error.response?.status === 401) { console.log(chalk.yellow('Token expired. Attempting to refresh...')); token = await refreshToken(); if (!token) { console.log(chalk.red('Session expired. Please login again.')); process.exit(1); } else { console.log(chalk.green('Token refreshed successfully.')); } } else { console.log(chalk.red(`Authentication error: ${error.message}`)); process.exit(1); } } return token; } // Improve feedback with a spinner function createSpinner(message) { const spinnerFrames = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā ']; let frameIndex = 0; let interval; let running = false; let spinnerText = ''; return { start: (text) => { spinnerText = text || message; running = true; frameIndex = 0; if (interval) clearInterval(interval); process.stdout.write(`${spinnerFrames[0]} ${spinnerText}`); interval = setInterval(() => { if (!running) return; frameIndex = (frameIndex + 1) % spinnerFrames.length; process.stdout.write(`\r${spinnerFrames[frameIndex]} ${spinnerText}`); }, 100); return this; }, stop: (endText) => { running = false; clearInterval(interval); if (endText) { process.stdout.write(`\r${endText}\n`); } else { process.stdout.write('\r' + ' '.repeat(spinnerText.length + 2) + '\r'); } }, update: (text) => { spinnerText = text; process.stdout.write(`\r${spinnerFrames[frameIndex]} ${spinnerText}`); } }; } // Main command setup - compatible with older Commander versions program .name('apploud') .description('CLI tool for container hosting management') .version(VERSION, '-v, --version', 'Output the current version'); // Add error handling to show help when errors occur program.on('command:*', function () { console.error(chalk.red(`Invalid command: ${program.args.join(' ')}`)); console.log(''); program.outputHelp(); process.exit(1); }); // Command to log in and store token program .command('login') .description('Log in to the system and store authentication token') .action(async () => { console.log(chalk.blue('šŸš€ Initiating login process...')); await login(); console.log(chalk.green('\nāœ… You are now logged in and ready to use Apploud CLI!')); console.log(chalk.gray('Try running "apploud instances" to list your instances.')); }); // Command to log out program .command('logout') .description('Log out and remove stored authentication token') .action(() => { if (fs.existsSync(tokenFilePath)) { fs.unlinkSync(tokenFilePath); console.log(chalk.green('āœ… Logged out successfully. Token removed.')); } else { console.log(chalk.yellow('ā„¹ļø No active session found.')); } }); // Add a command to list instances program .command('instances') .alias('list') .description('List all your instances') .option('-j, --json', 'Output in JSON format') .action(async (options) => { const spinner = createSpinner('Fetching your instances...'); spinner.start(); const token = await requireAuth(); try { const response = await axios.get(`${authApiUrl}/instances`, { headers: { Authorization: `Bearer ${token}` } }); if (response.data && Array.isArray(response.data) && response.data.length > 0) { spinner.stop(chalk.green(`āœ… Found ${response.data.length} instances`)); if (options.json) { console.log(JSON.stringify(response.data, null, 2)); return; } // Create a nice table const table = new Table({ head: [ chalk.bold('Name'), chalk.bold('Status'), chalk.bold('Node'), chalk.bold('Created') ], colWidths: [25, 15, 20, 25] }); // Add each instance to the table response.data.forEach(instance => { table.push([ chalk.green(instance.name), getStatusWithColor(instance.status), instance.node || 'N/A', instance.created_at ]); }); console.log(table.toString()); // Show tip for connecting to a running instance const runningInstance = response.data.find(instance => instance.status && instance.status.toLowerCase() === 'active' ); if (runningInstance) { console.log(chalk.blue(`šŸ’” Tip: Run 'apploud shell ${runningInstance.name}' to connect to an active instance.`)); } else { console.log(chalk.yellow(`šŸ’” No active instances found. Start one in the web console to connect.`)); } } else { spinner.stop(chalk.yellow('ā„¹ļø No instances found')); console.log(chalk.gray('Create one in the web console at https://my.apploud.ir')); } } catch (error) { spinner.stop(chalk.red(`āŒ Error fetching instances`)); console.error(chalk.red(`Details: ${error.message}`)); process.exit(1); } }); // Helper function to colorize instance status function getStatusWithColor(status) { if (!status) return chalk.gray('Unknown'); status = status.toLowerCase(); if (status === 'running') return chalk.green('Running'); if (status === 'stopped') return chalk.red('Stopped'); if (status === 'starting') return chalk.blue('Starting'); if (status === 'stopping') return chalk.yellow('Stopping'); if (status === 'error') return chalk.red('Error'); return chalk.gray(status); } // Enhanced recursive directory push to work like cp command program .command('push <filePath> <instanceName> <destinationPath>') .description('Push a file to the specified instance') .option('-r, --recursive', 'Recursively push directories') .option('-v, --verbose', 'Show detailed progress information') .option('-f, --force', 'Overwrite existing files without confirmation') .action(async (filePath, instanceName, destinationPath, options) => { const token = await requireAuth(); // Set verbose mode based on command option if (options.verbose) { process.env.APPLOUD_VERBOSE = 'true'; } try { // Handle wildcard patterns in recursive mode if (options.recursive && filePath.includes('*')) { console.log(chalk.blue(`Pushing files matching ${filePath} to ${instanceName}:${destinationPath}`)); // Get the base directory from the wildcard pattern const baseDir = path.dirname(filePath); const pattern = path.basename(filePath); // Read files matching the pattern const files = fs.readdirSync(baseDir) .filter(file => { // Simple wildcard matching (handles * as wildcard) if (pattern === '*') return true; if (pattern.startsWith('*') && pattern.endsWith('*')) { const mid = pattern.slice(1, -1); return file.includes(mid); } if (pattern.startsWith('*')) { return file.endsWith(pattern.slice(1)); } if (pattern.endsWith('*')) { return file.startsWith(pattern.slice(0, -1)); } return file === pattern; }) .map(file => path.join(baseDir, file)); if (files.length === 0) { console.error(chalk.red(`Error: No files found matching pattern: ${filePath}`)); process.exit(1); } // Push each file individually for (const file of files) { const stats = fs.statSync(file); const isDir = stats.isDirectory(); const fileName = path.basename(file); if (isDir && options.recursive) { console.log(chalk.blue(`Skipping directory ${file} - use specific directory path for recursive push`)); continue; } // Determine the target path const targetPath = path.join(destinationPath, fileName).replace(/\\/g, '/'); console.log(chalk.blue(`Pushing file ${file} to ${instanceName}:${targetPath}`)); await pushSingleFile(file, instanceName, targetPath, token); } console.log(chalk.green(`Completed pushing all matching files to ${instanceName}:${destinationPath}`)); return; } // Check if file exists if (!fs.existsSync(filePath)) { console.error(chalk.red(`Error: File or directory not found: ${filePath}`)); process.exit(1); } const stats = fs.statSync(filePath); if (options.recursive && stats.isDirectory()) { // Get directory name from source path (for cp-like behavior) const dirName = path.basename(filePath); // Create final destination path that includes the directory name // This makes it behave like: cp -r ./terraform /home/ubuntu -> /home/ubuntu/terraform let finalDestPath = destinationPath; // If destination doesn't end with the source directory name, append it if (!finalDestPath.endsWith(dirName) && !finalDestPath.endsWith(dirName + '/')) { finalDestPath = path.join(destinationPath, dirName); } console.log(chalk.blue(`Pushing directory ${filePath} to ${instanceName}:${finalDestPath}`)); // Create base destination directory const baseSuccess = await createRemoteDirectory(instanceName, finalDestPath, token); if (!baseSuccess && options.verbose) { console.log(chalk.yellow(`Warning: Could not create base directory, but will try to continue`)); } // Get all files in the directory recursively const files = getAllFiles(filePath); const baseDir = filePath; // Group files by directory to optimize directory creation const dirGroups = {}; files.forEach(file => { const relativePath = path.relative(baseDir, file); const targetDir = path.dirname(relativePath); if (!dirGroups[targetDir]) { dirGroups[targetDir] = []; } dirGroups[targetDir].push(file); }); // Process each directory first to create them all, but silently for (const dir of Object.keys(dirGroups).sort()) { if (dir !== '.') { const remoteDirPath = path.join(finalDestPath, dir).replace(/\\/g, '/'); await createRemoteDirectory(instanceName, remoteDirPath, token); } } // Now process all files after directories are created let successCount = 0; let failCount = 0; console.log(chalk.blue(`Copying ${files.length} files to ${instanceName}:${finalDestPath}/`)); // Show a progress indicator const fileCount = files.length; let processedCount = 0; for (const file of files) { // Create relative path from base directory const relativePath = path.relative(baseDir, file); const targetPath = path.join(finalDestPath, relativePath).replace(/\\/g, '/'); processedCount++; if (!options.verbose) { // Simple progress indicator without detailed messages process.stdout.write(`\rProgress: ${processedCount}/${fileCount} files (${Math.round(processedCount/fileCount*100)}%)`); } try { if (options.verbose) { console.log(chalk.blue(`Pushing ${file} to ${instanceName}:${targetPath}`)); } await pushSingleFile(file, instanceName, targetPath, token); successCount++; } catch (error) { console.error(chalk.red(`Failed to push ${file}: ${error.message}`)); failCount++; } } // Clear the progress line if (!options.verbose) { process.stdout.write('\r' + ' '.repeat(50) + '\r'); } console.log(chalk.green(`\nPush complete: ${successCount} files succeeded, ${failCount} files failed`)); } else { // Handle single file push // For files, check if the destination is a directory let finalDestPath = destinationPath; // If recursive is not set and source is a file, assume we want to rename it // unless destination is a directory path (ends with '/') const srcFileName = path.basename(filePath); if (destinationPath.endsWith('/')) { // If destination ends with /, append the source filename finalDestPath = path.join(destinationPath, srcFileName); } console.log(chalk.blue(`Pushing file ${filePath} to ${instanceName}:${finalDestPath}`)); await pushSingleFile(filePath, instanceName, finalDestPath, token); } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } }); // Helper function to get all files in a directory recursively function getAllFiles(dirPath, arrayOfFiles = []) { const files = fs.readdirSync(dirPath); files.forEach(file => { const fullPath = path.join(dirPath, file); if (fs.statSync(fullPath).isDirectory()) { arrayOfFiles = getAllFiles(fullPath, arrayOfFiles); } else { arrayOfFiles.push(fullPath); } }); return arrayOfFiles; } // Helper function to create directory on remote instance async function createRemoteDirectory(instanceName, dirPath, token) { try { // Normalize path format and remove leading slash const normalizedPath = dirPath.replace(/\\/g, '/').replace(/^\//, ''); // Don't print directory creation messages in recursive mode unless in verbose mode // This avoids flooding the console with messages const isVerbose = process.env.APPLOUD_VERBOSE === 'true'; if (isVerbose) { console.log(chalk.gray(`Creating directory on instance: /${normalizedPath}`)); } // Connect to the WebSocket server to create directory return new Promise((resolve, reject) => { const ws = new WebSocket(`${serverUrl}/${instanceName}/push`, { headers: { Authorization: `Bearer ${token}` }, handshakeTimeout: 15000 }); // Set a connection timeout const connectionTimeout = setTimeout(() => { ws.terminate(); if (isVerbose) { console.error(chalk.yellow(`Warning: Connection timed out when creating directory ${normalizedPath}`)); } resolve(false); // Continue despite timeout }, 15000); ws.on('open', () => { // Send a directory creation request (special message format) ws.send(JSON.stringify({ action: 'mkdir', path: normalizedPath })); }); ws.on('message', (data) => { const message = data.toString(); try { // Try to parse as JSON const response = JSON.parse(message); if (response.action === 'mkdir') { clearTimeout(connectionTimeout); if (response.success) { if (isVerbose) { console.log(chalk.green(`Created directory: /${normalizedPath}`)); } ws.close(); resolve(true); } else { if (isVerbose || response.critical) { console.log(chalk.yellow(`Warning: ${response.message || 'Failed to create directory'}`)); } ws.close(); resolve(false); } } else if (message.includes('Error:')) { clearTimeout(connectionTimeout); ws.close(); if (isVerbose) { console.error(chalk.yellow(`Warning: Failed to create directory ${normalizedPath}`)); } resolve(false); // Continue anyway } } catch (e) { // If not JSON, check for plain text responses if (message.includes('Directory created')) { clearTimeout(connectionTimeout); if (isVerbose) { console.log(chalk.green(`Created directory: /${normalizedPath}`)); } ws.close(); resolve(true); } else if (message === 'Ready to receive file data') { // This is for file transfer, but we're doing mkdir // Send a close signal ws.send(JSON.stringify({ action: 'close', reason: 'Directory creation only' })); setTimeout(() => { ws.close(); clearTimeout(connectionTimeout); resolve(false); }, 500); } } }); ws.on('error', (error) => { clearTimeout(connectionTimeout); if (isVerbose) { console.error(chalk.yellow(`Warning: Failed to create directory ${normalizedPath}: ${error.message}`)); } resolve(false); // Continue even if directory creation fails }); ws.on('close', () => { clearTimeout(connectionTimeout); resolve(false); }); }); } catch (error) { const isVerbose = process.env.APPLOUD_VERBOSE === 'true'; if (isVerbose) { console.error(chalk.yellow(`Warning: Failed to create directory ${dirPath} on instance ${instanceName}`)); console.error(chalk.yellow(`This might cause file transfers to fail if directories don't exist`)); } return false; } } // Helper function to push a single file via WebSocket async function pushSingleFile(filePath, instanceName, destinationPath, token) { return new Promise((resolve, reject) => { try { // Get the original filename from the path const originalFilename = path.basename(filePath); // Create parent directory first const parentDir = path.dirname(destinationPath); if (parentDir !== '.' && parentDir !== '/') { createRemoteDirectory(instanceName, parentDir, token) .then(() => startFileTransfer()) .catch(() => startFileTransfer()); // Continue even if directory creation fails } else { startFileTransfer(); } function startFileTransfer() { // Get file stats for progress reporting const fileSize = fs.statSync(filePath).size; let bytesSent = 0; let fileTransferStarted = false; let fileTransferComplete = false; const ws = new WebSocket(`${serverUrl}/${instanceName}/push`, { headers: { Authorization: `Bearer ${token}` }, handshakeTimeout: 15000 // Increase timeout to 15 seconds }); const connectionTimeout = setTimeout(() => { if (!fileTransferStarted) { ws.terminate(); reject(new Error("Connection timed out")); } }, 20000); // 20 second timeout ws.on('open', () => {}); ws.on('message', (data) => { const message = data.toString(); // Handle server messages if (message.startsWith('Error:')) { clearTimeout(connectionTimeout); ws.close(); reject(new Error(message.replace('Error: ', ''))); return; } else if (message === 'File transferred successfully') { fileTransferComplete = true; clearTimeout(connectionTimeout); ws.close(); resolve(); return; } else if (message === 'Ready to receive file data') { sendFileData(); return; } }); ws.on('close', () => { clearTimeout(connectionTimeout); if (!fileTransferComplete) { reject(new Error("Connection closed prematurely")); } }); ws.on('error', (error) => { clearTimeout(connectionTimeout); reject(error); }); // Function to send file data function sendFileData() { if (fileTransferStarted) return; fileTransferStarted = true; // Read the file and send its content try { const fileStream = fs.createReadStream(filePath); fileStream.on('data', (chunk) => { try { ws.send(chunk); bytesSent += chunk.length; } catch (err) { fileStream.destroy(); reject(new Error(`WebSocket error while sending file: ${err.message}`)); } }); fileStream.on('end', () => { // Send completion signal with destination path and original filename try { ws.send(JSON.stringify({ destinationPath, originalFilename, end: true })); } catch (err) { reject(new Error(`Error sending completion signal: ${err.message}`)); } }); fileStream.on('error', (error) => { ws.close(); reject(error); }); } catch (err) { reject(new Error(`Error opening file: ${err.message}`)); } } // Timeout to ensure the transfer starts even if we miss the ready message setTimeout(() => { if (!fileTransferStarted) { sendFileData(); } }, 3000); } } catch (error) { reject(error); } }); } // Command to pull a file from an instance program .command('pull <instanceName> <filePath> <destinationPath>') .description('Pull a file from the specified instance') .option('-r, --recursive', 'Recursively pull directories') .option('-v, --verbose', 'Show detailed progress information') .option('-f, --force', 'Overwrite existing files without confirmation') .action(async (instanceName, filePath, destinationPath, options) => { const token = await requireAuth(); try { if (options.recursive) { console.log(chalk.yellow("Recursive pull not implemented yet")); // To be implemented in future version } else { // Single file pull // Handle directory destinations - if destination ends with / or is a directory, append original filename let finalDestinationPath = destinationPath; if (destinationPath.endsWith('/') || destinationPath === '.' || destinationPath === './') { const originalFilename = path.basename(filePath); if (destinationPath === '.' || destinationPath === './') { finalDestinationPath = `./${originalFilename}`; } else { finalDestinationPath = path.join(destinationPath, originalFilename); } console.log(chalk.blue(`Pulling ${instanceName}:${filePath} to ${finalDestinationPath}`)); } else if (fs.existsSync(destinationPath) && fs.statSync(destinationPath).isDirectory()) { const originalFilename = path.basename(filePath); finalDestinationPath = path.join(destinationPath, originalFilename); console.log(chalk.blue(`Pulling ${instanceName}:${filePath} to ${finalDestinationPath}`)); } else { console.log(chalk.blue(`Pulling ${instanceName}:${filePath} to ${finalDestinationPath}`)); } // Check if destination directory exists const destDir = path.dirname(finalDestinationPath); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // Use WebSocket for file transfer const wsUrl = `${serverUrl}/${instanceName}/pull`; console.log(chalk.gray('Establishing connection...')); const ws = new WebSocket(wsUrl, { headers: { 'Authorization': `Bearer ${token}` }, handshakeTimeout: 15000 // Increase timeout }); let fileStream = null; let fileSize = 0; let bytesReceived = 0; let lastProgressUpdate = Date.now(); let transferStarted = false; let errored = false; // Set timeout to detect connection issues const connectionTimeout = setTimeout(() => { if (!transferStarted) { console.error(chalk.red('Connection timed out. The server did not respond.')); cleanupAndExit(1); } }, 30000); // 30 seconds timeout function cleanupAndExit(code) { if (fileStream && !fileStream.destroyed) { if (code === 0) { // For successful completion, ensure file is properly closed fileStream.end(() => { if (ws.readyState === WebSocket.OPEN) { ws.close(); } clearTimeout(connectionTimeout); process.exit(code); }); return; } else { // For errors, close immediately and remove partial file fileStream.end(); if (fs.existsSync(finalDestinationPath)) { try { fs.unlinkSync(finalDestinationPath); } catch (e) { // Ignore cleanup errors } } } } if (ws.readyState === WebSocket.OPEN) { ws.close(); } clearTimeout(connectionTimeout); process.exit(code); } ws.on('open', () => { console.log(chalk.gray('Connection established, waiting for server...')); }); ws.on('message', (data) => { try { // Try to parse as JSON (for metadata/control messages) const message = JSON.parse(data.toString()); if (message.status === 'ready') { console.log(chalk.gray('Server ready, sending file request...')); // Request the file ws.send(JSON.stringify({ sourcePath: filePath })); } else if (message.status === 'start') { transferStarted = true; clearTimeout(connectionTimeout); fileSize = message.size || 0; console.log(chalk.gray(`File size: ${formatBytes(fileSize)}`)); console.log(chalk.gray('Downloading...')); fileStream = fs.createWriteStream(finalDestinationPath); bytesReceived = 0; // Set a new timeout for data transfer clearTimeout(connectionTimeout); } else if (message.status === 'end') { console.log(chalk.green('\nDownload complete!')); cleanupAndExit(0); } else if (message.status === 'error') { errored = true; console.error(chalk.red(`\nError: ${message.message}`)); cleanupAndExit(1); } } catch (e) { // If not JSON, it's binary data - write to file if (fileStream) { try { fileStream.write(data); bytesReceived += data.length; // Update progress every 500ms to avoid console flooding const now = Date.now(); if (now - lastProgressUpdate > 500) { lastProgressUpdate = now; const percentage = fileSize ? Math.round((bytesReceived / fileSize) * 100) : 0; process.stdout.write(`\rDownloaded: ${formatBytes(bytesReceived)}${fileSize ? ` / ${formatBytes(fileSize)} (${percentage}%)` : ''}`); } } catch (writeError) { console.error(chalk.red(`\nError writing to file: ${writeError.message}`)); cleanupAndExit(1); } } } }); ws.on('error', (error) => { console.error(chalk.red(`\nConnection error: ${error.message}`)); cleanupAndExit(1); }); ws.on('close', () => { if (!errored && !transferStarted) { console.error(chalk.red('\nConnection closed without receiving any data. The file may not exist or may not be accessible.')); cleanupAndExit(1); } else if (!errored) { cleanupAndExit(0); } }); // Handle Ctrl+C process.on('SIGINT', () => { console.log(chalk.yellow('\nDownload cancelled by user')); cleanupAndExit(130); }); // Wait for WebSocket to complete await new Promise((resolve) => { ws.on('close', resolve); }); } } catch (error) { console.error(chalk.red(`Error: ${error.message}`)); process.exit(1); } }); // Helper function to format bytes to human-readable format function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } // Improved shell command program .command('shell <instanceName>') .description('Start a shell session with the specified instance') .action(async (instanceName) => { const spinner = createSpinner(`Connecting to ${instanceName}...`); spinner.start(); const token = await requireAuth(); try { const wsUrl = `${serverUrl}/${instanceName}`; // Create WebSocket connection const ws = new WebSocket(wsUrl, { followRedirects: true, headers: { 'Authorization': `Bearer ${token}` } }); // Set a timeout for connection const connectionTimeout = setTimeout(() => { spinner.stop(chalk.red(`āŒ Connection timed out after 15 seconds`)); process.exit(1); }, 15000); // Set up terminal const stdin = process.stdin; ws.on('error', (error) => { spinner.stop(chalk.red(`āŒ Connection error: ${error.message}`)); process.exit(1); }); ws.on('open', () => { // Clear the timeout clearTimeout(connectionTimeout); // Hide spinner once connected spinner.stop(); stdin.setRawMode(true); stdin.resume(); stdin.setEncoding(null); // Buffer mode for all keys // Set initial terminal size const size = process.stdout.getWindowSize(); ws.send(JSON.stringify({ type: 'resize', cols: size[0], rows: size[1] })); // Handle terminal input stdin.on('data', (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); // Always send as Buffer } }); // Handle terminal resize process.stdout.on('resize', () => { const size = process.stdout.getWindowSize(); ws.send(JSON.stringify({ type: 'resize', cols: size[0], rows: size[1] })); }); }); ws.on('message', (data) => { process.stdout.write(data.toString()); }); ws.on('close', () => { stdin.setRawMode(false); stdin.pause(); console.log(chalk.yellow('\nConnection closed')); process.exit(0); }); // Handle process termination process.on('SIGINT', () => { ws.close(); process.exit(0); }); } catch (error) { spinner.stop(chalk.red(`āŒ Connection error: ${error.message}`)); process.exit(1); } }); // Add ssh as a hidden alias to shell (doesn't show in help) program .command('ssh <instanceName>', { hidden: true }) .action(async (instanceName) => { // Just call the shell command const shellCmd = program.commands.find(cmd => cmd.name() === 'shell'); if (shellCmd) { await shellCmd._actionHandler(instanceName); } }); // Add command completion support program .command('completion') .description('Generate shell completion script') .action(() => { console.log(` # Apploud CLI completion script # Installation: # - For bash: add to .bashrc: source <(apploud completion) # - For zsh: add to .zshrc: source <(apploud completion) # - For fish: add to config.fish: apploud completion | source case "$SHELL" in */bash) # Bash completion _apploud_completion() { local cmd="\${COMP_WORDS[0]}" local cur="\${COMP_WORDS[COMP_CWORD]}" local prev="\${COMP_WORDS[COMP_CWORD-1]}" local commands="login logout instances list push pull shell" case "\${prev}" in apploud) COMPREPLY=($(compgen -W "$commands" -- "$cur")) return 0 ;; push|pull) if [[ "\${COMP_CWORD}" -eq 2 ]]; then # Suggest local files for push, instance names for pull if [[ "\${prev}" == "push" ]]; then COMPREPLY=($(compgen -f -- "$cur")) else # Could fetch instance list from API here COMPREPLY=() fi fi return 0 ;; shell) # Could fetch instance list from API here COMPREPLY=() return 0 ;; esac return 0 } complete -F _apploud_completion apploud ;; */zsh) # Zsh completion _apploud() { local -a commands commands=( "login:Log in to the system and store authentication token" "logout:Log out and remove stored authentication token" "instances:List all your instances" "list:List all your instances" "push:Push a file to the specified instance" "pull:Pull a file from an instance" "shell:Start a shell session with the specified instance" ) _describe 'apploud command' commands } compdef _apploud apploud ;; */fish) # Fish completion echo " function __fish_apploud_no_subcommand not __fish_seen_subcommand_from login logout instances list push pull shell end complete -c apploud -f -n '__fish_apploud_no_subcommand' -a 'login' -d 'Log in to the system' complete -c apploud -f -n '__fish_apploud_no_subcommand' -a 'logout' -d 'Log out' complete -c apploud -f -n '__fish_apploud_no_subcommand' -a 'instances' -d 'List your instances' complete -c apploud -f -n '__fish_apploud_no_subcommand' -a 'list' -d 'List your instances' complete -c apploud -f -n '__fish_apploud_no_subcommand' -a 'push' -d 'Push files to instance' complete -c apploud -f -n '__fish_apploud_no_subcommand' -a 'pull' -d 'Pull files from instance' complete -c apploud -f -n '