Skip to content

Commit 80e99e5

Browse files
authored
Merge pull request #123 from souloss/feat/cc-switch-import
feat(proxy): import providers from cc-switch (read-only SQLite)
2 parents c94882c + 9830465 commit 80e99e5

7 files changed

Lines changed: 1082 additions & 1 deletion

File tree

history.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- feat(proxy): **Import providers from cc-switch** — reads AI provider credentials (baseURL/authToken/model mappings) from the local [cc-switch](https://github.com/farion1231/cc-switch) Tauri app's SQLite database and auto-generates cc-viewer proxy profiles. Cross-platform path detection probes `~/.cc-switch/cc-switch.db` **first on every platform** (cc-switch hardcodes this path via `get_app_config_dir()` in its `config.rs` on mac/linux/windows; the Tauri identifier does not affect the DB path), with platform-specific Tauri app-data paths (`~/Library/Application Support/cc-switch/`, `%APPDATA%\cc-switch\`, `~/.local/share/cc-switch/`) kept only as low-priority legacy fallbacks so a stale leftover there can never shadow the real DB. Opens `cc-switch.db` in **read-only** mode (no SQLITE_BUSY lock when cc-switch is running), queries the `providers` table for `app_type='claude'` rows, and maps `settings_config.env` → cc-viewer profile fields (`ANTHROPIC_BASE_URL`→`baseURL`, `ANTHROPIC_AUTH_TOKEN`/`ANTHROPIC_API_KEY`→`apiKey`, `ANTHROPIC_MODEL` + the three family-model fields, ignoring the `_NAME` suffixed variants; `CLAUDE_CODE_EFFORT_LEVEL`→`effort` so a user's effort toggle is not silently dropped on import). Codex providers are skipped (incompatible auth format). Imported profiles get a `ccs_` id prefix and `source: 'cc-switch'` marker; a `mergeImportedProfiles` pure function updates existing `ccs_` entries (credential refresh) and appends new ones while **leaving user-created `proxy_` profiles untouched** — deleted-from-cc-switch entries are pruned. New `server/lib/ccswitch-import.js` (pure functions, fully unit-tested against the real db); `GET /api/ccswitch-providers` (preview, masked off-host) + `POST /api/ccswitch-import` (local-only merge + SSE `proxy_profile` broadcast); a "从 cc-switch 导入" button in ProxyModal. 4 new `ui.proxy.ccswitch*` i18n keys × 18 locales; `test/ccswitch-import.test.js` (25 cases incl. live-db integration + cross-platform path-priority injection).
6+
- fix(cc-switch-import): **the import reported a misleading `providers table not found`** whenever the DB was unreadable. The `sqlite_master` existence query's inner `catch {}` swallowed the real error (e.g. `file is not a database` for a corrupt/non-SQLite `cc-switch.db`) and collapsed every failure into the generic message, so the user could not tell a corrupt file from a genuine schema mismatch. The catch is removed — a throw now propagates to the existing outer catch, which surfaces `query failed: <real message>`; the genuine table-missing case reports `providers table not found in <resolved path>` (naming the file so a stale leftover at a probed path is distinguishable from the real cc-switch DB). Tests: a non-SQLite file asserts the error is *not* the masked string and exposes the real cause; a valid-but-tableless DB asserts the distinct `providers table not found in <path>` message.
7+
- fix(cc-switch-import): **five P1 findings from a 6-role review adopted.** (1) A corrupt/unparseable existing `profile.json` no longer wipes user-created proxies: the import previously swallowed the parse error, merged into an empty base, and overwrote the file with only `ccs_` entries — it now ABORTS with `ok:false` (`reportSwallowed('ccswitch-import.read-existing')`), and a parse to a non-object shape aborts the same way; the on-disk file is left byte-identical. (2) The client no longer misreports 403/400 as success: those response shapes carry no `imported`/`updated` counters, so the old `data.imported === 0` heuristic fell through to the green "Imported 0, updated 0" toast — success is now decided strictly on `resp.ok && data.ok === true`, and every server error path (403 gate, 400 exception) carries `ok:false`. (3) `mergeImportedProfiles` now upholds the same `max`-invariant as `proxyProfilesPost`: the built-in Default is seeded at the front when missing (a fresh install importing before ever saving a proxy used to write a list with no Default option), and existing id-less entries are preserved instead of silently dropped. (4) The Node floor mismatch is surfaced instead of silent: `node:sqlite` needs Node ≥ 22.5 (`--experimental-sqlite`) / ≥ 23.4 unflagged while the project floor is 20.14 — the unavailable-runtime error now names the requirement and ProxyModal maps it to a dedicated localized message (`ui.proxy.ccswitchNodeUnsupported` × 18 locales) instead of the misleading "cc-switch not detected"; the degradation contract is pinned by a test via the new `_setDatabaseSyncForTest` hook (CI runs Node 24, so the sqlite suites are exercised there). (5) The two HTTP endpoints — previously untested — get route coverage in `test/api-ccswitch-import.test.js` (fixture SQLite db under an injected HOME, no real cc-switch needed): local-only 403 gate writes nothing, `ok:false` contract on 400, first-import max seeding + `0o600` + credential-free SSE `refresh` broadcast, re-import preserve/prune semantics, corrupt/non-object `profile.json` abort leaves the file untouched, and off-host GET masking never leaks the plaintext key.
8+
- fix(cc-switch-import): **a stale/empty leftover `cc-switch.db` at a platform-specific probe path could shadow the real DB**. `candidateDbPaths()` probed `~/Library/Application Support/cc-switch/`, `%APPDATA%\cc-switch\`, and `~/.local/share/cc-switch/` *before* `~/.cc-switch/cc-switch.db`, but cc-switch never writes to those Tauri app-data paths — a leftover file there was matched first and yielded `providers table not found`. `~/.cc-switch/cc-switch.db` is now the primary probe on all platforms; the platform paths are demoted to legacy fallbacks. `candidateDbPaths()` was refactored to accept an injectable `{plat, home, env}` and exported as `_candidateDbPathsForTest` so the win32/darwin priority ordering is exercised on any test host (previously zero coverage on non-linux branches); new cross-platform tests assert the primary path wins and a stale legacy file cannot shadow it, using a neutral home value (no hardcoded username).
9+
310
## 1.7.3 (2026-07-17)
411

512
### Fix: a user-supplied `--settings` launch arg silently disabled traffic capture

server/lib/ccswitch-import.js

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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

Comments
 (0)