@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/proxy.js CHANGED
@@ -1,9 +1,9 @@
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';
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, '.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
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 === 'win32') {
24
- allowed.push(join(process.env.LOCALAPPDATA || '', 'Programs'), join(process.env.APPDATA || '', 'Programs'));
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 realPath = resolve(proxyPath);
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(', ')}\n\n` +
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('CLIProxyAPI not found. Install it first:\n' +
64
- ' 1. Run: npx -y @tuannvm/ccodex\n' +
65
- ' 2. Or install manually: brew install cliproxyapi');
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: 'cliproxyapi', path: installedProxyPath };
81
+ return { cmd: "cli-proxy-api", path: installedProxyPath };
78
82
  }
79
- if (await hasCommand('cliproxyapi')) {
80
- const resolved = await getCommandPath('cliproxyapi');
81
- return { cmd: 'cliproxyapi', path: resolved };
82
- }
83
- if (await hasCommand('cliproxy')) {
84
- const resolved = await getCommandPath('cliproxy');
85
- return { cmd: 'cliproxy', path: resolved };
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('fs/promises');
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('codex-') && f.endsWith('.json'));
120
+ hasAuthFiles = files.some((f) => f.startsWith("codex-") && f.endsWith(".json"));
113
121
  }
114
122
  catch {
115
123
  // Directory doesn't exist
116
- debugLog('Auth directory does not exist:', authDir);
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, ['status']);
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('Failed to check proxy status:', error);
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 === 'list' && Array.isArray(data.data) && data.data.length > 0;
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('Failed to check models via API');
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('\n')) {
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, '/').replace(/^.*\//, '');
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 = 'https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest';
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
- 'Accept': 'application/vnd.github.v3+json',
201
- 'User-Agent': '@tuannvm/ccodex',
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('Invalid GitHub API response');
212
+ throw new Error("Invalid GitHub API response");
211
213
  }
212
214
  return data.tag_name;
213
215
  }
214
216
  catch (error) {
215
- debugLog('Failed to fetch latest release tag:', error);
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
- 'Please check your internet connection or install CLIProxyAPI manually.');
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, '/').replace(/^\.\/+/, '');
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('/') || /^[a-zA-Z]:\//.test(p) || 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('/').filter(Boolean);
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
- '-': 'file',
264
- 'd': 'dir',
265
- 'l': 'symlink',
266
- 'h': 'hardlink',
267
- 'c': 'char',
268
- 'b': 'block',
269
- 'p': 'fifo',
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('/') || part.includes(' -> ') || (part.length > 0 && !foundPath)) {
288
- pathWithTarget = part + (pathWithTarget ? ' ' + 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('fs/promises');
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
- 'This may be a tar bomb.');
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('tar');
320
- const result = await runCmdBounded(tarPath, ['-ztvf', archivePath], 30000); // 30 second timeout
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.split('\n').map(s => s.trim()).filter(Boolean);
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 === 'symlink' || parsed.type === 'hardlink') {
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 === 'char' || parsed.type === 'block' || parsed.type === 'fifo') {
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('Archive is empty or contains no valid entries');
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('fs/promises');
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('os');
422
+ const { homedir } = await import("os");
400
423
  const home = homedir();
401
424
  if (!home) {
402
- throw new Error('Cannot determine home directory. Please set HOME environment variable.');
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, '.local', 'bin', '.cliproxyapi.install.lock');
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 === 'win32') {
410
- throw new Error('CLIProxyAPI installation on Windows requires manual setup.\n' +
411
- 'Please install CLIProxyAPI manually and ensure it\'s in your PATH.\n' +
412
- 'See CLIProxyAPI documentation for Windows installation instructions.');
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('brew')) {
418
- console.log('Installing CLIProxyAPI via Homebrew...');
419
- const brewPath = await requireTrustedCommand('brew');
420
- const spawnCmd = (await import('cross-spawn')).default;
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, ['install', 'cliproxyapi'], {
424
- stdio: 'inherit',
449
+ const child = spawnCmd(brewPath, ["install", "cli-proxy-api"], {
450
+ stdio: "inherit",
425
451
  });
426
- child.on('close', (code) => {
452
+ child.on("close", (code) => {
427
453
  if (code === 0) {
428
- console.log('CLIProxyAPI installed successfully via Homebrew');
454
+ console.log("CLIProxyAPI installed successfully via Homebrew");
429
455
  resolve();
430
456
  }
431
457
  else {
432
- reject(new Error('Failed to install CLIProxyAPI via Homebrew'));
458
+ reject(new Error("Failed to install CLIProxyAPI via Homebrew"));
433
459
  }
434
460
  });
435
- child.on('error', (error) => reject(error));
461
+ child.on("error", (error) => reject(error));
436
462
  });
437
463
  return;
438
464
  }
439
465
  catch (error) {
440
- debugLog('Homebrew installation failed, falling back to Go binary:', error);
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('Installing CLIProxyAPI via Go binary...');
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 = { darwin: 'darwin', linux: 'linux', win32: 'windows' };
449
- const archMap = { arm64: 'arm64', x64: 'amd64' };
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('Resolving latest release tag from GitHub API...');
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('v') ? releaseTag.slice(1) : releaseTag;
495
+ const version = releaseTag.startsWith("v") ? releaseTag.slice(1) : releaseTag;
466
496
  // Determine archive extension
467
- const isWindows = platform === 'win32';
468
- const archiveExt = isWindows ? 'zip' : 'tar.gz';
497
+ const isWindows = platform === "win32";
498
+ const archiveExt = isWindows ? "zip" : "tar.gz";
469
499
  const archiveFileName = `CLIProxyAPI_${version}_${cliPlatform}_${cliArch}.${archiveExt}`;
470
- const installDir = join(home, '.local', 'bin');
471
- const binaryPath = join(installDir, isWindows ? 'cliproxyapi.exe' : 'cliproxyapi');
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, `cliproxyapi-${randomSuffix}.${archiveExt}`);
475
- const extractDir = join(installDir, `cliproxyapi-extract-${randomSuffix}`);
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('fs/promises');
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 cliproxyapi\n` +
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('content-length') || 0);
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
- 'This may be a tar bomb.');
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('Response body is null');
549
+ throw new Error("Response body is null");
521
550
  }
