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