mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
fix(core): resolve edge cases and hardcoded drift
This commit is contained in:
@@ -140,6 +140,25 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
|
|||||||
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
|
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Providers currently supported by quota status fetchers. */
|
||||||
|
export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([
|
||||||
|
'agy',
|
||||||
|
'codex',
|
||||||
|
'gemini',
|
||||||
|
'ghcp',
|
||||||
|
] as const);
|
||||||
|
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDER_IDS)[number];
|
||||||
|
const QUOTA_SUPPORTED_PROVIDER_SET = new Set<QuotaSupportedProvider>(QUOTA_SUPPORTED_PROVIDER_IDS);
|
||||||
|
|
||||||
|
export const QUOTA_PROVIDER_OPTION_VALUES = Object.freeze(
|
||||||
|
[
|
||||||
|
...QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||||
|
...QUOTA_SUPPORTED_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases),
|
||||||
|
'all',
|
||||||
|
].filter((value, index, values) => values.indexOf(value) === index)
|
||||||
|
);
|
||||||
|
export const QUOTA_PROVIDER_HELP_TEXT = QUOTA_PROVIDER_OPTION_VALUES.join(', ');
|
||||||
|
|
||||||
export function buildProviderMap<T>(
|
export function buildProviderMap<T>(
|
||||||
valueFor: (provider: CLIProxyProvider) => T
|
valueFor: (provider: CLIProxyProvider) => T
|
||||||
): Record<CLIProxyProvider, T> {
|
): Record<CLIProxyProvider, T> {
|
||||||
@@ -243,6 +262,15 @@ export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapExternalProviderName(providerName: string): CLIProxyProvider | null {
|
export function mapExternalProviderName(providerName: string): CLIProxyProvider | null {
|
||||||
const normalized = providerName.toLowerCase();
|
const normalized = providerName.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return PROVIDER_ALIAS_MAP.get(normalized) ?? null;
|
return PROVIDER_ALIAS_MAP.get(normalized) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isQuotaSupportedProvider(
|
||||||
|
provider: CLIProxyProvider
|
||||||
|
): provider is QuotaSupportedProvider {
|
||||||
|
return QUOTA_SUPPORTED_PROVIDER_SET.has(provider as QuotaSupportedProvider);
|
||||||
|
}
|
||||||
|
|||||||
+119
-16
@@ -53,8 +53,14 @@ interface ApiCommandArgs {
|
|||||||
preset?: string;
|
preset?: string;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
|
errors: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const;
|
||||||
|
const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const;
|
||||||
|
const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
|
||||||
|
const API_VALUE_FLAG_SET = new Set<string>(API_VALUE_FLAGS);
|
||||||
|
|
||||||
function sanitizeHelpText(value: string): string {
|
function sanitizeHelpText(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/[\r\n\t]+/g, ' ')
|
.replace(/[\r\n\t]+/g, ' ')
|
||||||
@@ -71,39 +77,129 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
|
|||||||
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
|
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRepeatedOption(
|
||||||
|
args: string[],
|
||||||
|
flags: readonly string[],
|
||||||
|
onValue: (value: string) => void,
|
||||||
|
onMissing: () => void
|
||||||
|
): string[] {
|
||||||
|
let remaining = [...args];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const extracted = extractOption(remaining, flags, {
|
||||||
|
allowDashValue: true,
|
||||||
|
knownFlags: API_KNOWN_FLAGS,
|
||||||
|
});
|
||||||
|
if (!extracted.found) {
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extracted.missingValue || !extracted.value) {
|
||||||
|
onMissing();
|
||||||
|
} else {
|
||||||
|
onValue(extracted.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = extracted.remainingArgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPositionalArgs(args: string[]): string[] {
|
||||||
|
const positionals: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const token = args[i];
|
||||||
|
if (token === '--') {
|
||||||
|
positionals.push(...args.slice(i + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.startsWith('-')) {
|
||||||
|
if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) {
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (next && !next.startsWith('-')) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
positionals.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return positionals;
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse command line arguments for api commands */
|
/** Parse command line arguments for api commands */
|
||||||
function parseArgs(args: string[]): ApiCommandArgs {
|
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
|
||||||
const result: ApiCommandArgs = {
|
const result: ApiCommandArgs = {
|
||||||
force: hasAnyFlag(args, ['--force']),
|
force: hasAnyFlag(args, ['--force']),
|
||||||
yes: hasAnyFlag(args, ['--yes', '-y']),
|
yes: hasAnyFlag(args, ['--yes', '-y']),
|
||||||
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
let remaining = [...args];
|
let remaining = [...args];
|
||||||
|
|
||||||
const baseUrl = extractOption(remaining, ['--base-url']);
|
remaining = applyRepeatedOption(
|
||||||
if (baseUrl.value) result.baseUrl = baseUrl.value;
|
remaining,
|
||||||
remaining = baseUrl.remainingArgs;
|
['--base-url'],
|
||||||
|
(value) => {
|
||||||
|
result.baseUrl = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --base-url');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const apiKey = extractOption(remaining, ['--api-key']);
|
remaining = applyRepeatedOption(
|
||||||
if (apiKey.value) result.apiKey = apiKey.value;
|
remaining,
|
||||||
remaining = apiKey.remainingArgs;
|
['--api-key'],
|
||||||
|
(value) => {
|
||||||
|
result.apiKey = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --api-key');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const model = extractOption(remaining, ['--model']);
|
remaining = applyRepeatedOption(
|
||||||
if (model.value) result.model = model.value;
|
remaining,
|
||||||
remaining = model.remainingArgs;
|
['--model'],
|
||||||
|
(value) => {
|
||||||
|
result.model = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --model');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const preset = extractOption(remaining, ['--preset']);
|
remaining = applyRepeatedOption(
|
||||||
if (preset.value) result.preset = preset.value;
|
remaining,
|
||||||
remaining = preset.remainingArgs;
|
['--preset'],
|
||||||
|
(value) => {
|
||||||
|
result.preset = value;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
result.errors.push('Missing value for --preset');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
result.name = remaining.find((arg) => !arg.startsWith('-'));
|
const positionalArgs = extractPositionalArgs(remaining);
|
||||||
|
result.name = positionalArgs[0];
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle 'ccs api create' command */
|
/** Handle 'ccs api create' command */
|
||||||
async function handleCreate(args: string[]): Promise<void> {
|
async function handleCreate(args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
const parsedArgs = parseArgs(args);
|
const parsedArgs = parseApiCommandArgs(args);
|
||||||
|
|
||||||
|
if (parsedArgs.errors.length > 0) {
|
||||||
|
parsedArgs.errors.forEach((errorMessage) => {
|
||||||
|
console.log(fail(errorMessage));
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(header('Create API Profile'));
|
console.log(header('Create API Profile'));
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -389,7 +485,14 @@ async function handleList(): Promise<void> {
|
|||||||
/** Handle 'ccs api remove' command */
|
/** Handle 'ccs api remove' command */
|
||||||
async function handleRemove(args: string[]): Promise<void> {
|
async function handleRemove(args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
const parsedArgs = parseArgs(args);
|
const parsedArgs = parseApiCommandArgs(args);
|
||||||
|
|
||||||
|
if (parsedArgs.errors.length > 0) {
|
||||||
|
parsedArgs.errors.forEach((errorMessage) => {
|
||||||
|
console.log(fail(errorMessage));
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const apis = getApiProfileNames();
|
const apis = getApiProfileNames();
|
||||||
|
|
||||||
|
|||||||
@@ -9,17 +9,43 @@ export interface ExtractedOption {
|
|||||||
remainingArgs: string[];
|
remainingArgs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExtractOptionOptions {
|
||||||
|
/**
|
||||||
|
* Allow values that start with "-" when they are not recognized flags.
|
||||||
|
* Useful for model IDs or other arbitrary strings.
|
||||||
|
*/
|
||||||
|
allowDashValue?: boolean;
|
||||||
|
/**
|
||||||
|
* Known flags for the current command. Used with allowDashValue to avoid
|
||||||
|
* treating a real flag token as a value.
|
||||||
|
*/
|
||||||
|
knownFlags?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
function findInlineOption(arg: string, flag: string): string | undefined {
|
function findInlineOption(arg: string, flag: string): string | undefined {
|
||||||
const prefix = `${flag}=`;
|
const prefix = `${flag}=`;
|
||||||
return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
|
return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKnownFlagToken(token: string, knownFlags: readonly string[] | undefined): boolean {
|
||||||
|
if (!knownFlags || knownFlags.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return knownFlags.some((flag) => token === flag || token.startsWith(`${flag}=`));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a single-value option and remove it from args.
|
* Extract a single-value option and remove it from args.
|
||||||
* Supports `--flag value` and `--flag=value` forms.
|
* Supports `--flag value` and `--flag=value` forms.
|
||||||
*/
|
*/
|
||||||
export function extractOption(args: string[], flags: readonly string[]): ExtractedOption {
|
export function extractOption(
|
||||||
|
args: string[],
|
||||||
|
flags: readonly string[],
|
||||||
|
options: ExtractOptionOptions = {}
|
||||||
|
): ExtractedOption {
|
||||||
const remaining = [...args];
|
const remaining = [...args];
|
||||||
|
const allowDashValue = options.allowDashValue ?? false;
|
||||||
|
|
||||||
for (let i = 0; i < remaining.length; i++) {
|
for (let i = 0; i < remaining.length; i++) {
|
||||||
const token = remaining[i];
|
const token = remaining[i];
|
||||||
@@ -27,7 +53,14 @@ export function extractOption(args: string[], flags: readonly string[]): Extract
|
|||||||
for (const flag of flags) {
|
for (const flag of flags) {
|
||||||
if (token === flag) {
|
if (token === flag) {
|
||||||
const next = remaining[i + 1];
|
const next = remaining[i + 1];
|
||||||
if (!next || next.startsWith('-')) {
|
if (!next) {
|
||||||
|
remaining.splice(i, 1);
|
||||||
|
return { found: true, missingValue: true, remainingArgs: remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLooksLikeFlag = next.startsWith('-');
|
||||||
|
const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags);
|
||||||
|
if (nextLooksLikeFlag && (!allowDashValue || nextIsKnownFlag)) {
|
||||||
remaining.splice(i, 1);
|
remaining.splice(i, 1);
|
||||||
return { found: true, missingValue: true, remainingArgs: remaining };
|
return { found: true, missingValue: true, remainingArgs: remaining };
|
||||||
}
|
}
|
||||||
@@ -62,5 +95,20 @@ export function extractOption(args: string[], flags: readonly string[]): Extract
|
|||||||
|
|
||||||
/** Returns true if any of the provided boolean flags are present. */
|
/** Returns true if any of the provided boolean flags are present. */
|
||||||
export function hasAnyFlag(args: string[], flags: readonly string[]): boolean {
|
export function hasAnyFlag(args: string[], flags: readonly string[]): boolean {
|
||||||
return args.some((arg) => flags.includes(arg));
|
const truthyValues = new Set(['1', 'true', 'yes', 'on']);
|
||||||
|
return args.some((arg) =>
|
||||||
|
flags.some((flag) => {
|
||||||
|
if (arg === flag) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = `${flag}=`;
|
||||||
|
if (!arg.startsWith(prefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = arg.slice(prefix.length).trim().toLowerCase();
|
||||||
|
return truthyValues.has(value);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getFallbackVersion,
|
getFallbackVersion,
|
||||||
BACKEND_CONFIG,
|
BACKEND_CONFIG,
|
||||||
} from '../../cliproxy/platform-detector';
|
} from '../../cliproxy/platform-detector';
|
||||||
|
import { QUOTA_PROVIDER_HELP_TEXT } from '../../cliproxy/provider-capabilities';
|
||||||
|
|
||||||
export async function showHelp(): Promise<void> {
|
export async function showHelp(): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
@@ -55,7 +56,7 @@ export async function showHelp(): Promise<void> {
|
|||||||
['pause <account>', 'Pause account (skip in rotation)'],
|
['pause <account>', 'Pause account (skip in rotation)'],
|
||||||
['resume <account>', 'Resume paused account'],
|
['resume <account>', 'Resume paused account'],
|
||||||
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
|
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
|
||||||
['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
|
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
|
|
||||||
import { CLIProxyBackend } from '../../cliproxy/types';
|
import { CLIProxyBackend } from '../../cliproxy/types';
|
||||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
||||||
import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities';
|
import {
|
||||||
|
type QuotaSupportedProvider,
|
||||||
|
QUOTA_PROVIDER_HELP_TEXT,
|
||||||
|
mapExternalProviderName,
|
||||||
|
isQuotaSupportedProvider,
|
||||||
|
} from '../../cliproxy/provider-capabilities';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { handleSync } from '../cliproxy-sync-handler';
|
import { handleSync } from '../cliproxy-sync-handler';
|
||||||
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
import { extractOption, hasAnyFlag } from '../arg-extractor';
|
||||||
@@ -75,38 +80,21 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
|
|||||||
/**
|
/**
|
||||||
* Parse --provider flag from args for quota command
|
* Parse --provider flag from args for quota command
|
||||||
* Returns the provider filter value and remaining args
|
* Returns the provider filter value and remaining args
|
||||||
* Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all
|
* Accepts canonical + aliases from quota-supported providers, and `all`
|
||||||
*/
|
*/
|
||||||
type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp';
|
type QuotaProviderFilter = QuotaSupportedProvider | 'all';
|
||||||
type QuotaProviderFilter = QuotaProvider | 'all';
|
|
||||||
|
|
||||||
const PROVIDER_ARG_HELP_TEXT = 'agy, codex, gemini, gemini-cli, ghcp, github-copilot, all';
|
|
||||||
|
|
||||||
const QUOTA_PROVIDER_ALIAS_MAP: Readonly<Record<string, QuotaProvider>> = {
|
|
||||||
'gemini-cli': 'gemini',
|
|
||||||
'github-copilot': 'ghcp',
|
|
||||||
};
|
|
||||||
|
|
||||||
const QUOTA_PROVIDER_IDS = Object.freeze(
|
|
||||||
CLIPROXY_PROVIDER_IDS.filter(
|
|
||||||
(provider): provider is QuotaProvider =>
|
|
||||||
provider === 'agy' || provider === 'codex' || provider === 'gemini' || provider === 'ghcp'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const QUOTA_PROVIDER_SET = new Set<QuotaProvider>(QUOTA_PROVIDER_IDS);
|
|
||||||
|
|
||||||
function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
|
function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
|
||||||
if (value === 'all') {
|
if (value === 'all') {
|
||||||
return 'all';
|
return 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = QUOTA_PROVIDER_ALIAS_MAP[value] ?? value;
|
const canonicalProvider = mapExternalProviderName(value);
|
||||||
if (!QUOTA_PROVIDER_SET.has(normalized as QuotaProvider)) {
|
if (!canonicalProvider || !isQuotaSupportedProvider(canonicalProvider)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized as QuotaProvider;
|
return canonicalProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseProviderArg(args: string[]): {
|
function parseProviderArg(args: string[]): {
|
||||||
@@ -119,14 +107,16 @@ function parseProviderArg(args: string[]): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (extracted.missingValue || !extracted.value) {
|
if (extracted.missingValue || !extracted.value) {
|
||||||
console.error(`Warning: --provider requires a value. Valid options: ${PROVIDER_ARG_HELP_TEXT}`);
|
console.error(
|
||||||
|
`Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
|
||||||
|
);
|
||||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = extracted.value.toLowerCase();
|
const value = extracted.value.toLowerCase();
|
||||||
const normalized = normalizeQuotaProvider(value);
|
const normalized = normalizeQuotaProvider(value);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
console.error(`Invalid provider '${value}'. Valid options: ${PROVIDER_ARG_HELP_TEXT}`);
|
console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`);
|
||||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import type {
|
|||||||
} from '../../cliproxy/quota-types';
|
} from '../../cliproxy/quota-types';
|
||||||
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
||||||
import { CLIProxyProvider } from '../../cliproxy/types';
|
import { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
|
import {
|
||||||
|
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||||
|
type QuotaSupportedProvider,
|
||||||
|
} from '../../cliproxy/provider-capabilities';
|
||||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
||||||
|
|
||||||
interface CliproxyProfileArgs {
|
interface CliproxyProfileArgs {
|
||||||
@@ -481,65 +485,99 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QuotaProviderRuntime {
|
||||||
|
fetch: (verbose: boolean) => Promise<unknown>;
|
||||||
|
hasData: (result: unknown) => boolean;
|
||||||
|
render: (result: unknown) => void;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
authCommand: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntime> = {
|
||||||
|
agy: {
|
||||||
|
fetch: (verbose) => fetchAllProviderQuotas('agy', verbose),
|
||||||
|
hasData: (result) =>
|
||||||
|
(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>).accounts.length > 0,
|
||||||
|
render: (result) =>
|
||||||
|
displayAntigravityQuotaSection(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>),
|
||||||
|
emptyTitle: 'Antigravity (0 accounts)',
|
||||||
|
emptyMessage: 'No Antigravity accounts configured',
|
||||||
|
authCommand: 'ccs agy --auth',
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
fetch: (verbose) => fetchAllCodexQuotas(verbose),
|
||||||
|
hasData: (result) => (result as { account: string; quota: CodexQuotaResult }[]).length > 0,
|
||||||
|
render: (result) =>
|
||||||
|
displayCodexQuotaSection(result as { account: string; quota: CodexQuotaResult }[]),
|
||||||
|
emptyTitle: 'Codex (0 accounts)',
|
||||||
|
emptyMessage: 'No Codex accounts configured',
|
||||||
|
authCommand: 'ccs codex --auth',
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
fetch: (verbose) => fetchAllGeminiCliQuotas(verbose),
|
||||||
|
hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0,
|
||||||
|
render: (result) =>
|
||||||
|
displayGeminiCliQuotaSection(result as { account: string; quota: GeminiCliQuotaResult }[]),
|
||||||
|
emptyTitle: 'Gemini CLI (0 accounts)',
|
||||||
|
emptyMessage: 'No Gemini CLI accounts configured',
|
||||||
|
authCommand: 'ccs gemini --auth',
|
||||||
|
},
|
||||||
|
ghcp: {
|
||||||
|
fetch: (verbose) => fetchAllGhcpQuotas(verbose),
|
||||||
|
hasData: (result) => (result as { account: string; quota: GhcpQuotaResult }[]).length > 0,
|
||||||
|
render: (result) =>
|
||||||
|
displayGhcpQuotaSection(result as { account: string; quota: GhcpQuotaResult }[]),
|
||||||
|
emptyTitle: 'GitHub Copilot (0 accounts)',
|
||||||
|
emptyMessage: 'No GitHub Copilot accounts configured',
|
||||||
|
authCommand: 'ccs ghcp --auth',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export async function handleQuotaStatus(
|
export async function handleQuotaStatus(
|
||||||
verbose = false,
|
verbose = false,
|
||||||
providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all'
|
providerFilter: QuotaSupportedProvider | 'all' = 'all'
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
console.log(header('Quota Status'));
|
console.log(header('Quota Status'));
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const shouldFetch = {
|
const requestedProviders = new Set<QuotaSupportedProvider>(
|
||||||
agy: providerFilter === 'all' || providerFilter === 'agy',
|
providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter]
|
||||||
codex: providerFilter === 'all' || providerFilter === 'codex',
|
);
|
||||||
gemini: providerFilter === 'all' || providerFilter === 'gemini',
|
const shouldFetch = (provider: QuotaSupportedProvider): boolean =>
|
||||||
ghcp: providerFilter === 'all' || providerFilter === 'ghcp',
|
requestedProviders.has(provider);
|
||||||
};
|
|
||||||
|
|
||||||
console.log(dim('Fetching quotas...'));
|
console.log(dim('Fetching quotas...'));
|
||||||
|
|
||||||
const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([
|
const providerResults = new Map<QuotaSupportedProvider, unknown | null>(
|
||||||
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
|
await Promise.all(
|
||||||
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
|
QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => {
|
||||||
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
|
if (!shouldFetch(provider)) {
|
||||||
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
|
return [provider, null] as const;
|
||||||
]);
|
}
|
||||||
|
return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
if (agyResults && agyResults.accounts.length > 0) {
|
for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) {
|
||||||
displayAntigravityQuotaSection(agyResults);
|
if (!shouldFetch(provider)) {
|
||||||
} else if (shouldFetch.agy) {
|
continue;
|
||||||
console.log(subheader('Antigravity (0 accounts)'));
|
}
|
||||||
console.log(info('No Antigravity accounts configured'));
|
|
||||||
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (codexResults && codexResults.length > 0) {
|
const runtime = QUOTA_PROVIDER_RUNTIME[provider];
|
||||||
displayCodexQuotaSection(codexResults);
|
const result = providerResults.get(provider) ?? null;
|
||||||
} else if (shouldFetch.codex) {
|
if (result !== null && runtime.hasData(result)) {
|
||||||
console.log(subheader('Codex (0 accounts)'));
|
runtime.render(result);
|
||||||
console.log(info('No Codex accounts configured'));
|
continue;
|
||||||
console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`);
|
}
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geminiResults && geminiResults.length > 0) {
|
console.log(subheader(runtime.emptyTitle));
|
||||||
displayGeminiCliQuotaSection(geminiResults);
|
console.log(info(runtime.emptyMessage));
|
||||||
} else if (shouldFetch.gemini) {
|
console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`);
|
||||||
console.log(subheader('Gemini CLI (0 accounts)'));
|
|
||||||
console.log(info('No Gemini CLI accounts configured'));
|
|
||||||
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
|
|
||||||
console.log('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ghcpResults && ghcpResults.length > 0) {
|
|
||||||
displayGhcpQuotaSection(ghcpResults);
|
|
||||||
} else if (shouldFetch.ghcp) {
|
|
||||||
console.log(subheader('GitHub Copilot (0 accounts)'));
|
|
||||||
console.log(info('No GitHub Copilot accounts configured'));
|
|
||||||
console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`);
|
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
loadOrCreateUnifiedConfig,
|
loadOrCreateUnifiedConfig,
|
||||||
} from '../config/unified-config-loader';
|
} from '../config/unified-config-loader';
|
||||||
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
|
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
|
||||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
import {
|
||||||
|
CLIPROXY_PROVIDER_IDS,
|
||||||
|
PROVIDER_CAPABILITIES,
|
||||||
|
mapExternalProviderName,
|
||||||
|
} from '../cliproxy/provider-capabilities';
|
||||||
import { extractOption, hasAnyFlag } from './arg-extractor';
|
import { extractOption, hasAnyFlag } from './arg-extractor';
|
||||||
|
|
||||||
interface ImageAnalysisCommandOptions {
|
interface ImageAnalysisCommandOptions {
|
||||||
@@ -20,9 +24,16 @@ interface ImageAnalysisCommandOptions {
|
|||||||
disable?: boolean;
|
disable?: boolean;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
setModel?: { provider: string; model: string };
|
setModel?: { provider: string; model: string };
|
||||||
|
setModelError?: string;
|
||||||
help?: boolean;
|
help?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const IMAGE_ANALYSIS_PROVIDER_ALIASES = Object.freeze(
|
||||||
|
CLIPROXY_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases).filter(
|
||||||
|
(alias, index, aliases) => aliases.indexOf(alias) === index
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
function parseArgs(args: string[]): ImageAnalysisCommandOptions {
|
function parseArgs(args: string[]): ImageAnalysisCommandOptions {
|
||||||
const options: ImageAnalysisCommandOptions = {
|
const options: ImageAnalysisCommandOptions = {
|
||||||
enable: hasAnyFlag(args, ['--enable']),
|
enable: hasAnyFlag(args, ['--enable']),
|
||||||
@@ -46,6 +57,8 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions {
|
|||||||
const model = args[setModelIdx + 2];
|
const model = args[setModelIdx + 2];
|
||||||
if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) {
|
if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) {
|
||||||
options.setModel = { provider, model };
|
options.setModel = { provider, model };
|
||||||
|
} else {
|
||||||
|
options.setModelError = '--set-model requires <provider> <model>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +86,10 @@ function showHelp(): void {
|
|||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
console.log(subheader('Provider Models:'));
|
console.log(subheader('Provider Models:'));
|
||||||
console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`);
|
console.log(` ${dim(`Valid providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`)}`);
|
||||||
|
if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) {
|
||||||
|
console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`);
|
||||||
|
}
|
||||||
console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`);
|
console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
@@ -160,6 +176,11 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.setModelError) {
|
||||||
|
console.error(fail(options.setModelError));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
|
// Validate conflicting flags (Edge case #2: --enable + --disable conflict)
|
||||||
if (options.enable && options.disable) {
|
if (options.enable && options.disable) {
|
||||||
console.error(fail('Cannot use --enable and --disable together'));
|
console.error(fail('Cannot use --enable and --disable together'));
|
||||||
@@ -188,9 +209,9 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
|||||||
|
|
||||||
if (options.setModel) {
|
if (options.setModel) {
|
||||||
const validProviders = [...CLIPROXY_PROVIDER_IDS];
|
const validProviders = [...CLIPROXY_PROVIDER_IDS];
|
||||||
if (
|
const normalizedProviderInput = options.setModel.provider.trim().toLowerCase();
|
||||||
!validProviders.includes(options.setModel.provider as (typeof CLIPROXY_PROVIDER_IDS)[number])
|
const canonicalProvider = mapExternalProviderName(normalizedProviderInput);
|
||||||
) {
|
if (!canonicalProvider) {
|
||||||
console.error(fail(`Invalid provider: ${options.setModel.provider}`));
|
console.error(fail(`Invalid provider: ${options.setModel.provider}`));
|
||||||
console.error(info(`Valid providers: ${validProviders.join(', ')}`));
|
console.error(info(`Valid providers: ${validProviders.join(', ')}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -203,7 +224,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
|
|||||||
}
|
}
|
||||||
imageConfig.provider_models = {
|
imageConfig.provider_models = {
|
||||||
...imageConfig.provider_models,
|
...imageConfig.provider_models,
|
||||||
[options.setModel.provider]: model,
|
[canonicalProvider]: model,
|
||||||
};
|
};
|
||||||
hasChanges = true;
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui';
|
import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui';
|
||||||
import { InteractivePrompt } from '../utils/prompt';
|
import { InteractivePrompt } from '../utils/prompt';
|
||||||
import ProfileDetector, {
|
import ProfileDetector, {
|
||||||
@@ -58,7 +59,14 @@ function parseArgs(args: string[]): PersistCommandArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDisplayPath(filePath: string): string {
|
function formatDisplayPath(filePath: string): string {
|
||||||
|
const defaultClaudeDir = path.join(os.homedir(), '.claude');
|
||||||
const claudeDir = getClaudeConfigDir();
|
const claudeDir = getClaudeConfigDir();
|
||||||
|
|
||||||
|
// Keep real path when user overrides Claude directory.
|
||||||
|
if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
if (filePath === claudeDir) {
|
if (filePath === claudeDir) {
|
||||||
return '~/.claude';
|
return '~/.claude';
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,10 @@ function formatDisplayPath(filePath: string): string {
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getClaudeSettingsDisplayPath(): string {
|
||||||
|
return formatDisplayPath(getClaudeSettingsPath());
|
||||||
|
}
|
||||||
|
|
||||||
/** Read existing Claude settings.json with validation */
|
/** Read existing Claude settings.json with validation */
|
||||||
function readClaudeSettings(): Record<string, unknown> {
|
function readClaudeSettings(): Record<string, unknown> {
|
||||||
const settingsPath = getClaudeSettingsPath();
|
const settingsPath = getClaudeSettingsPath();
|
||||||
@@ -175,6 +187,25 @@ interface BackupFile {
|
|||||||
date: Date;
|
date: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBackupTimestamp(timestamp: string): Date | null {
|
||||||
|
const year = parseInt(timestamp.slice(0, 4), 10);
|
||||||
|
const month = parseInt(timestamp.slice(4, 6), 10);
|
||||||
|
const day = parseInt(timestamp.slice(6, 8), 10);
|
||||||
|
const hour = parseInt(timestamp.slice(9, 11), 10);
|
||||||
|
const minute = parseInt(timestamp.slice(11, 13), 10);
|
||||||
|
const second = parseInt(timestamp.slice(13, 15), 10);
|
||||||
|
const date = new Date(year, month - 1, day, hour, minute, second);
|
||||||
|
|
||||||
|
if (date.getFullYear() !== year) return null;
|
||||||
|
if (date.getMonth() !== month - 1) return null;
|
||||||
|
if (date.getDate() !== day) return null;
|
||||||
|
if (date.getHours() !== hour) return null;
|
||||||
|
if (date.getMinutes() !== minute) return null;
|
||||||
|
if (date.getSeconds() !== second) return null;
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get all backup files sorted by date (newest first) */
|
/** Get all backup files sorted by date (newest first) */
|
||||||
function getBackupFiles(): BackupFile[] {
|
function getBackupFiles(): BackupFile[] {
|
||||||
const settingsPath = getClaudeSettingsPath();
|
const settingsPath = getClaudeSettingsPath();
|
||||||
@@ -190,17 +221,12 @@ function getBackupFiles(): BackupFile[] {
|
|||||||
const match = f.match(backupPattern);
|
const match = f.match(backupPattern);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const timestamp = match[1];
|
const timestamp = match[1];
|
||||||
// Parse YYYYMMDD_HHMMSS
|
const date = parseBackupTimestamp(timestamp);
|
||||||
const year = parseInt(timestamp.slice(0, 4));
|
if (!date) return null;
|
||||||
const month = parseInt(timestamp.slice(4, 6)) - 1;
|
|
||||||
const day = parseInt(timestamp.slice(6, 8));
|
|
||||||
const hour = parseInt(timestamp.slice(9, 11));
|
|
||||||
const min = parseInt(timestamp.slice(11, 13));
|
|
||||||
const sec = parseInt(timestamp.slice(13, 15));
|
|
||||||
return {
|
return {
|
||||||
path: path.join(dir, f),
|
path: path.join(dir, f),
|
||||||
timestamp,
|
timestamp,
|
||||||
date: new Date(year, month, day, hour, min, sec),
|
date,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((f): f is BackupFile => f !== null)
|
.filter((f): f is BackupFile => f !== null)
|
||||||
@@ -328,7 +354,7 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
|
|||||||
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
|
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
|
||||||
console.log(`Date: ${backup.date.toLocaleString()}`);
|
console.log(`Date: ${backup.date.toLocaleString()}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(warn('This will replace ~/.claude/settings.json'));
|
console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
if (!yes) {
|
if (!yes) {
|
||||||
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
|
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
|
||||||
@@ -346,6 +372,11 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const nodeError = error as NodeJS.ErrnoException;
|
||||||
|
if (nodeError.code === 'ENOENT') {
|
||||||
|
console.log(fail('Backup was deleted during restore'));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
|
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -377,7 +408,7 @@ async function showHelp(): Promise<void> {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log(subheader('Description'));
|
console.log(subheader('Description'));
|
||||||
console.log(" Writes a profile's environment variables directly to");
|
console.log(" Writes a profile's environment variables directly to");
|
||||||
console.log(' ~/.claude/settings.json for native Claude Code usage.');
|
console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' This allows Claude Code to use the profile without CCS,');
|
console.log(' This allows Claude Code to use the profile without CCS,');
|
||||||
console.log(' enabling compatibility with IDEs and extensions.');
|
console.log(' enabling compatibility with IDEs and extensions.');
|
||||||
@@ -418,7 +449,9 @@ async function showHelp(): Promise<void> {
|
|||||||
console.log(subheader('Notes'));
|
console.log(subheader('Notes'));
|
||||||
console.log(' [i] CLIProxy profiles require the proxy to be running.');
|
console.log(' [i] CLIProxy profiles require the proxy to be running.');
|
||||||
console.log(' [i] Copilot profiles require copilot-api daemon.');
|
console.log(' [i] Copilot profiles require copilot-api daemon.');
|
||||||
console.log(' [i] Backups are saved as ~/.claude/settings.json.backup.YYYYMMDD_HHMMSS');
|
console.log(
|
||||||
|
` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS`
|
||||||
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +511,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log(`Profile type: ${color(resolved.profileType, 'command')}`);
|
console.log(`Profile type: ${color(resolved.profileType, 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('The following env vars will be written to ~/.claude/settings.json:');
|
console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`);
|
||||||
console.log('');
|
console.log('');
|
||||||
// Display env vars (mask sensitive values)
|
// Display env vars (mask sensitive values)
|
||||||
const envKeys = Object.keys(resolved.env);
|
const envKeys = Object.keys(resolved.env);
|
||||||
@@ -502,7 +535,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
|||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
// Warning about modification
|
// Warning about modification
|
||||||
console.log(warn('This will modify ~/.claude/settings.json'));
|
console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`));
|
||||||
console.log(dim(' Existing hooks and other settings will be preserved.'));
|
console.log(dim(' Existing hooks and other settings will be preserved.'));
|
||||||
console.log('');
|
console.log('');
|
||||||
// Check if settings.json exists for backup
|
// Check if settings.json exists for backup
|
||||||
@@ -570,7 +603,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(ok(`Profile '${parsedArgs.profile}' written to ~/.claude/settings.json`));
|
console.log(ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(info('Claude Code will now use this profile by default.'));
|
console.log(info('Claude Code will now use this profile by default.'));
|
||||||
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
|
console.log(dim(' To revert, restore the backup or edit settings.json manually.'));
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export interface ParserOptions {
|
|||||||
projectsDir?: string;
|
projectsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCAN_CONCURRENCY = 10;
|
||||||
|
const MAX_SCAN_CONCURRENCY = 64;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CORE PARSING FUNCTIONS
|
// CORE PARSING FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -71,6 +74,14 @@ export interface ParserOptions {
|
|||||||
* Parse a single JSONL line into RawUsageEntry if valid
|
* Parse a single JSONL line into RawUsageEntry if valid
|
||||||
* Returns null for non-assistant entries or entries without usage data
|
* Returns null for non-assistant entries or entries without usage data
|
||||||
*/
|
*/
|
||||||
|
function toNonNegativeNumber(value: unknown): number {
|
||||||
|
const numeric = typeof value === 'number' ? value : Number(value);
|
||||||
|
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null {
|
export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null {
|
||||||
// Strip UTF-8 BOM if present (can occur on first line of some files)
|
// Strip UTF-8 BOM if present (can occur on first line of some files)
|
||||||
const cleanLine = line.replace(/^\uFEFF/, '').trim();
|
const cleanLine = line.replace(/^\uFEFF/, '').trim();
|
||||||
@@ -88,10 +99,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr
|
|||||||
const assistant = entry as JsonlAssistantEntry;
|
const assistant = entry as JsonlAssistantEntry;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inputTokens: usage.input_tokens || 0,
|
inputTokens: toNonNegativeNumber(usage.input_tokens),
|
||||||
outputTokens: usage.output_tokens || 0,
|
outputTokens: toNonNegativeNumber(usage.output_tokens),
|
||||||
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens),
|
||||||
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens),
|
||||||
model: assistant.message.model,
|
model: assistant.message.model,
|
||||||
sessionId: assistant.sessionId || '',
|
sessionId: assistant.sessionId || '',
|
||||||
timestamp: assistant.timestamp || new Date().toISOString(),
|
timestamp: assistant.timestamp || new Date().toISOString(),
|
||||||
@@ -118,38 +129,48 @@ export async function parseJsonlFile(
|
|||||||
): Promise<RawUsageEntry[]> {
|
): Promise<RawUsageEntry[]> {
|
||||||
const entries: RawUsageEntry[] = [];
|
const entries: RawUsageEntry[] = [];
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
let fileStream: fs.ReadStream | null = null;
|
||||||
return entries;
|
let rl: readline.Interface | null = null;
|
||||||
}
|
try {
|
||||||
|
fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
||||||
|
rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
for await (const line of rl) {
|
||||||
const rl = readline.createInterface({
|
const entry = parseUsageEntry(line, projectPath);
|
||||||
input: fileStream,
|
if (entry) {
|
||||||
crlfDelay: Infinity,
|
entries.push(entry);
|
||||||
});
|
}
|
||||||
|
|
||||||
for await (const line of rl) {
|
|
||||||
const entry = parseUsageEntry(line, projectPath);
|
|
||||||
if (entry) {
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// File read/stream error - return whatever was parsed so far
|
||||||
|
} finally {
|
||||||
|
rl?.close();
|
||||||
|
fileStream?.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeProjectPath(projectDir: string): string {
|
||||||
|
const raw = path.basename(projectDir).replace(/-/g, '/');
|
||||||
|
const safeSegments = raw
|
||||||
|
.split('/')
|
||||||
|
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||||
|
|
||||||
|
return `/${safeSegments.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse all JSONL files in a single project directory
|
* Parse all JSONL files in a single project directory
|
||||||
*/
|
*/
|
||||||
export async function parseProjectDirectory(projectDir: string): Promise<RawUsageEntry[]> {
|
export async function parseProjectDirectory(projectDir: string): Promise<RawUsageEntry[]> {
|
||||||
const entries: RawUsageEntry[] = [];
|
const entries: RawUsageEntry[] = [];
|
||||||
|
|
||||||
if (!fs.existsSync(projectDir)) {
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project")
|
// Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project")
|
||||||
const projectPath = path.basename(projectDir).replace(/-/g, '/');
|
const projectPath = decodeProjectPath(projectDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await fs.promises.readdir(projectDir);
|
const files = await fs.promises.readdir(projectDir);
|
||||||
@@ -185,10 +206,6 @@ export function getDefaultProjectsDir(): string {
|
|||||||
export function findProjectDirectories(projectsDir?: string): string[] {
|
export function findProjectDirectories(projectsDir?: string): string[] {
|
||||||
const dir = projectsDir || getDefaultProjectsDir();
|
const dir = projectsDir || getDefaultProjectsDir();
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
return entries
|
return entries
|
||||||
@@ -207,7 +224,14 @@ export function findProjectDirectories(projectsDir?: string): string[] {
|
|||||||
* @returns All parsed usage entries from all projects
|
* @returns All parsed usage entries from all projects
|
||||||
*/
|
*/
|
||||||
export async function scanProjectsDirectory(options: ParserOptions = {}): Promise<RawUsageEntry[]> {
|
export async function scanProjectsDirectory(options: ParserOptions = {}): Promise<RawUsageEntry[]> {
|
||||||
const { concurrency = 10, projectsDir } = options;
|
const requestedConcurrency = options.concurrency;
|
||||||
|
const concurrency =
|
||||||
|
typeof requestedConcurrency === 'number' &&
|
||||||
|
Number.isInteger(requestedConcurrency) &&
|
||||||
|
requestedConcurrency > 0
|
||||||
|
? Math.min(requestedConcurrency, MAX_SCAN_CONCURRENCY)
|
||||||
|
: DEFAULT_SCAN_CONCURRENCY;
|
||||||
|
const { projectsDir } = options;
|
||||||
const allEntries: RawUsageEntry[] = [];
|
const allEntries: RawUsageEntry[] = [];
|
||||||
|
|
||||||
const projectDirs = findProjectDirectories(projectsDir);
|
const projectDirs = findProjectDirectories(projectsDir);
|
||||||
@@ -230,8 +254,8 @@ export async function scanProjectsDirectory(options: ParserOptions = {}): Promis
|
|||||||
if (options.minDate) {
|
if (options.minDate) {
|
||||||
const minTime = options.minDate.getTime();
|
const minTime = options.minDate.getTime();
|
||||||
return allEntries.filter((entry) => {
|
return allEntries.filter((entry) => {
|
||||||
const entryTime = new Date(entry.timestamp).getTime();
|
const entryTime = Date.parse(entry.timestamp);
|
||||||
return entryTime >= minTime;
|
return Number.isFinite(entryTime) && entryTime >= minTime;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,39 +28,28 @@ interface BackupFile {
|
|||||||
/**
|
/**
|
||||||
* Async mutex for restore operations - prevents race conditions
|
* Async mutex for restore operations - prevents race conditions
|
||||||
*
|
*
|
||||||
* Design: Uses a Promise queue pattern for atomic lock acquisition.
|
* Design: Fast-fail lock.
|
||||||
* When the mutex is locked, subsequent callers are added to a queue
|
* If a restore is already running, callers immediately get `false`
|
||||||
* and immediately receive `false` when released, signaling they should
|
* and the route returns HTTP 409. This avoids request pileup.
|
||||||
* return a 409 Conflict rather than wait. This prevents request pileup
|
|
||||||
* while ensuring only one restore can execute at a time.
|
|
||||||
*/
|
*/
|
||||||
class RestoreMutex {
|
class RestoreMutex {
|
||||||
private locked = false;
|
private locked = false;
|
||||||
private queue: Array<() => void> = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to acquire the mutex
|
* Attempt to acquire the mutex
|
||||||
* @returns true if acquired, false if already locked (queued request)
|
* @returns true if acquired, false if already locked
|
||||||
*/
|
*/
|
||||||
async acquire(): Promise<boolean> {
|
async acquire(): Promise<boolean> {
|
||||||
if (this.locked) {
|
if (this.locked) {
|
||||||
// Already locked - add to queue and wait
|
return false;
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.queue.push(() => resolve(false)); // Return false = was queued, reject
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.locked = true;
|
this.locked = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release the mutex, signaling next queued request (if any) to fail */
|
/** Release the mutex */
|
||||||
release(): void {
|
release(): void {
|
||||||
const next = this.queue.shift();
|
this.locked = false;
|
||||||
if (next) {
|
|
||||||
next(); // Signal queued request to fail
|
|
||||||
} else {
|
|
||||||
this.locked = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +65,25 @@ function isSymlink(filePath: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBackupTimestamp(timestamp: string): Date | null {
|
||||||
|
const year = parseInt(timestamp.slice(0, 4), 10);
|
||||||
|
const month = parseInt(timestamp.slice(4, 6), 10);
|
||||||
|
const day = parseInt(timestamp.slice(6, 8), 10);
|
||||||
|
const hour = parseInt(timestamp.slice(9, 11), 10);
|
||||||
|
const minute = parseInt(timestamp.slice(11, 13), 10);
|
||||||
|
const second = parseInt(timestamp.slice(13, 15), 10);
|
||||||
|
const date = new Date(year, month - 1, day, hour, minute, second);
|
||||||
|
|
||||||
|
if (date.getFullYear() !== year) return null;
|
||||||
|
if (date.getMonth() !== month - 1) return null;
|
||||||
|
if (date.getDate() !== day) return null;
|
||||||
|
if (date.getHours() !== hour) return null;
|
||||||
|
if (date.getMinutes() !== minute) return null;
|
||||||
|
if (date.getSeconds() !== second) return null;
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
/** Get all backup files sorted by date (newest first) */
|
/** Get all backup files sorted by date (newest first) */
|
||||||
function getBackupFiles(): BackupFile[] {
|
function getBackupFiles(): BackupFile[] {
|
||||||
const settingsPath = getClaudeSettingsPath();
|
const settingsPath = getClaudeSettingsPath();
|
||||||
@@ -91,16 +99,12 @@ function getBackupFiles(): BackupFile[] {
|
|||||||
const match = f.match(backupPattern);
|
const match = f.match(backupPattern);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const timestamp = match[1];
|
const timestamp = match[1];
|
||||||
const year = parseInt(timestamp.slice(0, 4));
|
const date = parseBackupTimestamp(timestamp);
|
||||||
const month = parseInt(timestamp.slice(4, 6)) - 1;
|
if (!date) return null;
|
||||||
const day = parseInt(timestamp.slice(6, 8));
|
|
||||||
const hour = parseInt(timestamp.slice(9, 11));
|
|
||||||
const min = parseInt(timestamp.slice(11, 13));
|
|
||||||
const sec = parseInt(timestamp.slice(13, 15));
|
|
||||||
return {
|
return {
|
||||||
path: path.join(dir, f),
|
path: path.join(dir, f),
|
||||||
timestamp,
|
timestamp,
|
||||||
date: new Date(year, month, day, hour, min, sec),
|
date,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((f): f is BackupFile => f !== null)
|
.filter((f): f is BackupFile => f !== null)
|
||||||
@@ -178,16 +182,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
|
|||||||
let backupContent: string;
|
let backupContent: string;
|
||||||
let fd: number | undefined;
|
let fd: number | undefined;
|
||||||
try {
|
try {
|
||||||
// Verify not symlink immediately before open
|
if (typeof fs.constants.O_NOFOLLOW !== 'number') {
|
||||||
const stats = fs.lstatSync(backup.path);
|
res.status(500).json({ error: 'Secure restore unsupported on this platform' });
|
||||||
if (stats.isSymbolicLink()) {
|
|
||||||
res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: 'Backup became symlink during read - refusing for security' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Open file descriptor for atomic read
|
// Open file descriptor for atomic read
|
||||||
fd = fs.openSync(backup.path, 'r');
|
const openFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW;
|
||||||
|
fd = fs.openSync(backup.path, openFlags);
|
||||||
|
const stats = fs.fstatSync(fd);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
res.status(400).json({ error: 'Backup path is not a regular file' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const buffer = Buffer.alloc(stats.size);
|
const buffer = Buffer.alloc(stats.size);
|
||||||
fs.readSync(fd, buffer, 0, stats.size, 0);
|
fs.readSync(fd, buffer, 0, stats.size, 0);
|
||||||
backupContent = buffer.toString('utf8');
|
backupContent = buffer.toString('utf8');
|
||||||
@@ -199,6 +205,10 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err as NodeJS.ErrnoException;
|
const error = err as NodeJS.ErrnoException;
|
||||||
|
if (error.code === 'ELOOP') {
|
||||||
|
res.status(400).json({ error: 'Backup file is a symlink - refusing for security' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (error.code === 'ENOENT') {
|
if (error.code === 'ENOENT') {
|
||||||
res.status(404).json({ error: 'Backup was deleted during restore' });
|
res.status(404).json({ error: 'Backup was deleted during restore' });
|
||||||
return;
|
return;
|
||||||
@@ -217,17 +227,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
|
|||||||
|
|
||||||
// Atomic restore with rollback capability
|
// Atomic restore with rollback capability
|
||||||
const settingsDir = path.dirname(settingsPath);
|
const settingsDir = path.dirname(settingsPath);
|
||||||
const tempPath = path.join(settingsDir, 'settings.json.restore-tmp');
|
const restoreNonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
const rollbackPath = path.join(settingsDir, 'settings.json.rollback-tmp');
|
const tempPath = path.join(settingsDir, `settings.json.restore-${restoreNonce}.tmp`);
|
||||||
|
const rollbackPath = path.join(settingsDir, `settings.json.rollback-${restoreNonce}.tmp`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Backup current settings for rollback
|
// Step 1: Backup current settings for rollback
|
||||||
if (fs.existsSync(settingsPath)) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
fs.copyFileSync(settingsPath, rollbackPath);
|
fs.copyFileSync(settingsPath, rollbackPath, fs.constants.COPYFILE_EXCL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Write validated content to temp file
|
// Step 2: Write validated content to temp file
|
||||||
fs.writeFileSync(tempPath, backupContent, 'utf8');
|
fs.writeFileSync(tempPath, backupContent, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
||||||
|
|
||||||
// Step 3: Atomic rename (replaces existing file)
|
// Step 3: Atomic rename (replaces existing file)
|
||||||
fs.renameSync(tempPath, settingsPath);
|
fs.renameSync(tempPath, settingsPath);
|
||||||
|
|||||||
@@ -161,21 +161,69 @@ export function updateSettingsFile(
|
|||||||
* - ~/.ccs/ directory: read/write allowed
|
* - ~/.ccs/ directory: read/write allowed
|
||||||
* - ~/.claude/settings.json: read-only
|
* - ~/.claude/settings.json: read-only
|
||||||
*/
|
*/
|
||||||
|
function normalizePathForComparison(filePath: string): string {
|
||||||
|
const normalized = path.resolve(path.normalize(filePath));
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithin(basePath: string, targetPath: string): boolean {
|
||||||
|
const relative = path.relative(basePath, targetPath);
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSymlinkPath(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.lstatSync(filePath).isSymbolicLink();
|
||||||
|
} catch (error) {
|
||||||
|
const nodeError = error as NodeJS.ErrnoException;
|
||||||
|
if (nodeError.code === 'ENOENT' || nodeError.code === 'ENOTDIR') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSymlinkSegment(basePath: string, targetPath: string): boolean {
|
||||||
|
const relative = path.relative(basePath, targetPath);
|
||||||
|
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPath = basePath;
|
||||||
|
const segments = relative.split(path.sep).filter(Boolean);
|
||||||
|
for (const segment of segments) {
|
||||||
|
currentPath = path.join(currentPath, segment);
|
||||||
|
if (isSymlinkPath(currentPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateFilePath(filePath: string): {
|
export function validateFilePath(filePath: string): {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
} {
|
} {
|
||||||
const expandedPath = expandPath(filePath);
|
const expandedPath = expandPath(filePath);
|
||||||
const normalizedPath = path.normalize(expandedPath);
|
const resolvedPath = path.resolve(path.normalize(expandedPath));
|
||||||
const ccsDir = getCcsDir();
|
const resolvedCcsDir = path.resolve(path.normalize(getCcsDir()));
|
||||||
const claudeSettingsPath = path.normalize(getClaudeSettingsPath());
|
const resolvedClaudeSettingsPath = path.resolve(path.normalize(getClaudeSettingsPath()));
|
||||||
|
const normalizedPath = normalizePathForComparison(resolvedPath);
|
||||||
|
const ccsDir = normalizePathForComparison(resolvedCcsDir);
|
||||||
|
const claudeSettingsPath = normalizePathForComparison(resolvedClaudeSettingsPath);
|
||||||
|
|
||||||
// Check if path is within ~/.ccs/
|
// Check if path is within ~/.ccs/
|
||||||
if (normalizedPath.startsWith(ccsDir)) {
|
if (isPathWithin(ccsDir, normalizedPath)) {
|
||||||
|
if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) {
|
||||||
|
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
// Block access to sensitive subdirectories
|
// Block access to sensitive subdirectories
|
||||||
const relativePath = normalizedPath.slice(ccsDir.length);
|
const relativePath = path.relative(ccsDir, normalizedPath);
|
||||||
if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) {
|
const pathSegments = relativePath.split(path.sep).filter(Boolean);
|
||||||
|
if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) {
|
||||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||||
}
|
}
|
||||||
return { valid: true, readonly: false };
|
return { valid: true, readonly: false };
|
||||||
@@ -183,6 +231,9 @@ export function validateFilePath(filePath: string): {
|
|||||||
|
|
||||||
// Allow read-only access to ~/.claude/settings.json
|
// Allow read-only access to ~/.claude/settings.json
|
||||||
if (normalizedPath === claudeSettingsPath) {
|
if (normalizedPath === claudeSettingsPath) {
|
||||||
|
if (isSymlinkPath(resolvedClaudeSettingsPath)) {
|
||||||
|
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||||
|
}
|
||||||
return { valid: true, readonly: true };
|
return { valid: true, readonly: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ describe('applyExtendedContextConfig', () => {
|
|||||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles model IDs that already include both thinking and [1m] suffixes', () => {
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
ANTHROPIC_MODEL: 'gemini-2.5-pro(high)[1m]',
|
||||||
|
};
|
||||||
|
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||||
|
});
|
||||||
|
|
||||||
it('handles empty env vars gracefully', () => {
|
it('handles empty env vars gracefully', () => {
|
||||||
const env: NodeJS.ProcessEnv = {};
|
const env: NodeJS.ProcessEnv = {};
|
||||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||||
@@ -164,8 +172,12 @@ describe('applyExtendedContextConfig', () => {
|
|||||||
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
|
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]',
|
ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro[1m]',
|
||||||
};
|
};
|
||||||
applyExtendedContextConfig(env, 'gemini', false);
|
applyExtendedContextConfig(env, 'gemini', false);
|
||||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro');
|
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro');
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview');
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
getProvidersByOAuthFlow,
|
getProvidersByOAuthFlow,
|
||||||
isCLIProxyProvider,
|
isCLIProxyProvider,
|
||||||
mapExternalProviderName,
|
mapExternalProviderName,
|
||||||
|
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||||
|
isQuotaSupportedProvider,
|
||||||
|
QUOTA_PROVIDER_OPTION_VALUES,
|
||||||
|
QUOTA_PROVIDER_HELP_TEXT,
|
||||||
} from '../../../src/cliproxy/provider-capabilities';
|
} from '../../../src/cliproxy/provider-capabilities';
|
||||||
import {
|
import {
|
||||||
OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS,
|
OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS,
|
||||||
@@ -63,9 +67,31 @@ describe('provider-capabilities', () => {
|
|||||||
expect(mapExternalProviderName('github-copilot')).toBe('ghcp');
|
expect(mapExternalProviderName('github-copilot')).toBe('ghcp');
|
||||||
expect(mapExternalProviderName('copilot')).toBe('ghcp');
|
expect(mapExternalProviderName('copilot')).toBe('ghcp');
|
||||||
expect(mapExternalProviderName('anthropic')).toBe('claude');
|
expect(mapExternalProviderName('anthropic')).toBe('claude');
|
||||||
|
expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp');
|
||||||
|
expect(mapExternalProviderName('')).toBeNull();
|
||||||
expect(mapExternalProviderName('unknown-provider')).toBeNull();
|
expect(mapExternalProviderName('unknown-provider')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes quota-supported providers and guards correctly', () => {
|
||||||
|
expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'gemini', 'ghcp']);
|
||||||
|
expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([
|
||||||
|
'agy',
|
||||||
|
'codex',
|
||||||
|
'gemini',
|
||||||
|
'ghcp',
|
||||||
|
'antigravity',
|
||||||
|
'gemini-cli',
|
||||||
|
'github-copilot',
|
||||||
|
'copilot',
|
||||||
|
'all',
|
||||||
|
]);
|
||||||
|
expect(QUOTA_PROVIDER_HELP_TEXT).toBe(
|
||||||
|
'agy, codex, gemini, ghcp, antigravity, gemini-cli, github-copilot, copilot, all'
|
||||||
|
);
|
||||||
|
expect(isQuotaSupportedProvider('ghcp')).toBe(true);
|
||||||
|
expect(isQuotaSupportedProvider('kiro')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes callback port and display name capabilities', () => {
|
it('exposes callback port and display name capabilities', () => {
|
||||||
expect(getOAuthCallbackPort('qwen')).toBeNull();
|
expect(getOAuthCallbackPort('qwen')).toBeNull();
|
||||||
expect(getOAuthCallbackPort('kiro')).toBeNull();
|
expect(getOAuthCallbackPort('kiro')).toBeNull();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { parseApiCommandArgs } from '../../../src/commands/api-command';
|
||||||
|
|
||||||
|
describe('api-command arg parser', () => {
|
||||||
|
test('keeps positional API name when boolean flags precede it', () => {
|
||||||
|
const parsed = parseApiCommandArgs(['--yes', 'my-api']);
|
||||||
|
|
||||||
|
expect(parsed.yes).toBe(true);
|
||||||
|
expect(parsed.name).toBe('my-api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses last value when repeated value flags are provided', () => {
|
||||||
|
const parsed = parseApiCommandArgs([
|
||||||
|
'profile-a',
|
||||||
|
'--model',
|
||||||
|
'claude-3-5-sonnet',
|
||||||
|
'--model=claude-3-7-sonnet',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(parsed.name).toBe('profile-a');
|
||||||
|
expect(parsed.model).toBe('claude-3-7-sonnet');
|
||||||
|
expect(parsed.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collects missing-value errors for required option values', () => {
|
||||||
|
const parsed = parseApiCommandArgs(['profile-a', '--base-url', '--api-key']);
|
||||||
|
|
||||||
|
expect(parsed.errors).toEqual(['Missing value for --base-url', 'Missing value for --api-key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports option terminator for positional args that look like flags', () => {
|
||||||
|
const parsed = parseApiCommandArgs(['--yes', '--', '-my-api']);
|
||||||
|
|
||||||
|
expect(parsed.yes).toBe(true);
|
||||||
|
expect(parsed.name).toBe('-my-api');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,6 +56,53 @@ describe('arg-extractor', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts dash-prefixed value when allowDashValue is enabled', () => {
|
||||||
|
const result = extractOption(['--model', '-preview', '--yes'], ['--model'], {
|
||||||
|
allowDashValue: true,
|
||||||
|
knownFlags: ['--model', '--yes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
found: true,
|
||||||
|
value: '-preview',
|
||||||
|
missingValue: false,
|
||||||
|
remainingArgs: ['--yes'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still treats known flags as missing when allowDashValue is enabled', () => {
|
||||||
|
const result = extractOption(['--model', '--yes', 'prompt'], ['--model'], {
|
||||||
|
allowDashValue: true,
|
||||||
|
knownFlags: ['--model', '--yes'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
found: true,
|
||||||
|
missingValue: true,
|
||||||
|
remainingArgs: ['--yes', 'prompt'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports repeated extraction loops with deterministic last-value wins behavior', () => {
|
||||||
|
let remaining = ['--model', 'gpt-4.1-mini', '--model', 'gpt-4.1'];
|
||||||
|
let selected: string | undefined;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const extracted = extractOption(remaining, ['--model']);
|
||||||
|
if (!extracted.found) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extracted.missingValue && extracted.value) {
|
||||||
|
selected = extracted.value;
|
||||||
|
}
|
||||||
|
remaining = extracted.remainingArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(selected).toBe('gpt-4.1');
|
||||||
|
expect(remaining).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns non-match state without altering args content', () => {
|
it('returns non-match state without altering args content', () => {
|
||||||
const args = ['--yes', 'prompt'];
|
const args = ['--yes', 'prompt'];
|
||||||
const result = extractOption(args, ['--profile', '-p']);
|
const result = extractOption(args, ['--profile', '-p']);
|
||||||
@@ -75,8 +122,14 @@ describe('arg-extractor', () => {
|
|||||||
expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true);
|
expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false when only non-matching or inline tokens exist', () => {
|
it('supports inline truthy values for boolean flags', () => {
|
||||||
expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(false);
|
expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(true);
|
||||||
|
expect(hasAnyFlag(['prompt', '--yes=1'], ['--yes', '-y'])).toBe(true);
|
||||||
|
expect(hasAnyFlag(['prompt', '--yes=on'], ['--yes', '-y'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-truthy or unrelated inline tokens', () => {
|
||||||
|
expect(hasAnyFlag(['prompt', '--yes=false'], ['--yes', '-y'])).toBe(false);
|
||||||
expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false);
|
expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -117,7 +117,17 @@ image_analysis:
|
|||||||
|
|
||||||
describe('provider validation', () => {
|
describe('provider validation', () => {
|
||||||
it('should accept valid providers', () => {
|
it('should accept valid providers', () => {
|
||||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
const validProviders = [
|
||||||
|
'agy',
|
||||||
|
'gemini',
|
||||||
|
'codex',
|
||||||
|
'kiro',
|
||||||
|
'ghcp',
|
||||||
|
'claude',
|
||||||
|
'qwen',
|
||||||
|
'iflow',
|
||||||
|
'kimi',
|
||||||
|
];
|
||||||
|
|
||||||
for (const provider of validProviders) {
|
for (const provider of validProviders) {
|
||||||
expect(validProviders.includes(provider)).toBe(true);
|
expect(validProviders.includes(provider)).toBe(true);
|
||||||
@@ -125,7 +135,17 @@ image_analysis:
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid providers', () => {
|
it('should reject invalid providers', () => {
|
||||||
const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow'];
|
const validProviders = [
|
||||||
|
'agy',
|
||||||
|
'gemini',
|
||||||
|
'codex',
|
||||||
|
'kiro',
|
||||||
|
'ghcp',
|
||||||
|
'claude',
|
||||||
|
'qwen',
|
||||||
|
'iflow',
|
||||||
|
'kimi',
|
||||||
|
];
|
||||||
const invalidProviders = ['unknown', 'custom', 'my-provider', 'test'];
|
const invalidProviders = ['unknown', 'custom', 'my-provider', 'test'];
|
||||||
|
|
||||||
for (const provider of invalidProviders) {
|
for (const provider of invalidProviders) {
|
||||||
@@ -147,12 +167,15 @@ image_analysis:
|
|||||||
kiro: 'kiro-claude-haiku-4-5',
|
kiro: 'kiro-claude-haiku-4-5',
|
||||||
ghcp: 'claude-haiku-4.5',
|
ghcp: 'claude-haiku-4.5',
|
||||||
claude: 'claude-haiku-4-5-20251001',
|
claude: 'claude-haiku-4-5-20251001',
|
||||||
|
qwen: 'vision-model',
|
||||||
|
iflow: 'qwen3-vl-plus',
|
||||||
|
kimi: 'vision-model',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(defaultConfig.enabled).toBe(true);
|
expect(defaultConfig.enabled).toBe(true);
|
||||||
expect(defaultConfig.timeout).toBe(60);
|
expect(defaultConfig.timeout).toBe(60);
|
||||||
expect(Object.keys(defaultConfig.provider_models).length).toBe(6);
|
expect(Object.keys(defaultConfig.provider_models).length).toBe(9);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,28 @@ describe('parseUsageEntry', () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.target).toBeUndefined();
|
expect(result!.target).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('coerces token fields to non-negative numbers', () => {
|
||||||
|
const withInvalidUsage = JSON.stringify({
|
||||||
|
...JSON.parse(VALID_ASSISTANT_ENTRY),
|
||||||
|
message: {
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
usage: {
|
||||||
|
input_tokens: '1500',
|
||||||
|
output_tokens: -10,
|
||||||
|
cache_creation_input_tokens: 'bad',
|
||||||
|
cache_read_input_tokens: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseUsageEntry(withInvalidUsage, '/test');
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.inputTokens).toBe(1500);
|
||||||
|
expect(result!.outputTokens).toBe(0);
|
||||||
|
expect(result!.cacheCreationTokens).toBe(0);
|
||||||
|
expect(result!.cacheReadTokens).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -217,6 +239,14 @@ describe('parseJsonlFile', () => {
|
|||||||
expect(entries.length).toBe(0);
|
expect(entries.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns empty array when stream cannot be opened', async () => {
|
||||||
|
const directoryPath = path.join(tempDir, 'not-a-file');
|
||||||
|
fs.mkdirSync(directoryPath);
|
||||||
|
|
||||||
|
const entries = await parseJsonlFile(directoryPath, '/test');
|
||||||
|
expect(entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('handles file with blank lines', async () => {
|
test('handles file with blank lines', async () => {
|
||||||
const filePath = path.join(tempDir, 'blanks.jsonl');
|
const filePath = path.join(tempDir, 'blanks.jsonl');
|
||||||
const content = ['', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, ''].join('\n');
|
const content = ['', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, ''].join('\n');
|
||||||
@@ -280,6 +310,17 @@ describe('parseProjectDirectory', () => {
|
|||||||
readdirSpy.mockRestore();
|
readdirSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sanitizes derived projectPath from dashed directory names', async () => {
|
||||||
|
const projectDir = path.join(tempDir, '-..-etc-passwd');
|
||||||
|
fs.mkdirSync(projectDir);
|
||||||
|
fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||||
|
|
||||||
|
const entries = await parseProjectDirectory(projectDir);
|
||||||
|
|
||||||
|
expect(entries.length).toBe(1);
|
||||||
|
expect(entries[0].projectPath).toBe('/etc/passwd');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findProjectDirectories', () => {
|
describe('findProjectDirectories', () => {
|
||||||
@@ -395,6 +436,33 @@ describe('scanProjectsDirectory', () => {
|
|||||||
expect(entries[0].sessionId).toBe('new');
|
expect(entries[0].sessionId).toBe('new');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('skips entries with invalid timestamps when minDate filtering is enabled', async () => {
|
||||||
|
const project = path.join(tempDir, '-test-invalid-timestamp');
|
||||||
|
fs.mkdirSync(project);
|
||||||
|
|
||||||
|
const invalidTimestampEntry = JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
sessionId: 'invalid-time',
|
||||||
|
timestamp: 'not-a-date',
|
||||||
|
message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 50 } },
|
||||||
|
});
|
||||||
|
const validEntry = JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
sessionId: 'valid-time',
|
||||||
|
timestamp: '2025-12-09T00:00:00.000Z',
|
||||||
|
message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 200, output_tokens: 100 } },
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(project, 'session.jsonl'), [invalidTimestampEntry, validEntry].join('\n'));
|
||||||
|
|
||||||
|
const entries = await scanProjectsDirectory({
|
||||||
|
projectsDir: tempDir,
|
||||||
|
minDate: new Date('2025-01-01'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.length).toBe(1);
|
||||||
|
expect(entries[0].sessionId).toBe('valid-time');
|
||||||
|
});
|
||||||
|
|
||||||
test('returns empty array for empty directory', async () => {
|
test('returns empty array for empty directory', async () => {
|
||||||
const entries = await scanProjectsDirectory({ projectsDir: tempDir });
|
const entries = await scanProjectsDirectory({ projectsDir: tempDir });
|
||||||
expect(entries.length).toBe(0);
|
expect(entries.length).toBe(0);
|
||||||
@@ -416,6 +484,41 @@ describe('scanProjectsDirectory', () => {
|
|||||||
|
|
||||||
expect(entries.length).toBe(5);
|
expect(entries.length).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('falls back to default concurrency when invalid concurrency is provided', async () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const project = path.join(tempDir, `-invalid-concurrency-${i}`);
|
||||||
|
fs.mkdirSync(project);
|
||||||
|
fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroEntries = await scanProjectsDirectory({
|
||||||
|
projectsDir: tempDir,
|
||||||
|
concurrency: 0,
|
||||||
|
});
|
||||||
|
expect(zeroEntries.length).toBe(3);
|
||||||
|
|
||||||
|
const negativeEntries = await scanProjectsDirectory({
|
||||||
|
projectsDir: tempDir,
|
||||||
|
concurrency: -5,
|
||||||
|
});
|
||||||
|
expect(negativeEntries.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps very high concurrency values to a safe maximum', async () => {
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const project = path.join(tempDir, `-capped-concurrency-${i}`);
|
||||||
|
fs.mkdirSync(project);
|
||||||
|
fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await scanProjectsDirectory({
|
||||||
|
projectsDir: tempDir,
|
||||||
|
concurrency: 9999,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(entries.length).toBe(4);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -235,26 +235,18 @@ describe('Persist Routes', function () {
|
|||||||
class RestoreMutex {
|
class RestoreMutex {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.locked = false;
|
this.locked = false;
|
||||||
this.queue = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async acquire() {
|
async acquire() {
|
||||||
if (this.locked) {
|
if (this.locked) {
|
||||||
return new Promise((resolve) => {
|
return false;
|
||||||
this.queue.push(() => resolve(false));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.locked = true;
|
this.locked = true;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
release() {
|
release() {
|
||||||
const next = this.queue.shift();
|
this.locked = false;
|
||||||
if (next) {
|
|
||||||
next();
|
|
||||||
} else {
|
|
||||||
this.locked = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,21 +258,16 @@ describe('Persist Routes', function () {
|
|||||||
assert.strictEqual(mutex.locked, true);
|
assert.strictEqual(mutex.locked, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should queue and reject concurrent requests', async function () {
|
it('should reject concurrent requests while locked', async function () {
|
||||||
const mutex = new RestoreMutex();
|
const mutex = new RestoreMutex();
|
||||||
|
|
||||||
// First acquire succeeds
|
// First acquire succeeds
|
||||||
const first = await mutex.acquire();
|
const first = await mutex.acquire();
|
||||||
assert.strictEqual(first, true);
|
assert.strictEqual(first, true);
|
||||||
|
|
||||||
// Second acquire queues and gets false when released
|
// Second acquire fails immediately
|
||||||
const secondPromise = mutex.acquire();
|
const second = await mutex.acquire();
|
||||||
|
assert.strictEqual(second, false);
|
||||||
// Release the mutex
|
|
||||||
mutex.release();
|
|
||||||
|
|
||||||
const second = await secondPromise;
|
|
||||||
assert.strictEqual(second, false); // Queued request returns false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should unlock after release with no queue', async function () {
|
it('should unlock after release with no queue', async function () {
|
||||||
@@ -293,30 +280,12 @@ describe('Persist Routes', function () {
|
|||||||
assert.strictEqual(mutex.locked, false);
|
assert.strictEqual(mutex.locked, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process multiple queued requests in order', async function () {
|
it('should allow new acquire after release', async function () {
|
||||||
const mutex = new RestoreMutex();
|
const mutex = new RestoreMutex();
|
||||||
const results = [];
|
assert.strictEqual(await mutex.acquire(), true);
|
||||||
|
assert.strictEqual(await mutex.acquire(), false);
|
||||||
// First acquire
|
mutex.release();
|
||||||
const first = await mutex.acquire();
|
assert.strictEqual(await mutex.acquire(), true);
|
||||||
results.push({ id: 1, acquired: first });
|
|
||||||
|
|
||||||
// Queue multiple requests
|
|
||||||
const p2 = mutex.acquire().then((r) => results.push({ id: 2, acquired: r }));
|
|
||||||
const p3 = mutex.acquire().then((r) => results.push({ id: 3, acquired: r }));
|
|
||||||
|
|
||||||
// Release all
|
|
||||||
mutex.release(); // Signals #2
|
|
||||||
mutex.release(); // Signals #3
|
|
||||||
|
|
||||||
await Promise.all([p2, p3]);
|
|
||||||
|
|
||||||
assert.strictEqual(results[0].id, 1);
|
|
||||||
assert.strictEqual(results[0].acquired, true);
|
|
||||||
assert.strictEqual(results[1].id, 2);
|
|
||||||
assert.strictEqual(results[1].acquired, false);
|
|
||||||
assert.strictEqual(results[2].id, 3);
|
|
||||||
assert.strictEqual(results[2].acquired, false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { validateFilePath } from '../../../src/web-server/routes/route-helpers';
|
||||||
|
|
||||||
|
describe('validateFilePath', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalClaudeConfigDir: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-helpers-test-'));
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
|
||||||
|
process.env.CCS_HOME = tempDir;
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome === undefined) {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalClaudeConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows files within ~/.ccs tree', () => {
|
||||||
|
const filePath = path.join(tempDir, '.ccs', 'config.yaml');
|
||||||
|
const result = validateFilePath(filePath);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.readonly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects sibling paths that only share ~/.ccs prefix', () => {
|
||||||
|
const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml');
|
||||||
|
const result = validateFilePath(bypassPath);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.readonly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows readonly access to resolved Claude settings path', () => {
|
||||||
|
const filePath = path.join(tempDir, '.claude-custom', 'settings.json');
|
||||||
|
const result = validateFilePath(filePath);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.readonly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects symlinked paths inside ~/.ccs tree', () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ccsDir = path.join(tempDir, '.ccs');
|
||||||
|
const outsideDir = path.join(tempDir, 'outside');
|
||||||
|
const linkedDir = path.join(ccsDir, 'linked');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(outsideDir, { recursive: true });
|
||||||
|
fs.symlinkSync(outsideDir, linkedDir, 'dir');
|
||||||
|
|
||||||
|
const result = validateFilePath(path.join(linkedDir, 'config.yaml'));
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.readonly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects symlinked Claude settings path', () => {
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const claudeDir = path.join(tempDir, '.claude-custom');
|
||||||
|
const targetFile = path.join(tempDir, 'target-settings.json');
|
||||||
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||||
|
fs.mkdirSync(claudeDir, { recursive: true });
|
||||||
|
fs.writeFileSync(targetFile, '{}');
|
||||||
|
fs.symlinkSync(targetFile, settingsPath, 'file');
|
||||||
|
|
||||||
|
const result = validateFilePath(settingsPath);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.readonly).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user