mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
fix(cliproxy): address lifecycle port and thinking regressions
This commit is contained in:
@@ -180,16 +180,17 @@ export function applyThinkingConfig(
|
||||
// Get base model to check thinking support
|
||||
const baseModel = result.ANTHROPIC_MODEL || '';
|
||||
const normalizedBaseModel = normalizeModelForThinkingLookup(baseModel, provider);
|
||||
if (!supportsThinking(provider, normalizedBaseModel)) {
|
||||
// U2: Warn user if they explicitly provided --thinking but model doesn't support it
|
||||
if (thinkingOverride !== undefined && shouldShowWarnings(thinkingConfig)) {
|
||||
console.warn(
|
||||
warn(
|
||||
`Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored.`
|
||||
)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
const baseModelSupportsThinking = supportsThinking(provider, normalizedBaseModel);
|
||||
if (
|
||||
!baseModelSupportsThinking &&
|
||||
thinkingOverride !== undefined &&
|
||||
shouldShowWarnings(thinkingConfig)
|
||||
) {
|
||||
console.warn(
|
||||
warn(
|
||||
`Model ${baseModel || 'unknown'} (provider: ${provider}) does not support thinking budget. --thinking flag ignored for default model.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Determine thinking value to use
|
||||
@@ -215,12 +216,14 @@ export function applyThinkingConfig(
|
||||
return result; // No thinking to apply
|
||||
}
|
||||
|
||||
// Validate thinking value against model capabilities
|
||||
const validation = validateThinking(provider, normalizedBaseModel, thinkingValue);
|
||||
if (validation.warning && shouldShowWarnings(thinkingConfig)) {
|
||||
console.warn(warn(validation.warning));
|
||||
if (baseModelSupportsThinking) {
|
||||
// Validate thinking value against default model capabilities.
|
||||
const validation = validateThinking(provider, normalizedBaseModel, thinkingValue);
|
||||
if (validation.warning && shouldShowWarnings(thinkingConfig)) {
|
||||
console.warn(warn(validation.warning));
|
||||
}
|
||||
thinkingValue = validation.value;
|
||||
}
|
||||
thinkingValue = validation.value;
|
||||
|
||||
// If auto-detection resolves default tier to "off", skip the main model but still allow
|
||||
// explicit per-tier thinking values for other tiers.
|
||||
@@ -232,7 +235,7 @@ export function applyThinkingConfig(
|
||||
return result; // No thinking to apply anywhere
|
||||
}
|
||||
// Otherwise, continue to process tiers with their own config (skip main model)
|
||||
} else if (result.ANTHROPIC_MODEL) {
|
||||
} else if (baseModelSupportsThinking && result.ANTHROPIC_MODEL) {
|
||||
// Apply thinking suffix to main model (only if not off)
|
||||
result.ANTHROPIC_MODEL = applyThinkingSuffixForProvider(
|
||||
result.ANTHROPIC_MODEL,
|
||||
|
||||
@@ -22,10 +22,12 @@ export {
|
||||
export {
|
||||
getProxyStatus,
|
||||
stopProxy,
|
||||
startProxy,
|
||||
isProxyRunning,
|
||||
getActiveSessionCount,
|
||||
type ProxyStatusResult,
|
||||
type StopProxyResult,
|
||||
type StartProxyResult,
|
||||
} from './proxy-lifecycle-service';
|
||||
|
||||
// Binary management
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* CLIProxy Proxy Lifecycle Service
|
||||
*
|
||||
* Handles start/stop/status operations for CLIProxy instances.
|
||||
* Delegates to session-tracker for actual process management.
|
||||
* Delegates to session-tracker and service-manager for actual process management.
|
||||
*/
|
||||
|
||||
import {
|
||||
stopProxy as stopProxySession,
|
||||
getProxyStatus as getProxyStatusSession,
|
||||
} from '../session-tracker';
|
||||
import { ensureCliproxyService } from '../service-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
|
||||
/** Proxy status result */
|
||||
export interface ProxyStatusResult {
|
||||
@@ -27,18 +29,37 @@ export interface StopProxyResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Start proxy result */
|
||||
export interface StartProxyResult {
|
||||
started: boolean;
|
||||
alreadyRunning: boolean;
|
||||
port: number;
|
||||
configRegenerated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current proxy status
|
||||
*/
|
||||
export function getProxyStatus(): ProxyStatusResult {
|
||||
return getProxyStatusSession();
|
||||
export function getProxyStatus(port?: number): ProxyStatusResult {
|
||||
return getProxyStatusSession(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the running CLIProxy instance
|
||||
*/
|
||||
export async function stopProxy(): Promise<StopProxyResult> {
|
||||
return stopProxySession();
|
||||
export async function stopProxy(port?: number): Promise<StopProxyResult> {
|
||||
return stopProxySession(port);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start CLIProxy service (or reuse existing running instance)
|
||||
*/
|
||||
export async function startProxy(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
verbose: boolean = false
|
||||
): Promise<StartProxyResult> {
|
||||
return ensureCliproxyService(port, verbose);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { deleteConfigForPort } from '../config-generator';
|
||||
import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { validateCompositeTiers } from '../composite-validator';
|
||||
import {
|
||||
createSettingsFile,
|
||||
@@ -24,7 +25,6 @@ import {
|
||||
createCompositeSettingsFile,
|
||||
deleteSettingsFile,
|
||||
getRelativeSettingsPath,
|
||||
getCompositeRelativeSettingsPath,
|
||||
updateSettingsModel,
|
||||
updateSettingsProviderAndModel,
|
||||
} from './variant-settings';
|
||||
@@ -359,7 +359,7 @@ export function createCompositeVariant(
|
||||
type: 'composite',
|
||||
default_tier: defaultTier,
|
||||
tiers,
|
||||
settings: getCompositeRelativeSettingsPath(name),
|
||||
settings: settingsPath,
|
||||
port,
|
||||
};
|
||||
saveCompositeVariantUnified(name, compositeConfig);
|
||||
@@ -436,7 +436,8 @@ export function updateCompositeVariant(
|
||||
}
|
||||
|
||||
// Preserve existing settings path when configured; otherwise use default path.
|
||||
const settingsRef = existing.settings || getCompositeRelativeSettingsPath(name);
|
||||
const settingsRef =
|
||||
existing.settings || path.join(getCcsDir(), `composite-${name}.settings.json`);
|
||||
|
||||
// Create new settings file with updated config
|
||||
const settingsPath = createCompositeSettingsFile(
|
||||
|
||||
@@ -61,6 +61,8 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'Proxy Lifecycle:',
|
||||
[
|
||||
['start', 'Start CLIProxy instance in background'],
|
||||
['restart', 'Restart CLIProxy instance'],
|
||||
['status', 'Show running CLIProxy status'],
|
||||
['stop', 'Stop running CLIProxy instance'],
|
||||
['doctor', 'Quota diagnostics and shared project detection'],
|
||||
|
||||
@@ -20,7 +20,12 @@ import {
|
||||
handleResumeAccount,
|
||||
} from './quota-subcommand';
|
||||
import { handleCreate, handleRemove, handleEdit } from './variant-subcommand';
|
||||
import { handleProxyStatus, handleStop } from './proxy-lifecycle-subcommand';
|
||||
import {
|
||||
handleProxyStatus,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRestart,
|
||||
} from './proxy-lifecycle-subcommand';
|
||||
import { showStatus, handleInstallVersion, handleInstallLatest } from './install-subcommand';
|
||||
import { showHelp } from './help-subcommand';
|
||||
import {
|
||||
@@ -198,11 +203,21 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// Proxy lifecycle commands
|
||||
if (command === 'start') {
|
||||
await handleStart(verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'stop') {
|
||||
await handleStop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'restart') {
|
||||
await handleRestart(verbose);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'status') {
|
||||
await handleProxyStatus();
|
||||
return;
|
||||
|
||||
@@ -2,21 +2,87 @@
|
||||
* CLIProxy Lifecycle Management
|
||||
*
|
||||
* Handles:
|
||||
* - ccs cliproxy start
|
||||
* - ccs cliproxy restart
|
||||
* - ccs cliproxy status
|
||||
* - ccs cliproxy stop
|
||||
*/
|
||||
|
||||
import { initUI, header, color, dim, ok, warn, info } from '../../utils/ui';
|
||||
import { getProxyStatus, stopProxy } from '../../cliproxy/services';
|
||||
import { getProxyStatus, startProxy, stopProxy } from '../../cliproxy/services';
|
||||
import { detectRunningProxy } from '../../cliproxy/proxy-detector';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config-generator';
|
||||
import { CLIPROXY_DEFAULT_PORT, validatePort } from '../../cliproxy/config/port-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
|
||||
/**
|
||||
* Resolve the local CLIProxy lifecycle port from unified config.
|
||||
* Falls back to default port when unset/invalid.
|
||||
*/
|
||||
export function resolveLifecyclePort(): number {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT);
|
||||
}
|
||||
|
||||
export async function handleStart(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('Start CLIProxy'));
|
||||
console.log('');
|
||||
|
||||
const port = resolveLifecyclePort();
|
||||
const result = await startProxy(port, verbose);
|
||||
if (result.started) {
|
||||
if (result.alreadyRunning) {
|
||||
console.log(info(`CLIProxy already running on port ${result.port}`));
|
||||
if (result.configRegenerated) {
|
||||
console.log(warn('Config updated - restart CLIProxy to apply changes'));
|
||||
}
|
||||
} else {
|
||||
console.log(ok(`CLIProxy started on port ${result.port}`));
|
||||
}
|
||||
console.log(dim('To stop: ccs cliproxy stop'));
|
||||
} else {
|
||||
console.log(warn(result.error || 'Failed to start CLIProxy'));
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleRestart(verbose = false): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('Restart CLIProxy'));
|
||||
console.log('');
|
||||
|
||||
const port = resolveLifecyclePort();
|
||||
const stopResult = await stopProxy(port);
|
||||
if (stopResult.stopped) {
|
||||
console.log(ok(`CLIProxy stopped (PID ${stopResult.pid})`));
|
||||
} else if (stopResult.error === 'No active CLIProxy session found') {
|
||||
console.log(info('No active CLIProxy session found, starting a new instance'));
|
||||
} else {
|
||||
console.log(warn(stopResult.error || 'Failed to stop existing CLIProxy'));
|
||||
console.log(info('Attempting to start a fresh instance...'));
|
||||
}
|
||||
|
||||
const startResult = await startProxy(port, verbose);
|
||||
if (startResult.started) {
|
||||
if (startResult.alreadyRunning) {
|
||||
console.log(info(`CLIProxy already running on port ${startResult.port}`));
|
||||
} else {
|
||||
console.log(ok(`CLIProxy started on port ${startResult.port}`));
|
||||
}
|
||||
} else {
|
||||
console.log(warn(startResult.error || 'Failed to restart CLIProxy'));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
export async function handleProxyStatus(): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('CLIProxy Status'));
|
||||
console.log('');
|
||||
|
||||
const status = getProxyStatus();
|
||||
const port = resolveLifecyclePort();
|
||||
const status = getProxyStatus(port);
|
||||
if (status.running) {
|
||||
console.log(` Status: ${color('Running', 'success')}`);
|
||||
console.log(` PID: ${status.pid}`);
|
||||
@@ -29,11 +95,11 @@ export async function handleProxyStatus(): Promise<void> {
|
||||
console.log(dim('To stop: ccs cliproxy stop'));
|
||||
} else {
|
||||
// Fallback: detect untracked/orphaned proxy process (e.g. detached session without lock file).
|
||||
const detected = await detectRunningProxy();
|
||||
const detected = await detectRunningProxy(port);
|
||||
if (detected.running && detected.verified) {
|
||||
console.log(` Status: ${color('Running', 'success')}`);
|
||||
console.log(` PID: ${detected.pid ?? 'unknown'}`);
|
||||
console.log(` Port: ${CLIPROXY_DEFAULT_PORT}`);
|
||||
console.log(` Port: ${port}`);
|
||||
console.log(` Sessions: ${detected.sessionCount || 0} active`);
|
||||
if (!detected.sessionCount) {
|
||||
console.log(dim(' Note: Detected running proxy without local session lock'));
|
||||
@@ -54,7 +120,8 @@ export async function handleStop(): Promise<void> {
|
||||
console.log(header('Stop CLIProxy'));
|
||||
console.log('');
|
||||
|
||||
const result = await stopProxy();
|
||||
const port = resolveLifecyclePort();
|
||||
const result = await stopProxy(port);
|
||||
if (result.stopped) {
|
||||
console.log(ok(`CLIProxy stopped (PID ${result.pid})`));
|
||||
if (result.sessionCount && result.sessionCount > 0) {
|
||||
|
||||
+111
-13
@@ -5,7 +5,7 @@
|
||||
* Uses CursorExecutor for OpenAI-compatible API proxy to Cursor backend.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { spawn, spawnSync, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
@@ -77,8 +77,26 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
|
||||
timeout: 3000,
|
||||
},
|
||||
(res) => {
|
||||
res.resume(); // Drain response body
|
||||
resolve(res.statusCode === 200);
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(body) as { service?: string };
|
||||
resolve(payload.service === 'cursor-daemon');
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -95,6 +113,79 @@ export async function isDaemonRunning(port: number): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
type DaemonOwnershipStatus = 'owned' | 'not-owned' | 'not-running' | 'unknown';
|
||||
|
||||
function getProcessCommandLine(pid: number): string | null {
|
||||
if (process.platform === 'linux') {
|
||||
try {
|
||||
// /proc cmdline uses null separators between arguments.
|
||||
return fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').replace(/\0/g, ' ').trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
return result.stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" | Select-Object -ExpandProperty CommandLine)`;
|
||||
const shells = ['powershell.exe', 'powershell', 'pwsh.exe', 'pwsh'];
|
||||
for (const shell of shells) {
|
||||
try {
|
||||
const result = spawnSync(shell, ['-NoProfile', '-Command', command], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.error) {
|
||||
continue;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
return result.stdout.trim();
|
||||
} catch {
|
||||
// Try next shell candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function verifyDaemonOwnership(pid: number): DaemonOwnershipStatus {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === 'ESRCH') {
|
||||
return 'not-running';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const commandLine = getProcessCommandLine(pid);
|
||||
if (!commandLine) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const looksLikeCursorDaemon =
|
||||
commandLine.includes('--ccs-daemon') && commandLine.includes('cursor-daemon-entry');
|
||||
|
||||
return looksLikeCursorDaemon ? 'owned' : 'not-owned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get daemon status.
|
||||
*/
|
||||
@@ -292,16 +383,23 @@ export async function stopDaemon(): Promise<{ success: boolean; error?: string }
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify the PID belongs to our daemon before signaling
|
||||
try {
|
||||
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
||||
if (!cmdline.includes('--ccs-daemon')) {
|
||||
// PID was reused by an unrelated process
|
||||
removePidFile();
|
||||
return { success: true };
|
||||
}
|
||||
} catch {
|
||||
// /proc not available (macOS/Windows) or process gone — proceed with kill
|
||||
const ownership = verifyDaemonOwnership(pid);
|
||||
if (ownership === 'not-running') {
|
||||
removePidFile();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
// PID was reused by an unrelated process.
|
||||
removePidFile();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
|
||||
};
|
||||
}
|
||||
|
||||
// Send SIGTERM to the process
|
||||
|
||||
@@ -334,6 +334,32 @@ describe('applyThinkingConfig - composite variant integration', () => {
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
});
|
||||
|
||||
it('applies tier thinking even when default model does not support thinking', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-4o-mini',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(
|
||||
envVars,
|
||||
'codex' as CLIProxyProvider,
|
||||
undefined,
|
||||
{
|
||||
sonnet: 'high',
|
||||
},
|
||||
{
|
||||
sonnet: { provider: 'agy' as CLIProxyProvider },
|
||||
}
|
||||
);
|
||||
|
||||
// Default model provider/model do not support thinking.
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gpt-4o-mini');
|
||||
// Supported mixed-provider tier must still receive its configured thinking value.
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
});
|
||||
|
||||
it('should handle numeric budgets in per-tier thinking', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||
@@ -468,13 +494,11 @@ describe('applyThinkingConfig - composite variant integration', () => {
|
||||
compositeTierThinking
|
||||
);
|
||||
|
||||
// Base model check uses ANTHROPIC_MODEL with suffix — supportsThinking
|
||||
// may not recognize suffixed model names, causing early return.
|
||||
// All models preserved as-is.
|
||||
// Existing suffix on main model should be preserved.
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||
// Opus stays unchanged because function returned early
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||
// Other supported tiers still receive configured thinking.
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(xhigh)');
|
||||
});
|
||||
|
||||
it('should use codex effort suffix style when provider is codex', () => {
|
||||
|
||||
@@ -407,6 +407,26 @@ cliproxy:
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Invalid tier config for 'opus'");
|
||||
});
|
||||
|
||||
it('stores composite settings path under active CCS_DIR', () => {
|
||||
const result = createCompositeVariant({
|
||||
name: 'scoped',
|
||||
defaultTier: 'sonnet',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
|
||||
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const variants = listVariantsFromConfig();
|
||||
const expectedPath = path.join(tmpDir, 'composite-scoped.settings.json');
|
||||
expect(variants.scoped.settings).toBe(expectedPath);
|
||||
expect(result.settingsPath).toBe(expectedPath);
|
||||
expect(fs.existsSync(expectedPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveCompositeVariantUnified', () => {
|
||||
|
||||
@@ -491,6 +491,18 @@ describe('CLIProxy Command - Proxy Lifecycle', () => {
|
||||
});
|
||||
|
||||
describe('Command Routing', () => {
|
||||
it('routes "start" subcommand correctly', () => {
|
||||
const args = ['cliproxy', 'start'];
|
||||
const subcommand = args[1];
|
||||
assert.strictEqual(subcommand, 'start');
|
||||
});
|
||||
|
||||
it('routes "restart" subcommand correctly', () => {
|
||||
const args = ['cliproxy', 'restart'];
|
||||
const subcommand = args[1];
|
||||
assert.strictEqual(subcommand, 'restart');
|
||||
});
|
||||
|
||||
it('routes "stop" subcommand correctly', () => {
|
||||
const args = ['cliproxy', 'stop'];
|
||||
const subcommand = args[1];
|
||||
@@ -504,7 +516,7 @@ describe('CLIProxy Command - Proxy Lifecycle', () => {
|
||||
});
|
||||
|
||||
it('handles unknown subcommand', () => {
|
||||
const validSubcommands = ['stop', 'status', 'create', 'list', 'remove'];
|
||||
const validSubcommands = ['start', 'restart', 'stop', 'status', 'create', 'list', 'remove'];
|
||||
const unknownCommand = 'invalid';
|
||||
assert.strictEqual(validSubcommands.includes(unknownCommand), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
|
||||
|
||||
let tempDir: string;
|
||||
let originalCcsDir: string | undefined;
|
||||
|
||||
function writeUnifiedConfig(localPort: number): void {
|
||||
const configPath = path.join(tempDir, 'config.yaml');
|
||||
const yaml = `version: 2
|
||||
accounts: {}
|
||||
profiles: {}
|
||||
preferences:
|
||||
theme: system
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
- codex
|
||||
- agy
|
||||
variants: {}
|
||||
cliproxy_server:
|
||||
local:
|
||||
port: ${localPort}
|
||||
`;
|
||||
fs.writeFileSync(configPath, yaml, 'utf8');
|
||||
}
|
||||
|
||||
describe('resolveLifecyclePort', () => {
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-'));
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsDir !== undefined) {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
} else {
|
||||
delete process.env.CCS_DIR;
|
||||
}
|
||||
|
||||
if (tempDir && fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses configured cliproxy_server.local.port', () => {
|
||||
writeUnifiedConfig(9456);
|
||||
expect(resolveLifecyclePort()).toBe(9456);
|
||||
});
|
||||
|
||||
it('falls back to default port when configured local port is invalid', () => {
|
||||
writeUnifiedConfig(70000);
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
|
||||
it('falls back to default port when config file is missing', () => {
|
||||
expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as http from 'http';
|
||||
import { spawn } from 'child_process';
|
||||
import {
|
||||
getPidFromFile,
|
||||
writePidToFile,
|
||||
@@ -180,6 +182,37 @@ describe('isDaemonRunning', () => {
|
||||
const result = await isDaemonRunning(19999);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when /health is 200 but service is not cursor-daemon', async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, service: 'not-cursor-daemon' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
|
||||
const result = await isDaemonRunning(address.port);
|
||||
expect(result).toBe(false);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDaemonStatus', () => {
|
||||
@@ -217,6 +250,36 @@ describe('stopDaemon', () => {
|
||||
const pidFile = path.join(getTestCursorDir(), 'daemon.pid');
|
||||
expect(fs.existsSync(pidFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not terminate unrelated process from stale PID file', async () => {
|
||||
const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
unrelatedProcess.unref();
|
||||
|
||||
const unrelatedPid = unrelatedProcess.pid;
|
||||
expect(unrelatedPid).toBeDefined();
|
||||
if (!unrelatedPid) {
|
||||
throw new Error('Failed to spawn unrelated process');
|
||||
}
|
||||
|
||||
writePidToFile(unrelatedPid);
|
||||
|
||||
try {
|
||||
const result = await stopDaemon();
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Unrelated process should still be alive.
|
||||
expect(() => process.kill(unrelatedPid, 0)).not.toThrow();
|
||||
} finally {
|
||||
try {
|
||||
process.kill(unrelatedPid, 'SIGTERM');
|
||||
} catch {
|
||||
// Process already exited.
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCursorCommand', () => {
|
||||
|
||||
Reference in New Issue
Block a user