@appkit/llamacpp-cli 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.
Files changed (136) hide show
  1. package/.versionrc.json +16 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +474 -0
  4. package/bin/llamacpp +26 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +196 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/commands/delete.d.ts +2 -0
  10. package/dist/commands/delete.d.ts.map +1 -0
  11. package/dist/commands/delete.js +104 -0
  12. package/dist/commands/delete.js.map +1 -0
  13. package/dist/commands/list.d.ts +2 -0
  14. package/dist/commands/list.d.ts.map +1 -0
  15. package/dist/commands/list.js +37 -0
  16. package/dist/commands/list.js.map +1 -0
  17. package/dist/commands/logs.d.ts +8 -0
  18. package/dist/commands/logs.d.ts.map +1 -0
  19. package/dist/commands/logs.js +57 -0
  20. package/dist/commands/logs.js.map +1 -0
  21. package/dist/commands/ps.d.ts +2 -0
  22. package/dist/commands/ps.d.ts.map +1 -0
  23. package/dist/commands/ps.js +72 -0
  24. package/dist/commands/ps.js.map +1 -0
  25. package/dist/commands/pull.d.ts +6 -0
  26. package/dist/commands/pull.d.ts.map +1 -0
  27. package/dist/commands/pull.js +36 -0
  28. package/dist/commands/pull.js.map +1 -0
  29. package/dist/commands/rm.d.ts +2 -0
  30. package/dist/commands/rm.d.ts.map +1 -0
  31. package/dist/commands/rm.js +134 -0
  32. package/dist/commands/rm.js.map +1 -0
  33. package/dist/commands/run.d.ts +2 -0
  34. package/dist/commands/run.d.ts.map +1 -0
  35. package/dist/commands/run.js +198 -0
  36. package/dist/commands/run.js.map +1 -0
  37. package/dist/commands/search.d.ts +7 -0
  38. package/dist/commands/search.d.ts.map +1 -0
  39. package/dist/commands/search.js +93 -0
  40. package/dist/commands/search.js.map +1 -0
  41. package/dist/commands/show.d.ts +6 -0
  42. package/dist/commands/show.d.ts.map +1 -0
  43. package/dist/commands/show.js +196 -0
  44. package/dist/commands/show.js.map +1 -0
  45. package/dist/commands/start.d.ts +9 -0
  46. package/dist/commands/start.d.ts.map +1 -0
  47. package/dist/commands/start.js +150 -0
  48. package/dist/commands/start.js.map +1 -0
  49. package/dist/commands/stop.d.ts +2 -0
  50. package/dist/commands/stop.d.ts.map +1 -0
  51. package/dist/commands/stop.js +39 -0
  52. package/dist/commands/stop.js.map +1 -0
  53. package/dist/lib/config-generator.d.ts +30 -0
  54. package/dist/lib/config-generator.d.ts.map +1 -0
  55. package/dist/lib/config-generator.js +125 -0
  56. package/dist/lib/config-generator.js.map +1 -0
  57. package/dist/lib/launchctl-manager.d.ts +55 -0
  58. package/dist/lib/launchctl-manager.d.ts.map +1 -0
  59. package/dist/lib/launchctl-manager.js +227 -0
  60. package/dist/lib/launchctl-manager.js.map +1 -0
  61. package/dist/lib/model-downloader.d.ts +44 -0
  62. package/dist/lib/model-downloader.d.ts.map +1 -0
  63. package/dist/lib/model-downloader.js +248 -0
  64. package/dist/lib/model-downloader.js.map +1 -0
  65. package/dist/lib/model-scanner.d.ts +31 -0
  66. package/dist/lib/model-scanner.d.ts.map +1 -0
  67. package/dist/lib/model-scanner.js +145 -0
  68. package/dist/lib/model-scanner.js.map +1 -0
  69. package/dist/lib/model-search.d.ts +29 -0
  70. package/dist/lib/model-search.d.ts.map +1 -0
  71. package/dist/lib/model-search.js +131 -0
  72. package/dist/lib/model-search.js.map +1 -0
  73. package/dist/lib/port-manager.d.ts +26 -0
  74. package/dist/lib/port-manager.d.ts.map +1 -0
  75. package/dist/lib/port-manager.js +75 -0
  76. package/dist/lib/port-manager.js.map +1 -0
  77. package/dist/lib/state-manager.d.ts +59 -0
  78. package/dist/lib/state-manager.d.ts.map +1 -0
  79. package/dist/lib/state-manager.js +178 -0
  80. package/dist/lib/state-manager.js.map +1 -0
  81. package/dist/lib/status-checker.d.ts +28 -0
  82. package/dist/lib/status-checker.d.ts.map +1 -0
  83. package/dist/lib/status-checker.js +99 -0
  84. package/dist/lib/status-checker.js.map +1 -0
  85. package/dist/types/global-config.d.ts +16 -0
  86. package/dist/types/global-config.d.ts.map +1 -0
  87. package/dist/types/global-config.js +18 -0
  88. package/dist/types/global-config.js.map +1 -0
  89. package/dist/types/model-info.d.ts +9 -0
  90. package/dist/types/model-info.d.ts.map +1 -0
  91. package/dist/types/model-info.js +3 -0
  92. package/dist/types/model-info.js.map +1 -0
  93. package/dist/types/server-config.d.ts +27 -0
  94. package/dist/types/server-config.d.ts.map +1 -0
  95. package/dist/types/server-config.js +15 -0
  96. package/dist/types/server-config.js.map +1 -0
  97. package/dist/utils/file-utils.d.ts +49 -0
  98. package/dist/utils/file-utils.d.ts.map +1 -0
  99. package/dist/utils/file-utils.js +144 -0
  100. package/dist/utils/file-utils.js.map +1 -0
  101. package/dist/utils/format-utils.d.ts +29 -0
  102. package/dist/utils/format-utils.d.ts.map +1 -0
  103. package/dist/utils/format-utils.js +82 -0
  104. package/dist/utils/format-utils.js.map +1 -0
  105. package/dist/utils/process-utils.d.ts +27 -0
  106. package/dist/utils/process-utils.d.ts.map +1 -0
  107. package/dist/utils/process-utils.js +66 -0
  108. package/dist/utils/process-utils.js.map +1 -0
  109. package/package.json +56 -0
  110. package/src/cli.ts +195 -0
  111. package/src/commands/delete.ts +74 -0
  112. package/src/commands/list.ts +37 -0
  113. package/src/commands/logs.ts +61 -0
  114. package/src/commands/ps.ts +79 -0
  115. package/src/commands/pull.ts +40 -0
  116. package/src/commands/rm.ts +114 -0
  117. package/src/commands/run.ts +209 -0
  118. package/src/commands/search.ts +107 -0
  119. package/src/commands/show.ts +207 -0
  120. package/src/commands/start.ts +140 -0
  121. package/src/commands/stop.ts +39 -0
  122. package/src/lib/config-generator.ts +119 -0
  123. package/src/lib/launchctl-manager.ts +209 -0
  124. package/src/lib/model-downloader.ts +259 -0
  125. package/src/lib/model-scanner.ts +125 -0
  126. package/src/lib/model-search.ts +114 -0
  127. package/src/lib/port-manager.ts +80 -0
  128. package/src/lib/state-manager.ts +177 -0
  129. package/src/lib/status-checker.ts +113 -0
  130. package/src/types/global-config.ts +26 -0
  131. package/src/types/model-info.ts +8 -0
  132. package/src/types/server-config.ts +42 -0
  133. package/src/utils/file-utils.ts +106 -0
  134. package/src/utils/format-utils.ts +80 -0
  135. package/src/utils/process-utils.ts +60 -0
  136. package/tsconfig.json +20 -0
