|
| 1 | +// cc-switch config import library (pure functions + read-only SQLite). |
| 2 | +// |
| 3 | +// cc-switch is a Tauri desktop app that stores AI provider credentials in the |
| 4 | +// providers table of a SQLite database (cc-switch.db). The settings_config column |
| 5 | +// is a JSON string containing env.ANTHROPIC_BASE_URL / env.ANTHROPIC_AUTH_TOKEN / |
| 6 | +// env.ANTHROPIC_MODEL, etc. |
| 7 | +// |
| 8 | +// This module locates the cc-switch data directory cross-platform (mac/linux/windows), |
| 9 | +// opens the db read-only, and maps providers with app_type='claude' into cc-viewer's |
| 10 | +// profile format. It performs no writes and never touches profile.json — persistence |
| 11 | +// is delegated to the caller (preferences.js POST /api/ccswitch-import). |
| 12 | +// |
| 13 | +// Design notes: |
| 14 | +// - Open SQLite read-only (readOnly:true) to avoid SQLITE_BUSY locks while cc-switch is running |
| 15 | +// - Multi-path probing: each platform tries the standard Tauri dir first, then falls back to ~/.cc-switch/ |
| 16 | +// - settings_config parsing is fully fault-tolerant: null / non-JSON / missing env are all skipped, never throws |
| 17 | +// - Profile ids get a ccs_ prefix to distinguish from user-created proxy_ prefixed ones; updates are idempotent (re-imports update, never duplicate) |
| 18 | +// - Only app_type='claude' is imported; codex is skipped (different format, cc-viewer does not support OpenAI auth) |
| 19 | +// - Never sets active automatically: import only populates the list; switching is decided by the caller/UI (avoids clobbering the user's current selection) |
| 20 | + |
| 21 | +import { existsSync } from 'node:fs'; |
| 22 | +import { join } from 'node:path'; |
| 23 | +import { homedir, platform } from 'node:os'; |
| 24 | + |
| 25 | +// Dynamically import node:sqlite — built into Node 22.5+ (22.5 needs --experimental-sqlite flag, |
| 26 | +// stable in 23+ / 26). Dynamic import is used so that a missing module on older Node does not |
| 27 | +// bring down the entire interceptor chain. |
| 28 | +let _DatabaseSync = null; |
| 29 | +async function getDatabaseSync() { |
| 30 | + if (_DatabaseSync !== null) return _DatabaseSync; |
| 31 | + try { |
| 32 | + const m = await import('node:sqlite'); |
| 33 | + _DatabaseSync = m.DatabaseSync || null; |
| 34 | + } catch { |
| 35 | + _DatabaseSync = null; |
| 36 | + } |
| 37 | + return _DatabaseSync; |
| 38 | +} |
| 39 | + |
| 40 | +// Cross-platform candidate paths (ordered by priority; the first existsSync hit wins). |
| 41 | +// cc-switch hardcodes ~/.cc-switch/cc-switch.db on ALL platforms (get_app_config_dir() in its |
| 42 | +// config.rs; the Tauri identifier com.ccswitch.desktop does NOT affect the DB path). So that |
| 43 | +// path is probed first. The platform-specific Tauri app-data dirs below are kept only as |
| 44 | +// legacy fallbacks in case some very old cc-switch version wrote there — never first — so a |
| 45 | +// stale/empty leftover file in them cannot shadow the real ~/.cc-switch/cc-switch.db. |
| 46 | +function candidateDbPaths(opts) { |
| 47 | + // opts is for testing only (inject platform/home/env without touching the runtime); |
| 48 | + // production callers omit it and use the real homedir()/platform()/process.env. |
| 49 | + const home = opts && opts.home != null ? opts.home : homedir(); |
| 50 | + const plat = opts && opts.plat != null ? opts.plat : platform(); |
| 51 | + const env = opts && opts.env ? opts.env : process.env; |
| 52 | + const paths = [join(home, '.cc-switch', 'cc-switch.db')]; // primary on every platform |
| 53 | + if (plat === 'darwin') { |
| 54 | + paths.push(join(home, 'Library', 'Application Support', 'cc-switch', 'cc-switch.db')); |
| 55 | + paths.push(join(home, 'Library', 'Application Support', 'com.ccswitch.desktop', 'cc-switch.db')); |
| 56 | + } else if (plat === 'win32') { |
| 57 | + const appdata = env.APPDATA || join(home, 'AppData', 'Roaming'); |
| 58 | + const localappdata = env.LOCALAPPDATA || join(home, 'AppData', 'Local'); |
| 59 | + paths.push(join(appdata, 'cc-switch', 'cc-switch.db')); |
| 60 | + paths.push(join(localappdata, 'cc-switch', 'cc-switch.db')); |
| 61 | + } else { |
| 62 | + // linux and other unix |
| 63 | + const xdg = env.XDG_DATA_HOME; |
| 64 | + if (xdg) paths.push(join(xdg, 'cc-switch', 'cc-switch.db')); |
| 65 | + paths.push(join(home, '.local', 'share', 'cc-switch', 'cc-switch.db')); |
| 66 | + } |
| 67 | + return paths; |
| 68 | +} |
| 69 | +// Exported for unit tests so the win32/darwin priority ordering can be exercised on any host. |
| 70 | +export { candidateDbPaths as _candidateDbPathsForTest }; |
| 71 | + |
| 72 | +// Test hook: override the cached DatabaseSync — pass false to simulate a runtime |
| 73 | +// without node:sqlite (the getter returns the cached falsy value and the callers |
| 74 | +// take the unavailable path), or null to restore lazy detection. |
| 75 | +export function _setDatabaseSyncForTest(v) { _DatabaseSync = v; } |
| 76 | + |
| 77 | +// Returns the first existing cc-switch.db path, or null if none found. |
| 78 | +export function findCcSwitchDbPath() { |
| 79 | + for (const p of candidateDbPaths()) { |
| 80 | + try { |
| 81 | + if (existsSync(p)) return p; |
| 82 | + } catch { /* ignore */ } |
| 83 | + } |
| 84 | + return null; |
| 85 | +} |
| 86 | + |
| 87 | +// Map of model fields in settings_config.env → cc-viewer profile fields. |
| 88 | +// Note: cc-switch has variants suffixed with _NAME (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL_NAME); |
| 89 | +// cc-viewer profiles only use the standard field names without _NAME, so we take only the |
| 90 | +// standard names and ignore the _NAME variants. |
| 91 | +const ENV_FIELD_MAP = { |
| 92 | + ANTHROPIC_BASE_URL: 'baseURL', |
| 93 | + ANTHROPIC_AUTH_TOKEN: 'apiKey', |
| 94 | + ANTHROPIC_API_KEY: 'apiKey', // rare, but kept for compatibility |
| 95 | + ANTHROPIC_MODEL: 'ANTHROPIC_MODEL', |
| 96 | + ANTHROPIC_DEFAULT_OPUS_MODEL: 'ANTHROPIC_DEFAULT_OPUS_MODEL', |
| 97 | + ANTHROPIC_DEFAULT_SONNET_MODEL: 'ANTHROPIC_DEFAULT_SONNET_MODEL', |
| 98 | + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'ANTHROPIC_DEFAULT_HAIKU_MODEL', |
| 99 | + // cc-switch's "通用配置" effort toggle writes CLAUDE_CODE_EFFORT_LEVEL (e.g. "max"). |
| 100 | + // cc-viewer's interceptor injects profile.effort as output_config.effort, so mapping |
| 101 | + // this env var preserves the user's effort setting across import (was previously dropped). |
| 102 | + CLAUDE_CODE_EFFORT_LEVEL: 'effort', |
| 103 | +}; |
| 104 | + |
| 105 | +// Map a cc-switch providers row into a cc-viewer profile object. |
| 106 | +// settings_config is a JSON string stored in a TEXT column. Returns null when the row is unusable (no credentials / no baseURL). |
| 107 | +export function mapProviderToProfile(row) { |
| 108 | + if (!row || !row.id || !row.name) return null; |
| 109 | + if (row.app_type && row.app_type !== 'claude') return null; // only import claude |
| 110 | + let cfg = null; |
| 111 | + try { |
| 112 | + cfg = typeof row.settings_config === 'string' |
| 113 | + ? JSON.parse(row.settings_config) |
| 114 | + : row.settings_config; |
| 115 | + } catch { return null; } |
| 116 | + if (!cfg || typeof cfg !== 'object') return null; |
| 117 | + const env = cfg.env; |
| 118 | + if (!env || typeof env !== 'object') return null; |
| 119 | + |
| 120 | + const profile = { |
| 121 | + id: `ccs_${row.id}`, |
| 122 | + name: String(row.name), |
| 123 | + baseURL: '', |
| 124 | + apiKey: '', |
| 125 | + effort: '', // CLAUDE_CODE_EFFORT_LEVEL → output_config.effort (injected by interceptor) |
| 126 | + ANTHROPIC_MODEL: '', |
| 127 | + ANTHROPIC_DEFAULT_OPUS_MODEL: '', |
| 128 | + ANTHROPIC_DEFAULT_SONNET_MODEL: '', |
| 129 | + ANTHROPIC_DEFAULT_HAIKU_MODEL: '', |
| 130 | + source: 'cc-switch', // source marker; UI may show an "imported" badge from this |
| 131 | + }; |
| 132 | + |
| 133 | + let hasCredential = false; |
| 134 | + for (const [envKey, profileKey] of Object.entries(ENV_FIELD_MAP)) { |
| 135 | + if (typeof env[envKey] === 'string' && env[envKey]) { |
| 136 | + // Prefer whichever credential appears first (ANTHROPIC_AUTH_TOKEN is ordered before ANTHROPIC_API_KEY; |
| 137 | + // if both exist and apiKey was already set by the former, do not overwrite it) |
| 138 | + if (profileKey === 'apiKey' && hasCredential) continue; |
| 139 | + profile[profileKey] = env[envKey]; |
| 140 | + if (profileKey === 'apiKey') hasCredential = true; |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + // A valid credential requires both baseURL and apiKey (rows with only non-credential config, e.g. Claude Official, are skipped) |
| 145 | + if (!profile.baseURL || !profile.apiKey) return null; |
| 146 | + return profile; |
| 147 | +} |
| 148 | + |
| 149 | +// Read claude providers from cc-switch.db and return a cc-viewer profile array. |
| 150 | +// Opened read-only with full fault tolerance: db cannot open / table missing / no rows → empty array + error message. |
| 151 | +export async function readCcSwitchProviders(dbPath) { |
| 152 | + const DatabaseSync = await getDatabaseSync(); |
| 153 | + if (!DatabaseSync) { |
| 154 | + // Keep the 'node:sqlite unavailable' prefix stable — the UI keys a dedicated |
| 155 | + // localized message off it (ui.proxy.ccswitchNodeUnsupported). |
| 156 | + return { profiles: [], error: 'node:sqlite unavailable on this runtime (requires Node >= 22.5 with --experimental-sqlite, or >= 23.4)' }; |
| 157 | + } |
| 158 | + let db = null; |
| 159 | + try { |
| 160 | + // Read-only: cc-switch stays unaffected by our reads (no SQLITE_BUSY) |
| 161 | + db = new DatabaseSync(dbPath, { readOnly: true }); |
| 162 | + } catch (err) { |
| 163 | + return { profiles: [], error: `cannot open db: ${err && err.message}` }; |
| 164 | + } |
| 165 | + try { |
| 166 | + // providers table existence check. A query that throws here means the file is |
| 167 | + // unreadable as a SQLite db (corrupt / non-SQLite / truncated) — the real cause |
| 168 | + // must surface, not be masked as "table not found". Let it propagate to the |
| 169 | + // outer catch, which formats it as `query failed: <message>`. |
| 170 | + const r = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='providers'").get(); |
| 171 | + if (!r) return { profiles: [], error: `providers table not found in ${dbPath}` }; |
| 172 | + |
| 173 | + const rows = db.prepare( |
| 174 | + "SELECT id, app_type, name, settings_config, is_current FROM providers WHERE app_type = 'claude' ORDER BY sort_index, name" |
| 175 | + ).all(); |
| 176 | + |
| 177 | + const profiles = []; |
| 178 | + let currentId = null; |
| 179 | + for (const row of rows) { |
| 180 | + const p = mapProviderToProfile(row); |
| 181 | + if (p) { |
| 182 | + profiles.push(p); |
| 183 | + if (row.is_current) currentId = p.id; |
| 184 | + } |
| 185 | + } |
| 186 | + return { profiles, currentId, error: null }; |
| 187 | + } catch (err) { |
| 188 | + return { profiles: [], error: `query failed: ${err && err.message}` }; |
| 189 | + } finally { |
| 190 | + try { db.close(); } catch { /* best effort */ } |
| 191 | + } |
| 192 | +} |
| 193 | + |
| 194 | +// Top-level convenience: probe path + read, returns { profiles, currentId, dbPath, error }. |
| 195 | +// Shared by GET /api/ccswitch-providers (preview) and POST /api/ccswitch-import (apply). |
| 196 | +export async function discoverCcSwitchProviders() { |
| 197 | + const dbPath = findCcSwitchDbPath(); |
| 198 | + if (!dbPath) return { profiles: [], currentId: null, dbPath: null, error: 'cc-switch not found' }; |
| 199 | + const result = await readCcSwitchProviders(dbPath); |
| 200 | + return { ...result, dbPath }; |
| 201 | +} |
| 202 | + |
| 203 | +// Merge cc-switch-imported profiles into cc-viewer's existing profile list. |
| 204 | +// Rules: |
| 205 | +// - ccs_-prefixed (previously imported): matched by id, updated with fresh data (credential refresh) |
| 206 | +// - proxy_-prefixed (user-created) and any entry without a ccs_ id (including id-less ones): preserved as-is |
| 207 | +// - newly seen ccs_ ids: appended to the end of the list |
| 208 | +// - max (built-in default) is always present and first — seeded when missing, mirroring |
| 209 | +// proxyProfilesPost's invariant, so a first-ever import can never write a list without it |
| 210 | +// Returns { profiles, imported, updated } counts. |
| 211 | +export function mergeImportedProfiles(existing, importedList) { |
| 212 | + const existingArr = Array.isArray(existing) ? existing : []; |
| 213 | + const importedMap = new Map(); |
| 214 | + for (const p of importedList) importedMap.set(p.id, p); |
| 215 | + |
| 216 | + const result = []; |
| 217 | + let newCount = 0; |
| 218 | + let updatedCount = 0; |
| 219 | + |
| 220 | + // Built-in max always first — reuse the existing entry, or seed the same shape |
| 221 | + // proxyProfilesPost injects. Without this, a fresh install importing before ever |
| 222 | + // saving a proxy would write a profiles list with no Default option. |
| 223 | + const existingMax = existingArr.find(p => p && p.id === 'max'); |
| 224 | + result.push(existingMax || { id: 'max', name: 'Default' }); |
| 225 | + // Everything that is not max and not ccs_-sourced (user proxy_ profiles, unknown or |
| 226 | + // id-less entries) is preserved verbatim — this function only owns the ccs_ namespace |
| 227 | + for (const p of existingArr) { |
| 228 | + if (!p || p.id === 'max') continue; |
| 229 | + if (!p.id || !p.id.startsWith('ccs_')) { |
| 230 | + result.push(p); |
| 231 | + } |
| 232 | + } |
| 233 | + // ccs_-sourced: use the freshly imported data |
| 234 | + for (const p of existingArr) { |
| 235 | + if (p && p.id && p.id.startsWith('ccs_')) { |
| 236 | + if (importedMap.has(p.id)) { |
| 237 | + result.push(importedMap.get(p.id)); |
| 238 | + importedMap.delete(p.id); |
| 239 | + updatedCount++; |
| 240 | + } |
| 241 | + // If the imported data no longer has this id (deleted on the cc-switch side), drop it (no stale entries) |
| 242 | + } |
| 243 | + } |
| 244 | + // Newly seen ccs_ profiles (not previously imported) |
| 245 | + for (const p of importedMap.values()) { |
| 246 | + result.push(p); |
| 247 | + newCount++; |
| 248 | + } |
| 249 | + |
| 250 | + return { profiles: result, imported: newCount, updated: updatedCount }; |
| 251 | +} |
0 commit comments