@ralphkrauss/codex-account-switcher 0.1.1 → 0.1.3
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/CHANGELOG.md +21 -0
- package/README.md +79 -1
- package/dist/cli.js +342 -1
- package/dist/cli.js.map +1 -1
- package/dist/hermes.d.ts +56 -0
- package/dist/hermes.js +638 -0
- package/dist/hermes.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/remote.d.ts +85 -0
- package/dist/remote.js +468 -0
- package/dist/remote.js.map +1 -0
- package/package.json +1 -1
- package/src/cli.ts +421 -0
- package/src/hermes.ts +794 -0
- package/src/index.ts +49 -0
- package/src/remote.ts +670 -0
package/src/hermes.ts
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { constants as fsConstants, type Stats } from 'node:fs';
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
chmod,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
rename,
|
|
9
|
+
rm,
|
|
10
|
+
stat,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
CxError,
|
|
17
|
+
accountPathForName,
|
|
18
|
+
getCodexPaths,
|
|
19
|
+
validateAccountName,
|
|
20
|
+
} from './accounts.js';
|
|
21
|
+
|
|
22
|
+
export const HERMES_OPENAI_CODEX_PROVIDER = 'openai-codex';
|
|
23
|
+
export const HERMES_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
|
24
|
+
|
|
25
|
+
const AUTH_STORE_VERSION = 1;
|
|
26
|
+
const ERROR_MARKER_KEYS = [
|
|
27
|
+
'last_status',
|
|
28
|
+
'last_status_at',
|
|
29
|
+
'last_error_code',
|
|
30
|
+
'last_error_reason',
|
|
31
|
+
'last_error_message',
|
|
32
|
+
'last_error_reset_at',
|
|
33
|
+
] as const;
|
|
34
|
+
const POOL_TOKEN_EXTRA_KEYS = [
|
|
35
|
+
'token_type',
|
|
36
|
+
'scope',
|
|
37
|
+
'client_id',
|
|
38
|
+
'expires_in',
|
|
39
|
+
'expires_at',
|
|
40
|
+
'expires_at_ms',
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
export interface HermesPaths {
|
|
44
|
+
readonly home: string;
|
|
45
|
+
readonly authFile: string;
|
|
46
|
+
readonly configFile: string;
|
|
47
|
+
readonly profile: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface HermesProfileOptions {
|
|
51
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
52
|
+
readonly profile?: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HermesUseOptions extends HermesProfileOptions {
|
|
56
|
+
readonly updateConfig?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface HermesUseResult {
|
|
60
|
+
readonly account: string;
|
|
61
|
+
readonly codexAccountFile: string;
|
|
62
|
+
readonly hermesHome: string;
|
|
63
|
+
readonly hermesAuthFile: string;
|
|
64
|
+
readonly hermesConfigFile: string | null;
|
|
65
|
+
readonly profile: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface HermesSyncResult {
|
|
69
|
+
readonly account: string;
|
|
70
|
+
readonly codexAccountFile: string;
|
|
71
|
+
readonly hermesHome: string;
|
|
72
|
+
readonly hermesAuthFile: string;
|
|
73
|
+
readonly profile: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HermesStatus {
|
|
77
|
+
readonly hermesHome: string;
|
|
78
|
+
readonly authFile: string;
|
|
79
|
+
readonly configFile: string;
|
|
80
|
+
readonly profile: string | null;
|
|
81
|
+
readonly authExists: boolean;
|
|
82
|
+
readonly authReadable: boolean;
|
|
83
|
+
readonly authError: string | null;
|
|
84
|
+
readonly openaiCodexAuthExists: boolean;
|
|
85
|
+
readonly hasTokens: boolean;
|
|
86
|
+
readonly hasAccessToken: boolean;
|
|
87
|
+
readonly hasRefreshToken: boolean;
|
|
88
|
+
readonly lastRefresh: string | null;
|
|
89
|
+
readonly authMode: string | null;
|
|
90
|
+
readonly linkedAccount: string | null;
|
|
91
|
+
readonly linkedAccounts: readonly string[];
|
|
92
|
+
readonly poolEntryCount: number;
|
|
93
|
+
readonly configuredProvider: string | null;
|
|
94
|
+
readonly configExists: boolean;
|
|
95
|
+
readonly configReadable: boolean;
|
|
96
|
+
readonly configError: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type JsonObject = Record<string, unknown>;
|
|
100
|
+
|
|
101
|
+
function isNotFoundError(error: unknown): boolean {
|
|
102
|
+
return typeof error === 'object'
|
|
103
|
+
&& error !== null
|
|
104
|
+
&& 'code' in error
|
|
105
|
+
&& (error as NodeJS.ErrnoException).code === 'ENOENT';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function errorMessage(error: unknown): string {
|
|
109
|
+
return error instanceof Error ? error.message : String(error);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRecord(value: unknown): value is JsonObject {
|
|
113
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stringValue(value: unknown): string {
|
|
117
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
121
|
+
try {
|
|
122
|
+
await access(path, fsConstants.F_OK);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (isNotFoundError(error)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function statIfExists(path: string): Promise<Stats | null> {
|
|
133
|
+
try {
|
|
134
|
+
return await stat(path);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (isNotFoundError(error)) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function chmodIfPossible(path: string, mode: number): Promise<void> {
|
|
144
|
+
if (process.platform === 'win32') {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
await chmod(path, mode);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
152
|
+
? (error as NodeJS.ErrnoException).code
|
|
153
|
+
: undefined;
|
|
154
|
+
if (code === 'ENOSYS' || code === 'ENOTSUP' || code === 'EINVAL' || code === 'EPERM') {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function ensurePrivateDir(path: string): Promise<void> {
|
|
162
|
+
const existed = await pathExists(path);
|
|
163
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
164
|
+
if (!existed) {
|
|
165
|
+
await chmodIfPossible(path, 0o700);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writeFilePrivate(destination: string, contents: string): Promise<void> {
|
|
170
|
+
await ensurePrivateDir(dirname(destination));
|
|
171
|
+
const temp = join(dirname(destination), `.cx-${randomBytes(6).toString('hex')}.tmp`);
|
|
172
|
+
try {
|
|
173
|
+
await writeFile(temp, contents, { mode: 0o600 });
|
|
174
|
+
await chmodIfPossible(temp, 0o600);
|
|
175
|
+
await rename(temp, destination);
|
|
176
|
+
await chmodIfPossible(destination, 0o600);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function readJsonObject(path: string, description: string): Promise<JsonObject> {
|
|
184
|
+
let raw: string;
|
|
185
|
+
try {
|
|
186
|
+
raw = await readFile(path, 'utf8');
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (isNotFoundError(error)) {
|
|
189
|
+
throw new CxError(`${description} not found at ${path}`, 1);
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const parsed: unknown = JSON.parse(raw);
|
|
196
|
+
if (!isRecord(parsed)) {
|
|
197
|
+
throw new CxError(`${description} at ${path} must be a JSON object`, 1);
|
|
198
|
+
}
|
|
199
|
+
return parsed;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof CxError) {
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
throw new CxError(`failed to parse ${description} at ${path}: ${errorMessage(error)}`, 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readHermesAuthStore(authFile: string): Promise<JsonObject> {
|
|
209
|
+
if (!(await pathExists(authFile))) {
|
|
210
|
+
return { version: AUTH_STORE_VERSION, providers: {} };
|
|
211
|
+
}
|
|
212
|
+
const store = await readJsonObject(authFile, 'Hermes auth.json');
|
|
213
|
+
if (!isRecord(store.providers)) {
|
|
214
|
+
store.providers = {};
|
|
215
|
+
}
|
|
216
|
+
return store;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function writeJsonPrivate(destination: string, payload: JsonObject): Promise<void> {
|
|
220
|
+
await writeFilePrivate(destination, `${JSON.stringify(payload, null, 2)}\n`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function userHomeFromEnv(env: NodeJS.ProcessEnv): string {
|
|
224
|
+
const configured = env.HOME ?? env.USERPROFILE;
|
|
225
|
+
if (configured && configured.trim().length > 0) {
|
|
226
|
+
return resolve(configured);
|
|
227
|
+
}
|
|
228
|
+
return homedir();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function validateHermesProfileName(name: string): string {
|
|
232
|
+
try {
|
|
233
|
+
return validateAccountName(name);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof CxError) {
|
|
236
|
+
throw new CxError(error.message.replace('account name', 'Hermes profile name'), error.exitCode);
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getHermesPaths(options: HermesProfileOptions = {}): HermesPaths {
|
|
243
|
+
const env = options.env ?? process.env;
|
|
244
|
+
const rawProfile = options.profile;
|
|
245
|
+
let profile: string | null = null;
|
|
246
|
+
let home: string;
|
|
247
|
+
|
|
248
|
+
if (rawProfile !== undefined && rawProfile !== null) {
|
|
249
|
+
const trimmedProfile = rawProfile.trim();
|
|
250
|
+
if (!trimmedProfile) {
|
|
251
|
+
throw new CxError('Hermes profile name cannot be empty', 2);
|
|
252
|
+
}
|
|
253
|
+
profile = validateHermesProfileName(trimmedProfile);
|
|
254
|
+
home = join(userHomeFromEnv(env), '.hermes', 'profiles', profile);
|
|
255
|
+
} else {
|
|
256
|
+
const configured = env.HERMES_HOME;
|
|
257
|
+
home = configured && configured.trim().length > 0
|
|
258
|
+
? resolve(configured)
|
|
259
|
+
: join(userHomeFromEnv(env), '.hermes');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
home,
|
|
264
|
+
authFile: join(home, 'auth.json'),
|
|
265
|
+
configFile: join(home, 'config.yaml'),
|
|
266
|
+
profile,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function accountTokensFromPayload(payload: JsonObject, account: string): JsonObject {
|
|
271
|
+
const nestedTokens = payload.tokens;
|
|
272
|
+
const tokens = isRecord(nestedTokens)
|
|
273
|
+
? nestedTokens
|
|
274
|
+
: (stringValue(payload.access_token) && stringValue(payload.refresh_token) ? payload : null);
|
|
275
|
+
|
|
276
|
+
if (!tokens) {
|
|
277
|
+
throw new CxError(`account '${account}' does not contain Codex OAuth tokens`, 1);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const accessToken = stringValue(tokens.access_token);
|
|
281
|
+
const refreshToken = stringValue(tokens.refresh_token);
|
|
282
|
+
if (!accessToken) {
|
|
283
|
+
throw new CxError(`account '${account}' is missing tokens.access_token`, 1);
|
|
284
|
+
}
|
|
285
|
+
if (!refreshToken) {
|
|
286
|
+
throw new CxError(`account '${account}' is missing tokens.refresh_token`, 1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
...tokens,
|
|
291
|
+
access_token: accessToken,
|
|
292
|
+
refresh_token: refreshToken,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function readCodexAccountPayload(
|
|
297
|
+
account: string,
|
|
298
|
+
env: NodeJS.ProcessEnv,
|
|
299
|
+
): Promise<{ account: string; accountFile: string; payload: JsonObject }> {
|
|
300
|
+
const safeAccount = validateAccountName(account);
|
|
301
|
+
const codexPaths = getCodexPaths(env);
|
|
302
|
+
const accountFile = accountPathForName(codexPaths, safeAccount);
|
|
303
|
+
if (!(await pathExists(accountFile))) {
|
|
304
|
+
throw new CxError(`no account '${safeAccount}'`, 1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
account: safeAccount,
|
|
309
|
+
accountFile,
|
|
310
|
+
payload: await readJsonObject(accountFile, `Codex account '${safeAccount}'`),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function isoNow(): string {
|
|
315
|
+
return new Date().toISOString();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function randomCredentialId(): string {
|
|
319
|
+
return randomBytes(3).toString('hex');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function numericValue(value: unknown): number | null {
|
|
323
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function nextPoolPriority(entries: readonly unknown[]): number {
|
|
327
|
+
let max = -1;
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
if (!isRecord(entry)) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const priority = numericValue(entry.priority);
|
|
333
|
+
if (priority !== null && priority > max) {
|
|
334
|
+
max = priority;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return max + 1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function buildCxPoolEntry(
|
|
341
|
+
account: string,
|
|
342
|
+
tokens: JsonObject,
|
|
343
|
+
lastRefresh: string,
|
|
344
|
+
entries: readonly unknown[],
|
|
345
|
+
priorEntry: JsonObject | null,
|
|
346
|
+
): JsonObject {
|
|
347
|
+
const entry: JsonObject = {
|
|
348
|
+
id: stringValue(priorEntry?.id) || randomCredentialId(),
|
|
349
|
+
label: `cx:${account}`,
|
|
350
|
+
auth_type: 'oauth',
|
|
351
|
+
priority: numericValue(priorEntry?.priority) ?? nextPoolPriority(entries),
|
|
352
|
+
source: `manual:cx:${account}`,
|
|
353
|
+
access_token: stringValue(tokens.access_token),
|
|
354
|
+
refresh_token: stringValue(tokens.refresh_token),
|
|
355
|
+
base_url: HERMES_CODEX_BASE_URL,
|
|
356
|
+
last_refresh: lastRefresh,
|
|
357
|
+
request_count: numericValue(priorEntry?.request_count) ?? 0,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
for (const key of POOL_TOKEN_EXTRA_KEYS) {
|
|
361
|
+
if (tokens[key] !== undefined && tokens[key] !== null) {
|
|
362
|
+
entry[key] = tokens[key];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const key of ERROR_MARKER_KEYS) {
|
|
366
|
+
entry[key] = null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return entry;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function upsertCxPoolEntry(entries: readonly unknown[], account: string, tokens: JsonObject, lastRefresh: string): JsonObject[] {
|
|
373
|
+
const label = `cx:${account}`;
|
|
374
|
+
const source = `manual:cx:${account}`;
|
|
375
|
+
const kept: JsonObject[] = [];
|
|
376
|
+
let insertIndex = -1;
|
|
377
|
+
let priorEntry: JsonObject | null = null;
|
|
378
|
+
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
if (!isRecord(entry)) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const isSameAccount = entry.label === label || entry.source === source;
|
|
384
|
+
if (isSameAccount) {
|
|
385
|
+
if (insertIndex === -1) {
|
|
386
|
+
insertIndex = kept.length;
|
|
387
|
+
priorEntry = entry;
|
|
388
|
+
}
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
kept.push(entry);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const nextEntry = buildCxPoolEntry(account, tokens, lastRefresh, entries, priorEntry);
|
|
395
|
+
if (insertIndex === -1 || insertIndex >= kept.length) {
|
|
396
|
+
kept.push(nextEntry);
|
|
397
|
+
} else {
|
|
398
|
+
kept.splice(insertIndex, 0, nextEntry);
|
|
399
|
+
}
|
|
400
|
+
return kept;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function ensureObjectProperty(parent: JsonObject, key: string): JsonObject {
|
|
404
|
+
const existing = parent[key];
|
|
405
|
+
if (isRecord(existing)) {
|
|
406
|
+
return existing;
|
|
407
|
+
}
|
|
408
|
+
const next: JsonObject = {};
|
|
409
|
+
parent[key] = next;
|
|
410
|
+
return next;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function stripYamlComment(value: string): string {
|
|
414
|
+
let quote: string | null = null;
|
|
415
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
416
|
+
const char = value[index];
|
|
417
|
+
if (quote) {
|
|
418
|
+
if (char === quote) {
|
|
419
|
+
quote = null;
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (char === '"' || char === "'") {
|
|
424
|
+
quote = char;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (char === '#') {
|
|
428
|
+
return value.slice(0, index);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function unquoteYamlScalar(value: string): string {
|
|
435
|
+
const trimmed = stripYamlComment(value).trim();
|
|
436
|
+
if (trimmed.length >= 2) {
|
|
437
|
+
const first = trimmed[0];
|
|
438
|
+
const last = trimmed[trimmed.length - 1];
|
|
439
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
440
|
+
return trimmed.slice(1, -1);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return trimmed;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isTopLevelModelLine(line: string): RegExpExecArray | null {
|
|
447
|
+
if (/^\s/u.test(line) || /^\s*(?:#|$)/u.test(line)) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
return /^model\s*:\s*(.*)$/u.exec(line);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function isIndentedOrBlank(line: string): boolean {
|
|
454
|
+
return /^\s/u.test(line) || /^\s*$/u.test(line);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isFlowMapping(value: string): boolean {
|
|
458
|
+
const scalar = unquoteYamlScalar(value);
|
|
459
|
+
return scalar.startsWith('{') && scalar.endsWith('}');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function replacementModelHeader(rawValue: string, provider: string): string[] {
|
|
463
|
+
const scalarValue = unquoteYamlScalar(rawValue);
|
|
464
|
+
const replacement = ['model:'];
|
|
465
|
+
if (scalarValue && scalarValue !== '{}' && scalarValue !== 'null' && !isFlowMapping(rawValue)) {
|
|
466
|
+
replacement.push(` default: ${scalarValue}`);
|
|
467
|
+
}
|
|
468
|
+
replacement.push(` provider: ${provider}`);
|
|
469
|
+
replacement.push(` base_url: ${HERMES_CODEX_BASE_URL}`);
|
|
470
|
+
return replacement;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function updateHermesConfigProviderText(text: string, provider: string): string {
|
|
474
|
+
const normalized = text.replaceAll('\r\n', '\n');
|
|
475
|
+
const lines = normalized.length > 0 ? normalized.split('\n') : [];
|
|
476
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
477
|
+
lines.pop();
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
481
|
+
const match = isTopLevelModelLine(lines[index] ?? '');
|
|
482
|
+
if (!match) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const rawValue = match[1] ?? '';
|
|
487
|
+
if (unquoteYamlScalar(rawValue)) {
|
|
488
|
+
lines.splice(index, 1, ...replacementModelHeader(rawValue, provider));
|
|
489
|
+
return `${lines.join('\n')}\n`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let end = index + 1;
|
|
493
|
+
while (end < lines.length && isIndentedOrBlank(lines[end] ?? '')) {
|
|
494
|
+
end += 1;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let providerSeen = false;
|
|
498
|
+
let baseUrlSeen = false;
|
|
499
|
+
const replacement: string[] = [];
|
|
500
|
+
for (let inner = index + 1; inner < end; inner += 1) {
|
|
501
|
+
const line = lines[inner] ?? '';
|
|
502
|
+
const childMatch = /^(\s*)(provider|base_url|api_key|api_mode)\s*:/u.exec(line);
|
|
503
|
+
if (!childMatch) {
|
|
504
|
+
replacement.push(line);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const indent = childMatch[1] && childMatch[1].length > 0 ? childMatch[1] : ' ';
|
|
509
|
+
const key = childMatch[2];
|
|
510
|
+
if (key === 'provider') {
|
|
511
|
+
if (!providerSeen) {
|
|
512
|
+
replacement.push(`${indent}provider: ${provider}`);
|
|
513
|
+
providerSeen = true;
|
|
514
|
+
}
|
|
515
|
+
} else if (key === 'base_url') {
|
|
516
|
+
if (!baseUrlSeen) {
|
|
517
|
+
replacement.push(`${indent}base_url: ${HERMES_CODEX_BASE_URL}`);
|
|
518
|
+
baseUrlSeen = true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Drop api_key/api_mode and duplicate provider/base_url entries to avoid stale provider-specific config.
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (!providerSeen) {
|
|
525
|
+
replacement.push(` provider: ${provider}`);
|
|
526
|
+
}
|
|
527
|
+
if (!baseUrlSeen) {
|
|
528
|
+
replacement.push(` base_url: ${HERMES_CODEX_BASE_URL}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
lines.splice(index + 1, end - index - 1, ...replacement);
|
|
532
|
+
return `${lines.join('\n')}\n`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (lines.length > 0 && (lines[lines.length - 1] ?? '').trim().length > 0) {
|
|
536
|
+
lines.push('');
|
|
537
|
+
}
|
|
538
|
+
lines.push('model:', ` provider: ${provider}`, ` base_url: ${HERMES_CODEX_BASE_URL}`);
|
|
539
|
+
return `${lines.join('\n')}\n`;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function updateHermesConfigProvider(configFile: string): Promise<void> {
|
|
543
|
+
let current = '';
|
|
544
|
+
try {
|
|
545
|
+
current = await readFile(configFile, 'utf8');
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!isNotFoundError(error)) {
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
await writeFilePrivate(configFile, updateHermesConfigProviderText(current, HERMES_OPENAI_CODEX_PROVIDER));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function flowMappingValueForKey(rawValue: string, key: string): string | null {
|
|
555
|
+
const scalar = stripYamlComment(rawValue).trim();
|
|
556
|
+
if (!scalar.startsWith('{') || !scalar.endsWith('}')) {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
const inner = scalar.slice(1, -1);
|
|
560
|
+
const match = new RegExp(`(?:^|,)\\s*${key}\\s*:\\s*([^,}]+)`, 'u').exec(inner);
|
|
561
|
+
return match ? unquoteYamlScalar(match[1] ?? '') || null : null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function readHermesConfiguredProvider(configFile: string): Promise<{
|
|
565
|
+
exists: boolean;
|
|
566
|
+
readable: boolean;
|
|
567
|
+
provider: string | null;
|
|
568
|
+
error: string | null;
|
|
569
|
+
}> {
|
|
570
|
+
let text: string;
|
|
571
|
+
try {
|
|
572
|
+
text = await readFile(configFile, 'utf8');
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (isNotFoundError(error)) {
|
|
575
|
+
return { exists: false, readable: false, provider: null, error: null };
|
|
576
|
+
}
|
|
577
|
+
return { exists: true, readable: false, provider: null, error: errorMessage(error) };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const lines = text.replaceAll('\r\n', '\n').split('\n');
|
|
581
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
582
|
+
const modelMatch = isTopLevelModelLine(lines[index] ?? '');
|
|
583
|
+
if (!modelMatch) {
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const inlineProvider = flowMappingValueForKey(modelMatch[1] ?? '', 'provider');
|
|
588
|
+
if (inlineProvider) {
|
|
589
|
+
return { exists: true, readable: true, provider: inlineProvider, error: null };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let end = index + 1;
|
|
593
|
+
while (end < lines.length && isIndentedOrBlank(lines[end] ?? '')) {
|
|
594
|
+
end += 1;
|
|
595
|
+
}
|
|
596
|
+
for (let inner = index + 1; inner < end; inner += 1) {
|
|
597
|
+
const providerMatch = /^\s*provider\s*:\s*(.*)$/u.exec(lines[inner] ?? '');
|
|
598
|
+
if (providerMatch) {
|
|
599
|
+
const provider = unquoteYamlScalar(providerMatch[1] ?? '');
|
|
600
|
+
return { exists: true, readable: true, provider: provider || null, error: null };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return { exists: true, readable: true, provider: null, error: null };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return { exists: true, readable: true, provider: null, error: null };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function providerStateFromStore(store: JsonObject): JsonObject | null {
|
|
610
|
+
const providers = store.providers;
|
|
611
|
+
if (!isRecord(providers)) {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
const state = providers[HERMES_OPENAI_CODEX_PROVIDER];
|
|
615
|
+
return isRecord(state) ? state : null;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function tokensFromProviderState(state: JsonObject | null): JsonObject | null {
|
|
619
|
+
if (!state) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
return isRecord(state.tokens) ? state.tokens : null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function openaiCodexPoolEntries(store: JsonObject): JsonObject[] {
|
|
626
|
+
const pool = store.credential_pool;
|
|
627
|
+
if (!isRecord(pool)) {
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
const entries = pool[HERMES_OPENAI_CODEX_PROVIDER];
|
|
631
|
+
if (!Array.isArray(entries)) {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
return entries.filter(isRecord);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function accountFromCxPoolEntry(entry: JsonObject): string | null {
|
|
638
|
+
const label = stringValue(entry.label);
|
|
639
|
+
if (label.startsWith('cx:')) {
|
|
640
|
+
return label.slice('cx:'.length).trim() || null;
|
|
641
|
+
}
|
|
642
|
+
const source = stringValue(entry.source);
|
|
643
|
+
if (source.startsWith('manual:cx:')) {
|
|
644
|
+
return source.slice('manual:cx:'.length).trim() || null;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function linkedAccountsFromPool(entries: readonly JsonObject[]): string[] {
|
|
650
|
+
const accounts: string[] = [];
|
|
651
|
+
for (const entry of entries) {
|
|
652
|
+
const account = accountFromCxPoolEntry(entry);
|
|
653
|
+
if (account && !accounts.includes(account)) {
|
|
654
|
+
accounts.push(account);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return accounts;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export async function useHermesAccount(account: string, options: HermesUseOptions = {}): Promise<HermesUseResult> {
|
|
661
|
+
const env = options.env ?? process.env;
|
|
662
|
+
const accountData = await readCodexAccountPayload(account, env);
|
|
663
|
+
const tokens = accountTokensFromPayload(accountData.payload, accountData.account);
|
|
664
|
+
const hermesPaths = getHermesPaths(options);
|
|
665
|
+
const lastRefresh = isoNow();
|
|
666
|
+
|
|
667
|
+
const store = await readHermesAuthStore(hermesPaths.authFile);
|
|
668
|
+
store.version = AUTH_STORE_VERSION;
|
|
669
|
+
store.updated_at = lastRefresh;
|
|
670
|
+
|
|
671
|
+
const providers = ensureObjectProperty(store, 'providers');
|
|
672
|
+
const currentState = isRecord(providers[HERMES_OPENAI_CODEX_PROVIDER])
|
|
673
|
+
? { ...(providers[HERMES_OPENAI_CODEX_PROVIDER] as JsonObject) }
|
|
674
|
+
: {};
|
|
675
|
+
providers[HERMES_OPENAI_CODEX_PROVIDER] = {
|
|
676
|
+
...currentState,
|
|
677
|
+
tokens,
|
|
678
|
+
last_refresh: lastRefresh,
|
|
679
|
+
auth_mode: 'chatgpt',
|
|
680
|
+
};
|
|
681
|
+
store.active_provider = HERMES_OPENAI_CODEX_PROVIDER;
|
|
682
|
+
|
|
683
|
+
const pool = ensureObjectProperty(store, 'credential_pool');
|
|
684
|
+
const currentEntries = Array.isArray(pool[HERMES_OPENAI_CODEX_PROVIDER])
|
|
685
|
+
? pool[HERMES_OPENAI_CODEX_PROVIDER]
|
|
686
|
+
: [];
|
|
687
|
+
pool[HERMES_OPENAI_CODEX_PROVIDER] = upsertCxPoolEntry(
|
|
688
|
+
currentEntries,
|
|
689
|
+
accountData.account,
|
|
690
|
+
tokens,
|
|
691
|
+
lastRefresh,
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
await writeJsonPrivate(hermesPaths.authFile, store);
|
|
695
|
+
let hermesConfigFile: string | null = null;
|
|
696
|
+
if (options.updateConfig !== false) {
|
|
697
|
+
await updateHermesConfigProvider(hermesPaths.configFile);
|
|
698
|
+
hermesConfigFile = hermesPaths.configFile;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
account: accountData.account,
|
|
703
|
+
codexAccountFile: accountData.accountFile,
|
|
704
|
+
hermesHome: hermesPaths.home,
|
|
705
|
+
hermesAuthFile: hermesPaths.authFile,
|
|
706
|
+
hermesConfigFile,
|
|
707
|
+
profile: hermesPaths.profile,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
export async function syncHermesAccount(account: string, options: HermesProfileOptions = {}): Promise<HermesSyncResult> {
|
|
712
|
+
const env = options.env ?? process.env;
|
|
713
|
+
const accountData = await readCodexAccountPayload(account, env);
|
|
714
|
+
const hermesPaths = getHermesPaths(options);
|
|
715
|
+
const store = await readHermesAuthStore(hermesPaths.authFile);
|
|
716
|
+
const providerState = providerStateFromStore(store);
|
|
717
|
+
const tokens = tokensFromProviderState(providerState);
|
|
718
|
+
if (!tokens) {
|
|
719
|
+
throw new CxError(`Hermes ${HERMES_OPENAI_CODEX_PROVIDER} tokens not found at ${hermesPaths.authFile}`, 1);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const accessToken = stringValue(tokens.access_token);
|
|
723
|
+
const refreshToken = stringValue(tokens.refresh_token);
|
|
724
|
+
if (!accessToken) {
|
|
725
|
+
throw new CxError(`Hermes ${HERMES_OPENAI_CODEX_PROVIDER} tokens are missing access_token`, 1);
|
|
726
|
+
}
|
|
727
|
+
if (!refreshToken) {
|
|
728
|
+
throw new CxError(`Hermes ${HERMES_OPENAI_CODEX_PROVIDER} tokens are missing refresh_token`, 1);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
await writeJsonPrivate(accountData.accountFile, {
|
|
732
|
+
...accountData.payload,
|
|
733
|
+
tokens: {
|
|
734
|
+
...tokens,
|
|
735
|
+
access_token: accessToken,
|
|
736
|
+
refresh_token: refreshToken,
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
account: accountData.account,
|
|
742
|
+
codexAccountFile: accountData.accountFile,
|
|
743
|
+
hermesHome: hermesPaths.home,
|
|
744
|
+
hermesAuthFile: hermesPaths.authFile,
|
|
745
|
+
profile: hermesPaths.profile,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export async function inspectHermesStatus(options: HermesProfileOptions = {}): Promise<HermesStatus> {
|
|
750
|
+
const hermesPaths = getHermesPaths(options);
|
|
751
|
+
const authStats = await statIfExists(hermesPaths.authFile);
|
|
752
|
+
const authExists = Boolean(authStats?.isFile());
|
|
753
|
+
let authReadable = false;
|
|
754
|
+
let authError: string | null = null;
|
|
755
|
+
let store: JsonObject = { version: AUTH_STORE_VERSION, providers: {} };
|
|
756
|
+
|
|
757
|
+
if (authExists) {
|
|
758
|
+
try {
|
|
759
|
+
store = await readHermesAuthStore(hermesPaths.authFile);
|
|
760
|
+
authReadable = true;
|
|
761
|
+
} catch (error) {
|
|
762
|
+
authError = errorMessage(error);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const providerState = authReadable ? providerStateFromStore(store) : null;
|
|
767
|
+
const tokens = tokensFromProviderState(providerState);
|
|
768
|
+
const poolEntries = authReadable ? openaiCodexPoolEntries(store) : [];
|
|
769
|
+
const linkedAccounts = linkedAccountsFromPool(poolEntries);
|
|
770
|
+
const config = await readHermesConfiguredProvider(hermesPaths.configFile);
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
hermesHome: hermesPaths.home,
|
|
774
|
+
authFile: hermesPaths.authFile,
|
|
775
|
+
configFile: hermesPaths.configFile,
|
|
776
|
+
profile: hermesPaths.profile,
|
|
777
|
+
authExists,
|
|
778
|
+
authReadable,
|
|
779
|
+
authError,
|
|
780
|
+
openaiCodexAuthExists: providerState !== null,
|
|
781
|
+
hasTokens: tokens !== null,
|
|
782
|
+
hasAccessToken: Boolean(tokens && stringValue(tokens.access_token)),
|
|
783
|
+
hasRefreshToken: Boolean(tokens && stringValue(tokens.refresh_token)),
|
|
784
|
+
lastRefresh: typeof providerState?.last_refresh === 'string' ? providerState.last_refresh : null,
|
|
785
|
+
authMode: typeof providerState?.auth_mode === 'string' ? providerState.auth_mode : null,
|
|
786
|
+
linkedAccount: linkedAccounts[0] ?? null,
|
|
787
|
+
linkedAccounts,
|
|
788
|
+
poolEntryCount: poolEntries.length,
|
|
789
|
+
configuredProvider: config.provider,
|
|
790
|
+
configExists: config.exists,
|
|
791
|
+
configReadable: config.readable,
|
|
792
|
+
configError: config.error,
|
|
793
|
+
};
|
|
794
|
+
}
|