@@ -0,0 +1,259 @@
1
+ import * as https from 'https';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import chalk from 'chalk';
5
+ import { getModelsDir } from '../utils/file-utils';
6
+ import { formatBytes } from '../utils/format-utils';
7
+
8
+ export interface DownloadProgress {
9
+ filename: string;
10
+ downloaded: number;
11
+ total: number;
12
+ percentage: number;
13
+ speed: string;
14
+ }
15
+
16
+ export class ModelDownloader {
17
+ private modelsDir: string;
18
+
19
+ constructor(modelsDir?: string) {
20
+ this.modelsDir = modelsDir || getModelsDir();
21
+ }
22
+
23
+ /**
24
+ * Parse Hugging Face identifier
25
+ * Examples:
26
+ * "bartowski/Llama-3.2-3B-Instruct-GGUF" → { repo: "...", file: undefined }
27
+ * "bartowski/Llama-3.2-3B-Instruct-GGUF/file.gguf" → { repo: "...", file: "file.gguf" }
28
+ */
29
+ parseHFIdentifier(identifier: string): { repo: string; file?: string } {
30
+ const parts = identifier.split('/');
31
+ if (parts.length === 2) {
32
+ return { repo: identifier };
33
+ } else if (parts.length === 3) {
34
+ return {
35
+ repo: `${parts[0]}/${parts[1]}`,
36
+ file: parts[2],
37
+ };
38
+ } else {
39
+ throw new Error(`Invalid Hugging Face identifier: ${identifier}`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Build Hugging Face download URL
45
+ */
46
+ buildDownloadUrl(repoId: string, filename: string, branch = 'main'): string {
47
+ return `https://huggingface.co/${repoId}/resolve/${branch}/${filename}`;
48
+ }
49
+
50
+ /**
51
+ * Download a file via HTTPS with progress tracking
52
+ */
53
+ private downloadFile(
54
+ url: string,
55
+ destPath: string,
56
+ onProgress?: (downloaded: number, total: number) => void
57
+ ): Promise<void> {
58
+ return new Promise((resolve, reject) => {
59
+ const file = fs.createWriteStream(destPath);
60
+ let downloadedBytes = 0;
61
+ let totalBytes = 0;
62
+ let lastUpdateTime = Date.now();
63
+ let lastDownloadedBytes = 0;
64
+ let completed = false;
65
+
66
+ const cleanup = (sigintHandler?: () => void) => {
67
+ if (sigintHandler) {
68
+ process.removeListener('SIGINT', sigintHandler);
69
+ }
70
+ };
71
+
72
+ const handleError = (err: Error, sigintHandler?: () => void) => {
73
+ if (completed) return;
74
+ completed = true;
75
+ cleanup(sigintHandler);
76
+ file.close(() => {
77
+ fs.unlink(destPath, () => {});
78
+ });
79
+ reject(err);
80
+ };
81
+
82
+ const sigintHandler = () => {
83
+ request.destroy();
84
+ handleError(new Error('Download interrupted by user'), sigintHandler);
85
+ };
86
+
87
+ const request = https.get(url, { agent: new https.Agent({ keepAlive: false }) }, (response) => {
88
+ // Handle redirects (301, 302, 307, 308)
89
+ if (response.statusCode === 301 || response.statusCode === 302 ||
90
+ response.statusCode === 307 || response.statusCode === 308) {
91
+ const redirectUrl = response.headers.location;
92
+ if (redirectUrl) {
93
+ cleanup(sigintHandler);
94
+ // Wait for file to close before starting new download
95
+ file.close(() => {
96
+ fs.unlink(destPath, () => {
97
+ // Start recursive download only after cleanup is complete
98
+ this.downloadFile(redirectUrl, destPath, onProgress)
99
+ .then(resolve)
100
+ .catch(reject);
101
+ });
102
+ });
103
+ return;
104
+ }
105
+ }
106
+
107
+ if (response.statusCode !== 200) {
108
+ return handleError(
109
+ new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`),
110
+ sigintHandler
111
+ );
112
+ }
113
+
114
+ totalBytes = parseInt(response.headers['content-length'] || '0', 10);
115
+
116
+ response.on('data', (chunk: Buffer) => {
117
+ downloadedBytes += chunk.length;
118
+
119
+ // Update progress every 500ms
120
+ const now = Date.now();
121
+ if (onProgress && now - lastUpdateTime >= 500) {
122
+ onProgress(downloadedBytes, totalBytes);
123
+ lastUpdateTime = now;
124
+ lastDownloadedBytes = downloadedBytes;
125
+ }
126
+ });
127
+
128
+ response.pipe(file);
129
+
130
+ file.on('finish', () => {
131
+ if (completed) return;
132
+ completed = true;
133
+
134
+ // Final progress update
135
+ if (onProgress) {
136
+ onProgress(downloadedBytes, totalBytes);
137
+ }
138
+
139
+ // Use callback to ensure close completes before resolving
140
+ file.close((err) => {
141
+ cleanup(sigintHandler);
142
+ if (err) reject(err);
143
+ else resolve();
144
+ });
145
+ });
146
+ });
147
+
148
+ request.on('error', (err) => {
149
+ handleError(err, sigintHandler);
150
+ });
151
+
152
+ file.on('error', (err) => {
153
+ handleError(err, sigintHandler);
154
+ });
155
+
156
+ // Handle Ctrl+C gracefully
157
+ process.on('SIGINT', sigintHandler);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Display progress bar
163
+ */
164
+ private displayProgress(downloaded: number, total: number, filename: string): void {
165
+ const percentage = total > 0 ? (downloaded / total) * 100 : 0;
166
+ const barLength = 40;
167
+ const filledLength = Math.round((barLength * downloaded) / total);
168
+ const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
169
+
170
+ const downloadedFormatted = formatBytes(downloaded);
171
+ const totalFormatted = formatBytes(total);
172
+ const percentFormatted = percentage.toFixed(1);
173
+
174
+ // Clear line and print progress
175
+ process.stdout.write('\r\x1b[K');
176
+ process.stdout.write(
177
+ chalk.blue(`[${bar}] ${percentFormatted}% | ${downloadedFormatted} / ${totalFormatted}`)
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Download a model from Hugging Face
183
+ */
184
+ async downloadModel(
185
+ repoId: string,
186
+ filename: string,
187
+ onProgress?: (progress: DownloadProgress) => void
188
+ ): Promise<string> {
189
+ console.log(chalk.blue(`📥 Downloading ${filename} from Hugging Face...`));
190
+ console.log(chalk.dim(`Repository: ${repoId}`));
191
+ console.log(chalk.dim(`Destination: ${this.modelsDir}`));
192
+ console.log();
193
+
194
+ // Build download URL
195
+ const url = this.buildDownloadUrl(repoId, filename);
196
+ const destPath = path.join(this.modelsDir, filename);
197
+
198
+ // Check if file already exists
199
+ if (fs.existsSync(destPath)) {
200
+ console.log(chalk.yellow(`⚠️ File already exists: ${filename}`));
201
+ console.log(chalk.dim(' Remove it first or choose a different filename'));
202
+ throw new Error('File already exists');
203
+ }
204
+
205
+ // Download with progress
206
+ const startTime = Date.now();
207
+ let lastDownloaded = 0;
208
+ let lastTime = startTime;
209
+
210
+ await this.downloadFile(url, destPath, (downloaded, total) => {
211
+ // Calculate speed
212
+ const now = Date.now();
213
+ const timeDiff = (now - lastTime) / 1000; // seconds
214
+ const bytesDiff = downloaded - lastDownloaded;
215
+ const speed = timeDiff > 0 ? bytesDiff / timeDiff : 0;
216
+
217
+ // Update for next calculation
218
+ lastTime = now;
219
+ lastDownloaded = downloaded;
220
+
221
+ // Display progress bar
222
+ this.displayProgress(downloaded, total, filename);
223
+
224
+ // Call user progress callback if provided
225
+ if (onProgress) {
226
+ onProgress({
227
+ filename,
228
+ downloaded,
229
+ total,
230
+ percentage: total > 0 ? (downloaded / total) * 100 : 0,
231
+ speed: `${formatBytes(speed)}/s`,
232
+ });
233
+ }
234
+ });
235
+
236
+ // Clear progress line and show completion
237
+ process.stdout.write('\r\x1b[K');
238
+ console.log(chalk.green('✅ Download complete!'));
239
+
240
+ const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
241
+ console.log(chalk.dim(` Time: ${totalTime}s`));
242
+ console.log(chalk.dim(` Location: ${destPath}`));
243
+
244
+ return destPath;
245
+ }
246
+
247
+ /**
248
+ * List GGUF files in a Hugging Face repository
249
+ * (This would require calling the HF API - simplified for now)
250
+ */
251
+ async listGGUFFiles(repoId: string): Promise<string[]> {
252
+ console.log(chalk.yellow('Listing files is not yet implemented.'));
253
+ console.log(chalk.dim('Please specify the file with --file <filename>'));
254
+ return [];
255
+ }
256
+ }
257
+
258
+ // Export singleton instance
259
+ export const modelDownloader = new ModelDownloader();
@@ -0,0 +1,125 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { ModelInfo } from '../types/model-info';
4
+ import { getModelsDir } from '../utils/file-utils';
5
+ import { formatBytes } from '../utils/format-utils';
6
+
7
+ export class ModelScanner {
8
+ private modelsDir: string;
9
+
10
+ constructor(modelsDir?: string) {
11
+ this.modelsDir = modelsDir || getModelsDir();
12
+ }
13
+
14
+ /**
15
+ * Scan models directory for GGUF files
16
+ */
17
+ async scanModels(): Promise<ModelInfo[]> {
18
+ try {
19
+ const files = await fs.readdir(this.modelsDir);
20
+ const ggufFiles = files.filter((f) => f.toLowerCase().endsWith('.gguf'));
21
+
22
+ const models: ModelInfo[] = [];
23
+ for (const file of ggufFiles) {
24
+ const modelInfo = await this.getModelInfo(file);
25
+ if (modelInfo) {
26
+ models.push(modelInfo);
27
+ }
28
+ }
29
+
30
+ // Sort by modified date (newest first)
31
+ models.sort((a, b) => b.modified.getTime() - a.modified.getTime());
32
+
33
+ return models;
34
+ } catch (error) {
35
+ // Models directory doesn't exist or is not accessible
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get information about a specific model file
42
+ */
43
+ async getModelInfo(filename: string): Promise<ModelInfo | null> {
44
+ const modelPath = path.join(this.modelsDir, filename);
45
+
46
+ try {
47
+ const stats = await fs.stat(modelPath);
48
+
49
+ return {
50
+ filename,
51
+ path: modelPath,
52
+ size: stats.size,
53
+ sizeFormatted: formatBytes(stats.size),
54
+ modified: stats.mtime,
55
+ exists: true,
56
+ };
57
+ } catch (error) {
58
+ // File doesn't exist or is not accessible
59
+ return {
60
+ filename,
61
+ path: modelPath,
62
+ size: 0,
63
+ sizeFormatted: '0 B',
64
+ modified: new Date(),
65
+ exists: false,
66
+ };
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Validate that a model file exists and is readable
72
+ */
73
+ async validateModel(filename: string): Promise<boolean> {
74
+ const modelInfo = await this.getModelInfo(filename);
75
+ return modelInfo !== null && modelInfo.exists && modelInfo.size > 0;
76
+ }
77
+
78
+ /**
79
+ * Resolve a model filename to full path
80
+ */
81
+ async resolveModelPath(filename: string): Promise<string | null> {
82
+ // If already absolute path, return it
83
+ if (path.isAbsolute(filename)) {
84
+ return filename;
85
+ }
86
+
87
+ // Try in models directory
88
+ const modelPath = path.join(this.modelsDir, filename);
89
+ const modelInfo = await this.getModelInfo(filename);
90
+
91
+ if (modelInfo && modelInfo.exists) {
92
+ return modelPath;
93
+ }
94
+
95
+ // Try adding .gguf extension
96
+ if (!filename.toLowerCase().endsWith('.gguf')) {
97
+ const withExtension = `${filename}.gguf`;
98
+ const modelInfoWithExt = await this.getModelInfo(withExtension);
99
+ if (modelInfoWithExt && modelInfoWithExt.exists) {
100
+ return path.join(this.modelsDir, withExtension);
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Get the size of a model file
109
+ */
110
+ async getModelSize(filename: string): Promise<number | null> {
111
+ const modelInfo = await this.getModelInfo(filename);
112
+ return modelInfo?.size || null;
113
+ }
114
+
115
+ /**
116
+ * Get total size of all models
117
+ */
118
+ async getTotalSize(): Promise<number> {
119
+ const models = await this.scanModels();
120
+ return models.reduce((total, model) => total + model.size, 0);
121
+ }
122
+ }
123
+
124
+ // Export singleton instance
125
+ export const modelScanner = new ModelScanner();
@@ -0,0 +1,114 @@
1
+ import * as https from 'https';
2
+
3
+ export interface HFModelResult {
4
+ modelId: string;
5
+ author: string;
6
+ modelName: string;
7
+ downloads: number;
8
+ likes: number;
9
+ tags: string[];
10
+ lastModified: string;
11
+ }
12
+
13
+ export class ModelSearch {
14
+ /**
15
+ * Search Hugging Face for GGUF models
16
+ */
17
+ async searchModels(query: string, limit = 20): Promise<HFModelResult[]> {
18
+ const searchUrl = this.buildSearchUrl(query, limit);
19
+
20
+ return new Promise((resolve, reject) => {
21
+ https.get(searchUrl, (response) => {
22
+ let data = '';
23
+
24
+ response.on('data', (chunk) => {
25
+ data += chunk;
26
+ });
27
+
28
+ response.on('end', () => {
29
+ try {
30
+ const results = JSON.parse(data);
31
+ const models = this.parseResults(results);
32
+ resolve(models);
33
+ } catch (error) {
34
+ reject(new Error(`Failed to parse search results: ${(error as Error).message}`));
35
+ }
36
+ });
37
+ }).on('error', (error) => {
38
+ reject(new Error(`Search request failed: ${error.message}`));
39
+ });
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Build Hugging Face search URL
45
+ */
46
+ private buildSearchUrl(query: string, limit: number): string {
47
+ const params = new URLSearchParams({
48
+ search: query,
49
+ filter: 'gguf',
50
+ sort: 'downloads',
51
+ direction: '-1',
52
+ limit: limit.toString(),
53
+ });
54
+
55
+ return `https://huggingface.co/api/models?${params.toString()}`;
56
+ }
57
+
58
+ /**
59
+ * Parse API results into our model format
60
+ */
61
+ private parseResults(results: any[]): HFModelResult[] {
62
+ return results.map((result) => {
63
+ const modelId = result.id || result.modelId || '';
64
+ const parts = modelId.split('/');
65
+ const author = parts[0] || '';
66
+ const modelName = parts.slice(1).join('/') || '';
67
+
68
+ return {
69
+ modelId,
70
+ author,
71
+ modelName,
72
+ downloads: result.downloads || 0,
73
+ likes: result.likes || 0,
74
+ tags: result.tags || [],
75
+ lastModified: result.lastModified || '',
76
+ };
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Get GGUF files for a specific model
82
+ */
83
+ async getModelFiles(modelId: string): Promise<string[]> {
84
+ const apiUrl = `https://huggingface.co/api/models/${modelId}`;
85
+
86
+ return new Promise((resolve, reject) => {
87
+ https.get(apiUrl, (response) => {
88
+ let data = '';
89
+
90
+ response.on('data', (chunk) => {
91
+ data += chunk;
92
+ });
93
+
94
+ response.on('end', () => {
95
+ try {
96
+ const modelInfo = JSON.parse(data);
97
+ const files = modelInfo.siblings || [];
98
+ const ggufFiles = files
99
+ .filter((file: any) => file.rfilename?.toLowerCase().endsWith('.gguf'))
100
+ .map((file: any) => file.rfilename);
101
+ resolve(ggufFiles);
102
+ } catch (error) {
103
+ reject(new Error(`Failed to fetch model files: ${(error as Error).message}`));
104
+ }
105
+ });
106
+ }).on('error', (error) => {
107
+ reject(new Error(`API request failed: ${error.message}`));
108
+ });
109
+ });
110
+ }
111
+ }
112
+
113
+ // Export singleton instance
114
+ export const modelSearch = new ModelSearch();
@@ -0,0 +1,80 @@
1
+ import { isPortInUse } from '../utils/process-utils';
2
+ import { stateManager } from './state-manager';
3
+
4
+ export class PortManager {
5
+ private readonly portRangeStart = 9000;
6
+ private readonly portRangeEnd = 9999;
7
+
8
+ /**
9
+ * Find an available port in the range
10
+ */
11
+ async findAvailablePort(startPort?: number): Promise<number> {
12
+ const start = startPort || this.portRangeStart;
13
+
14
+ // Get ports used by existing servers
15
+ const usedPorts = await stateManager.getUsedPorts();
16
+
17
+ // Find first available port
18
+ for (let port = start; port <= this.portRangeEnd; port++) {
19
+ if (!usedPorts.has(port)) {
20
+ // Check if port is actually available (not used by other processes)
21
+ const inUse = await isPortInUse(port);
22
+ if (!inUse) {
23
+ return port;
24
+ }
25
+ }
26
+ }
27
+
28
+ throw new Error(`No available ports in range ${start}-${this.portRangeEnd}`);
29
+ }
30
+
31
+ /**
32
+ * Check if a port is available
33
+ */
34
+ async isPortAvailable(port: number): Promise<boolean> {
35
+ // Check if port is in valid range
36
+ if (port < 1024 || port > 65535) {
37
+ return false;
38
+ }
39
+
40
+ // Check if port is used by any server
41
+ const usedPorts = await stateManager.getUsedPorts();
42
+ if (usedPorts.has(port)) {
43
+ return false;
44
+ }
45
+
46
+ // Check if port is actually in use
47
+ return !(await isPortInUse(port));
48
+ }
49
+
50
+ /**
51
+ * Validate a port number
52
+ */
53
+ validatePort(port: number): void {
54
+ if (port < 1024) {
55
+ throw new Error('Port must be >= 1024 (ports below 1024 require root)');
56
+ }
57
+ if (port > 65535) {
58
+ throw new Error('Port must be <= 65535');
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Find a server using a given port
64
+ */
65
+ async findServerByPort(port: number) {
66
+ return await stateManager.findServerByPort(port);
67
+ }
68
+
69
+ /**
70
+ * Check for port conflicts
71
+ */
72
+ async checkPortConflict(port: number, exceptId?: string): Promise<boolean> {
73
+ const servers = await stateManager.getAllServers();
74
+ const conflict = servers.find((s) => s.port === port && s.id !== exceptId);
75
+ return conflict !== undefined;
76
+ }
77
+ }
78
+
79
+ // Export singleton instance
80
+ export const portManager = new PortManager();