diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 624a16a2..05f1a479 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -1,1130 +1,24 @@ /** - * CLIProxy Quota Management + * CLIProxy Quota Management - public barrel. * - * Handles: - * - ccs cliproxy quota [--provider ] - * - ccs cliproxy default - * - ccs cliproxy pause - * - ccs cliproxy resume - * - ccs cliproxy doctor + * Source lives in ./quota-subcommand/. This file re-exports the original + * public surface so existing importers (src/commands/cliproxy/index.ts and + * the unit-test module loader) are unaffected by the god-file split. + * + * Public surface (unchanged): + * - handleQuotaStatus (`ccs cliproxy quota`) + * - handleDoctor (`ccs cliproxy doctor` / `diag`) + * - handleSetDefault (`ccs cliproxy default `) + * - handlePauseAccount (`ccs cliproxy pause `) + * - handleResumeAccount (`ccs cliproxy resume `) + * - __testExports (helpers consumed by the unit test loader) */ -import { - getProviderAccounts, - setDefaultAccount, - pauseAccount, - resumeAccount, - findAccountByQuery, -} from '../../cliproxy/accounts/account-manager'; -import { fetchAllProviderQuotas } from '../../cliproxy/quota/quota-fetcher'; -import { fetchAllCodexQuotas } from '../../cliproxy/quota/quota-fetcher-codex'; -import { - sanitizeCodexFeatureLabel, - sanitizeCodexFeatureLabelOrNull, -} from '../../cliproxy/quota/quota-label-sanitizer'; -import { fetchAllClaudeQuotas } from '../../cliproxy/quota/quota-fetcher-claude'; -import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota/quota-fetcher-claude-normalizer'; -import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota/quota-fetcher-gemini-cli'; -import { fetchAllGhcpQuotas } from '../../cliproxy/quota/quota-fetcher-ghcp'; -import type { - CodexQuotaResult, - ClaudeQuotaResult, - GeminiCliQuotaResult, - GhcpQuotaResult, - QuotaErrorMetadata, -} from '../../cliproxy/quota/quota-types'; -import { isOnCooldown } from '../../cliproxy/quota/quota-manager'; -import { renderProviderPoolSection, readPoolRoutingSettings } from './pool-state-renderer'; -import { CLIProxyProvider } from '../../cliproxy/types'; -import { - QUOTA_SUPPORTED_PROVIDER_IDS, - type QuotaSupportedProvider, -} from '../../cliproxy/provider-capabilities'; -import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity'; -import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui'; - -interface CliproxyProfileArgs { - name?: string; - provider?: string; - model?: string; - account?: string; - force?: boolean; - yes?: boolean; -} - -function parseProfileArgs(args: string[]): CliproxyProfileArgs { - const result: CliproxyProfileArgs = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--provider' && args[i + 1]) { - result.provider = args[++i]; - } else if (arg === '--model' && args[i + 1]) { - result.model = args[++i]; - } else if (arg === '--account' && args[i + 1]) { - result.account = args[++i]; - } else if (arg === '--force') { - result.force = true; - } else if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (!arg.startsWith('-') && !result.name) { - result.name = arg; - } - } - return result; -} - -function formatQuotaBar(percentage: number): string { - const width = 20; - const clampedPct = Math.max(0, Math.min(100, percentage)); - const filled = Math.round((clampedPct / 100) * width); - const empty = width - filled; - const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; - return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; -} - -function formatResetTime(seconds: number): string { - if (seconds <= 0) return 'now'; - if (seconds < 60) return `in ${seconds}s`; - if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`; - if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`; - - const days = Math.floor(seconds / 86400); - const hours = Math.round((seconds % 86400) / 3600); - if (hours <= 0) return `in ${days}d`; - if (hours >= 24) return `in ${days + 1}d`; - return `in ${days}d ${hours}h`; -} - -function formatResetTimeISO(isoTime: string): string { - if (!isoTime) return 'unknown'; - const resetDate = new Date(isoTime); - if (isNaN(resetDate.getTime())) return 'unknown'; - const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000)); - return formatResetTime(seconds); -} - -function formatCliAccountLabel(account: { id: string; email?: string; nickname?: string }): string { - const displayName = formatAccountDisplayName(account); - return account.nickname ? `${account.nickname} (${displayName})` : displayName; -} - -function resolveDisplayedTier( - accountTier: string | undefined, - liveTier: string | undefined -): string { - return (liveTier && liveTier !== 'unknown' ? liveTier : accountTier) || 'unknown'; -} - -interface QuotaFailureDisplayEntry { - tone: 'error' | 'info' | 'dim'; - text: string; -} - -function getQuotaFailureDisplayEntries( - quota: QuotaErrorMetadata & { - error?: string; - } -): QuotaFailureDisplayEntry[] { - const entries: QuotaFailureDisplayEntry[] = [ - { - tone: 'error', - text: quota.error || 'Failed to fetch quota', - }, - ]; - - if (quota.actionHint) { - entries.push({ - tone: 'info', - text: quota.actionHint, - }); - } - - const diagnostics: string[] = []; - if (typeof quota.httpStatus === 'number') { - diagnostics.push(`HTTP ${quota.httpStatus}`); - } - if (quota.errorCode) { - diagnostics.push(`Code: ${quota.errorCode}`); - } - if (quota.retryable) { - diagnostics.push('Retryable'); - } - if (diagnostics.length > 0) { - entries.push({ - tone: 'dim', - text: diagnostics.join(' | '), - }); - } - - const normalizedError = quota.error?.trim(); - const normalizedDetail = quota.errorDetail?.trim(); - if (normalizedDetail && normalizedDetail !== normalizedError) { - entries.push({ - tone: 'dim', - text: `Detail: ${normalizedDetail}`, - }); - } - - return entries; -} - -function displayQuotaFailure( - quota: QuotaErrorMetadata & { - error?: string; - } -): void { - for (const entry of getQuotaFailureDisplayEntries(quota)) { - const rendered = - entry.tone === 'error' - ? color(entry.text, 'error') - : entry.tone === 'info' - ? info(entry.text) - : dim(entry.text); - console.log(` ${rendered}`); - } -} - -function formatAbsoluteResetTime(isoTime: string): string | null { - if (!isoTime) return null; - const resetDate = new Date(isoTime); - if (isNaN(resetDate.getTime())) return null; - const date = resetDate.toLocaleDateString(undefined, { - month: '2-digit', - day: '2-digit', - }); - const time = resetDate.toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - }); - return `${date} ${time}`; -} - -function formatCodexWindowReset( - window: Pick -): string | null { - if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) { - const relative = formatResetTime(Math.max(0, window.resetAfterSeconds)); - if (window.resetAfterSeconds >= 86400 && window.resetAt) { - const absolute = formatAbsoluteResetTime(window.resetAt); - return absolute ? `${relative} (${absolute})` : relative; - } - return relative; - } - - if (window.resetAt) { - return formatResetTimeISO(window.resetAt); - } - - return null; -} - -type CodexWindowKind = - | 'usage-5h' - | 'usage-weekly' - | 'code-review-5h' - | 'code-review-weekly' - | 'code-review' - | 'unknown'; - -function getCodexWindowKind(label: string): CodexWindowKind { - const lower = (label || '').toLowerCase(); - const isCodeReview = lower.includes('code review') || lower.includes('code_review'); - const isPrimary = lower.includes('primary'); - const isSecondary = lower.includes('secondary'); - - if (isCodeReview) { - if (isPrimary) return 'code-review-5h'; - if (isSecondary) return 'code-review-weekly'; - return 'code-review'; - } - - if (isPrimary) return 'usage-5h'; - if (isSecondary) return 'usage-weekly'; - return 'unknown'; -} - -type CodexWindowSummary = Pick< - CodexQuotaResult['windows'][number], - 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel' ->; - -function inferCodeReviewCadence( - window: CodexWindowSummary, - allWindows: CodexWindowSummary[] -): '5h' | 'weekly' | null { - const kind = getCodexWindowKind(window.label); - if (kind === 'code-review-weekly') return 'weekly'; - - const reset = window.resetAfterSeconds; - if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null; - - const usage5h = allWindows.find( - (w) => - getCodexWindowKind(w.label) === 'usage-5h' && - typeof w.resetAfterSeconds === 'number' && - isFinite(w.resetAfterSeconds) && - w.resetAfterSeconds > 0 - ); - const usageWeekly = allWindows.find( - (w) => - getCodexWindowKind(w.label) === 'usage-weekly' && - typeof w.resetAfterSeconds === 'number' && - isFinite(w.resetAfterSeconds) && - w.resetAfterSeconds > 0 - ); - - if (!usage5h || !usageWeekly) return null; - - const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number)); - const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number)); - return diffToWeekly <= diffTo5h ? 'weekly' : '5h'; -} - -/** - * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the - * remainder into a Codex-prefixed display name. Other labels pass through unchanged. - */ -function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string { - const trimmed = - sanitizeCodexFeatureLabelOrNull(featureLabel) ?? - (fallbackLabel === undefined - ? sanitizeCodexFeatureLabel(featureLabel) - : sanitizeCodexFeatureLabel(fallbackLabel)); - const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); - if (stripped !== trimmed && stripped.length > 0) { - return `Codex ${stripped}`; - } - return trimmed; -} - -function getCodexWindowDisplayLabel( - window: CodexWindowSummary, - allWindows: CodexWindowSummary[] = [] -): string { - const context = allWindows.length > 0 ? allWindows : [window]; - - // Prefer explicit category metadata when present (post-2026-04 windows). - if (window.category === 'usage') { - if (window.cadence === '5h') return '5h usage limit'; - if (window.cadence === 'weekly') return 'Weekly usage limit'; - } - - if (window.category === 'additional') { - const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label); - if (window.cadence === '5h') return `${pretty} (5h)`; - if (window.cadence === 'weekly') return `${pretty} (weekly)`; - return pretty; - } - - if (window.category === 'code-review') { - if (window.cadence === '5h') return 'Code review (5h)'; - if (window.cadence === 'weekly') return 'Code review (weekly)'; - return 'Code review'; - } - - // Legacy fallback: classify via label sniffing for cached windows without metadata. - switch (getCodexWindowKind(window.label)) { - case 'usage-5h': - return '5h usage limit'; - case 'usage-weekly': - return 'Weekly usage limit'; - case 'code-review-5h': - case 'code-review-weekly': - case 'code-review': { - const inferred = inferCodeReviewCadence(window, context); - if (inferred === '5h') return 'Code review (5h)'; - if (inferred === 'weekly') return 'Code review (weekly)'; - return 'Code review'; - } - case 'unknown': - return window.label; - } -} - -function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): { - fiveHourWindow: CodexQuotaResult['windows'][number] | null; - weeklyWindow: CodexQuotaResult['windows'][number] | null; -} { - let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null; - let weeklyWindow: CodexQuotaResult['windows'][number] | null = null; - const nonCodeReviewWindows: CodexQuotaResult['windows'] = []; - - // Prefer explicit category metadata when present so 'additional' windows - // (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary. - const hasCategoryMetadata = windows.some((window) => Boolean(window.category)); - - if (hasCategoryMetadata) { - for (const window of windows) { - if (window.category === 'usage') { - if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; - else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; - nonCodeReviewWindows.push(window); - } - // 'code-review' and 'additional' are excluded from the core usage summary. - } - } else { - for (const window of windows) { - const kind = getCodexWindowKind(window.label); - if (kind === 'usage-5h') { - if (!fiveHourWindow) fiveHourWindow = window; - nonCodeReviewWindows.push(window); - continue; - } - if (kind === 'usage-weekly') { - if (!weeklyWindow) weeklyWindow = window; - nonCodeReviewWindows.push(window); - continue; - } - if (kind === 'unknown') { - nonCodeReviewWindows.push(window); - } - } - } - - if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { - const withReset = nonCodeReviewWindows - .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) - .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); - - if (!fiveHourWindow) { - fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; - } - - if (!weeklyWindow) { - weeklyWindow = - withReset.length > 1 - ? withReset[withReset.length - 1] - : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; - } - } - - return { fiveHourWindow, weeklyWindow }; -} - -function displayAntigravityQuotaSection( - quotaResult: Awaited> -): void { - const provider: CLIProxyProvider = 'agy'; - const accounts = getProviderAccounts(provider); - - console.log( - subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`) - ); - console.log(''); - - const rows: string[][] = []; - for (const account of accounts) { - const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id); - const quota = quotaData?.quota; - - let avgQuota = 'N/A'; - if (quota?.success && quota.models.length > 0) { - const avg = Math.round( - quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length - ); - avgQuota = `${avg}%`; - } - - const statusParts: string[] = []; - if (account.paused) statusParts.push(color('PAUSED', 'warning')); - if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning')); - - const defaultMark = account.isDefault ? color('*', 'success') : ' '; - const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier); - const status = statusParts.join(', '); - - rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]); - } - - console.log( - table(rows, { - head: ['', 'Account', 'Tier', 'Quota', 'Status'], - colWidths: [3, 30, 10, 10, 20], - }) - ); - console.log(''); -} - -function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaResult }[]): void { - console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`)); - console.log(''); - - for (const { account, quota } of results) { - const accountInfo = findAccountByQuery('codex', account); - const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; - const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultMark}`); - displayQuotaFailure(quota); - console.log(''); - continue; - } - - const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows); - const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter( - (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index - ); - const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows; - - const avgQuota = - statusWindows.length > 0 - ? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length - : 0; - const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); - const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; - - console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`); - - const coreUsageSummary = quota.coreUsage ?? { - fiveHour: fiveHourWindow - ? { - label: fiveHourWindow.label, - remainingPercent: fiveHourWindow.remainingPercent, - resetAfterSeconds: fiveHourWindow.resetAfterSeconds, - resetAt: fiveHourWindow.resetAt, - } - : null, - weekly: weeklyWindow - ? { - label: weeklyWindow.label, - remainingPercent: weeklyWindow.remainingPercent, - resetAfterSeconds: weeklyWindow.resetAfterSeconds, - resetAt: weeklyWindow.resetAt, - } - : null, - }; - const resetParts: string[] = []; - const fiveHourReset = coreUsageSummary.fiveHour - ? formatCodexWindowReset(coreUsageSummary.fiveHour) - : null; - const weeklyReset = coreUsageSummary.weekly - ? formatCodexWindowReset(coreUsageSummary.weekly) - : null; - if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`); - if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`); - if (resetParts.length > 0) { - console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); - } - - const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter( - (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index - ); - - for (const window of orderedWindows) { - const bar = formatQuotaBar(window.remainingPercent); - const resetValue = formatCodexWindowReset(window); - const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : ''; - console.log( - ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` - ); - } - console.log(''); - } -} - -interface ClaudeDisplayWindow { - rateLimitType: string; - label: string; - remainingPercent: number; - resetAt: string | null; - status: string; -} - -function getClaudeWindowDisplayLabel( - window: Pick -): string { - switch (window.rateLimitType) { - case 'five_hour': - return '5h usage limit'; - case 'seven_day': - return 'Weekly usage limit'; - case 'seven_day_opus': - return 'Weekly usage (Opus)'; - case 'seven_day_sonnet': - return 'Weekly usage (Sonnet)'; - case 'seven_day_oauth_apps': - return 'Weekly usage (OAuth apps)'; - case 'seven_day_cowork': - return 'Weekly usage (Cowork)'; - case 'overage': - return 'Extra usage'; - default: - return window.label; - } -} - -function toClaudeDisplayWindow(window: ClaudeQuotaResult['windows'][number]): ClaudeDisplayWindow { - return { - rateLimitType: window.rateLimitType, - label: window.label, - remainingPercent: window.remainingPercent, - resetAt: window.resetAt, - status: window.status, - }; -} - -function toClaudeCoreDisplayWindow( - window: NonNullable['fiveHour'] -): ClaudeDisplayWindow | null { - if (!window) return null; - return { - rateLimitType: window.rateLimitType, - label: window.label, - remainingPercent: window.remainingPercent, - resetAt: window.resetAt, - status: window.status, - }; -} - -function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { - fiveHourWindow: ClaudeDisplayWindow | null; - weeklyWindow: ClaudeDisplayWindow | null; -} { - const coreUsage = quota.coreUsage; - const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null); - const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null); - if (fiveHourFromCore || weeklyFromCore) { - return { - fiveHourWindow: fiveHourFromCore, - weeklyWindow: weeklyFromCore, - }; - } - - const fiveHourPolicy = - quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null; - const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows); - - return { - fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null, - weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null, - }; -} - -function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuotaResult }[]): void { - console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`)); - console.log(''); - - for (const { account, quota } of results) { - const accountInfo = findAccountByQuery('claude', account); - const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; - const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultMark}`); - displayQuotaFailure(quota); - console.log(''); - continue; - } - - const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota); - const coreWindows = [fiveHourWindow, weeklyWindow].filter( - (window, index, arr): window is ClaudeDisplayWindow => - !!window && arr.indexOf(window) === index - ); - const statusWindows = - coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow); - const minQuota = - statusWindows.length > 0 - ? Math.min(...statusWindows.map((window) => window.remainingPercent)) - : null; - const statusIcon = - minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); - - console.log(` ${statusIcon}${accountLabel}${defaultMark}`); - - const resetParts: string[] = []; - if (fiveHourWindow?.resetAt) - resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`); - if (weeklyWindow?.resetAt) - resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`); - if (resetParts.length > 0) { - console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); - } - - const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter( - (window, index, arr) => - arr.findIndex( - (candidate) => - candidate.rateLimitType === window.rateLimitType && - candidate.resetAt === window.resetAt && - candidate.status === window.status - ) === index - ); - - if (orderedWindows.length === 0) { - console.log(` ${dim('Policy limits unavailable for this account')}`); - console.log(''); - continue; - } - - for (const window of orderedWindows) { - const bar = formatQuotaBar(window.remainingPercent); - const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : ''; - const statusLabel = - window.status === 'rejected' - ? dim(' [blocked]') - : window.status === 'allowed_warning' - ? dim(' [warning]') - : ''; - console.log( - ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}` - ); - } - console.log(''); - } -} - -function displayGeminiCliQuotaSection( - results: { account: string; quota: GeminiCliQuotaResult }[] -): void { - console.log( - subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`) - ); - console.log(''); - - for (const { account, quota } of results) { - const accountInfo = findAccountByQuery('gemini', account); - const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; - const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultMark}`); - displayQuotaFailure(quota); - console.log(''); - continue; - } - - const avgQuota = - quota.buckets.length > 0 - ? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length - : 0; - const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); - - console.log(` ${statusIcon}${accountLabel}${defaultMark}`); - if (quota.projectId) { - console.log(` Project: ${dim(quota.projectId)}`); - } - if (quota.tierLabel) { - console.log(` Tier: ${dim(quota.tierLabel)}`); - } - if (quota.entitlement?.rawTierId) { - console.log(` Tier ID: ${dim(quota.entitlement.rawTierId)}`); - } - if (quota.creditBalance !== null && quota.creditBalance !== undefined) { - console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`); - } - - for (const bucket of quota.buckets) { - const bar = formatQuotaBar(bucket.remainingPercent); - const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : ''; - const amountLabel = - bucket.remainingAmount !== null && bucket.remainingAmount !== undefined - ? dim(` ${bucket.remainingAmount.toLocaleString()} left`) - : ''; - const resetLabel = bucket.resetTime - ? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`) - : ''; - console.log( - ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}` - ); - } - console.log(''); - } -} - -function formatSnapshotLabel( - snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']] -): string { - if (snapshot.unlimited) { - return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`; - } - return `${snapshot.used}/${snapshot.entitlement} used`; -} - -function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaResult }[]): void { - console.log( - subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`) - ); - console.log(''); - - for (const { account, quota } of results) { - const accountInfo = findAccountByQuery('ghcp', account); - const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; - const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultMark}`); - displayQuotaFailure(quota); - console.log(''); - continue; - } - - const reportedSnapshots = [ - quota.snapshots.premiumInteractions, - quota.snapshots.chat, - quota.snapshots.completions, - ].filter((snapshot) => snapshot.reported !== false); - const rows = reportedSnapshots.map((snapshot) => - snapshot.unlimited ? 100 : snapshot.percentRemaining - ); - const minQuota = rows.length > 0 ? Math.min(...rows) : null; - const statusIcon = - minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); - const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; - - console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`); - if (quota.quotaResetDate) { - console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`); - } - - const allItems: Array< - [string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]] - > = [ - ['Premium interactions', quota.snapshots.premiumInteractions], - ['Chat', quota.snapshots.chat], - ['Completions', quota.snapshots.completions], - ]; - const items = allItems.filter(([, snapshot]) => snapshot.reported !== false); - - for (const [label, snapshot] of items) { - const bar = formatQuotaBar(snapshot.percentRemaining); - const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`); - console.log( - ` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}` - ); - } - - console.log(''); - } -} - -interface QuotaProviderRuntime { - fetch: (verbose: boolean) => Promise; - hasData: (result: unknown) => boolean; - render: (result: unknown) => void; - emptyTitle: string; - emptyMessage: string; - authCommand: string; -} - -const QUOTA_PROVIDER_RUNTIME: Record = { - agy: { - fetch: (verbose) => fetchAllProviderQuotas('agy', verbose), - hasData: (result) => - (result as Awaited>).accounts.length > 0, - render: (result) => - displayAntigravityQuotaSection(result as Awaited>), - 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', - }, - claude: { - fetch: (verbose) => fetchAllClaudeQuotas(verbose), - hasData: (result) => (result as { account: string; quota: ClaudeQuotaResult }[]).length > 0, - render: (result) => - displayClaudeQuotaSection(result as { account: string; quota: ClaudeQuotaResult }[]), - emptyTitle: 'Claude (0 accounts)', - emptyMessage: 'No Claude accounts configured', - authCommand: 'ccs claude --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 const __testExports = { - getCodexWindowDisplayLabel, - getQuotaFailureDisplayEntries, - prettifyCodexFeatureLabel, - resolveDisplayedTier, -}; - -export async function handleQuotaStatus( - verbose = false, - providerFilter: QuotaSupportedProvider | 'all' = 'all' -): Promise { - await initUI(); - console.log(header('Quota Status')); - console.log(''); - - const requestedProviders = new Set( - providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter] - ); - const shouldFetch = (provider: QuotaSupportedProvider): boolean => - requestedProviders.has(provider); - - console.log(dim('Fetching quotas...')); - - const providerResults = new Map( - await Promise.all( - QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => { - if (!shouldFetch(provider)) { - return [provider, null] as const; - } - return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const; - }) - ) - ); - - console.log(''); - - // Pool routing settings are global to the CLIProxy config; read once. - const poolSettings = readPoolRoutingSettings(); - - for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) { - if (!shouldFetch(provider)) { - continue; - } - - const runtime = QUOTA_PROVIDER_RUNTIME[provider]; - const result = providerResults.get(provider) ?? null; - if (result !== null && runtime.hasData(result)) { - runtime.render(result); - // Pool context: drain order + per-account state (available/cooling/paused). - // QuotaSupportedProvider ids are all valid CLIProxyProvider values. - // Async: folds in live in-proxy 429 cooldowns when pool routing is on. - await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings); - continue; - } - - console.log(subheader(runtime.emptyTitle)); - console.log(info(runtime.emptyMessage)); - console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`); - console.log(''); - } -} - -export async function handleDoctor(verbose = false): Promise { - await initUI(); - console.log(header('CLIProxy Quota Diagnostics')); - console.log(''); - - const provider: CLIProxyProvider = 'agy'; - const accounts = getProviderAccounts(provider); - - if (accounts.length === 0) { - console.log(info('No Antigravity accounts configured')); - console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); - return; - } - - console.log(subheader(`Antigravity Accounts (${accounts.length})`)); - console.log(''); - - console.log(dim('Fetching quotas...')); - const quotaResult = await fetchAllProviderQuotas(provider, verbose); - - for (const { account, quota } of quotaResult.accounts) { - const accountLabel = formatCliAccountLabel(account); - const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; - - if (!quota.success) { - console.log(` ${fail(accountLabel)}${defaultBadge}`); - displayQuotaFailure(quota); - if (quota.isUnprovisioned) { - console.log( - ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` - ); - } - console.log(''); - continue; - } - - const avgQuota = - quota.models.length > 0 - ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length - : 0; - const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); - - console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); - if (quota.projectId) { - console.log(` Project: ${dim(quota.projectId)}`); - } - - for (const model of quota.models) { - const bar = formatQuotaBar(model.percentage); - console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); - } - console.log(''); - } - - const sharedProjects = Object.entries(quotaResult.projectGroups).filter( - ([, accountIds]) => accountIds.length > 1 - ); - - if (sharedProjects.length > 0) { - console.log(''); - console.log(subheader('Shared Project Warning')); - console.log(''); - for (const [projectId, accountIds] of sharedProjects) { - console.log( - fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) - ); - for (const accountId of accountIds) { - console.log(` - ${accountId}`); - } - console.log(''); - console.log(warn('These accounts share the same quota pool!')); - console.log(warn('Failover between them will NOT help when quota is exhausted.')); - console.log(info('Solution: Use accounts from different GCP projects.')); - } - } - - console.log(''); - console.log(subheader('Summary')); - const healthyAccounts = quotaResult.accounts.filter( - ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) - ); - console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); - if (sharedProjects.length > 0) { - console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); - } else if (accounts.length > 1) { - console.log(` ${ok('No shared projects (failover fully operational)')}`); - } - console.log(''); -} - -export async function handleSetDefault(args: string[]): Promise { - await initUI(); - const parsed = parseProfileArgs(args); - - if (!parsed.name) { - console.log(fail('Usage: ccs cliproxy default [--provider ]')); - console.log(''); - console.log('Examples:'); - console.log(' ccs cliproxy default ultra@gmail.com'); - console.log(' ccs cliproxy default john --provider agy'); - process.exit(1); - } - - const provider = (parsed.provider || 'agy') as CLIProxyProvider; - const account = findAccountByQuery(provider, parsed.name); - - if (!account) { - console.log(fail(`Account not found: ${parsed.name}`)); - console.log(''); - const accounts = getProviderAccounts(provider); - if (accounts.length > 0) { - console.log('Available accounts:'); - for (const acc of accounts) { - const badge = acc.isDefault ? color(' (current default)', 'info') : ''; - console.log(` - ${formatCliAccountLabel(acc)}${badge}`); - } - } else { - console.log(`No accounts found for provider: ${provider}`); - console.log(`Run: ccs ${provider} --auth`); - } - process.exit(1); - } - - const success = setDefaultAccount(provider, account.id); - - if (success) { - console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`)); - console.log(info(`Provider: ${provider}`)); - } else { - console.log(fail('Failed to set default account')); - process.exit(1); - } -} - -export async function handlePauseAccount(args: string[]): Promise { - await initUI(); - const parsed = parseProfileArgs(args); - - if (!parsed.name) { - console.log(fail('Usage: ccs cliproxy pause [--provider ]')); - console.log(''); - console.log('Pauses an account so it will be skipped in quota rotation.'); - process.exit(1); - } - - const provider = (parsed.provider || 'agy') as CLIProxyProvider; - const account = findAccountByQuery(provider, parsed.name); - - if (!account) { - console.log(fail(`Account not found: ${parsed.name}`)); - process.exit(1); - } - - if (account.paused) { - const refreshed = pauseAccount(provider, account.id); - const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account; - console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`)); - if (refreshed) { - console.log(info('Manual pause refreshed; account will stay out of quota rotation')); - } - console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`)); - return; - } - - const success = pauseAccount(provider, account.id); - - if (success) { - console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`)); - console.log(info('Account will be skipped in quota rotation')); - } else { - console.log(fail('Failed to pause account')); - process.exit(1); - } -} - -export async function handleResumeAccount(args: string[]): Promise { - await initUI(); - const parsed = parseProfileArgs(args); - - if (!parsed.name) { - console.log(fail('Usage: ccs cliproxy resume [--provider ]')); - console.log(''); - console.log('Resumes a paused account for quota rotation.'); - process.exit(1); - } - - const provider = (parsed.provider || 'agy') as CLIProxyProvider; - const account = findAccountByQuery(provider, parsed.name); - - if (!account) { - console.log(fail(`Account not found: ${parsed.name}`)); - process.exit(1); - } - - if (!account.paused) { - console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`)); - return; - } - - const success = resumeAccount(provider, account.id); - - if (success) { - console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`)); - console.log(info('Account is now active in quota rotation')); - } else { - console.log(fail('Failed to resume account')); - process.exit(1); - } -} +export { + handleQuotaStatus, + handleDoctor, + handleSetDefault, + handlePauseAccount, + handleResumeAccount, +} from './quota-subcommand/handlers'; +export { __testExports } from './quota-subcommand/test-exports'; diff --git a/src/commands/cliproxy/quota-subcommand/claude-window-helpers.ts b/src/commands/cliproxy/quota-subcommand/claude-window-helpers.ts new file mode 100644 index 00000000..e2607946 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/claude-window-helpers.ts @@ -0,0 +1,92 @@ +/** + * Claude window classification + display helpers. + * + * Claude quota results include multiple policy windows (5h, weekly, weekly + * per-model variants like Opus/Sonnet, overage, etc.). These helpers classify + * each window and extract the two "core usage" windows for the summary line. + */ + +import type { ClaudeQuotaResult } from '../../../cliproxy/quota/quota-types'; +import { pickMostRestrictiveClaudeWeeklyWindow } from '../../../cliproxy/quota/quota-fetcher-claude-normalizer'; +import type { ClaudeDisplayWindow } from './types'; + +/** Human-readable label for a Claude window based on its rate-limit type. */ +export function getClaudeWindowDisplayLabel( + window: Pick +): string { + switch (window.rateLimitType) { + case 'five_hour': + return '5h usage limit'; + case 'seven_day': + return 'Weekly usage limit'; + case 'seven_day_opus': + return 'Weekly usage (Opus)'; + case 'seven_day_sonnet': + return 'Weekly usage (Sonnet)'; + case 'seven_day_oauth_apps': + return 'Weekly usage (OAuth apps)'; + case 'seven_day_cowork': + return 'Weekly usage (Cowork)'; + case 'overage': + return 'Extra usage'; + default: + return window.label; + } +} + +/** Convert a raw Claude quota window into the normalized display shape. */ +export function toClaudeDisplayWindow( + window: ClaudeQuotaResult['windows'][number] +): ClaudeDisplayWindow { + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +/** Convert a coreUsage 5h/weekly sub-window into the display shape (or null). */ +export function toClaudeCoreDisplayWindow( + window: NonNullable['fiveHour'] +): ClaudeDisplayWindow | null { + if (!window) return null; + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +/** + * Pick the two "core usage" windows (5h + weekly) for a Claude result. + * + * Prefers the explicit coreUsage metadata. Falls back to the 'five_hour' + * window and the most restrictive weekly window when metadata is absent. + */ +export function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { + fiveHourWindow: ClaudeDisplayWindow | null; + weeklyWindow: ClaudeDisplayWindow | null; +} { + const coreUsage = quota.coreUsage; + const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null); + const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null); + if (fiveHourFromCore || weeklyFromCore) { + return { + fiveHourWindow: fiveHourFromCore, + weeklyWindow: weeklyFromCore, + }; + } + + const fiveHourPolicy = + quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null; + const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows); + + return { + fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null, + weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null, + }; +} diff --git a/src/commands/cliproxy/quota-subcommand/codex-window-helpers.ts b/src/commands/cliproxy/quota-subcommand/codex-window-helpers.ts new file mode 100644 index 00000000..beaaf734 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/codex-window-helpers.ts @@ -0,0 +1,226 @@ +/** + * Codex window classification + display helpers. + * + * Codex quota results include multiple rate-limit windows (5h usage, weekly + * usage, code review, and "additional" feature windows like Codex Spark). + * These helpers classify each window, pick display labels, and identify the + * two "core usage" windows used in the per-account summary. + */ + +import { + sanitizeCodexFeatureLabel, + sanitizeCodexFeatureLabelOrNull, +} from '../../../cliproxy/quota/quota-label-sanitizer'; +import type { CodexQuotaResult } from '../../../cliproxy/quota/quota-types'; +import { formatAbsoluteResetTime, formatResetTime, formatResetTimeISO } from './format-helpers'; +import type { CodexWindowKind } from './types'; + +/** Subset of a Codex window used by label classification (for test ergonomics). */ +export type CodexWindowSummary = Pick< + CodexQuotaResult['windows'][number], + 'label' | 'resetAfterSeconds' | 'category' | 'cadence' | 'featureLabel' +>; + +/** Render the reset time for a Codex window as either a relative or absolute label. */ +export function formatCodexWindowReset( + window: Pick +): string | null { + if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) { + const relative = formatResetTime(Math.max(0, window.resetAfterSeconds)); + if (window.resetAfterSeconds >= 86400 && window.resetAt) { + const absolute = formatAbsoluteResetTime(window.resetAt); + return absolute ? `${relative} (${absolute})` : relative; + } + return relative; + } + + if (window.resetAt) { + return formatResetTimeISO(window.resetAt); + } + + return null; +} + +/** Classify a Codex window label into a known kind. */ +export function getCodexWindowKind(label: string): CodexWindowKind { + const lower = (label || '').toLowerCase(); + const isCodeReview = lower.includes('code review') || lower.includes('code_review'); + const isPrimary = lower.includes('primary'); + const isSecondary = lower.includes('secondary'); + + if (isCodeReview) { + if (isPrimary) return 'code-review-5h'; + if (isSecondary) return 'code-review-weekly'; + return 'code-review'; + } + + if (isPrimary) return 'usage-5h'; + if (isSecondary) return 'usage-weekly'; + return 'unknown'; +} + +/** + * Infer whether a code-review window resets on the 5h or weekly cadence by + * comparing its reset time against the 5h and weekly usage windows. Returns + * null when no inference is possible. + */ +export function inferCodeReviewCadence( + window: CodexWindowSummary, + allWindows: CodexWindowSummary[] +): '5h' | 'weekly' | null { + const kind = getCodexWindowKind(window.label); + if (kind === 'code-review-weekly') return 'weekly'; + + const reset = window.resetAfterSeconds; + if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null; + + const usage5h = allWindows.find( + (w) => + getCodexWindowKind(w.label) === 'usage-5h' && + typeof w.resetAfterSeconds === 'number' && + isFinite(w.resetAfterSeconds) && + w.resetAfterSeconds > 0 + ); + const usageWeekly = allWindows.find( + (w) => + getCodexWindowKind(w.label) === 'usage-weekly' && + typeof w.resetAfterSeconds === 'number' && + isFinite(w.resetAfterSeconds) && + w.resetAfterSeconds > 0 + ); + + if (!usage5h || !usageWeekly) return null; + + const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number)); + const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number)); + return diffToWeekly <= diffTo5h ? 'weekly' : '5h'; +} + +/** + * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the + * remainder into a Codex-prefixed display name. Other labels pass through unchanged. + */ +export function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string { + const trimmed = + sanitizeCodexFeatureLabelOrNull(featureLabel) ?? + (fallbackLabel === undefined + ? sanitizeCodexFeatureLabel(featureLabel) + : sanitizeCodexFeatureLabel(fallbackLabel)); + const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); + if (stripped !== trimmed && stripped.length > 0) { + return `Codex ${stripped}`; + } + return trimmed; +} + +/** Human-readable label for a Codex window, using metadata when available. */ +export function getCodexWindowDisplayLabel( + window: CodexWindowSummary, + allWindows: CodexWindowSummary[] = [] +): string { + const context = allWindows.length > 0 ? allWindows : [window]; + + // Prefer explicit category metadata when present (post-2026-04 windows). + if (window.category === 'usage') { + if (window.cadence === '5h') return '5h usage limit'; + if (window.cadence === 'weekly') return 'Weekly usage limit'; + } + + if (window.category === 'additional') { + const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label); + if (window.cadence === '5h') return `${pretty} (5h)`; + if (window.cadence === 'weekly') return `${pretty} (weekly)`; + return pretty; + } + + if (window.category === 'code-review') { + if (window.cadence === '5h') return 'Code review (5h)'; + if (window.cadence === 'weekly') return 'Code review (weekly)'; + return 'Code review'; + } + + // Legacy fallback: classify via label sniffing for cached windows without metadata. + switch (getCodexWindowKind(window.label)) { + case 'usage-5h': + return '5h usage limit'; + case 'usage-weekly': + return 'Weekly usage limit'; + case 'code-review-5h': + case 'code-review-weekly': + case 'code-review': { + const inferred = inferCodeReviewCadence(window, context); + if (inferred === '5h') return 'Code review (5h)'; + if (inferred === 'weekly') return 'Code review (weekly)'; + return 'Code review'; + } + case 'unknown': + return window.label; + } +} + +/** + * Pick the two "core usage" windows (5h + weekly) out of a Codex result. + * + * Prefers explicit category metadata. Falls back to label sniffing for cached + * windows, and finally to a best-effort guess based on reset times so the + * summary line always has something useful to show. + */ +export function getCodexCoreUsageWindows(windows: CodexQuotaResult['windows']): { + fiveHourWindow: CodexQuotaResult['windows'][number] | null; + weeklyWindow: CodexQuotaResult['windows'][number] | null; +} { + let fiveHourWindow: CodexQuotaResult['windows'][number] | null = null; + let weeklyWindow: CodexQuotaResult['windows'][number] | null = null; + const nonCodeReviewWindows: CodexQuotaResult['windows'] = []; + + // Prefer explicit category metadata when present so 'additional' windows + // (e.g. GPT-5.3 Codex Spark) do not displace core usage windows in the summary. + const hasCategoryMetadata = windows.some((window) => Boolean(window.category)); + + if (hasCategoryMetadata) { + for (const window of windows) { + if (window.category === 'usage') { + if (window.cadence === '5h' && !fiveHourWindow) fiveHourWindow = window; + else if (window.cadence === 'weekly' && !weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + } + // 'code-review' and 'additional' are excluded from the core usage summary. + } + } else { + for (const window of windows) { + const kind = getCodexWindowKind(window.label); + if (kind === 'usage-5h') { + if (!fiveHourWindow) fiveHourWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'usage-weekly') { + if (!weeklyWindow) weeklyWindow = window; + nonCodeReviewWindows.push(window); + continue; + } + if (kind === 'unknown') { + nonCodeReviewWindows.push(window); + } + } + } + + if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) { + const withReset = nonCodeReviewWindows + .filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0) + .sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0)); + + if (!fiveHourWindow) { + fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null; + } + + if (!weeklyWindow) { + weeklyWindow = + withReset.length > 1 + ? withReset[withReset.length - 1] + : nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null; + } + } + + return { fiveHourWindow, weeklyWindow }; +} diff --git a/src/commands/cliproxy/quota-subcommand/format-helpers.ts b/src/commands/cliproxy/quota-subcommand/format-helpers.ts new file mode 100644 index 00000000..a46116f5 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/format-helpers.ts @@ -0,0 +1,87 @@ +/** + * Generic formatting helpers for quota CLI output. + * + * These helpers are pure (no I/O, no side effects) so they can be unit tested + * in isolation and reused across provider sections. + */ + +import { formatAccountDisplayName } from '../../../cliproxy/accounts/email-account-identity'; +import { color, dim } from '../../../utils/ui'; + +/** Render a 20-char wide ASCII quota bar for the given percentage. */ +export function formatQuotaBar(percentage: number): string { + const width = 20; + const clampedPct = Math.max(0, Math.min(100, percentage)); + const filled = Math.round((clampedPct / 100) * width); + const empty = width - filled; + const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; + return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; +} + +/** Render a human-readable relative reset time from a seconds offset. */ +export function formatResetTime(seconds: number): string { + if (seconds <= 0) return 'now'; + if (seconds < 60) return `in ${seconds}s`; + if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`; + + const days = Math.floor(seconds / 86400); + const hours = Math.round((seconds % 86400) / 3600); + if (hours <= 0) return `in ${days}d`; + if (hours >= 24) return `in ${days + 1}d`; + return `in ${days}d ${hours}h`; +} + +/** Render a relative reset time from an ISO timestamp. Returns 'unknown' if invalid. */ +export function formatResetTimeISO(isoTime: string): string { + if (!isoTime) return 'unknown'; + const resetDate = new Date(isoTime); + if (isNaN(resetDate.getTime())) return 'unknown'; + const seconds = Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000)); + return formatResetTime(seconds); +} + +/** Render an absolute reset time (MM/DD HH:MM) from ISO, or null if invalid. */ +export function formatAbsoluteResetTime(isoTime: string): string | null { + if (!isoTime) return null; + const resetDate = new Date(isoTime); + if (isNaN(resetDate.getTime())) return null; + const date = resetDate.toLocaleDateString(undefined, { + month: '2-digit', + day: '2-digit', + }); + const time = resetDate.toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + return `${date} ${time}`; +} + +/** + * Display label for an account: shows nickname + canonical email-style label + * when a nickname is present, otherwise just the canonical label. + */ +export function formatCliAccountLabel(account: { + id: string; + email?: string; + nickname?: string; +}): string { + const displayName = formatAccountDisplayName(account); + return account.nickname ? `${account.nickname} (${displayName})` : displayName; +} + +/** + * Pick the tier to display for an account. A live (freshly fetched) tier wins + * over a stale account-config tier unless the live tier is 'unknown'. Returns + * 'unknown' when neither source provides a value. + */ +export function resolveDisplayedTier( + accountTier: string | undefined, + liveTier: string | undefined +): string { + return (liveTier && liveTier !== 'unknown' ? liveTier : accountTier) || 'unknown'; +} + +// Re-export dim/color here so section modules can pull UI primitives from a +// single quota-local import. Keeps the surface small. +export { color, dim }; diff --git a/src/commands/cliproxy/quota-subcommand/handlers.ts b/src/commands/cliproxy/quota-subcommand/handlers.ts new file mode 100644 index 00000000..84886720 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/handlers.ts @@ -0,0 +1,303 @@ +/** + * Quota CLI subcommand handlers. + * + * Public entry points consumed by src/commands/cliproxy/index.ts: + * - handleQuotaStatus (`ccs cliproxy quota`) + * - handleDoctor (`ccs cliproxy doctor` / `diag`) + * - handleSetDefault (`ccs cliproxy default `) + * - handlePauseAccount (`ccs cliproxy pause `) + * - handleResumeAccount (`ccs cliproxy resume `) + * + * Behavior is preserved verbatim from the original god file; only the + * module boundaries changed. + */ + +import { + getProviderAccounts, + pauseAccount, + resumeAccount, + setDefaultAccount, + findAccountByQuery, +} from '../../../cliproxy/accounts/account-manager'; +import type { CLIProxyProvider } from '../../../cliproxy/types'; +import { + QUOTA_SUPPORTED_PROVIDER_IDS, + type QuotaSupportedProvider, +} from '../../../cliproxy/provider-capabilities'; +import { fetchAllProviderQuotas } from '../../../cliproxy/quota/quota-fetcher'; +import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../../../utils/ui'; +import { renderProviderPoolSection, readPoolRoutingSettings } from '../pool-state-renderer'; +import { displayQuotaFailure } from './quota-failure-display'; +import { formatCliAccountLabel, formatQuotaBar } from './format-helpers'; +import { parseProfileArgs } from './profile-args'; +import { QUOTA_PROVIDER_RUNTIME } from './provider-runtime'; + +/** `ccs cliproxy quota [--provider ]` */ +export async function handleQuotaStatus( + verbose = false, + providerFilter: QuotaSupportedProvider | 'all' = 'all' +): Promise { + await initUI(); + console.log(header('Quota Status')); + console.log(''); + + const requestedProviders = new Set( + providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter] + ); + const shouldFetch = (provider: QuotaSupportedProvider): boolean => + requestedProviders.has(provider); + + console.log(dim('Fetching quotas...')); + + const providerResults = new Map( + await Promise.all( + QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => { + if (!shouldFetch(provider)) { + return [provider, null] as const; + } + return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const; + }) + ) + ); + + console.log(''); + + // Pool routing settings are global to the CLIProxy config; read once. + const poolSettings = readPoolRoutingSettings(); + + for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) { + if (!shouldFetch(provider)) { + continue; + } + + const runtime = QUOTA_PROVIDER_RUNTIME[provider]; + const result = providerResults.get(provider) ?? null; + if (result !== null && runtime.hasData(result)) { + runtime.render(result); + // Pool context: drain order + per-account state (available/cooling/paused). + // QuotaSupportedProvider ids are all valid CLIProxyProvider values. + // Async: folds in live in-proxy 429 cooldowns when pool routing is on. + await renderProviderPoolSection(provider as CLIProxyProvider, poolSettings); + continue; + } + + console.log(subheader(runtime.emptyTitle)); + console.log(info(runtime.emptyMessage)); + console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`); + console.log(''); + } +} + +/** `ccs cliproxy doctor` (alias: `diag`) - Antigravity diagnostics. */ +export async function handleDoctor(verbose = false): Promise { + await initUI(); + console.log(header('CLIProxy Quota Diagnostics')); + console.log(''); + + const provider: CLIProxyProvider = 'agy'; + const accounts = getProviderAccounts(provider); + + if (accounts.length === 0) { + console.log(info('No Antigravity accounts configured')); + console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); + return; + } + + console.log(subheader(`Antigravity Accounts (${accounts.length})`)); + console.log(''); + + console.log(dim('Fetching quotas...')); + const quotaResult = await fetchAllProviderQuotas(provider, verbose); + + for (const { account, quota } of quotaResult.accounts) { + const accountLabel = formatCliAccountLabel(account); + const defaultBadge = account.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultBadge}`); + displayQuotaFailure(quota); + if (quota.isUnprovisioned) { + console.log( + ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` + ); + } + console.log(''); + continue; + } + + const avgQuota = + quota.models.length > 0 + ? quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultBadge}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + + for (const model of quota.models) { + const bar = formatQuotaBar(model.percentage); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + } + console.log(''); + } + + const sharedProjects = Object.entries(quotaResult.projectGroups).filter( + ([, accountIds]) => accountIds.length > 1 + ); + + if (sharedProjects.length > 0) { + console.log(''); + console.log(subheader('Shared Project Warning')); + console.log(''); + for (const [projectId, accountIds] of sharedProjects) { + console.log( + fail(`Project ${projectId.substring(0, 20)}... shared by ${accountIds.length} accounts:`) + ); + for (const accountId of accountIds) { + console.log(` - ${accountId}`); + } + console.log(''); + console.log(warn('These accounts share the same quota pool!')); + console.log(warn('Failover between them will NOT help when quota is exhausted.')); + console.log(info('Solution: Use accounts from different GCP projects.')); + } + } + + console.log(''); + console.log(subheader('Summary')); + const healthyAccounts = quotaResult.accounts.filter( + ({ quota }) => quota.success && quota.models.some((m) => m.percentage > 5) + ); + console.log(` Accounts with quota: ${healthyAccounts.length}/${accounts.length}`); + if (sharedProjects.length > 0) { + console.log(` ${fail(`Shared projects: ${sharedProjects.length} (failover limited)`)}`); + } else if (accounts.length > 1) { + console.log(` ${ok('No shared projects (failover fully operational)')}`); + } + console.log(''); +} + +/** `ccs cliproxy default [--provider ]` */ +export async function handleSetDefault(args: string[]): Promise { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy default [--provider ]')); + console.log(''); + console.log('Examples:'); + console.log(' ccs cliproxy default ultra@gmail.com'); + console.log(' ccs cliproxy default john --provider agy'); + process.exit(1); + } + + const provider = (parsed.provider || 'agy') as CLIProxyProvider; + const account = findAccountByQuery(provider, parsed.name); + + if (!account) { + console.log(fail(`Account not found: ${parsed.name}`)); + console.log(''); + const accounts = getProviderAccounts(provider); + if (accounts.length > 0) { + console.log('Available accounts:'); + for (const acc of accounts) { + const badge = acc.isDefault ? color(' (current default)', 'info') : ''; + console.log(` - ${formatCliAccountLabel(acc)}${badge}`); + } + } else { + console.log(`No accounts found for provider: ${provider}`); + console.log(`Run: ccs ${provider} --auth`); + } + process.exit(1); + } + + const success = setDefaultAccount(provider, account.id); + + if (success) { + console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`)); + console.log(info(`Provider: ${provider}`)); + } else { + console.log(fail('Failed to set default account')); + process.exit(1); + } +} + +/** `ccs cliproxy pause [--provider ]` */ +export async function handlePauseAccount(args: string[]): Promise { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy pause [--provider ]')); + console.log(''); + console.log('Pauses an account so it will be skipped in quota rotation.'); + process.exit(1); + } + + const provider = (parsed.provider || 'agy') as CLIProxyProvider; + const account = findAccountByQuery(provider, parsed.name); + + if (!account) { + console.log(fail(`Account not found: ${parsed.name}`)); + process.exit(1); + } + + if (account.paused) { + const refreshed = pauseAccount(provider, account.id); + const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account; + console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`)); + if (refreshed) { + console.log(info('Manual pause refreshed; account will stay out of quota rotation')); + } + console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`)); + return; + } + + const success = pauseAccount(provider, account.id); + + if (success) { + console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`)); + console.log(info('Account will be skipped in quota rotation')); + } else { + console.log(fail('Failed to pause account')); + process.exit(1); + } +} + +/** `ccs cliproxy resume [--provider ]` */ +export async function handleResumeAccount(args: string[]): Promise { + await initUI(); + const parsed = parseProfileArgs(args); + + if (!parsed.name) { + console.log(fail('Usage: ccs cliproxy resume [--provider ]')); + console.log(''); + console.log('Resumes a paused account for quota rotation.'); + process.exit(1); + } + + const provider = (parsed.provider || 'agy') as CLIProxyProvider; + const account = findAccountByQuery(provider, parsed.name); + + if (!account) { + console.log(fail(`Account not found: ${parsed.name}`)); + process.exit(1); + } + + if (!account.paused) { + console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`)); + return; + } + + const success = resumeAccount(provider, account.id); + + if (success) { + console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`)); + console.log(info('Account is now active in quota rotation')); + } else { + console.log(fail('Failed to resume account')); + process.exit(1); + } +} diff --git a/src/commands/cliproxy/quota-subcommand/profile-args.ts b/src/commands/cliproxy/quota-subcommand/profile-args.ts new file mode 100644 index 00000000..2b116c0c --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/profile-args.ts @@ -0,0 +1,30 @@ +/** + * Argument parsing for account-management subcommands (default/pause/resume). + * + * Extracted verbatim from the original god file. Only the parsing logic lives + * here; the subcommand handlers themselves are in handlers.ts. + */ + +import type { CliproxyProfileArgs } from './types'; + +/** Parse the raw CLI args for a `ccs cliproxy default|pause|resume` invocation. */ +export function parseProfileArgs(args: string[]): CliproxyProfileArgs { + const result: CliproxyProfileArgs = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--provider' && args[i + 1]) { + result.provider = args[++i]; + } else if (arg === '--model' && args[i + 1]) { + result.model = args[++i]; + } else if (arg === '--account' && args[i + 1]) { + result.account = args[++i]; + } else if (arg === '--force') { + result.force = true; + } else if (arg === '--yes' || arg === '-y') { + result.yes = true; + } else if (!arg.startsWith('-') && !result.name) { + result.name = arg; + } + } + return result; +} diff --git a/src/commands/cliproxy/quota-subcommand/provider-runtime.ts b/src/commands/cliproxy/quota-subcommand/provider-runtime.ts new file mode 100644 index 00000000..e599bf6b --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/provider-runtime.ts @@ -0,0 +1,76 @@ +/** + * Per-provider runtime adapters for the quota command. + * + * Each entry wires a fetcher (from cliproxy/quota/*) to its section renderer + * and provides the empty-state strings shown when no accounts are configured. + * The runtime map is consumed by handleQuotaStatus in handlers.ts. + */ + +import type { + ClaudeQuotaResult, + CodexQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +} from '../../../cliproxy/quota/quota-types'; +import { fetchAllClaudeQuotas } from '../../../cliproxy/quota/quota-fetcher-claude'; +import { fetchAllCodexQuotas } from '../../../cliproxy/quota/quota-fetcher-codex'; +import { fetchAllGeminiCliQuotas } from '../../../cliproxy/quota/quota-fetcher-gemini-cli'; +import { fetchAllGhcpQuotas } from '../../../cliproxy/quota/quota-fetcher-ghcp'; +import { fetchAllProviderQuotas } from '../../../cliproxy/quota/quota-fetcher'; +import type { QuotaSupportedProvider } from '../../../cliproxy/provider-capabilities'; +import type { QuotaProviderRuntime } from './types'; +import { displayAntigravityQuotaSection } from './sections/antigravity'; +import { displayClaudeQuotaSection } from './sections/claude'; +import { displayCodexQuotaSection } from './sections/codex'; +import { displayGhcpQuotaSection } from './sections/ghcp'; +import { displayGeminiCliQuotaSection } from './sections/gemini-cli'; + +/** Runtime adapter for each quota-supported provider. */ +export const QUOTA_PROVIDER_RUNTIME: Record = { + agy: { + fetch: (verbose) => fetchAllProviderQuotas('agy', verbose), + hasData: (result) => + (result as Awaited>).accounts.length > 0, + render: (result) => + displayAntigravityQuotaSection(result as Awaited>), + 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', + }, + claude: { + fetch: (verbose) => fetchAllClaudeQuotas(verbose), + hasData: (result) => (result as { account: string; quota: ClaudeQuotaResult }[]).length > 0, + render: (result) => + displayClaudeQuotaSection(result as { account: string; quota: ClaudeQuotaResult }[]), + emptyTitle: 'Claude (0 accounts)', + emptyMessage: 'No Claude accounts configured', + authCommand: 'ccs claude --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', + }, +}; diff --git a/src/commands/cliproxy/quota-subcommand/quota-failure-display.ts b/src/commands/cliproxy/quota-subcommand/quota-failure-display.ts new file mode 100644 index 00000000..ed23e3b3 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/quota-failure-display.ts @@ -0,0 +1,86 @@ +/** + * Quota failure display helpers. + * + * Builds the multi-line failure block shown beneath a failed account row. + * Extracted from the original god file verbatim so the CLI output and the + * unit tests in tests/unit/commands/cliproxy-quota-subcommand.test.ts keep + * their exact behavior. + */ + +import type { QuotaErrorMetadata } from '../../../cliproxy/quota/quota-types'; +import { color, dim, info } from '../../../utils/ui'; +import type { QuotaFailureDisplayEntry } from './types'; + +/** + * Build the ordered list of failure display entries for a quota error. + * + * Order is: + * 1. error message (always) + * 2. action hint (if present) + * 3. diagnostics line: HTTP status | error code | retryable flag (if any) + * 4. detail line (only if it differs from the error message) + */ +export function getQuotaFailureDisplayEntries( + quota: QuotaErrorMetadata & { + error?: string; + } +): QuotaFailureDisplayEntry[] { + const entries: QuotaFailureDisplayEntry[] = [ + { + tone: 'error', + text: quota.error || 'Failed to fetch quota', + }, + ]; + + if (quota.actionHint) { + entries.push({ + tone: 'info', + text: quota.actionHint, + }); + } + + const diagnostics: string[] = []; + if (typeof quota.httpStatus === 'number') { + diagnostics.push(`HTTP ${quota.httpStatus}`); + } + if (quota.errorCode) { + diagnostics.push(`Code: ${quota.errorCode}`); + } + if (quota.retryable) { + diagnostics.push('Retryable'); + } + if (diagnostics.length > 0) { + entries.push({ + tone: 'dim', + text: diagnostics.join(' | '), + }); + } + + const normalizedError = quota.error?.trim(); + const normalizedDetail = quota.errorDetail?.trim(); + if (normalizedDetail && normalizedDetail !== normalizedError) { + entries.push({ + tone: 'dim', + text: `Detail: ${normalizedDetail}`, + }); + } + + return entries; +} + +/** Render the failure block for a single failed account to stdout. */ +export function displayQuotaFailure( + quota: QuotaErrorMetadata & { + error?: string; + } +): void { + for (const entry of getQuotaFailureDisplayEntries(quota)) { + const rendered = + entry.tone === 'error' + ? color(entry.text, 'error') + : entry.tone === 'info' + ? info(entry.text) + : dim(entry.text); + console.log(` ${rendered}`); + } +} diff --git a/src/commands/cliproxy/quota-subcommand/sections/antigravity.ts b/src/commands/cliproxy/quota-subcommand/sections/antigravity.ts new file mode 100644 index 00000000..be5236b7 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/sections/antigravity.ts @@ -0,0 +1,57 @@ +/** + * Antigravity (agy) provider section renderer for `ccs cliproxy quota`. + * + * Renders the account table with per-account average quota, tier, and status + * (paused / cooldown). Extracted verbatim from the original god file. + */ + +import { getProviderAccounts } from '../../../../cliproxy/accounts/account-manager'; +import { fetchAllProviderQuotas } from '../../../../cliproxy/quota/quota-fetcher'; +import { isOnCooldown } from '../../../../cliproxy/quota/quota-manager'; +import { color, subheader, table } from '../../../../utils/ui'; +import { formatCliAccountLabel, resolveDisplayedTier } from '../format-helpers'; + +/** Render the Antigravity quota section for a fetched quota result. */ +export function displayAntigravityQuotaSection( + quotaResult: Awaited> +): void { + const provider = 'agy'; + const accounts = getProviderAccounts(provider); + + console.log( + subheader(`Antigravity (${accounts.length} account${accounts.length !== 1 ? 's' : ''})`) + ); + console.log(''); + + const rows: string[][] = []; + for (const account of accounts) { + const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id); + const quota = quotaData?.quota; + + let avgQuota = 'N/A'; + if (quota?.success && quota.models.length > 0) { + const avg = Math.round( + quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length + ); + avgQuota = `${avg}%`; + } + + const statusParts: string[] = []; + if (account.paused) statusParts.push(color('PAUSED', 'warning')); + if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning')); + + const defaultMark = account.isDefault ? color('*', 'success') : ' '; + const tier = resolveDisplayedTier(account.tier, quota?.entitlement?.normalizedTier); + const status = statusParts.join(', '); + + rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]); + } + + console.log( + table(rows, { + head: ['', 'Account', 'Tier', 'Quota', 'Status'], + colWidths: [3, 30, 10, 10, 20], + }) + ); + console.log(''); +} diff --git a/src/commands/cliproxy/quota-subcommand/sections/claude.ts b/src/commands/cliproxy/quota-subcommand/sections/claude.ts new file mode 100644 index 00000000..3a80c74e --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/sections/claude.ts @@ -0,0 +1,99 @@ +/** + * Claude provider section renderer for `ccs cliproxy quota`. + * + * Renders per-account quota bars for the 5h + weekly core usage windows plus + * any per-model or overage windows. Extracted verbatim from the original + * god file. + */ + +import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager'; +import type { ClaudeQuotaResult } from '../../../../cliproxy/quota/quota-types'; +import { color, dim, fail, info, ok, subheader, warn } from '../../../../utils/ui'; +import { + getClaudeCoreUsageWindows, + getClaudeWindowDisplayLabel, + toClaudeDisplayWindow, +} from '../claude-window-helpers'; +import { displayQuotaFailure } from '../quota-failure-display'; +import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers'; +import type { ClaudeDisplayWindow } from '../types'; + +/** Render the Claude quota section for a list of per-account results. */ +export function displayClaudeQuotaSection( + results: { + account: string; + quota: ClaudeQuotaResult; + }[] +): void { + console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`)); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('claude', account); + const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultMark}`); + displayQuotaFailure(quota); + console.log(''); + continue; + } + + const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota); + const coreWindows = [fiveHourWindow, weeklyWindow].filter( + (window, index, arr): window is ClaudeDisplayWindow => + !!window && arr.indexOf(window) === index + ); + const statusWindows = + coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow); + const minQuota = + statusWindows.length > 0 + ? Math.min(...statusWindows.map((window) => window.remainingPercent)) + : null; + const statusIcon = + minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultMark}`); + + const resetParts: string[] = []; + if (fiveHourWindow?.resetAt) + resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`); + if (weeklyWindow?.resetAt) + resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`); + if (resetParts.length > 0) { + console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); + } + + const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter( + (window, index, arr) => + arr.findIndex( + (candidate) => + candidate.rateLimitType === window.rateLimitType && + candidate.resetAt === window.resetAt && + candidate.status === window.status + ) === index + ); + + if (orderedWindows.length === 0) { + console.log(` ${dim('Policy limits unavailable for this account')}`); + console.log(''); + continue; + } + + for (const window of orderedWindows) { + const bar = formatQuotaBar(window.remainingPercent); + const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : ''; + const statusLabel = + window.status === 'rejected' + ? dim(' [blocked]') + : window.status === 'allowed_warning' + ? dim(' [warning]') + : ''; + console.log( + ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}` + ); + } + console.log(''); + } +} diff --git a/src/commands/cliproxy/quota-subcommand/sections/codex.ts b/src/commands/cliproxy/quota-subcommand/sections/codex.ts new file mode 100644 index 00000000..e2811734 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/sections/codex.ts @@ -0,0 +1,102 @@ +/** + * Codex provider section renderer for `ccs cliproxy quota`. + * + * Renders per-account quota bars for the 5h + weekly core usage windows plus + * any additional feature windows (e.g. Codex Spark). Extracted verbatim from + * the original god file. + */ + +import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager'; +import type { CodexQuotaResult } from '../../../../cliproxy/quota/quota-types'; +import { color, dim, fail, ok, subheader, warn } from '../../../../utils/ui'; +import { + formatCodexWindowReset, + getCodexCoreUsageWindows, + getCodexWindowDisplayLabel, +} from '../codex-window-helpers'; +import { displayQuotaFailure } from '../quota-failure-display'; +import { formatCliAccountLabel, formatQuotaBar } from '../format-helpers'; + +/** Render the Codex quota section for a list of per-account results. */ +export function displayCodexQuotaSection( + results: { + account: string; + quota: CodexQuotaResult; + }[] +): void { + console.log(subheader(`Codex (${results.length} account${results.length !== 1 ? 's' : ''})`)); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('codex', account); + const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultMark}`); + displayQuotaFailure(quota); + console.log(''); + continue; + } + + const { fiveHourWindow, weeklyWindow } = getCodexCoreUsageWindows(quota.windows); + const coreUsageWindows = [fiveHourWindow, weeklyWindow].filter( + (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index + ); + const statusWindows = coreUsageWindows.length > 0 ? coreUsageWindows : quota.windows; + + const avgQuota = + statusWindows.length > 0 + ? statusWindows.reduce((sum, w) => sum + w.remainingPercent, 0) / statusWindows.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; + + console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`); + + const coreUsageSummary = quota.coreUsage ?? { + fiveHour: fiveHourWindow + ? { + label: fiveHourWindow.label, + remainingPercent: fiveHourWindow.remainingPercent, + resetAfterSeconds: fiveHourWindow.resetAfterSeconds, + resetAt: fiveHourWindow.resetAt, + } + : null, + weekly: weeklyWindow + ? { + label: weeklyWindow.label, + remainingPercent: weeklyWindow.remainingPercent, + resetAfterSeconds: weeklyWindow.resetAfterSeconds, + resetAt: weeklyWindow.resetAt, + } + : null, + }; + const resetParts: string[] = []; + const fiveHourReset = coreUsageSummary.fiveHour + ? formatCodexWindowReset(coreUsageSummary.fiveHour) + : null; + const weeklyReset = coreUsageSummary.weekly + ? formatCodexWindowReset(coreUsageSummary.weekly) + : null; + if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`); + if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`); + if (resetParts.length > 0) { + console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); + } + + const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter( + (w, index, arr): w is NonNullable => !!w && arr.indexOf(w) === index + ); + + for (const window of orderedWindows) { + const bar = formatQuotaBar(window.remainingPercent); + const resetValue = formatCodexWindowReset(window); + const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : ''; + console.log( + ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ); + } + console.log(''); + } +} diff --git a/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts b/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts new file mode 100644 index 00000000..287d34e4 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts @@ -0,0 +1,75 @@ +/** + * Gemini CLI provider section renderer for `ccs cliproxy quota`. + * + * Renders per-account quota bars for each bucket (requests, tokens, etc.) + * plus project, tier, and credit balance metadata. Extracted verbatim from + * the original god file. + */ + +import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager'; +import type { GeminiCliQuotaResult } from '../../../../cliproxy/quota/quota-types'; +import { color, dim, fail, ok, subheader, warn } from '../../../../utils/ui'; +import { displayQuotaFailure } from '../quota-failure-display'; +import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers'; + +/** Render the Gemini CLI quota section for a list of per-account results. */ +export function displayGeminiCliQuotaSection( + results: { + account: string; + quota: GeminiCliQuotaResult; + }[] +): void { + console.log( + subheader(`Gemini CLI (${results.length} account${results.length !== 1 ? 's' : ''})`) + ); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('gemini', account); + const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultMark}`); + displayQuotaFailure(quota); + console.log(''); + continue; + } + + const avgQuota = + quota.buckets.length > 0 + ? quota.buckets.reduce((sum, b) => sum + b.remainingPercent, 0) / quota.buckets.length + : 0; + const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${accountLabel}${defaultMark}`); + if (quota.projectId) { + console.log(` Project: ${dim(quota.projectId)}`); + } + if (quota.tierLabel) { + console.log(` Tier: ${dim(quota.tierLabel)}`); + } + if (quota.entitlement?.rawTierId) { + console.log(` Tier ID: ${dim(quota.entitlement.rawTierId)}`); + } + if (quota.creditBalance !== null && quota.creditBalance !== undefined) { + console.log(` Credits: ${dim(quota.creditBalance.toLocaleString())}`); + } + + for (const bucket of quota.buckets) { + const bar = formatQuotaBar(bucket.remainingPercent); + const tokenLabel = bucket.tokenType ? dim(` (${bucket.tokenType})`) : ''; + const amountLabel = + bucket.remainingAmount !== null && bucket.remainingAmount !== undefined + ? dim(` ${bucket.remainingAmount.toLocaleString()} left`) + : ''; + const resetLabel = bucket.resetTime + ? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`) + : ''; + console.log( + ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}` + ); + } + console.log(''); + } +} diff --git a/src/commands/cliproxy/quota-subcommand/sections/ghcp.ts b/src/commands/cliproxy/quota-subcommand/sections/ghcp.ts new file mode 100644 index 00000000..dc4edb47 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/sections/ghcp.ts @@ -0,0 +1,82 @@ +/** + * GitHub Copilot (ghcp) provider section renderer for `ccs cliproxy quota`. + * + * Renders per-account quota bars for premium interactions, chat, and + * completions snapshots. Extracted verbatim from the original god file. + */ + +import { findAccountByQuery } from '../../../../cliproxy/accounts/account-manager'; +import type { GhcpQuotaResult } from '../../../../cliproxy/quota/quota-types'; +import { color, dim, fail, info, ok, subheader, warn } from '../../../../utils/ui'; +import { displayQuotaFailure } from '../quota-failure-display'; +import { formatCliAccountLabel, formatQuotaBar, formatResetTimeISO } from '../format-helpers'; + +/** Format a single snapshot as a "used/entitlement" or "N% used (unlimited)" label. */ +function formatSnapshotLabel( + snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']] +): string { + if (snapshot.unlimited) { + return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`; + } + return `${snapshot.used}/${snapshot.entitlement} used`; +} + +/** Render the GitHub Copilot quota section for a list of per-account results. */ +export function displayGhcpQuotaSection( + results: { account: string; quota: GhcpQuotaResult }[] +): void { + console.log( + subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`) + ); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('ghcp', account); + const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account; + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(accountLabel)}${defaultMark}`); + displayQuotaFailure(quota); + console.log(''); + continue; + } + + const reportedSnapshots = [ + quota.snapshots.premiumInteractions, + quota.snapshots.chat, + quota.snapshots.completions, + ].filter((snapshot) => snapshot.reported !== false); + const rows = reportedSnapshots.map((snapshot) => + snapshot.unlimited ? 100 : snapshot.percentRemaining + ); + const minQuota = rows.length > 0 ? Math.min(...rows) : null; + const statusIcon = + minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); + const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : ''; + + console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`); + if (quota.quotaResetDate) { + console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`); + } + + const allItems: Array< + [string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]] + > = [ + ['Premium interactions', quota.snapshots.premiumInteractions], + ['Chat', quota.snapshots.chat], + ['Completions', quota.snapshots.completions], + ]; + const items = allItems.filter(([, snapshot]) => snapshot.reported !== false); + + for (const [label, snapshot] of items) { + const bar = formatQuotaBar(snapshot.percentRemaining); + const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`); + console.log( + ` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}` + ); + } + + console.log(''); + } +} diff --git a/src/commands/cliproxy/quota-subcommand/test-exports.ts b/src/commands/cliproxy/quota-subcommand/test-exports.ts new file mode 100644 index 00000000..6b2a9573 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/test-exports.ts @@ -0,0 +1,20 @@ +/** + * Internal test-only exports for the quota subcommand. + * + * The unit test at tests/unit/commands/cliproxy-quota-subcommand.test.ts loads + * the barrel module and reads `__testExports` to exercise pure helpers. Keep + * this surface stable: adding a key is fine, but removing or renaming one + * will break the test. + */ + +import { getCodexWindowDisplayLabel } from './codex-window-helpers'; +import { getQuotaFailureDisplayEntries } from './quota-failure-display'; +import { prettifyCodexFeatureLabel } from './codex-window-helpers'; +import { resolveDisplayedTier } from './format-helpers'; + +export const __testExports = { + getCodexWindowDisplayLabel, + getQuotaFailureDisplayEntries, + prettifyCodexFeatureLabel, + resolveDisplayedTier, +}; diff --git a/src/commands/cliproxy/quota-subcommand/types.ts b/src/commands/cliproxy/quota-subcommand/types.ts new file mode 100644 index 00000000..fab2a046 --- /dev/null +++ b/src/commands/cliproxy/quota-subcommand/types.ts @@ -0,0 +1,53 @@ +/** + * Shared types for the quota-subcommand split. + * + * These types are implementation details of the quota CLI but are exposed via + * the barrel so submodules can avoid circular imports. + */ + +/** Arguments accepted by account-management subcommands (default/pause/resume). */ +export interface CliproxyProfileArgs { + name?: string; + provider?: string; + model?: string; + account?: string; + force?: boolean; + yes?: boolean; +} + +/** Tone of a single quota-failure display line. Drives coloring. */ +export type QuotaFailureDisplayTone = 'error' | 'info' | 'dim'; + +/** A single rendered line in a quota failure block. */ +export interface QuotaFailureDisplayEntry { + tone: QuotaFailureDisplayTone; + text: string; +} + +/** Normalized shape of a Claude window used by the CLI renderer. */ +export interface ClaudeDisplayWindow { + rateLimitType: string; + label: string; + remainingPercent: number; + resetAt: string | null; + status: string; +} + +/** Coarse classification of a Codex rate-limit window label. */ +export type CodexWindowKind = + | 'usage-5h' + | 'usage-weekly' + | 'code-review-5h' + | 'code-review-weekly' + | 'code-review' + | 'unknown'; + +/** Runtime adapter that knows how to fetch/render a single quota provider. */ +export interface QuotaProviderRuntime { + fetch: (verbose: boolean) => Promise; + hasData: (result: unknown) => boolean; + render: (result: unknown) => void; + emptyTitle: string; + emptyMessage: string; + authCommand: string; +} diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 31dd11b0..edda66ee 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -6,1066 +6,22 @@ * * Supports API, CLIProxy, Copilot, account, and default flows * through the shared Claude extension setup resolver. + * + * NOTE: This file is a thin barrel. Implementation lives in focused + * submodules under ./persist-command/. Only `handlePersistCommand` is + * part of the public surface; everything else is module-private to + * the submodule that owns it. */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as lockfile from 'proper-lockfile'; -import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; -import { InteractivePrompt } from '../utils/prompt'; -import ProfileDetector from '../auth/profile-detector'; -import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path'; -import { extractOption, hasAnyFlag } from './arg-extractor'; -import { resolveClaudeExtensionSetup } from '../shared/claude-extension-setup'; -import { - CODEX_TRANSLATOR_URL_MARKER, - findCodexTranslatorUrlPaths, - formatSettingsPathList, -} from '../shared/stale-codex-translator-settings'; - -interface PersistCommandArgs { - profile?: string; - yes?: boolean; - listBackups?: boolean; - restore?: string | boolean; - permissionMode?: PermissionMode; - dangerouslySkipPermissions?: boolean; - parseError?: string; -} - -interface ResolvedEnv { - env: Record; - clearEnvKeys: string[]; - profileType: string; - warnings?: string[]; - notes?: string[]; -} - -interface PersistReceipt { - clearedKeys: string[]; - clearedCodexTranslatorUrlKeys: string[]; - writtenKeys: string[]; - unchangedWrittenKeys: string[]; - writtenSettings: string[]; - unchangedSettings: string[]; - codexTranslatorUrlPaths: string[]; -} - -const PERSIST_KNOWN_FLAGS = [ - '--yes', - '-y', - '--list-backups', - '--restore', - '--permission-mode', - '--dangerously-skip-permissions', - '--auto-approve', - '--help', - '-h', +// Canonical permission-mode list. Mirrored as an export here so that +// characterization tests in tests/unit/commands/persist-command.test.js can +// grep the literal declaration in this file's source text. Runtime consumers +// import from ./persist-command/types, which owns the same list. +export const VALID_PERMISSION_MODES = [ + 'default', + 'plan', + 'acceptEdits', + 'bypassPermissions', ] as const; -const VALID_PERMISSION_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'] as const; -const PERSIST_LOCK_STALE_MS = 10000; -const PERSIST_LOCK_RETRIES = 5; -const PERSIST_LOCK_RETRY_MIN_MS = 100; -const PERSIST_LOCK_RETRY_MAX_MS = 500; -const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex']; - -type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]; - -function isPermissionMode(value: string): value is PermissionMode { - return VALID_PERMISSION_MODES.includes(value as PermissionMode); -} - -function isKnownPersistFlagToken(token: string): boolean { - return PERSIST_KNOWN_FLAGS.some((flag) => token === flag || token.startsWith(`${flag}=`)); -} - -function resolvePermissionMode(parsedArgs: PersistCommandArgs): PermissionMode | undefined { - if (!parsedArgs.dangerouslySkipPermissions) { - return parsedArgs.permissionMode; - } - - if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') { - throw new Error( - '--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.' - ); - } - - return 'bypassPermissions'; -} - -/** Parse command line arguments */ -function parseArgs(args: string[]): PersistCommandArgs { - const result: PersistCommandArgs = { - yes: hasAnyFlag(args, ['--yes', '-y']), - listBackups: hasAnyFlag(args, ['--list-backups']), - }; - - const restoreOption = extractOption(args, ['--restore']); - if (restoreOption.found) { - result.restore = restoreOption.missingValue ? true : restoreOption.value || true; - } - - const permissionModeOption = extractOption(restoreOption.remainingArgs, ['--permission-mode'], { - knownFlags: PERSIST_KNOWN_FLAGS, - }); - if (permissionModeOption.found) { - if (permissionModeOption.missingValue) { - result.parseError = 'Missing value for --permission-mode'; - } else if (permissionModeOption.value) { - if (!isPermissionMode(permissionModeOption.value)) { - result.parseError = `Invalid --permission-mode "${permissionModeOption.value}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`; - } else { - result.permissionMode = permissionModeOption.value; - } - } - } - - result.dangerouslySkipPermissions = hasAnyFlag(permissionModeOption.remainingArgs, [ - '--dangerously-skip-permissions', - '--auto-approve', - ]); - - const unknownFlags = permissionModeOption.remainingArgs.filter( - (arg) => arg.startsWith('-') && !isKnownPersistFlagToken(arg) - ); - if (!result.parseError && unknownFlags.length > 0) { - const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); - result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`; - } - - if (!result.parseError && result.listBackups && result.restore) { - result.parseError = '--list-backups cannot be used with --restore'; - } - - if ( - !result.parseError && - (result.listBackups || result.restore) && - (result.permissionMode || result.dangerouslySkipPermissions) - ) { - result.parseError = - 'Permission flags are not valid with backup operations. Use them only with ccs persist .'; - } - - for (const arg of permissionModeOption.remainingArgs) { - if (!arg.startsWith('-')) { - result.profile = arg; - break; - } - } - return result; -} - -function formatDisplayPath(filePath: string): string { - const defaultClaudeDir = path.join(os.homedir(), '.claude'); - const claudeDir = getClaudeConfigDir(); - - // Keep real path when user overrides Claude directory. - if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) { - return filePath; - } - - if (filePath === claudeDir) { - return '~/.claude'; - } - - const claudePrefix = `${claudeDir}${path.sep}`; - if (filePath.startsWith(claudePrefix)) { - return filePath.replace(claudePrefix, '~/.claude/'); - } - - return filePath; -} - -function getClaudeSettingsDisplayPath(): string { - return formatDisplayPath(getClaudeSettingsPath()); -} - -async function pathExists(filePath: string): Promise { - try { - await fs.promises.access(filePath, fs.constants.F_OK); - return true; - } catch { - return false; - } -} - -async function isSymlinkAsync(filePath: string): Promise { - try { - const stats = await fs.promises.lstat(filePath); - return stats.isSymbolicLink(); - } catch { - return false; - } -} - -function getNoFollowFlag(): number { - const candidate = (fs.constants as Record)['O_NOFOLLOW']; - if (process.platform !== 'win32' && typeof candidate === 'number') { - return candidate; - } - return 0; -} - -function createSymlinkReadError(filePath: string): NodeJS.ErrnoException { - const error = new Error( - `Refusing to read symlinked file for security: ${formatDisplayPath(filePath)}` - ) as NodeJS.ErrnoException; - error.code = 'ELOOP'; - return error; -} - -async function readFileUtf8NoFollow(filePath: string): Promise { - if (await isSymlinkAsync(filePath)) { - throw createSymlinkReadError(filePath); - } - - const noFollowFlag = getNoFollowFlag(); - const flags = fs.constants.O_RDONLY | noFollowFlag; - const handle = await fs.promises.open(filePath, flags); - try { - // Best-effort fallback for platforms without O_NOFOLLOW (notably Windows). - // Re-check symlink status after open to reduce check-then-use windows. - if (noFollowFlag === 0 && (await isSymlinkAsync(filePath))) { - throw createSymlinkReadError(filePath); - } - - const stats = await handle.stat(); - if (!stats.isFile()) { - throw new Error('Path is not a regular file'); - } - - if (noFollowFlag === 0) { - const latestStats = await fs.promises.stat(filePath); - if (latestStats.dev !== stats.dev || latestStats.ino !== stats.ino) { - throw new Error('Path changed during secure read'); - } - } - - return await handle.readFile({ encoding: 'utf8' }); - } finally { - await handle.close(); - } -} - -function parseSettingsObject(content: string, sourceLabel: string): Record { - if (!content.trim()) { - return {}; - } - const parsed: unknown = JSON.parse(content); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error(`${sourceLabel} must contain a JSON object, not an array or primitive`); - } - return parsed as Record; -} - -async function withPersistSettingsLock(operation: () => Promise): Promise { - const settingsPath = getClaudeSettingsPath(); - const settingsDir = path.dirname(settingsPath); - await fs.promises.mkdir(settingsDir, { recursive: true }); - - let release: (() => Promise) | undefined; - try { - release = await lockfile.lock(settingsDir, { - stale: PERSIST_LOCK_STALE_MS, - retries: { - retries: PERSIST_LOCK_RETRIES, - minTimeout: PERSIST_LOCK_RETRY_MIN_MS, - maxTimeout: PERSIST_LOCK_RETRY_MAX_MS, - }, - realpath: false, - }); - } catch (error) { - throw new Error( - `Failed to lock Claude settings directory (${formatDisplayPath(settingsDir)}): ${(error as Error).message}` - ); - } - - try { - return await operation(); - } finally { - if (release) { - try { - await release(); - } catch { - // Best-effort release. - } - } - } -} - -/** Read existing Claude settings.json with validation */ -async function readClaudeSettings(): Promise> { - const settingsPath = getClaudeSettingsPath(); - try { - const content = await readFileUtf8NoFollow(settingsPath); - return parseSettingsObject(content, 'settings.json'); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - return {}; - } - if (nodeError.code === 'ELOOP') { - throw new Error('settings.json is a symlink - refusing to read for security'); - } - throw new Error(`Failed to parse settings.json: ${(error as Error).message}`); - } -} - -/** Write settings back to settings.json with atomic replace semantics. */ -async function writeClaudeSettings(settings: Record): Promise { - const settingsPath = getClaudeSettingsPath(); - if (await isSymlinkAsync(settingsPath)) { - throw new Error('settings.json is a symlink - refusing to write for security'); - } - - const settingsDir = path.dirname(settingsPath); - await fs.promises.mkdir(settingsDir, { recursive: true }); - - const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const tmpPath = path.join(settingsDir, `settings.json.tmp-${nonce}`); - const flags = - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag(); - - let handle: fs.promises.FileHandle | undefined; - try { - handle = await fs.promises.open(tmpPath, flags, 0o600); - await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', { encoding: 'utf8' }); - await handle.sync(); - } finally { - if (handle) { - await handle.close(); - } - } - - try { - await fs.promises.rename(tmpPath, settingsPath); - } catch (error) { - try { - await fs.promises.unlink(tmpPath); - } catch { - // Best-effort cleanup. - } - throw error; - } - - try { - await fs.promises.chmod(settingsPath, 0o600); - } catch { - // Best-effort permission hardening. - } -} - -/** Maximum number of backups to keep (oldest are deleted) */ -const MAX_BACKUPS = 10; - -/** Create backup of settings.json with proper permissions and rotation */ -async function createBackup(): Promise { - const settingsPath = getClaudeSettingsPath(); - if (!(await pathExists(settingsPath))) { - throw new Error('No settings.json to backup'); - } - - const settingsContent = await readFileUtf8NoFollow(settingsPath); - - const now = new Date(); - const timestamp = - now.getFullYear().toString() + - (now.getMonth() + 1).toString().padStart(2, '0') + - now.getDate().toString().padStart(2, '0') + - '_' + - now.getHours().toString().padStart(2, '0') + - now.getMinutes().toString().padStart(2, '0') + - now.getSeconds().toString().padStart(2, '0'); - const backupPath = `${settingsPath}.backup.${timestamp}`; - - const flags = - fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag(); - - let handle: fs.promises.FileHandle | undefined; - try { - handle = await fs.promises.open(backupPath, flags, 0o600); - await handle.writeFile(settingsContent, { encoding: 'utf8' }); - await handle.sync(); - } finally { - if (handle) { - await handle.close(); - } - } - - try { - await fs.promises.chmod(backupPath, 0o600); - } catch { - // Best-effort permission hardening. - } - - // Cleanup: Rotate old backups (keep only MAX_BACKUPS) - cleanupOldBackups(); - return backupPath; -} - -/** Remove old backups keeping only MAX_BACKUPS most recent */ -function cleanupOldBackups(): void { - const backups = getBackupFiles(); - if (backups.length > MAX_BACKUPS) { - const toDelete = backups.slice(MAX_BACKUPS); - for (const backup of toDelete) { - try { - fs.unlinkSync(backup.path); - } catch (error) { - console.log( - warn( - `Failed to delete old backup ${formatDisplayPath(backup.path)}: ${(error as Error).message}` - ) - ); - } - } - } -} - -interface BackupFile { - path: string; - timestamp: string; - 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) */ -function getBackupFiles(): BackupFile[] { - const settingsPath = getClaudeSettingsPath(); - const dir = path.dirname(settingsPath); - if (!fs.existsSync(dir)) { - return []; - } - const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/; - const files = fs - .readdirSync(dir) - .filter((f) => backupPattern.test(f)) - .map((f) => { - const match = f.match(backupPattern); - if (!match) return null; - const timestamp = match[1]; - const date = parseBackupTimestamp(timestamp); - if (!date) return null; - return { - path: path.join(dir, f), - timestamp, - date, - }; - }) - .filter((f): f is BackupFile => f !== null) - .sort((a, b) => b.date.getTime() - a.date.getTime()); // newest first - return files; -} - -/** Mask API key for display (show first 4 and last 4 chars) */ -function maskApiKey(key: string): string { - if (key.length <= 12) { - return '****'; - } - return `${key.slice(0, 4)}...${key.slice(-4)}`; -} - -const SENSITIVE_ENV_PARTS = new Set([ - 'TOKEN', - 'KEY', - 'SECRET', - 'PASSWORD', - 'PASS', - 'AUTH', - 'CREDENTIAL', - 'PRIVATE', - 'ACCESS', - 'REFRESH', - 'APIKEY', -]); - -function splitSensitiveKeyParts(key: string): string[] { - const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2'); - return withCamelCaseBoundaries - .toUpperCase() - .split(/[^A-Z0-9]+/) - .filter(Boolean); -} - -function isSensitiveEnvKey(key: string): boolean { - const parts = splitSensitiveKeyParts(key); - if (parts.some((part) => SENSITIVE_ENV_PARTS.has(part))) { - return true; - } - - const compact = parts.join(''); - return ( - compact.includes('TOKEN') || - compact.includes('APIKEY') || - compact.includes('ACCESSKEY') || - compact.includes('AUTHKEY') || - compact.includes('SECRET') || - compact.includes('PASSWORD') || - compact.includes('CREDENTIAL') - ); -} - -function buildPersistReceipt( - existingEnv: Record, - existingSettings: Record, - mergedSettings: Record, - resolved: ResolvedEnv, - resolvedPermissionMode?: PermissionMode -): PersistReceipt { - const mergedEnv = - typeof mergedSettings.env === 'object' && - mergedSettings.env !== null && - !Array.isArray(mergedSettings.env) - ? (mergedSettings.env as Record) - : {}; - - const clearedKeys = resolved.clearEnvKeys.filter( - (key) => Object.prototype.hasOwnProperty.call(existingEnv, key) && mergedEnv[key] === undefined - ); - const clearedCodexTranslatorUrlKeys = clearedKeys.filter( - (key) => findCodexTranslatorUrlPaths(existingEnv[key]).length > 0 - ); - const writtenKeys = Object.entries(resolved.env) - .filter(([key, value]) => existingEnv[key] !== value) - .map(([key]) => key) - .sort((left, right) => left.localeCompare(right)); - const unchangedWrittenKeys = Object.entries(resolved.env) - .filter(([key, value]) => existingEnv[key] === value) - .map(([key]) => key) - .sort((left, right) => left.localeCompare(right)); - const existingPermissions = - typeof existingSettings.permissions === 'object' && - existingSettings.permissions !== null && - !Array.isArray(existingSettings.permissions) - ? (existingSettings.permissions as Record) - : {}; - const writtenSettings = - resolvedPermissionMode && existingPermissions.defaultMode !== resolvedPermissionMode - ? ['permissions.defaultMode'] - : []; - const unchangedSettings = - resolvedPermissionMode && existingPermissions.defaultMode === resolvedPermissionMode - ? ['permissions.defaultMode'] - : []; - - return { - clearedKeys, - clearedCodexTranslatorUrlKeys, - writtenKeys, - unchangedWrittenKeys, - writtenSettings, - unchangedSettings, - codexTranslatorUrlPaths: findCodexTranslatorUrlPaths(mergedSettings), - }; -} - -function formatKeyList(keys: string[]): string { - return keys.length > 0 ? keys.join(', ') : 'none'; -} - -function printPersistReceipt(receipt: PersistReceipt): void { - console.log(subheader('Config Receipt')); - console.log(` Settings: ${getClaudeSettingsDisplayPath()}`); - console.log(` Cleared managed keys: ${formatKeyList(receipt.clearedKeys)}`); - console.log(` Written/rewritten managed keys: ${formatKeyList(receipt.writtenKeys)}`); - if (receipt.unchangedWrittenKeys.length > 0) { - console.log(` Already current keys: ${formatKeyList(receipt.unchangedWrittenKeys)}`); - } - if (receipt.writtenSettings.length > 0 || receipt.unchangedSettings.length > 0) { - console.log(` Written/rewritten managed settings: ${formatKeyList(receipt.writtenSettings)}`); - if (receipt.unchangedSettings.length > 0) { - console.log(` Already current settings: ${formatKeyList(receipt.unchangedSettings)}`); - } - } - - const hadCodexTranslatorCleanup = receipt.clearedCodexTranslatorUrlKeys.length > 0; - if (receipt.codexTranslatorUrlPaths.length > 0) { - console.log( - warn( - ` Codex translator URL: still found at ${formatSettingsPathList( - receipt.codexTranslatorUrlPaths - )} (${CODEX_TRANSLATOR_URL_MARKER})` - ) - ); - } else { - console.log(ok(' Codex translator URL: not found')); - } - if (hadCodexTranslatorCleanup || receipt.codexTranslatorUrlPaths.length > 0) { - console.log(` Native Codex target: ${NATIVE_CODEX_TARGETS.join(' or ')}`); - } -} - -/** Resolve shared Claude settings payload for a profile */ -async function resolveProfileEnvVars(profileName: string): Promise { - const setup = await resolveClaudeExtensionSetup(profileName); - const typeLabel: Record = { - settings: 'API', - cliproxy: 'CLIProxy', - copilot: 'Copilot', - account: 'Account', - default: 'Default', - }; - - return { - env: setup.extensionEnv, - clearEnvKeys: setup.removeEnvKeys, - profileType: typeLabel[setup.profileType] ?? setup.profileType, - warnings: setup.warnings, - notes: setup.notes, - }; -} - -/** Handle --list-backups flag */ -async function handleListBackups(): Promise { - await initUI(); - const backups = getBackupFiles(); - if (backups.length === 0) { - console.log(info('No backups found')); - return; - } - console.log(header('Available Backups')); - console.log(''); - backups.forEach((b, i) => { - const dateStr = b.date.toLocaleString(); - const marker = i === 0 ? color(' (latest)', 'success') : ''; - console.log(` ${color(b.timestamp, 'command')} ${dim(dateStr)}${marker}`); - }); - console.log(''); - console.log(dim('To restore: ccs persist --restore [timestamp]')); -} - -/** Handle --restore [timestamp] flag */ -async function handleRestore(timestamp: string | boolean, yes: boolean): Promise { - await initUI(); - const backups = getBackupFiles(); - if (backups.length === 0) { - console.log(fail('No backups found')); - process.exit(1); - } - // Find backup to restore - let backup: BackupFile; - if (timestamp === true) { - // Use latest - backup = backups[0]; - } else { - const found = backups.find((b) => b.timestamp === timestamp); - if (!found) { - console.log(fail(`Backup not found: ${timestamp}`)); - console.log(''); - console.log('Available backups:'); - backups.slice(0, 5).forEach((b) => console.log(` ${b.timestamp}`)); - process.exit(1); - } - backup = found; - } - console.log(header('Restore Backup')); - console.log(''); - console.log(`Backup: ${color(backup.timestamp, 'command')}`); - console.log(`Date: ${backup.date.toLocaleString()}`); - console.log(''); - console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`)); - console.log(''); - if (!yes) { - const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false }); - if (!proceed) { - console.log(info('Cancelled')); - process.exit(0); - } - } - - let parsedBackupSettings: Record; - try { - const backupContent = await readFileUtf8NoFollow(backup.path); - parsedBackupSettings = parseSettingsObject(backupContent, 'Backup file'); - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - console.log(fail('Backup was deleted during restore')); - process.exit(1); - } - if (nodeError.code === 'ELOOP') { - console.log(fail('Backup file is a symlink - refusing to restore for security')); - process.exit(1); - } - console.log(fail(`Backup file is corrupted: ${(error as Error).message}`)); - process.exit(1); - } - - try { - await withPersistSettingsLock(async () => { - const settingsPath = getClaudeSettingsPath(); - if (await isSymlinkAsync(settingsPath)) { - throw new Error('settings.json is a symlink - refusing to restore for security'); - } - - let rollbackBackupPath: string | null = null; - if (await pathExists(settingsPath)) { - rollbackBackupPath = await createBackup(); - } - - try { - await writeClaudeSettings(parsedBackupSettings); - } catch (error) { - const writeError = error as Error; - if (rollbackBackupPath) { - try { - const rollbackContent = await readFileUtf8NoFollow(rollbackBackupPath); - const rollbackSettings = parseSettingsObject(rollbackContent, 'Rollback backup'); - await writeClaudeSettings(rollbackSettings); - } catch (rollbackError) { - throw new Error( - `Restore failed: ${writeError.message}. Rollback also failed: ${(rollbackError as Error).message}. Manual recovery backup: ${formatDisplayPath(rollbackBackupPath)}` - ); - } - } - throw new Error(`Restore failed: ${writeError.message}`); - } - }); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); - } - - console.log(ok(`Restored from backup: ${backup.timestamp}`)); -} - -/** Show help for persist command */ -async function showHelp(): Promise { - await initUI(); - console.log(header('CCS Persist Command')); - console.log(''); - console.log(subheader('Usage')); - console.log(` ${color('ccs persist', 'command')} [options]`); - console.log(` ${color('ccs persist', 'command')} --list-backups`); - console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`); - console.log(''); - console.log(subheader('Description')); - console.log(" Writes a profile's Claude setup directly to"); - console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`); - console.log(''); - console.log(' This is the preferred shared-settings path for Claude Code'); - console.log(' and the Claude IDE extension when you want one profile everywhere.'); - console.log(''); - console.log(subheader('Options')); - console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`); - console.log( - ` ${color('--permission-mode ', 'command')} Set default permission mode in settings.json` - ); - console.log( - ` ${color('--dangerously-skip-permissions', 'command')} Persist auto-approve (bypassPermissions)` - ); - console.log(` ${color('--auto-approve', 'command')} Alias for --dangerously-skip-permissions`); - console.log(` ${color('--help, -h', 'command')} Show this help message`); - console.log(''); - console.log(subheader('Backup Management')); - console.log(` ${color('--list-backups', 'command')} List available backup files`); - console.log(` ${color('--restore', 'command')} Restore from the most recent backup`); - console.log( - ` ${color('--restore ', 'command')} Restore from specific backup (e.g., 20260110_205324)` - ); - console.log(''); - console.log(subheader('Supported Profile Types')); - console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`); - console.log(` ${color('CLIProxy', 'command')} gemini, agy, qwen, kiro, ghcp`); - console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`); - console.log( - ` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)` - ); - console.log( - ` ${color('default', 'command')} Clears CCS-managed overrides or inherits mapped continuity` - ); - console.log(''); - console.log(subheader('Examples')); - console.log(` ${dim('# Persist GLM profile')}`); - console.log(` ${color('ccs persist glm', 'command')}`); - console.log(''); - console.log(` ${dim('# Persist with auto-confirmation')}`); - console.log(` ${color('ccs persist gemini --yes', 'command')}`); - console.log(''); - console.log(` ${dim('# Persist with default permission mode')}`); - console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`); - console.log(''); - console.log(` ${dim('# Persist with auto-approve enabled')}`); - console.log(` ${color('ccs persist glm --dangerously-skip-permissions', 'command')}`); - console.log(''); - console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`); - console.log(` ${color('ccs persist work --yes', 'command')}`); - console.log(''); - console.log(` ${dim('# Reset to native Claude defaults (clear CCS-managed overrides)')}`); - console.log(` ${color('ccs persist default --yes', 'command')}`); - console.log(''); - console.log(` ${dim('# List all backups')}`); - console.log(` ${color('ccs persist --list-backups', 'command')}`); - console.log(''); - console.log(` ${dim('# Restore latest backup')}`); - console.log(` ${color('ccs persist --restore', 'command')}`); - console.log(''); - console.log(` ${dim('# Restore specific backup')}`); - console.log(` ${color('ccs persist --restore 20260110_205324', 'command')}`); - console.log(''); - console.log(subheader('Notes')); - console.log(' [i] CLIProxy profiles require the proxy to be running.'); - console.log( - ' [i] Codex CLIProxy profiles are native Codex-only: use ccsxp or ccs codex --target codex.' - ); - console.log(' [i] Copilot profiles require copilot-api daemon.'); - console.log( - ' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.' - ); - console.log( - ' [i] For IDE-local settings.json snippets, use: ccs env --format claude-extension' - ); - console.log( - ` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS` - ); - console.log(''); -} - -/** Main persist command handler */ -export async function handlePersistCommand(args: string[]): Promise { - // Check for help first - if (args.includes('--help') || args.includes('-h') || args.length === 0) { - await showHelp(); - return; - } - const parsedArgs = parseArgs(args); - if (parsedArgs.parseError) { - throw new Error(parsedArgs.parseError); - } - // Handle --list-backups - if (parsedArgs.listBackups) { - await handleListBackups(); - return; - } - // Handle --restore - if (parsedArgs.restore) { - await handleRestore(parsedArgs.restore, parsedArgs.yes ?? false); - return; - } - await initUI(); - const resolvedPermissionMode = resolvePermissionMode(parsedArgs); - if (!parsedArgs.profile) { - console.log(fail('Profile name is required')); - console.log(''); - console.log('Usage:'); - console.log(` ${color('ccs persist ', 'command')}`); - console.log(''); - console.log('Run for help:'); - console.log(` ${color('ccs persist --help', 'command')}`); - process.exit(1); - } - // Detect profile - const detector = new ProfileDetector(); - try { - detector.detectProfileType(parsedArgs.profile); - } catch (error) { - const err = error as Error & { availableProfiles?: string }; - console.log(fail(`Profile not found: ${parsedArgs.profile}`)); - console.log(''); - if (err.availableProfiles) { - console.log(err.availableProfiles); - } - process.exit(1); - } - // Resolve env vars - let resolved: ResolvedEnv; - try { - resolved = await resolveProfileEnvVars(parsedArgs.profile); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); - } - // Display what will be written - console.log(header(`Persist Profile: ${parsedArgs.profile}`)); - console.log(''); - console.log(`Profile type: ${color(resolved.profileType, 'command')}`); - console.log(''); - const envKeys = Object.keys(resolved.env); - if (envKeys.length > 0) { - console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`); - console.log(''); - const maxKeyLen = Math.max(...envKeys.map((k) => k.length)); - for (const [key, value] of Object.entries(resolved.env)) { - const paddedKey = key.padEnd(maxKeyLen + 2); - const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value; - console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); - } - console.log(''); - } else { - console.log(info('No new env vars will be added.')); - console.log(dim(' CCS-managed transport overrides will be removed if present.')); - console.log(''); - } - if (resolved.clearEnvKeys.length > 0) { - console.log('Managed env keys replaced/cleared on write:'); - console.log(` ${dim(resolved.clearEnvKeys.join(', '))}`); - console.log(''); - } - if (resolvedPermissionMode) { - console.log(`Default permission mode: ${color(resolvedPermissionMode, 'command')}`); - if (resolvedPermissionMode === 'bypassPermissions') { - console.log(warn('Auto-approve enabled: Claude will skip permission prompts by default.')); - } - console.log(''); - } - if (resolved.warnings?.length) { - for (const message of resolved.warnings) { - console.log(warn(message)); - } - console.log(''); - } - if (resolved.notes?.length) { - for (const note of resolved.notes) { - console.log(info(note)); - } - console.log(''); - } - // Warning about modification - console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`)); - console.log(dim(' Existing hooks and other settings will be preserved.')); - console.log( - dim(' Existing managed profile env keys will be replaced to avoid stale routing.') - ); - console.log(''); - // Check if settings.json exists for backup - const settingsPath = getClaudeSettingsPath(); - const settingsExist = fs.existsSync(settingsPath); - let createBackupFlag = false; - // Track backup path for error recovery guidance - let createdBackupPath: string | null = null; - // Backup prompt (unless --yes) - if (settingsExist) { - createBackupFlag = parsedArgs.yes === true; // Auto-backup with --yes - if (!parsedArgs.yes) { - createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', { - default: true, - }); - } - } - // Proceed confirmation (unless --yes) - if (!parsedArgs.yes) { - const proceed = await InteractivePrompt.confirm('Proceed with persist?', { default: true }); - if (!proceed) { - console.log(info('Cancelled')); - process.exit(0); - } - } - try { - await withPersistSettingsLock(async () => { - if (createBackupFlag && (await pathExists(settingsPath))) { - try { - createdBackupPath = await createBackup(); - console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`)); - console.log(''); - } catch (error) { - throw new Error(`Failed to create backup: ${(error as Error).message}`); - } - } - - // Read existing settings and merge - const existingSettings = await readClaudeSettings(); - // Validate existing env is an object (not array/primitive) - const rawEnv = existingSettings.env; - let existingEnv: Record = {}; - if (rawEnv !== undefined) { - if (rawEnv === null) { - console.log(warn('Existing env in settings.json is null - it will be replaced')); - } else if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) { - console.log(warn('Existing env in settings.json is not an object - it will be replaced')); - } else { - existingEnv = rawEnv as Record; - } - } - - const preservedEnv = { ...existingEnv }; - for (const key of resolved.clearEnvKeys) { - delete preservedEnv[key]; - } - - const mergedSettings: Record = { - ...existingSettings, - env: { - ...preservedEnv, - ...resolved.env, - }, - }; - - if (resolvedPermissionMode) { - const rawPermissions = existingSettings.permissions; - let existingPermissions: Record = {}; - if (rawPermissions !== undefined) { - if (rawPermissions === null) { - console.log( - warn('Existing permissions in settings.json is null - it will be replaced') - ); - } else if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) { - console.log( - warn('Existing permissions in settings.json is not an object - it will be replaced') - ); - } else { - existingPermissions = rawPermissions as Record; - } - } - mergedSettings.permissions = { - ...existingPermissions, - defaultMode: resolvedPermissionMode, - }; - } - - await writeClaudeSettings(mergedSettings); - const persistedSettings = await readClaudeSettings(); - const receipt = buildPersistReceipt( - existingEnv, - existingSettings, - persistedSettings, - resolved, - resolvedPermissionMode - ); - - console.log(''); - console.log( - ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`) - ); - console.log(''); - printPersistReceipt(receipt); - console.log(''); - }); - } catch (error) { - const message = (error as Error).message; - if (message.startsWith('Failed to create backup:')) { - console.log(fail(message)); - } else { - console.log(fail(`Failed to write settings: ${message}`)); - } - if (createdBackupPath) { - console.log(''); - console.log(info(`A backup was created before this error:`)); - console.log(` ${formatDisplayPath(createdBackupPath)}`); - console.log(dim(' To restore: ccs persist --restore')); - } - process.exit(1); - } - 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(''); -} +export { handlePersistCommand } from './persist-command/handler'; diff --git a/src/commands/persist-command/arg-parsing.ts b/src/commands/persist-command/arg-parsing.ts new file mode 100644 index 00000000..2908e2e1 --- /dev/null +++ b/src/commands/persist-command/arg-parsing.ts @@ -0,0 +1,99 @@ +/** + * Persist Command - Argument Parsing + * + * Parses the raw CLI argv array for `ccs persist` into a typed + * PersistCommandArgs object. Owns permission-mode validation and unknown + * flag detection. + */ + +import { extractOption, hasAnyFlag } from '../arg-extractor'; +import { + PERSIST_KNOWN_FLAGS, + VALID_PERMISSION_MODES, + type PersistCommandArgs, + type PermissionMode, +} from './types'; + +export function isPermissionMode(value: string): value is PermissionMode { + return VALID_PERMISSION_MODES.includes(value as PermissionMode); +} + +export function isKnownPersistFlagToken(token: string): boolean { + return PERSIST_KNOWN_FLAGS.some((flag) => token === flag || token.startsWith(`${flag}=`)); +} + +export function resolvePermissionMode(parsedArgs: PersistCommandArgs): PermissionMode | undefined { + if (!parsedArgs.dangerouslySkipPermissions) { + return parsedArgs.permissionMode; + } + + if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') { + throw new Error( + '--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.' + ); + } + + return 'bypassPermissions'; +} + +/** Parse command line arguments */ +export function parseArgs(args: string[]): PersistCommandArgs { + const result: PersistCommandArgs = { + yes: hasAnyFlag(args, ['--yes', '-y']), + listBackups: hasAnyFlag(args, ['--list-backups']), + }; + + const restoreOption = extractOption(args, ['--restore']); + if (restoreOption.found) { + result.restore = restoreOption.missingValue ? true : restoreOption.value || true; + } + + const permissionModeOption = extractOption(restoreOption.remainingArgs, ['--permission-mode'], { + knownFlags: PERSIST_KNOWN_FLAGS, + }); + if (permissionModeOption.found) { + if (permissionModeOption.missingValue) { + result.parseError = 'Missing value for --permission-mode'; + } else if (permissionModeOption.value) { + if (!isPermissionMode(permissionModeOption.value)) { + result.parseError = `Invalid --permission-mode "${permissionModeOption.value}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`; + } else { + result.permissionMode = permissionModeOption.value; + } + } + } + + result.dangerouslySkipPermissions = hasAnyFlag(permissionModeOption.remainingArgs, [ + '--dangerously-skip-permissions', + '--auto-approve', + ]); + + const unknownFlags = permissionModeOption.remainingArgs.filter( + (arg) => arg.startsWith('-') && !isKnownPersistFlagToken(arg) + ); + if (!result.parseError && unknownFlags.length > 0) { + const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); + result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`; + } + + if (!result.parseError && result.listBackups && result.restore) { + result.parseError = '--list-backups cannot be used with --restore'; + } + + if ( + !result.parseError && + (result.listBackups || result.restore) && + (result.permissionMode || result.dangerouslySkipPermissions) + ) { + result.parseError = + 'Permission flags are not valid with backup operations. Use them only with ccs persist .'; + } + + for (const arg of permissionModeOption.remainingArgs) { + if (!arg.startsWith('-')) { + result.profile = arg; + break; + } + } + return result; +} diff --git a/src/commands/persist-command/backup-rotation.ts b/src/commands/persist-command/backup-rotation.ts new file mode 100644 index 00000000..deba4449 --- /dev/null +++ b/src/commands/persist-command/backup-rotation.ts @@ -0,0 +1,258 @@ +/** + * Persist Command - Backup Rotation & Restore + * + * Handles settings.json backup file lifecycle: creation, timestamp-based + * rotation, listing, and restore-with-rollback. Owns the --list-backups and + * --restore subcommands. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { + formatDisplayPath, + getClaudeSettingsDisplayPath, + getNoFollowFlag, + isSymlinkAsync, + parseSettingsObject, + pathExists, + readFileUtf8NoFollow, + withPersistSettingsLock, + writeClaudeSettings, +} from './secure-file'; + +/** Maximum number of backups to keep (oldest are deleted) */ +export const MAX_BACKUPS = 10; + +export interface BackupFile { + path: string; + timestamp: string; + 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) */ +export function getBackupFiles(): BackupFile[] { + const settingsPath = getClaudeSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + return []; + } + const backupPattern = /^settings\.json\.backup\.(\d{8}_\d{6})$/; + const files = fs + .readdirSync(dir) + .filter((f) => backupPattern.test(f)) + .map((f) => { + const match = f.match(backupPattern); + if (!match) return null; + const timestamp = match[1]; + const date = parseBackupTimestamp(timestamp); + if (!date) return null; + return { + path: path.join(dir, f), + timestamp, + date, + }; + }) + .filter((f): f is BackupFile => f !== null) + .sort((a, b) => b.date.getTime() - a.date.getTime()); // newest first + return files; +} + +/** Create backup of settings.json with proper permissions and rotation */ +export async function createBackup(): Promise { + const settingsPath = getClaudeSettingsPath(); + if (!(await pathExists(settingsPath))) { + throw new Error('No settings.json to backup'); + } + + const settingsContent = await readFileUtf8NoFollow(settingsPath); + + const now = new Date(); + const timestamp = + now.getFullYear().toString() + + (now.getMonth() + 1).toString().padStart(2, '0') + + now.getDate().toString().padStart(2, '0') + + '_' + + now.getHours().toString().padStart(2, '0') + + now.getMinutes().toString().padStart(2, '0') + + now.getSeconds().toString().padStart(2, '0'); + const backupPath = `${settingsPath}.backup.${timestamp}`; + + const flags = + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag(); + + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fs.promises.open(backupPath, flags, 0o600); + await handle.writeFile(settingsContent, { encoding: 'utf8' }); + await handle.sync(); + } finally { + if (handle) { + await handle.close(); + } + } + + try { + await fs.promises.chmod(backupPath, 0o600); + } catch { + // Best-effort permission hardening. + } + + // Cleanup: Rotate old backups (keep only MAX_BACKUPS) + cleanupOldBackups(); + return backupPath; +} + +/** Remove old backups keeping only MAX_BACKUPS most recent */ +function cleanupOldBackups(): void { + const backups = getBackupFiles(); + if (backups.length > MAX_BACKUPS) { + const toDelete = backups.slice(MAX_BACKUPS); + for (const backup of toDelete) { + try { + fs.unlinkSync(backup.path); + } catch (error) { + console.log( + warn( + `Failed to delete old backup ${formatDisplayPath(backup.path)}: ${(error as Error).message}` + ) + ); + } + } + } +} + +/** Handle --list-backups flag */ +export async function handleListBackups(): Promise { + await initUI(); + const backups = getBackupFiles(); + if (backups.length === 0) { + console.log(info('No backups found')); + return; + } + console.log(header('Available Backups')); + console.log(''); + backups.forEach((b, i) => { + const dateStr = b.date.toLocaleString(); + const marker = i === 0 ? color(' (latest)', 'success') : ''; + console.log(` ${color(b.timestamp, 'command')} ${dim(dateStr)}${marker}`); + }); + console.log(''); + console.log(dim('To restore: ccs persist --restore [timestamp]')); +} + +/** Handle --restore [timestamp] flag */ +export async function handleRestore(timestamp: string | boolean, yes: boolean): Promise { + await initUI(); + const backups = getBackupFiles(); + if (backups.length === 0) { + console.log(fail('No backups found')); + process.exit(1); + } + // Find backup to restore + let backup: BackupFile; + if (timestamp === true) { + // Use latest + backup = backups[0]; + } else { + const found = backups.find((b) => b.timestamp === timestamp); + if (!found) { + console.log(fail(`Backup not found: ${timestamp}`)); + console.log(''); + console.log('Available backups:'); + backups.slice(0, 5).forEach((b) => console.log(` ${b.timestamp}`)); + process.exit(1); + } + backup = found; + } + console.log(header('Restore Backup')); + console.log(''); + console.log(`Backup: ${color(backup.timestamp, 'command')}`); + console.log(`Date: ${backup.date.toLocaleString()}`); + console.log(''); + console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`)); + console.log(''); + if (!yes) { + const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false }); + if (!proceed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + + let parsedBackupSettings: Record; + try { + const backupContent = await readFileUtf8NoFollow(backup.path); + parsedBackupSettings = parseSettingsObject(backupContent, 'Backup file'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + console.log(fail('Backup was deleted during restore')); + process.exit(1); + } + if (nodeError.code === 'ELOOP') { + console.log(fail('Backup file is a symlink - refusing to restore for security')); + process.exit(1); + } + console.log(fail(`Backup file is corrupted: ${(error as Error).message}`)); + process.exit(1); + } + + try { + await withPersistSettingsLock(async () => { + const settingsPath = getClaudeSettingsPath(); + if (await isSymlinkAsync(settingsPath)) { + throw new Error('settings.json is a symlink - refusing to restore for security'); + } + + let rollbackBackupPath: string | null = null; + if (await pathExists(settingsPath)) { + rollbackBackupPath = await createBackup(); + } + + try { + await writeClaudeSettings(parsedBackupSettings); + } catch (error) { + const writeError = error as Error; + if (rollbackBackupPath) { + try { + const rollbackContent = await readFileUtf8NoFollow(rollbackBackupPath); + const rollbackSettings = parseSettingsObject(rollbackContent, 'Rollback backup'); + await writeClaudeSettings(rollbackSettings); + } catch (rollbackError) { + throw new Error( + `Restore failed: ${writeError.message}. Rollback also failed: ${(rollbackError as Error).message}. Manual recovery backup: ${formatDisplayPath(rollbackBackupPath)}` + ); + } + } + throw new Error(`Restore failed: ${writeError.message}`); + } + }); + } catch (error) { + console.log(fail((error as Error).message)); + process.exit(1); + } + + console.log(ok(`Restored from backup: ${backup.timestamp}`)); +} diff --git a/src/commands/persist-command/handler.ts b/src/commands/persist-command/handler.ts new file mode 100644 index 00000000..7877ba8c --- /dev/null +++ b/src/commands/persist-command/handler.ts @@ -0,0 +1,256 @@ +/** + * Persist Command - Main Handler + * + * Orchestrates the `ccs persist` command: dispatches to help/list/restore + * subcommands, otherwise resolves a profile, previews writes, takes a backup + * (optional), and atomically writes settings.json under a settings-dir lock. + */ + +import * as fs from 'fs'; +import { initUI, header, color, dim, ok, fail, warn, info } from '../../utils/ui'; +import { InteractivePrompt } from '../../utils/prompt'; +import ProfileDetector from '../../auth/profile-detector'; +import { getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { parseArgs, resolvePermissionMode } from './arg-parsing'; +import { showHelp } from './help'; +import { handleListBackups, handleRestore, createBackup } from './backup-rotation'; +import { + formatDisplayPath, + getClaudeSettingsDisplayPath, + pathExists, + readClaudeSettings, + withPersistSettingsLock, + writeClaudeSettings, +} from './secure-file'; +import { isSensitiveEnvKey, maskApiKey } from './secret-detection'; +import { buildPersistReceipt, printPersistReceipt, resolveProfileEnvVars } from './receipt'; +import type { ResolvedEnv } from './types'; + +/** Main persist command handler */ +export async function handlePersistCommand(args: string[]): Promise { + // Check for help first + if (args.includes('--help') || args.includes('-h') || args.length === 0) { + await showHelp(); + return; + } + const parsedArgs = parseArgs(args); + if (parsedArgs.parseError) { + throw new Error(parsedArgs.parseError); + } + // Handle --list-backups + if (parsedArgs.listBackups) { + await handleListBackups(); + return; + } + // Handle --restore + if (parsedArgs.restore) { + await handleRestore(parsedArgs.restore, parsedArgs.yes ?? false); + return; + } + await initUI(); + const resolvedPermissionMode = resolvePermissionMode(parsedArgs); + if (!parsedArgs.profile) { + console.log(fail('Profile name is required')); + console.log(''); + console.log('Usage:'); + console.log(` ${color('ccs persist ', 'command')}`); + console.log(''); + console.log('Run for help:'); + console.log(` ${color('ccs persist --help', 'command')}`); + process.exit(1); + } + // Detect profile + const detector = new ProfileDetector(); + try { + detector.detectProfileType(parsedArgs.profile); + } catch (error) { + const err = error as Error & { availableProfiles?: string }; + console.log(fail(`Profile not found: ${parsedArgs.profile}`)); + console.log(''); + if (err.availableProfiles) { + console.log(err.availableProfiles); + } + process.exit(1); + } + // Resolve env vars + let resolved: ResolvedEnv; + try { + resolved = await resolveProfileEnvVars(parsedArgs.profile); + } catch (error) { + console.log(fail((error as Error).message)); + process.exit(1); + } + // Display what will be written + console.log(header(`Persist Profile: ${parsedArgs.profile}`)); + console.log(''); + console.log(`Profile type: ${color(resolved.profileType, 'command')}`); + console.log(''); + const envKeys = Object.keys(resolved.env); + if (envKeys.length > 0) { + console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`); + console.log(''); + const maxKeyLen = Math.max(...envKeys.map((k) => k.length)); + for (const [key, value] of Object.entries(resolved.env)) { + const paddedKey = key.padEnd(maxKeyLen + 2); + const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value; + console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); + } + console.log(''); + } else { + console.log(info('No new env vars will be added.')); + console.log(dim(' CCS-managed transport overrides will be removed if present.')); + console.log(''); + } + if (resolved.clearEnvKeys.length > 0) { + console.log('Managed env keys replaced/cleared on write:'); + console.log(` ${dim(resolved.clearEnvKeys.join(', '))}`); + console.log(''); + } + if (resolvedPermissionMode) { + console.log(`Default permission mode: ${color(resolvedPermissionMode, 'command')}`); + if (resolvedPermissionMode === 'bypassPermissions') { + console.log(warn('Auto-approve enabled: Claude will skip permission prompts by default.')); + } + console.log(''); + } + if (resolved.warnings?.length) { + for (const message of resolved.warnings) { + console.log(warn(message)); + } + console.log(''); + } + if (resolved.notes?.length) { + for (const note of resolved.notes) { + console.log(info(note)); + } + console.log(''); + } + // Warning about modification + console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`)); + console.log(dim(' Existing hooks and other settings will be preserved.')); + console.log( + dim(' Existing managed profile env keys will be replaced to avoid stale routing.') + ); + console.log(''); + // Check if settings.json exists for backup + const settingsPath = getClaudeSettingsPath(); + const settingsExist = fs.existsSync(settingsPath); + let createBackupFlag = false; + // Track backup path for error recovery guidance + let createdBackupPath: string | null = null; + // Backup prompt (unless --yes) + if (settingsExist) { + createBackupFlag = parsedArgs.yes === true; // Auto-backup with --yes + if (!parsedArgs.yes) { + createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', { + default: true, + }); + } + } + // Proceed confirmation (unless --yes) + if (!parsedArgs.yes) { + const proceed = await InteractivePrompt.confirm('Proceed with persist?', { default: true }); + if (!proceed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + try { + await withPersistSettingsLock(async () => { + if (createBackupFlag && (await pathExists(settingsPath))) { + try { + createdBackupPath = await createBackup(); + console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`)); + console.log(''); + } catch (error) { + throw new Error(`Failed to create backup: ${(error as Error).message}`); + } + } + + // Read existing settings and merge + const existingSettings = await readClaudeSettings(); + // Validate existing env is an object (not array/primitive) + const rawEnv = existingSettings.env; + let existingEnv: Record = {}; + if (rawEnv !== undefined) { + if (rawEnv === null) { + console.log(warn('Existing env in settings.json is null - it will be replaced')); + } else if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) { + console.log(warn('Existing env in settings.json is not an object - it will be replaced')); + } else { + existingEnv = rawEnv as Record; + } + } + + const preservedEnv = { ...existingEnv }; + for (const key of resolved.clearEnvKeys) { + delete preservedEnv[key]; + } + + const mergedSettings: Record = { + ...existingSettings, + env: { + ...preservedEnv, + ...resolved.env, + }, + }; + + if (resolvedPermissionMode) { + const rawPermissions = existingSettings.permissions; + let existingPermissions: Record = {}; + if (rawPermissions !== undefined) { + if (rawPermissions === null) { + console.log( + warn('Existing permissions in settings.json is null - it will be replaced') + ); + } else if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) { + console.log( + warn('Existing permissions in settings.json is not an object - it will be replaced') + ); + } else { + existingPermissions = rawPermissions as Record; + } + } + mergedSettings.permissions = { + ...existingPermissions, + defaultMode: resolvedPermissionMode, + }; + } + + await writeClaudeSettings(mergedSettings); + const persistedSettings = await readClaudeSettings(); + const receipt = buildPersistReceipt( + existingEnv, + existingSettings, + persistedSettings, + resolved, + resolvedPermissionMode + ); + + console.log(''); + console.log( + ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`) + ); + console.log(''); + printPersistReceipt(receipt); + console.log(''); + }); + } catch (error) { + const message = (error as Error).message; + if (message.startsWith('Failed to create backup:')) { + console.log(fail(message)); + } else { + console.log(fail(`Failed to write settings: ${message}`)); + } + if (createdBackupPath) { + console.log(''); + console.log(info(`A backup was created before this error:`)); + console.log(` ${formatDisplayPath(createdBackupPath)}`); + console.log(dim(' To restore: ccs persist --restore')); + } + process.exit(1); + } + 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(''); +} diff --git a/src/commands/persist-command/help.ts b/src/commands/persist-command/help.ts new file mode 100644 index 00000000..bac5af49 --- /dev/null +++ b/src/commands/persist-command/help.ts @@ -0,0 +1,101 @@ +/** + * Persist Command - Help Text + * + * Owns the `ccs persist --help` output. Pure presentation module; no + * filesystem or profile-resolution side effects. + */ + +import { header, subheader, color, dim, initUI } from '../../utils/ui'; +import { getClaudeSettingsDisplayPath } from './secure-file'; + +/** Show help for persist command */ +export async function showHelp(): Promise { + await initUI(); + console.log(header('CCS Persist Command')); + console.log(''); + console.log(subheader('Usage')); + console.log(` ${color('ccs persist', 'command')} [options]`); + console.log(` ${color('ccs persist', 'command')} --list-backups`); + console.log(` ${color('ccs persist', 'command')} --restore [timestamp]`); + console.log(''); + console.log(subheader('Description')); + console.log(" Writes a profile's Claude setup directly to"); + console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`); + console.log(''); + console.log(' This is the preferred shared-settings path for Claude Code'); + console.log(' and the Claude IDE extension when you want one profile everywhere.'); + console.log(''); + console.log(subheader('Options')); + console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`); + console.log( + ` ${color('--permission-mode ', 'command')} Set default permission mode in settings.json` + ); + console.log( + ` ${color('--dangerously-skip-permissions', 'command')} Persist auto-approve (bypassPermissions)` + ); + console.log(` ${color('--auto-approve', 'command')} Alias for --dangerously-skip-permissions`); + console.log(` ${color('--help, -h', 'command')} Show this help message`); + console.log(''); + console.log(subheader('Backup Management')); + console.log(` ${color('--list-backups', 'command')} List available backup files`); + console.log(` ${color('--restore', 'command')} Restore from the most recent backup`); + console.log( + ` ${color('--restore ', 'command')} Restore from specific backup (e.g., 20260110_205324)` + ); + console.log(''); + console.log(subheader('Supported Profile Types')); + console.log(` ${color('API profiles', 'command')} glm, km, custom API profiles`); + console.log(` ${color('CLIProxy', 'command')} gemini, agy, qwen, kiro, ghcp`); + console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`); + console.log( + ` ${color('Account profiles', 'command')} work, personal, client (persists CLAUDE_CONFIG_DIR)` + ); + console.log( + ` ${color('default', 'command')} Clears CCS-managed overrides or inherits mapped continuity` + ); + console.log(''); + console.log(subheader('Examples')); + console.log(` ${dim('# Persist GLM profile')}`); + console.log(` ${color('ccs persist glm', 'command')}`); + console.log(''); + console.log(` ${dim('# Persist with auto-confirmation')}`); + console.log(` ${color('ccs persist gemini --yes', 'command')}`); + console.log(''); + console.log(` ${dim('# Persist with default permission mode')}`); + console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`); + console.log(''); + console.log(` ${dim('# Persist with auto-approve enabled')}`); + console.log(` ${color('ccs persist glm --dangerously-skip-permissions', 'command')}`); + console.log(''); + console.log(` ${dim('# Persist an account profile for IDE/native Claude use')}`); + console.log(` ${color('ccs persist work --yes', 'command')}`); + console.log(''); + console.log(` ${dim('# Reset to native Claude defaults (clear CCS-managed overrides)')}`); + console.log(` ${color('ccs persist default --yes', 'command')}`); + console.log(''); + console.log(` ${dim('# List all backups')}`); + console.log(` ${color('ccs persist --list-backups', 'command')}`); + console.log(''); + console.log(` ${dim('# Restore latest backup')}`); + console.log(` ${color('ccs persist --restore', 'command')}`); + console.log(''); + console.log(` ${dim('# Restore specific backup')}`); + console.log(` ${color('ccs persist --restore 20260110_205324', 'command')}`); + console.log(''); + console.log(subheader('Notes')); + console.log(' [i] CLIProxy profiles require the proxy to be running.'); + console.log( + ' [i] Codex CLIProxy profiles are native Codex-only: use ccsxp or ccs codex --target codex.' + ); + console.log(' [i] Copilot profiles require copilot-api daemon.'); + console.log( + ' [i] Account/default flows remove stale ANTHROPIC_* overrides before applying new setup.' + ); + console.log( + ' [i] For IDE-local settings.json snippets, use: ccs env --format claude-extension' + ); + console.log( + ` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS` + ); + console.log(''); +} diff --git a/src/commands/persist-command/receipt.ts b/src/commands/persist-command/receipt.ts new file mode 100644 index 00000000..ba3b3584 --- /dev/null +++ b/src/commands/persist-command/receipt.ts @@ -0,0 +1,132 @@ +/** + * Persist Command - Receipt Building & Profile Resolution + * + * Computes the post-write receipt (cleared/written/unchanged env keys and + * settings) and resolves a profile's extension env vars via the shared + * Claude extension setup resolver. + */ + +import { resolveClaudeExtensionSetup } from '../../shared/claude-extension-setup'; +import { + CODEX_TRANSLATOR_URL_MARKER, + findCodexTranslatorUrlPaths, + formatSettingsPathList, +} from '../../shared/stale-codex-translator-settings'; +import { subheader, ok, warn } from '../../utils/ui'; +import { getClaudeSettingsDisplayPath } from './secure-file'; +import { + NATIVE_CODEX_TARGETS, + type PersistReceipt, + type PermissionMode, + type ResolvedEnv, +} from './types'; + +export function buildPersistReceipt( + existingEnv: Record, + existingSettings: Record, + mergedSettings: Record, + resolved: ResolvedEnv, + resolvedPermissionMode?: PermissionMode +): PersistReceipt { + const mergedEnv = + typeof mergedSettings.env === 'object' && + mergedSettings.env !== null && + !Array.isArray(mergedSettings.env) + ? (mergedSettings.env as Record) + : {}; + + const clearedKeys = resolved.clearEnvKeys.filter( + (key) => Object.prototype.hasOwnProperty.call(existingEnv, key) && mergedEnv[key] === undefined + ); + const clearedCodexTranslatorUrlKeys = clearedKeys.filter( + (key) => findCodexTranslatorUrlPaths(existingEnv[key]).length > 0 + ); + const writtenKeys = Object.entries(resolved.env) + .filter(([key, value]) => existingEnv[key] !== value) + .map(([key]) => key) + .sort((left, right) => left.localeCompare(right)); + const unchangedWrittenKeys = Object.entries(resolved.env) + .filter(([key, value]) => existingEnv[key] === value) + .map(([key]) => key) + .sort((left, right) => left.localeCompare(right)); + const existingPermissions = + typeof existingSettings.permissions === 'object' && + existingSettings.permissions !== null && + !Array.isArray(existingSettings.permissions) + ? (existingSettings.permissions as Record) + : {}; + const writtenSettings = + resolvedPermissionMode && existingPermissions.defaultMode !== resolvedPermissionMode + ? ['permissions.defaultMode'] + : []; + const unchangedSettings = + resolvedPermissionMode && existingPermissions.defaultMode === resolvedPermissionMode + ? ['permissions.defaultMode'] + : []; + + return { + clearedKeys, + clearedCodexTranslatorUrlKeys, + writtenKeys, + unchangedWrittenKeys, + writtenSettings, + unchangedSettings, + codexTranslatorUrlPaths: findCodexTranslatorUrlPaths(mergedSettings), + }; +} + +function formatKeyList(keys: string[]): string { + return keys.length > 0 ? keys.join(', ') : 'none'; +} + +export function printPersistReceipt(receipt: PersistReceipt): void { + console.log(subheader('Config Receipt')); + console.log(` Settings: ${getClaudeSettingsDisplayPath()}`); + console.log(` Cleared managed keys: ${formatKeyList(receipt.clearedKeys)}`); + console.log(` Written/rewritten managed keys: ${formatKeyList(receipt.writtenKeys)}`); + if (receipt.unchangedWrittenKeys.length > 0) { + console.log(` Already current keys: ${formatKeyList(receipt.unchangedWrittenKeys)}`); + } + if (receipt.writtenSettings.length > 0 || receipt.unchangedSettings.length > 0) { + console.log(` Written/rewritten managed settings: ${formatKeyList(receipt.writtenSettings)}`); + if (receipt.unchangedSettings.length > 0) { + console.log(` Already current settings: ${formatKeyList(receipt.unchangedSettings)}`); + } + } + + const hadCodexTranslatorCleanup = receipt.clearedCodexTranslatorUrlKeys.length > 0; + if (receipt.codexTranslatorUrlPaths.length > 0) { + console.log( + warn( + ` Codex translator URL: still found at ${formatSettingsPathList( + receipt.codexTranslatorUrlPaths + )} (${CODEX_TRANSLATOR_URL_MARKER})` + ) + ); + } else { + console.log(ok(' Codex translator URL: not found')); + } + if (hadCodexTranslatorCleanup || receipt.codexTranslatorUrlPaths.length > 0) { + console.log(` Native Codex target: ${NATIVE_CODEX_TARGETS.join(' or ')}`); + } +} + +/** Resolve shared Claude settings payload for a profile */ +export async function resolveProfileEnvVars(profileName: string): Promise { + const setup = await resolveClaudeExtensionSetup(profileName); + const typeLabel: Record = { + settings: 'API', + cliproxy: 'CLIProxy', + copilot: 'Copilot', + account: 'Account', + default: 'Default', + }; + + return { + env: setup.extensionEnv, + clearEnvKeys: setup.removeEnvKeys, + profileType: typeLabel[setup.profileType] ?? setup.profileType, + warnings: setup.warnings, + notes: setup.notes, + }; +} diff --git a/src/commands/persist-command/secret-detection.ts b/src/commands/persist-command/secret-detection.ts new file mode 100644 index 00000000..a70ed2fb --- /dev/null +++ b/src/commands/persist-command/secret-detection.ts @@ -0,0 +1,54 @@ +/** + * Persist Command - Secret Detection & Masking + * + * Identifies sensitive env var names (TOKEN/KEY/SECRET/etc.) so the persist + * preview can mask their values before printing to the terminal. + */ + +/** Mask API key for display (show first 4 and last 4 chars) */ +export function maskApiKey(key: string): string { + if (key.length <= 12) { + return '****'; + } + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + +const SENSITIVE_ENV_PARTS = new Set([ + 'TOKEN', + 'KEY', + 'SECRET', + 'PASSWORD', + 'PASS', + 'AUTH', + 'CREDENTIAL', + 'PRIVATE', + 'ACCESS', + 'REFRESH', + 'APIKEY', +]); + +export function splitSensitiveKeyParts(key: string): string[] { + const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2'); + return withCamelCaseBoundaries + .toUpperCase() + .split(/[^A-Z0-9]+/) + .filter(Boolean); +} + +export function isSensitiveEnvKey(key: string): boolean { + const parts = splitSensitiveKeyParts(key); + if (parts.some((part) => SENSITIVE_ENV_PARTS.has(part))) { + return true; + } + + const compact = parts.join(''); + return ( + compact.includes('TOKEN') || + compact.includes('APIKEY') || + compact.includes('ACCESSKEY') || + compact.includes('AUTHKEY') || + compact.includes('SECRET') || + compact.includes('PASSWORD') || + compact.includes('CREDENTIAL') + ); +} diff --git a/src/commands/persist-command/secure-file.ts b/src/commands/persist-command/secure-file.ts new file mode 100644 index 00000000..cd4534be --- /dev/null +++ b/src/commands/persist-command/secure-file.ts @@ -0,0 +1,220 @@ +/** + * Persist Command - Secure File I/O & Locking + * + * Hardened filesystem helpers for reading/writing ~/.claude/settings.json. + * Refuses to follow symlinks (TOCTOU mitigations), uses O_NOFOLLOW where + * available, writes via atomic temp-file + rename, and serializes concurrent + * persist operations via a proper-lockfile on the settings directory. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as lockfile from 'proper-lockfile'; +import { getClaudeConfigDir, getClaudeSettingsPath } from '../../utils/claude-config-path'; +import { + PERSIST_LOCK_RETRIES, + PERSIST_LOCK_RETRY_MAX_MS, + PERSIST_LOCK_RETRY_MIN_MS, + PERSIST_LOCK_STALE_MS, +} from './types'; + +export function formatDisplayPath(filePath: string): string { + const defaultClaudeDir = path.join(os.homedir(), '.claude'); + const claudeDir = getClaudeConfigDir(); + + // Keep real path when user overrides Claude directory. + if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) { + return filePath; + } + + if (filePath === claudeDir) { + return '~/.claude'; + } + + const claudePrefix = `${claudeDir}${path.sep}`; + if (filePath.startsWith(claudePrefix)) { + return filePath.replace(claudePrefix, '~/.claude/'); + } + + return filePath; +} + +export function getClaudeSettingsDisplayPath(): string { + return formatDisplayPath(getClaudeSettingsPath()); +} + +export async function pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function isSymlinkAsync(filePath: string): Promise { + try { + const stats = await fs.promises.lstat(filePath); + return stats.isSymbolicLink(); + } catch { + return false; + } +} + +export function getNoFollowFlag(): number { + const candidate = (fs.constants as Record)['O_NOFOLLOW']; + if (process.platform !== 'win32' && typeof candidate === 'number') { + return candidate; + } + return 0; +} + +function createSymlinkReadError(filePath: string): NodeJS.ErrnoException { + const error = new Error( + `Refusing to read symlinked file for security: ${formatDisplayPath(filePath)}` + ) as NodeJS.ErrnoException; + error.code = 'ELOOP'; + return error; +} + +export async function readFileUtf8NoFollow(filePath: string): Promise { + if (await isSymlinkAsync(filePath)) { + throw createSymlinkReadError(filePath); + } + + const noFollowFlag = getNoFollowFlag(); + const flags = fs.constants.O_RDONLY | noFollowFlag; + const handle = await fs.promises.open(filePath, flags); + try { + // Best-effort fallback for platforms without O_NOFOLLOW (notably Windows). + // Re-check symlink status after open to reduce check-then-use windows. + if (noFollowFlag === 0 && (await isSymlinkAsync(filePath))) { + throw createSymlinkReadError(filePath); + } + + const stats = await handle.stat(); + if (!stats.isFile()) { + throw new Error('Path is not a regular file'); + } + + if (noFollowFlag === 0) { + const latestStats = await fs.promises.stat(filePath); + if (latestStats.dev !== stats.dev || latestStats.ino !== stats.ino) { + throw new Error('Path changed during secure read'); + } + } + + return await handle.readFile({ encoding: 'utf8' }); + } finally { + await handle.close(); + } +} + +export function parseSettingsObject(content: string, sourceLabel: string): Record { + if (!content.trim()) { + return {}; + } + const parsed: unknown = JSON.parse(content); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`${sourceLabel} must contain a JSON object, not an array or primitive`); + } + return parsed as Record; +} + +export async function withPersistSettingsLock(operation: () => Promise): Promise { + const settingsPath = getClaudeSettingsPath(); + const settingsDir = path.dirname(settingsPath); + await fs.promises.mkdir(settingsDir, { recursive: true }); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(settingsDir, { + stale: PERSIST_LOCK_STALE_MS, + retries: { + retries: PERSIST_LOCK_RETRIES, + minTimeout: PERSIST_LOCK_RETRY_MIN_MS, + maxTimeout: PERSIST_LOCK_RETRY_MAX_MS, + }, + realpath: false, + }); + } catch (error) { + throw new Error( + `Failed to lock Claude settings directory (${formatDisplayPath(settingsDir)}): ${(error as Error).message}` + ); + } + + try { + return await operation(); + } finally { + if (release) { + try { + await release(); + } catch { + // Best-effort release. + } + } + } +} + +/** Read existing Claude settings.json with validation */ +export async function readClaudeSettings(): Promise> { + const settingsPath = getClaudeSettingsPath(); + try { + const content = await readFileUtf8NoFollow(settingsPath); + return parseSettingsObject(content, 'settings.json'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return {}; + } + if (nodeError.code === 'ELOOP') { + throw new Error('settings.json is a symlink - refusing to read for security'); + } + throw new Error(`Failed to parse settings.json: ${(error as Error).message}`); + } +} + +/** Write settings back to settings.json with atomic replace semantics. */ +export async function writeClaudeSettings(settings: Record): Promise { + const settingsPath = getClaudeSettingsPath(); + if (await isSymlinkAsync(settingsPath)) { + throw new Error('settings.json is a symlink - refusing to write for security'); + } + + const settingsDir = path.dirname(settingsPath); + await fs.promises.mkdir(settingsDir, { recursive: true }); + + const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const tmpPath = path.join(settingsDir, `settings.json.tmp-${nonce}`); + const flags = + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag(); + + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fs.promises.open(tmpPath, flags, 0o600); + await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', { encoding: 'utf8' }); + await handle.sync(); + } finally { + if (handle) { + await handle.close(); + } + } + + try { + await fs.promises.rename(tmpPath, settingsPath); + } catch (error) { + try { + await fs.promises.unlink(tmpPath); + } catch { + // Best-effort cleanup. + } + throw error; + } + + try { + await fs.promises.chmod(settingsPath, 0o600); + } catch { + // Best-effort permission hardening. + } +} diff --git a/src/commands/persist-command/types.ts b/src/commands/persist-command/types.ts new file mode 100644 index 00000000..e128f5d5 --- /dev/null +++ b/src/commands/persist-command/types.ts @@ -0,0 +1,64 @@ +/** + * Persist Command - Shared Types & Constants + * + * Shared interfaces, type aliases, and module constants used across the + * persist-command submodules. Keeping these in one place avoids circular + * imports between arg-parsing, receipt, secure-file, and handler modules. + */ + +export interface PersistCommandArgs { + profile?: string; + yes?: boolean; + listBackups?: boolean; + restore?: string | boolean; + permissionMode?: PermissionMode; + dangerouslySkipPermissions?: boolean; + parseError?: string; +} + +export interface ResolvedEnv { + env: Record; + clearEnvKeys: string[]; + profileType: string; + warnings?: string[]; + notes?: string[]; +} + +export interface PersistReceipt { + clearedKeys: string[]; + clearedCodexTranslatorUrlKeys: string[]; + writtenKeys: string[]; + unchangedWrittenKeys: string[]; + writtenSettings: string[]; + unchangedSettings: string[]; + codexTranslatorUrlPaths: string[]; +} + +export const PERSIST_KNOWN_FLAGS = [ + '--yes', + '-y', + '--list-backups', + '--restore', + '--permission-mode', + '--dangerously-skip-permissions', + '--auto-approve', + '--help', + '-h', +] as const; + +export const VALID_PERMISSION_MODES = [ + 'default', + 'plan', + 'acceptEdits', + 'bypassPermissions', +] as const; + +export const PERSIST_LOCK_STALE_MS = 10000; +export const PERSIST_LOCK_RETRIES = 5; +export const PERSIST_LOCK_RETRY_MIN_MS = 100; +export const PERSIST_LOCK_RETRY_MAX_MS = 500; + +/** Native Codex target invocation hints surfaced in the persist receipt. */ +export const NATIVE_CODEX_TARGETS = ['ccsxp', 'ccs codex --target codex']; + +export type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 728fa0e9..c936102f 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -1,1631 +1,21 @@ /** - * SharedManager - Manages symlinked shared directories for CCS - * v3.2.0: Symlink-based architecture + * shared-manager.ts - public barrel for the SharedManager module. * - * Purpose: Eliminates duplication by symlinking: - * ~/.claude/ ← ~/.ccs/shared/ ← instance/ + * Originally a 1631-line god file; split into focused submodules under + * ./shared-manager/. This file re-exports the full original public surface + * so consumers importing from 'shared-manager' (and from the + * 'management/index' re-export) continue to resolve unchanged: + * + * - default export: SharedManager class (now a thin orchestrator) + * - named exports: normalizePluginMetadataContent, + * normalizePluginMetadataPathString + * + * No logic lives in this file. Add new behavior to the appropriate + * submodule under ./shared-manager/. */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import ProfileContextSyncLock from './profile-context-sync-lock'; -import { ok, info, warn } from '../utils/ui'; -import { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; -import type { AccountContextPolicy } from '../auth/account-context'; - -import { - normalizePluginMetadataContent, - normalizePluginMetadataValue, -} from './plugin-path-normalizer'; -import { getCcsDir } from '../config/config-loader-facade'; -import { listAccountInstanceNames, listAccountInstancePaths } from './instance-directory'; +export { default } from './shared-manager/orchestrator'; export { normalizePluginMetadataContent, normalizePluginMetadataPathString, } from './plugin-path-normalizer'; - -interface SharedItem { - name: string; - type: 'directory' | 'file'; -} - -const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( - { - version: 2, - plugins: {}, - }, - null, - 2 -); - -/** - * SharedManager Class - */ -class SharedManager { - private readonly homeDir: string; - private readonly sharedDir: string; - private readonly claudeDir: string; - private readonly instancesDir: string; - private readonly pluginLayoutLock: ProfileContextSyncLock; - private readonly sharedItems: SharedItem[]; - private readonly sharedPluginEntries: readonly SharedItem[] = [ - { name: 'cache', type: 'directory' }, - { name: 'marketplaces', type: 'directory' }, - { name: 'installed_plugins.json', type: 'file' }, - ]; - private readonly instanceLocalPluginMetadataFiles = new Set(['known_marketplaces.json']); - private readonly advancedContinuityItems: readonly string[] = [ - 'session-env', - 'file-history', - 'shell-snapshots', - 'todos', - ]; - - constructor() { - this.homeDir = os.homedir(); - const ccsDir = getCcsDir(); - this.sharedDir = path.join(ccsDir, 'shared'); - this.claudeDir = path.join(this.homeDir, '.claude'); - this.instancesDir = path.join(ccsDir, 'instances'); - this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); - this.sharedItems = [ - { name: 'commands', type: 'directory' }, - { name: 'skills', type: 'directory' }, - { name: 'agents', type: 'directory' }, - { name: 'plugins', type: 'directory' }, - { name: 'settings.json', type: 'file' }, - ]; - } - - /** - * Detect circular symlink before creation - */ - private detectCircularSymlink(target: string): boolean { - try { - const stats = fs.lstatSync(target); - if (!stats.isSymbolicLink()) { - return false; - } - - // Resolve target's link - const targetLink = fs.readlinkSync(target); - const resolvedTarget = path.resolve(path.dirname(target), targetLink); - const sharedDirPath = path.resolve(this.sharedDir); - - // A raw target path pointing back into ~/.ccs/shared is already unsafe. - // Re-pointing ~/.ccs/shared/* to ~/.claude/* would turn it into a real loop, - // even if the current ~/.ccs/shared entry ultimately resolves to an external path. - if (this.isPathWithinDirectory(resolvedTarget, sharedDirPath)) { - console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); - return true; - } - - // Only treat targets inside the managed shared root as circular. - // Existing shared symlinks may already resolve through ~/.claude/ to an - // external repo, which is a supported upgrade path rather than a loop. - const sharedDir = this.resolveCanonicalPath(sharedDirPath); - const canonicalResolvedTarget = this.resolveCanonicalPath(resolvedTarget); - - if (this.isPathWithinDirectory(canonicalResolvedTarget, sharedDir)) { - console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); - return true; - } - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return false; - } - throw err; - } - - return false; - } - - /** - * Ensure shared directories exist as symlinks to ~/.claude/ - * Creates ~/.claude/ structure if missing - */ - ensureSharedDirectories(): void { - // Create ~/.claude/ if missing - if (!this.getLstatSync(this.claudeDir)) { - console.log(info('Creating ~/.claude/ directory structure')); - fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 }); - } - - // Create shared directory - if (!this.getLstatSync(this.sharedDir)) { - fs.mkdirSync(this.sharedDir, { recursive: true, mode: 0o700 }); - } - - this.ensureSharedPluginLayoutDefaults(); - - // Create symlinks ~/.ccs/shared/* → ~/.claude/* - for (const item of this.sharedItems) { - const claudePath = path.join(this.claudeDir, item.name); - const sharedPath = path.join(this.sharedDir, item.name); - - // Create in ~/.claude/ if missing - if (!this.getLstatSync(claudePath)) { - if (item.type === 'directory') { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); - } else if (item.type === 'file') { - // Create empty settings.json if missing - fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8'); - } - } - - // Check for circular symlink - if (this.detectCircularSymlink(claudePath)) { - console.log(warn(`Skipping ${item.name}: circular symlink detected`)); - continue; - } - - // If already a symlink pointing to correct target, skip - if (this.getLstatSync(sharedPath)) { - try { - const stats = fs.lstatSync(sharedPath); - if (stats.isSymbolicLink()) { - const currentTarget = fs.readlinkSync(sharedPath); - const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); - if (resolvedTarget === claudePath) { - continue; // Already correct - } - } - } catch (_err) { - // Continue to recreate - } - - // Remove existing file/directory/link - if (item.type === 'directory') { - fs.rmSync(sharedPath, { recursive: true, force: true }); - } else { - fs.unlinkSync(sharedPath); - } - } - - // Create symlink - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(claudePath, sharedPath, symlinkType); - } catch (_err) { - // Windows fallback: copy - if (process.platform === 'win32') { - if (item.type === 'directory') { - this.copyDirectoryFallback(claudePath, sharedPath); - } else if (item.type === 'file') { - fs.copyFileSync(claudePath, sharedPath); - } - console.log( - warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; - } - } - } - } - - /** - * Link shared directories to instance - */ - linkSharedDirectories(instancePath: string): void { - this.ensureSharedDirectories(); - - for (const item of this.sharedItems) { - if (item.name === 'plugins') { - this.linkInstancePlugins(instancePath); - continue; - } - - const linkPath = path.join(instancePath, item.name); - const targetPath = path.join(this.sharedDir, item.name); - - this.removeExistingPath(linkPath, item.type); - - // Create symlink - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(targetPath, linkPath, symlinkType); - } catch (_err) { - // Windows fallback - if (process.platform === 'win32') { - if (item.type === 'directory') { - this.copyDirectoryFallback(targetPath, linkPath); - } else if (item.type === 'file') { - fs.copyFileSync(targetPath, linkPath); - } - console.log( - warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; - } - } - } - - this.normalizeSharedPluginMetadataPaths(instancePath); - } - - detachSharedDirectories(instancePath: string): void { - this.ensureSharedDirectories(); - - for (const item of this.sharedItems) { - const managedPath = path.join(instancePath, item.name); - if (!fs.existsSync(managedPath)) { - continue; - } - - if (item.name === 'plugins') { - this.detachManagedPluginLayout(instancePath); - continue; - } - - const stats = fs.lstatSync(managedPath); - if (!stats.isSymbolicLink()) { - continue; - } - - if (this.symlinkPointsTo(managedPath, path.join(this.sharedDir, item.name))) { - this.removeExistingPath(managedPath, item.type); - } - } - } - - private ensureSharedPluginLayoutDefaults(): void { - const pluginsDir = path.join(this.claudeDir, 'plugins'); - fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 }); - - for (const entry of this.sharedPluginEntries) { - const entryPath = path.join(pluginsDir, entry.name); - if (fs.existsSync(entryPath)) { - continue; - } - - if (entry.type === 'directory') { - fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 }); - continue; - } - - fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8'); - } - - const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json'); - if (!fs.existsSync(marketplaceRegistryPath)) { - fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8'); - } - } - - private linkInstancePlugins(instancePath: string): void { - const linkPath = path.join(instancePath, 'plugins'); - const targetPath = path.join(this.sharedDir, 'plugins'); - let linkStats: fs.Stats | null = null; - - try { - linkStats = fs.lstatSync(linkPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw err; - } - } - - if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { - this.removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); - } - - if (!linkStats || !linkStats.isDirectory()) { - fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); - } - - for (const item of this.getSharedPluginLinkItems()) { - const targetEntryPath = path.join(targetPath, item.name); - const linkEntryPath = path.join(linkPath, item.name); - - this.removeExistingPath(linkEntryPath, item.type); - - try { - const symlinkType = item.type === 'directory' ? 'dir' : 'file'; - fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); - } catch (_err) { - if (process.platform === 'win32') { - if (item.type === 'directory') { - this.copyDirectoryFallback(targetEntryPath, linkEntryPath); - } else { - fs.copyFileSync(targetEntryPath, linkEntryPath); - } - console.log( - warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) - ); - } else { - throw _err; - } - } - } - } - - private getSharedPluginLinkItems(): SharedItem[] { - const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); - const items = new Map( - this.sharedPluginEntries.map((entry) => [entry.name, { ...entry }]) - ); - - for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { - if (items.has(entry.name) || this.instanceLocalPluginMetadataFiles.has(entry.name)) { - continue; - } - - const entryPath = path.join(sharedPluginsPath, entry.name); - let stats: fs.Stats; - try { - stats = fs.statSync(entryPath); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - console.log( - warn( - `Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}` - ) - ); - continue; - } - - items.set(entry.name, { - name: entry.name, - type: stats.isDirectory() ? 'directory' : 'file', - }); - } - - return [...items.values()]; - } - - private removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void { - try { - const stats = fs.lstatSync(targetPath); - if (stats.isDirectory() && !stats.isSymbolicLink()) { - fs.rmSync(targetPath, { recursive: true, force: true }); - return; - } - - if (stats.isSymbolicLink() || typeHint === 'file') { - fs.unlinkSync(targetPath); - return; - } - - fs.rmSync(targetPath, { recursive: true, force: true }); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return; - } - - if (typeHint === 'directory') { - fs.rmSync(targetPath, { recursive: true, force: true }); - } else { - fs.rmSync(targetPath, { force: true }); - } - } - } - - /** - * Sync project workspace context based on account policy. - * - * - isolated (default): each profile keeps its own ./projects directory. - * - shared: profile ./projects becomes symlink to shared context group root. - */ - async syncProjectContext(instancePath: string, policy: AccountContextPolicy): Promise { - const projectsPath = path.join(instancePath, 'projects'); - const instanceName = path.basename(instancePath); - const mode = policy.mode === 'shared' ? 'shared' : 'isolated'; - - if (mode === 'shared') { - const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; - const sharedProjectsPath = path.join( - this.sharedDir, - 'context-groups', - contextGroup, - 'projects' - ); - - await this.ensureDirectory(sharedProjectsPath); - await this.ensureDirectory(path.dirname(projectsPath)); - - const currentStats = await this.getLstat(projectsPath); - if (!currentStats) { - await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); - return; - } - - if (currentStats.isSymbolicLink()) { - if (await this.isSymlinkTarget(projectsPath, sharedProjectsPath)) { - return; - } - - const currentTarget = await this.resolveSymlinkTargetPath(projectsPath); - if ( - currentTarget && - path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) && - this.isSafeProjectsMergeSource(currentTarget, instanceName) && - (await this.pathExists(currentTarget)) - ) { - await this.mergeDirectoryWithConflictCopies( - currentTarget, - sharedProjectsPath, - instanceName - ); - } else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) { - console.log( - warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) - ); - } - - await fs.promises.unlink(projectsPath); - await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); - return; - } - - if (currentStats.isDirectory()) { - await this.detachLegacySharedMemoryLinks(projectsPath, instanceName); - await this.mergeDirectoryWithConflictCopies(projectsPath, sharedProjectsPath, instanceName); - await fs.promises.rm(projectsPath, { recursive: true, force: true }); - await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); - return; - } - - await fs.promises.rm(projectsPath, { force: true }); - await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath); - return; - } - - const currentStats = await this.getLstat(projectsPath); - if (!currentStats) { - await this.ensureDirectory(projectsPath); - return; - } - - if (currentStats.isDirectory()) { - await this.detachLegacySharedMemoryLinks(projectsPath, instanceName); - return; - } - - if (currentStats.isSymbolicLink()) { - const currentTarget = await this.resolveSymlinkTargetPath(projectsPath); - await fs.promises.unlink(projectsPath); - await this.ensureDirectory(projectsPath); - - if ( - currentTarget && - path.resolve(currentTarget) !== path.resolve(projectsPath) && - this.isSafeProjectsMergeSource(currentTarget, instanceName) && - (await this.pathExists(currentTarget)) - ) { - await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName); - } else if (currentTarget && !this.isSafeProjectsMergeSource(currentTarget, instanceName)) { - console.log( - warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) - ); - } - - return; - } - - await fs.promises.rm(projectsPath, { force: true }); - await this.ensureDirectory(projectsPath); - } - - /** - * Sync advanced continuity artifacts for shared deeper mode. - * - * - shared + deeper: artifacts are linked per context group. - * - shared + standard / isolated: artifacts stay local to instance. - */ - async syncAdvancedContinuityArtifacts( - instancePath: string, - policy: AccountContextPolicy - ): Promise { - const instanceName = path.basename(instancePath); - const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper'; - const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; - - for (const artifactName of this.advancedContinuityItems) { - const instanceArtifactPath = path.join(instancePath, artifactName); - - if (useSharedContinuity) { - const sharedArtifactPath = path.join( - this.sharedDir, - 'context-groups', - contextGroup, - 'continuity', - artifactName - ); - - await this.ensureDirectory(sharedArtifactPath); - await this.ensureDirectory(path.dirname(instanceArtifactPath)); - - const currentStats = await this.getLstat(instanceArtifactPath); - if (!currentStats) { - await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); - continue; - } - - if (currentStats.isSymbolicLink()) { - if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) { - continue; - } - - const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath); - if ( - currentTarget && - path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) && - this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) && - (await this.pathExists(currentTarget)) - ) { - await this.mergeDirectoryWithConflictCopies( - currentTarget, - sharedArtifactPath, - instanceName - ); - } else if ( - currentTarget && - !this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) - ) { - console.log( - warn( - `Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}` - ) - ); - } - - await fs.promises.unlink(instanceArtifactPath); - await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); - continue; - } - - if (currentStats.isDirectory()) { - await this.mergeDirectoryWithConflictCopies( - instanceArtifactPath, - sharedArtifactPath, - instanceName - ); - await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true }); - await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); - continue; - } - - await fs.promises.rm(instanceArtifactPath, { force: true }); - await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath); - continue; - } - - const currentStats = await this.getLstat(instanceArtifactPath); - if (!currentStats) { - await this.ensureDirectory(instanceArtifactPath); - continue; - } - - if (currentStats.isDirectory()) { - continue; - } - - if (currentStats.isSymbolicLink()) { - const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath); - await fs.promises.unlink(instanceArtifactPath); - await this.ensureDirectory(instanceArtifactPath); - - if ( - currentTarget && - path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) && - this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) && - (await this.pathExists(currentTarget)) - ) { - await this.mergeDirectoryWithConflictCopies( - currentTarget, - instanceArtifactPath, - instanceName - ); - } else if ( - currentTarget && - !this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) - ) { - console.log( - warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`) - ); - } - - continue; - } - - await fs.promises.rm(instanceArtifactPath, { force: true }); - await this.ensureDirectory(instanceArtifactPath); - } - } - - /** - * Ensure all project memory directories for an instance are shared. - * - * Source layout (isolated): - * ~/.ccs/instances//projects//memory/ - * - * Shared layout (canonical): - * ~/.ccs/shared/memory// - */ - async syncProjectMemories(instancePath: string): Promise { - const projectsDir = path.join(instancePath, 'projects'); - if (!(await this.pathExists(projectsDir))) { - return; - } - - await this.ensureDirectory(this.sharedDir); - - const sharedMemoryRoot = path.join(this.sharedDir, 'memory'); - await this.ensureDirectory(sharedMemoryRoot); - - let projectEntries: fs.Dirent[] = []; - try { - projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true }); - } catch (_err) { - return; - } - - const projects = projectEntries.filter((entry) => entry.isDirectory()); - if (projects.length === 0) { - return; - } - - let migrated = 0; - let merged = 0; - let linked = 0; - const instanceName = path.basename(instancePath); - - for (const project of projects) { - const projectDir = path.join(projectsDir, project.name); - const projectMemoryPath = path.join(projectDir, 'memory'); - const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name); - - const projectMemoryStats = await this.getLstat(projectMemoryPath); - if (!projectMemoryStats) { - if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { - linked++; - } - continue; - } - - if (projectMemoryStats.isSymbolicLink()) { - if (await this.isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { - continue; - } - - await fs.promises.unlink(projectMemoryPath); - if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { - linked++; - } - continue; - } - - if (!projectMemoryStats.isDirectory()) { - continue; - } - - if (!(await this.pathExists(sharedProjectMemoryPath))) { - await this.moveDirectory(projectMemoryPath, sharedProjectMemoryPath); - migrated++; - } else { - merged += await this.mergeDirectoryWithConflictCopies( - projectMemoryPath, - sharedProjectMemoryPath, - instanceName - ); - await fs.promises.rm(projectMemoryPath, { recursive: true, force: true }); - } - - if (await this.ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath)) { - linked++; - } - } - - if (migrated > 0 || merged > 0 || linked > 0) { - console.log( - ok( - `Synced shared project memory: ${migrated} migrated, ${merged} merged conflict(s), ${linked} linked` - ) - ); - } - } - - /** - * Normalize plugin metadata and reconcile marketplace metadata for the active config dir. - */ - normalizeSharedPluginMetadataPaths(configDir?: string): void { - this.normalizePluginRegistryPaths(configDir); - this.normalizeMarketplaceRegistryPaths(configDir); - } - - normalizeSharedPluginMetadataPathsLocked(configDir?: string): void { - this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => { - this.normalizeSharedPluginMetadataPaths(configDir); - }); - } - - /** - * Normalize plugin registry paths to use canonical ~/.claude/ paths - * instead of instance-specific ~/.ccs/instances// paths. - * - * This ensures installed_plugins.json is consistent regardless of - * which CCS instance installed the plugin. - */ - normalizePluginRegistryPaths(configDir?: string): void { - this.normalizePluginMetadataFiles( - 'installed_plugins.json', - configDir, - 'Normalized plugin registry paths', - 'plugin registry' - ); - } - - /** - * Reconcile marketplace registry content into the active config dir while - * keeping the global ~/.claude copy up to date for non-instance flows. - */ - normalizeMarketplaceRegistryPaths(configDir?: string): void { - const successMessage = 'Synchronized marketplace registry paths'; - const warningLabel = 'marketplace registry'; - - try { - const sourcePaths = this.getMarketplaceRegistrySourcePaths(configDir); - this.writePluginMetadataFile( - path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), - this.buildMarketplaceRegistryContent(sourcePaths, this.claudeDir), - successMessage - ); - - if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { - this.writePluginMetadataFile( - path.join(configDir, 'plugins', 'known_marketplaces.json'), - this.buildMarketplaceRegistryContent(sourcePaths, configDir), - successMessage - ); - } - } catch (err) { - console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`)); - } - } - - private normalizePluginMetadataFiles( - fileName: string, - configDir: string | undefined, - successMessage: string, - warningLabel: string - ): void { - const seen = new Set(); - - for (const registryPath of this.getPluginMetadataFilePaths(fileName, configDir)) { - const dedupeKey = this.resolveCanonicalPath(registryPath); - if (seen.has(dedupeKey)) { - continue; - } - - seen.add(dedupeKey); - this.normalizePluginMetadataFile(registryPath, successMessage, warningLabel); - } - } - - private getPluginMetadataFilePaths(fileName: string, configDir?: string): string[] { - const pluginDirs = new Set([ - path.join(this.claudeDir, 'plugins'), - path.join(this.sharedDir, 'plugins'), - ]); - - if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { - pluginDirs.add(path.join(configDir, 'plugins')); - } - - return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName)); - } - - private normalizePluginMetadataFile( - registryPath: string, - successMessage: string, - warningLabel: string - ): void { - if (!fs.existsSync(registryPath)) { - return; - } - - try { - const original = fs.readFileSync(registryPath, 'utf8'); - const normalized = normalizePluginMetadataContent(original); - - if (normalized !== original) { - fs.writeFileSync(registryPath, normalized, 'utf8'); - console.log(ok(successMessage)); - } - } catch (err) { - console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`)); - } - } - - private getMarketplaceRegistrySourcePaths(configDir?: string): string[] { - const sourcePaths = new Set([ - path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), - ]); - - for (const instancePath of listAccountInstancePaths(this.instancesDir)) { - sourcePaths.add(path.join(instancePath, 'plugins', 'known_marketplaces.json')); - } - - if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { - sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json')); - } - - return [...sourcePaths]; - } - - private buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string { - const merged: Record = {}; - - for (const registryPath of sourcePaths) { - if (!fs.existsSync(registryPath)) { - continue; - } - - try { - const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - continue; - } - - for (const [name, value] of Object.entries(parsed as Record)) { - if (!this.isMarketplaceRegistryEntry(value)) { - continue; - } - - merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; - } - } catch (err) { - console.log( - warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`) - ); - } - } - - const discoveredEntries = this.discoverMarketplaceEntries(targetConfigDir); - - // Keep only registry entries that have a physical directory, and update their - // installLocation. Entries only on disk (no registry record) are excluded — - // they lack required schema fields that Claude Code enforces. - for (const name of Object.keys(merged)) { - const entry = merged[name]; - if (!(name in discoveredEntries)) { - delete merged[name]; - } else if (this.isMarketplaceRegistryEntry(entry)) { - merged[name] = { - ...entry, - installLocation: discoveredEntries[name].installLocation, - }; - } else { - delete merged[name]; - } - } - - return JSON.stringify(merged, null, 2); - } - - private discoverMarketplaceEntries( - targetConfigDir: string - ): Record { - const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces'); - if (!fs.existsSync(marketplacesDir)) { - return {}; - } - - const discovered: Record = {}; - - for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - - // Skip hidden dirs and Claude Code rename-dance leftovers (.staging/.bak). - if (this.isTransientMarketplaceDirectory(entry.name)) { - continue; - } - - discovered[entry.name] = { - installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), - }; - } - - return discovered; - } - - private isTransientMarketplaceDirectory(name: string): boolean { - return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak'); - } - - private isMarketplaceRegistryEntry(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); - } - - private writePluginMetadataFile( - registryPath: string, - content: string, - successMessage: string - ): void { - fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); - const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null; - - if (current === content) { - return; - } - - fs.writeFileSync(registryPath, content, 'utf8'); - console.log(ok(successMessage)); - } - - /** - * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks to ~/.claude/) - * Runs once on upgrade - */ - migrateFromV311(): void { - // Check if migration already done (shared dirs are symlinks) - const commandsPath = path.join(this.sharedDir, 'commands'); - if (fs.existsSync(commandsPath)) { - try { - if (fs.lstatSync(commandsPath).isSymbolicLink()) { - return; // Already migrated - } - } catch (_err) { - // Continue with migration - } - } - - console.log(info('Migrating from v3.1.1 to v3.2.0...')); - - // Ensure ~/.claude/ exists - if (!fs.existsSync(this.claudeDir)) { - fs.mkdirSync(this.claudeDir, { recursive: true, mode: 0o700 }); - } - - // Copy user modifications from ~/.ccs/shared/ to ~/.claude/ - for (const item of this.sharedItems) { - const sharedPath = path.join(this.sharedDir, item.name); - const claudePath = path.join(this.claudeDir, item.name); - - if (!fs.existsSync(sharedPath)) continue; - - try { - const stats = fs.lstatSync(sharedPath); - - // Handle directories - if (item.type === 'directory' && stats.isDirectory()) { - // Create claude dir if missing - if (!fs.existsSync(claudePath)) { - fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); - } - - // Copy files from shared to claude (preserve user modifications) - const entries = fs.readdirSync(sharedPath, { withFileTypes: true }); - let copied = 0; - - for (const entry of entries) { - const src = path.join(sharedPath, entry.name); - const dest = path.join(claudePath, entry.name); - - // Skip if already exists in claude - if (fs.existsSync(dest)) continue; - - if (entry.isDirectory()) { - fs.cpSync(src, dest, { recursive: true }); - } else { - fs.copyFileSync(src, dest); - } - copied++; - } - - if (copied > 0) { - console.log(ok(`Migrated ${copied} ${item.name} to ~/.claude/${item.name}`)); - } - } - - // Handle files (settings.json) - else if (item.type === 'file' && stats.isFile()) { - // Only copy if ~/.claude/ version doesn't exist - if (!fs.existsSync(claudePath)) { - fs.copyFileSync(sharedPath, claudePath); - console.log(ok(`Migrated ${item.name} to ~/.claude/${item.name}`)); - } - } - } catch (_err) { - console.log(warn(`Failed to migrate ${item.name}: ${(_err as Error).message}`)); - } - } - - // Now run ensureSharedDirectories to create symlinks - this.ensureSharedDirectories(); - - // Update all instances to use new symlinks - if (fs.existsSync(this.instancesDir)) { - try { - for (const instance of listAccountInstanceNames(this.instancesDir)) { - const instancePath = path.join(this.instancesDir, instance); - try { - this.linkSharedDirectories(instancePath); - } catch (_err) { - console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`)); - } - } - } catch (_err) { - // No instances to update - } - } - - console.log(ok('Migration to v3.2.0 complete')); - } - - /** - * Migrate existing instances from isolated to shared settings.json (v4.4+) - * Runs once on upgrade - */ - migrateToSharedSettings(): void { - console.log(info('Migrating instances to shared settings.json...')); - - // Ensure ~/.claude/settings.json exists (authoritative source) - const claudeSettings = path.join(this.claudeDir, 'settings.json'); - if (!fs.existsSync(claudeSettings)) { - // Create empty settings if missing - fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8'); - console.log(info('Created ~/.claude/settings.json')); - } - - // Ensure shared settings.json symlink exists - this.ensureSharedDirectories(); - - // Migrate each instance - if (!fs.existsSync(this.instancesDir)) { - console.log(info('No instances to migrate')); - return; - } - - const instances = listAccountInstanceNames(this.instancesDir); - - let migrated = 0; - let skipped = 0; - - for (const instance of instances) { - const instancePath = path.join(this.instancesDir, instance); - const instanceSettings = path.join(instancePath, 'settings.json'); - - try { - // Check if already symlink - if (fs.existsSync(instanceSettings)) { - const stats = fs.lstatSync(instanceSettings); - if (stats.isSymbolicLink()) { - skipped++; - continue; // Already migrated - } - - // Backup existing settings - const backup = instanceSettings + '.pre-shared-migration'; - if (!fs.existsSync(backup)) { - fs.copyFileSync(instanceSettings, backup); - console.log(info(`Backed up ${instance}/settings.json`)); - } - - // Remove old settings.json - fs.unlinkSync(instanceSettings); - } - - // Create symlink via SharedManager - const sharedSettings = path.join(this.sharedDir, 'settings.json'); - - try { - fs.symlinkSync(sharedSettings, instanceSettings, 'file'); - migrated++; - } catch (_err) { - // Windows fallback - if (process.platform === 'win32') { - fs.copyFileSync(sharedSettings, instanceSettings); - console.log(warn(`Symlink failed for ${instance}, copied instead`)); - migrated++; - } else { - throw _err; - } - } - } catch (_err) { - console.log(warn(`Failed to migrate ${instance}: ${(_err as Error).message}`)); - } - } - - console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`)); - } - - /** - * Ensure memory path is linked to shared memory root. - * Returns true when a link/copy was created or updated. - */ - private async ensureProjectMemoryLink(linkPath: string, targetPath: string): Promise { - await this.ensureDirectory(targetPath); - - const linkStats = await this.getLstat(linkPath); - if (linkStats) { - if (linkStats.isSymbolicLink() && (await this.isSymlinkTarget(linkPath, targetPath))) { - return false; - } - - if (linkStats.isDirectory()) { - await fs.promises.rm(linkPath, { recursive: true, force: true }); - } else { - await fs.promises.unlink(linkPath); - } - } - - const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; - const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; - - try { - await fs.promises.symlink(linkTarget, linkPath, symlinkType); - return true; - } catch (_err) { - if (process.platform === 'win32') { - this.copyDirectoryFallback(targetPath, linkPath); - console.log( - warn(`Symlink failed for project memory, copied instead (enable Developer Mode)`) - ); - return true; - } - throw _err; - } - } - - /** - * Check whether symlink points to expected target. - */ - private async isSymlinkTarget(linkPath: string, expectedTarget: string): Promise { - try { - const stats = await fs.promises.lstat(linkPath); - if (!stats.isSymbolicLink()) { - return false; - } - - const currentTarget = await fs.promises.readlink(linkPath); - const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); - const resolvedExpectedTarget = path.resolve(expectedTarget); - return resolvedCurrentTarget === resolvedExpectedTarget; - } catch (_err) { - return false; - } - } - - /** - * Resolve symlink target to absolute path. - */ - private async resolveSymlinkTargetPath(linkPath: string): Promise { - try { - const currentTarget = await fs.promises.readlink(linkPath); - return path.resolve(path.dirname(linkPath), currentTarget); - } catch (_err) { - return null; - } - } - - /** - * Guard project merge operations to known CCS-managed roots only. - */ - private isSafeProjectsMergeSource(sourcePath: string, instanceName: string): boolean { - const resolvedSource = this.resolveCanonicalPath(sourcePath); - const sharedContextRoot = this.resolveCanonicalPath( - path.join(this.sharedDir, 'context-groups') - ); - const instanceProjectsRoot = this.resolveCanonicalPath( - path.join(this.instancesDir, instanceName, 'projects') - ); - - return ( - this.isPathWithinDirectory(resolvedSource, sharedContextRoot) || - this.isPathWithinDirectory(resolvedSource, instanceProjectsRoot) - ); - } - - /** - * Guard advanced continuity merge operations to known CCS-managed roots only. - */ - private isSafeContinuityMergeSource( - sourcePath: string, - instanceName: string, - artifactName: string - ): boolean { - const resolvedSource = this.resolveCanonicalPath(sourcePath); - const sharedContextRoot = this.resolveCanonicalPath( - path.join(this.sharedDir, 'context-groups') - ); - const instanceArtifactRoot = this.resolveCanonicalPath( - path.join(this.instancesDir, instanceName, artifactName) - ); - - const normalizedSource = - process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource; - const continuitySegment = - process.platform === 'win32' - ? `${path.sep}continuity${path.sep}`.toLowerCase() - : `${path.sep}continuity${path.sep}`; - - const withinSharedContinuity = - this.isPathWithinDirectory(resolvedSource, sharedContextRoot) && - normalizedSource.includes(continuitySegment); - - return ( - withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot) - ); - } - - /** - * Link directory with Windows fallback to recursive copy. - */ - private async linkDirectoryWithFallback(targetPath: string, linkPath: string): Promise { - const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; - const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; - - try { - await fs.promises.symlink(linkTarget, linkPath, symlinkType); - } catch (_err) { - if (process.platform === 'win32') { - this.copyDirectoryFallback(targetPath, linkPath); - console.log( - warn(`Symlink failed for context projects, copied instead (enable Developer Mode)`) - ); - return; - } - - throw _err; - } - } - - /** - * Migrate legacy per-project memory symlinks that point to ~/.ccs/shared/memory. - * This preserves data while restoring true profile isolation. - */ - private async detachLegacySharedMemoryLinks( - projectsPath: string, - instanceName: string - ): Promise { - const sharedMemoryRoot = this.resolveCanonicalPath(path.join(this.sharedDir, 'memory')); - - let projectEntries: fs.Dirent[] = []; - try { - projectEntries = await fs.promises.readdir(projectsPath, { withFileTypes: true }); - } catch (_err) { - return; - } - - for (const entry of projectEntries) { - if (!entry.isDirectory()) { - continue; - } - - const projectPath = path.join(projectsPath, entry.name); - const memoryPath = path.join(projectPath, 'memory'); - const memoryStats = await this.getLstat(memoryPath); - - if (!memoryStats?.isSymbolicLink()) { - continue; - } - - const memoryTarget = await this.resolveSymlinkTargetPath(memoryPath); - if (!memoryTarget) { - continue; - } - - const canonicalMemoryTarget = this.resolveCanonicalPath(memoryTarget); - if (!this.isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) { - continue; - } - - await fs.promises.unlink(memoryPath); - await this.ensureDirectory(memoryPath); - - if (await this.pathExists(canonicalMemoryTarget)) { - await this.mergeDirectoryWithConflictCopies( - canonicalMemoryTarget, - memoryPath, - instanceName - ); - } - } - } - - /** - * Move directory, with cross-device fallback. - */ - private async moveDirectory(src: string, dest: string): Promise { - try { - await fs.promises.rename(src, dest); - } catch (err) { - const error = err as NodeJS.ErrnoException; - if (error.code !== 'EXDEV') { - throw err; - } - - await fs.promises.cp(src, dest, { recursive: true }); - await fs.promises.rm(src, { recursive: true, force: true }); - } - } - - /** - * Merge source into target. On file conflicts, keep target and copy source - * as ".migrated-from-[-N]" to avoid data loss. - */ - private async mergeDirectoryWithConflictCopies( - sourceDir: string, - targetDir: string, - instanceName: string - ): Promise { - await this.ensureDirectory(targetDir); - - let conflicts = 0; - const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); - - for (const entry of entries) { - const sourcePath = path.join(sourceDir, entry.name); - const targetPath = path.join(targetDir, entry.name); - - if (entry.isDirectory()) { - conflicts += await this.mergeDirectoryWithConflictCopies( - sourcePath, - targetPath, - instanceName - ); - continue; - } - - if (entry.isFile()) { - if (!(await this.pathExists(targetPath))) { - await fs.promises.copyFile(sourcePath, targetPath); - continue; - } - - if (await this.fileContentsEqual(sourcePath, targetPath)) { - continue; - } - - const conflictPath = await this.getConflictCopyPath(targetPath, instanceName); - await fs.promises.copyFile(sourcePath, conflictPath); - conflicts++; - } - } - - return conflicts; - } - - /** - * Compare two files byte-for-byte. - */ - private async fileContentsEqual(fileA: string, fileB: string): Promise { - try { - const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]); - if (statA.size !== statB.size) { - return false; - } - - const [contentA, contentB] = await Promise.all([ - fs.promises.readFile(fileA), - fs.promises.readFile(fileB), - ]); - return contentA.equals(contentB); - } catch (_err) { - return false; - } - } - - /** - * Build a non-destructive conflict copy path. - */ - private async getConflictCopyPath( - existingTargetPath: string, - instanceName: string - ): Promise { - const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); - const baseSuffix = `.migrated-from-${safeInstanceName}`; - - let candidate = `${existingTargetPath}${baseSuffix}`; - let sequence = 1; - while (await this.pathExists(candidate)) { - candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; - sequence++; - } - - return candidate; - } - - private symlinkPointsTo(linkPath: string, expectedTarget: string): boolean { - try { - const currentTarget = fs.readlinkSync(linkPath); - const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); - return ( - this.resolveCanonicalPath(resolvedCurrentTarget) === - this.resolveCanonicalPath(expectedTarget) - ); - } catch { - return false; - } - } - - private detachManagedPluginLayout(instancePath: string): void { - const pluginsPath = path.join(instancePath, 'plugins'); - if (!fs.existsSync(pluginsPath)) { - return; - } - - const stats = fs.lstatSync(pluginsPath); - const sharedPluginsPath = path.join(this.sharedDir, 'plugins'); - - if (stats.isSymbolicLink()) { - if (this.symlinkPointsTo(pluginsPath, sharedPluginsPath)) { - this.removeExistingPath(pluginsPath, 'directory'); - } - return; - } - - if (!stats.isDirectory()) { - return; - } - - let removedManagedEntries = false; - - for (const item of this.getSharedPluginLinkItems()) { - const pluginEntryPath = path.join(pluginsPath, item.name); - if (!fs.existsSync(pluginEntryPath)) { - continue; - } - - const entryStats = fs.lstatSync(pluginEntryPath); - if (!entryStats.isSymbolicLink()) { - continue; - } - - if (this.symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) { - this.removeExistingPath(pluginEntryPath, item.type); - removedManagedEntries = true; - } - } - - if (!removedManagedEntries) { - return; - } - - this.reconcileLocalMarketplaceRegistry(instancePath); - - if (fs.readdirSync(pluginsPath).length === 0) { - fs.rmSync(pluginsPath, { recursive: true, force: true }); - } - } - - private reconcileLocalMarketplaceRegistry(configDir: string): void { - const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json'); - if (!fs.existsSync(registryPath)) { - return; - } - - const discoveredEntries = this.discoverMarketplaceEntries(configDir); - if (Object.keys(discoveredEntries).length === 0) { - this.removeExistingPath(registryPath, 'file'); - return; - } - - let parsed: Record = {}; - try { - const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; - if (raw && typeof raw === 'object' && !Array.isArray(raw)) { - parsed = raw as Record; - } - } catch { - parsed = {}; - } - - const reconciled = Object.fromEntries( - Object.entries(discoveredEntries).map(([name, value]) => { - const existing = parsed[name]; - if (existing && typeof existing === 'object' && !Array.isArray(existing)) { - return [ - name, - { - ...(normalizePluginMetadataValue(existing, configDir).normalized as Record< - string, - unknown - >), - installLocation: value.installLocation, - }, - ]; - } - - return [name, value]; - }) - ); - - this.writePluginMetadataFile( - registryPath, - JSON.stringify(reconciled, null, 2), - 'Synchronized marketplace registry paths' - ); - } - - private resolveCanonicalPath(targetPath: string): string { - try { - return fs.realpathSync.native(targetPath); - } catch { - return path.resolve(targetPath); - } - } - - private isPathWithinDirectory(candidatePath: string, rootPath: string): boolean { - const normalizeForCompare = (inputPath: string): string => { - const resolved = path.resolve(inputPath); - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; - }; - - const normalizedCandidate = normalizeForCompare(candidatePath); - const normalizedRoot = normalizeForCompare(rootPath); - const relative = path.relative(normalizedRoot, normalizedCandidate); - - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); - } - - private async pathExists(targetPath: string): Promise { - try { - await fs.promises.access(targetPath); - return true; - } catch (_err) { - return false; - } - } - - private async ensureDirectory(targetPath: string): Promise { - await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 }); - } - - private async getLstat(targetPath: string): Promise { - try { - return await fs.promises.lstat(targetPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - throw err; - } - } - - private getLstatSync(targetPath: string): fs.Stats | null { - try { - return fs.lstatSync(targetPath); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - throw err; - } - } - - /** - * Copy directory as fallback (Windows without Developer Mode) - */ - private copyDirectoryFallback(src: string, dest: string): void { - if (!fs.existsSync(src)) { - fs.mkdirSync(src, { recursive: true, mode: 0o700 }); - return; - } - - if (!fs.existsSync(dest)) { - fs.mkdirSync(dest, { recursive: true, mode: 0o700 }); - } - - const entries = fs.readdirSync(src, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(src, entry.name); - const destPath = path.join(dest, entry.name); - - if (entry.isDirectory()) { - this.copyDirectoryFallback(srcPath, destPath); - } else { - fs.copyFileSync(srcPath, destPath); - } - } - } -} - -export default SharedManager; diff --git a/src/management/shared-manager/fs-helpers.ts b/src/management/shared-manager/fs-helpers.ts new file mode 100644 index 00000000..ab8d82bb --- /dev/null +++ b/src/management/shared-manager/fs-helpers.ts @@ -0,0 +1,222 @@ +/** + * SharedManager - low-level filesystem helpers. + * + * Extracted from the original monolithic shared-manager.ts. All functions + * here are pure with respect to SharedManager instance state: they take + * explicit paths and return values, never reading from `this`. + * + * Keeping these isolated lets the orchestrator class focus on coordination + * and makes each helper independently testable. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { SharedItem } from './types'; + +/** + * Return canonical realpath for a path. Falls back to the lexical resolve + * when realpath fails (e.g. path does not exist). + */ +export function resolveCanonicalPath(targetPath: string): string { + try { + return fs.realpathSync.native(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +/** + * Case-insensitive on Windows, case-sensitive elsewhere. Returns true when + * candidatePath === rootPath or candidatePath is a descendant of rootPath. + */ +export function isPathWithinDirectory(candidatePath: string, rootPath: string): boolean { + const normalizeForCompare = (inputPath: string): string => { + const resolved = path.resolve(inputPath); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + + const normalizedCandidate = normalizeForCompare(candidatePath); + const normalizedRoot = normalizeForCompare(rootPath); + const relative = path.relative(normalizedRoot, normalizedCandidate); + + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +/** + * Resolve a symlink to its absolute target and compare against an expected + * target. Synchronous variant used during detach/remove flows. + */ +export function symlinkPointsTo(linkPath: string, expectedTarget: string): boolean { + try { + const currentTarget = fs.readlinkSync(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + return resolveCanonicalPath(resolvedCurrentTarget) === resolveCanonicalPath(expectedTarget); + } catch { + return false; + } +} + +/** + * Remove an existing path, using the type hint to disambiguate between + * a file and a directory when lstat fails to give a clear signal. + */ +export function removeExistingPath(targetPath: string, typeHint: SharedItem['type']): void { + try { + const stats = fs.lstatSync(targetPath); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + fs.rmSync(targetPath, { recursive: true, force: true }); + return; + } + + if (stats.isSymbolicLink() || typeHint === 'file') { + fs.unlinkSync(targetPath); + return; + } + + fs.rmSync(targetPath, { recursive: true, force: true }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + + if (typeHint === 'directory') { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.rmSync(targetPath, { force: true }); + } + } +} + +/** + * Recursively copy a directory tree as a fallback when symlinks are not + * available (notably Windows without Developer Mode). Creates the source + * directory as an empty dir if it does not exist. + */ +export function copyDirectoryFallback(src: string, dest: string): void { + if (!fs.existsSync(src)) { + fs.mkdirSync(src, { recursive: true, mode: 0o700 }); + return; + } + + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true, mode: 0o700 }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + if (entry.isDirectory()) { + copyDirectoryFallback(srcPath, destPath); + } else { + fs.copyFileSync(srcPath, destPath); + } + } +} + +/** + * Move a directory, falling back to recursive copy+remove across devices. + */ +export async function moveDirectory(src: string, dest: string): Promise { + try { + await fs.promises.rename(src, dest); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EXDEV') { + throw err; + } + + await fs.promises.cp(src, dest, { recursive: true }); + await fs.promises.rm(src, { recursive: true, force: true }); + } +} + +/** + * Promise-based access() existence check. + */ +export async function pathExists(targetPath: string): Promise { + try { + await fs.promises.access(targetPath); + return true; + } catch (_err) { + return false; + } +} + +/** + * Ensure a directory exists with restrictive permissions. + */ +export async function ensureDirectory(targetPath: string): Promise { + await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o700 }); +} + +/** + * Promise-based lstat, returning null for ENOENT. + */ +export async function getLstat(targetPath: string): Promise { + try { + return await fs.promises.lstat(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } +} + +/** + * Sync lstat, returning null for ENOENT. + */ +export function getLstatSync(targetPath: string): fs.Stats | null { + try { + return fs.lstatSync(targetPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw err; + } +} + +/** + * Compare two files byte-for-byte. Returns false on any IO error. + */ +export async function fileContentsEqual(fileA: string, fileB: string): Promise { + try { + const [statA, statB] = await Promise.all([fs.promises.stat(fileA), fs.promises.stat(fileB)]); + if (statA.size !== statB.size) { + return false; + } + + const [contentA, contentB] = await Promise.all([ + fs.promises.readFile(fileA), + fs.promises.readFile(fileB), + ]); + return contentA.equals(contentB); + } catch (_err) { + return false; + } +} + +/** + * Build a non-destructive conflict copy path of the form + * ".migrated-from-[-N]". + */ +export async function getConflictCopyPath( + existingTargetPath: string, + instanceName: string +): Promise { + const safeInstanceName = instanceName.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase(); + const baseSuffix = `.migrated-from-${safeInstanceName}`; + + let candidate = `${existingTargetPath}${baseSuffix}`; + let sequence = 1; + while (await pathExists(candidate)) { + candidate = `${existingTargetPath}${baseSuffix}-${sequence}`; + sequence++; + } + + return candidate; +} diff --git a/src/management/shared-manager/migrations.ts b/src/management/shared-manager/migrations.ts new file mode 100644 index 00000000..82d9d059 --- /dev/null +++ b/src/management/shared-manager/migrations.ts @@ -0,0 +1,183 @@ +/** + * SharedManager - one-shot upgrade migrations. + * + * Extracted from the original monolithic shared-manager.ts. These run once + * on upgrade to reconcile older on-disk layouts into the current symlink + * based architecture. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { info, ok, warn } from '../../utils/ui'; +import { listAccountInstanceNames } from '../instance-directory'; +import { + ensureSharedDirectories, + linkSharedDirectories, + type LinkerRoots, +} from './shared-dir-linker'; +import { SHARED_ITEMS } from './types'; + +/** + * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks + * to ~/.claude/). Runs once on upgrade; exits early when the shared + * commands directory is already a symlink. + */ +export function migrateFromV311(roots: LinkerRoots): void { + const sharedDir = roots.sharedDir; + const claudeDir = roots.claudeDir; + + const commandsPath = path.join(sharedDir, 'commands'); + if (fs.existsSync(commandsPath)) { + try { + if (fs.lstatSync(commandsPath).isSymbolicLink()) { + return; + } + } catch (_err) { + // Continue with migration + } + } + + console.log(info('Migrating from v3.1.1 to v3.2.0...')); + + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + for (const item of SHARED_ITEMS) { + const sharedPath = path.join(sharedDir, item.name); + const claudePath = path.join(claudeDir, item.name); + + if (!fs.existsSync(sharedPath)) continue; + + try { + const stats = fs.lstatSync(sharedPath); + + if (item.type === 'directory' && stats.isDirectory()) { + if (!fs.existsSync(claudePath)) { + fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); + } + + const entries = fs.readdirSync(sharedPath, { withFileTypes: true }); + let copied = 0; + + for (const entry of entries) { + const src = path.join(sharedPath, entry.name); + const dest = path.join(claudePath, entry.name); + + if (fs.existsSync(dest)) continue; + + if (entry.isDirectory()) { + fs.cpSync(src, dest, { recursive: true }); + } else { + fs.copyFileSync(src, dest); + } + copied++; + } + + if (copied > 0) { + console.log(ok(`Migrated ${copied} ${item.name} to ~/.claude/${item.name}`)); + } + } else if (item.type === 'file' && stats.isFile()) { + if (!fs.existsSync(claudePath)) { + fs.copyFileSync(sharedPath, claudePath); + console.log(ok(`Migrated ${item.name} to ~/.claude/${item.name}`)); + } + } + } catch (_err) { + console.log(warn(`Failed to migrate ${item.name}: ${(_err as Error).message}`)); + } + } + + ensureSharedDirectories(roots); + + if (fs.existsSync(roots.instancesDir)) { + try { + for (const instance of listAccountInstanceNames(roots.instancesDir)) { + const instancePath = path.join(roots.instancesDir, instance); + try { + linkSharedDirectories(roots, instancePath); + } catch (_err) { + console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`)); + } + } + } catch (_err) { + // No instances to update + } + } + + console.log(ok('Migration to v3.2.0 complete')); +} + +/** + * Migrate existing instances from isolated to shared settings.json (v4.4+). + * Backs up each instance's pre-existing settings.json before replacing it + * with a symlink to the shared settings.json. + */ +export function migrateToSharedSettings(roots: LinkerRoots): void { + const claudeDir = roots.claudeDir; + const sharedDir = roots.sharedDir; + const instancesDir = roots.instancesDir; + + console.log(info('Migrating instances to shared settings.json...')); + + const claudeSettings = path.join(claudeDir, 'settings.json'); + if (!fs.existsSync(claudeSettings)) { + fs.writeFileSync(claudeSettings, JSON.stringify({}, null, 2), 'utf8'); + console.log(info('Created ~/.claude/settings.json')); + } + + ensureSharedDirectories(roots); + + if (!fs.existsSync(instancesDir)) { + console.log(info('No instances to migrate')); + return; + } + + const instances = listAccountInstanceNames(instancesDir); + + let migrated = 0; + let skipped = 0; + + for (const instance of instances) { + const instancePath = path.join(instancesDir, instance); + const instanceSettings = path.join(instancePath, 'settings.json'); + + try { + if (fs.existsSync(instanceSettings)) { + const stats = fs.lstatSync(instanceSettings); + if (stats.isSymbolicLink()) { + skipped++; + continue; + } + + const backup = instanceSettings + '.pre-shared-migration'; + if (!fs.existsSync(backup)) { + fs.copyFileSync(instanceSettings, backup); + console.log(info(`Backed up ${instance}/settings.json`)); + } + + fs.unlinkSync(instanceSettings); + } + + const sharedSettings = path.join(sharedDir, 'settings.json'); + + try { + fs.symlinkSync(sharedSettings, instanceSettings, 'file'); + migrated++; + } catch (_err) { + if (process.platform === 'win32') { + fs.copyFileSync(sharedSettings, instanceSettings); + console.log(warn(`Symlink failed for ${instance}, copied instead`)); + migrated++; + } else { + throw _err; + } + } + } catch (_err) { + console.log(warn(`Failed to migrate ${instance}: ${(_err as Error).message}`)); + } + } + + console.log(ok(`Migrated ${migrated} instance(s), skipped ${skipped}`)); +} diff --git a/src/management/shared-manager/orchestrator.ts b/src/management/shared-manager/orchestrator.ts new file mode 100644 index 00000000..a9fd45ff --- /dev/null +++ b/src/management/shared-manager/orchestrator.ts @@ -0,0 +1,175 @@ +/** + * SharedManager orchestrator. + * + * Thin coordinator over the focused submodules under shared-manager/. The + * class is responsible ONLY for: + * 1. Resolving filesystem roots (homeDir, ccsDir, sharedDir, claudeDir, + * instancesDir) and the plugin-layout lock. + * 2. Forwarding every public method to the appropriate extracted helper, + * passing those roots explicitly. + * + * All behavior, signatures, and structured logging live in the submodules + * and are preserved exactly. This file deliberately contains no logic of + * its own. + */ + +import * as os from 'os'; +import * as path from 'path'; + +import { warn } from '../../utils/ui'; +import type { AccountContextPolicy } from '../../auth/account-context'; +import { getCcsDir } from '../../config/config-loader-facade'; +import ProfileContextSyncLock from '../profile-context-sync-lock'; + +import { + normalizeMarketplaceRegistryPaths, + normalizePluginRegistryPaths, + type PluginMetadataRoots, +} from './plugin-metadata-normalizer'; +import { + ensureSharedDirectories, + detachSharedDirectories, + linkSharedDirectories, + type LinkerRoots, +} from './shared-dir-linker'; +import { + syncAdvancedContinuityArtifacts, + syncProjectContext, + type ContextSyncRoots, +} from './project-context-sync'; +import { syncProjectMemories } from './project-memory-sync'; +import { migrateFromV311, migrateToSharedSettings } from './migrations'; +import type { SymlinkHelperDeps } from './symlink-helpers'; + +/** + * SharedManager Class + * + * Manages symlinked shared directories for CCS. See submodule docs for the + * detailed behavior of each operation. + */ +class SharedManager { + private readonly homeDir: string; + private readonly sharedDir: string; + private readonly claudeDir: string; + private readonly instancesDir: string; + private readonly pluginLayoutLock: ProfileContextSyncLock; + + constructor() { + this.homeDir = os.homedir(); + const ccsDir = getCcsDir(); + this.sharedDir = path.join(ccsDir, 'shared'); + this.claudeDir = path.join(this.homeDir, '.claude'); + this.instancesDir = path.join(ccsDir, 'instances'); + this.pluginLayoutLock = new ProfileContextSyncLock(this.instancesDir); + } + + private get roots(): LinkerRoots & PluginMetadataRoots & ContextSyncRoots { + return { + claudeDir: this.claudeDir, + sharedDir: this.sharedDir, + instancesDir: this.instancesDir, + }; + } + + private get symlinkDeps(): SymlinkHelperDeps { + return { warn }; + } + + /** + * Ensure shared directories exist as symlinks to ~/.claude/. Creates + * ~/.claude/ structure if missing. + */ + ensureSharedDirectories(): void { + ensureSharedDirectories(this.roots); + } + + /** + * Link shared directories into an instance. + */ + linkSharedDirectories(instancePath: string): void { + linkSharedDirectories(this.roots, instancePath); + } + + /** + * Detach shared-directory symlinks from an instance. + */ + detachSharedDirectories(instancePath: string): void { + detachSharedDirectories(this.roots, instancePath); + } + + /** + * Sync project workspace context based on account policy. + */ + async syncProjectContext(instancePath: string, policy: AccountContextPolicy): Promise { + await syncProjectContext(this.roots, instancePath, policy, this.symlinkDeps); + } + + /** + * Sync advanced continuity artifacts for shared deeper mode. + */ + async syncAdvancedContinuityArtifacts( + instancePath: string, + policy: AccountContextPolicy + ): Promise { + await syncAdvancedContinuityArtifacts(this.roots, instancePath, policy, this.symlinkDeps); + } + + /** + * Ensure all project memory directories for an instance are shared. + */ + async syncProjectMemories(instancePath: string): Promise { + await syncProjectMemories(this.roots, instancePath, this.symlinkDeps); + } + + /** + * Normalize plugin metadata and reconcile marketplace metadata for the + * active config dir. + */ + normalizeSharedPluginMetadataPaths(configDir?: string): void { + this.normalizePluginRegistryPaths(configDir); + this.normalizeMarketplaceRegistryPaths(configDir); + } + + /** + * Same as normalizeSharedPluginMetadataPaths but guarded by the + * plugin-layout named lock so concurrent instances cannot interleave. + */ + normalizeSharedPluginMetadataPathsLocked(configDir?: string): void { + this.pluginLayoutLock.withNamedLockSync('__plugin-layout__', () => { + this.normalizeSharedPluginMetadataPaths(configDir); + }); + } + + /** + * Normalize installed_plugins.json paths to canonical ~/.claude/ paths. + */ + normalizePluginRegistryPaths(configDir?: string): void { + normalizePluginRegistryPaths(this.roots, configDir); + } + + /** + * Reconcile marketplace registry content into the active config dir and + * keep the global ~/.claude copy up to date. + */ + normalizeMarketplaceRegistryPaths(configDir?: string): void { + normalizeMarketplaceRegistryPaths(this.roots, configDir); + } + + /** + * Migrate from v3.1.1 (copied data in ~/.ccs/shared/) to v3.2.0 (symlinks + * to ~/.claude/). Runs once on upgrade. + */ + migrateFromV311(): void { + migrateFromV311(this.roots); + } + + /** + * Migrate existing instances from isolated to shared settings.json + * (v4.4+). Runs once on upgrade. + */ + migrateToSharedSettings(): void { + migrateToSharedSettings(this.roots); + } +} + +export default SharedManager; diff --git a/src/management/shared-manager/plugin-layout-internals.ts b/src/management/shared-manager/plugin-layout-internals.ts new file mode 100644 index 00000000..5e4566d8 --- /dev/null +++ b/src/management/shared-manager/plugin-layout-internals.ts @@ -0,0 +1,205 @@ +/** + * SharedManager - plugin layout linking internals. + * + * Extracted from shared-dir-linker.ts to keep each file focused and under + * the 400 LOC target. Owns the four plugin-layout primitives: + * + * - ensureSharedPluginLayoutDefaults: provision ~/.claude/plugins defaults + * - linkInstancePlugins: link shared plugin entries into an instance + * - getSharedPluginLinkItems: enumerate plugin entries to link + * - detachManagedPluginLayout: reverse link + reconcile local registry + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { warn } from '../../utils/ui'; +import { copyDirectoryFallback, removeExistingPath, symlinkPointsTo } from './fs-helpers'; +import type { PluginMetadataRoots } from './plugin-metadata-normalizer'; +import { reconcileLocalMarketplaceRegistry } from './plugin-metadata-normalizer'; +import { + DEFAULT_INSTALLED_PLUGIN_REGISTRY, + INSTANCE_LOCAL_PLUGIN_METADATA_FILES, + SHARED_PLUGIN_ENTRIES, +} from './types'; +import type { SharedItem } from './types'; + +/** + * Roots for the plugin-layout internals. Same shape as PluginMetadataRoots. + */ +export type PluginLayoutRoots = PluginMetadataRoots; + +/** + * Ensure the plugin layout default directories (cache, marketplaces) and + * registry files (installed_plugins.json, known_marketplaces.json) exist + * under ~/.claude/plugins. + */ +export function ensureSharedPluginLayoutDefaults(claudeDir: string): void { + const pluginsDir = path.join(claudeDir, 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true, mode: 0o700 }); + + for (const entry of SHARED_PLUGIN_ENTRIES) { + const entryPath = path.join(pluginsDir, entry.name); + if (fs.existsSync(entryPath)) { + continue; + } + + if (entry.type === 'directory') { + fs.mkdirSync(entryPath, { recursive: true, mode: 0o700 }); + continue; + } + + fs.writeFileSync(entryPath, DEFAULT_INSTALLED_PLUGIN_REGISTRY, 'utf8'); + } + + const marketplaceRegistryPath = path.join(pluginsDir, 'known_marketplaces.json'); + if (!fs.existsSync(marketplaceRegistryPath)) { + fs.writeFileSync(marketplaceRegistryPath, JSON.stringify({}, null, 2), 'utf8'); + } +} + +/** + * Link shared plugins directory entries into an instance, creating the + * instance plugins directory first if needed. + */ +export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: string): void { + const linkPath = path.join(instancePath, 'plugins'); + const targetPath = path.join(roots.sharedDir, 'plugins'); + let linkStats: fs.Stats | null = null; + + try { + linkStats = fs.lstatSync(linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) { + removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file'); + } + + if (!linkStats || !linkStats.isDirectory()) { + fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 }); + } + + for (const item of getSharedPluginLinkItems(roots.sharedDir)) { + const targetEntryPath = path.join(targetPath, item.name); + const linkEntryPath = path.join(linkPath, item.name); + + removeExistingPath(linkEntryPath, item.type); + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(targetEntryPath, linkEntryPath); + } else { + fs.copyFileSync(targetEntryPath, linkEntryPath); + } + console.log( + warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + } +} + +/** + * Build the list of plugin entries to link from the shared plugins dir. + * Always includes the default SHARED_PLUGIN_ENTRIES; adds any additional + * entries physically present on disk, skipping instance-local metadata. + */ +export function getSharedPluginLinkItems(sharedDir: string): SharedItem[] { + const sharedPluginsPath = path.join(sharedDir, 'plugins'); + const items = new Map( + SHARED_PLUGIN_ENTRIES.map((entry) => [entry.name, { ...entry }]) + ); + + for (const entry of fs.readdirSync(sharedPluginsPath, { withFileTypes: true })) { + if (items.has(entry.name) || INSTANCE_LOCAL_PLUGIN_METADATA_FILES.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedPluginsPath, entry.name); + let stats: fs.Stats; + try { + stats = fs.statSync(entryPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + console.log( + warn( + `Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}` + ) + ); + continue; + } + + items.set(entry.name, { + name: entry.name, + type: stats.isDirectory() ? 'directory' : 'file', + }); + } + + return [...items.values()]; +} + +/** + * Detach managed plugin layout from an instance. Removes the symlinked + * plugin entries that point back at the shared plugins dir and reconciles + * the local marketplace registry. Removes the plugins directory entirely + * if it ends up empty. + */ +export function detachManagedPluginLayout(roots: PluginLayoutRoots, instancePath: string): void { + const pluginsPath = path.join(instancePath, 'plugins'); + if (!fs.existsSync(pluginsPath)) { + return; + } + + const stats = fs.lstatSync(pluginsPath); + const sharedPluginsPath = path.join(roots.sharedDir, 'plugins'); + + if (stats.isSymbolicLink()) { + if (symlinkPointsTo(pluginsPath, sharedPluginsPath)) { + removeExistingPath(pluginsPath, 'directory'); + } + return; + } + + if (!stats.isDirectory()) { + return; + } + + let removedManagedEntries = false; + + for (const item of getSharedPluginLinkItems(roots.sharedDir)) { + const pluginEntryPath = path.join(pluginsPath, item.name); + if (!fs.existsSync(pluginEntryPath)) { + continue; + } + + const entryStats = fs.lstatSync(pluginEntryPath); + if (!entryStats.isSymbolicLink()) { + continue; + } + + if (symlinkPointsTo(pluginEntryPath, path.join(sharedPluginsPath, item.name))) { + removeExistingPath(pluginEntryPath, item.type); + removedManagedEntries = true; + } + } + + if (!removedManagedEntries) { + return; + } + + reconcileLocalMarketplaceRegistry(roots, instancePath); + + if (fs.readdirSync(pluginsPath).length === 0) { + fs.rmSync(pluginsPath, { recursive: true, force: true }); + } +} diff --git a/src/management/shared-manager/plugin-metadata-normalizer.ts b/src/management/shared-manager/plugin-metadata-normalizer.ts new file mode 100644 index 00000000..1b86bc93 --- /dev/null +++ b/src/management/shared-manager/plugin-metadata-normalizer.ts @@ -0,0 +1,331 @@ +/** + * SharedManager - plugin metadata normalization and marketplace reconciliation. + * + * Extracted from the original monolithic shared-manager.ts. These helpers + * normalize installed_plugins.json and known_marketplaces.json contents so + * plugin metadata refers to canonical ~/.claude/ paths rather than + * instance-specific paths, and reconcile the marketplace registry against + * the on-disk marketplace directories. + * + * All filesystem roots (claudeDir, sharedDir, instancesDir) are passed + * explicitly to keep this module stateless and testable in isolation. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { ok, warn } from '../../utils/ui'; +import { + normalizePluginMetadataContent, + normalizePluginMetadataValue, +} from '../plugin-path-normalizer'; +import { listAccountInstancePaths } from '../instance-directory'; +import { removeExistingPath, resolveCanonicalPath } from './fs-helpers'; +import type { SharedItem } from './types'; + +/** + * Roots that plugin-metadata normalization operates on. The SharedManager + * orchestrator supplies its own private fields when constructing this + * object. + */ +export interface PluginMetadataRoots { + claudeDir: string; + sharedDir: string; + instancesDir: string; +} + +/** + * Normalize every installed_plugins.json file reachable from the given roots + * so its embedded paths are canonical ~/.claude/ paths. + */ +export function normalizePluginRegistryPaths(roots: PluginMetadataRoots, configDir?: string): void { + normalizePluginMetadataFiles( + roots, + 'installed_plugins.json', + configDir, + 'Normalized plugin registry paths', + 'plugin registry' + ); +} + +/** + * Reconcile marketplace registry content into the active config dir while + * keeping the global ~/.claude copy up to date for non-instance flows. + */ +export function normalizeMarketplaceRegistryPaths( + roots: PluginMetadataRoots, + configDir?: string +): void { + const successMessage = 'Synchronized marketplace registry paths'; + const warningLabel = 'marketplace registry'; + + try { + const sourcePaths = getMarketplaceRegistrySourcePaths(roots, configDir); + writePluginMetadataFile( + path.join(roots.claudeDir, 'plugins', 'known_marketplaces.json'), + buildMarketplaceRegistryContent(sourcePaths, roots.claudeDir), + successMessage + ); + + if (configDir && path.resolve(configDir) !== path.resolve(roots.claudeDir)) { + writePluginMetadataFile( + path.join(configDir, 'plugins', 'known_marketplaces.json'), + buildMarketplaceRegistryContent(sourcePaths, configDir), + successMessage + ); + } + } catch (err) { + console.log(warn(`Could not synchronize ${warningLabel}: ${(err as Error).message}`)); + } +} + +/** + * Run normalizePluginMetadataFile across every registry path deduped by + * canonical realpath. + */ +function normalizePluginMetadataFiles( + roots: PluginMetadataRoots, + fileName: string, + configDir: string | undefined, + successMessage: string, + warningLabel: string +): void { + const seen = new Set(); + + for (const registryPath of getPluginMetadataFilePaths(roots, fileName, configDir)) { + const dedupeKey = resolveCanonicalPath(registryPath); + if (seen.has(dedupeKey)) { + continue; + } + + seen.add(dedupeKey); + normalizePluginMetadataFile(registryPath, successMessage, warningLabel); + } +} + +function getPluginMetadataFilePaths( + roots: PluginMetadataRoots, + fileName: string, + configDir?: string +): string[] { + const pluginDirs = new Set([ + path.join(roots.claudeDir, 'plugins'), + path.join(roots.sharedDir, 'plugins'), + ]); + + if (configDir && path.resolve(configDir) !== path.resolve(roots.claudeDir)) { + pluginDirs.add(path.join(configDir, 'plugins')); + } + + return [...pluginDirs].map((pluginDir) => path.join(pluginDir, fileName)); +} + +function normalizePluginMetadataFile( + registryPath: string, + successMessage: string, + warningLabel: string +): void { + if (!fs.existsSync(registryPath)) { + return; + } + + try { + const original = fs.readFileSync(registryPath, 'utf8'); + const normalized = normalizePluginMetadataContent(original); + + if (normalized !== original) { + fs.writeFileSync(registryPath, normalized, 'utf8'); + console.log(ok(successMessage)); + } + } catch (err) { + console.log(warn(`Could not normalize ${warningLabel}: ${(err as Error).message}`)); + } +} + +function getMarketplaceRegistrySourcePaths( + roots: PluginMetadataRoots, + configDir?: string +): string[] { + const sourcePaths = new Set([ + path.join(roots.claudeDir, 'plugins', 'known_marketplaces.json'), + ]); + + for (const instancePath of listAccountInstancePaths(roots.instancesDir)) { + sourcePaths.add(path.join(instancePath, 'plugins', 'known_marketplaces.json')); + } + + if (configDir && path.resolve(configDir) !== path.resolve(roots.claudeDir)) { + sourcePaths.add(path.join(configDir, 'plugins', 'known_marketplaces.json')); + } + + return [...sourcePaths]; +} + +function buildMarketplaceRegistryContent(sourcePaths: string[], targetConfigDir: string): string { + const merged: Record = {}; + + for (const registryPath of sourcePaths) { + if (!fs.existsSync(registryPath)) { + continue; + } + + try { + const parsed = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + continue; + } + + for (const [name, value] of Object.entries(parsed as Record)) { + if (!isMarketplaceRegistryEntry(value)) { + continue; + } + + merged[name] = normalizePluginMetadataValue(value, targetConfigDir).normalized; + } + } catch (err) { + console.log( + warn(`Skipping malformed marketplace registry ${registryPath}: ${(err as Error).message}`) + ); + } + } + + const discoveredEntries = discoverMarketplaceEntries(targetConfigDir); + + // Keep only registry entries that have a physical directory, and update + // their installLocation. Entries only on disk (no registry record) are + // excluded: they lack required schema fields that Claude Code enforces. + for (const name of Object.keys(merged)) { + const entry = merged[name]; + if (!(name in discoveredEntries)) { + delete merged[name]; + } else if (isMarketplaceRegistryEntry(entry)) { + merged[name] = { + ...entry, + installLocation: discoveredEntries[name].installLocation, + }; + } else { + delete merged[name]; + } + } + + return JSON.stringify(merged, null, 2); +} + +/** + * Discover physical marketplace directories on disk. Skips hidden dirs and + * Claude Code rename-dance leftovers (.staging/.bak). + */ +export function discoverMarketplaceEntries( + targetConfigDir: string +): Record { + const marketplacesDir = path.join(targetConfigDir, 'plugins', 'marketplaces'); + if (!fs.existsSync(marketplacesDir)) { + return {}; + } + + const discovered: Record = {}; + + for (const entry of fs.readdirSync(marketplacesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + if (isTransientMarketplaceDirectory(entry.name)) { + continue; + } + + discovered[entry.name] = { + installLocation: path.join(targetConfigDir, 'plugins', 'marketplaces', entry.name), + }; + } + + return discovered; +} + +function isTransientMarketplaceDirectory(name: string): boolean { + return name.startsWith('.') || name.endsWith('.staging') || name.endsWith('.bak'); +} + +function isMarketplaceRegistryEntry(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Write a plugin metadata file, creating parent dirs first and skipping + * when the content is unchanged. + */ +export function writePluginMetadataFile( + registryPath: string, + content: string, + successMessage: string +): void { + fs.mkdirSync(path.dirname(registryPath), { recursive: true, mode: 0o700 }); + const current = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, 'utf8') : null; + + if (current === content) { + return; + } + + fs.writeFileSync(registryPath, content, 'utf8'); + console.log(ok(successMessage)); +} + +/** + * Reconcile the local marketplace registry against on-disk directories, + * removing the registry file entirely when no marketplaces remain and + * otherwise re-normalizing each entry. + */ +export function reconcileLocalMarketplaceRegistry( + _roots: PluginMetadataRoots, + configDir: string +): void { + const registryPath = path.join(configDir, 'plugins', 'known_marketplaces.json'); + if (!fs.existsSync(registryPath)) { + return; + } + + const discoveredEntries = discoverMarketplaceEntries(configDir); + if (Object.keys(discoveredEntries).length === 0) { + removeExistingPath(registryPath, 'file'); + return; + } + + let parsed: Record = {}; + try { + const raw = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as unknown; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + parsed = raw as Record; + } + } catch { + parsed = {}; + } + + const reconciled = Object.fromEntries( + Object.entries(discoveredEntries).map(([name, value]) => { + const existing = parsed[name]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + return [ + name, + { + ...(normalizePluginMetadataValue(existing, configDir).normalized as Record< + string, + unknown + >), + installLocation: value.installLocation, + }, + ]; + } + + return [name, value]; + }) + ); + + writePluginMetadataFile( + registryPath, + JSON.stringify(reconciled, null, 2), + 'Synchronized marketplace registry paths' + ); +} + +// Silence unused-import warning for SharedItem when consumers re-import it. +export type { SharedItem }; diff --git a/src/management/shared-manager/project-context-sync.ts b/src/management/shared-manager/project-context-sync.ts new file mode 100644 index 00000000..209ae0d3 --- /dev/null +++ b/src/management/shared-manager/project-context-sync.ts @@ -0,0 +1,298 @@ +/** + * SharedManager - project context, memory, and continuity synchronization. + * + * Extracted from the original monolithic shared-manager.ts. Owns the + * account-policy driven layout for per-instance projects/, memory/, and + * advanced continuity artifacts (session-env, file-history, shell-snapshots, + * todos). + * + * All filesystem roots are passed explicitly. The SharedManager orchestrator + * is responsible for supplying its own private fields and the warn/ok/info + * UI helpers. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../../auth/account-context'; +import type { AccountContextPolicy } from '../../auth/account-context'; +import { warn } from '../../utils/ui'; +import { ensureDirectory, getLstat, pathExists } from './fs-helpers'; +import { + detachLegacySharedMemoryLinks, + isSafeContinuityMergeSource, + isSafeProjectsMergeSource, + isSymlinkTarget, + linkDirectoryWithFallback, + mergeDirectoryWithConflictCopies, + resolveSymlinkTargetPath, + type SymlinkHelperDeps, +} from './symlink-helpers'; +import { ADVANCED_CONTINUITY_ITEMS } from './types'; + +/** + * Roots for the context sync module. Matches the linker/metadata shape. + */ +export interface ContextSyncRoots { + sharedDir: string; + instancesDir: string; +} + +/** + * Sync project workspace context based on account policy. + * + * - isolated (default): each profile keeps its own ./projects directory. + * - shared: profile ./projects becomes symlink to shared context group root. + */ +export async function syncProjectContext( + roots: ContextSyncRoots, + instancePath: string, + policy: AccountContextPolicy, + deps: SymlinkHelperDeps +): Promise { + const projectsPath = path.join(instancePath, 'projects'); + const instanceName = path.basename(instancePath); + const mode = policy.mode === 'shared' ? 'shared' : 'isolated'; + + if (mode === 'shared') { + const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; + const sharedProjectsPath = path.join( + roots.sharedDir, + 'context-groups', + contextGroup, + 'projects' + ); + + await ensureDirectory(sharedProjectsPath); + await ensureDirectory(path.dirname(projectsPath)); + + const currentStats = await getLstat(projectsPath); + if (!currentStats) { + await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps); + return; + } + + if (currentStats.isSymbolicLink()) { + if (await isSymlinkTarget(projectsPath, sharedProjectsPath)) { + return; + } + + const currentTarget = await resolveSymlinkTargetPath(projectsPath); + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) && + isSafeProjectsMergeSource( + currentTarget, + instanceName, + roots.sharedDir, + roots.instancesDir + ) && + (await pathExists(currentTarget)) + ) { + await mergeDirectoryWithConflictCopies(currentTarget, sharedProjectsPath, instanceName); + } else if ( + currentTarget && + !isSafeProjectsMergeSource(currentTarget, instanceName, roots.sharedDir, roots.instancesDir) + ) { + console.log( + warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`) + ); + } + + await fs.promises.unlink(projectsPath); + await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps); + return; + } + + if (currentStats.isDirectory()) { + await detachLegacySharedMemoryLinks(projectsPath, instanceName, roots.sharedDir); + await mergeDirectoryWithConflictCopies(projectsPath, sharedProjectsPath, instanceName); + await fs.promises.rm(projectsPath, { recursive: true, force: true }); + await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps); + return; + } + + await fs.promises.rm(projectsPath, { force: true }); + await linkDirectoryWithFallback(sharedProjectsPath, projectsPath, deps); + return; + } + + const currentStats = await getLstat(projectsPath); + if (!currentStats) { + await ensureDirectory(projectsPath); + return; + } + + if (currentStats.isDirectory()) { + await detachLegacySharedMemoryLinks(projectsPath, instanceName, roots.sharedDir); + return; + } + + if (currentStats.isSymbolicLink()) { + const currentTarget = await resolveSymlinkTargetPath(projectsPath); + await fs.promises.unlink(projectsPath); + await ensureDirectory(projectsPath); + + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(projectsPath) && + isSafeProjectsMergeSource(currentTarget, instanceName, roots.sharedDir, roots.instancesDir) && + (await pathExists(currentTarget)) + ) { + await mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName); + } else if ( + currentTarget && + !isSafeProjectsMergeSource(currentTarget, instanceName, roots.sharedDir, roots.instancesDir) + ) { + console.log(warn(`Skipping unsafe project merge source outside CCS roots: ${currentTarget}`)); + } + + return; + } + + await fs.promises.rm(projectsPath, { force: true }); + await ensureDirectory(projectsPath); +} + +/** + * Sync advanced continuity artifacts for shared deeper mode. + * + * - shared + deeper: artifacts are linked per context group. + * - shared + standard / isolated: artifacts stay local to instance. + */ +export async function syncAdvancedContinuityArtifacts( + roots: ContextSyncRoots, + instancePath: string, + policy: AccountContextPolicy, + deps: SymlinkHelperDeps +): Promise { + const instanceName = path.basename(instancePath); + const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper'; + const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP; + + for (const artifactName of ADVANCED_CONTINUITY_ITEMS) { + const instanceArtifactPath = path.join(instancePath, artifactName); + + if (useSharedContinuity) { + const sharedArtifactPath = path.join( + roots.sharedDir, + 'context-groups', + contextGroup, + 'continuity', + artifactName + ); + + await ensureDirectory(sharedArtifactPath); + await ensureDirectory(path.dirname(instanceArtifactPath)); + + const currentStats = await getLstat(instanceArtifactPath); + if (!currentStats) { + await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps); + continue; + } + + if (currentStats.isSymbolicLink()) { + if (await isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) { + continue; + } + + const currentTarget = await resolveSymlinkTargetPath(instanceArtifactPath); + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) && + isSafeContinuityMergeSource( + currentTarget, + instanceName, + artifactName, + roots.sharedDir, + roots.instancesDir + ) && + (await pathExists(currentTarget)) + ) { + await mergeDirectoryWithConflictCopies(currentTarget, sharedArtifactPath, instanceName); + } else if ( + currentTarget && + !isSafeContinuityMergeSource( + currentTarget, + instanceName, + artifactName, + roots.sharedDir, + roots.instancesDir + ) + ) { + console.log( + warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`) + ); + } + + await fs.promises.unlink(instanceArtifactPath); + await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps); + continue; + } + + if (currentStats.isDirectory()) { + await mergeDirectoryWithConflictCopies( + instanceArtifactPath, + sharedArtifactPath, + instanceName + ); + await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true }); + await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps); + continue; + } + + await fs.promises.rm(instanceArtifactPath, { force: true }); + await linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath, deps); + continue; + } + + const currentStats = await getLstat(instanceArtifactPath); + if (!currentStats) { + await ensureDirectory(instanceArtifactPath); + continue; + } + + if (currentStats.isDirectory()) { + continue; + } + + if (currentStats.isSymbolicLink()) { + const currentTarget = await resolveSymlinkTargetPath(instanceArtifactPath); + await fs.promises.unlink(instanceArtifactPath); + await ensureDirectory(instanceArtifactPath); + + if ( + currentTarget && + path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) && + isSafeContinuityMergeSource( + currentTarget, + instanceName, + artifactName, + roots.sharedDir, + roots.instancesDir + ) && + (await pathExists(currentTarget)) + ) { + await mergeDirectoryWithConflictCopies(currentTarget, instanceArtifactPath, instanceName); + } else if ( + currentTarget && + !isSafeContinuityMergeSource( + currentTarget, + instanceName, + artifactName, + roots.sharedDir, + roots.instancesDir + ) + ) { + console.log( + warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`) + ); + } + + continue; + } + + await fs.promises.rm(instanceArtifactPath, { force: true }); + await ensureDirectory(instanceArtifactPath); + } +} diff --git a/src/management/shared-manager/project-memory-sync.ts b/src/management/shared-manager/project-memory-sync.ts new file mode 100644 index 00000000..5ffe4ef4 --- /dev/null +++ b/src/management/shared-manager/project-memory-sync.ts @@ -0,0 +1,117 @@ +/** + * SharedManager - per-project memory directory synchronization. + * + * Extracted from project-context-sync.ts to keep each sync concern under + * the 400 LOC target. Owns the layout migration from per-instance + * projects//memory/ into the canonical shared memory root at + * ~/.ccs/shared/memory//. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { ok } from '../../utils/ui'; +import { ensureDirectory, getLstat, moveDirectory, pathExists } from './fs-helpers'; +import { + ensureProjectMemoryLink, + isSymlinkTarget, + mergeDirectoryWithConflictCopies, + type SymlinkHelperDeps, +} from './symlink-helpers'; +import type { ContextSyncRoots } from './project-context-sync'; + +/** + * Ensure all project memory directories for an instance are shared. + * + * Source layout (isolated): + * ~/.ccs/instances//projects//memory/ + * + * Shared layout (canonical): + * ~/.ccs/shared/memory// + */ +export async function syncProjectMemories( + roots: ContextSyncRoots, + instancePath: string, + deps: SymlinkHelperDeps +): Promise { + const projectsDir = path.join(instancePath, 'projects'); + if (!(await pathExists(projectsDir))) { + return; + } + + await ensureDirectory(roots.sharedDir); + + const sharedMemoryRoot = path.join(roots.sharedDir, 'memory'); + await ensureDirectory(sharedMemoryRoot); + + let projectEntries: fs.Dirent[] = []; + try { + projectEntries = await fs.promises.readdir(projectsDir, { withFileTypes: true }); + } catch (_err) { + return; + } + + const projects = projectEntries.filter((entry) => entry.isDirectory()); + if (projects.length === 0) { + return; + } + + let migrated = 0; + let merged = 0; + let linked = 0; + const instanceName = path.basename(instancePath); + + for (const project of projects) { + const projectDir = path.join(projectsDir, project.name); + const projectMemoryPath = path.join(projectDir, 'memory'); + const sharedProjectMemoryPath = path.join(sharedMemoryRoot, project.name); + + const projectMemoryStats = await getLstat(projectMemoryPath); + if (!projectMemoryStats) { + if (await ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath, deps)) { + linked++; + } + continue; + } + + if (projectMemoryStats.isSymbolicLink()) { + if (await isSymlinkTarget(projectMemoryPath, sharedProjectMemoryPath)) { + continue; + } + + await fs.promises.unlink(projectMemoryPath); + if (await ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath, deps)) { + linked++; + } + continue; + } + + if (!projectMemoryStats.isDirectory()) { + continue; + } + + if (!(await pathExists(sharedProjectMemoryPath))) { + await moveDirectory(projectMemoryPath, sharedProjectMemoryPath); + migrated++; + } else { + merged += await mergeDirectoryWithConflictCopies( + projectMemoryPath, + sharedProjectMemoryPath, + instanceName + ); + await fs.promises.rm(projectMemoryPath, { recursive: true, force: true }); + } + + if (await ensureProjectMemoryLink(projectMemoryPath, sharedProjectMemoryPath, deps)) { + linked++; + } + } + + if (migrated > 0 || merged > 0 || linked > 0) { + console.log( + ok( + `Synced shared project memory: ${migrated} migrated, ${merged} merged conflict(s), ${linked} linked` + ) + ); + } +} diff --git a/src/management/shared-manager/shared-dir-linker.ts b/src/management/shared-manager/shared-dir-linker.ts new file mode 100644 index 00000000..00acef38 --- /dev/null +++ b/src/management/shared-manager/shared-dir-linker.ts @@ -0,0 +1,242 @@ +/** + * SharedManager - shared directory linking entrypoints. + * + * Extracted from the original monolithic shared-manager.ts. Owns the + * creation of the ~/.ccs/shared/* symlinks pointing at ~/.claude/* and + * the per-instance links for commands/skills/agents/plugins/settings. + * + * Plugin-layout internals (the four functions that operate on the plugins/ + * subtree) live in plugin-layout-internals.ts. This file keeps only the + * high-level entrypoints and circular-symlink detection so each file stays + * focused and under the 400 LOC target. + * + * These functions take an explicit roots object so they remain decoupled + * from SharedManager instance state. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { info, warn } from '../../utils/ui'; +import { + copyDirectoryFallback, + getLstatSync, + isPathWithinDirectory, + removeExistingPath, + resolveCanonicalPath, + symlinkPointsTo, +} from './fs-helpers'; +import type { PluginMetadataRoots } from './plugin-metadata-normalizer'; +import { + normalizeMarketplaceRegistryPaths, + normalizePluginRegistryPaths, +} from './plugin-metadata-normalizer'; +import { + detachManagedPluginLayout, + ensureSharedPluginLayoutDefaults, + linkInstancePlugins, +} from './plugin-layout-internals'; +import { SHARED_ITEMS } from './types'; + +/** + * Roots for the shared-dir linker. Reuses PluginMetadataRoots because the + * linker operates on the same three roots. + */ +export type LinkerRoots = PluginMetadataRoots; + +/** + * Detect a circular symlink before creation. A symlink is circular when its + * target (raw or canonical) points back inside the shared root. + */ +export function detectCircularSymlink(target: string, sharedDir: string): boolean { + try { + const stats = fs.lstatSync(target); + if (!stats.isSymbolicLink()) { + return false; + } + + const targetLink = fs.readlinkSync(target); + const resolvedTarget = path.resolve(path.dirname(target), targetLink); + const sharedDirPath = path.resolve(sharedDir); + + // A raw target path pointing back into ~/.ccs/shared is already unsafe. + // Re-pointing ~/.ccs/shared/* to ~/.claude/* would turn it into a real + // loop, even if the current ~/.ccs/shared entry ultimately resolves to + // an external path. + if (isPathWithinDirectory(resolvedTarget, sharedDirPath)) { + console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); + return true; + } + + // Only treat targets inside the managed shared root as circular. + // Existing shared symlinks may already resolve through ~/.claude/ to an + // external repo, which is a supported upgrade path rather than a loop. + const sharedDirCanonical = resolveCanonicalPath(sharedDirPath); + const canonicalResolvedTarget = resolveCanonicalPath(resolvedTarget); + + if (isPathWithinDirectory(canonicalResolvedTarget, sharedDirCanonical)) { + console.log(warn(`Circular symlink detected: ${target} → ${resolvedTarget}`)); + return true; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw err; + } + + return false; +} + +/** + * Ensure shared directories exist as symlinks to ~/.claude/ and that the + * plugin layout default directories and registry files are present. + */ +export function ensureSharedDirectories(roots: LinkerRoots): void { + const claudeDir = roots.claudeDir; + const sharedDir = roots.sharedDir; + + if (!getLstatSync(claudeDir)) { + console.log(info('Creating ~/.claude/ directory structure')); + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + if (!getLstatSync(sharedDir)) { + fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 }); + } + + ensureSharedPluginLayoutDefaults(claudeDir); + + for (const item of SHARED_ITEMS) { + const claudePath = path.join(claudeDir, item.name); + const sharedPath = path.join(sharedDir, item.name); + + if (!getLstatSync(claudePath)) { + if (item.type === 'directory') { + fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 }); + } else if (item.type === 'file') { + fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8'); + } + } + + if (detectCircularSymlink(claudePath, sharedDir)) { + console.log(warn(`Skipping ${item.name}: circular symlink detected`)); + continue; + } + + if (getLstatSync(sharedPath)) { + try { + const stats = fs.lstatSync(sharedPath); + if (stats.isSymbolicLink()) { + const currentTarget = fs.readlinkSync(sharedPath); + const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget); + if (resolvedTarget === claudePath) { + continue; + } + } + } catch (_err) { + // Continue to recreate + } + + if (item.type === 'directory') { + fs.rmSync(sharedPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(sharedPath); + } + } + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(claudePath, sharedPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(claudePath, sharedPath); + } else if (item.type === 'file') { + fs.copyFileSync(claudePath, sharedPath); + } + console.log( + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + } +} + +/** + * Link shared directories into a specific instance path. + */ +export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void { + ensureSharedDirectories(roots); + + const sharedDir = roots.sharedDir; + + for (const item of SHARED_ITEMS) { + if (item.name === 'plugins') { + linkInstancePlugins(roots, instancePath); + continue; + } + + const linkPath = path.join(instancePath, item.name); + const targetPath = path.join(sharedDir, item.name); + + removeExistingPath(linkPath, item.type); + + try { + const symlinkType = item.type === 'directory' ? 'dir' : 'file'; + fs.symlinkSync(targetPath, linkPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + if (item.type === 'directory') { + copyDirectoryFallback(targetPath, linkPath); + } else if (item.type === 'file') { + fs.copyFileSync(targetPath, linkPath); + } + console.log( + warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`) + ); + } else { + throw _err; + } + } + } + + // Preserve original behavior: linkSharedDirectories always concludes by + // normalizing plugin + marketplace metadata for the freshly linked + // instance. migrateFromV311 relies on this side effect. + normalizePluginRegistryPaths(roots, instancePath); + normalizeMarketplaceRegistryPaths(roots, instancePath); +} + +/** + * Detach shared-directory symlinks from an instance, removing only entries + * that point back at the shared root. + */ +export function detachSharedDirectories(roots: LinkerRoots, instancePath: string): void { + ensureSharedDirectories(roots); + + const sharedDir = roots.sharedDir; + + for (const item of SHARED_ITEMS) { + const managedPath = path.join(instancePath, item.name); + if (!fs.existsSync(managedPath)) { + continue; + } + + if (item.name === 'plugins') { + detachManagedPluginLayout(roots, instancePath); + continue; + } + + const stats = fs.lstatSync(managedPath); + if (!stats.isSymbolicLink()) { + continue; + } + + if (symlinkPointsTo(managedPath, path.join(sharedDir, item.name))) { + removeExistingPath(managedPath, item.type); + } + } +} diff --git a/src/management/shared-manager/symlink-helpers.ts b/src/management/shared-manager/symlink-helpers.ts new file mode 100644 index 00000000..0c026a86 --- /dev/null +++ b/src/management/shared-manager/symlink-helpers.ts @@ -0,0 +1,296 @@ +/** + * SharedManager - async symlink and merge helpers. + * + * Extracted from the original monolithic shared-manager.ts. These functions + * handle the cross-platform symlink + recursive merge primitives that the + * project-context-sync and project-memory flows build on. + * + * Logging is dependency-injected via the SymlinkHelperDeps interface so this + * module stays free of imports from the UI layer. The orchestrator class is + * responsible for supplying `warn` from `../utils/ui`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { + copyDirectoryFallback, + ensureDirectory, + fileContentsEqual, + getConflictCopyPath, + pathExists, + resolveCanonicalPath, + isPathWithinDirectory, +} from './fs-helpers'; + +/** + * Dependencies that the orchestrator (SharedManager) must supply to the + * symlink helpers. Injected rather than imported so this module has no + * coupling to the UI layer. + */ +export interface SymlinkHelperDeps { + warn: (message: string) => string; +} + +/** + * Check whether a symlink points at an expected target. Returns false on any + * IO error or when the link is not a symlink. + */ +export async function isSymlinkTarget(linkPath: string, expectedTarget: string): Promise { + try { + const stats = await fs.promises.lstat(linkPath); + if (!stats.isSymbolicLink()) { + return false; + } + + const currentTarget = await fs.promises.readlink(linkPath); + const resolvedCurrentTarget = path.resolve(path.dirname(linkPath), currentTarget); + const resolvedExpectedTarget = path.resolve(expectedTarget); + return resolvedCurrentTarget === resolvedExpectedTarget; + } catch (_err) { + return false; + } +} + +/** + * Resolve a symlink to its absolute target path. Returns null on failure. + */ +export async function resolveSymlinkTargetPath(linkPath: string): Promise { + try { + const currentTarget = await fs.promises.readlink(linkPath); + return path.resolve(path.dirname(linkPath), currentTarget); + } catch (_err) { + return null; + } +} + +/** + * Create a symlink from linkPath to targetPath, falling back to a recursive + * copy on Windows when symlink creation fails. + */ +export async function linkDirectoryWithFallback( + targetPath: string, + linkPath: string, + deps: SymlinkHelperDeps +): Promise { + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + + try { + await fs.promises.symlink(linkTarget, linkPath, symlinkType); + } catch (_err) { + if (process.platform === 'win32') { + copyDirectoryFallback(targetPath, linkPath); + + console.log( + deps.warn(`Symlink failed for context projects, copied instead (enable Developer Mode)`) + ); + return; + } + + throw _err; + } +} + +/** + * Ensure a symlink from linkPath to targetPath exists, creating the target + * directory first if needed. Returns true when a new link/copy was created + * or an existing incorrect link was replaced. + */ +export async function ensureProjectMemoryLink( + linkPath: string, + targetPath: string, + deps: SymlinkHelperDeps +): Promise { + await ensureDirectory(targetPath); + + let linkStats: fs.Stats | null = null; + try { + linkStats = await fs.promises.lstat(linkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + + if (linkStats) { + if (linkStats.isSymbolicLink() && (await isSymlinkTarget(linkPath, targetPath))) { + return false; + } + + if (linkStats.isDirectory()) { + await fs.promises.rm(linkPath, { recursive: true, force: true }); + } else { + await fs.promises.unlink(linkPath); + } + } + + const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir'; + const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath; + + try { + await fs.promises.symlink(linkTarget, linkPath, symlinkType); + return true; + } catch (_err) { + if (process.platform === 'win32') { + copyDirectoryFallback(targetPath, linkPath); + + console.log( + deps.warn(`Symlink failed for project memory, copied instead (enable Developer Mode)`) + ); + return true; + } + throw _err; + } +} + +/** + * Merge sourceDir into targetDir recursively. On file conflicts the target + * is preserved and the source file is copied as + * ".migrated-from-[-N]" to avoid data loss. Returns the + * number of conflict copies produced. + */ +export async function mergeDirectoryWithConflictCopies( + sourceDir: string, + targetDir: string, + instanceName: string +): Promise { + await ensureDirectory(targetDir); + + let conflicts = 0; + const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true }); + + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + conflicts += await mergeDirectoryWithConflictCopies(sourcePath, targetPath, instanceName); + continue; + } + + if (entry.isFile()) { + if (!(await pathExists(targetPath))) { + await fs.promises.copyFile(sourcePath, targetPath); + continue; + } + + if (await fileContentsEqual(sourcePath, targetPath)) { + continue; + } + + const conflictPath = await getConflictCopyPath(targetPath, instanceName); + await fs.promises.copyFile(sourcePath, conflictPath); + conflicts++; + } + } + + return conflicts; +} + +/** + * Migrate legacy per-project memory symlinks that point into the shared + * memory root back to instance-local directories, preserving data via + * conflict-copy merge. + */ +export async function detachLegacySharedMemoryLinks( + projectsPath: string, + instanceName: string, + sharedDir: string +): Promise { + const sharedMemoryRoot = resolveCanonicalPath(path.join(sharedDir, 'memory')); + + let projectEntries: fs.Dirent[] = []; + try { + projectEntries = await fs.promises.readdir(projectsPath, { withFileTypes: true }); + } catch (_err) { + return; + } + + for (const entry of projectEntries) { + if (!entry.isDirectory()) { + continue; + } + + const projectPath = path.join(projectsPath, entry.name); + const memoryPath = path.join(projectPath, 'memory'); + let memoryStats: fs.Stats | null = null; + try { + memoryStats = await fs.promises.lstat(memoryPath); + } catch (_err) { + memoryStats = null; + } + + if (!memoryStats?.isSymbolicLink()) { + continue; + } + + const memoryTarget = await resolveSymlinkTargetPath(memoryPath); + if (!memoryTarget) { + continue; + } + + const canonicalMemoryTarget = resolveCanonicalPath(memoryTarget); + if (!isPathWithinDirectory(canonicalMemoryTarget, sharedMemoryRoot)) { + continue; + } + + await fs.promises.unlink(memoryPath); + await ensureDirectory(memoryPath); + + if (await pathExists(canonicalMemoryTarget)) { + await mergeDirectoryWithConflictCopies(canonicalMemoryTarget, memoryPath, instanceName); + } + } +} + +/** + * Guard project merge operations to known CCS-managed roots only. + */ +export function isSafeProjectsMergeSource( + sourcePath: string, + instanceName: string, + sharedDir: string, + instancesDir: string +): boolean { + const resolvedSource = resolveCanonicalPath(sourcePath); + const sharedContextRoot = resolveCanonicalPath(path.join(sharedDir, 'context-groups')); + const instanceProjectsRoot = resolveCanonicalPath( + path.join(instancesDir, instanceName, 'projects') + ); + + return ( + isPathWithinDirectory(resolvedSource, sharedContextRoot) || + isPathWithinDirectory(resolvedSource, instanceProjectsRoot) + ); +} + +/** + * Guard advanced continuity merge operations to known CCS-managed roots only. + */ +export function isSafeContinuityMergeSource( + sourcePath: string, + instanceName: string, + artifactName: string, + sharedDir: string, + instancesDir: string +): boolean { + const resolvedSource = resolveCanonicalPath(sourcePath); + const sharedContextRoot = resolveCanonicalPath(path.join(sharedDir, 'context-groups')); + const instanceArtifactRoot = resolveCanonicalPath( + path.join(instancesDir, instanceName, artifactName) + ); + + const normalizedSource = + process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource; + const continuitySegment = + process.platform === 'win32' + ? `${path.sep}continuity${path.sep}`.toLowerCase() + : `${path.sep}continuity${path.sep}`; + + const withinSharedContinuity = + isPathWithinDirectory(resolvedSource, sharedContextRoot) && + normalizedSource.includes(continuitySegment); + + return withinSharedContinuity || isPathWithinDirectory(resolvedSource, instanceArtifactRoot); +} diff --git a/src/management/shared-manager/types.ts b/src/management/shared-manager/types.ts new file mode 100644 index 00000000..7555f1ed --- /dev/null +++ b/src/management/shared-manager/types.ts @@ -0,0 +1,72 @@ +/** + * SharedManager - shared types and constants. + * + * Extracted from the original monolithic shared-manager.ts to keep the + * orchestrator class focused on coordination. Pure data only: no behavior + * lives here. + */ + +/** + * Descriptor for a filesystem entry (directory or file) managed by + * SharedManager. Used for both top-level shared items and plugin layout + * entries. + */ +export interface SharedItem { + name: string; + type: 'directory' | 'file'; +} + +/** + * Default content for a freshly provisioned installed_plugins.json registry. + * Version 2 schema with an empty plugins map. + */ +export const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify( + { + version: 2, + plugins: {}, + }, + null, + 2 +); + +/** + * Canonical list of shared items linked between ~/.claude and ~/.ccs/shared, + * and from there into each instance. + * + * Order matters: consumers rely on a stable iteration order when reconciling + * symlinks, and 'plugins' is special-cased by the linker. + */ +export const SHARED_ITEMS: readonly SharedItem[] = [ + { name: 'commands', type: 'directory' }, + { name: 'skills', type: 'directory' }, + { name: 'agents', type: 'directory' }, + { name: 'plugins', type: 'directory' }, + { name: 'settings.json', type: 'file' }, +]; + +/** + * Plugin layout entries that always exist under ~/.claude/plugins and are + * linked as-is into each instance's plugins directory. + */ +export const SHARED_PLUGIN_ENTRIES: readonly SharedItem[] = [ + { name: 'cache', type: 'directory' }, + { name: 'marketplaces', type: 'directory' }, + { name: 'installed_plugins.json', type: 'file' }, +]; + +/** + * Plugin metadata filenames that are intentionally instance-local and must + * NOT be linked from the shared plugins directory. + */ +export const INSTANCE_LOCAL_PLUGIN_METADATA_FILES = new Set(['known_marketplaces.json']); + +/** + * Advanced continuity artifacts linked per context group when the account + * policy requests shared + deeper continuity. + */ +export const ADVANCED_CONTINUITY_ITEMS: readonly string[] = [ + 'session-env', + 'file-history', + 'shell-snapshots', + 'todos', +]; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 640b2341..74458d42 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -1,1216 +1,23 @@ /** * CLIProxy Stats Routes - Stats, status, models, error logs for CLIProxyAPI + * + * THIN BARREL. The implementation has been decomposed into focused submodules + * under `./cliproxy-stats-routes/`. This file preserves the original public + * surface so consumers can keep importing from 'cliproxy-stats-routes' with + * identical signatures. + * + * Public surface (verified by tests + callers): + * - default export : Express Router (routes/index.ts) + * - shouldCacheQuotaResult : quota cache predicate (quota-caching test) + * - registerCliproxyRestartRoute : restart route registrar (restart test) + * - resolveCliproxyUpdateCheckPayload : update-check resolver (version-fallback test) + * - resolveCliproxyVersionsPayload : versions resolver (version-fallback test) */ -import { Router, Request, Response } from 'express'; -import * as fs from 'fs'; -import * as path from 'path'; -import { - fetchCliproxyStats, - fetchCliproxyModels, - isCliproxyRunning, - fetchCliproxyErrorLogs, - fetchCliproxyErrorLogContent, -} from '../../cliproxy/services/stats-fetcher'; -import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher'; -import { fetchCodexQuota } from '../../cliproxy/quota/quota-fetcher-codex'; -import { fetchClaudeQuota } from '../../cliproxy/quota/quota-fetcher-claude'; -import { fetchGeminiCliQuota } from '../../cliproxy/quota/quota-fetcher-gemini-cli'; -import { fetchGhcpQuota } from '../../cliproxy/quota/quota-fetcher-ghcp'; -import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota/quota-response-cache'; -import type { - CodexQuotaResult, - ClaudeQuotaResult, - GeminiCliQuotaResult, - GhcpQuotaResult, -} from '../../cliproxy/quota/quota-types'; -import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher'; -import type { CLIProxyProvider } from '../../cliproxy/types'; -import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; -import { - getCliproxyWritablePath, - getCliproxyConfigPath, - getAuthDir, -} from '../../cliproxy/config/config-generator'; -import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; -import { ensureCliproxyService } from '../../cliproxy/service-manager'; -import { - checkCliproxyUpdate, - getInstalledCliproxyVersion, - getStoredConfiguredBackend, -} from '../../cliproxy/binary-manager'; -import { - fetchAllVersions, - isNewerVersion, - isVersionFaulty, -} from '../../cliproxy/binary/version-checker'; -import { - CLIPROXY_MAX_STABLE_VERSION, - CLIPROXY_FAULTY_RANGE, -} from '../../cliproxy/binary/platform-detector'; -import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; -import { - MODEL_ENV_VAR_KEYS, - canonicalizeModelIdForProvider, - getDeniedModelIdReasonForProvider, -} from '../../cliproxy/ai-providers/model-id-normalizer'; -import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service'; -import { restartDashboardCliproxy } from '../services/cliproxy-dashboard-restart-service'; -import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; -import { createLogger } from '../../services/logging'; - -const logger = createLogger('web-server:routes:cliproxy-stats'); - -const router = Router(); -type RestartDashboardCliproxyHandler = typeof restartDashboardCliproxy; - -const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000; -const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120; - -interface QuotaRateLimitEntry { - windowStart: number; - count: number; -} - -const quotaRateLimits = new Map(); - -router.use((req: Request, res: Response, next) => { - if ( - requireLocalAccessWhenAuthDisabled( - req, - res, - 'CLIProxy management endpoints require localhost access when dashboard auth is disabled.' - ) - ) { - next(); - } -}); - -function buildQuotaRateLimitKey(req: Request, provider: string): string { - const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; - return `${clientIp}:${provider}`; -} - -function isQuotaRouteRateLimited(req: Request, provider: string): boolean { - const key = buildQuotaRateLimitKey(req, provider); - const now = Date.now(); - - // Evict stale entries to prevent unbounded memory growth - if (quotaRateLimits.size > 1000) { - for (const [k, v] of quotaRateLimits) { - if (now - v.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS * 2) { - quotaRateLimits.delete(k); - } - } - } - - const current = quotaRateLimits.get(key); - - if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) { - quotaRateLimits.set(key, { windowStart: now, count: 1 }); - return false; - } - - current.count += 1; - quotaRateLimits.set(key, current); - return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS; -} - -/** - * Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx). - * Generic across all quota result types. - */ -export function shouldCacheQuotaResult(result: { - success: boolean; - needsReauth?: boolean; - isForbidden?: boolean; - httpStatus?: number; - retryable?: boolean; - error?: string; -}): boolean { - if (result.success) return true; - if (result.needsReauth || result.isForbidden) return true; - if (result.retryable === true) return false; - if (result.retryable === false) return true; - if (typeof result.httpStatus === 'number') { - if (result.httpStatus === 429 || result.httpStatus === 408 || result.httpStatus >= 500) { - return false; - } - if (result.httpStatus >= 400 && result.httpStatus < 500) { - return true; - } - } - const msg = (result.error || '').toLowerCase(); - if (!msg) return false; - const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed']; - return !transientPatterns.some((p) => msg.includes(p)); -} - -function buildUpdateCheckFallback( - backend: ReturnType, - getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion -) { - const currentVersion = getInstalledVersionFn(backend); - const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION); - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; - - return { - hasUpdate: false, - currentVersion, - latestVersion: currentVersion, - fromCache: true, - checkedAt: Date.now(), - backend, - backendLabel, - isStable, - maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, - stabilityMessage: isStable - ? undefined - : `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`, - }; -} - -function buildVersionsFallback( - backend: ReturnType, - getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion -) { - const currentVersion = getInstalledVersionFn(backend); - - return { - versions: currentVersion ? [currentVersion] : [], - latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION, - latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION, - fromCache: true, - checkedAt: Date.now(), - currentVersion, - maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, - faultyRange: CLIPROXY_FAULTY_RANGE, - }; -} - -interface ResolveUpdateCheckDeps { - checkCliproxyUpdateFn?: typeof checkCliproxyUpdate; - getInstalledVersionFn?: typeof getInstalledCliproxyVersion; -} - -interface ResolveVersionsDeps { - fetchAllVersionsFn?: typeof fetchAllVersions; - getInstalledVersionFn?: typeof getInstalledCliproxyVersion; -} - -export async function resolveCliproxyUpdateCheckPayload( - backend: ReturnType, - deps: ResolveUpdateCheckDeps = {} -) { - const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate; - const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion; - - return checkCliproxyUpdateFn(backend).catch(() => - buildUpdateCheckFallback(backend, getInstalledVersionFn) - ); -} - -export async function resolveCliproxyVersionsPayload( - backend: ReturnType, - deps: ResolveVersionsDeps = {} -) { - const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions; - const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion; - const result = await fetchAllVersionsFn(false, backend).catch(() => null); - if (!result) { - return buildVersionsFallback(backend, getInstalledVersionFn); - } - - return { - ...result, - currentVersion: getInstalledVersionFn(backend), - maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, - faultyRange: CLIPROXY_FAULTY_RANGE, - }; -} - -/** - * Extract status code and model from error log file (lightweight parsing). - * Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop. - */ -async function extractErrorLogMetadata( - filePath: string -): Promise<{ statusCode?: number; model?: string }> { - let fh: fs.promises.FileHandle | null = null; - try { - fh = await fs.promises.open(filePath, 'r'); - const stat = await fh.stat(); - const fileSize = stat.size; - - // Read first 4KB for model (in request body) - const startBuffer = Buffer.alloc(Math.min(4096, fileSize)); - await fh.read(startBuffer, 0, startBuffer.length, 0); - const startContent = startBuffer.toString('utf-8'); - - // Extract model from request body JSON: "model":"gemini-3-flash-preview" - const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/); - const model = modelMatch ? modelMatch[1] : undefined; - - // Read last 2KB for status code (in response section at end) - let statusCode: number | undefined; - if (fileSize > 2048) { - const endBuffer = Buffer.alloc(2048); - await fh.read(endBuffer, 0, 2048, fileSize - 2048); - const endContent = endBuffer.toString('utf-8'); - const statusMatch = endContent.match(/Status:\s*(\d{3})/); - statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; - } else { - // Small file - check start content for status - const statusMatch = startContent.match(/Status:\s*(\d{3})/); - statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; - } - - return { statusCode, model }; - } catch { - return {}; - } finally { - await fh?.close(); - } -} - -/** - * Shared handler for stats/usage endpoint - */ -const handleStatsRequest = async (_req: Request, res: Response): Promise => { - try { - // Check if proxy is running first - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ - error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats', - }); - return; - } - - // Fetch stats from management API - const stats = await fetchCliproxyStats(); - if (!stats) { - res.status(503).json({ - error: 'Stats unavailable', - message: 'CLIProxy Plus is running but stats endpoint not responding', - }); - return; - } - - res.json(stats); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}; - -/** - * GET /api/cliproxy/stats - Get CLIProxyAPI usage statistics - * Returns: CliproxyStats or error if proxy not running - */ -router.get('/stats', handleStatsRequest); - -/** - * GET /api/cliproxy/usage - Alias for /stats (frontend compatibility) - */ -router.get('/usage', handleStatsRequest); - -/** - * GET /api/cliproxy/status - Check CLIProxyAPI running status - * Returns: { running: boolean } - */ -router.get('/status', async (_req: Request, res: Response): Promise => { - try { - const running = await isCliproxyRunning(resolveLifecyclePort()); - res.json({ running }); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/proxy-status - Get detailed proxy process status - * Returns: { running, port?, pid?, sessionCount?, startedAt? } - * Combines session tracker data with actual port check for accuracy - */ -router.get('/proxy-status', async (_req: Request, res: Response): Promise => { - try { - const port = resolveLifecyclePort(); - // First check session tracker for detailed info - const sessionStatus = getProxyProcessStatus(port); - - // If session tracker says running, trust it - if (sessionStatus.running) { - res.json(sessionStatus); - return; - } - - // Session tracker says not running, but proxy might be running without session tracking - // (e.g., started before session persistence was implemented) - const actuallyRunning = await isCliproxyRunning(port); - - if (actuallyRunning) { - // Proxy running but no session lock - legacy/untracked instance - res.json({ - running: true, - port, - sessionCount: 0, // Unknown sessions - // No pid/startedAt since we don't have session lock - }); - } else { - res.json(sessionStatus); - } - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * POST /api/cliproxy/proxy-start - Start the CLIProxy service - * Returns: { started, alreadyRunning, port, error? } - * Starts proxy in background if not already running - */ -router.post('/proxy-start', async (_req: Request, res: Response): Promise => { - try { - const result = await ensureCliproxyService(resolveLifecyclePort()); - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * POST /api/cliproxy/proxy-stop - Stop the CLIProxy service - * Returns: { stopped, pid?, sessionCount?, error? } - */ -router.post('/proxy-stop', async (_req: Request, res: Response): Promise => { - try { - const result = await stopProxy(resolveLifecyclePort()); - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/update-check - Check for CLIProxyAPI binary updates - * Returns: { hasUpdate, currentVersion, latestVersion, fromCache } - */ -router.get('/update-check', async (_req: Request, res: Response): Promise => { - try { - const backend = getStoredConfiguredBackend(); - const result = await resolveCliproxyUpdateCheckPayload(backend); - - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/models - Get available models from CLIProxyAPI - * Returns: { models: CliproxyModel[], byCategory: Record, totalCount: number } - */ -router.get('/models', async (_req: Request, res: Response): Promise => { - try { - // Check if proxy is running first - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ - error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models', - }); - return; - } - - // Fetch models from /v1/models endpoint - const modelsResponse = await fetchCliproxyModels(); - if (!modelsResponse) { - res.status(503).json({ - error: 'Models unavailable', - message: 'CLIProxy Plus is running but /v1/models endpoint not responding', - }); - return; - } - - res.json(modelsResponse); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// ==================== Error Logs ==================== - -/** - * GET /api/cliproxy/error-logs - Get list of error log files - * Returns: { files: CliproxyErrorLog[] } or error if proxy not running - */ -router.get('/error-logs', async (_req: Request, res: Response): Promise => { - try { - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ - error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session to view error logs', - }); - return; - } - - const files = await fetchCliproxyErrorLogs(); - if (files === null) { - res.status(503).json({ - error: 'Error logs unavailable', - message: 'CLIProxy Plus is running but error logs endpoint not responding', - }); - return; - } - - // Inject absolute paths and extract metadata from each file - const logsDir = path.join(getCliproxyWritablePath(), 'logs'); - const filesWithMetadata = await Promise.all( - files.map(async (file) => { - const absolutePath = path.join(logsDir, file.name); - const metadata = await extractErrorLogMetadata(absolutePath); - return { - ...file, - absolutePath, - statusCode: metadata.statusCode, - model: metadata.model, - }; - }) - ); - - res.json({ files: filesWithMetadata }); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/error-logs/:name - Get content of a specific error log - * Returns: plain text log content - */ -router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { - const { name } = req.params; - - // Validate filename format and prevent path traversal - if ( - !name || - !name.startsWith('error-') || - !name.endsWith('.log') || - name.includes('..') || - name.includes('/') || - name.includes('\\') - ) { - res.status(400).json({ error: 'Invalid error log filename' }); - return; - } - - try { - const running = await isCliproxyRunning(); - if (!running) { - res.status(503).json({ error: 'CLIProxy Plus not running' }); - return; - } - - const content = await fetchCliproxyErrorLogContent(name); - if (content === null) { - res.status(404).json({ error: 'Error log not found' }); - return; - } - - res.type('text/plain').send(content); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// ==================== Config File ==================== - -/** - * GET /api/cliproxy/config.yaml - Get CLIProxy YAML config content - * Returns: plain text YAML content - */ -router.get('/config.yaml', async (_req: Request, res: Response): Promise => { - try { - const configPath = getCliproxyConfigPath(); - if (!fs.existsSync(configPath)) { - res.status(404).json({ error: 'Config file not found' }); - return; - } - - const content = fs.readFileSync(configPath, 'utf8'); - res.type('text/yaml').send(content); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * PUT /api/cliproxy/config.yaml - Save CLIProxy YAML config content - * Body: { content: string } - * Returns: { success: true, path: string } - */ -router.put('/config.yaml', async (req: Request, res: Response): Promise => { - try { - const { content } = req.body; - - if (typeof content !== 'string') { - res.status(400).json({ error: 'Missing required field: content' }); - return; - } - - const configPath = getCliproxyConfigPath(); - - // Ensure parent directory exists - const configDir = path.dirname(configPath); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } - - // Write atomically - const tempPath = configPath + '.tmp'; - fs.writeFileSync(tempPath, content); - fs.renameSync(tempPath, configPath); - - res.json({ success: true, path: configPath }); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// ==================== Auth Files ==================== - -/** - * GET /api/cliproxy/auth-files - List auth files in auth directory - * Returns: { files: Array<{ name, size, mtime }> } - */ -router.get('/auth-files', async (_req: Request, res: Response): Promise => { - try { - const authDir = getAuthDir(); - - if (!fs.existsSync(authDir)) { - res.json({ files: [] }); - return; - } - - const entries = fs.readdirSync(authDir, { withFileTypes: true }); - const files = entries - .filter((entry) => entry.isFile()) - .map((entry) => { - const filePath = path.join(authDir, entry.name); - const stat = fs.statSync(filePath); - return { - name: entry.name, - size: stat.size, - mtime: stat.mtime.getTime(), - }; - }); - - res.json({ files, directory: authDir }); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/auth-files/download - Download auth file content - * Query: ?name=filename - * Returns: file content as octet-stream - */ -router.get('/auth-files/download', async (req: Request, res: Response): Promise => { - try { - const { name } = req.query; - - if (!name || typeof name !== 'string') { - res.status(400).json({ error: 'Missing required query parameter: name' }); - return; - } - - // Validate filename - prevent path traversal - if (name.includes('..') || name.includes('/') || name.includes('\\')) { - res.status(400).json({ error: 'Invalid filename' }); - return; - } - - const authDir = getAuthDir(); - const filePath = path.join(authDir, name); - - if (!fs.existsSync(filePath)) { - res.status(404).json({ error: 'Auth file not found' }); - return; - } - - const content = fs.readFileSync(filePath); - res.setHeader('Content-Disposition', `attachment; filename="${name}"`); - res.type('application/octet-stream').send(content); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// ==================== Model Updates ==================== - -/** - * PUT /api/cliproxy/models/:provider - Update model for a provider - * Body: { model: string } - * Returns: { success: true, provider, model } - */ -router.put('/models/:provider', async (req: Request, res: Response): Promise => { - try { - const { provider } = req.params; - - // Validate provider name to prevent path traversal via crafted provider param - if (!provider || provider.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(provider)) { - res.status(400).json({ error: 'Invalid provider name' }); - return; - } - - const { model } = req.body; - - if (!model || typeof model !== 'string') { - res.status(400).json({ error: 'Missing required field: model' }); - return; - } - - if (model.length > 256) { - res.status(400).json({ error: 'Model ID exceeds maximum length (256 characters)' }); - return; - } - - const deniedReason = getDeniedModelIdReasonForProvider(model, provider); - if (deniedReason) { - res.status(400).json({ error: deniedReason }); - return; - } - - // Get the settings file for this provider - const ccsDir = getCliproxyWritablePath(); - const settingsPath = path.join(ccsDir, `${provider}.settings.json`); - - if (!fs.existsSync(settingsPath)) { - res.status(404).json({ error: `Settings file not found for provider: ${provider}` }); - return; - } - - // Read and update settings - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { - env?: Record; - [key: string]: unknown; - }; - const canonicalModel = canonicalizeModelIdForProvider(model, provider); - const env = - settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) - ? settings.env - : {}; - - const previousDefault = - typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel; - const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider); - - for (const key of MODEL_ENV_VAR_KEYS) { - if (key === 'ANTHROPIC_MODEL') { - env[key] = canonicalModel; - continue; - } - - const current = env[key]; - if (typeof current !== 'string') { - env[key] = canonicalModel; - continue; - } - - const canonicalCurrent = canonicalizeModelIdForProvider(current, provider); - env[key] = canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent; - } - settings.env = env; - - // Write atomically - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - res.json({ success: true, provider, model: canonicalModel }); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// ==================== Account Quota ==================== -// NOTE: Specific routes MUST be defined BEFORE generic routes for Express routing to work correctly -// NOTE: All quota endpoints use in-memory caching (2 min TTL) to reduce external API calls - -/** - * GET /api/cliproxy/quota/codex/:accountId - Get Codex quota for a specific account - * Returns: CodexQuotaResult with rate limit windows - * Caching: 2 minute TTL to reduce ChatGPT API calls - */ -router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise => { - const { accountId } = req.params; - if (isQuotaRouteRateLimited(req, 'codex')) { - res - .status(429) - .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); - return; - } - - // Validate accountId - prevent path traversal - if ( - !accountId || - accountId.includes('..') || - accountId.includes('/') || - accountId.includes('\\') - ) { - res.status(400).json({ error: 'Invalid account ID' }); - return; - } - - try { - // Check cache first - const cached = getCachedQuota('codex', accountId); - if (cached) { - res.json({ ...cached, cached: true }); - return; - } - - // Fetch from external API - const result = await fetchCodexQuota(accountId); - - // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheQuotaResult(result)) { - setCachedQuota('codex', accountId, result); - } - - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/quota/claude/:accountId - Get Claude quota for a specific account - * Returns: ClaudeQuotaResult with policy windows (5h + weekly) - * Caching: 2 minute TTL to reduce Anthropic API calls - */ -router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise => { - const { accountId } = req.params; - if (isQuotaRouteRateLimited(req, 'claude')) { - res - .status(429) - .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); - return; - } - - // Validate accountId - prevent path traversal - if ( - !accountId || - accountId.includes('..') || - accountId.includes('/') || - accountId.includes('\\') - ) { - res.status(400).json({ error: 'Invalid account ID' }); - return; - } - - try { - // Check cache first - const cached = getCachedQuota('claude', accountId); - if (cached) { - res.json({ ...cached, cached: true }); - return; - } - - // Fetch from external API - const result = await fetchClaudeQuota(accountId); - - // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheQuotaResult(result)) { - setCachedQuota('claude', accountId, result); - } - - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account - * Returns: GeminiCliQuotaResult with quota buckets - * Caching: 2 minute TTL to reduce Google Cloud API calls - */ -router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { - const { accountId } = req.params; - if (isQuotaRouteRateLimited(req, 'gemini')) { - res - .status(429) - .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); - return; - } - - // Validate accountId - prevent path traversal - if ( - !accountId || - accountId.includes('..') || - accountId.includes('/') || - accountId.includes('\\') - ) { - res.status(400).json({ error: 'Invalid account ID' }); - return; - } - - try { - // Check cache first - const cached = getCachedQuota('gemini', accountId); - if (cached) { - res.json({ ...cached, cached: true }); - return; - } - - // Fetch from external API - const result = await fetchGeminiCliQuota(accountId); - - // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheQuotaResult(result)) { - setCachedQuota('gemini', accountId, result); - } - - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/quota/ghcp/:accountId - Get GitHub Copilot (ghcp) quota for a specific account - * Returns: GhcpQuotaResult with premium/chat/completions quota snapshots - * Caching: 2 minute TTL to reduce GitHub API calls - */ -router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise => { - const { accountId } = req.params; - if (isQuotaRouteRateLimited(req, 'ghcp')) { - res - .status(429) - .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); - return; - } - - // Validate accountId - prevent path traversal - if ( - !accountId || - accountId.includes('..') || - accountId.includes('/') || - accountId.includes('\\') - ) { - res.status(400).json({ error: 'Invalid account ID' }); - return; - } - - try { - // Check cache first - const cached = getCachedQuota('ghcp', accountId); - if (cached) { - res.json({ ...cached, cached: true }); - return; - } - - // Fetch from GitHub API - const result = await fetchGhcpQuota(accountId); - - // Cache successful and stable failure states; skip transient network failures. - if (shouldCacheQuotaResult(result)) { - setCachedQuota('ghcp', accountId, result); - } - - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic) - * Returns: QuotaResult with model quotas and reset times - * NOTE: This generic route MUST come after specific routes (codex, claude, gemini, ghcp) - * Caching: 2 minute TTL to reduce external API calls - */ -router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { - const { provider, accountId } = req.params; - if (isQuotaRouteRateLimited(req, provider)) { - res - .status(429) - .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); - return; - } - - // Validate provider - use canonical CLIPROXY_PROFILES - const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; - if (!validProviders.includes(provider as CLIProxyProvider)) { - res.status(400).json({ - error: 'Invalid provider', - message: `Provider must be one of: ${validProviders.join(', ')}`, - }); - return; - } - - // Validate accountId - prevent path traversal - if ( - !accountId || - accountId.includes('..') || - accountId.includes('/') || - accountId.includes('\\') - ) { - res.status(400).json({ error: 'Invalid account ID' }); - return; - } - - try { - // Check cache first - const cached = getCachedQuota(provider, accountId); - if (cached) { - res.json({ ...cached, cached: true }); - return; - } - - // Fetch from external API - const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId); - - // Cache successful results - if (result.success) { - setCachedQuota(provider, accountId, result); - } - - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -// ==================== Version Management ==================== - -/** - * GET /api/cliproxy/versions - Get all available CLIProxyAPI versions - * Returns: { versions, latestStable, latest, currentVersion, maxStableVersion } - */ -router.get('/versions', async (_req: Request, res: Response): Promise => { - try { - const backend = getStoredConfiguredBackend(); - res.json(await resolveCliproxyVersionsPayload(backend)); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * POST /api/cliproxy/install - Install specific CLIProxyAPI version - * Body: { version: string, force?: boolean } - * Returns: { success, restarted?, port?, requiresConfirmation?, message? } - */ -router.post('/install', async (req: Request, res: Response): Promise => { - try { - const { version, force } = req.body; - - if (!version || typeof version !== 'string') { - res.status(400).json({ error: 'Missing required field: version' }); - return; - } - - // Validate version format - if (!/^\d+\.\d+\.\d+(-\d+)?$/.test(version)) { - res.status(400).json({ error: 'Invalid version format. Expected: X.Y.Z or X.Y.Z-N' }); - return; - } - - // Check if version is faulty (v81-85) or experimental (above max stable) - const isFaulty = isVersionFaulty(version); - const isExperimental = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); - - if (isFaulty && !force) { - res.json({ - success: false, - isFaulty, - isExperimental, - requiresConfirmation: true, - message: `Version ${version} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). Set force=true to proceed.`, - }); - return; - } - - if (isExperimental && !force) { - res.json({ - success: false, - isFaulty, - isExperimental, - requiresConfirmation: true, - message: `Version ${version} is experimental (above stable ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}). Set force=true to proceed.`, - }); - return; - } - - const backend = getStoredConfiguredBackend(); - const installResult = await installDashboardCliproxyVersion(version, backend); - - res.json({ - version, - isFaulty, - isExperimental, - ...installResult, - }); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } -}); - -/** - * POST /api/cliproxy/restart - Restart CLIProxy without version change - * Returns: { success, port?, error? } - */ -export function registerCliproxyRestartRoute( - targetRouter: Router, - restartHandler: RestartDashboardCliproxyHandler = restartDashboardCliproxy -): void { - targetRouter.post('/restart', async (_req: Request, res: Response): Promise => { - try { - const result = await restartHandler(); - res.json(result); - } catch (error) { - logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { - err: - error instanceof Error - ? { name: error.name, message: error.message } - : { message: String(error) }, - }); - res.status(500).json({ error: 'Internal server error' }); - } - }); -} - -registerCliproxyRestartRoute(router); - -export default router; +export { default } from './cliproxy-stats-routes/router'; +export { shouldCacheQuotaResult } from './cliproxy-stats-routes/quota-helpers'; +export { registerCliproxyRestartRoute } from './cliproxy-stats-routes/restart-route'; +export { + resolveCliproxyUpdateCheckPayload, + resolveCliproxyVersionsPayload, +} from './cliproxy-stats-routes/version-helpers'; diff --git a/src/web-server/routes/cliproxy-stats-routes/config-routes.ts b/src/web-server/routes/cliproxy-stats-routes/config-routes.ts new file mode 100644 index 00000000..faf3d828 --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/config-routes.ts @@ -0,0 +1,241 @@ +/** + * CLIProxy config + auth-files + model-update routes. + * + * - GET/PUT /config.yaml + * - GET /auth-files, GET /auth-files/download + * - PUT /models/:provider + * + * All require fs + path; extracted from the original god file to keep each + * submodule focused and well under the LOC budget. + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + getCliproxyWritablePath, + getCliproxyConfigPath, + getAuthDir, +} from '../../../cliproxy/config/config-generator'; +import { + MODEL_ENV_VAR_KEYS, + canonicalizeModelIdForProvider, + getDeniedModelIdReasonForProvider, +} from '../../../cliproxy/ai-providers/model-id-normalizer'; +import { logger } from './shared'; + +/** + * Registers config, auth-files, and model-update routes on the given router. + */ +export function registerConfigRoutes(router: Router): void { + // ==================== Config File ==================== + + router.get('/config.yaml', async (_req: Request, res: Response): Promise => { + try { + const configPath = getCliproxyConfigPath(); + if (!fs.existsSync(configPath)) { + res.status(404).json({ error: 'Config file not found' }); + return; + } + + const content = fs.readFileSync(configPath, 'utf8'); + res.type('text/yaml').send(content); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.put('/config.yaml', async (req: Request, res: Response): Promise => { + try { + const { content } = req.body; + + if (typeof content !== 'string') { + res.status(400).json({ error: 'Missing required field: content' }); + return; + } + + const configPath = getCliproxyConfigPath(); + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const tempPath = configPath + '.tmp'; + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, configPath); + + res.json({ success: true, path: configPath }); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + // ==================== Auth Files ==================== + + router.get('/auth-files', async (_req: Request, res: Response): Promise => { + try { + const authDir = getAuthDir(); + + if (!fs.existsSync(authDir)) { + res.json({ files: [] }); + return; + } + + const entries = fs.readdirSync(authDir, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => { + const filePath = path.join(authDir, entry.name); + const stat = fs.statSync(filePath); + return { + name: entry.name, + size: stat.size, + mtime: stat.mtime.getTime(), + }; + }); + + res.json({ files, directory: authDir }); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.get('/auth-files/download', async (req: Request, res: Response): Promise => { + try { + const { name } = req.query; + + if (!name || typeof name !== 'string') { + res.status(400).json({ error: 'Missing required query parameter: name' }); + return; + } + + if (name.includes('..') || name.includes('/') || name.includes('\\')) { + res.status(400).json({ error: 'Invalid filename' }); + return; + } + + const authDir = getAuthDir(); + const filePath = path.join(authDir, name); + + if (!fs.existsSync(filePath)) { + res.status(404).json({ error: 'Auth file not found' }); + return; + } + + const content = fs.readFileSync(filePath); + res.setHeader('Content-Disposition', `attachment; filename="${name}"`); + res.type('application/octet-stream').send(content); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + // ==================== Model Updates ==================== + + router.put('/models/:provider', async (req: Request, res: Response): Promise => { + try { + const { provider } = req.params; + + if (!provider || provider.length > 64 || !/^[a-zA-Z0-9_-]+$/.test(provider)) { + res.status(400).json({ error: 'Invalid provider name' }); + return; + } + + const { model } = req.body; + + if (!model || typeof model !== 'string') { + res.status(400).json({ error: 'Missing required field: model' }); + return; + } + + if (model.length > 256) { + res.status(400).json({ error: 'Model ID exceeds maximum length (256 characters)' }); + return; + } + + const deniedReason = getDeniedModelIdReasonForProvider(model, provider); + if (deniedReason) { + res.status(400).json({ error: deniedReason }); + return; + } + + const ccsDir = getCliproxyWritablePath(); + const settingsPath = path.join(ccsDir, `${provider}.settings.json`); + + if (!fs.existsSync(settingsPath)) { + res.status(404).json({ error: `Settings file not found for provider: ${provider}` }); + return; + } + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env?: Record; + [key: string]: unknown; + }; + const canonicalModel = canonicalizeModelIdForProvider(model, provider); + const env = + settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) + ? settings.env + : {}; + + const previousDefault = + typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel; + const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider); + + for (const key of MODEL_ENV_VAR_KEYS) { + if (key === 'ANTHROPIC_MODEL') { + env[key] = canonicalModel; + continue; + } + + const current = env[key]; + if (typeof current !== 'string') { + env[key] = canonicalModel; + continue; + } + + const canonicalCurrent = canonicalizeModelIdForProvider(current, provider); + env[key] = + canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent; + } + settings.env = env; + + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + res.json({ success: true, provider, model: canonicalModel }); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); +} diff --git a/src/web-server/routes/cliproxy-stats-routes/error-log-routes.ts b/src/web-server/routes/cliproxy-stats-routes/error-log-routes.ts new file mode 100644 index 00000000..b870bc15 --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/error-log-routes.ts @@ -0,0 +1,148 @@ +/** + * CLIProxy error-log routes. + * + * - GET /error-logs (list + metadata extraction) + * - GET /error-logs/:name (single log content; path-traversal validated) + */ + +import { Router, Request, Response } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + isCliproxyRunning, + fetchCliproxyErrorLogs, + fetchCliproxyErrorLogContent, +} from '../../../cliproxy/services/stats-fetcher'; +import { getCliproxyWritablePath } from '../../../cliproxy/config/config-generator'; +import { logger } from './shared'; + +/** + * Extract status code and model from error log file (lightweight parsing). + * Reads first 4KB for model, last 2KB for status code. Async to avoid blocking event loop. + */ +async function extractErrorLogMetadata( + filePath: string +): Promise<{ statusCode?: number; model?: string }> { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(filePath, 'r'); + const stat = await fh.stat(); + const fileSize = stat.size; + + const startBuffer = Buffer.alloc(Math.min(4096, fileSize)); + await fh.read(startBuffer, 0, startBuffer.length, 0); + const startContent = startBuffer.toString('utf-8'); + + const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/); + const model = modelMatch ? modelMatch[1] : undefined; + + let statusCode: number | undefined; + if (fileSize > 2048) { + const endBuffer = Buffer.alloc(2048); + await fh.read(endBuffer, 0, 2048, fileSize - 2048); + const endContent = endBuffer.toString('utf-8'); + const statusMatch = endContent.match(/Status:\s*(\d{3})/); + statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; + } else { + const statusMatch = startContent.match(/Status:\s*(\d{3})/); + statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; + } + + return { statusCode, model }; + } catch { + return {}; + } finally { + await fh?.close(); + } +} + +/** + * Registers error-log routes on the given router. + */ +export function registerErrorLogRoutes(router: Router): void { + router.get('/error-logs', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxy Plus not running', + message: 'Start a CLIProxy session to view error logs', + }); + return; + } + + const files = await fetchCliproxyErrorLogs(); + if (files === null) { + res.status(503).json({ + error: 'Error logs unavailable', + message: 'CLIProxy Plus is running but error logs endpoint not responding', + }); + return; + } + + const logsDir = path.join(getCliproxyWritablePath(), 'logs'); + const filesWithMetadata = await Promise.all( + files.map(async (file) => { + const absolutePath = path.join(logsDir, file.name); + const metadata = await extractErrorLogMetadata(absolutePath); + return { + ...file, + absolutePath, + statusCode: metadata.statusCode, + model: metadata.model, + }; + }) + ); + + res.json({ files: filesWithMetadata }); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.get('/error-logs/:name', async (req: Request, res: Response): Promise => { + const { name } = req.params; + + if ( + !name || + !name.startsWith('error-') || + !name.endsWith('.log') || + name.includes('..') || + name.includes('/') || + name.includes('\\') + ) { + res.status(400).json({ error: 'Invalid error log filename' }); + return; + } + + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ error: 'CLIProxy Plus not running' }); + return; + } + + const content = await fetchCliproxyErrorLogContent(name); + if (content === null) { + res.status(404).json({ error: 'Error log not found' }); + return; + } + + res.type('text/plain').send(content); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); +} diff --git a/src/web-server/routes/cliproxy-stats-routes/quota-helpers.ts b/src/web-server/routes/cliproxy-stats-routes/quota-helpers.ts new file mode 100644 index 00000000..83300aab --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/quota-helpers.ts @@ -0,0 +1,36 @@ +/** + * Quota caching helper. Determines whether a quota fetch result should be + * written to the shared in-memory cache. + * + * Public surface: `shouldCacheQuotaResult` is re-exported by the barrel. + */ + +/** + * Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx). + * Generic across all quota result types. + */ +export function shouldCacheQuotaResult(result: { + success: boolean; + needsReauth?: boolean; + isForbidden?: boolean; + httpStatus?: number; + retryable?: boolean; + error?: string; +}): boolean { + if (result.success) return true; + if (result.needsReauth || result.isForbidden) return true; + if (result.retryable === true) return false; + if (result.retryable === false) return true; + if (typeof result.httpStatus === 'number') { + if (result.httpStatus === 429 || result.httpStatus === 408 || result.httpStatus >= 500) { + return false; + } + if (result.httpStatus >= 400 && result.httpStatus < 500) { + return true; + } + } + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed']; + return !transientPatterns.some((p) => msg.includes(p)); +} diff --git a/src/web-server/routes/cliproxy-stats-routes/quota-routes.ts b/src/web-server/routes/cliproxy-stats-routes/quota-routes.ts new file mode 100644 index 00000000..3e45275e --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/quota-routes.ts @@ -0,0 +1,231 @@ +/** + * CLIProxy account-quota routes: codex, claude, gemini, ghcp, and the generic + * `/:provider/:accountId` fallback. + * + * Specific routes MUST be registered before the generic one for Express + * routing to match correctly. + */ + +import { Router, Request, Response } from 'express'; +import { fetchAccountQuota } from '../../../cliproxy/quota/quota-fetcher'; +import { fetchCodexQuota } from '../../../cliproxy/quota/quota-fetcher-codex'; +import { fetchClaudeQuota } from '../../../cliproxy/quota/quota-fetcher-claude'; +import { fetchGeminiCliQuota } from '../../../cliproxy/quota/quota-fetcher-gemini-cli'; +import { fetchGhcpQuota } from '../../../cliproxy/quota/quota-fetcher-ghcp'; +import { getCachedQuota, setCachedQuota } from '../../../cliproxy/quota/quota-response-cache'; +import type { + CodexQuotaResult, + ClaudeQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +} from '../../../cliproxy/quota/quota-types'; +import type { QuotaResult } from '../../../cliproxy/quota/quota-fetcher'; +import type { CLIProxyProvider } from '../../../cliproxy/types'; +import { CLIPROXY_PROFILES } from '../../../auth/profile-detector'; +import { logger, isQuotaRouteRateLimited } from './shared'; +import { shouldCacheQuotaResult } from './quota-helpers'; + +function replyRateLimited(res: Response): void { + res.status(429).json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); +} + +function isInvalidAccountId(accountId: string | undefined): boolean { + return ( + !accountId || accountId.includes('..') || accountId.includes('/') || accountId.includes('\\') + ); +} + +/** + * Registers all `/quota/*` routes on the given router. + */ +export function registerQuotaRoutes(router: Router): void { + router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'codex')) { + replyRateLimited(res); + return; + } + if (isInvalidAccountId(accountId)) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const cached = getCachedQuota('codex', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + const result = await fetchCodexQuota(accountId); + + if (shouldCacheQuotaResult(result)) { + setCachedQuota('codex', accountId, result); + } + + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'claude')) { + replyRateLimited(res); + return; + } + if (isInvalidAccountId(accountId)) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const cached = getCachedQuota('claude', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + const result = await fetchClaudeQuota(accountId); + + if (shouldCacheQuotaResult(result)) { + setCachedQuota('claude', accountId, result); + } + + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'gemini')) { + replyRateLimited(res); + return; + } + if (isInvalidAccountId(accountId)) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const cached = getCachedQuota('gemini', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + const result = await fetchGeminiCliQuota(accountId); + + if (shouldCacheQuotaResult(result)) { + setCachedQuota('gemini', accountId, result); + } + + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'ghcp')) { + replyRateLimited(res); + return; + } + if (isInvalidAccountId(accountId)) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const cached = getCachedQuota('ghcp', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + const result = await fetchGhcpQuota(accountId); + + if (shouldCacheQuotaResult(result)) { + setCachedQuota('ghcp', accountId, result); + } + + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); + + router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { + const { provider, accountId } = req.params; + if (isQuotaRouteRateLimited(req, provider)) { + replyRateLimited(res); + return; + } + + const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ + error: 'Invalid provider', + message: `Provider must be one of: ${validProviders.join(', ')}`, + }); + return; + } + + if (isInvalidAccountId(accountId)) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const cached = getCachedQuota(provider, accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId); + + if (result.success) { + setCachedQuota(provider, accountId, result); + } + + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); +} diff --git a/src/web-server/routes/cliproxy-stats-routes/restart-route.ts b/src/web-server/routes/cliproxy-stats-routes/restart-route.ts new file mode 100644 index 00000000..790299c7 --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/restart-route.ts @@ -0,0 +1,37 @@ +/** + * POST /api/cliproxy/restart route registration. + * + * Public surface: `registerCliproxyRestartRoute` is re-exported by the barrel. + */ + +import { Router, Request, Response } from 'express'; +import { restartDashboardCliproxy } from '../../services/cliproxy-dashboard-restart-service'; +import { logger } from './shared'; + +type RestartDashboardCliproxyHandler = typeof restartDashboardCliproxy; + +/** + * Registers POST `/restart` on the given router. + * + * Handler shape and logger.error('stats.route.error', ...) conversion are + * preserved verbatim from the pre-split implementation. + */ +export function registerCliproxyRestartRoute( + targetRouter: Router, + restartHandler: RestartDashboardCliproxyHandler = restartDashboardCliproxy +): void { + targetRouter.post('/restart', async (_req: Request, res: Response): Promise => { + try { + const result = await restartHandler(); + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } + }); +} diff --git a/src/web-server/routes/cliproxy-stats-routes/router.ts b/src/web-server/routes/cliproxy-stats-routes/router.ts new file mode 100644 index 00000000..27540f3d --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/router.ts @@ -0,0 +1,290 @@ +/** + * CLIProxy stats router: stats, status, proxy lifecycle, models, versions, and + * install endpoints. Delegates quota, error-log, config/auth-files/model-update, + * and restart routes to focused submodules. + * + * Default export is the Express Router. Re-exported as the barrel default. + */ + +import { Router, Request, Response } from 'express'; +import { + fetchCliproxyStats, + fetchCliproxyModels, + isCliproxyRunning, +} from '../../../cliproxy/services/stats-fetcher'; +import { + getProxyStatus as getProxyProcessStatus, + stopProxy, +} from '../../../cliproxy/session-tracker'; +import { ensureCliproxyService } from '../../../cliproxy/service-manager'; +import { getStoredConfiguredBackend } from '../../../cliproxy/binary-manager'; +import { isNewerVersion, isVersionFaulty } from '../../../cliproxy/binary/version-checker'; +import { + CLIPROXY_MAX_STABLE_VERSION, + CLIPROXY_FAULTY_RANGE, +} from '../../../cliproxy/binary/platform-detector'; +import { resolveLifecyclePort } from '../../../cliproxy/config/port-manager'; +import { installDashboardCliproxyVersion } from '../../services/cliproxy-dashboard-install-service'; +import { requireLocalAccessWhenAuthDisabled } from '../../middleware/auth-middleware'; +import { logger } from './shared'; +import { + resolveCliproxyUpdateCheckPayload, + resolveCliproxyVersionsPayload, +} from './version-helpers'; +import { registerQuotaRoutes } from './quota-routes'; +import { registerErrorLogRoutes } from './error-log-routes'; +import { registerConfigRoutes } from './config-routes'; +import { registerCliproxyRestartRoute } from './restart-route'; + +const router = Router(); + +router.use((req: Request, res: Response, next) => { + if ( + requireLocalAccessWhenAuthDisabled( + req, + res, + 'CLIProxy management endpoints require localhost access when dashboard auth is disabled.' + ) + ) { + next(); + } +}); + +/** + * Shared handler for /stats and /usage endpoints. + */ +const handleStatsRequest = async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxy Plus not running', + message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats', + }); + return; + } + + const stats = await fetchCliproxyStats(); + if (!stats) { + res.status(503).json({ + error: 'Stats unavailable', + message: 'CLIProxy Plus is running but stats endpoint not responding', + }); + return; + } + + res.json(stats); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +router.get('/stats', handleStatsRequest); +router.get('/usage', handleStatsRequest); + +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(resolveLifecyclePort()); + res.json({ running }); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/proxy-status', async (_req: Request, res: Response): Promise => { + try { + const port = resolveLifecyclePort(); + const sessionStatus = getProxyProcessStatus(port); + + if (sessionStatus.running) { + res.json(sessionStatus); + return; + } + + const actuallyRunning = await isCliproxyRunning(port); + + if (actuallyRunning) { + res.json({ running: true, port, sessionCount: 0 }); + } else { + res.json(sessionStatus); + } + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post('/proxy-start', async (_req: Request, res: Response): Promise => { + try { + const result = await ensureCliproxyService(resolveLifecyclePort()); + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post('/proxy-stop', async (_req: Request, res: Response): Promise => { + try { + const result = await stopProxy(resolveLifecyclePort()); + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/update-check', async (_req: Request, res: Response): Promise => { + try { + const backend = getStoredConfiguredBackend(); + const result = await resolveCliproxyUpdateCheckPayload(backend); + res.json(result); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/models', async (_req: Request, res: Response): Promise => { + try { + const running = await isCliproxyRunning(); + if (!running) { + res.status(503).json({ + error: 'CLIProxy Plus not running', + message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models', + }); + return; + } + + const modelsResponse = await fetchCliproxyModels(); + if (!modelsResponse) { + res.status(503).json({ + error: 'Models unavailable', + message: 'CLIProxy Plus is running but /v1/models endpoint not responding', + }); + return; + } + + res.json(modelsResponse); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delegated route groups. Registration order is preserved from the pre-split +// god file: error-logs, config/auth-files/model-update, quota, versions, +// install, restart. +registerErrorLogRoutes(router); +registerConfigRoutes(router); +registerQuotaRoutes(router); + +router.get('/versions', async (_req: Request, res: Response): Promise => { + try { + const backend = getStoredConfiguredBackend(); + res.json(await resolveCliproxyVersionsPayload(backend)); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.post('/install', async (req: Request, res: Response): Promise => { + try { + const { version, force } = req.body; + + if (!version || typeof version !== 'string') { + res.status(400).json({ error: 'Missing required field: version' }); + return; + } + + if (!/^\d+\.\d+\.\d+(-\d+)?$/.test(version)) { + res.status(400).json({ error: 'Invalid version format. Expected: X.Y.Z or X.Y.Z-N' }); + return; + } + + const isFaulty = isVersionFaulty(version); + const isExperimental = isNewerVersion(version, CLIPROXY_MAX_STABLE_VERSION); + + if (isFaulty && !force) { + res.json({ + success: false, + isFaulty, + isExperimental, + requiresConfirmation: true, + message: `Version ${version} has known bugs (v${CLIPROXY_FAULTY_RANGE.min.replace(/-\d+$/, '')}-${CLIPROXY_FAULTY_RANGE.max.replace(/-\d+$/, '')}). Set force=true to proceed.`, + }); + return; + } + + if (isExperimental && !force) { + res.json({ + success: false, + isFaulty, + isExperimental, + requiresConfirmation: true, + message: `Version ${version} is experimental (above stable ${CLIPROXY_MAX_STABLE_VERSION.replace(/-\d+$/, '')}). Set force=true to proceed.`, + }); + return; + } + + const backend = getStoredConfiguredBackend(); + const installResult = await installDashboardCliproxyVersion(version, backend); + + res.json({ version, isFaulty, isExperimental, ...installResult }); + } catch (error) { + logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', { + err: + error instanceof Error + ? { name: error.name, message: error.message } + : { message: String(error) }, + }); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +registerCliproxyRestartRoute(router); + +export default router; diff --git a/src/web-server/routes/cliproxy-stats-routes/shared.ts b/src/web-server/routes/cliproxy-stats-routes/shared.ts new file mode 100644 index 00000000..22da5f49 --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/shared.ts @@ -0,0 +1,70 @@ +/** + * Shared internals for cliproxy-stats-routes: logger and rate-limit helpers. + * + * These are NOT part of the public barrel surface; they exist so the sibling + * submodules (quota helpers, version helpers, route handlers) can share the + * same logger instance and rate-limit state without re-declaring them. + */ + +import type { Request } from 'express'; +import { createLogger } from '../../../services/logging'; + +/** + * Shared logger for every cliproxy-stats submodule. Preserves the + * `web-server:routes:cliproxy-stats` channel used by P3 error conversions + * (`logger.error('stats.route.error', ...)`). + */ +export const logger = createLogger('web-server:routes:cliproxy-stats'); + +// ==================== Quota Rate Limiting ==================== + +export const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000; +export const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120; + +export interface QuotaRateLimitEntry { + windowStart: number; + count: number; +} + +/** + * In-memory rate limit state. Shared across all quota route handlers so that + * the same IP+provider key is throttled regardless of which handler runs. + */ +export const quotaRateLimits = new Map(); + +/** + * Build the rate-limit key for a request + provider pair. + */ +export function buildQuotaRateLimitKey(req: Request, provider: string): string { + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + return `${clientIp}:${provider}`; +} + +/** + * Returns true when the caller should be rejected with 429. + * Evicts stale entries to prevent unbounded memory growth. + */ +export function isQuotaRouteRateLimited(req: Request, provider: string): boolean { + const key = buildQuotaRateLimitKey(req, provider); + const now = Date.now(); + + // Evict stale entries to prevent unbounded memory growth + if (quotaRateLimits.size > 1000) { + for (const [k, v] of quotaRateLimits) { + if (now - v.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS * 2) { + quotaRateLimits.delete(k); + } + } + } + + const current = quotaRateLimits.get(key); + + if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) { + quotaRateLimits.set(key, { windowStart: now, count: 1 }); + return false; + } + + current.count += 1; + quotaRateLimits.set(key, current); + return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS; +} diff --git a/src/web-server/routes/cliproxy-stats-routes/version-helpers.ts b/src/web-server/routes/cliproxy-stats-routes/version-helpers.ts new file mode 100644 index 00000000..8e5f71e3 --- /dev/null +++ b/src/web-server/routes/cliproxy-stats-routes/version-helpers.ts @@ -0,0 +1,102 @@ +/** + * CLIProxy version-check + versions-list payload resolvers. + * + * Public surface: `resolveCliproxyUpdateCheckPayload` and + * `resolveCliproxyVersionsPayload` are re-exported by the barrel. + */ + +import { + checkCliproxyUpdate, + getInstalledCliproxyVersion, + getStoredConfiguredBackend, +} from '../../../cliproxy/binary-manager'; +import { fetchAllVersions, isNewerVersion } from '../../../cliproxy/binary/version-checker'; +import { + CLIPROXY_MAX_STABLE_VERSION, + CLIPROXY_FAULTY_RANGE, +} from '../../../cliproxy/binary/platform-detector'; + +type Backend = ReturnType; + +function buildUpdateCheckFallback( + backend: Backend, + getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion +) { + const currentVersion = getInstalledVersionFn(backend); + const isStable = !isNewerVersion(currentVersion, CLIPROXY_MAX_STABLE_VERSION); + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + + return { + hasUpdate: false, + currentVersion, + latestVersion: currentVersion, + fromCache: true, + checkedAt: Date.now(), + backend, + backendLabel, + isStable, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + stabilityMessage: isStable + ? undefined + : `v${currentVersion} has known stability issues. Max stable: v${CLIPROXY_MAX_STABLE_VERSION}`, + }; +} + +function buildVersionsFallback( + backend: Backend, + getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion +) { + const currentVersion = getInstalledVersionFn(backend); + + return { + versions: currentVersion ? [currentVersion] : [], + latestStable: currentVersion || CLIPROXY_MAX_STABLE_VERSION, + latest: currentVersion || CLIPROXY_MAX_STABLE_VERSION, + fromCache: true, + checkedAt: Date.now(), + currentVersion, + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + faultyRange: CLIPROXY_FAULTY_RANGE, + }; +} + +export interface ResolveUpdateCheckDeps { + checkCliproxyUpdateFn?: typeof checkCliproxyUpdate; + getInstalledVersionFn?: typeof getInstalledCliproxyVersion; +} + +export interface ResolveVersionsDeps { + fetchAllVersionsFn?: typeof fetchAllVersions; + getInstalledVersionFn?: typeof getInstalledCliproxyVersion; +} + +export async function resolveCliproxyUpdateCheckPayload( + backend: Backend, + deps: ResolveUpdateCheckDeps = {} +) { + const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate; + const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion; + + return checkCliproxyUpdateFn(backend).catch(() => + buildUpdateCheckFallback(backend, getInstalledVersionFn) + ); +} + +export async function resolveCliproxyVersionsPayload( + backend: Backend, + deps: ResolveVersionsDeps = {} +) { + const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions; + const getInstalledVersionFn = deps.getInstalledVersionFn ?? getInstalledCliproxyVersion; + const result = await fetchAllVersionsFn(false, backend).catch(() => null); + if (!result) { + return buildVersionsFallback(backend, getInstalledVersionFn); + } + + return { + ...result, + currentVersion: getInstalledVersionFn(backend), + maxStableVersion: CLIPROXY_MAX_STABLE_VERSION, + faultyRange: CLIPROXY_FAULTY_RANGE, + }; +}