@tuannvm/ccodex 0.1.1 → 0.2.9
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 +35 -0
- package/dist/aliases.js +2 -2
- package/dist/aliases.js.map +1 -1
- package/dist/claude.d.ts +5 -1
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +136 -37
- package/dist/claude.js.map +1 -1
- package/dist/cli.js +149 -6
- package/dist/cli.js.map +1 -1
- package/dist/powershell.js +2 -2
- package/dist/powershell.js.map +1 -1
- package/dist/proxy.d.ts +7 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +737 -62
- package/dist/proxy.js.map +1 -1
- package/dist/status.js +4 -4
- package/dist/status.js.map +1 -1
- package/dist/utils.d.ts +28 -4
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +210 -28
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -1,28 +1,88 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
2
|
-
import {
|
|
1
|
+
import { join, delimiter, normalize, sep, resolve, dirname, isAbsolute } from 'path';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { createHash, randomUUID } from 'crypto';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { hasCommand, getCommandPath, execCommand, httpGet, sleep, ensureDir, fileExists, safeJsonParse, debugLog, runCmdBounded, requireTrustedCommand, withInstallLock } from './utils.js';
|
|
3
6
|
import { CONFIG, getProxyUrl, getAuthDir, getLogFilePath } from './config.js';
|
|
7
|
+
// Track installed proxy binary path for this process
|
|
8
|
+
let installedProxyPath = null;
|
|
9
|
+
/**
|
|
10
|
+
* Get allowed installation directories for proxy binary
|
|
11
|
+
* Returns paths that are considered safe sources for CLIProxyAPI
|
|
12
|
+
*/
|
|
13
|
+
function getAllowedInstallDirs() {
|
|
14
|
+
const home = homedir();
|
|
15
|
+
const allowed = [
|
|
16
|
+
join(home, '.local', 'bin'), // User local bin
|
|
17
|
+
'/usr/local/bin', // System local bin
|
|
18
|
+
'/opt/homebrew/bin', // Homebrew Apple Silicon
|
|
19
|
+
'/usr/local/bin', // Homebrew Intel
|
|
20
|
+
join(home, 'go', 'bin'), // Go user bin
|
|
21
|
+
];
|
|
22
|
+
// Add common Windows paths if on Windows
|
|
23
|
+
if (process.platform === 'win32') {
|
|
24
|
+
allowed.push(join(process.env.LOCALAPPDATA || '', 'Programs'), join(process.env.APPDATA || '', 'Programs'));
|
|
25
|
+
}
|
|
26
|
+
return allowed;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Validate that a proxy binary path is from a trusted location
|
|
30
|
+
* Throws if path is not absolute or not from allowed directory
|
|
31
|
+
*/
|
|
32
|
+
function validateProxyPath(proxyPath) {
|
|
33
|
+
if (!isAbsolute(proxyPath)) {
|
|
34
|
+
throw new Error(`Proxy binary path is not absolute: ${proxyPath}`);
|
|
35
|
+
}
|
|
36
|
+
const realPath = resolve(proxyPath);
|
|
37
|
+
const allowedDirs = getAllowedInstallDirs();
|
|
38
|
+
const isAllowed = allowedDirs.some(allowedDir => {
|
|
39
|
+
const resolvedAllowed = resolve(allowedDir);
|
|
40
|
+
return realPath.startsWith(resolvedAllowed + sep) || realPath === resolvedAllowed;
|
|
41
|
+
});
|
|
42
|
+
if (!isAllowed) {
|
|
43
|
+
throw new Error(`Proxy binary not from trusted location.\n` +
|
|
44
|
+
`Path: ${realPath}\n` +
|
|
45
|
+
`Allowed directories: ${allowedDirs.join(', ')}\n\n` +
|
|
46
|
+
`For security, only proxy binaries from trusted locations are executed.\n` +
|
|
47
|
+
`If you installed CLIProxyAPI manually, move it to ~/.local/bin or install via Homebrew.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Get trusted proxy command path
|
|
52
|
+
* Validates that the proxy binary is from a trusted location before execution
|
|
53
|
+
*/
|
|
54
|
+
export async function requireTrustedProxyCommand() {
|
|
55
|
+
const cmdResult = await detectProxyCommand();
|
|
56
|
+
// Prefer installed path from this process
|
|
57
|
+
if (installedProxyPath && fileExists(installedProxyPath)) {
|
|
58
|
+
validateProxyPath(installedProxyPath);
|
|
59
|
+
return installedProxyPath;
|
|
60
|
+
}
|
|
61
|
+
// Use detected path
|
|
62
|
+
if (!cmdResult.path) {
|
|
63
|
+
throw new Error('CLIProxyAPI not found. Install it first:\n' +
|
|
64
|
+
' 1. Run: npx -y @tuannvm/ccodex\n' +
|
|
65
|
+
' 2. Or install manually: brew install cliproxyapi');
|
|
66
|
+
}
|
|
67
|
+
validateProxyPath(cmdResult.path);
|
|
68
|
+
return cmdResult.path;
|
|
69
|
+
}
|
|
4
70
|
/**
|
|
5
71
|
* Detect CLIProxyAPI command
|
|
72
|
+
* Prefers locally installed binary from this process if available
|
|
6
73
|
*/
|
|
7
74
|
export async function detectProxyCommand() {
|
|
75
|
+
// Prefer locally installed binary from this process
|
|
76
|
+
if (installedProxyPath && fileExists(installedProxyPath)) {
|
|
77
|
+
return { cmd: 'cliproxyapi', path: installedProxyPath };
|
|
78
|
+
}
|
|
8
79
|
if (await hasCommand('cliproxyapi')) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return { cmd: 'cliproxyapi', path };
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
// which might fail, continue anyway
|
|
15
|
-
return { cmd: 'cliproxyapi', path: null };
|
|
16
|
-
}
|
|
80
|
+
const resolved = await getCommandPath('cliproxyapi');
|
|
81
|
+
return { cmd: 'cliproxyapi', path: resolved };
|
|
17
82
|
}
|
|
18
83
|
if (await hasCommand('cliproxy')) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return { cmd: 'cliproxy', path };
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
return { cmd: 'cliproxy', path: null };
|
|
25
|
-
}
|
|
84
|
+
const resolved = await getCommandPath('cliproxy');
|
|
85
|
+
return { cmd: 'cliproxy', path: resolved };
|
|
26
86
|
}
|
|
27
87
|
return { cmd: null, path: null };
|
|
28
88
|
}
|
|
@@ -57,21 +117,19 @@ export async function checkAuthConfigured() {
|
|
|
57
117
|
}
|
|
58
118
|
// Check auth via proxy status
|
|
59
119
|
let hasAuthEntries = false;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
hasAuthEntries = count > 0;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
catch (error) {
|
|
72
|
-
debugLog('Failed to check proxy status:', error);
|
|
120
|
+
try {
|
|
121
|
+
const proxyExe = await requireTrustedProxyCommand();
|
|
122
|
+
const output = await execCommand(proxyExe, ['status']);
|
|
123
|
+
// Match "N auth entries" or "N auth files" where N > 0
|
|
124
|
+
const match = output.match(/(\d+)\s+(auth entries|auth files)/);
|
|
125
|
+
if (match) {
|
|
126
|
+
const count = parseInt(match[1], 10);
|
|
127
|
+
hasAuthEntries = count > 0;
|
|
73
128
|
}
|
|
74
129
|
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
debugLog('Failed to check proxy status:', error);
|
|
132
|
+
}
|
|
75
133
|
// Check via API
|
|
76
134
|
let hasModels = false;
|
|
77
135
|
try {
|
|
@@ -96,36 +154,661 @@ export async function checkAuthConfigured() {
|
|
|
96
154
|
};
|
|
97
155
|
}
|
|
98
156
|
/**
|
|
99
|
-
*
|
|
157
|
+
* Compute SHA-256 hash of binary data
|
|
158
|
+
*/
|
|
159
|
+
function sha256Hex(data) {
|
|
160
|
+
return createHash('sha256').update(data).digest('hex');
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Parse checksum file to find expected hash for a specific file
|
|
164
|
+
* Supports common checksum formats:
|
|
165
|
+
* - SHA256SUMS: "a1b2c3... filename" or "a1b2c3... *filename"
|
|
166
|
+
* - checksums.txt: "a1b2c3... filename"
|
|
167
|
+
* Handles path prefixes: "./filename", "subdir/filename", "subdir\filename" (Windows)
|
|
168
|
+
* Prefers exact filename match over basename match to avoid collisions
|
|
169
|
+
*/
|
|
170
|
+
function parseExpectedSha256(content, fileName) {
|
|
171
|
+
// First pass: try exact filename match (with or without path separators)
|
|
172
|
+
for (const line of content.split('\n')) {
|
|
173
|
+
const trimmed = line.trim();
|
|
174
|
+
if (!trimmed)
|
|
175
|
+
continue;
|
|
176
|
+
// Match: hash followed by whitespace and filename (with optional * prefix)
|
|
177
|
+
const match = trimmed.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
|
|
178
|
+
if (match) {
|
|
179
|
+
const [, hash, name] = match;
|
|
180
|
+
const normalizedName = name.trim();
|
|
181
|
+
// Normalize path separators to / and strip any path prefix
|
|
182
|
+
const normalizedBase = normalizedName.replace(/\\/g, '/').replace(/^.*\//, '');
|
|
183
|
+
if (normalizedBase === fileName) {
|
|
184
|
+
// Found exact basename match
|
|
185
|
+
return hash.toLowerCase();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Fetch latest release info from GitHub API
|
|
193
|
+
* Returns the exact tag name to avoid moving 'latest' redirects
|
|
194
|
+
*/
|
|
195
|
+
async function getLatestReleaseTag() {
|
|
196
|
+
const apiUrl = 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest';
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(apiUrl, {
|
|
199
|
+
headers: {
|
|
200
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
201
|
+
'User-Agent': '@tuannvm/ccodex',
|
|
202
|
+
},
|
|
203
|
+
signal: AbortSignal.timeout(10000), // 10 second timeout
|
|
204
|
+
});
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`GitHub API returned ${response.status}`);
|
|
207
|
+
}
|
|
208
|
+
const data = await safeJsonParse(await response.text());
|
|
209
|
+
if (!data?.tag_name) {
|
|
210
|
+
throw new Error('Invalid GitHub API response');
|
|
211
|
+
}
|
|
212
|
+
return data.tag_name;
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
debugLog('Failed to fetch latest release tag:', error);
|
|
216
|
+
throw new Error(`Failed to resolve latest release tag from GitHub API: ${error instanceof Error ? error.message : String(error)}\n\n` +
|
|
217
|
+
'Please check your internet connection or install CLIProxyAPI manually.');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Check if an archive entry path is unsafe (contains path traversal or absolute paths)
|
|
222
|
+
*/
|
|
223
|
+
function isUnsafeArchivePath(raw) {
|
|
224
|
+
if (!raw)
|
|
225
|
+
return true;
|
|
226
|
+
// Normalize zip/tar separators and strip leading "./"
|
|
227
|
+
const p = raw.replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
228
|
+
if (!p)
|
|
229
|
+
return true;
|
|
230
|
+
// Reject absolute, drive-letter, UNC-like
|
|
231
|
+
if (p.startsWith('/') || /^[a-zA-Z]:\//.test(p) || p.startsWith('//'))
|
|
232
|
+
return true;
|
|
233
|
+
// Reject traversal segments
|
|
234
|
+
const parts = p.split('/').filter(Boolean);
|
|
235
|
+
if (parts.some(seg => seg === '..'))
|
|
236
|
+
return true;
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
// Resource limits for archive extraction (prevent tar/zip bombs)
|
|
240
|
+
const MAX_ENTRIES = 1000;
|
|
241
|
+
const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; // 100 MB compressed
|
|
242
|
+
const MAX_EXTRACTED_BYTES = 500 * 1024 * 1024; // 500 MB uncompressed
|
|
243
|
+
/**
|
|
244
|
+
* Parse tar verbose output line to extract path and type
|
|
245
|
+
* Handles GNU/BSD tar verbose output with special bits and spaces in filenames
|
|
246
|
+
* Returns null for lines that don't match expected tar verbose format (treated as suspicious)
|
|
247
|
+
*/
|
|
248
|
+
function parseTarVerboseLine(line) {
|
|
249
|
+
// Tar verbose format typically:
|
|
250
|
+
// permissions owner/group size date time... path
|
|
251
|
+
// Or with links: permissions ... path -> target
|
|
252
|
+
//
|
|
253
|
+
// We parse by:
|
|
254
|
+
// 1. Finding the path segment (everything after the date/time)
|
|
255
|
+
// 2. Checking the first character for file type
|
|
256
|
+
//
|
|
257
|
+
// Handle special bits that may appear: s/S (setuid/setgid), t/T (sticky), + (ACL), @ (extended attributes)
|
|
258
|
+
// The first character indicates type: - (file), d (dir), l (symlink), h (hardlink), c (char), b (block), p (fifo)
|
|
259
|
+
if (!line || line.length === 0)
|
|
260
|
+
return null;
|
|
261
|
+
const firstChar = line.charAt(0);
|
|
262
|
+
const fileTypeMap = {
|
|
263
|
+
'-': 'file',
|
|
264
|
+
'd': 'dir',
|
|
265
|
+
'l': 'symlink',
|
|
266
|
+
'h': 'hardlink',
|
|
267
|
+
'c': 'char',
|
|
268
|
+
'b': 'block',
|
|
269
|
+
'p': 'fifo',
|
|
270
|
+
};
|
|
271
|
+
const type = fileTypeMap[firstChar];
|
|
272
|
+
if (!type) {
|
|
273
|
+
// Unknown file type character - treat as suspicious
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
// Find the path by splitting on whitespace
|
|
277
|
+
// Tar verbose format fields are separated by whitespace
|
|
278
|
+
// The path is typically the last field (or second-to-last before "-> target")
|
|
279
|
+
const parts = line.split(/\s+/);
|
|
280
|
+
// For very long lines with many fields, the path might be split further
|
|
281
|
+
// Find the part that looks like a path (contains '/', or ends with ' -> ', or is just a name)
|
|
282
|
+
let pathWithTarget = '';
|
|
283
|
+
let foundPath = false;
|
|
284
|
+
// Iterate from the end to find the path
|
|
285
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
286
|
+
const part = parts[i];
|
|
287
|
+
if (part.includes('/') || part.includes(' -> ') || (part.length > 0 && !foundPath)) {
|
|
288
|
+
pathWithTarget = part + (pathWithTarget ? ' ' + pathWithTarget : '');
|
|
289
|
+
foundPath = true;
|
|
290
|
+
}
|
|
291
|
+
else if (foundPath) {
|
|
292
|
+
// We've collected all path parts
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (!pathWithTarget)
|
|
297
|
+
return null;
|
|
298
|
+
// Extract just the path (before " -> " for symlinks)
|
|
299
|
+
const arrowIndex = pathWithTarget.indexOf(' -> ');
|
|
300
|
+
const path = arrowIndex >= 0 ? pathWithTarget.substring(0, arrowIndex).trim() : pathWithTarget.trim();
|
|
301
|
+
if (!path)
|
|
302
|
+
return null;
|
|
303
|
+
return { path, type };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* List entries in a tar archive with resource limits and link type validation
|
|
307
|
+
*/
|
|
308
|
+
async function listTarEntries(archivePath) {
|
|
309
|
+
const fs = await import('fs/promises');
|
|
310
|
+
// Check archive file size before extraction
|
|
311
|
+
const archiveStat = await fs.stat(archivePath);
|
|
312
|
+
if (archiveStat.size > MAX_ARCHIVE_BYTES) {
|
|
313
|
+
throw new Error(`Archive file is too large (${(archiveStat.size / 1024 / 1024).toFixed(1)} MB > ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB). ` +
|
|
314
|
+
'This may be a tar bomb.');
|
|
315
|
+
}
|
|
316
|
+
// Use verbose mode with -z to explicitly handle gzip compression
|
|
317
|
+
// Some tar versions auto-detect compression, but -z ensures consistency
|
|
318
|
+
// Use trusted tar path to avoid PATH hijacking
|
|
319
|
+
const tarPath = await requireTrustedCommand('tar');
|
|
320
|
+
const result = await runCmdBounded(tarPath, ['-ztvf', archivePath], 30000); // 30 second timeout
|
|
321
|
+
if (result.code !== 0) {
|
|
322
|
+
throw new Error(`tar list failed with code ${result.code}`);
|
|
323
|
+
}
|
|
324
|
+
const lines = result.stdout.split('\n').map(s => s.trim()).filter(Boolean);
|
|
325
|
+
const entries = [];
|
|
326
|
+
// Resource limits: prevent tar/zip bombs
|
|
327
|
+
if (lines.length > MAX_ENTRIES) {
|
|
328
|
+
throw new Error(`Archive has too many entries (${lines.length} > ${MAX_ENTRIES}). This may be a tar bomb.`);
|
|
329
|
+
}
|
|
330
|
+
// Parse and validate each entry
|
|
331
|
+
for (const line of lines) {
|
|
332
|
+
const parsed = parseTarVerboseLine(line);
|
|
333
|
+
// Treat unparsable lines as suspicious - fail closed instead of skipping
|
|
334
|
+
if (!parsed) {
|
|
335
|
+
throw new Error(`Unrecognized tar output format (possible archive corruption): ${line.substring(0, 100)}`);
|
|
336
|
+
}
|
|
337
|
+
// Check for unsafe paths
|
|
338
|
+
if (isUnsafeArchivePath(parsed.path)) {
|
|
339
|
+
throw new Error(`Unsafe archive path: ${parsed.path}`);
|
|
340
|
+
}
|
|
341
|
+
// Reject symlinks and hardlinks for security
|
|
342
|
+
if (parsed.type === 'symlink' || parsed.type === 'hardlink') {
|
|
343
|
+
throw new Error(`Archive contains forbidden link entry: ${parsed.path} (${parsed.type})`);
|
|
344
|
+
}
|
|
345
|
+
// Reject character/block devices, fifos (unusual in CLIProxyAPI archives)
|
|
346
|
+
if (parsed.type === 'char' || parsed.type === 'block' || parsed.type === 'fifo') {
|
|
347
|
+
throw new Error(`Archive contains unusual entry type: ${parsed.path} (${parsed.type})`);
|
|
348
|
+
}
|
|
349
|
+
entries.push(parsed.path);
|
|
350
|
+
}
|
|
351
|
+
if (entries.length === 0) {
|
|
352
|
+
throw new Error('Archive is empty or contains no valid entries');
|
|
353
|
+
}
|
|
354
|
+
return entries;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Validate archive listing for unsafe paths and link types
|
|
358
|
+
* Note: Windows installation is not currently supported (throws early in installProxyApi)
|
|
359
|
+
*/
|
|
360
|
+
async function validateArchiveListing(archivePath) {
|
|
361
|
+
// listTarEntries now does all the validation (path safety, link rejection, resource limits)
|
|
362
|
+
await listTarEntries(archivePath);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Assert that all extracted files are confined within the target directory
|
|
366
|
+
* Validates that realpath of all files stays within the target directory
|
|
367
|
+
* Symlinks and hardlinks are already rejected during tar parsing
|
|
368
|
+
*/
|
|
369
|
+
async function assertRealpathConfinement(rootDir) {
|
|
370
|
+
const fs = await import('fs/promises');
|
|
371
|
+
const rootReal = await fs.realpath(rootDir);
|
|
372
|
+
const stack = [rootDir];
|
|
373
|
+
while (stack.length > 0) {
|
|
374
|
+
const cur = stack.pop();
|
|
375
|
+
const entries = await fs.readdir(cur, { withFileTypes: true });
|
|
376
|
+
for (const ent of entries) {
|
|
377
|
+
const full = join(cur, ent.name);
|
|
378
|
+
// Double-check for symlinks (defensive: should have been caught during tar parsing)
|
|
379
|
+
const lst = await fs.lstat(full);
|
|
380
|
+
if (lst.isSymbolicLink()) {
|
|
381
|
+
throw new Error(`Symlink not allowed in extracted archive: ${full}`);
|
|
382
|
+
}
|
|
383
|
+
// Verify realpath stays within target directory
|
|
384
|
+
const rp = await fs.realpath(full);
|
|
385
|
+
const confined = rp === rootReal || rp.startsWith(rootReal + sep);
|
|
386
|
+
if (!confined) {
|
|
387
|
+
throw new Error(`Extracted path escapes target directory: ${full}`);
|
|
388
|
+
}
|
|
389
|
+
if (ent.isDirectory()) {
|
|
390
|
+
stack.push(full);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Install CLIProxyAPI via Homebrew or Go binary fallback
|
|
100
397
|
*/
|
|
101
398
|
export async function installProxyApi() {
|
|
399
|
+
const { homedir } = await import('os');
|
|
400
|
+
const home = homedir();
|
|
401
|
+
if (!home) {
|
|
402
|
+
throw new Error('Cannot determine home directory. Please set HOME environment variable.');
|
|
403
|
+
}
|
|
404
|
+
// Install lock file path (in the target install directory)
|
|
405
|
+
const lockPath = join(home, '.local', 'bin', '.cliproxyapi.install.lock');
|
|
102
406
|
// Check platform
|
|
103
407
|
const platform = process.platform;
|
|
408
|
+
const arch = process.arch;
|
|
104
409
|
if (platform === 'win32') {
|
|
105
410
|
throw new Error('CLIProxyAPI installation on Windows requires manual setup.\n' +
|
|
106
411
|
'Please install CLIProxyAPI manually and ensure it\'s in your PATH.\n' +
|
|
107
412
|
'See CLIProxyAPI documentation for Windows installation instructions.');
|
|
108
413
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
414
|
+
// Wrap entire installation process (both Homebrew and Go binary paths) in lock
|
|
415
|
+
await withInstallLock(lockPath, async () => {
|
|
416
|
+
// Try Homebrew first (preferred)
|
|
417
|
+
if (await hasCommand('brew')) {
|
|
418
|
+
console.log('Installing CLIProxyAPI via Homebrew...');
|
|
419
|
+
const brewPath = await requireTrustedCommand('brew');
|
|
420
|
+
const spawnCmd = (await import('cross-spawn')).default;
|
|
421
|
+
try {
|
|
422
|
+
await new Promise((resolve, reject) => {
|
|
423
|
+
const child = spawnCmd(brewPath, ['install', 'cliproxyapi'], {
|
|
424
|
+
stdio: 'inherit',
|
|
425
|
+
});
|
|
426
|
+
child.on('close', (code) => {
|
|
427
|
+
if (code === 0) {
|
|
428
|
+
console.log('CLIProxyAPI installed successfully via Homebrew');
|
|
429
|
+
resolve();
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
reject(new Error('Failed to install CLIProxyAPI via Homebrew'));
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
child.on('error', (error) => reject(error));
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
123
438
|
}
|
|
124
|
-
|
|
125
|
-
|
|
439
|
+
catch (error) {
|
|
440
|
+
debugLog('Homebrew installation failed, falling back to Go binary:', error);
|
|
441
|
+
// Fall through to Go binary installation
|
|
126
442
|
}
|
|
127
|
-
}
|
|
128
|
-
|
|
443
|
+
}
|
|
444
|
+
// Fallback: Install Go binary directly
|
|
445
|
+
console.log('Installing CLIProxyAPI via Go binary...');
|
|
446
|
+
// Determine platform/arch for CLIProxyAPI release asset format
|
|
447
|
+
// CLIProxyAPI uses: CLIProxyAPI_{version}_{platform}_{arch}.{ext}
|
|
448
|
+
const platformMap = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
|
|
449
|
+
const archMap = { arm64: 'arm64', x64: 'amd64' };
|
|
450
|
+
const cliPlatform = platformMap[platform];
|
|
451
|
+
const cliArch = archMap[arch];
|
|
452
|
+
if (!cliPlatform) {
|
|
453
|
+
throw new Error(`Unsupported platform: ${platform}\n` +
|
|
454
|
+
`Supported platforms: ${Object.keys(platformMap).join(', ')}`);
|
|
455
|
+
}
|
|
456
|
+
if (!cliArch) {
|
|
457
|
+
throw new Error(`Unsupported architecture: ${arch}\n` +
|
|
458
|
+
`Supported architectures: ${Object.keys(archMap).join(', ')}`);
|
|
459
|
+
}
|
|
460
|
+
// Resolve exact release tag first for security (avoid moving 'latest' redirects)
|
|
461
|
+
console.log('Resolving latest release tag from GitHub API...');
|
|
462
|
+
const releaseTag = await getLatestReleaseTag();
|
|
463
|
+
console.log(`Latest release: ${releaseTag}`);
|
|
464
|
+
// Strip 'v' prefix from tag for version (e.g., v6.9.5 -> 6.9.5)
|
|
465
|
+
const version = releaseTag.startsWith('v') ? releaseTag.slice(1) : releaseTag;
|
|
466
|
+
// Determine archive extension
|
|
467
|
+
const isWindows = platform === 'win32';
|
|
468
|
+
const archiveExt = isWindows ? 'zip' : 'tar.gz';
|
|
469
|
+
const archiveFileName = `CLIProxyAPI_${version}_${cliPlatform}_${cliArch}.${archiveExt}`;
|
|
470
|
+
const installDir = join(home, '.local', 'bin');
|
|
471
|
+
const binaryPath = join(installDir, isWindows ? 'cliproxyapi.exe' : 'cliproxyapi');
|
|
472
|
+
// Use crypto.randomUUID() for temp files to avoid collision in concurrent installs
|
|
473
|
+
const randomSuffix = randomUUID();
|
|
474
|
+
const archivePath = join(installDir, `cliproxyapi-${randomSuffix}.${archiveExt}`);
|
|
475
|
+
const extractDir = join(installDir, `cliproxyapi-extract-${randomSuffix}`);
|
|
476
|
+
// Ensure install directory exists
|
|
477
|
+
await ensureDir(installDir);
|
|
478
|
+
const baseUrl = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${releaseTag}`;
|
|
479
|
+
const archiveUrl = `${baseUrl}/${archiveFileName}`;
|
|
480
|
+
console.log(`Downloading ${archiveFileName} from GitHub releases...`);
|
|
481
|
+
const fs = await import('fs/promises');
|
|
482
|
+
try {
|
|
483
|
+
// Download archive with streaming and byte limits
|
|
484
|
+
console.log(`Downloading ${archiveFileName} from GitHub releases...`);
|
|
485
|
+
const controller = new AbortController();
|
|
486
|
+
const timeout = setTimeout(() => controller.abort(), 120000); // 120 second timeout
|
|
487
|
+
let response;
|
|
488
|
+
try {
|
|
489
|
+
response = await fetch(archiveUrl, { signal: controller.signal });
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
clearTimeout(timeout);
|
|
493
|
+
}
|
|
494
|
+
if (!response.ok) {
|
|
495
|
+
if (response.status === 404) {
|
|
496
|
+
// 404 means the archive doesn't exist for this platform/arch in this release
|
|
497
|
+
throw new Error(`Archive not found for ${cliPlatform}_${cliArch}\n` +
|
|
498
|
+
`URL: ${archiveUrl}\n\n` +
|
|
499
|
+
`This could mean:\n` +
|
|
500
|
+
` - ${cliPlatform}_${cliArch} archives are not available in release ${releaseTag}\n` +
|
|
501
|
+
` - Check the CLIProxyAPI releases page for available platforms\n\n` +
|
|
502
|
+
`Suggested alternatives:\n` +
|
|
503
|
+
` 1. Try Homebrew installation: brew install cliproxyapi\n` +
|
|
504
|
+
` 2. Check available releases: ${baseUrl}\n` +
|
|
505
|
+
` 3. Download manually from: https://github.com/router-for-me/CLIProxyAPI/releases\n\n` +
|
|
506
|
+
`Available platforms for CLIProxyAPI may vary by release.`);
|
|
507
|
+
}
|
|
508
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}\n` +
|
|
509
|
+
`URL: ${archiveUrl}\n` +
|
|
510
|
+
`Platform/Arch: ${cliPlatform}_${cliArch}`);
|
|
511
|
+
}
|
|
512
|
+
// Pre-check content-length if available
|
|
513
|
+
const contentLength = Number(response.headers.get('content-length') || 0);
|
|
514
|
+
if (contentLength > MAX_ARCHIVE_BYTES) {
|
|
515
|
+
throw new Error(`Archive too large (Content-Length: ${(contentLength / 1024 / 1024).toFixed(1)} MB > ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB). ` +
|
|
516
|
+
'This may be a tar bomb.');
|
|
517
|
+
}
|
|
518
|
+
// Stream download with byte limit and incremental hash
|
|
519
|
+
if (!response.body) {
|
|
520
|
+
throw new Error('Response body is null');
|
|
521
|
+
}
|
|
522
|
+
const fileHandle = await fs.open(archivePath, 'w');
|
|
523
|
+
const hash = createHash('sha256');
|
|
524
|
+
let downloadedBytes = 0;
|
|
525
|
+
try {
|
|
526
|
+
for await (const chunk of response.body) {
|
|
527
|
+
downloadedBytes += chunk.byteLength;
|
|
528
|
+
if (downloadedBytes > MAX_ARCHIVE_BYTES) {
|
|
529
|
+
throw new Error(`Archive exceeded size limit during download (${(downloadedBytes / 1024 / 1024).toFixed(1)} MB > ${MAX_ARCHIVE_BYTES / 1024 / 1024} MB). ` +
|
|
530
|
+
'This may be a tar bomb.');
|
|
531
|
+
}
|
|
532
|
+
hash.update(chunk);
|
|
533
|
+
await fileHandle.write(chunk);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
await fileHandle.close();
|
|
538
|
+
}
|
|
539
|
+
console.log(`Downloaded ${(downloadedBytes / 1024 / 1024).toFixed(1)} MB`);
|
|
540
|
+
// Get the calculated hash
|
|
541
|
+
const actualHash = hash.digest('hex');
|
|
542
|
+
// Try to download and verify checksums file
|
|
543
|
+
let checksumVerified = false;
|
|
544
|
+
let checksumMismatchError = null;
|
|
545
|
+
const checksumUrls = [
|
|
546
|
+
`${baseUrl}/SHA256SUMS`,
|
|
547
|
+
`${baseUrl}/checksums.txt`,
|
|
548
|
+
`${baseUrl}/checksums.sha256`,
|
|
549
|
+
];
|
|
550
|
+
for (const checksumUrl of checksumUrls) {
|
|
551
|
+
try {
|
|
552
|
+
console.log(`Attempting checksum verification from: ${new URL(checksumUrl).pathname}`);
|
|
553
|
+
const checksumResponse = await fetch(checksumUrl, { signal: AbortSignal.timeout(10000) });
|
|
554
|
+
if (checksumResponse.ok) {
|
|
555
|
+
const checksumContent = await checksumResponse.text();
|
|
556
|
+
const expectedHash = parseExpectedSha256(checksumContent, archiveFileName);
|
|
557
|
+
if (expectedHash) {
|
|
558
|
+
if (actualHash === expectedHash) {
|
|
559
|
+
console.log(chalk.green('✓ Checksum verification passed'));
|
|
560
|
+
checksumVerified = true;
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
// Checksum mismatch - this is FATAL, do not continue
|
|
565
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
566
|
+
checksumMismatchError = new Error(`Checksum verification failed!\n` +
|
|
567
|
+
`Expected: ${expectedHash}\n` +
|
|
568
|
+
`Actual: ${actualHash}\n\n` +
|
|
569
|
+
`The downloaded archive may be corrupted or tampered with.\n` +
|
|
570
|
+
`Please try again or install CLIProxyAPI manually.`);
|
|
571
|
+
break; // Exit loop immediately on mismatch
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch (checksumError) {
|
|
577
|
+
// Only catch network/parsing errors - let checksum mismatches fail hard
|
|
578
|
+
const errorMsg = checksumError instanceof Error ? checksumError.message : String(checksumError);
|
|
579
|
+
if (errorMsg.includes('Checksum verification failed')) {
|
|
580
|
+
// Re-throw checksum mismatch errors
|
|
581
|
+
throw checksumError;
|
|
582
|
+
}
|
|
583
|
+
debugLog(`Checksum verification failed for ${checksumUrl}:`, checksumError);
|
|
584
|
+
// Try next checksum URL on network errors
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// If we found a checksum mismatch, fail hard
|
|
588
|
+
if (checksumMismatchError) {
|
|
589
|
+
throw checksumMismatchError;
|
|
590
|
+
}
|
|
591
|
+
// SECURITY: Fail closed if checksum verification is not available
|
|
592
|
+
// This prevents installation of potentially tampered binaries
|
|
593
|
+
if (!checksumVerified) {
|
|
594
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
595
|
+
throw new Error(chalk.red('Checksum verification required but failed.\n\n') +
|
|
596
|
+
'The downloaded archive could not be verified against a checksum file.\n' +
|
|
597
|
+
'This is a security requirement to prevent installation of tampered binaries.\n\n' +
|
|
598
|
+
'Possible reasons:\n' +
|
|
599
|
+
' - Network issues prevented checksum file download\n' +
|
|
600
|
+
' - Checksum files are not published for this release\n' +
|
|
601
|
+
' - GitHub releases are temporarily unavailable\n\n' +
|
|
602
|
+
'To install CLIProxyAPI safely:\n' +
|
|
603
|
+
` 1. Visit ${baseUrl}/\n` +
|
|
604
|
+
' 2. Download the archive and checksum files manually\n' +
|
|
605
|
+
' 3. Verify the checksums match\n' +
|
|
606
|
+
' 4. Extract the archive\n' +
|
|
607
|
+
' 5. Place the binary in a directory in your PATH\n' +
|
|
608
|
+
' 6. Make it executable: chmod +x cliproxyapi\n\n' +
|
|
609
|
+
'Then run ccodex again.');
|
|
610
|
+
}
|
|
611
|
+
// Archive was already written to disk during streaming download
|
|
612
|
+
// Extract archive using hardened extraction strategy
|
|
613
|
+
console.log(`Extracting ${archiveExt} archive...`);
|
|
614
|
+
await ensureDir(extractDir);
|
|
615
|
+
// Preflight: validate archive listing before extraction
|
|
616
|
+
console.log('Validating archive contents...');
|
|
617
|
+
await validateArchiveListing(archivePath);
|
|
618
|
+
try {
|
|
619
|
+
// Unix/macOS: use tar with portable hardened flags
|
|
620
|
+
// Note: --no-same-owner and --no-same-permissions are supported by both GNU and BSD tar
|
|
621
|
+
// We avoid GNU-specific flags like --delay-directory-restore for macOS compatibility
|
|
622
|
+
// Use bounded execution with timeout (60 seconds for extraction) and trusted tar path
|
|
623
|
+
const tarPath = await requireTrustedCommand('tar');
|
|
624
|
+
const result = await runCmdBounded(tarPath, [
|
|
625
|
+
'-xzf', archivePath,
|
|
626
|
+
'-C', extractDir,
|
|
627
|
+
'--no-same-owner',
|
|
628
|
+
'--no-same-permissions',
|
|
629
|
+
], 60000);
|
|
630
|
+
if (result.code !== 0) {
|
|
631
|
+
throw new Error(`tar extraction failed with code ${result.code}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch (extractError) {
|
|
635
|
+
// Clean up on extraction failure
|
|
636
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
637
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
638
|
+
throw new Error(`Failed to extract archive: ${extractError instanceof Error ? extractError.message : String(extractError)}\n\n` +
|
|
639
|
+
'The archive may be corrupted or incompatible with your system.');
|
|
640
|
+
}
|
|
641
|
+
// Post-extraction: validate extracted size (prevent zip bomb)
|
|
642
|
+
console.log('Validating extracted size...');
|
|
643
|
+
let extractedBytes = 0;
|
|
644
|
+
async function getDirSize(dir) {
|
|
645
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
646
|
+
let size = 0;
|
|
647
|
+
for (const ent of entries) {
|
|
648
|
+
const full = join(dir, ent.name);
|
|
649
|
+
const stat = await fs.stat(full);
|
|
650
|
+
if (ent.isDirectory()) {
|
|
651
|
+
size += await getDirSize(full);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
size += stat.size;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return size;
|
|
658
|
+
}
|
|
659
|
+
try {
|
|
660
|
+
extractedBytes = await getDirSize(extractDir);
|
|
661
|
+
if (extractedBytes > MAX_EXTRACTED_BYTES) {
|
|
662
|
+
throw new Error(`Extracted content is too large (${(extractedBytes / 1024 / 1024).toFixed(1)} MB > ${MAX_EXTRACTED_BYTES / 1024 / 1024} MB). ` +
|
|
663
|
+
'This may be a zip bomb.');
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
catch (sizeError) {
|
|
667
|
+
// Clean up on size check failure
|
|
668
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
669
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
670
|
+
throw sizeError;
|
|
671
|
+
}
|
|
672
|
+
// Post-extraction: validate realpath confinement
|
|
673
|
+
// This detects path traversal via symlinks, hardlinks, or other escape mechanisms
|
|
674
|
+
console.log('Validating extraction safety...');
|
|
675
|
+
try {
|
|
676
|
+
await assertRealpathConfinement(extractDir);
|
|
677
|
+
}
|
|
678
|
+
catch (confinementError) {
|
|
679
|
+
// Clean up on confinement failure
|
|
680
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
681
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
682
|
+
throw confinementError;
|
|
683
|
+
}
|
|
684
|
+
// Find the extracted binary
|
|
685
|
+
// CLIProxyAPI archives contain a single binary named 'CLIProxyAPI' (or 'CLIProxyAPI.exe')
|
|
686
|
+
const extractedFiles = await fs.readdir(extractDir);
|
|
687
|
+
const extractedBinary = extractedFiles.find(f => isWindows ? f === 'CLIProxyAPI.exe' : f === 'CLIProxyAPI');
|
|
688
|
+
if (!extractedBinary) {
|
|
689
|
+
// Clean up
|
|
690
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
691
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
692
|
+
throw new Error(`Could not find CLIProxyAPI binary in extracted archive.\n` +
|
|
693
|
+
`Files found: ${extractedFiles.join(', ')}\n\n` +
|
|
694
|
+
'The archive format may have changed. Please report this issue.');
|
|
695
|
+
}
|
|
696
|
+
const extractedBinaryPath = join(extractDir, extractedBinary);
|
|
697
|
+
// Set executable permission on extracted binary before validation (Unix/macOS only)
|
|
698
|
+
if (!isWindows) {
|
|
699
|
+
try {
|
|
700
|
+
await fs.chmod(extractedBinaryPath, 0o755);
|
|
701
|
+
}
|
|
702
|
+
catch (chmodError) {
|
|
703
|
+
debugLog('Warning: Could not set executable permission on extracted binary:', chmodError);
|
|
704
|
+
// Continue anyway - the archive may already have execute bits set
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Validate the extracted binary works by running it
|
|
708
|
+
console.log('Validating extracted binary...');
|
|
709
|
+
try {
|
|
710
|
+
const testResult = await runCmdBounded(extractedBinaryPath, ['--version'], 5000);
|
|
711
|
+
// Fail on non-zero exit
|
|
712
|
+
if (testResult.code !== 0) {
|
|
713
|
+
throw new Error(`Binary validation failed: exited with code ${testResult.code}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
catch (validationError) {
|
|
717
|
+
// Clean up invalid binary
|
|
718
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
719
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
720
|
+
throw new Error(`Extracted binary failed validation: ${validationError instanceof Error ? validationError.message : String(validationError)}\n\n` +
|
|
721
|
+
'The binary may be corrupted or incompatible with your system.');
|
|
722
|
+
}
|
|
723
|
+
// Backup existing binary if present, but be ready to rollback
|
|
724
|
+
let backupPath = null;
|
|
725
|
+
let didBackup = false;
|
|
726
|
+
if (await fileExists(binaryPath)) {
|
|
727
|
+
backupPath = `${binaryPath}.backup.${randomUUID()}`;
|
|
728
|
+
await fs.rename(binaryPath, backupPath);
|
|
729
|
+
didBackup = true;
|
|
730
|
+
}
|
|
731
|
+
// Copy extracted binary to final location
|
|
732
|
+
try {
|
|
733
|
+
await fs.copyFile(extractedBinaryPath, binaryPath);
|
|
734
|
+
// Set executable permission on Unix/macOS
|
|
735
|
+
if (!isWindows) {
|
|
736
|
+
await fs.chmod(binaryPath, 0o755);
|
|
737
|
+
}
|
|
738
|
+
// Store the installed path for this process
|
|
739
|
+
installedProxyPath = binaryPath;
|
|
740
|
+
}
|
|
741
|
+
catch (copyError) {
|
|
742
|
+
// Rollback: restore backup if we had one
|
|
743
|
+
if (didBackup && backupPath) {
|
|
744
|
+
try {
|
|
745
|
+
await fs.rename(backupPath, binaryPath);
|
|
746
|
+
}
|
|
747
|
+
catch (rollbackError) {
|
|
748
|
+
debugLog('Failed to rollback after copy failure:', rollbackError);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Clean up
|
|
752
|
+
await fs.unlink(archivePath).catch(() => { });
|
|
753
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => { });
|
|
754
|
+
throw new Error(`Failed to copy binary to final location: ${copyError instanceof Error ? copyError.message : String(copyError)}`);
|
|
755
|
+
}
|
|
756
|
+
// Clean up on success
|
|
757
|
+
await fs.unlink(archivePath).catch((err) => {
|
|
758
|
+
debugLog('Warning: Failed to cleanup archive file:', err);
|
|
759
|
+
});
|
|
760
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch((err) => {
|
|
761
|
+
debugLog('Warning: Failed to cleanup extract directory:', err);
|
|
762
|
+
});
|
|
763
|
+
// Clean up backup on success
|
|
764
|
+
if (backupPath) {
|
|
765
|
+
await fs.unlink(backupPath).catch((err) => {
|
|
766
|
+
debugLog('Warning: Failed to cleanup backup file:', err);
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
console.log(`CLIProxyAPI installed successfully to: ${binaryPath}`);
|
|
770
|
+
// Check if install dir is in PATH (use platform-specific delimiter and case-insensitive on Windows)
|
|
771
|
+
const pathEnv = process.env.PATH || '';
|
|
772
|
+
// Filter empty segments to avoid false positives from '::' in PATH
|
|
773
|
+
const pathDirs = pathEnv.split(delimiter).filter(p => p.length > 0);
|
|
774
|
+
// Normalize paths for comparison: resolve to absolute paths, normalize separators, case-insensitive on Windows
|
|
775
|
+
const normalizePath = (p) => {
|
|
776
|
+
const resolved = resolve(p);
|
|
777
|
+
const normalized = normalize(resolved);
|
|
778
|
+
return isWindows ? normalized.toLowerCase() : normalized;
|
|
779
|
+
};
|
|
780
|
+
const binInPath = pathDirs.some(dir => normalizePath(dir) === normalizePath(installDir));
|
|
781
|
+
if (!binInPath) {
|
|
782
|
+
console.log('');
|
|
783
|
+
console.log('⚠️ WARNING: ~/.local/bin is not in your PATH');
|
|
784
|
+
console.log('');
|
|
785
|
+
console.log('To use ccodex, add ~/.local/bin to your PATH:');
|
|
786
|
+
console.log('');
|
|
787
|
+
console.log(' For bash (add to ~/.bashrc):');
|
|
788
|
+
console.log(' export PATH="$HOME/.local/bin:$PATH"');
|
|
789
|
+
console.log('');
|
|
790
|
+
console.log(' For zsh (add to ~/.zshrc):');
|
|
791
|
+
console.log(' export PATH="$HOME/.local/bin:$PATH"');
|
|
792
|
+
console.log('');
|
|
793
|
+
console.log('Then reload your shell: source ~/.bashrc (or ~/.zshrc)');
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
// Clean up archive and extract dir on error
|
|
798
|
+
await fs.unlink(archivePath).catch((err) => {
|
|
799
|
+
debugLog('Warning: Failed to cleanup archive file during error handling:', err);
|
|
800
|
+
});
|
|
801
|
+
await fs.rm(extractDir, { recursive: true, force: true }).catch((err) => {
|
|
802
|
+
debugLog('Warning: Failed to cleanup extract directory during error handling:', err);
|
|
803
|
+
});
|
|
804
|
+
throw new Error(`Failed to install CLIProxyAPI: ${error instanceof Error ? error.message : String(error)}\n\n` +
|
|
805
|
+
'Please install CLIProxyAPI manually:\n' +
|
|
806
|
+
' 1. Visit https://github.com/router-for-me/CLIProxyAPI/releases\n' +
|
|
807
|
+
` 2. Download ${archiveFileName} for your system\n` +
|
|
808
|
+
' 3. Extract the archive\n' +
|
|
809
|
+
' 4. Place the binary in a directory in your PATH\n' +
|
|
810
|
+
' 5. Make it executable: chmod +x cliproxyapi');
|
|
811
|
+
}
|
|
129
812
|
});
|
|
130
813
|
}
|
|
131
814
|
/**
|
|
@@ -135,14 +818,10 @@ export async function startProxy() {
|
|
|
135
818
|
if (await isProxyRunning()) {
|
|
136
819
|
return;
|
|
137
820
|
}
|
|
138
|
-
const
|
|
139
|
-
const proxyCmd = cmdResult.cmd;
|
|
140
|
-
if (!proxyCmd) {
|
|
141
|
-
throw new Error('CLIProxyAPI not found. Run: npx -y @tuannvm/ccodex');
|
|
142
|
-
}
|
|
821
|
+
const proxyExe = await requireTrustedProxyCommand();
|
|
143
822
|
console.log('Starting CLIProxyAPI in background...');
|
|
144
823
|
const logFile = getLogFilePath();
|
|
145
|
-
await ensureDir(
|
|
824
|
+
await ensureDir(dirname(logFile));
|
|
146
825
|
const { spawn } = await import('child_process');
|
|
147
826
|
const fs = await import('fs/promises');
|
|
148
827
|
let out = null;
|
|
@@ -159,7 +838,7 @@ export async function startProxy() {
|
|
|
159
838
|
debugLog('Warning: Could not set restrictive permissions on log file');
|
|
160
839
|
}
|
|
161
840
|
}
|
|
162
|
-
const child = spawn(
|
|
841
|
+
const child = spawn(proxyExe, [], {
|
|
163
842
|
detached: true,
|
|
164
843
|
stdio: ['ignore', out.fd, out.fd],
|
|
165
844
|
});
|
|
@@ -191,15 +870,11 @@ export async function startProxy() {
|
|
|
191
870
|
* Launch OAuth login
|
|
192
871
|
*/
|
|
193
872
|
export async function launchLogin() {
|
|
194
|
-
const
|
|
195
|
-
const proxyCmd = cmdResult.cmd;
|
|
196
|
-
if (!proxyCmd) {
|
|
197
|
-
throw new Error('CLIProxyAPI not found. Run: npx -y @tuannvm/ccodex');
|
|
198
|
-
}
|
|
873
|
+
const proxyExe = await requireTrustedProxyCommand();
|
|
199
874
|
console.log('Launching ChatGPT/Codex OAuth login in browser...');
|
|
200
875
|
const spawnCmd = (await import('cross-spawn')).default;
|
|
201
876
|
return new Promise((resolve, reject) => {
|
|
202
|
-
const child = spawnCmd(
|
|
877
|
+
const child = spawnCmd(proxyExe, ['-codex-login'], {
|
|
203
878
|
stdio: 'inherit',
|
|
204
879
|
});
|
|
205
880
|
child.on('close', (code) => {
|