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.
- package/README.md +288 -0
- package/index.js +16 -0
- package/package.json +40 -0
- package/src/README.md +88 -0
- package/src/cli/menu.js +359 -0
- package/src/cli/prompts.js +247 -0
- package/src/cli/setup-wizard.js +243 -0
- package/src/config/constants.js +83 -0
- package/src/config/index.js +49 -0
- package/src/index.js +58 -0
- package/src/services/asset-manager.js +159 -0
- package/src/services/download.js +102 -0
- package/src/services/github.js +72 -0
- package/src/services/server.js +174 -0
- package/src/utils/system.js +150 -0
|
@@ -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
|
+
};
|