522
- const fileHandle = await fs.open(archivePath, 'w');
523
- const hash = createHash('sha256');
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
- 'This may be a tar bomb.');
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('hex');
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('✓ Checksum verification passed'));
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('Checksum verification failed')) {
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('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' +
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
- ' 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.');
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('Validating archive contents...');
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('tar');
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
- 'The archive may be corrupted or incompatible with your system.');
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('Validating extracted size...');
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
- 'This may be a zip bomb.');
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('Validating extraction safety...');
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 single binary named 'CLIProxyAPI' (or 'CLIProxyAPI.exe')
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 extractedBinary = extractedFiles.find(f => isWindows ? f === 'CLIProxyAPI.exe' : f === 'CLIProxyAPI');
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(', ')}\n\n` +
694
- 'The archive format may have changed. Please report this issue.');
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('Warning: Could not set executable permission on extracted binary:', chmodError);
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('Validating extracted binary...');
721
+ console.log("Validating extracted binary...");
709
722
  try {
710
- const testResult = await runCmdBounded(extractedBinaryPath, ['--version'], 5000);
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
- 'The binary may be corrupted or incompatible with your system.');
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('Failed to rollback after copy failure:', rollbackError);
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('Warning: Failed to cleanup archive file:', err);
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('Warning: Failed to cleanup extract directory:', err);
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('Warning: Failed to cleanup backup file:', err);
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('⚠️ 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):');
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(' For zsh (add to ~/.zshrc):');
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('Then reload your shell: source ~/.bashrc (or ~/.zshrc)');
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('Warning: Failed to cleanup archive file during error handling:', err);
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('Warning: Failed to cleanup extract directory during error handling:', err);
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
- 'Please install CLIProxyAPI manually:\n' +
806
- ' 1. Visit https://github.com/router-for-me/CLIProxyAPI/releases\n' +
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
- ' 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');
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('Starting CLIProxyAPI in background...');
835
+ console.log("Starting CLIProxyAPI in background...");
823
836
  const logFile = getLogFilePath();
824
837
  await ensureDir(dirname(logFile));
825
- const { spawn } = await import('child_process');
826
- const fs = await import('fs/promises');
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, 'a');
843
+ out = await fs.open(logFile, "a");
831
844
  // Set restrictive permissions on Unix/macOS (0600 = user read/write only)
832
- if (process.platform !== 'win32') {
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('Warning: Could not set restrictive permissions on log file');
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: ['ignore', out.fd, out.fd],
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('error', (error) => {
860
+ child.once("error", (error) => {
848
861
  reject(new Error(`Failed to start CLIProxyAPI: ${error.message}`));
849
862
  });
850
- child.once('spawn', () => resolve());
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('CLIProxyAPI is running.');
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('Launching ChatGPT/Codex OAuth login in browser...');
875
- const spawnCmd = (await import('cross-spawn')).default;
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, ['-codex-login'], {
878
- stdio: 'inherit',
890
+ const child = spawnCmd(proxyExe, ["-codex-login"], {
891
+ stdio: "inherit",
879
892
  });
880
- child.on('close', (code) => {
893
+ child.on("close", (code) => {
881
894
  if (code === 0) {
882
895
  resolve();
883
896
  }
884
897
  else {
885
- reject(new Error('Login failed'));
898
+ reject(new Error("Login failed"));
886
899
  }
887
900
  });
888
- child.on('error', (error) => reject(error));
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('Waiting for authentication...');
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('Authentication configured.');
913
+ console.log("Authentication configured.");
901
914
  return;
902
915
  }
903
916
  }
904
- throw new Error('Authentication still not configured after login.');
917
+ throw new Error("Authentication still not configured after login.");
905
918
  }
906
919
  //# sourceMappingURL=proxy.js.map