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