diff --git a/src/cliproxy/accounts/__tests__/drain-order.test.ts b/src/cliproxy/accounts/__tests__/drain-order.test.ts index a8942d28..cc397a65 100644 --- a/src/cliproxy/accounts/__tests__/drain-order.test.ts +++ b/src/cliproxy/accounts/__tests__/drain-order.test.ts @@ -933,3 +933,36 @@ describe('fetchProxyAuthCooldowns', () => { }); }); }); + +describe('DRAIN_ORDER_MIN_VERSION and isDrainOrderSupported (#1724)', () => { + it('defines backend-specific minimum versions for drain-order priorities', async () => { + const { DRAIN_ORDER_MIN_VERSION } = await loadDrainOrder(); + expect(DRAIN_ORDER_MIN_VERSION).toEqual({ + original: '6.6.106', + plus: '6.6.107-0', + }); + }); + + it('evaluates original backend boundary correctly', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('original', '6.6.105')).toBe(false); + expect(isDrainOrderSupported('original', '6.6.106')).toBe(true); + expect(isDrainOrderSupported('original', '6.7.0')).toBe(true); + }); + + it('evaluates plus backend boundary with fork suffixes correctly', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('plus', '6.6.105-9')).toBe(false); + expect(isDrainOrderSupported('plus', '6.6.106-0')).toBe(false); + expect(isDrainOrderSupported('plus', '6.6.107-0')).toBe(true); + expect(isDrainOrderSupported('plus', '7.2.127-7')).toBe(true); + }); + + it('rejects invalid and empty version strings', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('original', '')).toBe(false); + expect(isDrainOrderSupported('original', 'invalid')).toBe(false); + expect(isDrainOrderSupported('plus', '')).toBe(false); + expect(isDrainOrderSupported('plus', 'invalid')).toBe(false); + }); +}); diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts index 6f1e82ce..b8014818 100644 --- a/src/cliproxy/accounts/drain-order.ts +++ b/src/cliproxy/accounts/drain-order.ts @@ -21,11 +21,32 @@ import { } from '../proxy/proxy-target-resolver'; import { loadDrainOrderConfig } from './registry'; import type { AccountTier } from './types'; -import type { CLIProxyProvider } from '../types'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { + type CLIProxyBackendMinVersions, + meetsBackendMinimumVersion, +} from '../binary/version-checker'; /** Minimum valid priority value. Management layer treats 0 as delete. */ export const MIN_PRIORITY = 1; +/** + * Minimum CLIProxy version that supports drain-order priority selection. + * Original: v6.6.106 + * Plus: v6.6.107-0 + */ +export const DRAIN_ORDER_MIN_VERSION: CLIProxyBackendMinVersions = { + original: '6.6.106', + plus: '6.6.107-0', +}; + +export function isDrainOrderSupported( + backend: CLIProxyBackend, + installedVersion: string +): boolean { + return meetsBackendMinimumVersion(installedVersion, backend, DRAIN_ORDER_MIN_VERSION); +} + /** Management API path for patching auth file fields */ const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields'; diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts index 8cc917d6..37fc0893 100644 --- a/src/commands/cliproxy/__tests__/order-subcommand.test.ts +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -24,6 +24,7 @@ mock.module('../../../cliproxy/proxy/proxy-detector', () => ({ describe('handleOrderSubcommand', () => { let tempHome: string; let originalCcsHome: string | undefined; + let originalNoColor: string | undefined; let logSpy: ReturnType; let lines: string[]; @@ -35,7 +36,16 @@ describe('handleOrderSubcommand', () => { fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); fs.writeFileSync( path.join(authDir(), fileName), - JSON.stringify({ type: 'claude', ...fields }, null, 2), + JSON.stringify({ type: 'claude', email: fileName, ...fields }, null, 2), + { mode: 0o600 } + ); + } + + function writeAgyAuthFile(fileName: string, fields: Record = {}): void { + fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(authDir(), fileName), + JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2), { mode: 0o600 } ); } @@ -47,6 +57,24 @@ describe('handleOrderSubcommand', () => { >; } + async function loadRegistry() { + return import(`../../../cliproxy/accounts/registry?order-subcommand-reg=${Date.now()}`); + } + + async function configureBackend(backend: 'original' | 'plus', version: string): Promise { + const { mutateConfig, invalidateConfigCache } = await import( + '../../../config/config-loader-facade' + ); + mutateConfig((cfg) => { + if (!cfg.cliproxy) cfg.cliproxy = {}; + cfg.cliproxy.backend = backend; + }); + invalidateConfigCache(); + const verPath = path.join(tempHome, '.ccs', 'cliproxy', 'bin', backend, '.version'); + fs.mkdirSync(path.dirname(verPath), { recursive: true }); + fs.writeFileSync(verPath, version.trim() + '\n', 'utf-8'); + } + async function registerClaude(): Promise<{ registerAccount: (provider: string, tokenFile: string, email: string) => unknown; saveDrainOrderConfig: (provider: string, config: unknown) => boolean; @@ -64,7 +92,9 @@ describe('handleOrderSubcommand', () => { beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-')); originalCcsHome = process.env.CCS_HOME; + originalNoColor = process.env.NO_COLOR; process.env.CCS_HOME = tempHome; + process.env.NO_COLOR = '1'; process.exitCode = 0; lines = []; logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { @@ -80,6 +110,11 @@ describe('handleOrderSubcommand', () => { } else { delete process.env.CCS_HOME; } + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } fs.rmSync(tempHome, { recursive: true, force: true }); }); @@ -164,5 +199,126 @@ describe('handleOrderSubcommand', () => { expect(output).toContain('reset to file order'); expect(output).toContain('no priority set'); }); +}); + + describe('binary version capability gate (#1724)', () => { + const cases: Array<{ + backend: 'original' | 'plus'; + belowMinVersion: string; + atMinVersion: string; + backendLabel: string; + requiredMinVersion: string; + }> = [ + { + backend: 'original', + belowMinVersion: '6.6.105', + atMinVersion: '6.6.106', + backendLabel: 'CLIProxy', + requiredMinVersion: '6.6.106', + }, + { + backend: 'plus', + belowMinVersion: '6.6.105-0', + atMinVersion: '6.6.107-0', + backendLabel: 'CLIProxy Plus', + requiredMinVersion: '6.6.107-0', + }, + ]; + + for (const { backend, belowMinVersion, atMinVersion, backendLabel, requiredMinVersion } of cases) { + describe(`${backend} backend`, () => { + it(`refuses --set below minimum version (${belowMinVersion}) without mutating files`, async () => { + await configureBackend(backend, belowMinVersion); + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']); + + expect(process.exitCode).toBe(1); + const output = lines.join('\n'); + expect(output).toContain('[X]'); + expect(output).toContain( + `${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).` + ); + expect(output).toContain( + `Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.` + ); + expect(output).not.toContain('Set priorities'); + expect('priority' in readAuthFile('claude-a.json')).toBe(false); + expect('priority' in readAuthFile('claude-b.json')).toBe(false); + expect(loadDrainOrderConfig('claude')).toBeUndefined(); + }); + + it(`refuses --by-tier below minimum version (${belowMinVersion}) without mutating files`, async () => { + await configureBackend(backend, belowMinVersion); + writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' }); + writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' }); + + const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + setAccountTier('agy', 'a@x.com', 'pro'); + setAccountTier('agy', 'b@x.com', 'free'); + + await runOrderSubcommand(['agy', '--by-tier']); + + expect(process.exitCode).toBe(1); + const output = lines.join('\n'); + expect(output).toContain('[X]'); + expect(output).toContain( + `${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).` + ); + expect(output).toContain( + `Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.` + ); + expect(output).not.toContain('Set priorities'); + expect('priority' in readAuthFile('antigravity-a.json')).toBe(false); + expect('priority' in readAuthFile('antigravity-b.json')).toBe(false); + expect(loadDrainOrderConfig('agy')).toBeUndefined(); + }); + + it(`applies and persists --set at minimum version (${atMinVersion})`, async () => { + await configureBackend(backend, atMinVersion); + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']); + + expect(process.exitCode).toBe(0); + const output = lines.join('\n'); + expect(output).toContain('Set priorities for 2 account(s).'); + expect('priority' in readAuthFile('claude-a.json')).toBe(true); + expect(loadDrainOrderConfig('claude')?.mode).toBe('manual'); + }); + + it(`applies and persists --by-tier at minimum version (${atMinVersion})`, async () => { + await configureBackend(backend, atMinVersion); + writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' }); + writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' }); + + const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + setAccountTier('agy', 'a@x.com', 'pro'); + setAccountTier('agy', 'b@x.com', 'free'); + + await runOrderSubcommand(['agy', '--by-tier']); + + expect(process.exitCode).toBe(0); + const output = lines.join('\n'); + expect(output).toContain('Set priorities for 2 account(s).'); + expect('priority' in readAuthFile('antigravity-a.json')).toBe(true); + expect(loadDrainOrderConfig('agy')?.mode).toBe('tier'); + }); + }); + } }); }); diff --git a/src/commands/cliproxy/order-subcommand.ts b/src/commands/cliproxy/order-subcommand.ts index b2b50061..29d3c6cf 100644 --- a/src/commands/cliproxy/order-subcommand.ts +++ b/src/commands/cliproxy/order-subcommand.ts @@ -12,6 +12,7 @@ import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../ import { extractOption, hasAnyFlag } from '../arg-extractor'; import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry'; import { getProviderAccounts } from '../../cliproxy/accounts/query'; +import { getConfiguredBackend, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager'; import { computeManualDrainOrder, computeTierDrainOrder, @@ -19,6 +20,8 @@ import { resolveEffectiveDrainOrder, clearDrainOrderPriorities, tieBreakKey, + DRAIN_ORDER_MIN_VERSION, + isDrainOrderSupported, type DrainOrderEntry, type DrainOrderInput, } from '../../cliproxy/accounts/drain-order'; @@ -45,6 +48,27 @@ function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): s return tierDerived ? label : dim(tier); } +function ensureDrainOrderBinarySupport(): boolean { + const backend = getConfiguredBackend(); + const installedVersion = getInstalledCliproxyVersion(backend); + if (!isDrainOrderSupported(backend, installedVersion)) { + const minimumVersion = DRAIN_ORDER_MIN_VERSION[backend]; + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + console.log( + fail( + `${backendLabel} v${installedVersion} does not support drain-order priorities (requires v${minimumVersion} or newer).` + ) + ); + console.log( + info(`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`) + ); + console.log(''); + process.exitCode = 1; + return false; + } + return true; +} + function printOrderTable( entries: DrainOrderEntry[], showCurrentPriority: boolean, @@ -281,6 +305,9 @@ async function handleOrderByTier(provider: CLIProxyProvider): Promise { const entries = computeTierDrainOrder(accounts); + if (!ensureDrainOrderBinarySupport()) { + return; + } console.log(subheader('Computed tier-based drain order:')); printOrderTable(entries, false); console.log(''); @@ -404,6 +431,10 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi return; } + if (!ensureDrainOrderBinarySupport()) { + return; + } + console.log(subheader('Computed manual drain order:')); printOrderTable(entries, false); console.log('');