From 384da3706445e2eae29d396aee67f5ddb748e48d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Mar 2025 12:41:54 -0800 Subject: [PATCH] fix(npm): download issues and improve CLI messaging --- packages/simstudio/package.json | 2 +- packages/simstudio/src/commands/start.ts | 349 +++++++++++++++-------- packages/simstudio/src/index.ts | 12 +- 3 files changed, 240 insertions(+), 123 deletions(-) diff --git a/packages/simstudio/package.json b/packages/simstudio/package.json index b37aee4ea..62e233e8e 100644 --- a/packages/simstudio/package.json +++ b/packages/simstudio/package.json @@ -1,6 +1,6 @@ { "name": "simstudio", - "version": "0.1.3", + "version": "0.1.4", "description": "CLI tool for Sim Studio - easily start, build and test agent workflows", "license": "MIT", "author": "Sim Studio Team", diff --git a/packages/simstudio/src/commands/start.ts b/packages/simstudio/src/commands/start.ts index 1908e0756..7be43858f 100644 --- a/packages/simstudio/src/commands/start.ts +++ b/packages/simstudio/src/commands/start.ts @@ -19,9 +19,11 @@ interface StartOptions { const SIM_HOME_DIR = path.join(os.homedir(), '.sim-studio') const SIM_STANDALONE_DIR = path.join(SIM_HOME_DIR, 'standalone') const SIM_VERSION_FILE = path.join(SIM_HOME_DIR, 'version.json') -const STANDALONE_VERSION = '0.1.3' +const STANDALONE_VERSION = '0.1.4' const DOWNLOAD_URL = 'https://github.com/simstudioai/sim/releases/latest/download/sim-standalone.tar.gz' +// Add a custom user agent to avoid GitHub API rate limiting +const USER_AGENT = 'SimStudio-CLI' /** * Start command that launches Sim Studio using local storage @@ -85,7 +87,7 @@ export async function start(options: StartOptions) { stdio: 'inherit', }) } catch (error) { - spinner.fail('Failed to build Next.js app') + spinner.fail('Build failed') console.error(chalk.red('Error:'), error instanceof Error ? error.message : error) process.exit(1) } @@ -99,7 +101,7 @@ export async function start(options: StartOptions) { } } else { // Running from outside the project via npx - we'll download and start a standalone version - spinner.text = 'Setting up standalone Sim Studio...' + spinner.text = 'Setting up Sim Studio...' // Create the .sim-studio directory if it doesn't exist if (!fs.existsSync(SIM_HOME_DIR)) { @@ -124,11 +126,11 @@ export async function start(options: StartOptions) { // Download and extract if needed if (needsDownload) { try { + console.log(`\n${chalk.blue('ℹ')} Downloading Sim Studio...`) + await downloadStandaloneApp(spinner, options) } catch (error) { - spinner.fail( - `Failed to download Sim Studio: ${error instanceof Error ? error.message : String(error)}` - ) + spinner.fail('Download failed') console.log(`\n${chalk.yellow('⚠️')} If you're having network issues, you can try: 1. Check your internet connection 2. Try again later @@ -136,18 +138,18 @@ export async function start(options: StartOptions) { process.exit(1) } } else { - spinner.text = 'Using cached Sim Studio standalone version...' + spinner.text = 'Using cached version...' } // Start the standalone app - spinner.text = 'Starting Sim Studio standalone...' + spinner.text = 'Starting Sim Studio...' // Make sure the standalone directory exists if ( !fs.existsSync(SIM_STANDALONE_DIR) || !fs.existsSync(path.join(SIM_STANDALONE_DIR, 'server.js')) ) { - spinner.fail('Standalone app files are missing. Re-run to download again.') + spinner.fail('Setup incomplete') // Force a fresh download next time if (fs.existsSync(SIM_VERSION_FILE)) { fs.unlinkSync(SIM_VERSION_FILE) @@ -170,13 +172,13 @@ export async function start(options: StartOptions) { } // Successful start - spinner.succeed(`Sim Studio is running on ${chalk.cyan(`http://localhost:${port}`)}`) + spinner.succeed(`Sim Studio started on ${chalk.cyan(`http://localhost:${port}`)}`) console.log(` -${chalk.green('✓')} Using local storage mode - your data will be stored in the browser -${chalk.green('✓')} Any changes will be persisted between sessions through localStorage -${chalk.green('✓')} Authentication is disabled - you have immediate access to all features -${chalk.yellow('i')} Navigate to ${chalk.cyan(`http://localhost:${port}/w`)} to create a new workflow -${chalk.yellow('i')} Press ${chalk.bold('Ctrl+C')} to stop the server +${chalk.green('✓')} Local storage mode enabled +${chalk.green('✓')} Changes persist between sessions +${chalk.green('✓')} Authentication disabled +${chalk.blue('ℹ')} Create a workflow at ${chalk.cyan(`http://localhost:${port}/w`)} +${chalk.blue('ℹ')} Press ${chalk.bold('Ctrl+C')} to stop the server `) // Auto-open browser to workflow page @@ -188,18 +190,18 @@ ${chalk.yellow('i')} Press ${chalk.bold('Ctrl+C')} to stop the server // Wait a short time for the server to fully start setTimeout(() => { open(`http://localhost:${port}/w`) - console.log(`${chalk.green('✓')} Opened browser to workflow canvas`) + console.log(`${chalk.green('✓')} Browser opened to workflow canvas`) }, 1000) } catch (error) { console.log( - `${chalk.yellow('i')} Could not automatically open browser. Please navigate to ${chalk.cyan(`http://localhost:${port}/w`)} manually.` + `${chalk.blue('ℹ')} Please navigate to ${chalk.cyan(`http://localhost:${port}/w`)} in your browser` ) } } // Handle process termination process.on('SIGINT', () => { - console.log(`\n${chalk.yellow('⚠️')} Shutting down Sim Studio...`) + console.log(`\n${chalk.blue('ℹ')} Shutting down Sim Studio...`) simProcess.kill('SIGINT') process.exit(0) }) @@ -207,7 +209,7 @@ ${chalk.yellow('i')} Press ${chalk.bold('Ctrl+C')} to stop the server // Return the process for testing purposes return simProcess } catch (error) { - spinner.fail('Failed to start Sim Studio') + spinner.fail('Failed to start') console.error(chalk.red('Error:'), error instanceof Error ? error.message : error) process.exit(1) } @@ -261,132 +263,239 @@ async function downloadStandaloneApp(spinner: SimpleSpinner, options: StartOptio fs.mkdirSync(tmpDir, { recursive: true }) const tarballPath = path.join(tmpDir, 'sim-standalone.tar.gz') - const file = createWriteStream(tarballPath) - spinner.text = 'Downloading Sim Studio standalone package...' + // Track retry attempts + let retryCount = 0 + const maxRetries = 3 - // Function to handle the download - const downloadFile = (url: string, redirectCount = 0) => { - // Prevent infinite redirects - if (redirectCount > 5) { - spinner.fail('Too many redirects') - if (options.debug) { - console.error('Redirect chain:', url) - } - return reject(new Error('Download failed: Too many redirects')) + // Function to start a fresh download attempt + const startDownload = (url: string) => { + // Create a new file stream for each attempt + const file = createWriteStream(tarballPath) + + if (retryCount > 0) { + spinner.text = `Downloading Sim Studio... (Attempt ${retryCount + 1}/${maxRetries + 1})` + } else { + spinner.text = 'Downloading Sim Studio...' } - https - .get(url, (response) => { - // Handle redirects (302, 301, 307, etc.) - if ( - response.statusCode && - response.statusCode >= 300 && - response.statusCode < 400 && - response.headers.location - ) { - // Close the current file stream before following the redirect - file.close() + // Function to handle the download + downloadFile(url, 0, file) + } - const redirectUrl = response.headers.location.startsWith('http') - ? response.headers.location - : new URL(response.headers.location, url).toString() + // Function to handle the download + const downloadFile = (url: string, redirectCount = 0, file: fs.WriteStream) => { + // Prevent infinite redirects + if (redirectCount > 5) { + spinner.fail('Download failed') + if (options.debug) { + console.error('Redirect chain exceeded maximum depth') + } + return handleError(new Error('Too many redirects')) + } - // Only update spinner text on first redirect, then just show "Following redirects..." - if (redirectCount === 0) { - spinner.text = 'Following GitHub redirects...' + // Set a timeout for the request (15 seconds) + const request = https + .get( + url, + { + timeout: 15000, + headers: { + 'User-Agent': USER_AGENT, + }, + }, + (response) => { + // Handle redirects (302, 301, 307, etc.) + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + // Close the current file stream before following the redirect + file.close() + + const redirectUrl = response.headers.location.startsWith('http') + ? response.headers.location + : new URL(response.headers.location, url).toString() + + // Only show "Following redirects" on the first redirect + if (redirectCount === 0 && !options.debug) { + spinner.text = 'Following redirects...' + } + + // Only log redirect details in debug mode + if (options.debug) { + console.log(`Redirect ${redirectCount + 1}: ${url} -> ${redirectUrl}`) + } + + // Follow the redirect with incremented counter + downloadFile(redirectUrl, redirectCount + 1, createWriteStream(tarballPath)) + return } - if (options.debug) { - console.log(`Redirect ${redirectCount + 1}: ${url} -> ${redirectUrl}`) + if (response.statusCode !== 200) { + spinner.fail('Download failed') + if (options.debug) { + console.error(`Server returned status code: ${response.statusCode}`) + console.error('URL that failed:', url) + console.error('Response headers:', JSON.stringify(response.headers, null, 2)) + } + file.close() + return handleError(new Error(`Server returned ${response.statusCode}`)) } - // Follow the redirect with incremented counter - downloadFile(redirectUrl, redirectCount + 1) - return - } + // Get content length for progress tracking + const totalSize = parseInt(response.headers['content-length'] || '0', 10) + let downloadedSize = 0 + let lastProgressUpdate = Date.now() + let lastDataReceived = Date.now() + const startTime = Date.now() - if (response.statusCode !== 200) { - spinner.fail(`Failed to download: ${response.statusCode}`) - if (options.debug) { - console.error('URL that failed:', url) - console.error('Headers:', JSON.stringify(response.headers, null, 2)) - } - return reject(new Error(`Download failed with status code: ${response.statusCode}`)) - } - - spinner.text = 'Downloading Sim Studio standalone package...' - response.pipe(file) - - file.on('finish', () => { - file.close() - - // Clear the standalone directory if it exists - if (fs.existsSync(SIM_STANDALONE_DIR)) { - fs.rmSync(SIM_STANDALONE_DIR, { recursive: true, force: true }) + if (totalSize > 0) { + spinner.text = `Downloading Sim Studio... (${(totalSize / 1024 / 1024).toFixed(1)} MB)` + } else { + spinner.text = 'Downloading Sim Studio...' } - // Create the directory - fs.mkdirSync(SIM_STANDALONE_DIR, { recursive: true }) + // Track download progress + response.on('data', (chunk) => { + downloadedSize += chunk.length + lastDataReceived = Date.now() - spinner.text = 'Extracting Sim Studio...' + // Only update the spinner text every 500ms to avoid excessive updates + const now = Date.now() + if (now - lastProgressUpdate > 500) { + lastProgressUpdate = now + const elapsedSeconds = (now - startTime) / 1000 + const downloadSpeed = downloadedSize / elapsedSeconds / 1024 / 1024 // MB/s - // Dynamically import tar only when needed - import('tar') - .then(({ extract }) => { - // Extract the tarball - extract({ - file: tarballPath, - cwd: SIM_STANDALONE_DIR, - }) - .then(() => { - // Clean up - fs.rmSync(tmpDir, { recursive: true, force: true }) + if (totalSize > 0) { + const percent = Math.round((downloadedSize / totalSize) * 100) + spinner.text = `Downloading Sim Studio... ${percent}% (${(downloadedSize / 1024 / 1024).toFixed(1)}/${(totalSize / 1024 / 1024).toFixed(1)} MB)` + } else { + spinner.text = `Downloading Sim Studio... ${(downloadedSize / 1024 / 1024).toFixed(1)} MB downloaded` + } + } + }) - // Install dependencies if needed - if (fs.existsSync(path.join(SIM_STANDALONE_DIR, 'package.json'))) { - spinner.text = 'Installing dependencies...' + // Set up a progress check interval to detect stalled downloads + const progressInterval = setInterval(() => { + const timeSinceLastData = Date.now() - lastDataReceived + if (timeSinceLastData > 10000) { + // 10 seconds with no data + clearInterval(progressInterval) + request.destroy() + spinner.fail('Download failed') + handleError(new Error('Download stalled - no data received for 10 seconds')) + } + }, 2000) - try { - execSync('npm install --production', { - cwd: SIM_STANDALONE_DIR, - stdio: 'ignore', - }) - } catch (error) { - spinner.warn('Error installing dependencies, but trying to continue...') + response.pipe(file) + + file.on('finish', () => { + clearInterval(progressInterval) + file.close() + + // Clear the standalone directory if it exists + if (fs.existsSync(SIM_STANDALONE_DIR)) { + fs.rmSync(SIM_STANDALONE_DIR, { recursive: true, force: true }) + } + + // Create the directory + fs.mkdirSync(SIM_STANDALONE_DIR, { recursive: true }) + + spinner.text = 'Extracting files...' + + // Dynamically import tar only when needed + import('tar') + .then(({ extract }) => { + // Extract the tarball + extract({ + file: tarballPath, + cwd: SIM_STANDALONE_DIR, + }) + .then(() => { + // Clean up + fs.rmSync(tmpDir, { recursive: true, force: true }) + + // Install dependencies if needed + if (fs.existsSync(path.join(SIM_STANDALONE_DIR, 'package.json'))) { + spinner.text = 'Installing dependencies...' + + try { + execSync('npm install --production', { + cwd: SIM_STANDALONE_DIR, + stdio: 'ignore', + }) + } catch (error) { + spinner.warn('Error installing dependencies, but trying to continue...') + } } - } - // Write version file - fs.writeFileSync( - SIM_VERSION_FILE, - JSON.stringify({ - version: STANDALONE_VERSION, - date: new Date().toISOString(), - }) - ) + // Write version file + fs.writeFileSync( + SIM_VERSION_FILE, + JSON.stringify({ + version: STANDALONE_VERSION, + date: new Date().toISOString(), + }) + ) - spinner.succeed('Sim Studio downloaded successfully') - resolve() - }) - .catch((err) => { - spinner.fail('Failed to extract Sim Studio') - reject(err) - }) - }) - .catch((err) => { - spinner.fail('Failed to load tar module') - reject(err) - }) - }) - }) + spinner.succeed('Setup complete') + resolve() + }) + .catch((err) => { + spinner.fail('Extraction failed') + handleError(err) + }) + }) + .catch((err) => { + spinner.fail('Extraction failed') + handleError(err) + }) + }) + + file.on('error', (err) => { + clearInterval(progressInterval) + spinner.fail('File error') + handleError(err) + }) + } + ) .on('error', (err) => { spinner.fail('Network error') - reject(err) + handleError(err) + }) + .on('timeout', () => { + request.destroy() + spinner.fail('Download timed out') + handleError(new Error('Download timed out after 15 seconds')) }) } + // Error handler with retry logic + const handleError = (err: Error) => { + if (retryCount < maxRetries) { + retryCount++ + spinner.text = `Retrying download (${retryCount}/${maxRetries})...` + + // Use the same URL but with a delay + setTimeout(() => startDownload(DOWNLOAD_URL), 1000) + } else { + // Clean up + if (fs.existsSync(tarballPath)) { + fs.unlinkSync(tarballPath) + } + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + + reject(err) + } + } + // Start the download process - downloadFile(DOWNLOAD_URL) + startDownload(DOWNLOAD_URL) }) } diff --git a/packages/simstudio/src/index.ts b/packages/simstudio/src/index.ts index eeb4bec7b..e63e0249b 100644 --- a/packages/simstudio/src/index.ts +++ b/packages/simstudio/src/index.ts @@ -28,7 +28,10 @@ async function main() { .on('--help', () => help()) .action(() => { // Default command (no args) runs start with default options - start({ port: config.get('port'), debug: config.get('debug') }) + start({ + port: config.get('port'), + debug: config.get('debug'), + }) }) // Start command @@ -37,8 +40,13 @@ async function main() { .description('Start Sim Studio with local storage') .option('-p, --port ', 'Port to run on', config.get('port')) .option('-d, --debug', 'Enable debug mode', config.get('debug')) + .option('--no-open', 'Do not automatically open browser') .action((options) => { - start(options) + start({ + port: options.port, + debug: options.debug, + noOpen: !options.open, + }) }) // Version command