apploud-cli
Version:
CLI tool for Apploud container hosting management
1,440 lines (1,253 loc) ⢠50.9 kB
JavaScript
#!/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 '