mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
Merge remote-tracking branch 'origin/dev' into kai/fix/958-searxng-pr-review
This commit is contained in:
commit
54791b5cca
9 files changed
+237
-33
No files matched your search
@@ -1,3 +1,9 @@
|
||||
## [7.68.1](https://github.com/kaitranntt/ccs/compare/v7.68.0...v7.68.1) (2026-04-12)
|
||||
|
||||
### Hotfixes
|
||||
|
||||
* detect bun-owned installs through symlinked ccs path ([5f8e66e](https://github.com/kaitranntt/ccs/commit/5f8e66ec1e1ef9cceb135c560f9bd44c9269dd6b))
|
||||
|
||||
## [7.68.0](https://github.com/kaitranntt/ccs/compare/v7.67.0...v7.68.0) (2026-04-11)
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.68.0",
|
||||
"version": "7.68.1-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Supervisord lifecycle helpers for Docker deployments (`ccs docker up`).
|
||||
*
|
||||
* In Docker, supervisord owns the CLIProxy process lifecycle. Direct
|
||||
* stop+start via session-tracker / service-manager creates orphaned
|
||||
* processes and causes supervisord to enter FATAL state (EADDRINUSE).
|
||||
* All restart operations must delegate to `supervisorctl` instead.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
|
||||
const SUPERVISOR_SOCK = '/var/run/supervisor.sock';
|
||||
const SUPERVISOR_CONF = '/etc/supervisord.conf';
|
||||
|
||||
/** True when running inside a supervisord-managed container. */
|
||||
export function isRunningUnderSupervisord(): boolean {
|
||||
return fs.existsSync(SUPERVISOR_SOCK);
|
||||
}
|
||||
|
||||
/** Restart the cliproxy program via supervisorctl. Returns port on success. */
|
||||
export function restartCliproxyViaSupervisord(): {
|
||||
success: boolean;
|
||||
port?: number;
|
||||
error?: string;
|
||||
} {
|
||||
try {
|
||||
execSync(`supervisorctl -c ${SUPERVISOR_CONF} restart cliproxy`, { timeout: 15_000 });
|
||||
return { success: true, port: CLIPROXY_DEFAULT_PORT };
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[cliproxy] supervisorctl restart failed: ${detail}`);
|
||||
return { success: false, error: 'supervisorctl restart failed' };
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@ export interface InstalledPackageState {
|
||||
const CCS_PACKAGE_NAME = '@kaitranntt/ccs';
|
||||
|
||||
function resolveScriptPath(scriptPath: string): string {
|
||||
if (path.win32.isAbsolute(scriptPath)) {
|
||||
// Keep Windows-style absolute test fixtures stable on non-Windows hosts, but
|
||||
// still resolve real POSIX symlink paths such as ~/.bun/bin/ccs.
|
||||
if (path.win32.isAbsolute(scriptPath) && !path.isAbsolute(scriptPath)) {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
isRunningUnderSupervisord,
|
||||
restartCliproxyViaSupervisord,
|
||||
} from '../../docker/supervisord-lifecycle';
|
||||
import {
|
||||
fetchCliproxyStats,
|
||||
fetchCliproxyModels,
|
||||
@@ -1014,7 +1018,14 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||
*/
|
||||
router.post('/restart', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Stop proxy first
|
||||
if (isRunningUnderSupervisord()) {
|
||||
// Docker mode: delegate to supervisord which owns the process lifecycle
|
||||
const result = restartCliproxyViaSupervisord();
|
||||
res.json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local mode: direct process management
|
||||
await stopProxy();
|
||||
|
||||
// Small delay to ensure port is released
|
||||
|
||||
@@ -3,6 +3,10 @@ import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/s
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
|
||||
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
|
||||
import type { CLIProxyBackend } from '../../cliproxy/types';
|
||||
import {
|
||||
isRunningUnderSupervisord,
|
||||
restartCliproxyViaSupervisord,
|
||||
} from '../../docker/supervisord-lifecycle';
|
||||
|
||||
interface ProxyStatusLike {
|
||||
running: boolean;
|
||||
@@ -63,6 +67,20 @@ export async function installDashboardCliproxyVersion(
|
||||
};
|
||||
}
|
||||
|
||||
// In Docker, supervisord owns process lifecycle — delegate restart to it
|
||||
if (isRunningUnderSupervisord()) {
|
||||
const result = restartCliproxyViaSupervisord();
|
||||
return {
|
||||
success: result.success,
|
||||
restarted: result.success,
|
||||
port: result.port,
|
||||
error: result.error,
|
||||
message: result.success
|
||||
? `Successfully installed ${backendLabel} v${version} and restarted it on port ${result.port}`
|
||||
: `Installed ${backendLabel} v${version}, but restart failed`,
|
||||
};
|
||||
}
|
||||
|
||||
const startResult = await deps.ensureCliproxyService();
|
||||
if (!startResult.started && !startResult.alreadyRunning) {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
@@ -72,6 +72,39 @@ describe('package-manager-detector', () => {
|
||||
expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9');
|
||||
});
|
||||
|
||||
it.if(process.platform !== 'win32')(
|
||||
'detects bun installs from a POSIX symlinked ~/.bun/bin/ccs entrypoint',
|
||||
() => {
|
||||
const tempRoot = makeTempDir('ccs-install-detector-bun-symlink-');
|
||||
const packageRoot = join(
|
||||
tempRoot,
|
||||
'.bun',
|
||||
'install',
|
||||
'global',
|
||||
'node_modules',
|
||||
'@kaitranntt',
|
||||
'ccs'
|
||||
);
|
||||
const scriptPath = join(packageRoot, 'dist', 'ccs.js');
|
||||
const symlinkPath = join(tempRoot, 'home', '.bun', 'bin', 'ccs');
|
||||
|
||||
writePackage(packageRoot, '7.67.0-dev.9');
|
||||
mkdirSync(join(packageRoot, 'dist'), { recursive: true });
|
||||
writeFileSync(scriptPath, '#!/usr/bin/env node\n');
|
||||
mkdirSync(join(tempRoot, 'home', '.bun', 'bin'), { recursive: true });
|
||||
symlinkSync(scriptPath, symlinkPath);
|
||||
|
||||
const install = detectCurrentInstall(symlinkPath);
|
||||
const resolvedTempRoot = realpathSync(tempRoot);
|
||||
const resolvedScriptPath = realpathSync(scriptPath);
|
||||
|
||||
expect(install.manager).toBe('bun');
|
||||
expect(install.prefix).toBe(join(resolvedTempRoot, '.bun'));
|
||||
expect(install.resolvedScriptPath).toBe(resolvedScriptPath);
|
||||
expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9');
|
||||
}
|
||||
);
|
||||
|
||||
it('detects custom bun install roots that still use install/global/node_modules', () => {
|
||||
const tempRoot = makeTempDir('ccs-install-detector-custom-bun-');
|
||||
const packageRoot = join(
|
||||
|
||||
@@ -78,6 +78,63 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s
|
||||
}
|
||||
}
|
||||
|
||||
function formatGeminiTokenType(tokenType: string | null | undefined): string | null {
|
||||
if (!tokenType) return null;
|
||||
|
||||
switch (tokenType.trim().toLowerCase()) {
|
||||
case 'requests':
|
||||
return 'Requests';
|
||||
case 'input':
|
||||
return 'Input tokens';
|
||||
case 'output':
|
||||
return 'Output tokens';
|
||||
default:
|
||||
return tokenType
|
||||
.split(/[\s_-]+/g)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
function formatGeminiBucketLabel(label: string): string {
|
||||
switch (label) {
|
||||
case 'Gemini Flash Lite Series':
|
||||
return 'Flash Lite';
|
||||
case 'Gemini Flash Series':
|
||||
return 'Flash';
|
||||
case 'Gemini Pro Series':
|
||||
return 'Pro';
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
function formatGeminiBucketModels(modelIds: string[] | undefined): string | null {
|
||||
const uniqueModelIds = Array.from(new Set((modelIds || []).filter(Boolean)));
|
||||
return uniqueModelIds.length > 0 ? uniqueModelIds.join(', ') : null;
|
||||
}
|
||||
|
||||
function formatGeminiRemainingAmount(
|
||||
remainingAmount: number | null | undefined,
|
||||
tokenType: string | null | undefined
|
||||
): string | null {
|
||||
if (remainingAmount === null || remainingAmount === undefined) return null;
|
||||
|
||||
const formattedAmount = remainingAmount.toLocaleString();
|
||||
switch (tokenType?.trim().toLowerCase()) {
|
||||
case 'requests':
|
||||
return `${formattedAmount} requests remaining`;
|
||||
case 'input':
|
||||
return `${formattedAmount} input tokens remaining`;
|
||||
case 'output':
|
||||
return `${formattedAmount} output tokens remaining`;
|
||||
default:
|
||||
return `${formattedAmount} remaining`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) {
|
||||
if (!entitlement) return null;
|
||||
|
||||
@@ -301,6 +358,14 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||
const hasEntitlementTier =
|
||||
!!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown';
|
||||
const distinctTokenTypes = Array.from(
|
||||
new Set(
|
||||
quota.buckets
|
||||
.map((bucket) => formatGeminiTokenType(bucket.tokenType))
|
||||
.filter((tokenType): tokenType is string => !!tokenType)
|
||||
)
|
||||
);
|
||||
const sharedTokenType = distinctTokenTypes.length === 1 ? distinctTokenTypes[0] : null;
|
||||
|
||||
return (
|
||||
<div className="text-xs space-y-1.5">
|
||||
@@ -317,28 +382,53 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
<span className="font-mono">{quota.creditBalance.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="font-medium">Buckets:</p>
|
||||
{quota.buckets.map((b) => (
|
||||
<div key={b.id} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className={cn(b.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{b.label}
|
||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||
</span>
|
||||
<span className="font-mono">{b.remainingPercent}%</span>
|
||||
</div>
|
||||
{((b.remainingAmount !== null && b.remainingAmount !== undefined) || b.resetTime) && (
|
||||
<div className="flex justify-between gap-4 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{b.remainingAmount !== null && b.remainingAmount !== undefined
|
||||
? `${b.remainingAmount.toLocaleString()} remaining`
|
||||
: ''}
|
||||
</span>
|
||||
<span>{formatAbsoluteResetTime(b.resetTime) ?? ''}</span>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Model quotas:</p>
|
||||
{sharedTokenType && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
All buckets report {sharedTokenType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{quota.buckets.map((bucket) => {
|
||||
const bucketTokenType = sharedTokenType ? null : formatGeminiTokenType(bucket.tokenType);
|
||||
const bucketModels = formatGeminiBucketModels(bucket.modelIds);
|
||||
const remainingAmountLabel = formatGeminiRemainingAmount(
|
||||
bucket.remainingAmount,
|
||||
bucket.tokenType
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={bucket.id} className="space-y-0.5">
|
||||
<div className="flex justify-between gap-4">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(bucket.remainingPercent < 20 && lowQuotaTextClass)}>
|
||||
{formatGeminiBucketLabel(bucket.label)}
|
||||
</span>
|
||||
{bucketTokenType && (
|
||||
<span className="rounded border border-border/60 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{bucketTokenType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{bucketModels && (
|
||||
<div className="break-words text-[11px] text-muted-foreground">
|
||||
{bucketModels}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 font-mono">{bucket.remainingPercent}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(remainingAmountLabel || bucket.resetTime) && (
|
||||
<div className="flex justify-between gap-4 text-[11px] text-muted-foreground">
|
||||
<span>{remainingAmountLabel ?? ''}</span>
|
||||
<span>{formatAbsoluteResetTime(bucket.resetTime) ?? ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!hasBucketResetTime && <ResetTimeIndicator resetTime={resetTime} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,22 +12,22 @@ function createGeminiQuotaResult(
|
||||
{
|
||||
id: 'gemini-flash-lite-series::combined',
|
||||
label: 'Gemini Flash Lite Series',
|
||||
tokenType: null,
|
||||
tokenType: 'requests',
|
||||
remainingFraction: 1,
|
||||
remainingPercent: 100,
|
||||
remainingAmount: 100,
|
||||
resetTime: '2026-01-30T09:00:00Z',
|
||||
modelIds: ['gemini-2.5-flash-lite'],
|
||||
modelIds: ['gemini-2.5-flash-lite', 'gemini-3.1-flash-lite-preview'],
|
||||
},
|
||||
{
|
||||
id: 'gemini-flash-series::combined',
|
||||
label: 'Gemini Flash Series',
|
||||
tokenType: null,
|
||||
tokenType: 'requests',
|
||||
remainingFraction: 0.82,
|
||||
remainingPercent: 82,
|
||||
remainingAmount: 82,
|
||||
resetTime: '2026-01-30T14:00:00Z',
|
||||
modelIds: ['gemini-3-flash-preview'],
|
||||
modelIds: ['gemini-3-flash-preview', 'gemini-3.1-flash-preview', 'gemini-2.5-flash'],
|
||||
},
|
||||
],
|
||||
projectId: 'cloudaicompanion-test-123',
|
||||
@@ -51,7 +51,7 @@ function createGeminiQuotaResult(
|
||||
}
|
||||
|
||||
describe('QuotaTooltipContent', () => {
|
||||
it('renders Gemini tier, credits, remaining amount, and bucket reset timestamps', () => {
|
||||
it('renders Gemini tier, model coverage, and clearer bucket wording', () => {
|
||||
const quota = createGeminiQuotaResult();
|
||||
const expectedReset = new Date('2026-01-30T14:00:00Z').toLocaleString(undefined, {
|
||||
month: '2-digit',
|
||||
@@ -69,9 +69,17 @@ describe('QuotaTooltipContent', () => {
|
||||
expect(screen.getByText('g1-pro-tier')).toBeInTheDocument();
|
||||
expect(screen.getByText('Credits')).toBeInTheDocument();
|
||||
expect(screen.getByText('12')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument();
|
||||
expect(screen.getByText('100 remaining')).toBeInTheDocument();
|
||||
expect(screen.getByText('82 remaining')).toBeInTheDocument();
|
||||
expect(screen.getByText('Model quotas:')).toBeInTheDocument();
|
||||
expect(screen.getByText('All buckets report Requests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flash Lite')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('gemini-2.5-flash-lite, gemini-3.1-flash-lite-preview')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('100 requests remaining')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('gemini-3-flash-preview, gemini-3.1-flash-preview, gemini-2.5-flash')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('82 requests remaining')).toBeInTheDocument();
|
||||
expect(screen.getByText(expectedReset)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user