mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #121 from kaitranntt/dev
feat(release): v6.0.0 - privacy mode and CLIProxy stability
This commit is contained in:
18 files changed
+894
-113
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "6.0.0",
|
||||
"version": "6.0.0-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -45,6 +45,13 @@ import {
|
||||
installWebSearchHook,
|
||||
displayWebSearchStatus,
|
||||
} from '../utils/websearch-manager';
|
||||
import {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
hasActiveSessions,
|
||||
cleanupOrphanedSessions,
|
||||
} from './session-tracker';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -303,85 +310,116 @@ export async function execClaudeWithCLIProxy(
|
||||
const configPath = generateConfig(provider, cfg.port);
|
||||
log(`Config written: ${configPath}`);
|
||||
|
||||
// 6a. Pre-flight check: ensure port is free (auto-kill zombie CLIProxy)
|
||||
const portProcess = await getPortProcess(cfg.port);
|
||||
if (portProcess) {
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
// Zombie CLIProxy from previous run - auto-kill it
|
||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||
const killed = killProcessOnPort(cfg.port, verbose);
|
||||
if (killed) {
|
||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||
// Wait a bit for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
// 6a. Pre-flight check: handle existing proxy or port conflicts
|
||||
// Clean up orphaned sessions first (from crashed proxies)
|
||||
cleanupOrphanedSessions(cfg.port);
|
||||
|
||||
// Check if there's an existing healthy proxy we can reuse
|
||||
const existingProxy = getExistingProxy(cfg.port);
|
||||
let proxy: ChildProcess | null = null;
|
||||
let sessionId: string;
|
||||
let isReusingProxy = false;
|
||||
|
||||
if (existingProxy) {
|
||||
// Reuse existing proxy - another CCS session started it
|
||||
log(`Reusing existing CLIProxy on port ${cfg.port} (PID ${existingProxy.pid})`);
|
||||
sessionId = registerSession(cfg.port, existingProxy.pid);
|
||||
isReusingProxy = true;
|
||||
console.log(
|
||||
info(`Joined existing CLIProxy (${existingProxy.sessions.length + 1} sessions active)`)
|
||||
);
|
||||
} else {
|
||||
// No existing proxy - check if port is free
|
||||
const portProcess = await getPortProcess(cfg.port);
|
||||
if (portProcess) {
|
||||
if (isCLIProxyProcess(portProcess)) {
|
||||
// CLIProxy on port but no session lock - likely orphaned/zombie
|
||||
// Only kill if no active sessions registered
|
||||
if (!hasActiveSessions()) {
|
||||
log(`Found zombie CLIProxy on port ${cfg.port} (PID ${portProcess.pid}), killing...`);
|
||||
const killed = killProcessOnPort(cfg.port, verbose);
|
||||
if (killed) {
|
||||
console.log(info(`Cleaned up zombie CLIProxy process`));
|
||||
// Wait a bit for port to be released
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
} else {
|
||||
// Active sessions exist but getExistingProxy returned null - something's wrong
|
||||
// Try to connect anyway
|
||||
log(`CLIProxy on port ${cfg.port} has active sessions, attempting to join...`);
|
||||
}
|
||||
} else {
|
||||
// Non-CLIProxy process blocking the port - warn user
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`)
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error('');
|
||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||
}
|
||||
} else {
|
||||
// Non-CLIProxy process blocking the port - warn user
|
||||
console.error('');
|
||||
console.error(
|
||||
warn(`Port ${cfg.port} is blocked by ${portProcess.processName} (PID ${portProcess.pid})`)
|
||||
);
|
||||
console.error('');
|
||||
console.error('To fix this, close the blocking application or run:');
|
||||
console.error(` ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error('');
|
||||
throw new Error(`Port ${cfg.port} is in use by another application`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Spawn CLIProxyAPI binary
|
||||
const proxyArgs = ['--config', configPath];
|
||||
// 6b. Spawn CLIProxyAPI binary (only if not reusing existing proxy)
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
const proxy = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// Forward proxy output in verbose mode
|
||||
if (verbose) {
|
||||
proxy.stdout?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
|
||||
proxy = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: false,
|
||||
});
|
||||
proxy.stderr?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||
|
||||
// Forward proxy output in verbose mode
|
||||
if (verbose) {
|
||||
proxy.stdout?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-out] ${data.toString()}`);
|
||||
});
|
||||
proxy.stderr?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle proxy errors
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
}
|
||||
|
||||
// Handle proxy errors
|
||||
proxy.on('error', (error) => {
|
||||
console.error(fail(`CLIProxy spawn error: ${error.message}`));
|
||||
});
|
||||
// 7. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
|
||||
// 7. Wait for proxy readiness via TCP polling
|
||||
const readySpinner = new ProgressIndicator(`Waiting for CLIProxy on port ${cfg.port}`);
|
||||
readySpinner.start();
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
try {
|
||||
await waitForProxyReady(cfg.port, cfg.timeout, cfg.pollInterval);
|
||||
readySpinner.succeed(`CLIProxy ready on port ${cfg.port}`);
|
||||
} catch (error) {
|
||||
readySpinner.fail('CLIProxy startup failed');
|
||||
proxy.kill('SIGTERM');
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail('CLIProxy failed to start'));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
|
||||
const err = error as Error;
|
||||
console.error('');
|
||||
console.error(fail('CLIProxy failed to start'));
|
||||
console.error('');
|
||||
console.error('Possible causes:');
|
||||
console.error(` 1. Port ${cfg.port} already in use`);
|
||||
console.error(' 2. Binary crashed on startup');
|
||||
console.error(' 3. Invalid configuration');
|
||||
console.error('');
|
||||
console.error('Troubleshooting:');
|
||||
console.error(` - Check port: ${getPortCheckCommand(cfg.port)}`);
|
||||
console.error(' - Run with --verbose for detailed logs');
|
||||
console.error(` - View config: ${getCatCommand(configPath)}`);
|
||||
console.error(' - Try: ccs doctor --fix');
|
||||
console.error('');
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
}
|
||||
|
||||
throw new Error(`CLIProxy startup failed: ${err.message}`);
|
||||
// Register this session with the new proxy
|
||||
sessionId = registerSession(cfg.port, proxy.pid as number);
|
||||
log(`Registered session ${sessionId} with new proxy (PID ${proxy.pid})`);
|
||||
}
|
||||
|
||||
// 7. Execute Claude CLI with proxied environment
|
||||
@@ -435,10 +473,25 @@ export async function execClaudeWithCLIProxy(
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Cleanup: kill proxy when Claude exits
|
||||
// 8. Cleanup: unregister session when Claude exits, kill proxy only if last session
|
||||
claude.on('exit', (code, signal) => {
|
||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
// Unregister this session - returns true if we were the last session
|
||||
const shouldKillProxy = unregisterSession(sessionId);
|
||||
log(`Session ${sessionId} unregistered, shouldKillProxy=${shouldKillProxy}`);
|
||||
|
||||
if (shouldKillProxy && proxy) {
|
||||
// We were the last session and we own the proxy - kill it
|
||||
log('Last session, killing proxy');
|
||||
proxy.kill('SIGTERM');
|
||||
} else if (shouldKillProxy && isReusingProxy) {
|
||||
// We were the last session but don't own the proxy process
|
||||
// The proxy will be cleaned up as zombie on next session start
|
||||
log('Last session but reusing proxy, proxy will be cleaned up later');
|
||||
} else {
|
||||
log(`Other sessions still active, keeping proxy running`);
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal as NodeJS.Signals);
|
||||
@@ -449,27 +502,39 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
claude.on('error', (error) => {
|
||||
console.error(fail(`Claude CLI error: ${error}`));
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
// Unregister and conditionally kill proxy
|
||||
const shouldKillProxy = unregisterSession(sessionId);
|
||||
if (shouldKillProxy && proxy) {
|
||||
proxy.kill('SIGTERM');
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle parent process termination (SIGTERM, SIGINT)
|
||||
const cleanup = () => {
|
||||
log('Parent signal received, cleaning up');
|
||||
proxy.kill('SIGTERM');
|
||||
|
||||
// Unregister and conditionally kill proxy
|
||||
const shouldKillProxy = unregisterSession(sessionId);
|
||||
if (shouldKillProxy && proxy) {
|
||||
proxy.kill('SIGTERM');
|
||||
}
|
||||
claude.kill('SIGTERM');
|
||||
};
|
||||
|
||||
process.once('SIGTERM', cleanup);
|
||||
process.once('SIGINT', cleanup);
|
||||
|
||||
// Handle proxy crash
|
||||
proxy.on('exit', (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
|
||||
// Don't kill Claude - it may have already exited
|
||||
}
|
||||
});
|
||||
// Handle proxy crash (only if we own the proxy)
|
||||
if (proxy) {
|
||||
proxy.on('exit', (code, signal) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
log(`Proxy exited unexpectedly: code=${code}, signal=${signal}`);
|
||||
// Don't kill Claude - it may have already exited
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Session Tracker for CLIProxy Multi-Instance Support
|
||||
*
|
||||
* Manages reference counting for shared CLIProxy instances.
|
||||
* Multiple CCS sessions can share a single proxy on the same port.
|
||||
* Proxy only terminates when ALL sessions exit (count reaches 0).
|
||||
*
|
||||
* Lock file format: ~/.ccs/cliproxy/sessions.json
|
||||
* {
|
||||
* "port": 8317,
|
||||
* "pid": 12345, // CLIProxy process PID
|
||||
* "sessions": ["abc123", "def456"], // Active session IDs
|
||||
* "startedAt": "2024-01-01T00:00:00Z"
|
||||
* }
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import { getCliproxyDir } from './config-generator';
|
||||
|
||||
/** Session lock file structure */
|
||||
interface SessionLock {
|
||||
port: number;
|
||||
pid: number;
|
||||
sessions: string[];
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/** Generate unique session ID */
|
||||
function generateSessionId(): string {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
/** Get path to session lock file */
|
||||
function getSessionLockPath(): string {
|
||||
return path.join(getCliproxyDir(), 'sessions.json');
|
||||
}
|
||||
|
||||
/** Read session lock file (returns null if not exists or invalid) */
|
||||
function readSessionLock(): SessionLock | null {
|
||||
const lockPath = getSessionLockPath();
|
||||
try {
|
||||
if (!fs.existsSync(lockPath)) {
|
||||
return null;
|
||||
}
|
||||
const content = fs.readFileSync(lockPath, 'utf-8');
|
||||
const lock = JSON.parse(content) as SessionLock;
|
||||
// Validate structure
|
||||
if (
|
||||
typeof lock.port !== 'number' ||
|
||||
typeof lock.pid !== 'number' ||
|
||||
!Array.isArray(lock.sessions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return lock;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write session lock file */
|
||||
function writeSessionLock(lock: SessionLock): void {
|
||||
const lockPath = getSessionLockPath();
|
||||
const dir = path.dirname(lockPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Delete session lock file */
|
||||
function deleteSessionLock(): void {
|
||||
const lockPath = getSessionLockPath();
|
||||
try {
|
||||
if (fs.existsSync(lockPath)) {
|
||||
fs.unlinkSync(lockPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors on cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a PID is still running */
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
// Sending signal 0 checks if process exists without killing it
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an existing proxy running that we can reuse.
|
||||
* Returns the existing lock if proxy is healthy, null otherwise.
|
||||
*/
|
||||
export function getExistingProxy(port: number): SessionLock | null {
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify port matches
|
||||
if (lock.port !== port) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify proxy process is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
// Proxy crashed - clean up stale lock
|
||||
deleteSessionLock();
|
||||
return null;
|
||||
}
|
||||
|
||||
return lock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new session with the proxy.
|
||||
* Call this when starting a new CCS session that will use an existing proxy.
|
||||
* @returns Session ID for this session
|
||||
*/
|
||||
export function registerSession(port: number, proxyPid: number): string {
|
||||
const sessionId = generateSessionId();
|
||||
const existingLock = readSessionLock();
|
||||
|
||||
if (existingLock && existingLock.port === port && existingLock.pid === proxyPid) {
|
||||
// Add to existing sessions
|
||||
existingLock.sessions.push(sessionId);
|
||||
writeSessionLock(existingLock);
|
||||
} else {
|
||||
// Create new lock (first session for this proxy)
|
||||
const newLock: SessionLock = {
|
||||
port,
|
||||
pid: proxyPid,
|
||||
sessions: [sessionId],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
writeSessionLock(newLock);
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a session from the proxy.
|
||||
* @returns true if this was the last session (proxy should be killed)
|
||||
*/
|
||||
export function unregisterSession(sessionId: string): boolean {
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
// No lock file - assume we're the only session
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove this session from the list
|
||||
const index = lock.sessions.indexOf(sessionId);
|
||||
if (index !== -1) {
|
||||
lock.sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
// Check if any sessions remain
|
||||
if (lock.sessions.length === 0) {
|
||||
// Last session - clean up lock file
|
||||
deleteSessionLock();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Other sessions still active - keep proxy running
|
||||
writeSessionLock(lock);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session count for the proxy.
|
||||
*/
|
||||
export function getSessionCount(): number {
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
return 0;
|
||||
}
|
||||
return lock.sessions.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if proxy has any active sessions.
|
||||
* Used to determine if a "zombie" proxy should be killed.
|
||||
*/
|
||||
export function hasActiveSessions(): boolean {
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify proxy is still running
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
return false;
|
||||
}
|
||||
|
||||
return lock.sessions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned sessions (when proxy crashes).
|
||||
* Called on startup to ensure clean state.
|
||||
*/
|
||||
export function cleanupOrphanedSessions(port: number): void {
|
||||
const lock = readSessionLock();
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If port doesn't match, this lock is for a different proxy
|
||||
if (lock.port !== port) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If proxy is dead, clean up lock
|
||||
if (!isProcessRunning(lock.pid)) {
|
||||
deleteSessionLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Session Tracker Tests
|
||||
*
|
||||
* Tests for multi-instance CLIProxy session management.
|
||||
* Verifies reference counting and cleanup behavior.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// Set test isolation environment before importing
|
||||
const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
process.env.CCS_HOME = testHome;
|
||||
|
||||
const {
|
||||
getExistingProxy,
|
||||
registerSession,
|
||||
unregisterSession,
|
||||
getSessionCount,
|
||||
hasActiveSessions,
|
||||
cleanupOrphanedSessions,
|
||||
} = require('../../../dist/cliproxy/session-tracker');
|
||||
|
||||
describe('Session Tracker', function () {
|
||||
const testPort = 18317;
|
||||
let sessionLockPath;
|
||||
|
||||
beforeEach(function () {
|
||||
// Create test directories
|
||||
const cliproxyDir = path.join(testHome, '.ccs', 'cliproxy');
|
||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||
sessionLockPath = path.join(cliproxyDir, 'sessions.json');
|
||||
|
||||
// Clean up any existing lock file
|
||||
if (fs.existsSync(sessionLockPath)) {
|
||||
fs.unlinkSync(sessionLockPath);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
// Clean up lock file
|
||||
if (fs.existsSync(sessionLockPath)) {
|
||||
fs.unlinkSync(sessionLockPath);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(function () {
|
||||
// Clean up test directory
|
||||
try {
|
||||
fs.rmSync(testHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
delete process.env.CCS_HOME;
|
||||
});
|
||||
|
||||
describe('getExistingProxy', function () {
|
||||
it('should return null when no lock file exists', function () {
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('should return null when port does not match', function () {
|
||||
// Create lock with different port
|
||||
const lock = {
|
||||
port: 9999,
|
||||
pid: process.pid, // Use current process as it's definitely running
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
it('should return lock when proxy is healthy', function () {
|
||||
// Create lock with current process (guaranteed to be running)
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: process.pid,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.notStrictEqual(result, null);
|
||||
assert.strictEqual(result.port, testPort);
|
||||
assert.strictEqual(result.pid, process.pid);
|
||||
assert.deepStrictEqual(result.sessions, ['session1']);
|
||||
});
|
||||
|
||||
it('should return null and cleanup when proxy is dead', function () {
|
||||
// Create lock with non-existent PID
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Very unlikely to exist
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
const result = getExistingProxy(testPort);
|
||||
assert.strictEqual(result, null);
|
||||
|
||||
// Lock file should be cleaned up
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerSession', function () {
|
||||
it('should create new lock file for first session', function () {
|
||||
const sessionId = registerSession(testPort, process.pid);
|
||||
|
||||
assert.ok(sessionId, 'should return session ID');
|
||||
assert.ok(fs.existsSync(sessionLockPath), 'should create lock file');
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.port, testPort);
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], sessionId);
|
||||
});
|
||||
|
||||
it('should add session to existing lock', function () {
|
||||
// Register first session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
|
||||
// Register second session
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
|
||||
assert.notStrictEqual(session1, session2, 'should generate unique IDs');
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.sessions.length, 2);
|
||||
assert.ok(lock.sessions.includes(session1));
|
||||
assert.ok(lock.sessions.includes(session2));
|
||||
});
|
||||
|
||||
it('should create new lock if PID differs', function () {
|
||||
// Create lock with different PID
|
||||
const oldLock = {
|
||||
port: testPort,
|
||||
pid: 12345,
|
||||
sessions: ['old-session'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(oldLock));
|
||||
|
||||
// Register with new PID
|
||||
const sessionId = registerSession(testPort, process.pid);
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.pid, process.pid);
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterSession', function () {
|
||||
it('should return true when no lock exists', function () {
|
||||
const result = unregisterSession('nonexistent');
|
||||
assert.strictEqual(result, true);
|
||||
});
|
||||
|
||||
it('should remove session and return false when others remain', function () {
|
||||
// Register two sessions
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
|
||||
// Unregister first
|
||||
const shouldKill = unregisterSession(session1);
|
||||
|
||||
assert.strictEqual(shouldKill, false, 'should not kill - other sessions active');
|
||||
|
||||
const lock = JSON.parse(fs.readFileSync(sessionLockPath, 'utf-8'));
|
||||
assert.strictEqual(lock.sessions.length, 1);
|
||||
assert.strictEqual(lock.sessions[0], session2);
|
||||
});
|
||||
|
||||
it('should return true and delete lock when last session', function () {
|
||||
// Register single session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
|
||||
// Unregister it
|
||||
const shouldKill = unregisterSession(session1);
|
||||
|
||||
assert.strictEqual(shouldKill, true, 'should kill - last session');
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false, 'should delete lock file');
|
||||
});
|
||||
|
||||
it('should handle unregistering non-existent session gracefully', function () {
|
||||
// Register a session
|
||||
registerSession(testPort, process.pid);
|
||||
|
||||
// Try to unregister wrong session
|
||||
const shouldKill = unregisterSession('wrong-session-id');
|
||||
|
||||
// Should return false since a session still exists
|
||||
assert.strictEqual(shouldKill, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionCount', function () {
|
||||
it('should return 0 when no lock exists', function () {
|
||||
assert.strictEqual(getSessionCount(), 0);
|
||||
});
|
||||
|
||||
it('should return correct count', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 1);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 2);
|
||||
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveSessions', function () {
|
||||
it('should return false when no lock exists', function () {
|
||||
assert.strictEqual(hasActiveSessions(), false);
|
||||
});
|
||||
|
||||
it('should return true when sessions exist and proxy running', function () {
|
||||
registerSession(testPort, process.pid);
|
||||
assert.strictEqual(hasActiveSessions(), true);
|
||||
});
|
||||
|
||||
it('should return false and cleanup when proxy is dead', function () {
|
||||
// Create lock with dead PID
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999,
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
assert.strictEqual(hasActiveSessions(), false);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOrphanedSessions', function () {
|
||||
it('should do nothing when no lock exists', function () {
|
||||
cleanupOrphanedSessions(testPort);
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it('should not cleanup when port differs', function () {
|
||||
const lock = {
|
||||
port: 9999,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
cleanupOrphanedSessions(testPort);
|
||||
|
||||
// Should still exist (different port)
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||
});
|
||||
|
||||
it('should cleanup when proxy is dead', function () {
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: 999999999, // Dead PID
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
cleanupOrphanedSessions(testPort);
|
||||
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
|
||||
it('should not cleanup when proxy is alive', function () {
|
||||
const lock = {
|
||||
port: testPort,
|
||||
pid: process.pid, // Current process - alive
|
||||
sessions: ['session1'],
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
fs.writeFileSync(sessionLockPath, JSON.stringify(lock));
|
||||
|
||||
cleanupOrphanedSessions(testPort);
|
||||
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multi-session scenario', function () {
|
||||
it('should handle complete multi-terminal workflow', function () {
|
||||
// Terminal 1 starts - first session
|
||||
const session1 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 1);
|
||||
|
||||
// Terminal 2 starts - joins existing
|
||||
const session2 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 2);
|
||||
|
||||
// Terminal 3 starts - joins existing
|
||||
const session3 = registerSession(testPort, process.pid);
|
||||
assert.strictEqual(getSessionCount(), 3);
|
||||
|
||||
// Terminal 1 exits - should NOT kill proxy
|
||||
let shouldKill = unregisterSession(session1);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
assert.strictEqual(getSessionCount(), 2);
|
||||
|
||||
// Terminal 3 exits - should NOT kill proxy
|
||||
shouldKill = unregisterSession(session3);
|
||||
assert.strictEqual(shouldKill, false);
|
||||
assert.strictEqual(getSessionCount(), 1);
|
||||
|
||||
// Terminal 2 exits - SHOULD kill proxy (last session)
|
||||
shouldKill = unregisterSession(session2);
|
||||
assert.strictEqual(shouldKill, true);
|
||||
assert.strictEqual(getSessionCount(), 0);
|
||||
assert.strictEqual(fs.existsSync(sessionLockPath), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
+19
-16
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { PrivacyProvider } from '@/contexts/privacy-context';
|
||||
import { Layout } from '@/components/layout';
|
||||
|
||||
// Eager load: HomePage (initial route)
|
||||
@@ -33,22 +34,24 @@ export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="system" storageKey="vite-ui-theme">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/api" element={<ApiPage />} />
|
||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
<Route path="/shared" element={<SharedPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
<PrivacyProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/api" element={<ApiPage />} />
|
||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
<Route path="/shared" element={<SharedPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</PrivacyProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from '@/lib/utils';
|
||||
import { ProviderIcon } from '@/components/provider-icon';
|
||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { STATUS_COLORS } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import {
|
||||
ChevronRight,
|
||||
X,
|
||||
@@ -94,7 +95,13 @@ function formatTimelineTime(date: Date): string {
|
||||
}
|
||||
|
||||
/** Connection Timeline Component - right sidebar panel */
|
||||
function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) {
|
||||
function ConnectionTimeline({
|
||||
events,
|
||||
privacyMode,
|
||||
}: {
|
||||
events: ConnectionEvent[];
|
||||
privacyMode: boolean;
|
||||
}) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center rounded-xl bg-muted/20 dark:bg-zinc-900/40 border border-border/30 dark:border-white/[0.05]">
|
||||
@@ -149,7 +156,12 @@ function ConnectionTimeline({ events }: { events: ConnectionEvent[] }) {
|
||||
{/* Event content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-foreground truncate">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono text-foreground truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{cleanEmail(event.accountEmail)}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted-foreground font-mono flex-shrink-0">
|
||||
@@ -217,6 +229,9 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
const [selectedAccount, setSelectedAccount] = useState<AccountData | null>(null);
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
|
||||
// Privacy mode for demo purposes
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
// Drag state for all cards (account IDs + 'provider')
|
||||
const [dragOffsets, setDragOffsets] = useState<Record<string, DragOffset>>({});
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
@@ -494,7 +509,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
{/* Drag handle indicator */}
|
||||
<GripVertical className="absolute top-1/2 left-1 -translate-y-1/2 w-3 h-3 text-muted-foreground/40" />
|
||||
<div className="flex justify-between items-start mb-1 ml-3">
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
<ChevronRight
|
||||
@@ -672,7 +692,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
isHovered ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -704,7 +729,7 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
|
||||
{/* Right Section: Connection Timeline - Fixed compact width */}
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<ConnectionTimeline events={connectionEvents} />
|
||||
<ConnectionTimeline events={connectionEvents} privacyMode={privacyMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -733,7 +758,12 @@ export function AccountFlowViz({ providerData, onBack }: AccountFlowVizProps) {
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: selectedAccount.color }}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-foreground tracking-tight truncate">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold text-foreground tracking-tight truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{cleanEmail(selectedAccount.email)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getProviderDisplayName, PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ProviderIcon } from '@/components/provider-icon';
|
||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import { Activity, CheckCircle2, XCircle, ChevronRight, Radio } from 'lucide-react';
|
||||
|
||||
@@ -114,6 +115,7 @@ function InlineStatsBadge({ success, failure }: { success: number; failure: numb
|
||||
export function AuthMonitor() {
|
||||
const { data, isLoading, error } = useCliproxyAuth();
|
||||
const { data: statsData, isLoading: statsLoading, dataUpdatedAt } = useCliproxyStats();
|
||||
const { privacyMode } = usePrivacy();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [hoveredProvider, setHoveredProvider] = useState<string | null>(null);
|
||||
const [timeSinceUpdate, setTimeSinceUpdate] = useState('');
|
||||
@@ -406,7 +408,7 @@ export function AuthMonitor() {
|
||||
key={acc.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={cleanEmail(acc.email)}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
/>
|
||||
))}
|
||||
{ps.accounts.length > 5 && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
|
||||
const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const;
|
||||
|
||||
@@ -43,6 +44,7 @@ const providerOptions = [
|
||||
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
const createMutation = useCreateVariant();
|
||||
const { data: authData } = useCliproxyAuth();
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -115,7 +117,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
<option value="">Use default account</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{acc.email || acc.id}
|
||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||
{acc.isDefault ? ' (default)' : ''}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
type CredentialStatus = 'ready' | 'warning' | 'error' | 'disabled';
|
||||
|
||||
@@ -20,6 +21,7 @@ interface CredentialRowProps {
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
onRefresh?: () => void;
|
||||
privacyMode?: boolean;
|
||||
}
|
||||
|
||||
function CredentialRow({
|
||||
@@ -30,6 +32,7 @@ function CredentialRow({
|
||||
email,
|
||||
expiresAt,
|
||||
onRefresh,
|
||||
privacyMode,
|
||||
}: CredentialRowProps) {
|
||||
const statusConfig = {
|
||||
ready: {
|
||||
@@ -76,7 +79,9 @@ function CredentialRow({
|
||||
<Icon className={cn('w-4 h-4', config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{email ?? name}</div>
|
||||
<div className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{email ?? name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +129,7 @@ function CredentialHealthSkeleton() {
|
||||
|
||||
export function CredentialHealthList() {
|
||||
const { data: authData, isLoading } = useCliproxyAuth();
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
if (isLoading) {
|
||||
return <CredentialHealthSkeleton />;
|
||||
@@ -164,7 +170,11 @@ export function CredentialHealthList() {
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{credentials.map((cred, i) => (
|
||||
<CredentialRow key={`${cred.provider}-${cred.name}-${i}`} {...cred} />
|
||||
<CredentialRow
|
||||
key={`${cred.provider}-${cred.name}-${i}`}
|
||||
{...cred}
|
||||
privacyMode={privacyMode}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
useDeletePreset,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
const CodeEditor = lazy(() =>
|
||||
@@ -100,6 +101,7 @@ export function ProviderEditor({
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
// Fetch available models from CLIProxy API
|
||||
const { data: modelsData } = useCliproxyModels();
|
||||
@@ -343,9 +345,9 @@ export function ProviderEditor({
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border border-dashed"
|
||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
onClick={() => setCustomPresetOpen(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
@@ -414,7 +416,7 @@ export function ProviderEditor({
|
||||
)}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={onAddAccount}
|
||||
@@ -433,6 +435,7 @@ export function ProviderEditor({
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
isRemoving={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -685,11 +688,13 @@ function AccountItem({
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
privacyMode,
|
||||
}: {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
isRemoving?: boolean;
|
||||
privacyMode?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
@@ -709,7 +714,9 @@ function AccountItem({
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{account.email || account.id}</span>
|
||||
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{account.email || account.id}
|
||||
</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
|
||||
@@ -17,6 +17,7 @@ const PROVIDER_IMAGES: Record<string, string> = {
|
||||
codex: '/assets/providers/openai.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
qwen: '/assets/providers/qwen-color.svg',
|
||||
iflow: '/assets/providers/iflow.png',
|
||||
};
|
||||
|
||||
/** Provider color configuration (for fallback only - no background for image logos) */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Outlet } from 'react-router-dom';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/app-sidebar';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { PrivacyToggle } from '@/components/privacy-toggle';
|
||||
import { GitHubLink } from '@/components/github-link';
|
||||
import { DocsLink } from '@/components/docs-link';
|
||||
import { ConnectionIndicator } from '@/components/connection-indicator';
|
||||
@@ -29,6 +30,7 @@ export function Layout() {
|
||||
<ConnectionIndicator />
|
||||
<DocsLink />
|
||||
<GitHubLink />
|
||||
<PrivacyToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Privacy Toggle Button
|
||||
* Toggles demo mode to blur personal information (emails, account IDs)
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PrivacyToggle() {
|
||||
const { privacyMode, togglePrivacyMode } = usePrivacy();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={togglePrivacyMode}
|
||||
className={cn(
|
||||
'h-8 w-8 transition-colors',
|
||||
privacyMode && 'text-amber-600 hover:text-amber-700 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
{privacyMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{privacyMode
|
||||
? 'Privacy mode ON - Click to show data'
|
||||
: 'Privacy mode OFF - Click to hide data'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
import { useCliproxyAuth, useCreateVariant, useStartAuth } from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
interface QuickSetupWizardProps {
|
||||
open: boolean;
|
||||
@@ -68,6 +70,7 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
const { data: authData, refetch } = useCliproxyAuth();
|
||||
const createMutation = useCreateVariant();
|
||||
const startAuthMutation = useStartAuth();
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
// Get auth status for selected provider
|
||||
const providerAuth = authData?.authStatus.find(
|
||||
@@ -347,7 +350,9 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{acc.email || acc.id}</div>
|
||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{acc.email || acc.id}
|
||||
</div>
|
||||
{acc.isDefault && (
|
||||
<div className="text-xs text-muted-foreground">Default account</div>
|
||||
)}
|
||||
@@ -403,7 +408,12 @@ export function QuickSetupWizard({ open, onClose }: QuickSetupWizardProps) {
|
||||
{selectedAccount && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
||||
<User className="w-4 h-4" />
|
||||
<span>Using: {selectedAccount.email || selectedAccount.id}</span>
|
||||
<span>
|
||||
Using:{' '}
|
||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{selectedAccount.email || selectedAccount.id}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Privacy Context - Global demo mode for censoring personal information
|
||||
* Provides blur effect on emails, account IDs, and other PII
|
||||
* Persists state to localStorage
|
||||
*/
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface PrivacyContextValue {
|
||||
/** Whether privacy/demo mode is enabled */
|
||||
privacyMode: boolean;
|
||||
/** Toggle privacy mode on/off */
|
||||
togglePrivacyMode: () => void;
|
||||
}
|
||||
|
||||
const PrivacyContext = createContext<PrivacyContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'ccs-privacy-mode';
|
||||
|
||||
export function PrivacyProvider({ children }: { children: ReactNode }) {
|
||||
const [privacyMode, setPrivacyMode] = useState(() => {
|
||||
// Initialize from localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Persist to localStorage when changed
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, String(privacyMode));
|
||||
}, [privacyMode]);
|
||||
|
||||
const togglePrivacyMode = () => setPrivacyMode((prev) => !prev);
|
||||
|
||||
return (
|
||||
<PrivacyContext.Provider value={{ privacyMode, togglePrivacyMode }}>
|
||||
{children}
|
||||
</PrivacyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePrivacy() {
|
||||
const context = useContext(PrivacyContext);
|
||||
if (!context) {
|
||||
throw new Error('usePrivacy must be used within a PrivacyProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** CSS class for blurring sensitive content */
|
||||
export const PRIVACY_BLUR_CLASS =
|
||||
'blur-[4px] select-none hover:blur-none transition-all duration-200';
|
||||
@@ -247,7 +247,7 @@ export function CliproxyPage() {
|
||||
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full gap-2"
|
||||
onClick={() => setWizardOpen(true)}
|
||||
|
||||
Reference in new issue
Block a user