threadshelf 1.2.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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +763 -0
- package/SECURITY.md +75 -0
- package/bin/threadshelf-mcp.js +12 -0
- package/bin/threadshelf.js +87 -0
- package/dist/mcp/server.js +388 -0
- package/dist/src/chunking.js +72 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/embedding.js +59 -0
- package/dist/src/env.js +2 -0
- package/dist/src/generation/config.js +344 -0
- package/dist/src/generation/downloader.js +172 -0
- package/dist/src/generation/error-log.js +34 -0
- package/dist/src/generation/filesystem-browser.js +83 -0
- package/dist/src/generation/gguf-metadata.js +179 -0
- package/dist/src/generation/hardware.js +87 -0
- package/dist/src/generation/llama-install.js +563 -0
- package/dist/src/generation/llama-process.js +576 -0
- package/dist/src/generation/llama-profile.js +136 -0
- package/dist/src/generation/master-prompts.js +155 -0
- package/dist/src/generation/model-catalog.js +276 -0
- package/dist/src/generation/model-discovery.js +60 -0
- package/dist/src/generation/model-download.js +151 -0
- package/dist/src/generation/openai-compatible.js +231 -0
- package/dist/src/generation/providers/llama-cpp.js +97 -0
- package/dist/src/generation/providers/openrouter.js +106 -0
- package/dist/src/generation/quick-setup.js +215 -0
- package/dist/src/generation/registry.js +23 -0
- package/dist/src/generation/service.js +100 -0
- package/dist/src/generation/threads.js +311 -0
- package/dist/src/generation/types.js +1 -0
- package/dist/src/ingest-cli.js +95 -0
- package/dist/src/ingest.js +257 -0
- package/dist/src/load-env.js +17 -0
- package/dist/src/model-label.js +15 -0
- package/dist/src/parser.js +811 -0
- package/dist/src/paths.js +79 -0
- package/dist/src/routes/collections.js +97 -0
- package/dist/src/routes/files.js +136 -0
- package/dist/src/routes/generation.js +536 -0
- package/dist/src/routes/health.js +6 -0
- package/dist/src/routes/index.js +21 -0
- package/dist/src/routes/ingest.js +300 -0
- package/dist/src/routes/insights.js +24 -0
- package/dist/src/routes/loopback.js +15 -0
- package/dist/src/routes/model-catalog.js +178 -0
- package/dist/src/routes/search.js +57 -0
- package/dist/src/routes/stream-abort.js +23 -0
- package/dist/src/routes/thread.js +43 -0
- package/dist/src/search-cli.js +93 -0
- package/dist/src/server.js +78 -0
- package/dist/src/services/collections.js +58 -0
- package/dist/src/services/insights.js +111 -0
- package/dist/src/services/search.js +68 -0
- package/dist/src/services/stats.js +35 -0
- package/dist/src/services/thread.js +140 -0
- package/dist/src/store.js +1138 -0
- package/dist/src/validation.js +250 -0
- package/dist/src/watch.js +83 -0
- package/package.json +103 -0
- package/public/assets/index-CIm_Idqi.js +38 -0
- package/public/assets/index-Dv09K2vS.css +1 -0
- package/public/favicon.svg +6 -0
- package/public/index.html +28 -0
- package/scripts/openrouter-export-all.js +228 -0
- package/scripts/openrouter-export-browser.js +153 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { access, chmod, cp, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile, } from 'fs/promises';
|
|
4
|
+
import { homedir, tmpdir } from 'os';
|
|
5
|
+
import { dataPath } from '../paths.js';
|
|
6
|
+
import { basename, dirname, extname, join, resolve } from 'path';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { downloadToFile, sha256File } from './downloader.js';
|
|
9
|
+
export { sha256File };
|
|
10
|
+
export const LLAMA_CPP_REPOSITORY = 'ggml-org/llama.cpp';
|
|
11
|
+
export const LLAMA_CPP_RELEASE_API = 'https://api.github.com/repos/ggml-org/llama.cpp/releases/latest';
|
|
12
|
+
export const LLAMA_CPP_RELEASES_API = 'https://api.github.com/repos/ggml-org/llama.cpp/releases';
|
|
13
|
+
/**
|
|
14
|
+
* Upstream publishes stable semver releases (`v0.2.0`) that carry no binaries and
|
|
15
|
+
* mark the nightly build in a `nightly-tag.txt` asset, while the actual archives
|
|
16
|
+
* live in `bNNNNN` releases flagged as pre-releases. GitHub's `/releases/latest`
|
|
17
|
+
* therefore points at a release with nothing to install.
|
|
18
|
+
*/
|
|
19
|
+
export const NIGHTLY_TAG_ASSET = 'nightly-tag.txt';
|
|
20
|
+
const NIGHTLY_TAG_PATTERN = /^b\d+$/;
|
|
21
|
+
const RELEASE_SCAN_PAGE_SIZE = 30;
|
|
22
|
+
const executableNames = (platform = process.platform) => platform === 'win32' ? ['llama-server.exe'] : ['llama-server'];
|
|
23
|
+
const canExecute = async (path) => {
|
|
24
|
+
try {
|
|
25
|
+
await access(path, process.platform === 'win32' ? undefined : 1);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const unique = (values) => [
|
|
33
|
+
...new Set(values.map((value) => resolve(value))),
|
|
34
|
+
];
|
|
35
|
+
export const defaultLlamaInstallRoot = () => resolve(process.env.THREADSHELF_TOOLS_PATH || dataPath('tools'));
|
|
36
|
+
export const llamaExecutableCandidates = ({ platform = process.platform, installRoot = defaultLlamaInstallRoot(), env = process.env, } = {}) => {
|
|
37
|
+
const names = executableNames(platform);
|
|
38
|
+
const configured = [env.LLAMA_CPP_SERVER, env.LLAMA_SERVER_PATH].filter((value) => Boolean(value?.trim()));
|
|
39
|
+
const pathDelimiter = platform === 'win32' ? ';' : ':';
|
|
40
|
+
const pathEntries = (env.PATH || '')
|
|
41
|
+
.split(pathDelimiter)
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.flatMap((entry) => names.map((name) => join(entry, name)));
|
|
44
|
+
const roots = [
|
|
45
|
+
installRoot,
|
|
46
|
+
join(homedir(), '.local', 'bin'),
|
|
47
|
+
...(platform === 'win32'
|
|
48
|
+
? [join(env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local'), 'llama.cpp')]
|
|
49
|
+
: ['/usr/local/bin', '/opt/homebrew/bin']),
|
|
50
|
+
];
|
|
51
|
+
const rooted = roots.flatMap((root) => names.map((name) => join(root, name)));
|
|
52
|
+
return unique([...configured, ...pathEntries, ...rooted]);
|
|
53
|
+
};
|
|
54
|
+
const findRecursively = async (root, names, depth = 3) => {
|
|
55
|
+
if (depth < 0 || !existsSync(root))
|
|
56
|
+
return [];
|
|
57
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
58
|
+
const found = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
const path = join(root, entry.name);
|
|
61
|
+
if (entry.isFile() && names.has(entry.name.toLowerCase()))
|
|
62
|
+
found.push(path);
|
|
63
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
64
|
+
found.push(...(await findRecursively(path, names, depth - 1)));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
};
|
|
69
|
+
export const findLlamaExecutables = async (options = {}) => {
|
|
70
|
+
const platform = options.platform ?? process.platform;
|
|
71
|
+
const direct = await Promise.all(llamaExecutableCandidates(options).map(async (path) => (await canExecute(path)) ? path : null));
|
|
72
|
+
const installRoot = options.installRoot ?? defaultLlamaInstallRoot();
|
|
73
|
+
const nested = await findRecursively(installRoot, new Set(executableNames(platform).map((name) => name.toLowerCase())));
|
|
74
|
+
const releaseNumber = (path) => {
|
|
75
|
+
const match = path.match(/[\\/]b(\d+)(?:-[^\\/]+)?[\\/]/i);
|
|
76
|
+
return match?.[1] ? Number(match[1]) : 0;
|
|
77
|
+
};
|
|
78
|
+
const acceleratorScore = (path) => /[\\/]b\d+-(cuda|vulkan|rocm|sycl)[\\/]/i.test(path) ? 1 : 0;
|
|
79
|
+
nested.sort((left, right) => releaseNumber(right) - releaseNumber(left) ||
|
|
80
|
+
acceleratorScore(right) - acceleratorScore(left) ||
|
|
81
|
+
right.localeCompare(left));
|
|
82
|
+
return unique([...direct.filter((path) => path !== null), ...nested]);
|
|
83
|
+
};
|
|
84
|
+
/** Build of a ThreadShelf-managed install path such as `…/llama.cpp/b10809-cuda/llama-server`. */
|
|
85
|
+
export const managedLlamaBuild = (path) => {
|
|
86
|
+
const match = path.match(/[\\/]llama\.cpp[\\/](b(\d+))-([a-z0-9]+)[\\/]/i);
|
|
87
|
+
return match?.[1] && match[2] && match[3]
|
|
88
|
+
? { tag: match[1], build: Number(match[2]), flavor: match[3].toLowerCase() }
|
|
89
|
+
: null;
|
|
90
|
+
};
|
|
91
|
+
const githubHeaders = (env = process.env) => {
|
|
92
|
+
const token = (env.GITHUB_TOKEN || env.GH_TOKEN || '').trim();
|
|
93
|
+
return {
|
|
94
|
+
Accept: 'application/vnd.github+json',
|
|
95
|
+
'User-Agent': 'ThreadShelf-llama-installer',
|
|
96
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
97
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
const githubGet = async (url, fetchImpl) => {
|
|
101
|
+
const response = await fetchImpl(url, {
|
|
102
|
+
headers: githubHeaders(),
|
|
103
|
+
signal: AbortSignal.timeout(15_000),
|
|
104
|
+
});
|
|
105
|
+
if (response.status === 403 || response.status === 429) {
|
|
106
|
+
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
107
|
+
throw new Error(remaining === '0'
|
|
108
|
+
? 'GitHub API rate limit reached. Set GITHUB_TOKEN to raise the limit, or retry later.'
|
|
109
|
+
: `GitHub release lookup was refused (${response.status})`);
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok)
|
|
112
|
+
throw new Error(`GitHub release lookup failed (${response.status})`);
|
|
113
|
+
return response;
|
|
114
|
+
};
|
|
115
|
+
const asRelease = (value) => {
|
|
116
|
+
const release = value;
|
|
117
|
+
if (!release?.tag_name || !release.html_url || !Array.isArray(release.assets)) {
|
|
118
|
+
throw new Error('GitHub returned an invalid llama.cpp release payload');
|
|
119
|
+
}
|
|
120
|
+
return release;
|
|
121
|
+
};
|
|
122
|
+
export const fetchLatestLlamaRelease = async (fetchImpl = fetch) => asRelease(await (await githubGet(LLAMA_CPP_RELEASE_API, fetchImpl)).json());
|
|
123
|
+
export const fetchLlamaReleaseByTag = async (tag, fetchImpl = fetch) => {
|
|
124
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(tag)) {
|
|
125
|
+
throw new Error(`Invalid llama.cpp release tag: ${tag}`);
|
|
126
|
+
}
|
|
127
|
+
return asRelease(await (await githubGet(`${LLAMA_CPP_RELEASES_API}/tags/${encodeURIComponent(tag)}`, fetchImpl)).json());
|
|
128
|
+
};
|
|
129
|
+
/** True when a release actually carries installable `llama-*-bin-*` archives. */
|
|
130
|
+
export const releaseHasLlamaBinaries = (release) => release.assets.some((asset) => /^llama-.*-bin-.*\.(zip|tar\.gz|tgz)$/i.test(asset.name));
|
|
131
|
+
/** Reads the `bNNNNN` build that a binary-less stable release points at. */
|
|
132
|
+
export const readNightlyTagPointer = async (release, fetchImpl = fetch) => {
|
|
133
|
+
const pointer = release.assets.find((asset) => asset.name.toLowerCase() === NIGHTLY_TAG_ASSET);
|
|
134
|
+
if (!pointer)
|
|
135
|
+
return null;
|
|
136
|
+
const response = await fetchImpl(pointer.browser_download_url, {
|
|
137
|
+
redirect: 'follow',
|
|
138
|
+
headers: { 'User-Agent': 'ThreadShelf-llama-installer' },
|
|
139
|
+
signal: AbortSignal.timeout(15_000),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok)
|
|
142
|
+
return null;
|
|
143
|
+
const tag = (await response.text()).trim().split(/\s+/)[0] ?? '';
|
|
144
|
+
return NIGHTLY_TAG_PATTERN.test(tag) ? tag : null;
|
|
145
|
+
};
|
|
146
|
+
const scanRecentReleasesForBinaries = async (fetchImpl) => {
|
|
147
|
+
const payload = (await (await githubGet(`${LLAMA_CPP_RELEASES_API}?per_page=${RELEASE_SCAN_PAGE_SIZE}`, fetchImpl)).json());
|
|
148
|
+
if (!Array.isArray(payload))
|
|
149
|
+
return null;
|
|
150
|
+
for (const entry of payload) {
|
|
151
|
+
const release = entry;
|
|
152
|
+
if (!release?.tag_name || !Array.isArray(release.assets))
|
|
153
|
+
continue;
|
|
154
|
+
if (!NIGHTLY_TAG_PATTERN.test(release.tag_name))
|
|
155
|
+
continue;
|
|
156
|
+
if (releaseHasLlamaBinaries(release))
|
|
157
|
+
return release;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Resolves the newest release that really has binaries, following the
|
|
163
|
+
* `nightly-tag.txt` pointer and falling back to a scan of recent releases.
|
|
164
|
+
*/
|
|
165
|
+
export const resolveLlamaRelease = async ({ tag, fetchImpl = fetch, } = {}) => {
|
|
166
|
+
if (tag) {
|
|
167
|
+
const pinned = await fetchLlamaReleaseByTag(tag, fetchImpl);
|
|
168
|
+
if (!releaseHasLlamaBinaries(pinned)) {
|
|
169
|
+
throw new Error(`Release ${pinned.tag_name} carries no llama.cpp binaries.`);
|
|
170
|
+
}
|
|
171
|
+
return pinned;
|
|
172
|
+
}
|
|
173
|
+
const latest = await fetchLatestLlamaRelease(fetchImpl);
|
|
174
|
+
if (releaseHasLlamaBinaries(latest))
|
|
175
|
+
return latest;
|
|
176
|
+
const nightlyTag = await readNightlyTagPointer(latest, fetchImpl);
|
|
177
|
+
if (nightlyTag) {
|
|
178
|
+
const nightly = await fetchLlamaReleaseByTag(nightlyTag, fetchImpl).catch(() => null);
|
|
179
|
+
if (nightly && releaseHasLlamaBinaries(nightly))
|
|
180
|
+
return nightly;
|
|
181
|
+
}
|
|
182
|
+
const scanned = await scanRecentReleasesForBinaries(fetchImpl);
|
|
183
|
+
if (scanned)
|
|
184
|
+
return scanned;
|
|
185
|
+
throw new Error(`No llama.cpp release with binaries was found (latest tag ${latest.tag_name} has none). Use --url for a custom build.`);
|
|
186
|
+
};
|
|
187
|
+
const architectureToken = (arch) => {
|
|
188
|
+
if (arch === 'x64' || arch === 'arm64')
|
|
189
|
+
return arch;
|
|
190
|
+
throw new Error(`Unsupported architecture: ${arch}. Use --url for a compatible custom build.`);
|
|
191
|
+
};
|
|
192
|
+
const platformToken = (platform) => {
|
|
193
|
+
if (platform === 'win32' || platform === 'darwin' || platform === 'linux')
|
|
194
|
+
return platform;
|
|
195
|
+
throw new Error(`Unsupported platform: ${platform}. Use --url for a compatible custom build.`);
|
|
196
|
+
};
|
|
197
|
+
/** Orders `-cuda-13.3-` ahead of `-cuda-12.4-`; unversioned assets rank lowest. */
|
|
198
|
+
export const toolkitRank = (name) => {
|
|
199
|
+
const match = name.toLowerCase().match(/-(?:cuda|rocm|sycl|openvino)-(\d+)(?:\.(\d+))?/);
|
|
200
|
+
if (!match)
|
|
201
|
+
return -1;
|
|
202
|
+
return Number(match[1]) * 1000 + Number(match[2] ?? 0);
|
|
203
|
+
};
|
|
204
|
+
export const selectReleaseAsset = (release, { platform = process.platform, arch = process.arch, variant = 'cpu', } = {}) => {
|
|
205
|
+
const os = platformToken(platform);
|
|
206
|
+
const cpu = architectureToken(arch);
|
|
207
|
+
const supportedVariant = os === 'darwin' ? 'cpu' : variant;
|
|
208
|
+
if (os === 'darwin' && variant !== 'cpu') {
|
|
209
|
+
throw new Error('macOS release builds use Metal automatically; choose the cpu variant.');
|
|
210
|
+
}
|
|
211
|
+
const required = os === 'win32'
|
|
212
|
+
? ['-bin-win-', supportedVariant === 'cpu' ? '-cpu-' : `-${supportedVariant}-`, `-${cpu}.zip`]
|
|
213
|
+
: os === 'darwin'
|
|
214
|
+
? ['-bin-macos-', `-${cpu}.tar.gz`]
|
|
215
|
+
: [
|
|
216
|
+
'-bin-ubuntu-',
|
|
217
|
+
...(supportedVariant === 'cpu' ? [] : [`-${supportedVariant}-`]),
|
|
218
|
+
`-${cpu}.tar.gz`,
|
|
219
|
+
];
|
|
220
|
+
const matches = release.assets.filter((asset) => {
|
|
221
|
+
const name = asset.name.toLowerCase();
|
|
222
|
+
if (name.startsWith('cudart-'))
|
|
223
|
+
return false;
|
|
224
|
+
if (os === 'linux' &&
|
|
225
|
+
supportedVariant === 'cpu' &&
|
|
226
|
+
/-(vulkan|rocm|sycl|openvino|cuda)-/.test(name)) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return required.every((token) => name.includes(token));
|
|
230
|
+
});
|
|
231
|
+
// Accelerator builds are published per toolkit version (cuda-12.4, cuda-13.3,
|
|
232
|
+
// rocm-7.14). Plain alphabetical order would pin the oldest toolkit forever.
|
|
233
|
+
const asset = matches.sort((a, b) => toolkitRank(b.name) - toolkitRank(a.name) || a.name.localeCompare(b.name))[0];
|
|
234
|
+
if (!asset) {
|
|
235
|
+
throw new Error(`No official ${os}/${cpu}/${supportedVariant} binary exists in release ${release.tag_name}. Use --url for a custom build.`);
|
|
236
|
+
}
|
|
237
|
+
return asset;
|
|
238
|
+
};
|
|
239
|
+
const normalizeDigest = (digest) => {
|
|
240
|
+
if (!digest)
|
|
241
|
+
return undefined;
|
|
242
|
+
const value = digest.toLowerCase().replace(/^sha256:/, '');
|
|
243
|
+
return /^[a-f0-9]{64}$/.test(value) ? value : undefined;
|
|
244
|
+
};
|
|
245
|
+
export const sourceFromRelease = (release, options = {}) => {
|
|
246
|
+
const asset = selectReleaseAsset(release, options);
|
|
247
|
+
const sha256 = normalizeDigest(asset.digest);
|
|
248
|
+
if (!sha256) {
|
|
249
|
+
throw new Error(`Release asset ${asset.name} has no usable SHA-256 digest; refusing install.`);
|
|
250
|
+
}
|
|
251
|
+
const platform = options.platform ?? process.platform;
|
|
252
|
+
const variant = options.variant ?? 'cpu';
|
|
253
|
+
let companions;
|
|
254
|
+
if (platform === 'win32' && variant === 'cuda') {
|
|
255
|
+
const expectedName = asset.name.replace(/^llama-[^-]+-bin-win-/i, 'cudart-llama-bin-win-');
|
|
256
|
+
const companion = release.assets.find((candidate) => candidate.name.toLowerCase() === expectedName.toLowerCase());
|
|
257
|
+
const companionSha256 = normalizeDigest(companion?.digest);
|
|
258
|
+
if (!companion || !companionSha256) {
|
|
259
|
+
throw new Error(`Release ${release.tag_name} has no authenticated CUDA runtime companion ${expectedName}; refusing an incomplete Windows CUDA install.`);
|
|
260
|
+
}
|
|
261
|
+
companions = [
|
|
262
|
+
{
|
|
263
|
+
url: companion.browser_download_url,
|
|
264
|
+
filename: companion.name,
|
|
265
|
+
sha256: companionSha256,
|
|
266
|
+
sizeBytes: companion.size,
|
|
267
|
+
},
|
|
268
|
+
];
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
url: asset.browser_download_url,
|
|
272
|
+
filename: asset.name,
|
|
273
|
+
sha256,
|
|
274
|
+
sizeBytes: asset.size,
|
|
275
|
+
tag: release.tag_name,
|
|
276
|
+
releaseUrl: release.html_url,
|
|
277
|
+
flavor: options.variant ?? 'cpu',
|
|
278
|
+
companions,
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Downloads and verifies in one pass. The digest is computed while the bytes are
|
|
283
|
+
* written, so a several-hundred-megabyte archive is never read back off disk.
|
|
284
|
+
*/
|
|
285
|
+
const downloadAndVerify = async (url, destination, sha256, onProgress, signal) => {
|
|
286
|
+
if (existsSync(destination)) {
|
|
287
|
+
const valid = !sha256 || (await sha256File(destination)) === sha256.toLowerCase();
|
|
288
|
+
if (valid)
|
|
289
|
+
return;
|
|
290
|
+
await rm(destination, { force: true });
|
|
291
|
+
}
|
|
292
|
+
await downloadToFile(url, destination, {
|
|
293
|
+
sha256,
|
|
294
|
+
signal,
|
|
295
|
+
headers: { 'User-Agent': 'ThreadShelf-llama-installer' },
|
|
296
|
+
onProgress: (progress) => onProgress?.({
|
|
297
|
+
phase: 'downloading',
|
|
298
|
+
downloadedBytes: progress.downloadedBytes,
|
|
299
|
+
totalBytes: progress.totalBytes,
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
const cachedArtifactPath = (directory, url, filename, sha256) => {
|
|
304
|
+
const identity = createHash('sha256')
|
|
305
|
+
.update(`${url}\0${sha256 ?? ''}`)
|
|
306
|
+
.digest('hex')
|
|
307
|
+
.slice(0, 20);
|
|
308
|
+
const safeName = basename(filename).replace(/[^A-Za-z0-9._-]/g, '_') || 'artifact';
|
|
309
|
+
return join(directory, `${identity}-${safeName}`);
|
|
310
|
+
};
|
|
311
|
+
const cleanupFailedArtifact = async (archive, error) => {
|
|
312
|
+
if (error instanceof Error && error.name === 'AbortError')
|
|
313
|
+
return;
|
|
314
|
+
await Promise.all([
|
|
315
|
+
rm(archive, { force: true }).catch(() => undefined),
|
|
316
|
+
rm(`${archive}.part`, { force: true }).catch(() => undefined),
|
|
317
|
+
]);
|
|
318
|
+
};
|
|
319
|
+
const run = async (command, args) => new Promise((resolveRun, reject) => {
|
|
320
|
+
const child = spawn(command, [...args], { stdio: 'inherit', windowsHide: true });
|
|
321
|
+
child.once('error', reject);
|
|
322
|
+
child.once('exit', (code) => code === 0 ? resolveRun() : reject(new Error(`${command} exited with code ${code}`)));
|
|
323
|
+
});
|
|
324
|
+
export const runCommandCapture = async (command, args) => new Promise((resolveRun, reject) => {
|
|
325
|
+
const child = spawn(command, [...args], { windowsHide: true });
|
|
326
|
+
let stdout = '';
|
|
327
|
+
let stderr = '';
|
|
328
|
+
child.stdout?.on('data', (chunk) => {
|
|
329
|
+
stdout += chunk.toString('utf8');
|
|
330
|
+
});
|
|
331
|
+
child.stderr?.on('data', (chunk) => {
|
|
332
|
+
stderr += chunk.toString('utf8');
|
|
333
|
+
});
|
|
334
|
+
child.once('error', reject);
|
|
335
|
+
// `exit` can fire before stdout/stderr have emitted their final buffered
|
|
336
|
+
// chunks. `close` is emitted only after the stdio streams are closed.
|
|
337
|
+
child.once('close', (code) => code === 0
|
|
338
|
+
? resolveRun(stdout)
|
|
339
|
+
: reject(new Error(`${command} exited with code ${code}: ${stderr.trim()}`)));
|
|
340
|
+
});
|
|
341
|
+
export const assertSafeArchiveEntries = (entries) => {
|
|
342
|
+
if (entries.length === 0)
|
|
343
|
+
throw new Error('Archive is empty');
|
|
344
|
+
for (const original of entries) {
|
|
345
|
+
const normalized = original.trim().replace(/\\/g, '/').replace(/\/$/, '');
|
|
346
|
+
if (!normalized)
|
|
347
|
+
continue;
|
|
348
|
+
const segments = normalized.split('/');
|
|
349
|
+
if (normalized.startsWith('/') ||
|
|
350
|
+
/^[a-zA-Z]:/.test(normalized) ||
|
|
351
|
+
normalized.includes('\0') ||
|
|
352
|
+
segments.includes('..')) {
|
|
353
|
+
throw new Error(`Unsafe archive entry: ${original}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
export const inspectLlamaArchive = async (archive) => {
|
|
358
|
+
const lower = archive.toLowerCase();
|
|
359
|
+
if (lower.endsWith('.zip')) {
|
|
360
|
+
if (process.platform === 'win32') {
|
|
361
|
+
const listing = await runCommandCapture('powershell.exe', [
|
|
362
|
+
'-NoProfile',
|
|
363
|
+
'-NonInteractive',
|
|
364
|
+
'-Command',
|
|
365
|
+
"& { param($archive) Add-Type -AssemblyName System.IO.Compression.FileSystem; $z=[IO.Compression.ZipFile]::OpenRead($archive); try { foreach($e in $z.Entries) { if ((($e.ExternalAttributes -shr 16) -band 0xF000) -eq 0xA000) { throw 'Archive contains a symbolic link' }; $e.FullName } } finally { $z.Dispose() } }",
|
|
366
|
+
archive,
|
|
367
|
+
]);
|
|
368
|
+
assertSafeArchiveEntries(listing.split(/\r?\n/).filter(Boolean));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const listing = await runCommandCapture('unzip', ['-Z1', archive]);
|
|
372
|
+
const verbose = await runCommandCapture('unzip', ['-Z', '-l', archive]);
|
|
373
|
+
if (/^\s*l[rwx-]{9}\s/m.test(verbose)) {
|
|
374
|
+
throw new Error('Archive contains a symbolic link');
|
|
375
|
+
}
|
|
376
|
+
assertSafeArchiveEntries(listing.split(/\r?\n/).filter(Boolean));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
|
|
380
|
+
const listing = await runCommandCapture('tar', ['-tzf', archive]);
|
|
381
|
+
const verbose = await runCommandCapture('tar', ['-tvzf', archive]);
|
|
382
|
+
if (/^[lh][rwx-]{9}\s/m.test(verbose)) {
|
|
383
|
+
throw new Error('Archive contains a symbolic or hard link');
|
|
384
|
+
}
|
|
385
|
+
assertSafeArchiveEntries(listing.split(/\r?\n/).filter(Boolean));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
throw new Error(`Unsupported archive type: ${basename(archive)}`);
|
|
389
|
+
};
|
|
390
|
+
const extractArchive = async (archive, destination) => {
|
|
391
|
+
const lower = archive.toLowerCase();
|
|
392
|
+
if (lower.endsWith('.zip')) {
|
|
393
|
+
if (process.platform === 'win32') {
|
|
394
|
+
await run('powershell.exe', [
|
|
395
|
+
'-NoProfile',
|
|
396
|
+
'-NonInteractive',
|
|
397
|
+
'-Command',
|
|
398
|
+
'& { param($archive, $destination) Expand-Archive -LiteralPath $archive -DestinationPath $destination }',
|
|
399
|
+
archive,
|
|
400
|
+
destination,
|
|
401
|
+
]);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
await run('unzip', ['-q', archive, '-d', destination]);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) {
|
|
408
|
+
await run('tar', ['-xzf', archive, '-C', destination]);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
throw new Error(`Unsupported archive type: ${basename(archive)}`);
|
|
412
|
+
};
|
|
413
|
+
const installCompanionArchives = async (companions, targetDirectory, staging, downloadDirectory, onProgress, signal) => {
|
|
414
|
+
for (const [index, companion] of companions.entries()) {
|
|
415
|
+
const archive = cachedArtifactPath(downloadDirectory, companion.url, companion.filename, companion.sha256);
|
|
416
|
+
const extracted = join(staging, `companion-${index}-extracted`);
|
|
417
|
+
await mkdir(extracted);
|
|
418
|
+
try {
|
|
419
|
+
onProgress?.({ phase: 'downloading', downloadedBytes: 0 });
|
|
420
|
+
await downloadAndVerify(companion.url, archive, companion.sha256, onProgress, signal);
|
|
421
|
+
signal?.throwIfAborted();
|
|
422
|
+
onProgress?.({ phase: 'inspecting' });
|
|
423
|
+
await inspectLlamaArchive(archive);
|
|
424
|
+
signal?.throwIfAborted();
|
|
425
|
+
onProgress?.({ phase: 'extracting' });
|
|
426
|
+
await extractArchive(archive, extracted);
|
|
427
|
+
signal?.throwIfAborted();
|
|
428
|
+
// Runtime archives contain DLLs shared by the executable. Never replace an
|
|
429
|
+
// existing file during repair; matching files are left untouched.
|
|
430
|
+
for (const entry of await readdir(extracted)) {
|
|
431
|
+
await cp(join(extracted, entry), join(targetDirectory, entry), {
|
|
432
|
+
recursive: true,
|
|
433
|
+
force: false,
|
|
434
|
+
errorOnExist: false,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
await rm(archive, { force: true });
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
await cleanupFailedArtifact(archive, error);
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const copyLicense = async (tag, destination) => {
|
|
446
|
+
const url = `https://raw.githubusercontent.com/${LLAMA_CPP_REPOSITORY}/${encodeURIComponent(tag)}/LICENSE`;
|
|
447
|
+
const response = await fetch(url, {
|
|
448
|
+
headers: { 'User-Agent': 'ThreadShelf-llama-installer' },
|
|
449
|
+
signal: AbortSignal.timeout(15_000),
|
|
450
|
+
});
|
|
451
|
+
if (!response.ok)
|
|
452
|
+
throw new Error(`Could not retrieve llama.cpp license (${response.status})`);
|
|
453
|
+
await writeFile(join(destination, 'LICENSE.llama.cpp'), await response.text(), {
|
|
454
|
+
encoding: 'utf8',
|
|
455
|
+
mode: 0o600,
|
|
456
|
+
});
|
|
457
|
+
};
|
|
458
|
+
export const installLlamaCpp = async (source, { installRoot = defaultLlamaInstallRoot(), onProgress, signal, } = {}) => {
|
|
459
|
+
const safeTag = source.tag.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
460
|
+
const safeFlavor = source.flavor?.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
461
|
+
const destination = resolve(installRoot, 'llama.cpp', safeFlavor ? `${safeTag}-${safeFlavor}` : safeTag);
|
|
462
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
463
|
+
const downloadDirectory = join(dirname(destination), '.downloads');
|
|
464
|
+
await mkdir(downloadDirectory, { recursive: true });
|
|
465
|
+
const staging = await mkdtemp(join(tmpdir(), 'threadshelf-llama-'));
|
|
466
|
+
let primaryArchive;
|
|
467
|
+
try {
|
|
468
|
+
if (existsSync(destination)) {
|
|
469
|
+
const metadata = await readFile(join(destination, 'THREADSHELF_INSTALL.json'), 'utf8')
|
|
470
|
+
.then((value) => JSON.parse(value))
|
|
471
|
+
.catch(() => null);
|
|
472
|
+
if (metadata?.source?.tag !== source.tag ||
|
|
473
|
+
(metadata.source.flavor ?? 'cpu') !== (source.flavor ?? 'cpu')) {
|
|
474
|
+
throw new Error(`Existing install metadata does not match ${source.tag}/${source.flavor ?? 'cpu'}; refusing repair.`);
|
|
475
|
+
}
|
|
476
|
+
const existing = await findRecursively(destination, new Set(executableNames().map((name) => name.toLowerCase())), 5);
|
|
477
|
+
const executablePath = existing[0];
|
|
478
|
+
if (!executablePath) {
|
|
479
|
+
throw new Error(`Existing install has no llama-server: ${destination}`);
|
|
480
|
+
}
|
|
481
|
+
// Re-running an install of the same build is a no-op rather than an error,
|
|
482
|
+
// so the one-click setup screen stays safe to press twice.
|
|
483
|
+
if (!source.companions?.length) {
|
|
484
|
+
return { installDirectory: destination, executablePath, source };
|
|
485
|
+
}
|
|
486
|
+
await installCompanionArchives(source.companions, dirname(executablePath), staging, downloadDirectory, onProgress, signal);
|
|
487
|
+
await writeFile(join(destination, 'THREADSHELF_INSTALL.json'), `${JSON.stringify({ source, installedAt: new Date().toISOString() }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
488
|
+
return { installDirectory: destination, executablePath, source };
|
|
489
|
+
}
|
|
490
|
+
const archiveFilename = source.filename || `llama${extname(new URL(source.url).pathname)}`;
|
|
491
|
+
const archive = cachedArtifactPath(downloadDirectory, source.url, archiveFilename, source.sha256);
|
|
492
|
+
primaryArchive = archive;
|
|
493
|
+
const extracted = join(staging, 'extracted');
|
|
494
|
+
await mkdir(extracted);
|
|
495
|
+
onProgress?.({ phase: 'downloading', downloadedBytes: 0 });
|
|
496
|
+
await downloadAndVerify(source.url, archive, source.sha256, onProgress, signal);
|
|
497
|
+
signal?.throwIfAborted();
|
|
498
|
+
onProgress?.({ phase: 'inspecting' });
|
|
499
|
+
await inspectLlamaArchive(archive);
|
|
500
|
+
onProgress?.({ phase: 'extracting' });
|
|
501
|
+
signal?.throwIfAborted();
|
|
502
|
+
await extractArchive(archive, extracted);
|
|
503
|
+
const found = await findRecursively(extracted, new Set(executableNames().map((name) => name.toLowerCase())), 5);
|
|
504
|
+
const executable = found[0];
|
|
505
|
+
if (!executable)
|
|
506
|
+
throw new Error('Archive does not contain llama-server');
|
|
507
|
+
if (process.platform !== 'win32')
|
|
508
|
+
await chmod(executable, 0o755);
|
|
509
|
+
if (source.companions?.length) {
|
|
510
|
+
await installCompanionArchives(source.companions, dirname(executable), staging, downloadDirectory, onProgress, signal);
|
|
511
|
+
}
|
|
512
|
+
if (source.releaseUrl?.includes('github.com/ggml-org/llama.cpp/')) {
|
|
513
|
+
onProgress?.({ phase: 'licensing' });
|
|
514
|
+
await copyLicense(source.tag, extracted);
|
|
515
|
+
}
|
|
516
|
+
await writeFile(join(extracted, 'THREADSHELF_INSTALL.json'), `${JSON.stringify({ source, installedAt: new Date().toISOString() }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
517
|
+
await rename(extracted, destination).catch(async (error) => {
|
|
518
|
+
const code = error.code;
|
|
519
|
+
if (code !== 'EXDEV')
|
|
520
|
+
throw error;
|
|
521
|
+
await cp(extracted, destination, { recursive: true, errorOnExist: true });
|
|
522
|
+
});
|
|
523
|
+
const relativeExecutable = executable.slice(extracted.length + 1);
|
|
524
|
+
await rm(archive, { force: true });
|
|
525
|
+
primaryArchive = undefined;
|
|
526
|
+
return {
|
|
527
|
+
installDirectory: destination,
|
|
528
|
+
executablePath: join(destination, relativeExecutable),
|
|
529
|
+
source,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
if (primaryArchive)
|
|
534
|
+
await cleanupFailedArtifact(primaryArchive, error);
|
|
535
|
+
throw error;
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
await rm(staging, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
export const customInstallSource = (url, { sha256, tag = 'custom' } = {}) => {
|
|
542
|
+
const parsed = new URL(url);
|
|
543
|
+
if (!['https:', 'http:'].includes(parsed.protocol)) {
|
|
544
|
+
throw new Error('Custom URL must use HTTPS or HTTP');
|
|
545
|
+
}
|
|
546
|
+
const filename = basename(parsed.pathname);
|
|
547
|
+
if (!filename || (!filename.endsWith('.zip') && !filename.match(/\.(tar\.gz|tgz)$/))) {
|
|
548
|
+
throw new Error('Custom URL must point to a .zip, .tar.gz, or .tgz archive');
|
|
549
|
+
}
|
|
550
|
+
const normalizedSha = normalizeDigest(sha256);
|
|
551
|
+
if (sha256 && !normalizedSha)
|
|
552
|
+
throw new Error('Invalid SHA-256 digest');
|
|
553
|
+
return { url: parsed.toString(), filename, sha256: normalizedSha, tag };
|
|
554
|
+
};
|
|
555
|
+
export const readInstalledSource = async (installDirectory) => {
|
|
556
|
+
try {
|
|
557
|
+
const raw = await readFile(join(installDirectory, 'THREADSHELF_INSTALL.json'), 'utf8');
|
|
558
|
+
return JSON.parse(raw).source ?? null;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
};
|