lemonade-interactive-loader 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ const inquirer = require('inquirer');
2
+ const { DEFAULTS, BACKEND_TYPES, LOG_LEVELS, RUN_MODES, HOSTS } = require('../config/constants');
3
+ const { loadConfig, saveConfig } = require('../config');
4
+ const { selectLlamaCppRelease, selectAsset, selectInstalledAssetPrompt, displayConfigSummary } = require('./prompts');
5
+ const { downloadAndExtractLlamaCpp } = require('../services/asset-manager');
6
+ const { inferBackendType } = require('../utils/system');
7
+
8
+ /**
9
+ * Run the interactive setup wizard
10
+ * @param {boolean} isEdit - If true, use saved values as defaults
11
+ * @returns {Promise<Object>} Configuration object
12
+ */
13
+ async function runSetupWizard(isEdit = false) {
14
+ console.log(isEdit ? 'šŸ‹ Edit Configuration From Saved' : 'šŸ‹ Setup Configuration From Defaults');
15
+
16
+ const existingConfig = isEdit ? loadConfig() : {};
17
+
18
+ // Q1: Local network exposure
19
+ const { exposeToNetwork } = await inquirer.prompt([
20
+ {
21
+ type: 'confirm',
22
+ name: 'exposeToNetwork',
23
+ message: 'Do you want to expose the server to the local network?',
24
+ default: existingConfig.exposeToNetwork || DEFAULTS.EXPOSE_TO_NETWORK
25
+ }
26
+ ]);
27
+
28
+ const host = exposeToNetwork ? HOSTS.ALL_INTERFACES : HOSTS.LOCALHOST;
29
+
30
+ // Q2: Port selection
31
+ const { port } = await inquirer.prompt([
32
+ {
33
+ type: 'input',
34
+ name: 'port',
35
+ message: 'What port would you like the server to run on?',
36
+ default: existingConfig.port || DEFAULTS.PORT.toString(),
37
+ validate: (input) => !isNaN(input) && parseInt(input) > 0 && parseInt(input) < 65536 || 'Invalid port number (must be 1-65535)'
38
+ }
39
+ ]);
40
+
41
+ // Q3: Logging level
42
+ const { logLevel } = await inquirer.prompt([
43
+ {
44
+ type: 'list',
45
+ name: 'logLevel',
46
+ message: 'What logging level do you want?',
47
+ choices: [
48
+ { name: 'info (default)', value: LOG_LEVELS.INFO },
49
+ { name: 'debug (verbose)', value: LOG_LEVELS.DEBUG },
50
+ { name: 'warning (warnings only)', value: LOG_LEVELS.WARNING },
51
+ { name: 'error (errors only)', value: LOG_LEVELS.ERROR }
52
+ ],
53
+ default: existingConfig.logLevel || DEFAULTS.LOG_LEVEL
54
+ }
55
+ ]);
56
+
57
+ // Q4: Custom model directory
58
+ const existingModelDir = existingConfig.modelDir;
59
+ const hasExistingModelDir = existingModelDir && existingModelDir !== 'None';
60
+
61
+ const { useCustomModelDir } = await inquirer.prompt([
62
+ {
63
+ type: 'confirm',
64
+ name: 'useCustomModelDir',
65
+ message: 'Is there another model directory to use? (example: LM Studio)',
66
+ default: false
67
+ }
68
+ ]);
69
+
70
+ let finalModelDir;
71
+
72
+ if (useCustomModelDir) {
73
+ const modelDirAnswer = await inquirer.prompt([
74
+ {
75
+ type: 'input',
76
+ name: 'modelDir',
77
+ message: 'Enter the model directory path:',
78
+ default: existingModelDir || './models'
79
+ }
80
+ ]);
81
+ finalModelDir = modelDirAnswer.modelDir;
82
+ } else {
83
+ finalModelDir = DEFAULTS.MODEL_DIR;
84
+ }
85
+
86
+ // Q5: System tray vs headless
87
+ const { runMode } = await inquirer.prompt([
88
+ {
89
+ type: 'list',
90
+ name: 'runMode',
91
+ message: 'Do you want a system tray or headless?',
92
+ choices: [
93
+ { name: 'system-tray (with system tray icon)', value: RUN_MODES.SYSTEM_TRAY },
94
+ { name: 'headless (background service)', value: RUN_MODES.HEADLESS }
95
+ ],
96
+ default: existingConfig.runMode || DEFAULTS.RUN_MODE
97
+ }
98
+ ]);
99
+
100
+ // Q6: Custom llama.cpp args
101
+ const existingLlamacppArgs = existingConfig.llamacppArgs || '';
102
+ const hasExistingArgs = existingLlamacppArgs.length > 0;
103
+
104
+ let finalLlamacppArgs;
105
+
106
+ const { useCustomArgs } = await inquirer.prompt([
107
+ {
108
+ type: 'confirm',
109
+ name: 'useCustomArgs',
110
+ message: 'Are there any llama.cpp args you need to set?',
111
+ default: hasExistingArgs
112
+ }
113
+ ]);
114
+
115
+ if (useCustomArgs) {
116
+ const argsAnswer = await inquirer.prompt([
117
+ {
118
+ type: 'input',
119
+ name: 'llamacppArgs',
120
+ message: 'Enter llama.cpp arguments (comma-separated, e.g., --ctx-size 4096,--batch-size 512):',
121
+ default: existingLlamacppArgs
122
+ }
123
+ ]);
124
+ finalLlamacppArgs = argsAnswer.llamacppArgs;
125
+ } else {
126
+ finalLlamacppArgs = '';
127
+ }
128
+
129
+ // Q7: Custom llama.cpp build
130
+ const existingCustomPath = existingConfig.customLlamacppPath || '';
131
+ const hasExistingBuild = existingCustomPath.length > 0;
132
+
133
+ let customLlamacppPath;
134
+ let customBackendType = existingConfig.customBackendType || '';
135
+ let customServerPath = existingConfig.customServerPath || '';
136
+ let backend = existingConfig.backend || DEFAULTS.BACKEND;
137
+
138
+ const { useCustomBuild } = await inquirer.prompt([
139
+ {
140
+ type: 'confirm',
141
+ name: 'useCustomBuild',
142
+ message: 'Do you want to use a different build for llama cpp?',
143
+ default: hasExistingBuild
144
+ }
145
+ ]);
146
+
147
+ if (useCustomBuild) {
148
+ const { useInstalled } = await inquirer.prompt([
149
+ {
150
+ type: 'confirm',
151
+ name: 'useInstalled',
152
+ message: 'Use an already installed custom build?',
153
+ default: hasExistingBuild
154
+ }
155
+ ]);
156
+
157
+ if (useInstalled) {
158
+ const installedAsset = await selectInstalledAssetPrompt();
159
+
160
+ if (installedAsset) {
161
+ customLlamacppPath = installedAsset.installPath;
162
+ customBackendType = installedAsset.backendType;
163
+ customServerPath = installedAsset.serverPath;
164
+ backend = customBackendType;
165
+ } else {
166
+ customLlamacppPath = '';
167
+ customBackendType = '';
168
+ customServerPath = '';
169
+ }
170
+ } else {
171
+ console.log('\nFetching recent llama.cpp builds...');
172
+ const release = await selectLlamaCppRelease();
173
+ const asset = await selectAsset(release);
174
+
175
+ const version = release.tag_name;
176
+ customLlamacppPath = await downloadAndExtractLlamaCpp(asset, version);
177
+
178
+ customBackendType = inferBackendType(asset.name);
179
+ customServerPath = customLlamacppPath;
180
+
181
+ console.log(`\nāœ“ Custom llama.cpp build installed at: ${customLlamacppPath}`);
182
+ console.log(` Backend Type: ${customBackendType.toUpperCase()}`);
183
+ }
184
+ } else {
185
+ const { selectedBackend } = await inquirer.prompt([
186
+ {
187
+ type: 'list',
188
+ name: 'selectedBackend',
189
+ message: 'Which llama.cpp backend to use?',
190
+ choices: [
191
+ { name: 'auto (automatically select best backend)', value: BACKEND_TYPES.AUTO },
192
+ { name: 'vulkan (GPU acceleration)', value: BACKEND_TYPES.VULKAN },
193
+ { name: 'rocm (AMD GPU acceleration)', value: BACKEND_TYPES.ROCM },
194
+ { name: 'cpu (CPU only)', value: BACKEND_TYPES.CPU }
195
+ ],
196
+ default: existingConfig.backend || DEFAULTS.BACKEND
197
+ }
198
+ ]);
199
+
200
+ backend = selectedBackend;
201
+ customLlamacppPath = '';
202
+ customBackendType = '';
203
+ customServerPath = '';
204
+ }
205
+
206
+ // Save configuration
207
+ const config = {
208
+ exposeToNetwork,
209
+ host,
210
+ port: parseInt(port),
211
+ logLevel,
212
+ backend,
213
+ modelDir: finalModelDir,
214
+ runMode,
215
+ llamacppArgs: finalLlamacppArgs,
216
+ customLlamacppPath,
217
+ customBackendType,
218
+ customServerPath,
219
+ createdAt: new Date().toISOString(),
220
+ updatedAt: new Date().toISOString()
221
+ };
222
+
223
+ const { saveConfiguration } = await inquirer.prompt([
224
+ {
225
+ type: 'confirm',
226
+ name: 'saveConfiguration',
227
+ message: 'Do you want to save this configuration for future use?',
228
+ default: true
229
+ }
230
+ ]);
231
+
232
+ if (saveConfiguration) {
233
+ saveConfig(config);
234
+ }
235
+
236
+ displayConfigSummary(config);
237
+
238
+ return config;
239
+ }
240
+
241
+ module.exports = {
242
+ runSetupWizard
243
+ };
@@ -0,0 +1,83 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+
4
+ // Configuration directories
5
+ const USER_CONFIG_DIR = path.join(os.homedir(), '.lemonade-launcher');
6
+ const USER_CONFIG_FILE = path.join(USER_CONFIG_DIR, 'config.json');
7
+ const DEFAULT_LLAMACPP_INSTALL_DIR = path.join(os.homedir(), '.lemonade-launcher', 'llama-cpp');
8
+
9
+ // GitHub API
10
+ const GITHUB_RELEASES_URL = 'https://api.github.com/repos/ggml-org/llama.cpp/releases';
11
+ const GITHUB_API_HEADERS = {
12
+ 'User-Agent': 'lemonade-launcher',
13
+ 'Accept': 'application/vnd.github.v3+json'
14
+ };
15
+
16
+ // Default lemonade-server paths by platform
17
+ function getLemonadeServerDefaultPath() {
18
+ if (process.platform === 'win32') {
19
+ return path.join(os.homedir(), 'AppData', 'Local', 'lemonade_server', 'bin', 'lemonade-server.exe');
20
+ } else if (process.platform === 'darwin') {
21
+ return '/opt/homebrew/bin/lemonade-server';
22
+ } else {
23
+ return '/usr/local/bin/lemonade-server';
24
+ }
25
+ }
26
+
27
+ const LEMONADE_SERVER_DEFAULT_PATH = getLemonadeServerDefaultPath();
28
+
29
+ // Supported backends
30
+ const BACKEND_TYPES = {
31
+ AUTO: 'auto',
32
+ CPU: 'cpu',
33
+ CUDA: 'cuda',
34
+ ROCM: 'rocm',
35
+ VULKAN: 'vulkan',
36
+ SYCL: 'sycl',
37
+ OPENCL: 'opencl'
38
+ };
39
+
40
+ // Logging levels
41
+ const LOG_LEVELS = {
42
+ DEBUG: 'debug',
43
+ INFO: 'info',
44
+ WARNING: 'warning',
45
+ ERROR: 'error'
46
+ };
47
+
48
+ // Run modes
49
+ const RUN_MODES = {
50
+ HEADLESS: 'headless',
51
+ SYSTEM_TRAY: 'system-tray'
52
+ };
53
+
54
+ // Network configuration
55
+ const HOSTS = {
56
+ LOCALHOST: '127.0.0.1',
57
+ ALL_INTERFACES: '0.0.0.0'
58
+ };
59
+
60
+ // Default values
61
+ const DEFAULTS = {
62
+ PORT: 8080,
63
+ LOG_LEVEL: LOG_LEVELS.INFO,
64
+ HOST: HOSTS.LOCALHOST,
65
+ EXPOSE_TO_NETWORK: false,
66
+ RUN_MODE: RUN_MODES.SYSTEM_TRAY,
67
+ MODEL_DIR: 'None',
68
+ BACKEND: BACKEND_TYPES.AUTO
69
+ };
70
+
71
+ module.exports = {
72
+ USER_CONFIG_DIR,
73
+ USER_CONFIG_FILE,
74
+ DEFAULT_LLAMACPP_INSTALL_DIR,
75
+ GITHUB_RELEASES_URL,
76
+ GITHUB_API_HEADERS,
77
+ LEMONADE_SERVER_DEFAULT_PATH,
78
+ BACKEND_TYPES,
79
+ LOG_LEVELS,
80
+ RUN_MODES,
81
+ HOSTS,
82
+ DEFAULTS
83
+ };
@@ -0,0 +1,49 @@
1
+ const fs = require('fs');
2
+ const { USER_CONFIG_DIR, USER_CONFIG_FILE } = require('./constants');
3
+
4
+ /**
5
+ * Load user configuration from file
6
+ * @returns {Object} Configuration object or empty object if not found
7
+ */
8
+ function loadConfig() {
9
+ if (!fs.existsSync(USER_CONFIG_FILE)) {
10
+ return {};
11
+ }
12
+
13
+ try {
14
+ const configData = fs.readFileSync(USER_CONFIG_FILE, 'utf-8');
15
+ return JSON.parse(configData);
16
+ } catch (error) {
17
+ console.warn(`Warning: Could not load config file: ${error.message}`);
18
+ return {};
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Save user configuration to file
24
+ * @param {Object} config - Configuration object to save
25
+ */
26
+ function saveConfig(config) {
27
+ if (!fs.existsSync(USER_CONFIG_DIR)) {
28
+ fs.mkdirSync(USER_CONFIG_DIR, { recursive: true });
29
+ }
30
+
31
+ fs.writeFileSync(USER_CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
32
+ console.log(`\nāœ“ Configuration saved to ${USER_CONFIG_FILE}`);
33
+ }
34
+
35
+ /**
36
+ * Reset configuration to defaults
37
+ */
38
+ function resetConfig() {
39
+ if (fs.existsSync(USER_CONFIG_FILE)) {
40
+ fs.unlinkSync(USER_CONFIG_FILE);
41
+ console.log('Configuration reset successfully.');
42
+ }
43
+ }
44
+
45
+ module.exports = {
46
+ loadConfig,
47
+ saveConfig,
48
+ resetConfig
49
+ };
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Lemonade Launcher - Interactive CLI for managing llama.cpp builds and Lemonade Server
5
+ *
6
+ * A professional, modular CLI tool for downloading llama.cpp releases and launching
7
+ * Lemonade Server with a user-friendly setup wizard.
8
+ */
9
+
10
+ const { runCLI } = require('./cli/menu');
11
+
12
+ /**
13
+ * Main entry point
14
+ */
15
+ async function main() {
16
+ try {
17
+ await runCLI();
18
+ } catch (error) {
19
+ console.error('Error:', error.message);
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ // Export modules for programmatic use
25
+ module.exports = {
26
+ // Config
27
+ loadConfig: require('./config').loadConfig,
28
+ saveConfig: require('./config').saveConfig,
29
+ resetConfig: require('./config').resetConfig,
30
+
31
+ // Services
32
+ fetchAllReleases: require('./services/github').fetchAllReleases,
33
+ fetchLatestRelease: require('./services/github').fetchLatestRelease,
34
+ downloadFile: require('./services/download').downloadFile,
35
+ extractArchive: require('./services/download').extractArchive,
36
+ getAllInstalledAssets: require('./services/asset-manager').getAllInstalledAssets,
37
+ downloadAndExtractLlamaCpp: require('./services/asset-manager').downloadAndExtractLlamaCpp,
38
+ deleteInstalledAsset: require('./services/asset-manager').deleteInstalledAsset,
39
+ launchLemonadeServer: require('./services/server').launchLemonadeServer,
40
+
41
+ // Utils
42
+ detectSystem: require('./utils/system').detectSystem,
43
+ formatBytes: require('./utils/system').formatBytes,
44
+ inferBackendType: require('./utils/system').inferBackendType,
45
+ categorizeAsset: require('./utils/system').categorizeAsset,
46
+ getAssetType: require('./utils/system').getAssetType,
47
+ filterServerAssets: require('./utils/system').filterServerAssets,
48
+ findLlamaServer: require('./utils/system').findLlamaServer,
49
+
50
+ // CLI
51
+ runSetupWizard: require('./cli/setup-wizard').runSetupWizard,
52
+ runCLI: require('./cli/menu').runCLI
53
+ };
54
+
55
+ // Run if executed directly
56
+ if (require.main === module) {
57
+ main();
58
+ }
@@ -0,0 +1,159 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { DEFAULT_LLAMACPP_INSTALL_DIR } = require('../config/constants');
4
+ const { inferBackendType, findLlamaServer } = require('../utils/system');
5
+ const { downloadFile, extractArchive } = require('./download');
6
+
7
+ /**
8
+ * Check if a specific asset is already installed
9
+ * @param {string} version - Release version
10
+ * @param {string} assetName - Asset filename
11
+ * @returns {boolean}
12
+ */
13
+ function isAssetInstalled(version, assetName) {
14
+ const installDir = path.join(
15
+ DEFAULT_LLAMACPP_INSTALL_DIR,
16
+ version,
17
+ assetName.replace('.tar.gz', '').replace('.zip', '')
18
+ );
19
+ const markerFile = path.join(installDir, `.installed-${assetName}`);
20
+ return fs.existsSync(markerFile);
21
+ }
22
+
23
+ /**
24
+ * Mark an asset as installed
25
+ * @param {string} version - Release version
26
+ * @param {string} assetName - Asset filename
27
+ */
28
+ function markAssetAsInstalled(version, assetName) {
29
+ const archiveBaseName = assetName.endsWith('.tar.gz')
30
+ ? assetName.replace('.tar.gz', '')
31
+ : assetName.replace('.zip', '');
32
+
33
+ const installDir = path.join(DEFAULT_LLAMACPP_INSTALL_DIR, version, archiveBaseName);
34
+ const markerFile = path.join(installDir, `.installed-${assetName}`);
35
+ fs.writeFileSync(markerFile, new Date().toISOString(), 'utf-8');
36
+ }
37
+
38
+ /**
39
+ * Get all installed assets sorted by install time (newest first)
40
+ * @returns {Array} Array of installed asset info
41
+ */
42
+ function getAllInstalledAssets() {
43
+ const installedAssets = [];
44
+
45
+ if (!fs.existsSync(DEFAULT_LLAMACPP_INSTALL_DIR)) {
46
+ return installedAssets;
47
+ }
48
+
49
+ const versions = fs.readdirSync(DEFAULT_LLAMACPP_INSTALL_DIR);
50
+
51
+ for (const version of versions) {
52
+ const versionPath = path.join(DEFAULT_LLAMACPP_INSTALL_DIR, version);
53
+
54
+ if (!fs.statSync(versionPath).isDirectory()) continue;
55
+
56
+ const assetDirs = fs.readdirSync(versionPath);
57
+
58
+ for (const assetDir of assetDirs) {
59
+ const assetPath = path.join(versionPath, assetDir);
60
+
61
+ if (!fs.statSync(assetPath).isDirectory()) continue;
62
+
63
+ const entries = fs.readdirSync(assetPath);
64
+ const markerFiles = entries.filter(e => e.startsWith('.installed-'));
65
+
66
+ for (const markerFile of markerFiles) {
67
+ const assetName = markerFile.replace('.installed-', '');
68
+ const installTime = fs.readFileSync(path.join(assetPath, markerFile), 'utf-8');
69
+ const backendType = inferBackendType(assetName);
70
+
71
+ installedAssets.push({
72
+ version,
73
+ assetName,
74
+ installPath: assetPath,
75
+ installTime,
76
+ backendType
77
+ });
78
+ }
79
+ }
80
+ }
81
+
82
+ installedAssets.sort((a, b) => new Date(b.installTime) - new Date(a.installTime));
83
+
84
+ return installedAssets;
85
+ }
86
+
87
+ /**
88
+ * Get the llama-server binary path for an installed asset
89
+ * @param {string} installPath - Installation directory path
90
+ * @returns {string|null} Path to llama-server
91
+ */
92
+ function getLlamaServerPath(installPath) {
93
+ return findLlamaServer(installPath);
94
+ }
95
+
96
+ /**
97
+ * Download and extract llama.cpp build to user directory
98
+ * @param {Object} asset - Asset object from GitHub API
99
+ * @param {string} version - Release version
100
+ * @returns {Promise<string>} Installation directory path
101
+ */
102
+ async function downloadAndExtractLlamaCpp(asset, version) {
103
+ const archiveName = asset.name;
104
+ const archiveBaseName = archiveName.endsWith('.tar.gz')
105
+ ? archiveName.replace('.tar.gz', '')
106
+ : archiveName.replace('.zip', '');
107
+
108
+ const installDir = path.join(DEFAULT_LLAMACPP_INSTALL_DIR, version, archiveBaseName);
109
+
110
+ if (isAssetInstalled(version, asset.name)) {
111
+ console.log(`\nāœ“ ${asset.name} is already installed at ${installDir}`);
112
+ return installDir;
113
+ }
114
+
115
+ if (!fs.existsSync(installDir)) {
116
+ fs.mkdirSync(installDir, { recursive: true });
117
+ }
118
+
119
+ const archivePath = path.join(installDir, archiveName);
120
+
121
+ console.log(`\nDownloading ${archiveName}...`);
122
+ await downloadFile(asset.browser_download_url, archivePath);
123
+
124
+ console.log(`Extracting to ${installDir}...`);
125
+ await extractArchive(archivePath, installDir);
126
+
127
+ if (fs.existsSync(archivePath)) {
128
+ fs.unlinkSync(archivePath);
129
+ }
130
+
131
+ markAssetAsInstalled(version, asset.name);
132
+
133
+ console.log(`āœ“ ${asset.name} installed to ${installDir}`);
134
+ return installDir;
135
+ }
136
+
137
+ /**
138
+ * Delete an installed asset
139
+ * @param {string} installPath - Installation directory path
140
+ * @returns {boolean} Success status
141
+ */
142
+ function deleteInstalledAsset(installPath) {
143
+ try {
144
+ fs.rmSync(installPath, { recursive: true, force: true });
145
+ return true;
146
+ } catch (error) {
147
+ console.error(`Error deleting asset: ${error.message}`);
148
+ return false;
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ isAssetInstalled,
154
+ markAssetAsInstalled,
155
+ getAllInstalledAssets,
156
+ getLlamaServerPath,
157
+ downloadAndExtractLlamaCpp,
158
+ deleteInstalledAsset
159
+ };
@@ -0,0 +1,102 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const tar = require('tar');
6
+ const AdmZip = require('adm-zip');
7
+ const { getAssetType } = require('../utils/system');
8
+
9
+ /**
10
+ * Download a file from URL
11
+ * @param {string} url - Download URL
12
+ * @param {string} outputPath - Local output path
13
+ * @returns {Promise<void>}
14
+ */
15
+ function downloadFile(url, outputPath) {
16
+ return new Promise((resolve, reject) => {
17
+ const protocol = url.startsWith('https') ? https : http;
18
+
19
+ console.log(`Downloading: ${path.basename(outputPath)}`);
20
+
21
+ const file = fs.createWriteStream(outputPath);
22
+
23
+ const req = protocol.get(url, { headers: { 'User-Agent': 'lemonade-launcher' } }, (res) => {
24
+ if (res.statusCode === 302 || res.statusCode === 301) {
25
+ downloadFile(res.headers.location, outputPath)
26
+ .then(resolve)
27
+ .catch(reject);
28
+ return;
29
+ }
30
+
31
+ if (res.statusCode !== 200) {
32
+ reject(new Error(`Download failed with status ${res.statusCode}`));
33
+ return;
34
+ }
35
+
36
+ const totalSize = parseInt(res.headers['content-length'], 10) || 0;
37
+ let downloadedSize = 0;
38
+
39
+ res.on('data', (chunk) => {
40
+ downloadedSize += chunk.length;
41
+ if (totalSize > 0) {
42
+ const percent = ((downloadedSize / totalSize) * 100).toFixed(1);
43
+ process.stdout.write(`\rProgress: ${percent}%`);
44
+ }
45
+ });
46
+
47
+ res.pipe(file);
48
+
49
+ file.on('finish', () => {
50
+ file.close();
51
+ console.log('\rDownload complete! ');
52
+ resolve();
53
+ });
54
+ });
55
+
56
+ req.on('error', (e) => {
57
+ fs.unlink(outputPath, () => {});
58
+ reject(new Error(`Download error: ${e.message}`));
59
+ });
60
+
61
+ req.setTimeout(300000, () => {
62
+ req.destroy();
63
+ fs.unlink(outputPath, () => {});
64
+ reject(new Error('Download timed out'));
65
+ });
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Extract downloaded archive
71
+ * @param {string} archivePath - Path to archive file
72
+ * @param {string} extractDir - Directory to extract to
73
+ * @returns {Promise<void>}
74
+ */
75
+ async function extractArchive(archivePath, extractDir) {
76
+ return new Promise((resolve, reject) => {
77
+ const assetType = getAssetType(archivePath);
78
+
79
+ console.log('Extracting archive...');
80
+
81
+ if (assetType === 'tar') {
82
+ tar.x({
83
+ file: archivePath,
84
+ cwd: extractDir,
85
+ strip: 1
86
+ }).then(() => {
87
+ resolve();
88
+ }).catch(reject);
89
+ } else if (assetType === 'zip') {
90
+ const zip = new AdmZip(archivePath);
91
+ zip.extractAllTo(extractDir, true);
92
+ resolve();
93
+ } else {
94
+ reject(new Error(`Unsupported archive type: ${assetType}`));
95
+ }
96
+ });
97
+ }
98
+
99
+ module.exports = {
100
+ downloadFile,
101
+ extractArchive
102
+ };