@skillshub-labs/cli 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/bin/skills-hub +1611 -0
- package/lib/core/kit-core.d.ts +56 -0
- package/lib/core/kit-core.mjs +626 -0
- package/lib/core/kit-types.ts +59 -0
- package/lib/core/provider-core.d.ts +134 -0
- package/lib/core/provider-core.mjs +1223 -0
- package/lib/services/kit-service.d.ts +66 -0
- package/lib/services/kit-service.mjs +266 -0
- package/lib/services/provider-service.d.ts +63 -0
- package/lib/services/provider-service.mjs +183 -0
- package/package.json +94 -0
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import fsp from 'fs/promises'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import os from 'os'
|
|
5
|
+
import crypto from 'crypto'
|
|
6
|
+
import Database from 'better-sqlite3'
|
|
7
|
+
import TOML from '@iarna/toml'
|
|
8
|
+
|
|
9
|
+
const APP_TYPES = ['claude', 'codex', 'gemini']
|
|
10
|
+
const DB_DIR = path.join(os.homedir(), '.skills-hub')
|
|
11
|
+
const DB_PATH = path.join(DB_DIR, 'skills-hub.db')
|
|
12
|
+
|
|
13
|
+
let dbInstance = null
|
|
14
|
+
|
|
15
|
+
function ensureDb() {
|
|
16
|
+
if (dbInstance) return dbInstance
|
|
17
|
+
|
|
18
|
+
fs.mkdirSync(DB_DIR, { recursive: true })
|
|
19
|
+
dbInstance = new Database(DB_PATH)
|
|
20
|
+
dbInstance.pragma('journal_mode = WAL')
|
|
21
|
+
dbInstance.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS providers (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
app_type TEXT NOT NULL,
|
|
25
|
+
name TEXT NOT NULL,
|
|
26
|
+
config_json TEXT NOT NULL,
|
|
27
|
+
is_current INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
created_at INTEGER NOT NULL,
|
|
29
|
+
updated_at INTEGER NOT NULL
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_current_app
|
|
33
|
+
ON providers(app_type)
|
|
34
|
+
WHERE is_current = 1;
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS live_backups (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
app_type TEXT NOT NULL,
|
|
39
|
+
backup_json TEXT NOT NULL,
|
|
40
|
+
created_at INTEGER NOT NULL
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS universal_providers (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
name TEXT NOT NULL,
|
|
46
|
+
base_url TEXT NOT NULL,
|
|
47
|
+
api_key TEXT NOT NULL,
|
|
48
|
+
website_url TEXT,
|
|
49
|
+
notes TEXT,
|
|
50
|
+
apps_json TEXT NOT NULL,
|
|
51
|
+
models_json TEXT NOT NULL,
|
|
52
|
+
created_at INTEGER NOT NULL,
|
|
53
|
+
updated_at INTEGER NOT NULL
|
|
54
|
+
);
|
|
55
|
+
`)
|
|
56
|
+
|
|
57
|
+
return dbInstance
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nowTs() {
|
|
61
|
+
return Date.now()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertAppType(appType) {
|
|
65
|
+
if (!APP_TYPES.includes(appType)) {
|
|
66
|
+
throw new Error(`Unsupported app type: ${appType}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseJsonSafe(raw, fallback) {
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(raw)
|
|
73
|
+
} catch {
|
|
74
|
+
return fallback
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseProviderRow(row) {
|
|
79
|
+
if (!row) return null
|
|
80
|
+
return {
|
|
81
|
+
id: row.id,
|
|
82
|
+
appType: row.app_type,
|
|
83
|
+
name: row.name,
|
|
84
|
+
config: parseJsonSafe(row.config_json, {}),
|
|
85
|
+
isCurrent: row.is_current === 1,
|
|
86
|
+
createdAt: row.created_at,
|
|
87
|
+
updatedAt: row.updated_at,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeUniversalApps(appsInput) {
|
|
92
|
+
const apps = appsInput && typeof appsInput === 'object' ? appsInput : {}
|
|
93
|
+
return {
|
|
94
|
+
claude: apps.claude !== false,
|
|
95
|
+
codex: apps.codex !== false,
|
|
96
|
+
gemini: apps.gemini !== false,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeUniversalModels(modelsInput) {
|
|
101
|
+
const models = modelsInput && typeof modelsInput === 'object' ? modelsInput : {}
|
|
102
|
+
const toModelObj = (value) => {
|
|
103
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
104
|
+
const model = typeof value.model === 'string' ? value.model : undefined
|
|
105
|
+
return model ? { model } : {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
claude: toModelObj(models.claude),
|
|
110
|
+
codex: toModelObj(models.codex),
|
|
111
|
+
gemini: toModelObj(models.gemini),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseUniversalProviderRow(row) {
|
|
116
|
+
if (!row) return null
|
|
117
|
+
return {
|
|
118
|
+
id: row.id,
|
|
119
|
+
name: row.name,
|
|
120
|
+
baseUrl: row.base_url,
|
|
121
|
+
apiKey: row.api_key,
|
|
122
|
+
websiteUrl: row.website_url || undefined,
|
|
123
|
+
notes: row.notes || undefined,
|
|
124
|
+
apps: normalizeUniversalApps(parseJsonSafe(row.apps_json, {})),
|
|
125
|
+
models: normalizeUniversalModels(parseJsonSafe(row.models_json, {})),
|
|
126
|
+
createdAt: row.created_at,
|
|
127
|
+
updatedAt: row.updated_at,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureObject(value, fieldName) {
|
|
132
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
133
|
+
throw new Error(`${fieldName} must be an object`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function readJsonFile(filePath) {
|
|
138
|
+
try {
|
|
139
|
+
const raw = await fsp.readFile(filePath, 'utf-8')
|
|
140
|
+
return JSON.parse(raw)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error && error.code === 'ENOENT') return {}
|
|
143
|
+
throw new Error(`Failed to read JSON file: ${filePath}`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function atomicWriteFile(filePath, content) {
|
|
148
|
+
const dir = path.dirname(filePath)
|
|
149
|
+
await fsp.mkdir(dir, { recursive: true })
|
|
150
|
+
|
|
151
|
+
const tempFile = path.join(
|
|
152
|
+
dir,
|
|
153
|
+
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
await fsp.writeFile(tempFile, content, 'utf-8')
|
|
157
|
+
await fsp.rename(tempFile, filePath)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function writeJsonAtomic(filePath, value) {
|
|
161
|
+
await atomicWriteFile(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function atomicWriteFileSync(filePath, content) {
|
|
165
|
+
const dir = path.dirname(filePath)
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
167
|
+
|
|
168
|
+
const tempFile = path.join(
|
|
169
|
+
dir,
|
|
170
|
+
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
171
|
+
)
|
|
172
|
+
fs.writeFileSync(tempFile, content, 'utf-8')
|
|
173
|
+
fs.renameSync(tempFile, filePath)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function writeJsonAtomicSync(filePath, value) {
|
|
177
|
+
atomicWriteFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getCodexProviderAuthSnapshotPath(providerId) {
|
|
181
|
+
return path.join(DB_DIR, 'provider-auth', 'codex', providerId, 'auth.json')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeCodexProviderAuthSnapshot(providerId, auth) {
|
|
185
|
+
if (!providerId) return
|
|
186
|
+
if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return
|
|
187
|
+
writeJsonAtomicSync(getCodexProviderAuthSnapshotPath(providerId), auth)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readCodexProviderAuthSnapshot(providerId) {
|
|
191
|
+
if (!providerId) return null
|
|
192
|
+
const snapshotPath = getCodexProviderAuthSnapshotPath(providerId)
|
|
193
|
+
try {
|
|
194
|
+
const raw = await fsp.readFile(snapshotPath, 'utf-8')
|
|
195
|
+
const parsed = JSON.parse(raw)
|
|
196
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
197
|
+
return parsed
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error && error.code === 'ENOENT') return null
|
|
200
|
+
throw new Error(`Failed to read Codex auth snapshot: ${snapshotPath}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseEnv(raw) {
|
|
205
|
+
const result = {}
|
|
206
|
+
const lines = raw.split(/\r?\n/)
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
const trimmed = line.trim()
|
|
209
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
210
|
+
const eqIndex = trimmed.indexOf('=')
|
|
211
|
+
if (eqIndex <= 0) continue
|
|
212
|
+
const key = trimmed.slice(0, eqIndex).trim()
|
|
213
|
+
const value = trimmed.slice(eqIndex + 1).trim().replace(/^"|"$/g, '')
|
|
214
|
+
result[key] = value
|
|
215
|
+
}
|
|
216
|
+
return result
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stringifyEnv(envObj) {
|
|
220
|
+
return (
|
|
221
|
+
Object.entries(envObj)
|
|
222
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
223
|
+
.map(([key, value]) => `${key}=${String(value)}`)
|
|
224
|
+
.join('\n') + '\n'
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function deepMergeObject(base, next) {
|
|
229
|
+
if (!base || typeof base !== 'object' || Array.isArray(base)) return next
|
|
230
|
+
if (!next || typeof next !== 'object' || Array.isArray(next)) return next
|
|
231
|
+
|
|
232
|
+
const merged = { ...base }
|
|
233
|
+
for (const [key, value] of Object.entries(next)) {
|
|
234
|
+
if (
|
|
235
|
+
value &&
|
|
236
|
+
typeof value === 'object' &&
|
|
237
|
+
!Array.isArray(value) &&
|
|
238
|
+
merged[key] &&
|
|
239
|
+
typeof merged[key] === 'object' &&
|
|
240
|
+
!Array.isArray(merged[key])
|
|
241
|
+
) {
|
|
242
|
+
merged[key] = deepMergeObject(merged[key], value)
|
|
243
|
+
} else {
|
|
244
|
+
merged[key] = value
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return merged
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sanitizeProviderConfigForLive(providerConfig) {
|
|
251
|
+
if (!providerConfig || typeof providerConfig !== 'object' || Array.isArray(providerConfig)) {
|
|
252
|
+
return providerConfig
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const sanitized = { ...providerConfig }
|
|
256
|
+
if (Object.prototype.hasOwnProperty.call(sanitized, '_profile')) {
|
|
257
|
+
delete sanitized._profile
|
|
258
|
+
}
|
|
259
|
+
return sanitized
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function preserveProviderProfile(nextConfig, previousConfig) {
|
|
263
|
+
if (!nextConfig || typeof nextConfig !== 'object' || Array.isArray(nextConfig)) {
|
|
264
|
+
return nextConfig
|
|
265
|
+
}
|
|
266
|
+
if (!previousConfig || typeof previousConfig !== 'object' || Array.isArray(previousConfig)) {
|
|
267
|
+
return nextConfig
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const profile = previousConfig._profile
|
|
271
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
272
|
+
return nextConfig
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
...nextConfig,
|
|
277
|
+
_profile: profile,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sanitizeOfficialProviderConfig(appType, liveConfig) {
|
|
282
|
+
if (!liveConfig || typeof liveConfig !== 'object' || Array.isArray(liveConfig)) {
|
|
283
|
+
return liveConfig
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Official Codex providers should never carry API-key credentials from API-mode snapshots.
|
|
287
|
+
if (appType === 'codex') {
|
|
288
|
+
const next = { ...liveConfig }
|
|
289
|
+
const auth =
|
|
290
|
+
next.auth && typeof next.auth === 'object' && !Array.isArray(next.auth) ? { ...next.auth } : {}
|
|
291
|
+
auth.OPENAI_API_KEY = null
|
|
292
|
+
if (Object.prototype.hasOwnProperty.call(auth, 'api_key')) {
|
|
293
|
+
delete auth.api_key
|
|
294
|
+
}
|
|
295
|
+
next.auth = auth
|
|
296
|
+
return next
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return liveConfig
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sanitizeOfficialConfigIfNeeded(appType, config) {
|
|
303
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
304
|
+
return config
|
|
305
|
+
}
|
|
306
|
+
const profile = config._profile
|
|
307
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
308
|
+
return config
|
|
309
|
+
}
|
|
310
|
+
if (profile.kind !== 'official') {
|
|
311
|
+
return config
|
|
312
|
+
}
|
|
313
|
+
return sanitizeOfficialProviderConfig(appType, config)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function normalizeCodexAuthConfig(authConfig) {
|
|
317
|
+
if (!authConfig || typeof authConfig !== 'object' || Array.isArray(authConfig)) {
|
|
318
|
+
return {}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const normalized = { ...authConfig }
|
|
322
|
+
if (
|
|
323
|
+
typeof normalized.OPENAI_API_KEY !== 'string' &&
|
|
324
|
+
typeof normalized.api_key === 'string'
|
|
325
|
+
) {
|
|
326
|
+
normalized.OPENAI_API_KEY = normalized.api_key
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return normalized
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function normalizeCodexProviderConfig(providerConfig) {
|
|
333
|
+
if (!providerConfig || typeof providerConfig !== 'object' || Array.isArray(providerConfig)) {
|
|
334
|
+
return {}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const normalized = {}
|
|
338
|
+
if (providerConfig.auth && typeof providerConfig.auth === 'object' && !Array.isArray(providerConfig.auth)) {
|
|
339
|
+
normalized.auth = normalizeCodexAuthConfig(providerConfig.auth)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (typeof providerConfig.config === 'string') {
|
|
343
|
+
normalized.config = providerConfig.config
|
|
344
|
+
} else if (
|
|
345
|
+
providerConfig.config &&
|
|
346
|
+
typeof providerConfig.config === 'object' &&
|
|
347
|
+
!Array.isArray(providerConfig.config)
|
|
348
|
+
) {
|
|
349
|
+
normalized.config = TOML.stringify(providerConfig.config)
|
|
350
|
+
} else if (
|
|
351
|
+
providerConfig.configToml &&
|
|
352
|
+
typeof providerConfig.configToml === 'object' &&
|
|
353
|
+
!Array.isArray(providerConfig.configToml)
|
|
354
|
+
) {
|
|
355
|
+
// Backward compatibility for old stored shape.
|
|
356
|
+
normalized.config = TOML.stringify(providerConfig.configToml)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return normalized
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function sanitizeCodexProviderName(rawName) {
|
|
363
|
+
const normalized = String(rawName || '')
|
|
364
|
+
.trim()
|
|
365
|
+
.toLowerCase()
|
|
366
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
367
|
+
.replace(/^_+|_+$/g, '')
|
|
368
|
+
return normalized || 'custom'
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function buildCodexConfigToml(providerName, endpoint, modelName = 'gpt-5.2') {
|
|
372
|
+
const providerKey = sanitizeCodexProviderName(providerName)
|
|
373
|
+
return `model_provider = "${providerKey}"
|
|
374
|
+
model = "${modelName || 'gpt-5.2'}"
|
|
375
|
+
model_reasoning_effort = "high"
|
|
376
|
+
disable_response_storage = true
|
|
377
|
+
|
|
378
|
+
[model_providers.${providerKey}]
|
|
379
|
+
name = "${providerKey}"
|
|
380
|
+
base_url = "${endpoint}"
|
|
381
|
+
wire_api = "responses"
|
|
382
|
+
requires_openai_auth = true
|
|
383
|
+
`
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function mergeLiveConfig(appType, liveConfig, providerConfig) {
|
|
387
|
+
const sanitizedProviderConfig = sanitizeProviderConfigForLive(providerConfig || {})
|
|
388
|
+
|
|
389
|
+
if (appType === 'claude') {
|
|
390
|
+
return deepMergeObject(liveConfig || {}, sanitizedProviderConfig || {})
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (appType === 'codex') {
|
|
394
|
+
// Align with cc-switch: switch writes provider snapshot directly.
|
|
395
|
+
// If legacy provider misses auth/config, fallback to current live values.
|
|
396
|
+
const liveCodex = normalizeCodexProviderConfig(liveConfig || {})
|
|
397
|
+
const nextCodex = normalizeCodexProviderConfig(sanitizedProviderConfig || {})
|
|
398
|
+
return {
|
|
399
|
+
...(nextCodex.auth || liveCodex.auth ? { auth: nextCodex.auth || liveCodex.auth } : {}),
|
|
400
|
+
...(typeof nextCodex.config === 'string' || typeof liveCodex.config === 'string'
|
|
401
|
+
? { config: typeof nextCodex.config === 'string' ? nextCodex.config : liveCodex.config }
|
|
402
|
+
: {}),
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (appType === 'gemini') {
|
|
407
|
+
return {
|
|
408
|
+
env: { ...(liveConfig?.env || {}), ...(sanitizedProviderConfig?.env || {}) },
|
|
409
|
+
settings: deepMergeObject(liveConfig?.settings || {}, sanitizedProviderConfig?.settings || {}),
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return sanitizedProviderConfig
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function getAdapter(appType) {
|
|
417
|
+
assertAppType(appType)
|
|
418
|
+
|
|
419
|
+
if (appType === 'claude') {
|
|
420
|
+
const livePath = path.join(os.homedir(), '.claude', 'settings.json')
|
|
421
|
+
return {
|
|
422
|
+
async readLive() {
|
|
423
|
+
return await readJsonFile(livePath)
|
|
424
|
+
},
|
|
425
|
+
async validateProviderConfig(providerConfig) {
|
|
426
|
+
ensureObject(providerConfig, 'Claude provider config')
|
|
427
|
+
},
|
|
428
|
+
async writeLive(providerConfig) {
|
|
429
|
+
ensureObject(providerConfig, 'Claude provider config')
|
|
430
|
+
await writeJsonAtomic(livePath, providerConfig)
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (appType === 'codex') {
|
|
436
|
+
const authPath = path.join(os.homedir(), '.codex', 'auth.json')
|
|
437
|
+
const configTomlPath = path.join(os.homedir(), '.codex', 'config.toml')
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
async readLive() {
|
|
441
|
+
let config = ''
|
|
442
|
+
try {
|
|
443
|
+
config = await fsp.readFile(configTomlPath, 'utf-8')
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (!error || error.code !== 'ENOENT') {
|
|
446
|
+
throw new Error(`Failed to read Codex config.toml: ${configTomlPath}`)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
auth: await readJsonFile(authPath),
|
|
452
|
+
config,
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
async validateProviderConfig(providerConfig) {
|
|
456
|
+
ensureObject(providerConfig, 'Codex provider config')
|
|
457
|
+
const normalized = normalizeCodexProviderConfig(providerConfig)
|
|
458
|
+
const hasAuth = normalized.auth && typeof normalized.auth === 'object'
|
|
459
|
+
const hasConfig = typeof normalized.config === 'string'
|
|
460
|
+
if (!hasAuth && !hasConfig) {
|
|
461
|
+
throw new Error('Codex provider config must include auth and/or config')
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
async writeLive(providerConfig) {
|
|
465
|
+
ensureObject(providerConfig, 'Codex provider config')
|
|
466
|
+
const normalized = normalizeCodexProviderConfig(providerConfig)
|
|
467
|
+
|
|
468
|
+
if (normalized.auth && typeof normalized.auth === 'object') {
|
|
469
|
+
await writeJsonAtomic(authPath, normalized.auth)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (typeof normalized.config === 'string') {
|
|
473
|
+
await atomicWriteFile(configTomlPath, normalized.config)
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (appType === 'gemini') {
|
|
480
|
+
const envPath = path.join(os.homedir(), '.gemini', '.env')
|
|
481
|
+
const settingsPath = path.join(os.homedir(), '.gemini', 'settings.json')
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
async readLive() {
|
|
485
|
+
let env = {}
|
|
486
|
+
try {
|
|
487
|
+
const rawEnv = await fsp.readFile(envPath, 'utf-8')
|
|
488
|
+
env = parseEnv(rawEnv)
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (!error || error.code !== 'ENOENT') {
|
|
491
|
+
throw new Error(`Failed to read Gemini .env: ${envPath}`)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
env,
|
|
497
|
+
settings: await readJsonFile(settingsPath),
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
async validateProviderConfig(providerConfig) {
|
|
501
|
+
ensureObject(providerConfig, 'Gemini provider config')
|
|
502
|
+
const hasEnv = providerConfig.env && typeof providerConfig.env === 'object'
|
|
503
|
+
const hasSettings = providerConfig.settings && typeof providerConfig.settings === 'object'
|
|
504
|
+
if (!hasEnv && !hasSettings) {
|
|
505
|
+
throw new Error('Gemini provider config must include env and/or settings object')
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
async writeLive(providerConfig) {
|
|
509
|
+
ensureObject(providerConfig, 'Gemini provider config')
|
|
510
|
+
|
|
511
|
+
if (providerConfig.env && typeof providerConfig.env === 'object') {
|
|
512
|
+
await atomicWriteFile(envPath, stringifyEnv(providerConfig.env))
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (providerConfig.settings && typeof providerConfig.settings === 'object') {
|
|
516
|
+
await writeJsonAtomic(settingsPath, providerConfig.settings)
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
throw new Error(`No adapter for app type: ${appType}`)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function addLiveBackup(appType, backup) {
|
|
526
|
+
const db = ensureDb()
|
|
527
|
+
const stmt = db.prepare(`
|
|
528
|
+
INSERT INTO live_backups (app_type, backup_json, created_at)
|
|
529
|
+
VALUES (@appType, @backupJson, @createdAt)
|
|
530
|
+
`)
|
|
531
|
+
|
|
532
|
+
const result = stmt.run({
|
|
533
|
+
appType,
|
|
534
|
+
backupJson: JSON.stringify(backup),
|
|
535
|
+
createdAt: nowTs(),
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
return Number(result.lastInsertRowid)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function getBackupById(backupId) {
|
|
542
|
+
const db = ensureDb()
|
|
543
|
+
const row = db
|
|
544
|
+
.prepare(`SELECT id, app_type, backup_json, created_at FROM live_backups WHERE id = ?`)
|
|
545
|
+
.get(backupId)
|
|
546
|
+
|
|
547
|
+
if (!row) return null
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
id: row.id,
|
|
551
|
+
appType: row.app_type,
|
|
552
|
+
backup: parseJsonSafe(row.backup_json, {}),
|
|
553
|
+
createdAt: row.created_at,
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function getLatestBackup(appType) {
|
|
558
|
+
assertAppType(appType)
|
|
559
|
+
const db = ensureDb()
|
|
560
|
+
const row = db
|
|
561
|
+
.prepare(
|
|
562
|
+
`SELECT id, app_type, backup_json, created_at
|
|
563
|
+
FROM live_backups
|
|
564
|
+
WHERE app_type = ?
|
|
565
|
+
ORDER BY id DESC
|
|
566
|
+
LIMIT 1`
|
|
567
|
+
)
|
|
568
|
+
.get(appType)
|
|
569
|
+
|
|
570
|
+
if (!row) return null
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
id: row.id,
|
|
574
|
+
appType: row.app_type,
|
|
575
|
+
backup: parseJsonSafe(row.backup_json, {}),
|
|
576
|
+
createdAt: row.created_at,
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function listProviders(appType) {
|
|
581
|
+
const db = ensureDb()
|
|
582
|
+
|
|
583
|
+
if (appType) assertAppType(appType)
|
|
584
|
+
|
|
585
|
+
const rows = appType
|
|
586
|
+
? db
|
|
587
|
+
.prepare(
|
|
588
|
+
`SELECT id, app_type, name, config_json, is_current, created_at, updated_at
|
|
589
|
+
FROM providers
|
|
590
|
+
WHERE app_type = ?
|
|
591
|
+
ORDER BY updated_at DESC, name ASC`
|
|
592
|
+
)
|
|
593
|
+
.all(appType)
|
|
594
|
+
: db
|
|
595
|
+
.prepare(
|
|
596
|
+
`SELECT id, app_type, name, config_json, is_current, created_at, updated_at
|
|
597
|
+
FROM providers
|
|
598
|
+
ORDER BY app_type ASC, updated_at DESC, name ASC`
|
|
599
|
+
)
|
|
600
|
+
.all()
|
|
601
|
+
|
|
602
|
+
return rows.map(parseProviderRow)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function getProviderById(id) {
|
|
606
|
+
const db = ensureDb()
|
|
607
|
+
const row = db
|
|
608
|
+
.prepare(
|
|
609
|
+
`SELECT id, app_type, name, config_json, is_current, created_at, updated_at
|
|
610
|
+
FROM providers
|
|
611
|
+
WHERE id = ?`
|
|
612
|
+
)
|
|
613
|
+
.get(id)
|
|
614
|
+
|
|
615
|
+
return parseProviderRow(row)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function getCurrentProvider(appType) {
|
|
619
|
+
assertAppType(appType)
|
|
620
|
+
const db = ensureDb()
|
|
621
|
+
const row = db
|
|
622
|
+
.prepare(
|
|
623
|
+
`SELECT id, app_type, name, config_json, is_current, created_at, updated_at
|
|
624
|
+
FROM providers
|
|
625
|
+
WHERE app_type = ? AND is_current = 1
|
|
626
|
+
LIMIT 1`
|
|
627
|
+
)
|
|
628
|
+
.get(appType)
|
|
629
|
+
|
|
630
|
+
return parseProviderRow(row)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function normalizeProviderProfile(profileInput) {
|
|
634
|
+
const profile = profileInput && typeof profileInput === 'object' ? profileInput : {}
|
|
635
|
+
return {
|
|
636
|
+
kind: profile.kind === 'official' ? 'official' : 'api',
|
|
637
|
+
vendorKey: typeof profile.vendorKey === 'string' ? profile.vendorKey : undefined,
|
|
638
|
+
universalId: typeof profile.universalId === 'string' ? profile.universalId : undefined,
|
|
639
|
+
accountName: typeof profile.accountName === 'string' ? profile.accountName : undefined,
|
|
640
|
+
endpoint: typeof profile.endpoint === 'string' ? profile.endpoint : undefined,
|
|
641
|
+
website: typeof profile.website === 'string' ? profile.website : undefined,
|
|
642
|
+
model: typeof profile.model === 'string' ? profile.model : undefined,
|
|
643
|
+
accountId: typeof profile.accountId === 'string' ? profile.accountId : undefined,
|
|
644
|
+
note: typeof profile.note === 'string' ? profile.note : undefined,
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function attachProfile(config, profileInput) {
|
|
649
|
+
const profile = normalizeProviderProfile(profileInput)
|
|
650
|
+
return {
|
|
651
|
+
...(config || {}),
|
|
652
|
+
_profile: profile,
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function addProvider({ appType, name, config }) {
|
|
657
|
+
assertAppType(appType)
|
|
658
|
+
ensureObject(config, 'Provider config')
|
|
659
|
+
const normalizedConfig = sanitizeOfficialConfigIfNeeded(appType, config)
|
|
660
|
+
|
|
661
|
+
const db = ensureDb()
|
|
662
|
+
const ts = nowTs()
|
|
663
|
+
const id = crypto.randomUUID()
|
|
664
|
+
|
|
665
|
+
db.prepare(
|
|
666
|
+
`INSERT INTO providers (id, app_type, name, config_json, is_current, created_at, updated_at)
|
|
667
|
+
VALUES (@id, @appType, @name, @configJson, 0, @createdAt, @updatedAt)`
|
|
668
|
+
).run({
|
|
669
|
+
id,
|
|
670
|
+
appType,
|
|
671
|
+
name: name?.trim() || `${appType}-${id.slice(0, 8)}`,
|
|
672
|
+
configJson: JSON.stringify(normalizedConfig),
|
|
673
|
+
createdAt: ts,
|
|
674
|
+
updatedAt: ts,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
if (
|
|
678
|
+
appType === 'codex' &&
|
|
679
|
+
normalizedConfig.auth &&
|
|
680
|
+
typeof normalizedConfig.auth === 'object' &&
|
|
681
|
+
!Array.isArray(normalizedConfig.auth)
|
|
682
|
+
) {
|
|
683
|
+
writeCodexProviderAuthSnapshot(id, normalizedConfig.auth)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return getProviderById(id)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function captureProviderFromLive({ appType, name, profile }) {
|
|
690
|
+
assertAppType(appType)
|
|
691
|
+
const adapter = getAdapter(appType)
|
|
692
|
+
const liveConfig = await adapter.readLive()
|
|
693
|
+
ensureObject(liveConfig, 'Live config')
|
|
694
|
+
const capturedConfig = sanitizeOfficialProviderConfig(appType, liveConfig)
|
|
695
|
+
const normalizedProfileInput = profile && typeof profile === 'object' ? { ...profile } : {}
|
|
696
|
+
|
|
697
|
+
if (appType === 'codex') {
|
|
698
|
+
const capturedAccountId = getCodexAccountIdFromConfig(capturedConfig)
|
|
699
|
+
if (capturedAccountId) {
|
|
700
|
+
normalizedProfileInput.accountId = capturedAccountId
|
|
701
|
+
|
|
702
|
+
const existingOfficial = listProviders('codex').find((provider) => {
|
|
703
|
+
const providerProfile = getProviderProfile(provider)
|
|
704
|
+
if (providerProfile?.kind !== 'official') return false
|
|
705
|
+
|
|
706
|
+
const providerAccountId =
|
|
707
|
+
(typeof providerProfile.accountId === 'string' && providerProfile.accountId) ||
|
|
708
|
+
getCodexAccountIdFromConfig(provider.config)
|
|
709
|
+
|
|
710
|
+
return providerAccountId === capturedAccountId
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
if (existingOfficial) {
|
|
714
|
+
delete normalizedProfileInput.accountId
|
|
715
|
+
const emptyOfficialAuth = {
|
|
716
|
+
OPENAI_API_KEY: null,
|
|
717
|
+
auth_mode: 'chatgpt',
|
|
718
|
+
}
|
|
719
|
+
capturedConfig.auth = emptyOfficialAuth
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const provider = addProvider({
|
|
725
|
+
appType,
|
|
726
|
+
name,
|
|
727
|
+
config: attachProfile(capturedConfig, {
|
|
728
|
+
...normalizedProfileInput,
|
|
729
|
+
kind: 'official',
|
|
730
|
+
}),
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
return provider
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function updateProvider({ id, name, config }) {
|
|
737
|
+
const db = ensureDb()
|
|
738
|
+
const existing = getProviderById(id)
|
|
739
|
+
if (!existing) {
|
|
740
|
+
throw new Error(`Provider not found: ${id}`)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (config !== undefined) ensureObject(config, 'Provider config')
|
|
744
|
+
const nextConfig =
|
|
745
|
+
config === undefined ? existing.config : sanitizeOfficialConfigIfNeeded(existing.appType, config)
|
|
746
|
+
|
|
747
|
+
db.prepare(
|
|
748
|
+
`UPDATE providers
|
|
749
|
+
SET name = @name,
|
|
750
|
+
config_json = @configJson,
|
|
751
|
+
updated_at = @updatedAt
|
|
752
|
+
WHERE id = @id`
|
|
753
|
+
).run({
|
|
754
|
+
id,
|
|
755
|
+
name: name?.trim() || existing.name,
|
|
756
|
+
configJson: JSON.stringify(nextConfig),
|
|
757
|
+
updatedAt: nowTs(),
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
if (
|
|
761
|
+
existing.appType === 'codex' &&
|
|
762
|
+
nextConfig &&
|
|
763
|
+
typeof nextConfig === 'object' &&
|
|
764
|
+
!Array.isArray(nextConfig) &&
|
|
765
|
+
nextConfig.auth &&
|
|
766
|
+
typeof nextConfig.auth === 'object' &&
|
|
767
|
+
!Array.isArray(nextConfig.auth)
|
|
768
|
+
) {
|
|
769
|
+
writeCodexProviderAuthSnapshot(existing.id, nextConfig.auth)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return getProviderById(id)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function deleteProvider(id) {
|
|
776
|
+
const db = ensureDb()
|
|
777
|
+
const existing = getProviderById(id)
|
|
778
|
+
if (!existing) return false
|
|
779
|
+
|
|
780
|
+
db.prepare(`DELETE FROM providers WHERE id = ?`).run(id)
|
|
781
|
+
|
|
782
|
+
if (existing.appType === 'codex') {
|
|
783
|
+
const snapshotDir = path.dirname(getCodexProviderAuthSnapshotPath(id))
|
|
784
|
+
try {
|
|
785
|
+
fs.rmSync(snapshotDir, { recursive: true, force: true })
|
|
786
|
+
} catch {
|
|
787
|
+
// Ignore cleanup failures.
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return true
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function listUniversalProviders() {
|
|
795
|
+
const db = ensureDb()
|
|
796
|
+
const rows = db
|
|
797
|
+
.prepare(
|
|
798
|
+
`SELECT id, name, base_url, api_key, website_url, notes, apps_json, models_json, created_at, updated_at
|
|
799
|
+
FROM universal_providers
|
|
800
|
+
ORDER BY updated_at DESC, name ASC`
|
|
801
|
+
)
|
|
802
|
+
.all()
|
|
803
|
+
|
|
804
|
+
return rows.map(parseUniversalProviderRow)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function getUniversalProviderById(id) {
|
|
808
|
+
const db = ensureDb()
|
|
809
|
+
const row = db
|
|
810
|
+
.prepare(
|
|
811
|
+
`SELECT id, name, base_url, api_key, website_url, notes, apps_json, models_json, created_at, updated_at
|
|
812
|
+
FROM universal_providers
|
|
813
|
+
WHERE id = ?`
|
|
814
|
+
)
|
|
815
|
+
.get(id)
|
|
816
|
+
|
|
817
|
+
return parseUniversalProviderRow(row)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function addUniversalProvider(input) {
|
|
821
|
+
const db = ensureDb()
|
|
822
|
+
const ts = nowTs()
|
|
823
|
+
const id = crypto.randomUUID()
|
|
824
|
+
|
|
825
|
+
const name = input?.name?.trim()
|
|
826
|
+
const baseUrl = input?.baseUrl?.trim()
|
|
827
|
+
const apiKey = input?.apiKey?.trim()
|
|
828
|
+
if (!name) throw new Error('Universal provider name is required')
|
|
829
|
+
if (!baseUrl) throw new Error('Universal provider baseUrl is required')
|
|
830
|
+
if (!apiKey) throw new Error('Universal provider apiKey is required')
|
|
831
|
+
|
|
832
|
+
db.prepare(
|
|
833
|
+
`INSERT INTO universal_providers
|
|
834
|
+
(id, name, base_url, api_key, website_url, notes, apps_json, models_json, created_at, updated_at)
|
|
835
|
+
VALUES (@id, @name, @baseUrl, @apiKey, @websiteUrl, @notes, @appsJson, @modelsJson, @createdAt, @updatedAt)`
|
|
836
|
+
).run({
|
|
837
|
+
id,
|
|
838
|
+
name,
|
|
839
|
+
baseUrl,
|
|
840
|
+
apiKey,
|
|
841
|
+
websiteUrl: input.websiteUrl?.trim() || null,
|
|
842
|
+
notes: input.notes?.trim() || null,
|
|
843
|
+
appsJson: JSON.stringify(normalizeUniversalApps(input.apps || {})),
|
|
844
|
+
modelsJson: JSON.stringify(normalizeUniversalModels(input.models || {})),
|
|
845
|
+
createdAt: ts,
|
|
846
|
+
updatedAt: ts,
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
return getUniversalProviderById(id)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function updateUniversalProvider(input) {
|
|
853
|
+
const existing = getUniversalProviderById(input?.id)
|
|
854
|
+
if (!existing) {
|
|
855
|
+
throw new Error(`Universal provider not found: ${input?.id}`)
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const db = ensureDb()
|
|
859
|
+
const next = {
|
|
860
|
+
name: input.name?.trim() || existing.name,
|
|
861
|
+
baseUrl: input.baseUrl?.trim() || existing.baseUrl,
|
|
862
|
+
apiKey: input.apiKey?.trim() || existing.apiKey,
|
|
863
|
+
websiteUrl:
|
|
864
|
+
input.websiteUrl === undefined ? existing.websiteUrl : input.websiteUrl?.trim() || undefined,
|
|
865
|
+
notes: input.notes === undefined ? existing.notes : input.notes?.trim() || undefined,
|
|
866
|
+
apps: normalizeUniversalApps(input.apps ? { ...existing.apps, ...input.apps } : existing.apps),
|
|
867
|
+
models: input.models ? normalizeUniversalModels(input.models) : existing.models,
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
db.prepare(
|
|
871
|
+
`UPDATE universal_providers
|
|
872
|
+
SET name = @name,
|
|
873
|
+
base_url = @baseUrl,
|
|
874
|
+
api_key = @apiKey,
|
|
875
|
+
website_url = @websiteUrl,
|
|
876
|
+
notes = @notes,
|
|
877
|
+
apps_json = @appsJson,
|
|
878
|
+
models_json = @modelsJson,
|
|
879
|
+
updated_at = @updatedAt
|
|
880
|
+
WHERE id = @id`
|
|
881
|
+
).run({
|
|
882
|
+
id: existing.id,
|
|
883
|
+
name: next.name,
|
|
884
|
+
baseUrl: next.baseUrl,
|
|
885
|
+
apiKey: next.apiKey,
|
|
886
|
+
websiteUrl: next.websiteUrl || null,
|
|
887
|
+
notes: next.notes || null,
|
|
888
|
+
appsJson: JSON.stringify(next.apps),
|
|
889
|
+
modelsJson: JSON.stringify(next.models),
|
|
890
|
+
updatedAt: nowTs(),
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
return getUniversalProviderById(existing.id)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function deleteUniversalProvider(id) {
|
|
897
|
+
const db = ensureDb()
|
|
898
|
+
const existing = getUniversalProviderById(id)
|
|
899
|
+
if (!existing) return false
|
|
900
|
+
db.prepare(`DELETE FROM universal_providers WHERE id = ?`).run(id)
|
|
901
|
+
return true
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function getProviderProfile(provider) {
|
|
905
|
+
if (!provider?.config || typeof provider.config !== 'object') return null
|
|
906
|
+
const profile = provider.config._profile
|
|
907
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null
|
|
908
|
+
return profile
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function getCodexAccountIdFromConfig(config) {
|
|
912
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return undefined
|
|
913
|
+
const auth = config.auth
|
|
914
|
+
if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return undefined
|
|
915
|
+
const tokens = auth.tokens
|
|
916
|
+
if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) return undefined
|
|
917
|
+
return typeof tokens.account_id === 'string' ? tokens.account_id : undefined
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function buildProviderConfigFromUniversal(universalProvider, appType) {
|
|
921
|
+
const model = universalProvider?.models?.[appType]?.model
|
|
922
|
+
const endpoint = universalProvider.baseUrl
|
|
923
|
+
const profile = {
|
|
924
|
+
kind: 'api',
|
|
925
|
+
vendorKey: 'universal',
|
|
926
|
+
universalId: universalProvider.id,
|
|
927
|
+
endpoint,
|
|
928
|
+
website: universalProvider.websiteUrl || undefined,
|
|
929
|
+
model: model || undefined,
|
|
930
|
+
note: universalProvider.notes || undefined,
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if (appType === 'claude') {
|
|
934
|
+
return attachProfile(
|
|
935
|
+
{
|
|
936
|
+
api_key: universalProvider.apiKey,
|
|
937
|
+
model: model || 'claude-sonnet-4',
|
|
938
|
+
api_base_url: endpoint,
|
|
939
|
+
},
|
|
940
|
+
profile
|
|
941
|
+
)
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (appType === 'codex') {
|
|
945
|
+
return attachProfile(
|
|
946
|
+
{
|
|
947
|
+
auth: {
|
|
948
|
+
OPENAI_API_KEY: universalProvider.apiKey,
|
|
949
|
+
},
|
|
950
|
+
config: buildCodexConfigToml(universalProvider.name, endpoint, model || 'gpt-5.2'),
|
|
951
|
+
},
|
|
952
|
+
profile
|
|
953
|
+
)
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
if (appType === 'gemini') {
|
|
957
|
+
return attachProfile(
|
|
958
|
+
{
|
|
959
|
+
env: {
|
|
960
|
+
GEMINI_API_KEY: universalProvider.apiKey,
|
|
961
|
+
},
|
|
962
|
+
settings: {
|
|
963
|
+
model: model || 'gemini-2.5-pro',
|
|
964
|
+
api_base_url: endpoint,
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
profile
|
|
968
|
+
)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
throw new Error(`Unsupported app type: ${appType}`)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function applyUniversalProvider({ id }) {
|
|
975
|
+
const universal = getUniversalProviderById(id)
|
|
976
|
+
if (!universal) {
|
|
977
|
+
throw new Error(`Universal provider not found: ${id}`)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const applied = []
|
|
981
|
+
for (const appType of APP_TYPES) {
|
|
982
|
+
if (!universal.apps[appType]) continue
|
|
983
|
+
|
|
984
|
+
const existing = listProviders(appType).find((provider) => {
|
|
985
|
+
const profile = getProviderProfile(provider)
|
|
986
|
+
return profile?.universalId === universal.id
|
|
987
|
+
})
|
|
988
|
+
|
|
989
|
+
const config = buildProviderConfigFromUniversal(universal, appType)
|
|
990
|
+
if (existing) {
|
|
991
|
+
applied.push(
|
|
992
|
+
updateProvider({
|
|
993
|
+
id: existing.id,
|
|
994
|
+
name: universal.name,
|
|
995
|
+
config,
|
|
996
|
+
})
|
|
997
|
+
)
|
|
998
|
+
} else {
|
|
999
|
+
applied.push(
|
|
1000
|
+
addProvider({
|
|
1001
|
+
appType,
|
|
1002
|
+
name: universal.name,
|
|
1003
|
+
config,
|
|
1004
|
+
})
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return applied
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function setCurrentProvider(appType, providerId) {
|
|
1013
|
+
assertAppType(appType)
|
|
1014
|
+
const db = ensureDb()
|
|
1015
|
+
|
|
1016
|
+
const tx = db.transaction(() => {
|
|
1017
|
+
db.prepare(`UPDATE providers SET is_current = 0, updated_at = ? WHERE app_type = ?`).run(
|
|
1018
|
+
nowTs(),
|
|
1019
|
+
appType
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
db.prepare(`UPDATE providers SET is_current = 1, updated_at = ? WHERE id = ?`).run(nowTs(), providerId)
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
tx()
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function updateProviderConfig(providerId, config) {
|
|
1029
|
+
ensureObject(config, 'Provider config')
|
|
1030
|
+
const db = ensureDb()
|
|
1031
|
+
db.prepare(`UPDATE providers SET config_json = ?, updated_at = ? WHERE id = ?`).run(
|
|
1032
|
+
JSON.stringify(config),
|
|
1033
|
+
nowTs(),
|
|
1034
|
+
providerId
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function formatSwitchError(step, err, rollbackErr, appType, backupId) {
|
|
1039
|
+
const detail = err instanceof Error ? err.message : String(err)
|
|
1040
|
+
const rollbackDetail = rollbackErr
|
|
1041
|
+
? ` Rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}.`
|
|
1042
|
+
: ''
|
|
1043
|
+
|
|
1044
|
+
return new Error(
|
|
1045
|
+
`Provider switch failed at ${step} for ${appType}. Backup id: ${backupId}.${rollbackDetail} Root cause: ${detail}`
|
|
1046
|
+
)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async function restoreBackup(appType, backupId) {
|
|
1050
|
+
const adapter = getAdapter(appType)
|
|
1051
|
+
const byId = backupId ? getBackupById(backupId) : null
|
|
1052
|
+
const latest = byId || getLatestBackup(appType)
|
|
1053
|
+
|
|
1054
|
+
if (!latest) {
|
|
1055
|
+
throw new Error(`No backup found for app ${appType}`)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
await adapter.writeLive(latest.backup)
|
|
1059
|
+
return latest
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function switchProvider({ appType, providerId }) {
|
|
1063
|
+
assertAppType(appType)
|
|
1064
|
+
|
|
1065
|
+
const target = getProviderById(providerId)
|
|
1066
|
+
if (!target) {
|
|
1067
|
+
throw new Error(`Target provider not found: ${providerId}`)
|
|
1068
|
+
}
|
|
1069
|
+
if (target.appType !== appType) {
|
|
1070
|
+
throw new Error(`Provider ${providerId} does not belong to app ${appType}`)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const adapter = getAdapter(appType)
|
|
1074
|
+
const current = getCurrentProvider(appType)
|
|
1075
|
+
|
|
1076
|
+
const liveBefore = await adapter.readLive()
|
|
1077
|
+
const backupId = addLiveBackup(appType, liveBefore)
|
|
1078
|
+
|
|
1079
|
+
if (current && current.id !== target.id) {
|
|
1080
|
+
if (appType === 'codex') {
|
|
1081
|
+
const liveAuth =
|
|
1082
|
+
liveBefore &&
|
|
1083
|
+
typeof liveBefore === 'object' &&
|
|
1084
|
+
!Array.isArray(liveBefore) &&
|
|
1085
|
+
liveBefore.auth &&
|
|
1086
|
+
typeof liveBefore.auth === 'object' &&
|
|
1087
|
+
!Array.isArray(liveBefore.auth)
|
|
1088
|
+
? liveBefore.auth
|
|
1089
|
+
: null
|
|
1090
|
+
if (liveAuth) {
|
|
1091
|
+
writeCodexProviderAuthSnapshot(current.id, liveAuth)
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
updateProviderConfig(current.id, preserveProviderProfile(liveBefore, current.config))
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
let targetConfigForSwitch = target.config
|
|
1098
|
+
if (appType === 'codex') {
|
|
1099
|
+
const snapshotAuth = await readCodexProviderAuthSnapshot(target.id)
|
|
1100
|
+
if (snapshotAuth) {
|
|
1101
|
+
targetConfigForSwitch = {
|
|
1102
|
+
...(target.config || {}),
|
|
1103
|
+
auth: snapshotAuth,
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const nextConfig = mergeLiveConfig(appType, liveBefore, targetConfigForSwitch)
|
|
1109
|
+
|
|
1110
|
+
try {
|
|
1111
|
+
await adapter.validateProviderConfig(nextConfig)
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
throw formatSwitchError('validate', error, null, appType, backupId)
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
await adapter.writeLive(nextConfig)
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
let rollbackErr = null
|
|
1120
|
+
try {
|
|
1121
|
+
await restoreBackup(appType, backupId)
|
|
1122
|
+
} catch (restoreError) {
|
|
1123
|
+
rollbackErr = restoreError
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
throw formatSwitchError('writeLive', error, rollbackErr, appType, backupId)
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
try {
|
|
1130
|
+
setCurrentProvider(appType, target.id)
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
let rollbackErr = null
|
|
1133
|
+
try {
|
|
1134
|
+
await restoreBackup(appType, backupId)
|
|
1135
|
+
} catch (restoreError) {
|
|
1136
|
+
rollbackErr = restoreError
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
throw formatSwitchError('setCurrent', error, rollbackErr, appType, backupId)
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
appType,
|
|
1144
|
+
currentProviderId: target.id,
|
|
1145
|
+
backupId,
|
|
1146
|
+
switchedFrom: current?.id || null,
|
|
1147
|
+
switchedTo: target.id,
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function getDbPath() {
|
|
1152
|
+
return DB_PATH
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function maskProviderConfig(value) {
|
|
1156
|
+
if (Array.isArray(value)) {
|
|
1157
|
+
return value.map((entry) => maskProviderConfig(entry))
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
if (!value || typeof value !== 'object') return value
|
|
1161
|
+
|
|
1162
|
+
const masked = {}
|
|
1163
|
+
for (const [key, val] of Object.entries(value)) {
|
|
1164
|
+
const lowerKey = key.toLowerCase()
|
|
1165
|
+
const shouldMask =
|
|
1166
|
+
lowerKey.includes('key') ||
|
|
1167
|
+
lowerKey.includes('token') ||
|
|
1168
|
+
lowerKey.includes('secret') ||
|
|
1169
|
+
lowerKey.includes('password')
|
|
1170
|
+
|
|
1171
|
+
if (shouldMask && typeof val === 'string') {
|
|
1172
|
+
const raw = val.trim()
|
|
1173
|
+
if (raw.length <= 8) {
|
|
1174
|
+
masked[key] = '****'
|
|
1175
|
+
} else {
|
|
1176
|
+
masked[key] = `${raw.slice(0, 4)}****${raw.slice(-2)}`
|
|
1177
|
+
}
|
|
1178
|
+
continue
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
masked[key] = maskProviderConfig(val)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return masked
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function maskProvider(provider) {
|
|
1188
|
+
if (!provider) return provider
|
|
1189
|
+
return {
|
|
1190
|
+
...provider,
|
|
1191
|
+
config: maskProviderConfig(provider.config),
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function maskProviders(providers) {
|
|
1196
|
+
return providers.map(maskProvider)
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
export {
|
|
1200
|
+
APP_TYPES,
|
|
1201
|
+
addProvider,
|
|
1202
|
+
addUniversalProvider,
|
|
1203
|
+
applyUniversalProvider,
|
|
1204
|
+
captureProviderFromLive,
|
|
1205
|
+
deleteProvider,
|
|
1206
|
+
deleteUniversalProvider,
|
|
1207
|
+
ensureDb,
|
|
1208
|
+
getAdapter,
|
|
1209
|
+
getBackupById,
|
|
1210
|
+
getCurrentProvider,
|
|
1211
|
+
getDbPath,
|
|
1212
|
+
getLatestBackup,
|
|
1213
|
+
getProviderById,
|
|
1214
|
+
getUniversalProviderById,
|
|
1215
|
+
listProviders,
|
|
1216
|
+
listUniversalProviders,
|
|
1217
|
+
maskProvider,
|
|
1218
|
+
maskProviders,
|
|
1219
|
+
restoreBackup,
|
|
1220
|
+
switchProvider,
|
|
1221
|
+
updateProvider,
|
|
1222
|
+
updateUniversalProvider,
|
|
1223
|
+
}
|