@huui/cdx-switcher 1.8.7
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/NOTICE +18 -0
- package/README.md +226 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/cdx.mjs +4671 -0
- package/package.json +29 -0
package/cdx.mjs
ADDED
|
@@ -0,0 +1,4671 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import tab from "@bomb.sh/tab/commander";
|
|
4
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
5
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
9
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
|
|
11
|
+
import { createInterface } from "node:readline/promises";
|
|
12
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
13
|
+
import { Decrypter, Encrypter } from "age-encryption";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
//#region package.json
|
|
16
|
+
var version = "1.8.7";
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region lib/platform/path-resolver.ts
|
|
19
|
+
const envValue = (env, key) => {
|
|
20
|
+
const value = env[key];
|
|
21
|
+
if (!value) return void 0;
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
24
|
+
};
|
|
25
|
+
const resolvePiAuthPath = (env, homeDir, platform) => {
|
|
26
|
+
const piAgentDir = envValue(env, "PI_CODING_AGENT_DIR");
|
|
27
|
+
if (piAgentDir) return platform === "win32" ? path.win32.join(piAgentDir, "auth.json") : path.join(piAgentDir, "auth.json");
|
|
28
|
+
return platform === "win32" ? path.win32.join(homeDir, ".pi", "agent", "auth.json") : path.join(homeDir, ".pi", "agent", "auth.json");
|
|
29
|
+
};
|
|
30
|
+
const resolveXdgPaths = (env, homeDir, platform) => {
|
|
31
|
+
const configHome = envValue(env, "XDG_CONFIG_HOME") ?? path.join(homeDir, ".config");
|
|
32
|
+
const dataHome = envValue(env, "XDG_DATA_HOME") ?? path.join(homeDir, ".local", "share");
|
|
33
|
+
const configDir = path.join(configHome, "cdx");
|
|
34
|
+
return {
|
|
35
|
+
profile: "xdg",
|
|
36
|
+
configDir,
|
|
37
|
+
configPath: path.join(configDir, "accounts.json"),
|
|
38
|
+
authPath: path.join(dataHome, "opencode", "auth.json"),
|
|
39
|
+
codexAuthPath: path.join(homeDir, ".codex", "auth.json"),
|
|
40
|
+
piAuthPath: resolvePiAuthPath(env, homeDir, platform)
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const resolveWindowsPaths = (env, homeDir) => {
|
|
44
|
+
const winPath = path.win32;
|
|
45
|
+
const appData = envValue(env, "APPDATA") ?? winPath.join(homeDir, "AppData", "Roaming");
|
|
46
|
+
const localAppData = envValue(env, "LOCALAPPDATA") ?? winPath.join(homeDir, "AppData", "Local");
|
|
47
|
+
const configDir = winPath.join(appData, "cdx");
|
|
48
|
+
return {
|
|
49
|
+
profile: "windows-appdata",
|
|
50
|
+
configDir,
|
|
51
|
+
configPath: winPath.join(configDir, "accounts.json"),
|
|
52
|
+
authPath: winPath.join(localAppData, "opencode", "auth.json"),
|
|
53
|
+
codexAuthPath: winPath.join(homeDir, ".codex", "auth.json"),
|
|
54
|
+
piAuthPath: resolvePiAuthPath(env, homeDir, "win32")
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const resolveRuntimePaths = (input) => {
|
|
58
|
+
if (input.platform === "win32") return resolveWindowsPaths(input.env, input.homeDir);
|
|
59
|
+
return resolveXdgPaths(input.env, input.homeDir, input.platform);
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region lib/paths.ts
|
|
63
|
+
const toPathConfig = (paths) => ({
|
|
64
|
+
configDir: paths.configDir,
|
|
65
|
+
configPath: paths.configPath,
|
|
66
|
+
authPath: paths.authPath,
|
|
67
|
+
codexAuthPath: paths.codexAuthPath,
|
|
68
|
+
piAuthPath: paths.piAuthPath
|
|
69
|
+
});
|
|
70
|
+
const createDefaultPaths = () => {
|
|
71
|
+
const resolved = resolveRuntimePaths({
|
|
72
|
+
platform: process.platform,
|
|
73
|
+
env: process.env,
|
|
74
|
+
homeDir: os.homedir()
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
paths: toPathConfig(resolved),
|
|
78
|
+
resolution: {
|
|
79
|
+
platform: process.platform,
|
|
80
|
+
profile: resolved.profile
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const initial = createDefaultPaths();
|
|
85
|
+
let currentPaths = initial.paths;
|
|
86
|
+
let currentResolution = initial.resolution;
|
|
87
|
+
const getPaths = () => currentPaths;
|
|
88
|
+
const getPathResolutionInfo = () => currentResolution;
|
|
89
|
+
const setPaths = (paths) => {
|
|
90
|
+
currentPaths = {
|
|
91
|
+
...currentPaths,
|
|
92
|
+
...paths
|
|
93
|
+
};
|
|
94
|
+
if (paths.configDir && !paths.configPath) currentPaths.configPath = path.join(paths.configDir, "accounts.json");
|
|
95
|
+
};
|
|
96
|
+
const resetPaths = () => {
|
|
97
|
+
const next = createDefaultPaths();
|
|
98
|
+
currentPaths = next.paths;
|
|
99
|
+
currentResolution = next.resolution;
|
|
100
|
+
};
|
|
101
|
+
const createTestPaths = (testDir) => ({
|
|
102
|
+
configDir: path.join(testDir, "config"),
|
|
103
|
+
configPath: path.join(testDir, "config", "accounts.json"),
|
|
104
|
+
authPath: path.join(testDir, "auth", "auth.json"),
|
|
105
|
+
codexAuthPath: path.join(testDir, "codex", "auth.json"),
|
|
106
|
+
piAuthPath: path.join(testDir, "pi", "auth.json")
|
|
107
|
+
});
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region lib/config.ts
|
|
110
|
+
const isSecretStoreSelection = (value) => value === "auto" || value === "legacy-keychain";
|
|
111
|
+
const loadConfiguredSecretStoreSelection = async () => {
|
|
112
|
+
const { configPath } = getPaths();
|
|
113
|
+
if (!existsSync(configPath)) return;
|
|
114
|
+
try {
|
|
115
|
+
const raw = await readFile(configPath, "utf8");
|
|
116
|
+
const parsed = JSON.parse(raw);
|
|
117
|
+
return isSecretStoreSelection(parsed.secretStore) ? parsed.secretStore : void 0;
|
|
118
|
+
} catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const loadConfig = async () => {
|
|
123
|
+
const { configPath } = getPaths();
|
|
124
|
+
if (!existsSync(configPath)) throw new Error(`Missing config at ${configPath}. Create accounts.json to list Keychain services.`);
|
|
125
|
+
const raw = await readFile(configPath, "utf8");
|
|
126
|
+
const parsed = JSON.parse(raw);
|
|
127
|
+
if (!Array.isArray(parsed.accounts) || parsed.accounts.length === 0) throw new Error("accounts.json must include a non-empty accounts array.");
|
|
128
|
+
if (typeof parsed.current !== "number" || Number.isNaN(parsed.current)) parsed.current = 0;
|
|
129
|
+
if (!isSecretStoreSelection(parsed.secretStore)) delete parsed.secretStore;
|
|
130
|
+
return parsed;
|
|
131
|
+
};
|
|
132
|
+
const saveConfig = async (config) => {
|
|
133
|
+
const { configDir, configPath } = getPaths();
|
|
134
|
+
await mkdir(configDir, { recursive: true });
|
|
135
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
|
|
136
|
+
};
|
|
137
|
+
const configExists = () => {
|
|
138
|
+
const { configPath } = getPaths();
|
|
139
|
+
return existsSync(configPath);
|
|
140
|
+
};
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region lib/commands/errors.ts
|
|
143
|
+
const exitWithCommandError = (error) => {
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
process.stderr.write(`${message}\n`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
};
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region lib/auth.ts
|
|
150
|
+
const readExistingJson = async (filePath) => {
|
|
151
|
+
if (!existsSync(filePath)) return {};
|
|
152
|
+
try {
|
|
153
|
+
const raw = await readFile(filePath, "utf8");
|
|
154
|
+
const parsed = JSON.parse(raw);
|
|
155
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
156
|
+
} catch {
|
|
157
|
+
return {};
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const writeAuthFile = async (payload) => {
|
|
161
|
+
const { authPath } = getPaths();
|
|
162
|
+
await mkdir(path.dirname(authPath), { recursive: true });
|
|
163
|
+
const existing = await readExistingJson(authPath);
|
|
164
|
+
existing.openai = {
|
|
165
|
+
type: "oauth",
|
|
166
|
+
refresh: payload.refresh,
|
|
167
|
+
access: payload.access,
|
|
168
|
+
expires: payload.expires,
|
|
169
|
+
accountId: payload.accountId
|
|
170
|
+
};
|
|
171
|
+
await writeFile(authPath, JSON.stringify(existing, null, 2), "utf8");
|
|
172
|
+
};
|
|
173
|
+
const writeCodexAuthFile = async (payload) => {
|
|
174
|
+
const { codexAuthPath } = getPaths();
|
|
175
|
+
await mkdir(path.dirname(codexAuthPath), { recursive: true });
|
|
176
|
+
const existing = await readExistingJson(codexAuthPath);
|
|
177
|
+
const existingTokens = typeof existing.tokens === "object" && existing.tokens !== null ? existing.tokens : {};
|
|
178
|
+
existing.auth_mode = "chatgpt";
|
|
179
|
+
if (!("OPENAI_API_KEY" in existing)) existing.OPENAI_API_KEY = null;
|
|
180
|
+
existing.tokens = {
|
|
181
|
+
...existingTokens,
|
|
182
|
+
id_token: payload.idToken ?? null,
|
|
183
|
+
access_token: payload.access,
|
|
184
|
+
refresh_token: payload.refresh,
|
|
185
|
+
account_id: payload.accountId
|
|
186
|
+
};
|
|
187
|
+
existing.last_refresh = (/* @__PURE__ */ new Date()).toISOString();
|
|
188
|
+
await writeFile(codexAuthPath, JSON.stringify(existing, null, 2), "utf8");
|
|
189
|
+
};
|
|
190
|
+
const writePiAuthFile = async (payload) => {
|
|
191
|
+
const { piAuthPath } = getPaths();
|
|
192
|
+
await mkdir(path.dirname(piAuthPath), { recursive: true });
|
|
193
|
+
const existing = await readExistingJson(piAuthPath);
|
|
194
|
+
existing["openai-codex"] = {
|
|
195
|
+
type: "oauth",
|
|
196
|
+
access: payload.access,
|
|
197
|
+
refresh: payload.refresh,
|
|
198
|
+
expires: payload.expires,
|
|
199
|
+
accountId: payload.accountId
|
|
200
|
+
};
|
|
201
|
+
await writeFile(piAuthPath, JSON.stringify(existing, null, 2), "utf8");
|
|
202
|
+
};
|
|
203
|
+
const writeAllAuthFiles = async (payload) => {
|
|
204
|
+
await writeAuthFile(payload);
|
|
205
|
+
await writePiAuthFile(payload);
|
|
206
|
+
if (payload.idToken) {
|
|
207
|
+
await writeCodexAuthFile(payload);
|
|
208
|
+
return {
|
|
209
|
+
piWritten: true,
|
|
210
|
+
codexWritten: true,
|
|
211
|
+
codexMissingIdToken: false,
|
|
212
|
+
codexCleared: false
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const { codexAuthPath } = getPaths();
|
|
216
|
+
let codexCleared = false;
|
|
217
|
+
if (existsSync(codexAuthPath)) try {
|
|
218
|
+
await rm(codexAuthPath);
|
|
219
|
+
codexCleared = true;
|
|
220
|
+
} catch {
|
|
221
|
+
codexCleared = false;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
piWritten: true,
|
|
225
|
+
codexWritten: false,
|
|
226
|
+
codexMissingIdToken: true,
|
|
227
|
+
codexCleared
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region lib/platform/browser.ts
|
|
232
|
+
const getBrowserLauncher = (platform = process.platform, url) => {
|
|
233
|
+
if (platform === "darwin") return {
|
|
234
|
+
command: "open",
|
|
235
|
+
args: [url],
|
|
236
|
+
label: "open"
|
|
237
|
+
};
|
|
238
|
+
if (platform === "win32") return {
|
|
239
|
+
command: "rundll32.exe",
|
|
240
|
+
args: ["url.dll,FileProtocolHandler", url],
|
|
241
|
+
label: "rundll32.exe url.dll,FileProtocolHandler"
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
command: "xdg-open",
|
|
245
|
+
args: [url],
|
|
246
|
+
label: "xdg-open"
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
const isCommandAvailable$3 = (command, platform = process.platform) => {
|
|
250
|
+
const probe = platform === "win32" ? "where" : "which";
|
|
251
|
+
return Bun.spawnSync({
|
|
252
|
+
cmd: [probe, command],
|
|
253
|
+
stdout: "pipe",
|
|
254
|
+
stderr: "pipe"
|
|
255
|
+
}).exitCode === 0;
|
|
256
|
+
};
|
|
257
|
+
const getBrowserLauncherCapability = (platform = process.platform) => {
|
|
258
|
+
const launcher = getBrowserLauncher(platform, "https://example.com");
|
|
259
|
+
return {
|
|
260
|
+
command: launcher.command,
|
|
261
|
+
label: launcher.label,
|
|
262
|
+
available: isCommandAvailable$3(launcher.command, platform)
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
const openBrowserUrl = (url, options = {}) => {
|
|
266
|
+
const platform = options.platform ?? process.platform;
|
|
267
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
268
|
+
const commandAvailable = options.isCommandAvailableImpl ?? isCommandAvailable$3;
|
|
269
|
+
const launcher = getBrowserLauncher(platform, url);
|
|
270
|
+
if (!commandAvailable(launcher.command, platform)) return {
|
|
271
|
+
ok: false,
|
|
272
|
+
launcher,
|
|
273
|
+
reason: "launcher_missing",
|
|
274
|
+
error: `${launcher.command} not found in PATH`
|
|
275
|
+
};
|
|
276
|
+
try {
|
|
277
|
+
const child = spawnImpl(launcher.command, launcher.args, {
|
|
278
|
+
detached: true,
|
|
279
|
+
stdio: "ignore"
|
|
280
|
+
});
|
|
281
|
+
child.once("error", () => {});
|
|
282
|
+
child.unref();
|
|
283
|
+
return {
|
|
284
|
+
ok: true,
|
|
285
|
+
launcher
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
launcher,
|
|
291
|
+
reason: "spawn_failed",
|
|
292
|
+
error: error instanceof Error ? error.message : String(error)
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
//#endregion
|
|
297
|
+
//#region lib/platform/clipboard.ts
|
|
298
|
+
const isCommandAvailable$2 = (command, platform = process.platform) => {
|
|
299
|
+
const probe = platform === "win32" ? "where" : "which";
|
|
300
|
+
return Bun.spawnSync({
|
|
301
|
+
cmd: [probe, command],
|
|
302
|
+
stdout: "pipe",
|
|
303
|
+
stderr: "pipe"
|
|
304
|
+
}).exitCode === 0;
|
|
305
|
+
};
|
|
306
|
+
const isLikelyMoshSession = (env = process.env) => Boolean(env.MOSH_IP || env.MOSH_KEY || env.MOSH_PREDICTION_DISPLAY);
|
|
307
|
+
const isLikelyRemoteSession = (env = process.env) => {
|
|
308
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return true;
|
|
309
|
+
return isLikelyMoshSession(env);
|
|
310
|
+
};
|
|
311
|
+
const hasDisplayServer = (env) => Boolean(env.WAYLAND_DISPLAY || env.DISPLAY);
|
|
312
|
+
const supportsOsc52 = (isTTY, env) => {
|
|
313
|
+
if (!isTTY) return false;
|
|
314
|
+
if ((env.TERM ?? "").toLowerCase() === "dumb") return false;
|
|
315
|
+
return true;
|
|
316
|
+
};
|
|
317
|
+
const getLocalClipboardTargets = (platform, env, commandExists) => {
|
|
318
|
+
if (platform === "darwin") return commandExists("pbcopy", platform) ? [{
|
|
319
|
+
kind: "command",
|
|
320
|
+
method: "pbcopy",
|
|
321
|
+
command: "pbcopy",
|
|
322
|
+
args: []
|
|
323
|
+
}] : [];
|
|
324
|
+
if (platform === "win32") {
|
|
325
|
+
const targets = [];
|
|
326
|
+
if (commandExists("clip", platform)) targets.push({
|
|
327
|
+
kind: "command",
|
|
328
|
+
method: "clip",
|
|
329
|
+
command: "cmd",
|
|
330
|
+
args: ["/c", "clip"]
|
|
331
|
+
});
|
|
332
|
+
if (commandExists("powershell", platform)) targets.push({
|
|
333
|
+
kind: "command",
|
|
334
|
+
method: "powershell",
|
|
335
|
+
command: "powershell",
|
|
336
|
+
args: [
|
|
337
|
+
"-NoProfile",
|
|
338
|
+
"-Command",
|
|
339
|
+
"$v=[Console]::In.ReadToEnd(); Set-Clipboard -Value $v"
|
|
340
|
+
]
|
|
341
|
+
});
|
|
342
|
+
if (commandExists("pwsh", platform)) targets.push({
|
|
343
|
+
kind: "command",
|
|
344
|
+
method: "powershell",
|
|
345
|
+
command: "pwsh",
|
|
346
|
+
args: [
|
|
347
|
+
"-NoProfile",
|
|
348
|
+
"-Command",
|
|
349
|
+
"$v=[Console]::In.ReadToEnd(); Set-Clipboard -Value $v"
|
|
350
|
+
]
|
|
351
|
+
});
|
|
352
|
+
return targets;
|
|
353
|
+
}
|
|
354
|
+
const targets = [];
|
|
355
|
+
const wayland = Boolean(env.WAYLAND_DISPLAY);
|
|
356
|
+
const x11 = Boolean(env.DISPLAY);
|
|
357
|
+
if (isLikelyRemoteSession(env) && !hasDisplayServer(env)) return targets;
|
|
358
|
+
if (wayland && commandExists("wl-copy", platform)) targets.push({
|
|
359
|
+
kind: "command",
|
|
360
|
+
method: "wl-copy",
|
|
361
|
+
command: "wl-copy",
|
|
362
|
+
args: []
|
|
363
|
+
});
|
|
364
|
+
if ((x11 || !wayland) && commandExists("xclip", platform)) targets.push({
|
|
365
|
+
kind: "command",
|
|
366
|
+
method: "xclip",
|
|
367
|
+
command: "xclip",
|
|
368
|
+
args: ["-selection", "clipboard"]
|
|
369
|
+
});
|
|
370
|
+
if ((x11 || !wayland) && commandExists("xsel", platform)) targets.push({
|
|
371
|
+
kind: "command",
|
|
372
|
+
method: "xsel",
|
|
373
|
+
command: "xsel",
|
|
374
|
+
args: ["--clipboard", "--input"]
|
|
375
|
+
});
|
|
376
|
+
if (!wayland && commandExists("wl-copy", platform)) targets.push({
|
|
377
|
+
kind: "command",
|
|
378
|
+
method: "wl-copy",
|
|
379
|
+
command: "wl-copy",
|
|
380
|
+
args: []
|
|
381
|
+
});
|
|
382
|
+
return targets;
|
|
383
|
+
};
|
|
384
|
+
const resolveClipboardTargets = (context = {}, commandExists = isCommandAvailable$2) => {
|
|
385
|
+
const platform = context.platform ?? process.platform;
|
|
386
|
+
const env = context.env ?? process.env;
|
|
387
|
+
const isTTY = context.isTTY ?? (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY));
|
|
388
|
+
const localTargets = getLocalClipboardTargets(platform, env, commandExists);
|
|
389
|
+
const remote = isLikelyRemoteSession(env);
|
|
390
|
+
const allowOsc52 = supportsOsc52(isTTY, env);
|
|
391
|
+
const result = [];
|
|
392
|
+
if (remote && allowOsc52) result.push({
|
|
393
|
+
kind: "osc52",
|
|
394
|
+
method: "osc52"
|
|
395
|
+
});
|
|
396
|
+
result.push(...localTargets);
|
|
397
|
+
if (!remote && allowOsc52) result.push({
|
|
398
|
+
kind: "osc52",
|
|
399
|
+
method: "osc52"
|
|
400
|
+
});
|
|
401
|
+
return result;
|
|
402
|
+
};
|
|
403
|
+
const buildOsc52Sequence = (text, env = process.env) => {
|
|
404
|
+
const osc = `\u001b]52;c;${Buffer.from(text, "utf8").toString("base64")}\u0007`;
|
|
405
|
+
if (env.TMUX) return `\u001bPtmux;\u001b${osc}\u001b\\`;
|
|
406
|
+
const term = (env.TERM ?? "").toLowerCase();
|
|
407
|
+
if (env.STY || term.startsWith("screen")) return `\u001bP${osc}\u001b\\`;
|
|
408
|
+
return osc;
|
|
409
|
+
};
|
|
410
|
+
const defaultRunCommand = (command, args, input) => {
|
|
411
|
+
const result = spawnSync(command, args, {
|
|
412
|
+
input,
|
|
413
|
+
encoding: "utf8",
|
|
414
|
+
windowsHide: true
|
|
415
|
+
});
|
|
416
|
+
if (result.status === 0) return { ok: true };
|
|
417
|
+
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
418
|
+
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
|
419
|
+
return {
|
|
420
|
+
ok: false,
|
|
421
|
+
error: stderr || stdout || `exit status ${result.status ?? "unknown"}`
|
|
422
|
+
};
|
|
423
|
+
};
|
|
424
|
+
const tryCopyToClipboard = (text, options = {}) => {
|
|
425
|
+
const commandExists = options.commandExistsImpl ?? isCommandAvailable$2;
|
|
426
|
+
const runCommand = options.runCommandImpl ?? defaultRunCommand;
|
|
427
|
+
const writeStdout = options.writeStdoutImpl ?? ((chunk) => {
|
|
428
|
+
process.stdout.write(chunk);
|
|
429
|
+
});
|
|
430
|
+
const targets = resolveClipboardTargets(options, commandExists);
|
|
431
|
+
if (targets.length === 0) return {
|
|
432
|
+
ok: false,
|
|
433
|
+
method: "none",
|
|
434
|
+
error: "No clipboard method available in this environment"
|
|
435
|
+
};
|
|
436
|
+
const errors = [];
|
|
437
|
+
for (const target of targets) {
|
|
438
|
+
if (target.kind === "osc52") {
|
|
439
|
+
const env = options.env ?? process.env;
|
|
440
|
+
try {
|
|
441
|
+
writeStdout(buildOsc52Sequence(text, env));
|
|
442
|
+
if (isLikelyMoshSession(env)) return {
|
|
443
|
+
ok: true,
|
|
444
|
+
method: "osc52",
|
|
445
|
+
warning: "Mosh session detected: OSC52 clipboard updates may not work reliably. If clipboard did not update, copy the URL manually from above."
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
ok: true,
|
|
449
|
+
method: "osc52"
|
|
450
|
+
};
|
|
451
|
+
} catch (error) {
|
|
452
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
453
|
+
errors.push(`osc52: ${message}`);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const commandResult = runCommand(target.command, target.args, text);
|
|
458
|
+
if (commandResult.ok) return {
|
|
459
|
+
ok: true,
|
|
460
|
+
method: target.method
|
|
461
|
+
};
|
|
462
|
+
errors.push(`${target.method}: ${commandResult.error ?? "unknown error"}`);
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
method: "none",
|
|
467
|
+
error: errors.join("; ")
|
|
468
|
+
};
|
|
469
|
+
};
|
|
470
|
+
const escapePosixSingleQuoted = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
471
|
+
const buildClipboardHelperCommand = (text, context = {}, commandExists = isCommandAvailable$2) => {
|
|
472
|
+
const commandTarget = resolveClipboardTargets(context, commandExists).find((target) => target.kind === "command");
|
|
473
|
+
if (!commandTarget) return null;
|
|
474
|
+
if (commandTarget.method === "powershell") {
|
|
475
|
+
const escaped = text.replace(/'/g, "''");
|
|
476
|
+
return `${commandTarget.command} -NoProfile -Command "Set-Clipboard -Value '${escaped}'"`;
|
|
477
|
+
}
|
|
478
|
+
if (commandTarget.method === "clip") return `printf '%s' ${escapePosixSingleQuoted(text)} | cmd /c clip`;
|
|
479
|
+
return `printf '%s' ${escapePosixSingleQuoted(text)} | ${commandTarget.command} ${commandTarget.args.join(" ")}`.trim();
|
|
480
|
+
};
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region lib/keychain.ts
|
|
483
|
+
const SERVICE_PREFIX$3 = "cdx-openai-";
|
|
484
|
+
const getKeychainService = (accountId) => {
|
|
485
|
+
return `${SERVICE_PREFIX$3}${accountId}`;
|
|
486
|
+
};
|
|
487
|
+
const runSecurity = (args) => {
|
|
488
|
+
const result = Bun.spawnSync({
|
|
489
|
+
cmd: ["security", ...args],
|
|
490
|
+
stderr: "pipe",
|
|
491
|
+
stdout: "pipe"
|
|
492
|
+
});
|
|
493
|
+
if (result.exitCode !== 0) {
|
|
494
|
+
const message = result.stderr.toString().trim();
|
|
495
|
+
throw new Error(message || "Keychain command failed");
|
|
496
|
+
}
|
|
497
|
+
return result.stdout.toString();
|
|
498
|
+
};
|
|
499
|
+
const runSecuritySafe = (args) => {
|
|
500
|
+
const result = Bun.spawnSync({
|
|
501
|
+
cmd: ["security", ...args],
|
|
502
|
+
stderr: "pipe",
|
|
503
|
+
stdout: "pipe"
|
|
504
|
+
});
|
|
505
|
+
return {
|
|
506
|
+
success: result.exitCode === 0,
|
|
507
|
+
output: result.exitCode === 0 ? result.stdout.toString() : result.stderr.toString()
|
|
508
|
+
};
|
|
509
|
+
};
|
|
510
|
+
const runSecuritySafeAsync = async (args) => {
|
|
511
|
+
const childProcess = Bun.spawn(["security", ...args], {
|
|
512
|
+
stderr: "pipe",
|
|
513
|
+
stdout: "pipe"
|
|
514
|
+
});
|
|
515
|
+
const stdoutPromise = childProcess.stdout ? new Response(childProcess.stdout).text() : Promise.resolve("");
|
|
516
|
+
const stderrPromise = childProcess.stderr ? new Response(childProcess.stderr).text() : Promise.resolve("");
|
|
517
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
518
|
+
childProcess.exited,
|
|
519
|
+
stdoutPromise,
|
|
520
|
+
stderrPromise
|
|
521
|
+
]);
|
|
522
|
+
return {
|
|
523
|
+
success: exitCode === 0,
|
|
524
|
+
output: exitCode === 0 ? stdout : stderr
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
const saveKeychainPayload = (accountId, payload) => {
|
|
528
|
+
runSecurity([
|
|
529
|
+
"add-generic-password",
|
|
530
|
+
"-a",
|
|
531
|
+
accountId,
|
|
532
|
+
"-s",
|
|
533
|
+
getKeychainService(accountId),
|
|
534
|
+
"-w",
|
|
535
|
+
JSON.stringify(payload),
|
|
536
|
+
"-U"
|
|
537
|
+
]);
|
|
538
|
+
};
|
|
539
|
+
const loadKeychainPayload = (accountId) => {
|
|
540
|
+
const raw = runSecurity([
|
|
541
|
+
"find-generic-password",
|
|
542
|
+
"-s",
|
|
543
|
+
getKeychainService(accountId),
|
|
544
|
+
"-w"
|
|
545
|
+
]).trim();
|
|
546
|
+
if (!raw) throw new Error(`No Keychain payload found for account ${accountId}.`);
|
|
547
|
+
const parsed = JSON.parse(raw);
|
|
548
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Keychain payload for account ${accountId} is missing required fields.`);
|
|
549
|
+
return parsed;
|
|
550
|
+
};
|
|
551
|
+
const deleteKeychainPayload = (accountId) => {
|
|
552
|
+
runSecurity([
|
|
553
|
+
"delete-generic-password",
|
|
554
|
+
"-s",
|
|
555
|
+
getKeychainService(accountId)
|
|
556
|
+
]);
|
|
557
|
+
};
|
|
558
|
+
const keychainPayloadExists = (accountId) => {
|
|
559
|
+
return runSecuritySafe([
|
|
560
|
+
"find-generic-password",
|
|
561
|
+
"-s",
|
|
562
|
+
getKeychainService(accountId)
|
|
563
|
+
]).success;
|
|
564
|
+
};
|
|
565
|
+
const listKeychainAccounts = () => {
|
|
566
|
+
const result = Bun.spawnSync({
|
|
567
|
+
cmd: ["security", "dump-keychain"],
|
|
568
|
+
stderr: "pipe",
|
|
569
|
+
stdout: "pipe"
|
|
570
|
+
});
|
|
571
|
+
if (result.exitCode !== 0) return [];
|
|
572
|
+
const output = result.stdout.toString();
|
|
573
|
+
const accounts = [];
|
|
574
|
+
const serviceRegex = new RegExp(`"svce"<blob>="${SERVICE_PREFIX$3}([^"]+)"`, "g");
|
|
575
|
+
let match;
|
|
576
|
+
while ((match = serviceRegex.exec(output)) !== null) if (match[1]) accounts.push(match[1]);
|
|
577
|
+
return [...new Set(accounts)];
|
|
578
|
+
};
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region lib/secrets/cross-keychain-overrides.ts
|
|
581
|
+
const LEGACY_MAX_PASSWORD_LENGTH = 4096;
|
|
582
|
+
const parseMaxPasswordLength = (value) => {
|
|
583
|
+
if (!value) return null;
|
|
584
|
+
const parsed = Number.parseInt(value, 10);
|
|
585
|
+
if (!Number.isInteger(parsed) || parsed <= LEGACY_MAX_PASSWORD_LENGTH) return null;
|
|
586
|
+
return parsed;
|
|
587
|
+
};
|
|
588
|
+
const getCrossKeychainBackendOverrides = () => {
|
|
589
|
+
return { max_password_length: parseMaxPasswordLength(process.env.CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH) ?? 16384 };
|
|
590
|
+
};
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region lib/secrets/fallback-consent.ts
|
|
593
|
+
const CONSENT_FILE = "secure-store-fallback-consent.json";
|
|
594
|
+
const CONSENT_ENV_BYPASS = "CDX_ALLOW_SECURE_STORE_FALLBACK";
|
|
595
|
+
const isBypassEnabled = () => {
|
|
596
|
+
const value = process.env[CONSENT_ENV_BYPASS];
|
|
597
|
+
if (!value) return false;
|
|
598
|
+
return [
|
|
599
|
+
"1",
|
|
600
|
+
"true",
|
|
601
|
+
"yes",
|
|
602
|
+
"y"
|
|
603
|
+
].includes(value.trim().toLowerCase());
|
|
604
|
+
};
|
|
605
|
+
const consentFilePath = () => path.join(getPaths().configDir, CONSENT_FILE);
|
|
606
|
+
const loadConsentMap = async () => {
|
|
607
|
+
try {
|
|
608
|
+
const raw = await readFile(consentFilePath(), "utf8");
|
|
609
|
+
return JSON.parse(raw).accepted ?? {};
|
|
610
|
+
} catch {
|
|
611
|
+
return {};
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
const saveConsentMap = async (accepted) => {
|
|
615
|
+
const { configDir } = getPaths();
|
|
616
|
+
await mkdir(configDir, { recursive: true });
|
|
617
|
+
const payload = { accepted };
|
|
618
|
+
await writeFile(consentFilePath(), JSON.stringify(payload, null, 2), "utf8");
|
|
619
|
+
};
|
|
620
|
+
const promptConsent = async (message) => {
|
|
621
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
622
|
+
process.stdout.write(`\n${message}\n\n`);
|
|
623
|
+
const rl = createInterface({
|
|
624
|
+
input: process.stdin,
|
|
625
|
+
output: process.stdout
|
|
626
|
+
});
|
|
627
|
+
try {
|
|
628
|
+
const normalized = (await rl.question("Do you want to continue with this fallback? [y/N]: ")).trim().toLowerCase();
|
|
629
|
+
return normalized === "y" || normalized === "yes";
|
|
630
|
+
} finally {
|
|
631
|
+
rl.close();
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
const ensureFallbackConsent = async (scope, warningMessage) => {
|
|
635
|
+
if (isBypassEnabled()) return;
|
|
636
|
+
const accepted = await loadConsentMap();
|
|
637
|
+
if (accepted[scope]) return;
|
|
638
|
+
if (!await promptConsent(warningMessage)) throw new Error(`Secure-store fallback usage was not approved for '${scope}'. Re-run in an interactive terminal to confirm, or set ${CONSENT_ENV_BYPASS}=1 if you accept the fallback risk.`);
|
|
639
|
+
accepted[scope] = { acceptedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
640
|
+
await saveConsentMap(accepted);
|
|
641
|
+
};
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region lib/secrets/linux-cross-keychain.ts
|
|
644
|
+
const SERVICE_PREFIX$2 = "cdx-openai-";
|
|
645
|
+
const LINUX_FALLBACK_SCOPE = "linux:cross-keychain:secret-service";
|
|
646
|
+
const MISSING_ENTRY_MARKERS = [
|
|
647
|
+
"no matching entry found in secure storage",
|
|
648
|
+
"password not found",
|
|
649
|
+
"no stored credentials found",
|
|
650
|
+
"credential not found",
|
|
651
|
+
"no result found"
|
|
652
|
+
];
|
|
653
|
+
const STORE_UNAVAILABLE_MARKERS = [
|
|
654
|
+
"unable to initialize linux secure-store backend",
|
|
655
|
+
"no keyring backend could be initialized",
|
|
656
|
+
"native keyring module not available",
|
|
657
|
+
"linux secure store is unavailable",
|
|
658
|
+
"secret service operation failed",
|
|
659
|
+
"couldn't access platform secure storage",
|
|
660
|
+
"dbus",
|
|
661
|
+
"d-bus",
|
|
662
|
+
"org.freedesktop.secrets",
|
|
663
|
+
"service unavailable"
|
|
664
|
+
];
|
|
665
|
+
let backendInitPromise$2 = null;
|
|
666
|
+
let selectedBackend$2 = null;
|
|
667
|
+
const getErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
668
|
+
const classifyLinuxSecureStoreError = (error) => {
|
|
669
|
+
const message = getErrorMessage(error).toLowerCase();
|
|
670
|
+
if (MISSING_ENTRY_MARKERS.some((marker) => message.includes(marker))) return "missing_entry";
|
|
671
|
+
if (STORE_UNAVAILABLE_MARKERS.some((marker) => message.includes(marker))) return "store_unavailable";
|
|
672
|
+
return "other";
|
|
673
|
+
};
|
|
674
|
+
const createLinuxSecureStoreUnavailableError = (details) => {
|
|
675
|
+
const guidance = "Linux secure store is unavailable. Ensure Secret Service is installed/running (for example gnome-keyring with secret-tool), then retry login.";
|
|
676
|
+
if (!details) return new Error(guidance);
|
|
677
|
+
return /* @__PURE__ */ new Error(`${guidance} Technical details: ${details}`);
|
|
678
|
+
};
|
|
679
|
+
const tryUseBackend$2 = async (backendId) => {
|
|
680
|
+
try {
|
|
681
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
682
|
+
return true;
|
|
683
|
+
} catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
const selectBackend$2 = async () => {
|
|
688
|
+
const backends = await listBackends();
|
|
689
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
690
|
+
if (available.has("native-linux") && await tryUseBackend$2("native-linux")) return "native-linux";
|
|
691
|
+
if (available.has("secret-service") && await tryUseBackend$2("secret-service")) return "secret-service";
|
|
692
|
+
if (await tryUseBackend$2("native-linux")) return "native-linux";
|
|
693
|
+
if (await tryUseBackend$2("secret-service")) return "secret-service";
|
|
694
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
695
|
+
};
|
|
696
|
+
const setActiveBackend = (backendId) => {
|
|
697
|
+
selectedBackend$2 = backendId;
|
|
698
|
+
backendInitPromise$2 = Promise.resolve();
|
|
699
|
+
};
|
|
700
|
+
const trySwitchBackend = async (backendId, options = {}) => {
|
|
701
|
+
if (options.forWrite && backendId === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
702
|
+
try {
|
|
703
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
704
|
+
setActiveBackend(backendId);
|
|
705
|
+
return true;
|
|
706
|
+
} catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
const ensureLinuxBackend = async (options = {}) => {
|
|
711
|
+
if (!backendInitPromise$2) backendInitPromise$2 = (async () => {
|
|
712
|
+
selectedBackend$2 = await selectBackend$2();
|
|
713
|
+
})();
|
|
714
|
+
try {
|
|
715
|
+
await backendInitPromise$2;
|
|
716
|
+
} catch {
|
|
717
|
+
backendInitPromise$2 = null;
|
|
718
|
+
selectedBackend$2 = null;
|
|
719
|
+
throw new Error("Unable to initialize Linux secure-store backend via cross-keychain.");
|
|
720
|
+
}
|
|
721
|
+
if (options.forWrite && selectedBackend$2 === "secret-service") await ensureFallbackConsent(LINUX_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Linux fallback backend is available.\nThis path relies on shell-based `secret-tool` operations for Secret Service access.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
722
|
+
};
|
|
723
|
+
const getLinuxCrossKeychainService = (accountId) => `${SERVICE_PREFIX$2}${accountId}`;
|
|
724
|
+
const parsePayload$2 = (accountId, raw) => {
|
|
725
|
+
let parsed;
|
|
726
|
+
try {
|
|
727
|
+
parsed = JSON.parse(raw);
|
|
728
|
+
} catch {
|
|
729
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
730
|
+
}
|
|
731
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
732
|
+
return parsed;
|
|
733
|
+
};
|
|
734
|
+
const withService$1 = async (accountId, run, options = {}) => {
|
|
735
|
+
try {
|
|
736
|
+
await ensureLinuxBackend(options);
|
|
737
|
+
} catch (error) {
|
|
738
|
+
throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
return await run(getLinuxCrossKeychainService(accountId));
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (classifyLinuxSecureStoreError(error) === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
|
|
744
|
+
throw error;
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const isInteractiveTerminal$2 = () => Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
748
|
+
const applyEnvAssignments$1 = (raw) => {
|
|
749
|
+
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
750
|
+
for (const line of lines) {
|
|
751
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*);?$/);
|
|
752
|
+
if (!match) continue;
|
|
753
|
+
const key = match[1];
|
|
754
|
+
const value = match[2].replace(/;$/, "");
|
|
755
|
+
if (key) process.env[key] = value;
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
const runCommandWithInput = async (command, args, input) => await new Promise((resolve) => {
|
|
759
|
+
const child = spawn(command, args, { stdio: [
|
|
760
|
+
"pipe",
|
|
761
|
+
"pipe",
|
|
762
|
+
"pipe"
|
|
763
|
+
] });
|
|
764
|
+
let stdout = "";
|
|
765
|
+
let stderr = "";
|
|
766
|
+
let spawnError = null;
|
|
767
|
+
child.stdout?.on("data", (chunk) => {
|
|
768
|
+
stdout += chunk.toString();
|
|
769
|
+
});
|
|
770
|
+
child.stderr?.on("data", (chunk) => {
|
|
771
|
+
stderr += chunk.toString();
|
|
772
|
+
});
|
|
773
|
+
child.once("error", (error) => {
|
|
774
|
+
spawnError = error.message;
|
|
775
|
+
});
|
|
776
|
+
child.once("close", (code) => {
|
|
777
|
+
resolve({
|
|
778
|
+
ok: spawnError === null && code === 0,
|
|
779
|
+
stdout: stdout.trim(),
|
|
780
|
+
stderr: stderr.trim(),
|
|
781
|
+
...spawnError ? { error: spawnError } : {}
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
child.stdin?.write(input);
|
|
785
|
+
child.stdin?.end();
|
|
786
|
+
});
|
|
787
|
+
const attemptInteractiveLinuxKeyringUnlock = async () => {
|
|
788
|
+
if (!isInteractiveTerminal$2()) return false;
|
|
789
|
+
const shouldUnlock = await p.confirm({
|
|
790
|
+
message: "Linux keyring appears locked. Unlock it now?",
|
|
791
|
+
initialValue: true
|
|
792
|
+
});
|
|
793
|
+
if (p.isCancel(shouldUnlock) || !shouldUnlock) return false;
|
|
794
|
+
const passphrase = await p.password({
|
|
795
|
+
message: "Enter Linux keyring password:",
|
|
796
|
+
validate: (value) => {
|
|
797
|
+
if (!value || !value.trim()) return "Password is required to unlock keyring.";
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
if (p.isCancel(passphrase) || !passphrase) return false;
|
|
801
|
+
const result = await runCommandWithInput("gnome-keyring-daemon", ["--unlock", "--components=secrets"], `${passphrase}\n`);
|
|
802
|
+
if (!result.ok) {
|
|
803
|
+
const details = result.error || result.stderr || result.stdout;
|
|
804
|
+
if (details) process.stderr.write(`cdx: keyring unlock failed (${details})\n`);
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
applyEnvAssignments$1(result.stdout);
|
|
808
|
+
return true;
|
|
809
|
+
};
|
|
810
|
+
const withLinuxUnlockRetry = async (run, options = {}) => {
|
|
811
|
+
let unlockAttempted = false;
|
|
812
|
+
while (true) try {
|
|
813
|
+
return await run();
|
|
814
|
+
} catch (error) {
|
|
815
|
+
const kind = classifyLinuxSecureStoreError(error);
|
|
816
|
+
if (!(!unlockAttempted && (kind === "store_unavailable" || kind === "missing_entry" && options.forWrite && options.retryOnMissingEntryForNativeWrite && selectedBackend$2 === "native-linux"))) throw error;
|
|
817
|
+
unlockAttempted = true;
|
|
818
|
+
if (!await attemptInteractiveLinuxKeyringUnlock()) throw error;
|
|
819
|
+
backendInitPromise$2 = null;
|
|
820
|
+
selectedBackend$2 = null;
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
const trySaveWithSecretServiceFallback = async (accountId, serializedPayload) => {
|
|
824
|
+
if (!await trySwitchBackend("secret-service", { forWrite: true })) return {
|
|
825
|
+
ok: false,
|
|
826
|
+
error: /* @__PURE__ */ new Error("Unable to switch Linux secure-store backend to secret-service fallback.")
|
|
827
|
+
};
|
|
828
|
+
try {
|
|
829
|
+
await setPassword(getLinuxCrossKeychainService(accountId), accountId, serializedPayload);
|
|
830
|
+
return { ok: true };
|
|
831
|
+
} catch (error) {
|
|
832
|
+
return {
|
|
833
|
+
ok: false,
|
|
834
|
+
error
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
const saveLinuxCrossKeychainPayload = async (accountId, payload) => {
|
|
839
|
+
const serialized = JSON.stringify(payload);
|
|
840
|
+
try {
|
|
841
|
+
await withLinuxUnlockRetry(() => withService$1(accountId, (service) => setPassword(service, accountId, serialized), { forWrite: true }), {
|
|
842
|
+
forWrite: true,
|
|
843
|
+
retryOnMissingEntryForNativeWrite: true
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
} catch (error) {
|
|
847
|
+
const kind = classifyLinuxSecureStoreError(error);
|
|
848
|
+
if (kind === "missing_entry" && selectedBackend$2 === "native-linux") {
|
|
849
|
+
const fallbackResult = await trySaveWithSecretServiceFallback(accountId, serialized);
|
|
850
|
+
if (fallbackResult.ok) return;
|
|
851
|
+
throw createLinuxSecureStoreUnavailableError(`Native backend could not create the credential entry (${getErrorMessage(error)}). Fallback secret-service backend also failed (${getErrorMessage(fallbackResult.error)}).`);
|
|
852
|
+
}
|
|
853
|
+
if (kind === "store_unavailable") throw createLinuxSecureStoreUnavailableError(getErrorMessage(error));
|
|
854
|
+
throw error;
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
const loadLinuxCrossKeychainPayload = async (accountId) => {
|
|
858
|
+
try {
|
|
859
|
+
const raw = await withLinuxUnlockRetry(() => withService$1(accountId, (service) => getPassword(service, accountId)));
|
|
860
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
861
|
+
return parsePayload$2(accountId, raw);
|
|
862
|
+
} catch (error) {
|
|
863
|
+
if (classifyLinuxSecureStoreError(error) === "missing_entry") throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
const deleteLinuxCrossKeychainPayload = async (accountId) => {
|
|
868
|
+
try {
|
|
869
|
+
await withLinuxUnlockRetry(() => withService$1(accountId, (service) => deletePassword(service, accountId)));
|
|
870
|
+
} catch (error) {
|
|
871
|
+
if (classifyLinuxSecureStoreError(error) === "missing_entry") return;
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
const linuxCrossKeychainPayloadExists = async (accountId) => {
|
|
876
|
+
try {
|
|
877
|
+
return await withLinuxUnlockRetry(() => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null));
|
|
878
|
+
} catch (error) {
|
|
879
|
+
if (classifyLinuxSecureStoreError(error) === "missing_entry") return false;
|
|
880
|
+
throw error;
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
//#endregion
|
|
884
|
+
//#region lib/secrets/macos-cross-keychain.ts
|
|
885
|
+
const SERVICE_PREFIX$1 = "cdx-openai-";
|
|
886
|
+
const MACOS_FALLBACK_SCOPE = "darwin:cross-keychain:macos";
|
|
887
|
+
let backendInitPromise$1 = null;
|
|
888
|
+
let selectedBackend$1 = null;
|
|
889
|
+
const tryUseBackend$1 = async (backendId) => {
|
|
890
|
+
try {
|
|
891
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
892
|
+
return true;
|
|
893
|
+
} catch {
|
|
894
|
+
return false;
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
const selectBackend$1 = async () => {
|
|
898
|
+
const backends = await listBackends();
|
|
899
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
900
|
+
if (available.has("native-macos") && await tryUseBackend$1("native-macos")) return "native-macos";
|
|
901
|
+
if (available.has("macos") && await tryUseBackend$1("macos")) return "macos";
|
|
902
|
+
if (await tryUseBackend$1("native-macos")) return "native-macos";
|
|
903
|
+
if (await tryUseBackend$1("macos")) return "macos";
|
|
904
|
+
throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
905
|
+
};
|
|
906
|
+
const ensureMacOSBackend = async (options = {}) => {
|
|
907
|
+
if (!backendInitPromise$1) backendInitPromise$1 = (async () => {
|
|
908
|
+
selectedBackend$1 = await selectBackend$1();
|
|
909
|
+
})();
|
|
910
|
+
try {
|
|
911
|
+
await backendInitPromise$1;
|
|
912
|
+
} catch {
|
|
913
|
+
backendInitPromise$1 = null;
|
|
914
|
+
selectedBackend$1 = null;
|
|
915
|
+
throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
916
|
+
}
|
|
917
|
+
if (options.forWrite && selectedBackend$1 === "macos") await ensureFallbackConsent(MACOS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain macOS fallback backend is available.\nThis path uses the `security` command to access Keychain.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while helper commands run.");
|
|
918
|
+
};
|
|
919
|
+
const resolveMacOSCrossKeychainBackendId$1 = async () => {
|
|
920
|
+
await ensureMacOSBackend();
|
|
921
|
+
if (!selectedBackend$1) throw new Error("Unable to initialize macOS keychain backend via cross-keychain.");
|
|
922
|
+
return selectedBackend$1;
|
|
923
|
+
};
|
|
924
|
+
const getMacOSCrossKeychainService = (accountId) => `${SERVICE_PREFIX$1}${accountId}`;
|
|
925
|
+
const parsePayload$1 = (accountId, raw) => {
|
|
926
|
+
let parsed;
|
|
927
|
+
try {
|
|
928
|
+
parsed = JSON.parse(raw);
|
|
929
|
+
} catch {
|
|
930
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
931
|
+
}
|
|
932
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
933
|
+
return parsed;
|
|
934
|
+
};
|
|
935
|
+
const withService = async (accountId, run, options = {}) => {
|
|
936
|
+
await ensureMacOSBackend(options);
|
|
937
|
+
return run(getMacOSCrossKeychainService(accountId));
|
|
938
|
+
};
|
|
939
|
+
const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
940
|
+
const loadMacOSCrossKeychainPayload = async (accountId) => {
|
|
941
|
+
const raw = await withService(accountId, (service) => getPassword(service, accountId));
|
|
942
|
+
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
943
|
+
return parsePayload$1(accountId, raw);
|
|
944
|
+
};
|
|
945
|
+
const deleteMacOSCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
|
|
946
|
+
const macosCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region lib/secrets/windows-cross-keychain.ts
|
|
949
|
+
const SERVICE_PREFIX = "cdx-openai-";
|
|
950
|
+
const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
|
|
951
|
+
const WINDOWS_VAULT_FILE = "accounts.windows.age";
|
|
952
|
+
const WINDOWS_VAULT_VERSION = 1;
|
|
953
|
+
const WINDOWS_VAULT_KEY_SERVICE = "cdx-openai-vault-passphrase";
|
|
954
|
+
const WINDOWS_VAULT_KEY_ACCOUNT = "windows-v1";
|
|
955
|
+
let backendInitPromise = null;
|
|
956
|
+
let selectedBackend = null;
|
|
957
|
+
const tryUseBackend = async (backendId) => {
|
|
958
|
+
try {
|
|
959
|
+
await useBackend(backendId, getCrossKeychainBackendOverrides());
|
|
960
|
+
return true;
|
|
961
|
+
} catch {
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
const selectBackend = async () => {
|
|
966
|
+
const backends = await listBackends();
|
|
967
|
+
const available = new Set(backends.map((backend) => backend.id));
|
|
968
|
+
if (available.has("native-windows") && await tryUseBackend("native-windows")) return "native-windows";
|
|
969
|
+
if (available.has("windows") && await tryUseBackend("windows")) return "windows";
|
|
970
|
+
if (await tryUseBackend("native-windows")) return "native-windows";
|
|
971
|
+
if (await tryUseBackend("windows")) return "windows";
|
|
972
|
+
throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
|
|
973
|
+
};
|
|
974
|
+
const ensureWindowsBackend = async (options = {}) => {
|
|
975
|
+
if (!backendInitPromise) backendInitPromise = (async () => {
|
|
976
|
+
selectedBackend = await selectBackend();
|
|
977
|
+
})();
|
|
978
|
+
try {
|
|
979
|
+
await backendInitPromise;
|
|
980
|
+
} catch {
|
|
981
|
+
backendInitPromise = null;
|
|
982
|
+
selectedBackend = null;
|
|
983
|
+
throw new Error("Unable to initialize Windows credential backend via cross-keychain.");
|
|
984
|
+
}
|
|
985
|
+
if (options.forWrite && selectedBackend === "windows") await ensureFallbackConsent(WINDOWS_FALLBACK_SCOPE, "⚠ Security warning: only the cross-keychain Windows fallback backend is available.\nThis path runs a PowerShell helper to access Windows Credential Manager.\nCompared to native bindings, secrets may be more exposed to process inspection/logging while the helper runs.");
|
|
986
|
+
};
|
|
987
|
+
const withWindowsBackend = async (run, options = {}) => {
|
|
988
|
+
await ensureWindowsBackend(options);
|
|
989
|
+
return run();
|
|
990
|
+
};
|
|
991
|
+
const getWindowsVaultPath = () => path.join(getPaths().configDir, WINDOWS_VAULT_FILE);
|
|
992
|
+
const createVaultPassphrase = () => randomBytes(32).toString("hex");
|
|
993
|
+
const getVaultPassphrase = async (options = {}) => {
|
|
994
|
+
const current = await getPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT);
|
|
995
|
+
if (current) return current;
|
|
996
|
+
if (!options.createIfMissing) return null;
|
|
997
|
+
const generated = createVaultPassphrase();
|
|
998
|
+
await setPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT, generated);
|
|
999
|
+
return generated;
|
|
1000
|
+
};
|
|
1001
|
+
const createEmptyVault = () => ({
|
|
1002
|
+
version: WINDOWS_VAULT_VERSION,
|
|
1003
|
+
accounts: {}
|
|
1004
|
+
});
|
|
1005
|
+
const parsePayload = (accountId, input) => {
|
|
1006
|
+
if (!input || typeof input !== "object") throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
1007
|
+
const parsed = input;
|
|
1008
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
1009
|
+
return {
|
|
1010
|
+
refresh: parsed.refresh,
|
|
1011
|
+
access: parsed.access,
|
|
1012
|
+
expires: parsed.expires,
|
|
1013
|
+
accountId: parsed.accountId,
|
|
1014
|
+
...parsed.idToken ? { idToken: parsed.idToken } : {}
|
|
1015
|
+
};
|
|
1016
|
+
};
|
|
1017
|
+
const parseVault = (raw, source) => {
|
|
1018
|
+
let parsed;
|
|
1019
|
+
try {
|
|
1020
|
+
parsed = JSON.parse(raw);
|
|
1021
|
+
} catch {
|
|
1022
|
+
throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
|
|
1023
|
+
}
|
|
1024
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
|
|
1025
|
+
const vault = parsed;
|
|
1026
|
+
const rawAccounts = vault.accounts;
|
|
1027
|
+
if (!rawAccounts || typeof rawAccounts !== "object") throw new Error(`Stored Windows credential vault (${source}) is missing account data.`);
|
|
1028
|
+
const accounts = {};
|
|
1029
|
+
for (const [accountId, payload] of Object.entries(rawAccounts)) accounts[accountId] = parsePayload(accountId, payload);
|
|
1030
|
+
return {
|
|
1031
|
+
version: typeof vault.version === "number" ? vault.version : WINDOWS_VAULT_VERSION,
|
|
1032
|
+
accounts
|
|
1033
|
+
};
|
|
1034
|
+
};
|
|
1035
|
+
const decryptVault = async (ciphertext, passphrase, source) => {
|
|
1036
|
+
const decrypter = new Decrypter();
|
|
1037
|
+
decrypter.addPassphrase(passphrase);
|
|
1038
|
+
let plaintext;
|
|
1039
|
+
try {
|
|
1040
|
+
plaintext = await decrypter.decrypt(ciphertext, "text");
|
|
1041
|
+
} catch {
|
|
1042
|
+
throw new Error(`Failed to decrypt Windows credential vault (${source}). Stored passphrase or vault file may be invalid.`);
|
|
1043
|
+
}
|
|
1044
|
+
return parseVault(plaintext, source);
|
|
1045
|
+
};
|
|
1046
|
+
const encryptVault = async (vault, passphrase) => {
|
|
1047
|
+
const encrypter = new Encrypter();
|
|
1048
|
+
encrypter.setPassphrase(passphrase);
|
|
1049
|
+
return encrypter.encrypt(JSON.stringify(vault));
|
|
1050
|
+
};
|
|
1051
|
+
const loadVault = async (passphrase) => {
|
|
1052
|
+
const vaultPath = getWindowsVaultPath();
|
|
1053
|
+
let ciphertext;
|
|
1054
|
+
try {
|
|
1055
|
+
ciphertext = await readFile(vaultPath);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
if (error?.code === "ENOENT") return createEmptyVault();
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
if (ciphertext.length === 0) return createEmptyVault();
|
|
1061
|
+
return decryptVault(ciphertext, passphrase, vaultPath);
|
|
1062
|
+
};
|
|
1063
|
+
const saveVault = async (vault, passphrase) => {
|
|
1064
|
+
const { configDir } = getPaths();
|
|
1065
|
+
const vaultPath = getWindowsVaultPath();
|
|
1066
|
+
await mkdir(configDir, { recursive: true });
|
|
1067
|
+
await writeFile(vaultPath, await encryptVault(vault, passphrase));
|
|
1068
|
+
};
|
|
1069
|
+
const loadLegacyPayload = async (accountId) => {
|
|
1070
|
+
const raw = await getPassword(getWindowsCrossKeychainService(accountId), accountId);
|
|
1071
|
+
if (raw === null) return null;
|
|
1072
|
+
let parsed;
|
|
1073
|
+
try {
|
|
1074
|
+
parsed = JSON.parse(raw);
|
|
1075
|
+
} catch {
|
|
1076
|
+
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
1077
|
+
}
|
|
1078
|
+
return parsePayload(accountId, parsed);
|
|
1079
|
+
};
|
|
1080
|
+
const deleteLegacyPayload = async (accountId) => {
|
|
1081
|
+
const service = getWindowsCrossKeychainService(accountId);
|
|
1082
|
+
try {
|
|
1083
|
+
await deletePassword(service, accountId);
|
|
1084
|
+
} catch {}
|
|
1085
|
+
};
|
|
1086
|
+
const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
|
|
1087
|
+
const saveWindowsCrossKeychainPayload = async (accountId, payload) => withWindowsBackend(async () => {
|
|
1088
|
+
const passphrase = await getVaultPassphrase({ createIfMissing: true });
|
|
1089
|
+
if (!passphrase) throw new Error("Unable to resolve Windows credential vault passphrase.");
|
|
1090
|
+
const vault = await loadVault(passphrase);
|
|
1091
|
+
vault.accounts[accountId] = payload;
|
|
1092
|
+
await saveVault(vault, passphrase);
|
|
1093
|
+
await deleteLegacyPayload(accountId);
|
|
1094
|
+
}, { forWrite: true });
|
|
1095
|
+
const loadWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
|
|
1096
|
+
const passphrase = await getVaultPassphrase();
|
|
1097
|
+
if (passphrase) {
|
|
1098
|
+
const payload = (await loadVault(passphrase)).accounts[accountId];
|
|
1099
|
+
if (payload) return payload;
|
|
1100
|
+
}
|
|
1101
|
+
const legacyPayload = await loadLegacyPayload(accountId);
|
|
1102
|
+
if (legacyPayload) return legacyPayload;
|
|
1103
|
+
throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
1104
|
+
});
|
|
1105
|
+
const deleteWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
|
|
1106
|
+
const passphrase = await getVaultPassphrase();
|
|
1107
|
+
if (passphrase) {
|
|
1108
|
+
const vault = await loadVault(passphrase);
|
|
1109
|
+
if (vault.accounts[accountId]) {
|
|
1110
|
+
delete vault.accounts[accountId];
|
|
1111
|
+
if (Object.keys(vault.accounts).length === 0) await rm(getWindowsVaultPath(), { force: true });
|
|
1112
|
+
else await saveVault(vault, passphrase);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
await deleteLegacyPayload(accountId);
|
|
1116
|
+
});
|
|
1117
|
+
const windowsCrossKeychainPayloadExists = async (accountId) => withWindowsBackend(async () => {
|
|
1118
|
+
const passphrase = await getVaultPassphrase();
|
|
1119
|
+
if (passphrase) {
|
|
1120
|
+
if ((await loadVault(passphrase)).accounts[accountId]) return true;
|
|
1121
|
+
}
|
|
1122
|
+
return await loadLegacyPayload(accountId) !== null;
|
|
1123
|
+
});
|
|
1124
|
+
//#endregion
|
|
1125
|
+
//#region lib/secrets/store.ts
|
|
1126
|
+
const MISSING_SECRET_STORE_ERROR_MARKERS = [
|
|
1127
|
+
"no stored credentials found",
|
|
1128
|
+
"no keychain payload found",
|
|
1129
|
+
"password not found",
|
|
1130
|
+
"no matching entry found in secure storage",
|
|
1131
|
+
"no result found"
|
|
1132
|
+
];
|
|
1133
|
+
const isMissingSecretStoreEntryError = (error) => {
|
|
1134
|
+
if (!(error instanceof Error)) return false;
|
|
1135
|
+
const message = error.message.toLowerCase();
|
|
1136
|
+
return MISSING_SECRET_STORE_ERROR_MARKERS.some((marker) => message.includes(marker));
|
|
1137
|
+
};
|
|
1138
|
+
const createMissingSecretStoreEntryError = (accountId) => /* @__PURE__ */ new Error(`No stored credentials found for account ${accountId}.`);
|
|
1139
|
+
const CACHED_ADAPTER_SYMBOL = Symbol.for("cdx.secretStore.cachedAdapter");
|
|
1140
|
+
const withSecretStoreCache = (adapter) => {
|
|
1141
|
+
if (adapter[CACHED_ADAPTER_SYMBOL]) return adapter;
|
|
1142
|
+
const payloadCache = /* @__PURE__ */ new Map();
|
|
1143
|
+
const existsCache = /* @__PURE__ */ new Map();
|
|
1144
|
+
const missingAccounts = /* @__PURE__ */ new Set();
|
|
1145
|
+
const inFlightLoads = /* @__PURE__ */ new Map();
|
|
1146
|
+
const markPresent = (accountId, payload) => {
|
|
1147
|
+
payloadCache.set(accountId, payload);
|
|
1148
|
+
existsCache.set(accountId, true);
|
|
1149
|
+
missingAccounts.delete(accountId);
|
|
1150
|
+
};
|
|
1151
|
+
const markMissing = (accountId) => {
|
|
1152
|
+
payloadCache.delete(accountId);
|
|
1153
|
+
existsCache.set(accountId, false);
|
|
1154
|
+
missingAccounts.add(accountId);
|
|
1155
|
+
};
|
|
1156
|
+
const loadAndCache = async (accountId) => {
|
|
1157
|
+
const existingPromise = inFlightLoads.get(accountId);
|
|
1158
|
+
if (existingPromise) return existingPromise;
|
|
1159
|
+
const promise = (async () => {
|
|
1160
|
+
try {
|
|
1161
|
+
const payload = await adapter.load(accountId);
|
|
1162
|
+
markPresent(accountId, payload);
|
|
1163
|
+
return payload;
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
|
|
1166
|
+
throw error;
|
|
1167
|
+
} finally {
|
|
1168
|
+
inFlightLoads.delete(accountId);
|
|
1169
|
+
}
|
|
1170
|
+
})();
|
|
1171
|
+
inFlightLoads.set(accountId, promise);
|
|
1172
|
+
return promise;
|
|
1173
|
+
};
|
|
1174
|
+
return {
|
|
1175
|
+
id: adapter.id,
|
|
1176
|
+
label: adapter.label,
|
|
1177
|
+
getServiceName: (accountId) => adapter.getServiceName(accountId),
|
|
1178
|
+
save: async (accountId, payload) => {
|
|
1179
|
+
await adapter.save(accountId, payload);
|
|
1180
|
+
markPresent(accountId, payload);
|
|
1181
|
+
},
|
|
1182
|
+
load: async (accountId) => {
|
|
1183
|
+
const cachedPayload = payloadCache.get(accountId);
|
|
1184
|
+
if (cachedPayload) return cachedPayload;
|
|
1185
|
+
if (missingAccounts.has(accountId)) throw createMissingSecretStoreEntryError(accountId);
|
|
1186
|
+
return loadAndCache(accountId);
|
|
1187
|
+
},
|
|
1188
|
+
delete: async (accountId) => {
|
|
1189
|
+
try {
|
|
1190
|
+
await adapter.delete(accountId);
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
if (isMissingSecretStoreEntryError(error)) markMissing(accountId);
|
|
1193
|
+
throw error;
|
|
1194
|
+
}
|
|
1195
|
+
markMissing(accountId);
|
|
1196
|
+
},
|
|
1197
|
+
exists: async (accountId) => {
|
|
1198
|
+
if (payloadCache.has(accountId)) return true;
|
|
1199
|
+
if (missingAccounts.has(accountId)) return false;
|
|
1200
|
+
const cachedExists = existsCache.get(accountId);
|
|
1201
|
+
if (cachedExists !== void 0) return cachedExists;
|
|
1202
|
+
try {
|
|
1203
|
+
await loadAndCache(accountId);
|
|
1204
|
+
return true;
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
if (isMissingSecretStoreEntryError(error)) return false;
|
|
1207
|
+
}
|
|
1208
|
+
const exists = await adapter.exists(accountId);
|
|
1209
|
+
existsCache.set(accountId, exists);
|
|
1210
|
+
if (!exists) missingAccounts.add(accountId);
|
|
1211
|
+
return exists;
|
|
1212
|
+
},
|
|
1213
|
+
listAccountIds: async () => {
|
|
1214
|
+
const accountIds = await adapter.listAccountIds();
|
|
1215
|
+
for (const accountId of accountIds) {
|
|
1216
|
+
existsCache.set(accountId, true);
|
|
1217
|
+
missingAccounts.delete(accountId);
|
|
1218
|
+
}
|
|
1219
|
+
return accountIds;
|
|
1220
|
+
},
|
|
1221
|
+
getCapability: () => adapter.getCapability(),
|
|
1222
|
+
[CACHED_ADAPTER_SYMBOL]: true
|
|
1223
|
+
};
|
|
1224
|
+
};
|
|
1225
|
+
const unsupportedError = (platform) => /* @__PURE__ */ new Error(`No default secret store adapter configured for platform '${platform}'. Only macOS, Windows, and Linux adapters are wired by default right now.`);
|
|
1226
|
+
const loadConfiguredAccountIds = async () => {
|
|
1227
|
+
if (!configExists()) return [];
|
|
1228
|
+
return (await loadConfig()).accounts.map((account) => account.accountId);
|
|
1229
|
+
};
|
|
1230
|
+
const createMacOSCrossKeychainAdapter = () => ({
|
|
1231
|
+
id: "macos-cross-keychain",
|
|
1232
|
+
label: "macOS Keychain (cross-keychain)",
|
|
1233
|
+
getServiceName: getMacOSCrossKeychainService,
|
|
1234
|
+
save: saveMacOSCrossKeychainPayload,
|
|
1235
|
+
load: loadMacOSCrossKeychainPayload,
|
|
1236
|
+
delete: deleteMacOSCrossKeychainPayload,
|
|
1237
|
+
exists: macosCrossKeychainPayloadExists,
|
|
1238
|
+
listAccountIds: async () => {
|
|
1239
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
1240
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
1241
|
+
accountId,
|
|
1242
|
+
exists: await macosCrossKeychainPayloadExists(accountId)
|
|
1243
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
1244
|
+
},
|
|
1245
|
+
getCapability: () => ({ available: true })
|
|
1246
|
+
});
|
|
1247
|
+
const createMacOSLegacyKeychainAdapter = () => ({
|
|
1248
|
+
id: "macos-legacy-keychain",
|
|
1249
|
+
label: "macOS Keychain (legacy security CLI)",
|
|
1250
|
+
getServiceName: getKeychainService,
|
|
1251
|
+
save: async (accountId, payload) => {
|
|
1252
|
+
saveKeychainPayload(accountId, payload);
|
|
1253
|
+
},
|
|
1254
|
+
load: async (accountId) => loadKeychainPayload(accountId),
|
|
1255
|
+
delete: async (accountId) => {
|
|
1256
|
+
deleteKeychainPayload(accountId);
|
|
1257
|
+
},
|
|
1258
|
+
exists: async (accountId) => keychainPayloadExists(accountId),
|
|
1259
|
+
listAccountIds: async () => listKeychainAccounts(),
|
|
1260
|
+
getCapability: () => ({ available: true })
|
|
1261
|
+
});
|
|
1262
|
+
const createWindowsCrossKeychainAdapter = () => ({
|
|
1263
|
+
id: "windows-cross-keychain",
|
|
1264
|
+
label: "Windows Credential Manager (cross-keychain)",
|
|
1265
|
+
getServiceName: getWindowsCrossKeychainService,
|
|
1266
|
+
save: saveWindowsCrossKeychainPayload,
|
|
1267
|
+
load: loadWindowsCrossKeychainPayload,
|
|
1268
|
+
delete: deleteWindowsCrossKeychainPayload,
|
|
1269
|
+
exists: windowsCrossKeychainPayloadExists,
|
|
1270
|
+
listAccountIds: async () => {
|
|
1271
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
1272
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
1273
|
+
accountId,
|
|
1274
|
+
exists: await windowsCrossKeychainPayloadExists(accountId)
|
|
1275
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
1276
|
+
},
|
|
1277
|
+
getCapability: () => ({ available: true })
|
|
1278
|
+
});
|
|
1279
|
+
const createLinuxCrossKeychainAdapter = () => ({
|
|
1280
|
+
id: "linux-cross-keychain",
|
|
1281
|
+
label: "Linux Secret Service (cross-keychain)",
|
|
1282
|
+
getServiceName: getLinuxCrossKeychainService,
|
|
1283
|
+
save: saveLinuxCrossKeychainPayload,
|
|
1284
|
+
load: loadLinuxCrossKeychainPayload,
|
|
1285
|
+
delete: deleteLinuxCrossKeychainPayload,
|
|
1286
|
+
exists: linuxCrossKeychainPayloadExists,
|
|
1287
|
+
listAccountIds: async () => {
|
|
1288
|
+
const accountIds = await loadConfiguredAccountIds();
|
|
1289
|
+
return (await Promise.all(accountIds.map(async (accountId) => ({
|
|
1290
|
+
accountId,
|
|
1291
|
+
exists: await linuxCrossKeychainPayloadExists(accountId)
|
|
1292
|
+
})))).filter((item) => item.exists).map((item) => item.accountId);
|
|
1293
|
+
},
|
|
1294
|
+
getCapability: () => ({ available: true })
|
|
1295
|
+
});
|
|
1296
|
+
const createUnsupportedAdapter = (platform) => ({
|
|
1297
|
+
id: "unsupported",
|
|
1298
|
+
label: "Unsupported (no adapter configured)",
|
|
1299
|
+
getServiceName: (accountId) => `cdx-openai-${accountId}`,
|
|
1300
|
+
save: async () => {
|
|
1301
|
+
throw unsupportedError(platform);
|
|
1302
|
+
},
|
|
1303
|
+
load: async () => {
|
|
1304
|
+
throw unsupportedError(platform);
|
|
1305
|
+
},
|
|
1306
|
+
delete: async () => {
|
|
1307
|
+
throw unsupportedError(platform);
|
|
1308
|
+
},
|
|
1309
|
+
exists: async () => false,
|
|
1310
|
+
listAccountIds: async () => [],
|
|
1311
|
+
getCapability: () => ({
|
|
1312
|
+
available: false,
|
|
1313
|
+
reason: "No default secure-store adapter available for this platform."
|
|
1314
|
+
})
|
|
1315
|
+
});
|
|
1316
|
+
const createRuntimeSecretStoreAdapter = (platform = process.platform) => {
|
|
1317
|
+
if (platform === "darwin") return createMacOSCrossKeychainAdapter();
|
|
1318
|
+
if (platform === "win32") return createWindowsCrossKeychainAdapter();
|
|
1319
|
+
if (platform === "linux") return createLinuxCrossKeychainAdapter();
|
|
1320
|
+
return createUnsupportedAdapter(platform);
|
|
1321
|
+
};
|
|
1322
|
+
const createSecretStoreAdapterFromSelection = (selection = "auto", platform = process.platform) => {
|
|
1323
|
+
if (selection === "legacy-keychain") {
|
|
1324
|
+
if (platform !== "darwin") throw new Error("The legacy keychain adapter is only available on macOS (darwin).");
|
|
1325
|
+
return createMacOSLegacyKeychainAdapter();
|
|
1326
|
+
}
|
|
1327
|
+
return createRuntimeSecretStoreAdapter(platform);
|
|
1328
|
+
};
|
|
1329
|
+
const resolveMacOSCrossKeychainBackendId = async (platform = process.platform) => {
|
|
1330
|
+
if (platform !== "darwin") return null;
|
|
1331
|
+
return resolveMacOSCrossKeychainBackendId$1();
|
|
1332
|
+
};
|
|
1333
|
+
let currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
|
|
1334
|
+
const getSecretStoreAdapter = () => currentSecretStoreAdapter;
|
|
1335
|
+
const setSecretStoreAdapter = (adapter) => {
|
|
1336
|
+
currentSecretStoreAdapter = withSecretStoreCache(adapter);
|
|
1337
|
+
};
|
|
1338
|
+
const resetSecretStoreAdapter = () => {
|
|
1339
|
+
currentSecretStoreAdapter = withSecretStoreCache(createRuntimeSecretStoreAdapter());
|
|
1340
|
+
};
|
|
1341
|
+
const getSecretStoreCapability = () => {
|
|
1342
|
+
const adapter = getSecretStoreAdapter();
|
|
1343
|
+
const capability = adapter.getCapability();
|
|
1344
|
+
return {
|
|
1345
|
+
id: adapter.id,
|
|
1346
|
+
label: adapter.label,
|
|
1347
|
+
available: capability.available,
|
|
1348
|
+
...capability.reason ? { reason: capability.reason } : {}
|
|
1349
|
+
};
|
|
1350
|
+
};
|
|
1351
|
+
//#endregion
|
|
1352
|
+
//#region lib/oauth/constants.ts
|
|
1353
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
1354
|
+
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
1355
|
+
const DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code";
|
|
1356
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
1357
|
+
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
1358
|
+
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
1359
|
+
const CALLBACK_PORT = 1455;
|
|
1360
|
+
/**
|
|
1361
|
+
* Preserve an explicit Codex Desktop originator override when it is present.
|
|
1362
|
+
* Regular terminals use the Desktop default above.
|
|
1363
|
+
*/
|
|
1364
|
+
const resolveOriginator = (env = process.env) => {
|
|
1365
|
+
return env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE?.trim() || "Codex Desktop";
|
|
1366
|
+
};
|
|
1367
|
+
//#endregion
|
|
1368
|
+
//#region lib/oauth/auth.ts
|
|
1369
|
+
/**
|
|
1370
|
+
* Create an RFC 7636 S256 PKCE verifier/challenge pair using Node's built-in
|
|
1371
|
+
* cryptography. Keeping this small primitive local avoids shipping an OAuth
|
|
1372
|
+
* helper dependency solely for PKCE generation.
|
|
1373
|
+
*/
|
|
1374
|
+
const createPKCE = () => {
|
|
1375
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1376
|
+
return {
|
|
1377
|
+
verifier,
|
|
1378
|
+
challenge: createHash("sha256").update(verifier).digest("base64url")
|
|
1379
|
+
};
|
|
1380
|
+
};
|
|
1381
|
+
const createState = () => {
|
|
1382
|
+
return randomBytes(32).toString("base64url");
|
|
1383
|
+
};
|
|
1384
|
+
const createAuthorizationFlow = async () => {
|
|
1385
|
+
const pkce = createPKCE();
|
|
1386
|
+
const state = createState();
|
|
1387
|
+
const url = new URL(AUTHORIZE_URL);
|
|
1388
|
+
url.searchParams.set("response_type", "code");
|
|
1389
|
+
url.searchParams.set("client_id", CLIENT_ID);
|
|
1390
|
+
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
1391
|
+
url.searchParams.set("scope", SCOPE);
|
|
1392
|
+
url.searchParams.set("code_challenge", pkce.challenge);
|
|
1393
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
1394
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
1395
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
1396
|
+
url.searchParams.set("state", state);
|
|
1397
|
+
url.searchParams.set("originator", resolveOriginator());
|
|
1398
|
+
return {
|
|
1399
|
+
pkce,
|
|
1400
|
+
state,
|
|
1401
|
+
url: url.toString().replaceAll("+", "%20")
|
|
1402
|
+
};
|
|
1403
|
+
};
|
|
1404
|
+
const truncateForLog = (value, maxLength = 300) => {
|
|
1405
|
+
if (value.length <= maxLength) return value;
|
|
1406
|
+
return `${value.slice(0, maxLength)}…`;
|
|
1407
|
+
};
|
|
1408
|
+
const isCloudflareChallengeResponse = (headers, bodyText) => {
|
|
1409
|
+
if (headers.get("cf-mitigated")?.toLowerCase() === "challenge") return true;
|
|
1410
|
+
if (!bodyText) return false;
|
|
1411
|
+
return [
|
|
1412
|
+
/<title>Just a moment\.\.\.<\/title>/i,
|
|
1413
|
+
/Enable JavaScript and cookies to continue/i,
|
|
1414
|
+
/challenge-platform/i,
|
|
1415
|
+
/_cf_chl_opt/i
|
|
1416
|
+
].some((pattern) => pattern.test(bodyText));
|
|
1417
|
+
};
|
|
1418
|
+
const parseOAuthErrorResponse = async (res) => {
|
|
1419
|
+
let rawBody;
|
|
1420
|
+
try {
|
|
1421
|
+
rawBody = await res.text();
|
|
1422
|
+
} catch {
|
|
1423
|
+
rawBody = void 0;
|
|
1424
|
+
}
|
|
1425
|
+
const failureReason = isCloudflareChallengeResponse(res.headers, rawBody) ? "cloudflare_challenge" : void 0;
|
|
1426
|
+
if (!rawBody) return { ...failureReason ? { failureReason } : {} };
|
|
1427
|
+
const trimmed = rawBody.trim();
|
|
1428
|
+
if (!trimmed) return { ...failureReason ? { failureReason } : {} };
|
|
1429
|
+
try {
|
|
1430
|
+
const json = JSON.parse(trimmed);
|
|
1431
|
+
return {
|
|
1432
|
+
...json.error ? { oauthError: json.error } : {},
|
|
1433
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {},
|
|
1434
|
+
responseBody: truncateForLog(JSON.stringify({
|
|
1435
|
+
...json.error ? { error: json.error } : {},
|
|
1436
|
+
...json.error_description ? { error_description: json.error_description } : {},
|
|
1437
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {}
|
|
1438
|
+
})),
|
|
1439
|
+
...failureReason ? { failureReason } : {}
|
|
1440
|
+
};
|
|
1441
|
+
} catch {
|
|
1442
|
+
return {
|
|
1443
|
+
responseBody: failureReason === "cloudflare_challenge" ? "Cloudflare challenge response detected (HTML page)" : truncateForLog(trimmed),
|
|
1444
|
+
...failureReason ? { failureReason } : {}
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
const startDeviceAuthorizationFlow = async () => {
|
|
1449
|
+
try {
|
|
1450
|
+
const res = await fetch(DEVICE_CODE_URL, {
|
|
1451
|
+
method: "POST",
|
|
1452
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1453
|
+
body: new URLSearchParams({
|
|
1454
|
+
client_id: CLIENT_ID,
|
|
1455
|
+
scope: SCOPE
|
|
1456
|
+
})
|
|
1457
|
+
});
|
|
1458
|
+
if (!res.ok) {
|
|
1459
|
+
const { oauthError, responseBody, failureReason } = await parseOAuthErrorResponse(res);
|
|
1460
|
+
return {
|
|
1461
|
+
type: "failed",
|
|
1462
|
+
error: failureReason === "cloudflare_challenge" ? "Device code request was blocked by a Cloudflare challenge response." : `Device code request failed with HTTP ${res.status} ${res.statusText}`,
|
|
1463
|
+
status: res.status,
|
|
1464
|
+
...oauthError ? { oauthError } : {},
|
|
1465
|
+
...responseBody ? { responseBody } : {},
|
|
1466
|
+
...failureReason ? { failureReason } : {}
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
const json = await res.json();
|
|
1470
|
+
if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return {
|
|
1471
|
+
type: "failed",
|
|
1472
|
+
error: "Device code response is missing required fields.",
|
|
1473
|
+
responseBody: truncateForLog(JSON.stringify(json))
|
|
1474
|
+
};
|
|
1475
|
+
return {
|
|
1476
|
+
type: "success",
|
|
1477
|
+
flow: {
|
|
1478
|
+
deviceCode: json.device_code,
|
|
1479
|
+
userCode: json.user_code,
|
|
1480
|
+
verificationUri: json.verification_uri,
|
|
1481
|
+
verificationUriComplete: json.verification_uri_complete,
|
|
1482
|
+
expiresIn: json.expires_in,
|
|
1483
|
+
interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
} catch (error) {
|
|
1487
|
+
return {
|
|
1488
|
+
type: "failed",
|
|
1489
|
+
error: `Device code request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
const pollDeviceAuthorizationToken = async (deviceCode) => {
|
|
1494
|
+
try {
|
|
1495
|
+
const res = await fetch(TOKEN_URL, {
|
|
1496
|
+
method: "POST",
|
|
1497
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1498
|
+
body: new URLSearchParams({
|
|
1499
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1500
|
+
device_code: deviceCode,
|
|
1501
|
+
client_id: CLIENT_ID
|
|
1502
|
+
})
|
|
1503
|
+
});
|
|
1504
|
+
if (res.ok) {
|
|
1505
|
+
const json = await res.json();
|
|
1506
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return {
|
|
1507
|
+
type: "failed",
|
|
1508
|
+
error: "Device token response is missing access_token/refresh_token/expires_in.",
|
|
1509
|
+
responseBody: truncateForLog(JSON.stringify(json))
|
|
1510
|
+
};
|
|
1511
|
+
return {
|
|
1512
|
+
type: "success",
|
|
1513
|
+
access: json.access_token,
|
|
1514
|
+
refresh: json.refresh_token,
|
|
1515
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1516
|
+
idToken: json.id_token
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
const { oauthError: errorCode, interval, responseBody, failureReason } = await parseOAuthErrorResponse(res);
|
|
1520
|
+
if (errorCode === "authorization_pending") return {
|
|
1521
|
+
type: "pending",
|
|
1522
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 5
|
|
1523
|
+
};
|
|
1524
|
+
if (errorCode === "slow_down") return {
|
|
1525
|
+
type: "slow_down",
|
|
1526
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 10
|
|
1527
|
+
};
|
|
1528
|
+
if (errorCode === "access_denied") return { type: "access_denied" };
|
|
1529
|
+
if (errorCode === "expired_token") return { type: "expired" };
|
|
1530
|
+
return {
|
|
1531
|
+
type: "failed",
|
|
1532
|
+
error: failureReason === "cloudflare_challenge" ? "Device token polling was blocked by a Cloudflare challenge response." : `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
|
|
1533
|
+
status: res.status,
|
|
1534
|
+
...errorCode ? { oauthError: errorCode } : {},
|
|
1535
|
+
...responseBody ? { responseBody } : {},
|
|
1536
|
+
...failureReason ? { failureReason } : {}
|
|
1537
|
+
};
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
return {
|
|
1540
|
+
type: "failed",
|
|
1541
|
+
error: `Device token polling request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
const exchangeAuthorizationCode = async (code, verifier) => {
|
|
1546
|
+
const res = await fetch(TOKEN_URL, {
|
|
1547
|
+
method: "POST",
|
|
1548
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1549
|
+
body: new URLSearchParams({
|
|
1550
|
+
grant_type: "authorization_code",
|
|
1551
|
+
client_id: CLIENT_ID,
|
|
1552
|
+
code,
|
|
1553
|
+
code_verifier: verifier,
|
|
1554
|
+
redirect_uri: REDIRECT_URI
|
|
1555
|
+
})
|
|
1556
|
+
});
|
|
1557
|
+
if (!res.ok) return { type: "failed" };
|
|
1558
|
+
const json = await res.json();
|
|
1559
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
1560
|
+
return {
|
|
1561
|
+
type: "success",
|
|
1562
|
+
access: json.access_token,
|
|
1563
|
+
refresh: json.refresh_token,
|
|
1564
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1565
|
+
idToken: json.id_token
|
|
1566
|
+
};
|
|
1567
|
+
};
|
|
1568
|
+
const refreshAccessToken = async (refreshToken) => {
|
|
1569
|
+
try {
|
|
1570
|
+
const response = await fetch(TOKEN_URL, {
|
|
1571
|
+
method: "POST",
|
|
1572
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1573
|
+
body: new URLSearchParams({
|
|
1574
|
+
grant_type: "refresh_token",
|
|
1575
|
+
refresh_token: refreshToken,
|
|
1576
|
+
client_id: CLIENT_ID
|
|
1577
|
+
})
|
|
1578
|
+
});
|
|
1579
|
+
if (!response.ok) return { type: "failed" };
|
|
1580
|
+
const json = await response.json();
|
|
1581
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
1582
|
+
return {
|
|
1583
|
+
type: "success",
|
|
1584
|
+
access: json.access_token,
|
|
1585
|
+
refresh: json.refresh_token,
|
|
1586
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1587
|
+
idToken: json.id_token
|
|
1588
|
+
};
|
|
1589
|
+
} catch {
|
|
1590
|
+
return { type: "failed" };
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
const decodeJWT = (token) => {
|
|
1594
|
+
try {
|
|
1595
|
+
const parts = token.split(".");
|
|
1596
|
+
if (parts.length !== 3) return null;
|
|
1597
|
+
const payload = parts[1];
|
|
1598
|
+
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
|
1599
|
+
return JSON.parse(decoded);
|
|
1600
|
+
} catch {
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
const extractAccountId = (accessToken) => {
|
|
1605
|
+
const payload = decodeJWT(accessToken);
|
|
1606
|
+
if (!payload) return null;
|
|
1607
|
+
const authClaim = payload["https://api.openai.com/auth"];
|
|
1608
|
+
if (payload.chatgpt_account_id) return payload.chatgpt_account_id;
|
|
1609
|
+
if (authClaim?.chatgpt_account_id) return authClaim.chatgpt_account_id;
|
|
1610
|
+
const organizationId = payload.organizations?.find((organization) => typeof organization?.id === "string" && organization.id.length > 0)?.id;
|
|
1611
|
+
if (organizationId) return organizationId;
|
|
1612
|
+
if (authClaim?.user_id) return authClaim.user_id;
|
|
1613
|
+
return payload.sub ?? null;
|
|
1614
|
+
};
|
|
1615
|
+
/**
|
|
1616
|
+
* Codex persists the ChatGPT account ID from the ID token. Prefer that token
|
|
1617
|
+
* because its claims are tailored for the Codex CLI, then fall back to the
|
|
1618
|
+
* access token for OAuth responses that do not include an ID token.
|
|
1619
|
+
*/
|
|
1620
|
+
const extractAccountIdFromTokens = (idToken, accessToken) => {
|
|
1621
|
+
return (idToken ? extractAccountId(idToken) : null) ?? extractAccountId(accessToken);
|
|
1622
|
+
};
|
|
1623
|
+
//#endregion
|
|
1624
|
+
//#region lib/oauth/server.ts
|
|
1625
|
+
const AUTH_TIMEOUT_MS = 300 * 1e3;
|
|
1626
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
1627
|
+
<html>
|
|
1628
|
+
<head>
|
|
1629
|
+
<title>cdx - Login Successful</title>
|
|
1630
|
+
<style>
|
|
1631
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a1a; color: #fff; }
|
|
1632
|
+
.container { text-align: center; padding: 2rem; }
|
|
1633
|
+
h1 { color: #10b981; margin-bottom: 1rem; }
|
|
1634
|
+
p { color: #9ca3af; }
|
|
1635
|
+
</style>
|
|
1636
|
+
</head>
|
|
1637
|
+
<body>
|
|
1638
|
+
<div class="container">
|
|
1639
|
+
<h1>Login Successful!</h1>
|
|
1640
|
+
<p>You can close this window and return to the terminal.</p>
|
|
1641
|
+
</div>
|
|
1642
|
+
</body>
|
|
1643
|
+
</html>`;
|
|
1644
|
+
const startOAuthServer = (state) => {
|
|
1645
|
+
let resolveCode = null;
|
|
1646
|
+
let hasResolved = false;
|
|
1647
|
+
const codePromise = new Promise((resolve) => {
|
|
1648
|
+
resolveCode = resolve;
|
|
1649
|
+
});
|
|
1650
|
+
let server;
|
|
1651
|
+
const finalize = (result) => {
|
|
1652
|
+
if (hasResolved) return;
|
|
1653
|
+
hasResolved = true;
|
|
1654
|
+
if (resolveCode) resolveCode(result);
|
|
1655
|
+
try {
|
|
1656
|
+
server.close();
|
|
1657
|
+
} catch {}
|
|
1658
|
+
};
|
|
1659
|
+
server = http.createServer((req, res) => {
|
|
1660
|
+
try {
|
|
1661
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
1662
|
+
if (url.pathname !== "/auth/callback") {
|
|
1663
|
+
res.statusCode = 404;
|
|
1664
|
+
res.end("Not found");
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (url.searchParams.get("state") !== state) {
|
|
1668
|
+
res.statusCode = 400;
|
|
1669
|
+
res.end("State mismatch");
|
|
1670
|
+
finalize(null);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const code = url.searchParams.get("code");
|
|
1674
|
+
if (!code) {
|
|
1675
|
+
res.statusCode = 400;
|
|
1676
|
+
res.end("Missing authorization code");
|
|
1677
|
+
finalize(null);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
res.statusCode = 200;
|
|
1681
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1682
|
+
res.end(SUCCESS_HTML);
|
|
1683
|
+
finalize({ code });
|
|
1684
|
+
} catch {
|
|
1685
|
+
res.statusCode = 500;
|
|
1686
|
+
res.end("Internal error");
|
|
1687
|
+
finalize(null);
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
return new Promise((resolve) => {
|
|
1691
|
+
server.listen(CALLBACK_PORT, "127.0.0.1", () => {
|
|
1692
|
+
const timeout = setTimeout(() => finalize(null), AUTH_TIMEOUT_MS);
|
|
1693
|
+
resolve({
|
|
1694
|
+
port: CALLBACK_PORT,
|
|
1695
|
+
ready: true,
|
|
1696
|
+
close: () => {
|
|
1697
|
+
clearTimeout(timeout);
|
|
1698
|
+
server.close();
|
|
1699
|
+
},
|
|
1700
|
+
waitForCode: () => codePromise
|
|
1701
|
+
});
|
|
1702
|
+
}).on("error", (error) => {
|
|
1703
|
+
const err = error;
|
|
1704
|
+
resolve({
|
|
1705
|
+
port: CALLBACK_PORT,
|
|
1706
|
+
ready: false,
|
|
1707
|
+
reason: err?.code === "EADDRINUSE" ? "port_in_use" : "listen_failed",
|
|
1708
|
+
...typeof err?.message === "string" ? { error: err.message } : {},
|
|
1709
|
+
...typeof err?.code === "string" ? { errorCode: err.code } : {},
|
|
1710
|
+
close: () => {
|
|
1711
|
+
try {
|
|
1712
|
+
server.close();
|
|
1713
|
+
} catch {}
|
|
1714
|
+
},
|
|
1715
|
+
waitForCode: async () => null
|
|
1716
|
+
});
|
|
1717
|
+
});
|
|
1718
|
+
});
|
|
1719
|
+
};
|
|
1720
|
+
//#endregion
|
|
1721
|
+
//#region lib/oauth/login.ts
|
|
1722
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1723
|
+
const isLikelyRemoteEnvironment = () => {
|
|
1724
|
+
if (process.platform !== "linux") return false;
|
|
1725
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
|
|
1726
|
+
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
1727
|
+
};
|
|
1728
|
+
const parseLsofListeningProcess = (output) => {
|
|
1729
|
+
const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
1730
|
+
let pid = null;
|
|
1731
|
+
let command;
|
|
1732
|
+
for (const line of lines) {
|
|
1733
|
+
if (line.startsWith("p") && pid === null) {
|
|
1734
|
+
const parsedPid = Number.parseInt(line.slice(1), 10);
|
|
1735
|
+
if (!Number.isNaN(parsedPid) && parsedPid > 0) pid = parsedPid;
|
|
1736
|
+
continue;
|
|
1737
|
+
}
|
|
1738
|
+
if (line.startsWith("c") && pid !== null && !command) {
|
|
1739
|
+
const parsedCommand = line.slice(1).trim();
|
|
1740
|
+
if (parsedCommand) command = parsedCommand;
|
|
1741
|
+
}
|
|
1742
|
+
if (pid !== null && command) break;
|
|
1743
|
+
}
|
|
1744
|
+
if (pid === null) return null;
|
|
1745
|
+
return {
|
|
1746
|
+
pid,
|
|
1747
|
+
...command ? { command } : {}
|
|
1748
|
+
};
|
|
1749
|
+
};
|
|
1750
|
+
const parseWindowsNetstatListeningPid = (output, port) => {
|
|
1751
|
+
const lines = output.split(/\r?\n/);
|
|
1752
|
+
const portSuffix = `:${port}`;
|
|
1753
|
+
for (const rawLine of lines) {
|
|
1754
|
+
const line = rawLine.trim();
|
|
1755
|
+
if (!line || !/LISTENING/i.test(line) || !line.includes(portSuffix)) continue;
|
|
1756
|
+
const pidRaw = line.split(/\s+/).at(-1);
|
|
1757
|
+
const parsedPid = pidRaw ? Number.parseInt(pidRaw, 10) : NaN;
|
|
1758
|
+
if (!Number.isNaN(parsedPid) && parsedPid > 0) return parsedPid;
|
|
1759
|
+
}
|
|
1760
|
+
return null;
|
|
1761
|
+
};
|
|
1762
|
+
const findListeningProcessOnPort = (port, platform = process.platform) => {
|
|
1763
|
+
if (platform === "win32") {
|
|
1764
|
+
const netstat = Bun.spawnSync({
|
|
1765
|
+
cmd: [
|
|
1766
|
+
"netstat",
|
|
1767
|
+
"-ano",
|
|
1768
|
+
"-p",
|
|
1769
|
+
"tcp"
|
|
1770
|
+
],
|
|
1771
|
+
stdout: "pipe",
|
|
1772
|
+
stderr: "pipe"
|
|
1773
|
+
});
|
|
1774
|
+
if (netstat.exitCode !== 0) return null;
|
|
1775
|
+
const pid = parseWindowsNetstatListeningPid(Buffer.from(netstat.stdout).toString("utf8"), port);
|
|
1776
|
+
if (!pid) return null;
|
|
1777
|
+
const tasklist = Bun.spawnSync({
|
|
1778
|
+
cmd: [
|
|
1779
|
+
"tasklist",
|
|
1780
|
+
"/FI",
|
|
1781
|
+
`PID eq ${pid}`,
|
|
1782
|
+
"/FO",
|
|
1783
|
+
"CSV",
|
|
1784
|
+
"/NH"
|
|
1785
|
+
],
|
|
1786
|
+
stdout: "pipe",
|
|
1787
|
+
stderr: "pipe"
|
|
1788
|
+
});
|
|
1789
|
+
if (tasklist.exitCode !== 0) return { pid };
|
|
1790
|
+
const line = Buffer.from(tasklist.stdout).toString("utf8").trim().split(/\r?\n/)[0] ?? "";
|
|
1791
|
+
if (!line || /No tasks are running/i.test(line)) return { pid };
|
|
1792
|
+
const command = line.replace(/^"|"$/g, "").split("\",\"")[0]?.trim();
|
|
1793
|
+
return {
|
|
1794
|
+
pid,
|
|
1795
|
+
...command ? { command } : {}
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
const lsof = Bun.spawnSync({
|
|
1799
|
+
cmd: [
|
|
1800
|
+
"lsof",
|
|
1801
|
+
"-nP",
|
|
1802
|
+
`-iTCP:${port}`,
|
|
1803
|
+
"-sTCP:LISTEN",
|
|
1804
|
+
"-FpPc"
|
|
1805
|
+
],
|
|
1806
|
+
stdout: "pipe",
|
|
1807
|
+
stderr: "pipe"
|
|
1808
|
+
});
|
|
1809
|
+
if (lsof.exitCode !== 0) return null;
|
|
1810
|
+
return parseLsofListeningProcess(Buffer.from(lsof.stdout).toString("utf8"));
|
|
1811
|
+
};
|
|
1812
|
+
const killProcessByPid = (pid) => {
|
|
1813
|
+
try {
|
|
1814
|
+
process.kill(pid, "SIGTERM");
|
|
1815
|
+
return { ok: true };
|
|
1816
|
+
} catch (error) {
|
|
1817
|
+
return {
|
|
1818
|
+
ok: false,
|
|
1819
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
const parseOAuthCallbackInput = (input) => {
|
|
1824
|
+
const trimmed = input.trim();
|
|
1825
|
+
if (!trimmed) return null;
|
|
1826
|
+
if (!trimmed.includes("://") && !trimmed.includes("code=") && !trimmed.includes("?")) return { code: trimmed };
|
|
1827
|
+
try {
|
|
1828
|
+
const parsedUrl = new URL(trimmed);
|
|
1829
|
+
const code = parsedUrl.searchParams.get("code");
|
|
1830
|
+
if (code) return {
|
|
1831
|
+
code,
|
|
1832
|
+
state: parsedUrl.searchParams.get("state") ?? void 0
|
|
1833
|
+
};
|
|
1834
|
+
} catch {}
|
|
1835
|
+
const queryLike = trimmed.startsWith("?") || trimmed.startsWith("#") ? trimmed.slice(1) : trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed;
|
|
1836
|
+
const params = new URLSearchParams(queryLike);
|
|
1837
|
+
const code = params.get("code");
|
|
1838
|
+
if (!code) return null;
|
|
1839
|
+
return {
|
|
1840
|
+
code,
|
|
1841
|
+
state: params.get("state") ?? void 0
|
|
1842
|
+
};
|
|
1843
|
+
};
|
|
1844
|
+
const promptBrowserFallbackChoice = async () => {
|
|
1845
|
+
const remoteHint = isLikelyRemoteEnvironment();
|
|
1846
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1847
|
+
const selected = remoteHint ? "device" : "manual";
|
|
1848
|
+
p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
|
|
1849
|
+
if (selected === "device") p.log.info("When interactive, manual URL copy/paste flow is recommended because device flow may be blocked by Cloudflare on some VPS/server IPs.");
|
|
1850
|
+
return selected;
|
|
1851
|
+
}
|
|
1852
|
+
const options = [
|
|
1853
|
+
{
|
|
1854
|
+
value: "manual",
|
|
1855
|
+
label: "Finish manually by copying URL (recommended)",
|
|
1856
|
+
hint: remoteHint ? "Best on SSH/VPS: open URL on any machine and paste callback URL/code back here" : "Open URL on any machine and paste callback URL/code back here"
|
|
1857
|
+
},
|
|
1858
|
+
{
|
|
1859
|
+
value: "device",
|
|
1860
|
+
label: "Use device OAuth flow",
|
|
1861
|
+
hint: "May fail on some VPS/servers due to Cloudflare challenge"
|
|
1862
|
+
},
|
|
1863
|
+
{
|
|
1864
|
+
value: "cancel",
|
|
1865
|
+
label: "Cancel login"
|
|
1866
|
+
}
|
|
1867
|
+
];
|
|
1868
|
+
const selection = await p.select({
|
|
1869
|
+
message: "Browser launcher is unavailable. How do you want to continue?",
|
|
1870
|
+
options
|
|
1871
|
+
});
|
|
1872
|
+
if (p.isCancel(selection)) return "cancel";
|
|
1873
|
+
return selection;
|
|
1874
|
+
};
|
|
1875
|
+
const promptPortConflictChoice = async (listeningProcess) => {
|
|
1876
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1877
|
+
p.log.info("Non-interactive terminal detected. Falling back to device OAuth flow.");
|
|
1878
|
+
return "device";
|
|
1879
|
+
}
|
|
1880
|
+
const killHint = listeningProcess ? `PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}` : "Attempt to free port and retry";
|
|
1881
|
+
const selection = await p.select({
|
|
1882
|
+
message: `Port ${CALLBACK_PORT} is already in use. How do you want to continue?`,
|
|
1883
|
+
options: [
|
|
1884
|
+
{
|
|
1885
|
+
value: "kill_and_retry",
|
|
1886
|
+
label: `Kill existing listener on port ${CALLBACK_PORT} and retry browser flow`,
|
|
1887
|
+
hint: killHint
|
|
1888
|
+
},
|
|
1889
|
+
{
|
|
1890
|
+
value: "device",
|
|
1891
|
+
label: "Continue with OAuth device flow",
|
|
1892
|
+
hint: "No local callback server required"
|
|
1893
|
+
},
|
|
1894
|
+
{
|
|
1895
|
+
value: "cancel",
|
|
1896
|
+
label: "Cancel login"
|
|
1897
|
+
}
|
|
1898
|
+
]
|
|
1899
|
+
});
|
|
1900
|
+
if (p.isCancel(selection)) return "cancel";
|
|
1901
|
+
return selection;
|
|
1902
|
+
};
|
|
1903
|
+
const maybeCopyAuthorizationUrlToClipboard = async (authorizationUrl) => {
|
|
1904
|
+
if (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY)) {
|
|
1905
|
+
const shouldCopy = await p.confirm({
|
|
1906
|
+
message: "Copy login URL to clipboard now?",
|
|
1907
|
+
initialValue: true
|
|
1908
|
+
});
|
|
1909
|
+
if (p.isCancel(shouldCopy) || !shouldCopy) return;
|
|
1910
|
+
}
|
|
1911
|
+
const copyResult = tryCopyToClipboard(authorizationUrl);
|
|
1912
|
+
if (copyResult.ok) {
|
|
1913
|
+
p.log.success(`Copied login URL to clipboard via ${copyResult.method}.`);
|
|
1914
|
+
if (copyResult.warning) {
|
|
1915
|
+
p.log.warning(copyResult.warning);
|
|
1916
|
+
const helper = buildClipboardHelperCommand(authorizationUrl);
|
|
1917
|
+
if (helper) p.log.message(`If needed, run this copy command instead:\n${helper}`);
|
|
1918
|
+
}
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
p.log.warning(`Could not copy login URL automatically${copyResult.error ? ` (${copyResult.error})` : ""}.`);
|
|
1922
|
+
const helper = buildClipboardHelperCommand(authorizationUrl);
|
|
1923
|
+
if (helper) p.log.message(`Try this copy command:\n${helper}`);
|
|
1924
|
+
};
|
|
1925
|
+
const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
|
|
1926
|
+
p.log.info("Manual login selected.");
|
|
1927
|
+
p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
|
|
1928
|
+
p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
|
|
1929
|
+
await maybeCopyAuthorizationUrlToClipboard(authorizationUrl);
|
|
1930
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1931
|
+
const response = await p.text({
|
|
1932
|
+
message: "Paste callback URL or authorization code:",
|
|
1933
|
+
placeholder: "http://localhost:1455/auth/callback?code=...&state=..."
|
|
1934
|
+
});
|
|
1935
|
+
if (p.isCancel(response)) {
|
|
1936
|
+
p.log.info("Login cancelled.");
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
const parsed = parseOAuthCallbackInput(String(response));
|
|
1940
|
+
if (!parsed) {
|
|
1941
|
+
p.log.warning("Could not parse input. Please paste a callback URL or code.");
|
|
1942
|
+
continue;
|
|
1943
|
+
}
|
|
1944
|
+
if (parsed.state && parsed.state !== expectedState) {
|
|
1945
|
+
p.log.error("State mismatch in callback URL. Please retry the login flow.");
|
|
1946
|
+
return null;
|
|
1947
|
+
}
|
|
1948
|
+
return parsed.code;
|
|
1949
|
+
}
|
|
1950
|
+
p.log.error("Failed to parse callback input after multiple attempts.");
|
|
1951
|
+
return null;
|
|
1952
|
+
};
|
|
1953
|
+
const runDeviceOAuthFlow = async (useSpinner) => {
|
|
1954
|
+
p.log.info("Device OAuth flow may fail on some VPS/servers because auth.openai.com can return a Cloudflare challenge.");
|
|
1955
|
+
p.log.info("Recommended alternative: run login without --device-flow and use manual URL copy/paste callback completion.");
|
|
1956
|
+
const deviceFlowResult = await startDeviceAuthorizationFlow();
|
|
1957
|
+
if (deviceFlowResult.type !== "success") {
|
|
1958
|
+
p.log.error("Device OAuth flow is not available right now.");
|
|
1959
|
+
p.log.error(`Technical details: ${deviceFlowResult.error}`);
|
|
1960
|
+
if (typeof deviceFlowResult.status === "number") p.log.error(`HTTP status: ${deviceFlowResult.status}`);
|
|
1961
|
+
if (deviceFlowResult.oauthError) p.log.error(`OAuth error: ${deviceFlowResult.oauthError}`);
|
|
1962
|
+
if (deviceFlowResult.responseBody) p.log.error(`Response: ${deviceFlowResult.responseBody}`);
|
|
1963
|
+
if (deviceFlowResult.failureReason === "cloudflare_challenge") {
|
|
1964
|
+
p.log.warning("Detected Cloudflare challenge on auth.openai.com.");
|
|
1965
|
+
p.log.info("Retry without --device-flow to use browser/manual callback flow.");
|
|
1966
|
+
}
|
|
1967
|
+
return null;
|
|
1968
|
+
}
|
|
1969
|
+
const deviceFlow = deviceFlowResult.flow;
|
|
1970
|
+
p.log.info("Using device OAuth flow.");
|
|
1971
|
+
p.log.message(`Verification URL: ${deviceFlow.verificationUri}`);
|
|
1972
|
+
p.log.message(`User code: ${deviceFlow.userCode}`);
|
|
1973
|
+
const launchResult = openBrowserUrl(deviceFlow.verificationUriComplete ?? deviceFlow.verificationUri);
|
|
1974
|
+
if (!launchResult.ok) {
|
|
1975
|
+
const msg = launchResult.error ?? "unknown error";
|
|
1976
|
+
p.log.warning(`Could not auto-open verification URL via ${launchResult.launcher.label} (${msg}).`);
|
|
1977
|
+
}
|
|
1978
|
+
const spinner = useSpinner ? p.spinner() : null;
|
|
1979
|
+
if (spinner) spinner.start("Waiting for device authorization...");
|
|
1980
|
+
else p.log.message("Waiting for device authorization...");
|
|
1981
|
+
let intervalMs = Math.max(deviceFlow.interval, 1) * 1e3;
|
|
1982
|
+
const deadline = Date.now() + deviceFlow.expiresIn * 1e3;
|
|
1983
|
+
while (Date.now() < deadline) {
|
|
1984
|
+
await sleep(intervalMs);
|
|
1985
|
+
const pollResult = await pollDeviceAuthorizationToken(deviceFlow.deviceCode);
|
|
1986
|
+
if (pollResult.type === "success") {
|
|
1987
|
+
if (spinner) spinner.stop("Device authorization completed.");
|
|
1988
|
+
else p.log.success("Device authorization completed.");
|
|
1989
|
+
return pollResult;
|
|
1990
|
+
}
|
|
1991
|
+
if (pollResult.type === "pending") {
|
|
1992
|
+
intervalMs = Math.max(pollResult.interval, 1) * 1e3;
|
|
1993
|
+
continue;
|
|
1994
|
+
}
|
|
1995
|
+
if (pollResult.type === "slow_down") {
|
|
1996
|
+
intervalMs = Math.max(pollResult.interval, Math.ceil(intervalMs / 1e3) + 5) * 1e3;
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
if (pollResult.type === "access_denied") {
|
|
2000
|
+
if (spinner) spinner.stop("Device authorization was denied.");
|
|
2001
|
+
else p.log.error("Device authorization was denied.");
|
|
2002
|
+
return null;
|
|
2003
|
+
}
|
|
2004
|
+
if (pollResult.type === "expired") {
|
|
2005
|
+
if (spinner) spinner.stop("Device authorization expired.");
|
|
2006
|
+
else p.log.error("Device authorization expired.");
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
if (spinner) spinner.stop("Device authorization failed.");
|
|
2010
|
+
else p.log.error("Device authorization failed.");
|
|
2011
|
+
if (pollResult.type === "failed") {
|
|
2012
|
+
if (pollResult.error) p.log.error(`Technical details: ${pollResult.error}`);
|
|
2013
|
+
if (typeof pollResult.status === "number") p.log.error(`HTTP status: ${pollResult.status}`);
|
|
2014
|
+
if (pollResult.oauthError) p.log.error(`OAuth error: ${pollResult.oauthError}`);
|
|
2015
|
+
if (pollResult.responseBody) p.log.error(`Response: ${pollResult.responseBody}`);
|
|
2016
|
+
if (pollResult.failureReason === "cloudflare_challenge") {
|
|
2017
|
+
p.log.warning("Detected Cloudflare challenge on auth.openai.com.");
|
|
2018
|
+
p.log.info("Retry without --device-flow to use browser/manual callback flow.");
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
return null;
|
|
2022
|
+
}
|
|
2023
|
+
if (spinner) spinner.stop("Device authorization timed out.");
|
|
2024
|
+
else p.log.error("Device authorization timed out.");
|
|
2025
|
+
return null;
|
|
2026
|
+
};
|
|
2027
|
+
const requestTokenViaOAuth = async (flow, options) => {
|
|
2028
|
+
if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
|
|
2029
|
+
let server = await startOAuthServer(flow.state);
|
|
2030
|
+
if (!server.ready) if (server.reason === "port_in_use") {
|
|
2031
|
+
p.log.warning(`Local callback server port ${CALLBACK_PORT} is already in use.`);
|
|
2032
|
+
const listeningProcess = findListeningProcessOnPort(CALLBACK_PORT);
|
|
2033
|
+
if (listeningProcess) p.log.info(`Detected listener on port ${CALLBACK_PORT}: PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}`);
|
|
2034
|
+
const choice = await promptPortConflictChoice(listeningProcess);
|
|
2035
|
+
if (choice === "cancel") {
|
|
2036
|
+
p.log.info("Login cancelled.");
|
|
2037
|
+
return null;
|
|
2038
|
+
}
|
|
2039
|
+
if (choice === "device") return runDeviceOAuthFlow(options.useSpinner);
|
|
2040
|
+
if (listeningProcess) {
|
|
2041
|
+
const confirmed = await p.confirm({
|
|
2042
|
+
message: `Kill PID ${listeningProcess.pid}${listeningProcess.command ? ` (${listeningProcess.command})` : ""}?`,
|
|
2043
|
+
initialValue: false
|
|
2044
|
+
});
|
|
2045
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
2046
|
+
p.log.info("Cancelled. No process was terminated.");
|
|
2047
|
+
return null;
|
|
2048
|
+
}
|
|
2049
|
+
const killResult = killProcessByPid(listeningProcess.pid);
|
|
2050
|
+
if (!killResult.ok) {
|
|
2051
|
+
p.log.error(`Failed to terminate PID ${listeningProcess.pid}.`);
|
|
2052
|
+
p.log.error(`Technical details: ${killResult.error ?? "unknown error"}`);
|
|
2053
|
+
p.log.info("Try device flow instead: cdx login --device-flow");
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
p.log.success(`Sent SIGTERM to PID ${listeningProcess.pid}. Retrying local server...`);
|
|
2057
|
+
await sleep(250);
|
|
2058
|
+
} else p.log.warning(`Could not identify which process is listening on port ${CALLBACK_PORT}. Retrying once...`);
|
|
2059
|
+
server = await startOAuthServer(flow.state);
|
|
2060
|
+
if (!server.ready) {
|
|
2061
|
+
p.log.error(`Failed to start local server on port ${CALLBACK_PORT} after retry.`);
|
|
2062
|
+
if (server.error) p.log.error(`Technical details: ${server.error}`);
|
|
2063
|
+
if (server.errorCode) p.log.error(`Error code: ${server.errorCode}`);
|
|
2064
|
+
p.log.info("Try device flow instead: cdx login --device-flow");
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
} else {
|
|
2068
|
+
p.log.error(`Failed to start local server on port ${CALLBACK_PORT}.`);
|
|
2069
|
+
if (server.error) p.log.error(`Technical details: ${server.error}`);
|
|
2070
|
+
if (server.errorCode) p.log.error(`Error code: ${server.errorCode}`);
|
|
2071
|
+
p.log.info("Please ensure the port is not in use.");
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
const spinner = options.useSpinner ? p.spinner() : null;
|
|
2075
|
+
let spinnerStarted = false;
|
|
2076
|
+
p.log.info("Opening browser for authentication...");
|
|
2077
|
+
const launchResult = openBrowserUrl(flow.url);
|
|
2078
|
+
if (!launchResult.ok) {
|
|
2079
|
+
const msg = launchResult.error ?? "unknown error";
|
|
2080
|
+
p.log.warning(`Could not auto-open browser via ${launchResult.launcher.label} (${msg}).`);
|
|
2081
|
+
}
|
|
2082
|
+
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
2083
|
+
if (launchResult.ok) {
|
|
2084
|
+
if (spinner) {
|
|
2085
|
+
spinner.start("Waiting for authentication...");
|
|
2086
|
+
spinnerStarted = true;
|
|
2087
|
+
}
|
|
2088
|
+
const result = await server.waitForCode();
|
|
2089
|
+
server.close();
|
|
2090
|
+
if (!result) {
|
|
2091
|
+
if (spinner) spinner.stop("Authentication timed out or failed.");
|
|
2092
|
+
else p.log.warning("Authentication timed out or failed.");
|
|
2093
|
+
return null;
|
|
2094
|
+
}
|
|
2095
|
+
if (spinner) spinner.message("Exchanging authorization code...");
|
|
2096
|
+
else p.log.message("Exchanging authorization code...");
|
|
2097
|
+
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
2098
|
+
if (tokenResult.type !== "success") {
|
|
2099
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
2100
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
2101
|
+
return null;
|
|
2102
|
+
}
|
|
2103
|
+
if (spinner) spinner.stop("Authentication completed.");
|
|
2104
|
+
return tokenResult;
|
|
2105
|
+
}
|
|
2106
|
+
const fallbackChoice = await promptBrowserFallbackChoice();
|
|
2107
|
+
if (fallbackChoice === "cancel") {
|
|
2108
|
+
server.close();
|
|
2109
|
+
p.log.info("Login cancelled.");
|
|
2110
|
+
return null;
|
|
2111
|
+
}
|
|
2112
|
+
if (fallbackChoice === "device") {
|
|
2113
|
+
server.close();
|
|
2114
|
+
return runDeviceOAuthFlow(options.useSpinner);
|
|
2115
|
+
}
|
|
2116
|
+
server.close();
|
|
2117
|
+
const code = await promptManualAuthorizationCode(flow.url, flow.state);
|
|
2118
|
+
if (!code) return null;
|
|
2119
|
+
if (spinner) if (spinnerStarted) spinner.message("Exchanging authorization code...");
|
|
2120
|
+
else {
|
|
2121
|
+
spinner.start("Exchanging authorization code...");
|
|
2122
|
+
spinnerStarted = true;
|
|
2123
|
+
}
|
|
2124
|
+
else p.log.message("Exchanging authorization code...");
|
|
2125
|
+
const tokenResult = await exchangeAuthorizationCode(code, flow.pkce.verifier);
|
|
2126
|
+
if (tokenResult.type !== "success") {
|
|
2127
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
2128
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
2129
|
+
return null;
|
|
2130
|
+
}
|
|
2131
|
+
if (spinner) spinner.stop("Authentication completed.");
|
|
2132
|
+
return tokenResult;
|
|
2133
|
+
};
|
|
2134
|
+
const addAccountToConfig = async (accountId, label) => {
|
|
2135
|
+
let config;
|
|
2136
|
+
const secretStore = getSecretStoreAdapter();
|
|
2137
|
+
if (configExists()) {
|
|
2138
|
+
config = await loadConfig();
|
|
2139
|
+
if (!config.accounts.some((a) => a.accountId === accountId)) config.accounts.push({
|
|
2140
|
+
accountId,
|
|
2141
|
+
keychainService: secretStore.getServiceName(accountId),
|
|
2142
|
+
...label ? { label } : {}
|
|
2143
|
+
});
|
|
2144
|
+
} else config = {
|
|
2145
|
+
current: 0,
|
|
2146
|
+
accounts: [{
|
|
2147
|
+
accountId,
|
|
2148
|
+
keychainService: secretStore.getServiceName(accountId),
|
|
2149
|
+
...label ? { label } : {}
|
|
2150
|
+
}]
|
|
2151
|
+
};
|
|
2152
|
+
await saveConfig(config);
|
|
2153
|
+
};
|
|
2154
|
+
const performRefresh = async (targetAccountId, label, options = {}) => {
|
|
2155
|
+
const keepAlive = setInterval(() => {}, 1e3);
|
|
2156
|
+
try {
|
|
2157
|
+
const displayName = label ?? targetAccountId;
|
|
2158
|
+
p.log.step(`Re-authenticating account "${displayName}"...`);
|
|
2159
|
+
const useSpinner = options.useSpinner ?? true;
|
|
2160
|
+
const authFlow = options.authFlow ?? "auto";
|
|
2161
|
+
let tokenResult = null;
|
|
2162
|
+
if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(useSpinner);
|
|
2163
|
+
else {
|
|
2164
|
+
let flow;
|
|
2165
|
+
try {
|
|
2166
|
+
flow = await createAuthorizationFlow();
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2169
|
+
p.log.error(`Failed to create authorization flow: ${msg}`);
|
|
2170
|
+
process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
|
|
2171
|
+
return null;
|
|
2172
|
+
}
|
|
2173
|
+
tokenResult = await requestTokenViaOAuth(flow, {
|
|
2174
|
+
useSpinner,
|
|
2175
|
+
authFlow
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
if (!tokenResult) return null;
|
|
2179
|
+
const newAccountId = extractAccountIdFromTokens(tokenResult.idToken, tokenResult.access);
|
|
2180
|
+
if (!newAccountId) {
|
|
2181
|
+
p.log.error("Failed to extract account ID from token.");
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
if (newAccountId !== targetAccountId) {
|
|
2185
|
+
p.log.error("Authentication completed for a different account.");
|
|
2186
|
+
throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
|
|
2187
|
+
}
|
|
2188
|
+
if (!useSpinner) p.log.message("Updating credentials...");
|
|
2189
|
+
const payload = {
|
|
2190
|
+
refresh: tokenResult.refresh,
|
|
2191
|
+
access: tokenResult.access,
|
|
2192
|
+
expires: tokenResult.expires,
|
|
2193
|
+
accountId: newAccountId,
|
|
2194
|
+
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
2195
|
+
};
|
|
2196
|
+
await getSecretStoreAdapter().save(newAccountId, payload);
|
|
2197
|
+
p.log.success("Credentials refreshed!");
|
|
2198
|
+
p.log.success(`Account "${displayName}" credentials updated in secure store.`);
|
|
2199
|
+
return { accountId: newAccountId };
|
|
2200
|
+
} finally {
|
|
2201
|
+
clearInterval(keepAlive);
|
|
2202
|
+
}
|
|
2203
|
+
};
|
|
2204
|
+
const performLogin = async (options = {}) => {
|
|
2205
|
+
p.intro("cdx login - Add OpenAI account");
|
|
2206
|
+
const authFlow = options.authFlow ?? "auto";
|
|
2207
|
+
let tokenResult = null;
|
|
2208
|
+
if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(true);
|
|
2209
|
+
else tokenResult = await requestTokenViaOAuth(await createAuthorizationFlow(), {
|
|
2210
|
+
useSpinner: true,
|
|
2211
|
+
authFlow
|
|
2212
|
+
});
|
|
2213
|
+
if (!tokenResult) return null;
|
|
2214
|
+
const accountId = extractAccountIdFromTokens(tokenResult.idToken, tokenResult.access);
|
|
2215
|
+
if (!accountId) {
|
|
2216
|
+
p.log.error("Failed to extract account ID from token.");
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
p.log.message("Saving credentials...");
|
|
2220
|
+
const payload = {
|
|
2221
|
+
refresh: tokenResult.refresh,
|
|
2222
|
+
access: tokenResult.access,
|
|
2223
|
+
expires: tokenResult.expires,
|
|
2224
|
+
accountId,
|
|
2225
|
+
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
2226
|
+
};
|
|
2227
|
+
await getSecretStoreAdapter().save(accountId, payload);
|
|
2228
|
+
p.log.success("Login successful!");
|
|
2229
|
+
const labelInput = await p.text({
|
|
2230
|
+
message: "Enter a label for this account (or press Enter to skip):",
|
|
2231
|
+
placeholder: "e.g. Work, Personal"
|
|
2232
|
+
});
|
|
2233
|
+
const label = !p.isCancel(labelInput) && labelInput?.trim() ? labelInput.trim() : void 0;
|
|
2234
|
+
await addAccountToConfig(accountId, label);
|
|
2235
|
+
const displayName = label ?? accountId;
|
|
2236
|
+
p.log.success(`Account "${displayName}" saved to secure store and config.`);
|
|
2237
|
+
p.outro("You can now use 'cdx switch' to activate this account.");
|
|
2238
|
+
return { accountId };
|
|
2239
|
+
};
|
|
2240
|
+
//#endregion
|
|
2241
|
+
//#region lib/refresh.ts
|
|
2242
|
+
const writeActiveAuthFilesIfCurrent = async (accountId) => {
|
|
2243
|
+
if (!configExists()) return null;
|
|
2244
|
+
const config = await loadConfig();
|
|
2245
|
+
const current = config.accounts[config.current];
|
|
2246
|
+
if (!current || current.accountId !== accountId) return null;
|
|
2247
|
+
return writeAllAuthFiles(await getSecretStoreAdapter().load(accountId));
|
|
2248
|
+
};
|
|
2249
|
+
//#endregion
|
|
2250
|
+
//#region lib/platform/capabilities.ts
|
|
2251
|
+
const getRuntimeCapabilities = () => {
|
|
2252
|
+
const pathResolution = getPathResolutionInfo();
|
|
2253
|
+
return {
|
|
2254
|
+
platform: process.platform,
|
|
2255
|
+
pathProfile: pathResolution.profile,
|
|
2256
|
+
secretStore: getSecretStoreCapability(),
|
|
2257
|
+
browserLauncher: getBrowserLauncherCapability(process.platform)
|
|
2258
|
+
};
|
|
2259
|
+
};
|
|
2260
|
+
//#endregion
|
|
2261
|
+
//#region lib/status.ts
|
|
2262
|
+
const formatDuration = (ms) => {
|
|
2263
|
+
const absMs = Math.abs(ms);
|
|
2264
|
+
const seconds = Math.floor(absMs / 1e3);
|
|
2265
|
+
const minutes = Math.floor(seconds / 60);
|
|
2266
|
+
const hours = Math.floor(minutes / 60);
|
|
2267
|
+
const days = Math.floor(hours / 24);
|
|
2268
|
+
if (days > 0) {
|
|
2269
|
+
const remainingHours = hours % 24;
|
|
2270
|
+
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
|
|
2271
|
+
}
|
|
2272
|
+
if (hours > 0) {
|
|
2273
|
+
const remainingMinutes = minutes % 60;
|
|
2274
|
+
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
2275
|
+
}
|
|
2276
|
+
if (minutes > 0) return `${minutes}m`;
|
|
2277
|
+
return `${seconds}s`;
|
|
2278
|
+
};
|
|
2279
|
+
const formatExpiry = (expiresAt) => {
|
|
2280
|
+
if (expiresAt === null) return "unknown";
|
|
2281
|
+
const remaining = expiresAt - Date.now();
|
|
2282
|
+
if (remaining <= 0) return `EXPIRED ${formatDuration(remaining)} ago`;
|
|
2283
|
+
return `expires in ${formatDuration(remaining)}`;
|
|
2284
|
+
};
|
|
2285
|
+
const readOpenCodeAuthAccount = async () => {
|
|
2286
|
+
const { authPath } = getPaths();
|
|
2287
|
+
if (!existsSync(authPath)) return {
|
|
2288
|
+
exists: false,
|
|
2289
|
+
accountId: null
|
|
2290
|
+
};
|
|
2291
|
+
try {
|
|
2292
|
+
const raw = await readFile(authPath, "utf8");
|
|
2293
|
+
return {
|
|
2294
|
+
exists: true,
|
|
2295
|
+
accountId: JSON.parse(raw).openai?.accountId ?? null
|
|
2296
|
+
};
|
|
2297
|
+
} catch {
|
|
2298
|
+
return {
|
|
2299
|
+
exists: true,
|
|
2300
|
+
accountId: null
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
const readCodexAuthAccount = async () => {
|
|
2305
|
+
const { codexAuthPath } = getPaths();
|
|
2306
|
+
if (!existsSync(codexAuthPath)) return {
|
|
2307
|
+
exists: false,
|
|
2308
|
+
accountId: null
|
|
2309
|
+
};
|
|
2310
|
+
try {
|
|
2311
|
+
const raw = await readFile(codexAuthPath, "utf8");
|
|
2312
|
+
return {
|
|
2313
|
+
exists: true,
|
|
2314
|
+
accountId: JSON.parse(raw).tokens?.account_id ?? null
|
|
2315
|
+
};
|
|
2316
|
+
} catch {
|
|
2317
|
+
return {
|
|
2318
|
+
exists: true,
|
|
2319
|
+
accountId: null
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
};
|
|
2323
|
+
const readPiAuthAccount = async () => {
|
|
2324
|
+
const { piAuthPath } = getPaths();
|
|
2325
|
+
if (!existsSync(piAuthPath)) return {
|
|
2326
|
+
exists: false,
|
|
2327
|
+
accountId: null
|
|
2328
|
+
};
|
|
2329
|
+
try {
|
|
2330
|
+
const raw = await readFile(piAuthPath, "utf8");
|
|
2331
|
+
return {
|
|
2332
|
+
exists: true,
|
|
2333
|
+
accountId: JSON.parse(raw)["openai-codex"]?.accountId ?? null
|
|
2334
|
+
};
|
|
2335
|
+
} catch {
|
|
2336
|
+
return {
|
|
2337
|
+
exists: true,
|
|
2338
|
+
accountId: null
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
const getAccountStatus = async (accountId, isCurrent, label) => {
|
|
2343
|
+
const secretStore = getSecretStoreAdapter();
|
|
2344
|
+
let secureStoreExists = false;
|
|
2345
|
+
let expiresAt = null;
|
|
2346
|
+
let hasIdToken = false;
|
|
2347
|
+
try {
|
|
2348
|
+
const payload = await secretStore.load(accountId);
|
|
2349
|
+
secureStoreExists = true;
|
|
2350
|
+
expiresAt = payload.expires;
|
|
2351
|
+
hasIdToken = !!payload.idToken;
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
secureStoreExists = !isMissingSecretStoreEntryError(error);
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
accountId,
|
|
2357
|
+
label,
|
|
2358
|
+
isCurrent,
|
|
2359
|
+
secureStoreExists,
|
|
2360
|
+
hasIdToken,
|
|
2361
|
+
expiresAt,
|
|
2362
|
+
expiresIn: formatExpiry(expiresAt)
|
|
2363
|
+
};
|
|
2364
|
+
};
|
|
2365
|
+
const getStatus = async () => {
|
|
2366
|
+
const accounts = [];
|
|
2367
|
+
if (configExists()) {
|
|
2368
|
+
const config = await loadConfig();
|
|
2369
|
+
for (let i = 0; i < config.accounts.length; i++) {
|
|
2370
|
+
const account = config.accounts[i];
|
|
2371
|
+
accounts.push(await getAccountStatus(account.accountId, i === config.current, account.label));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
const [opencodeAuth, codexAuth, piAuth] = await Promise.all([
|
|
2375
|
+
readOpenCodeAuthAccount(),
|
|
2376
|
+
readCodexAuthAccount(),
|
|
2377
|
+
readPiAuthAccount()
|
|
2378
|
+
]);
|
|
2379
|
+
return {
|
|
2380
|
+
accounts,
|
|
2381
|
+
opencodeAuth,
|
|
2382
|
+
codexAuth,
|
|
2383
|
+
piAuth,
|
|
2384
|
+
capabilities: getRuntimeCapabilities()
|
|
2385
|
+
};
|
|
2386
|
+
};
|
|
2387
|
+
//#endregion
|
|
2388
|
+
//#region lib/interactive.ts
|
|
2389
|
+
const getAccountDisplay = (accountId, isCurrent, label) => {
|
|
2390
|
+
const name = label ? `${label} (${accountId})` : accountId;
|
|
2391
|
+
return isCurrent ? `${name} (current)` : name;
|
|
2392
|
+
};
|
|
2393
|
+
const hasStoredCredentials = async (accountId) => getSecretStoreAdapter().exists(accountId);
|
|
2394
|
+
const loadStoredCredentials = async (accountId) => getSecretStoreAdapter().load(accountId);
|
|
2395
|
+
const getStoredAccountIds = async () => getSecretStoreAdapter().listAccountIds();
|
|
2396
|
+
const removeStoredCredentials = async (accountId) => {
|
|
2397
|
+
await getSecretStoreAdapter().delete(accountId);
|
|
2398
|
+
};
|
|
2399
|
+
const getRefreshExpiryState = async (accountId) => {
|
|
2400
|
+
if (!await hasStoredCredentials(accountId)) return "unknown [no secure store entry]";
|
|
2401
|
+
try {
|
|
2402
|
+
return formatExpiry((await loadStoredCredentials(accountId)).expires);
|
|
2403
|
+
} catch {
|
|
2404
|
+
return "unknown";
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
const handleListAccounts = async () => {
|
|
2408
|
+
if (!configExists()) {
|
|
2409
|
+
p.log.warning("No accounts configured. Use 'Add account' to get started.");
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
const config = await loadConfig();
|
|
2413
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
2414
|
+
p.log.info("Configured accounts:");
|
|
2415
|
+
for (const account of config.accounts) {
|
|
2416
|
+
const marker = account.accountId === currentAccountId ? "→ " : " ";
|
|
2417
|
+
const displayName = account.label ? `${account.label} (${account.accountId})` : account.accountId;
|
|
2418
|
+
const status = await hasStoredCredentials(account.accountId) ? "" : " (missing credentials)";
|
|
2419
|
+
p.log.message(`${marker}${displayName}${status}`);
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
const handleSwitchAccount = async () => {
|
|
2423
|
+
if (!configExists()) {
|
|
2424
|
+
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
const config = await loadConfig();
|
|
2428
|
+
if (config.accounts.length === 0) {
|
|
2429
|
+
p.log.warning("No accounts found. Use 'Add account' first.");
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
if (config.accounts.length === 1) {
|
|
2433
|
+
p.log.info("Only one account configured. Nothing to switch.");
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
2437
|
+
const options = config.accounts.map((account, index) => ({
|
|
2438
|
+
value: index,
|
|
2439
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
2440
|
+
}));
|
|
2441
|
+
const selected = await p.select({
|
|
2442
|
+
message: "Select account to activate:",
|
|
2443
|
+
options
|
|
2444
|
+
});
|
|
2445
|
+
if (p.isCancel(selected)) {
|
|
2446
|
+
p.log.info("Cancelled.");
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
const selectedAccount = config.accounts[selected];
|
|
2450
|
+
if (!selectedAccount) {
|
|
2451
|
+
p.log.error("Invalid selection.");
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
let payload;
|
|
2455
|
+
try {
|
|
2456
|
+
payload = await loadStoredCredentials(selectedAccount.accountId);
|
|
2457
|
+
} catch {
|
|
2458
|
+
p.log.error(`Missing credentials for account ${selectedAccount.label ?? selectedAccount.accountId}. Re-login with 'cdx login'.`);
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
const result = await writeAllAuthFiles(payload);
|
|
2462
|
+
config.current = selected;
|
|
2463
|
+
await saveConfig(config);
|
|
2464
|
+
const displayName = selectedAccount.label ?? selectedAccount.accountId;
|
|
2465
|
+
const opencodeMark = "✓";
|
|
2466
|
+
const piMark = result.piWritten ? "✓" : "✗";
|
|
2467
|
+
const codexMark = result.codexWritten ? "✓" : result.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
2468
|
+
p.log.success(`Switched to account ${displayName}`);
|
|
2469
|
+
p.log.message(` OpenCode: ${opencodeMark}`);
|
|
2470
|
+
p.log.message(` Pi Agent: ${piMark}`);
|
|
2471
|
+
p.log.message(` Codex CLI: ${codexMark}`);
|
|
2472
|
+
};
|
|
2473
|
+
const handleAddAccount = async () => {
|
|
2474
|
+
await performLogin();
|
|
2475
|
+
};
|
|
2476
|
+
const handleReloginAccount = async (reloginOptions = {}) => {
|
|
2477
|
+
if (!configExists()) {
|
|
2478
|
+
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
const config = await loadConfig();
|
|
2482
|
+
if (config.accounts.length === 0) {
|
|
2483
|
+
p.log.warning("No accounts to re-login.");
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
2487
|
+
const options = await Promise.all(config.accounts.map(async (account) => ({
|
|
2488
|
+
value: account.accountId,
|
|
2489
|
+
label: `${getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)} — ${await getRefreshExpiryState(account.accountId)}`
|
|
2490
|
+
})));
|
|
2491
|
+
const selected = await p.select({
|
|
2492
|
+
message: "Select account to re-login:",
|
|
2493
|
+
options
|
|
2494
|
+
});
|
|
2495
|
+
if (p.isCancel(selected)) {
|
|
2496
|
+
p.log.info("Cancelled.");
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
const accountId = selected;
|
|
2500
|
+
const account = config.accounts.find((a) => a.accountId === accountId);
|
|
2501
|
+
const expiryState = await getRefreshExpiryState(accountId);
|
|
2502
|
+
const displayName = account?.label ?? accountId;
|
|
2503
|
+
p.log.info(`Current token status for ${displayName}: ${expiryState}`);
|
|
2504
|
+
try {
|
|
2505
|
+
const result = await performRefresh(accountId, account?.label, {
|
|
2506
|
+
useSpinner: false,
|
|
2507
|
+
authFlow: reloginOptions.authFlow
|
|
2508
|
+
});
|
|
2509
|
+
if (!result) p.log.warning("Re-login was not completed.");
|
|
2510
|
+
else {
|
|
2511
|
+
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
2512
|
+
if (authResult) {
|
|
2513
|
+
const piMark = authResult.piWritten ? "✓" : "✗";
|
|
2514
|
+
const codexMark = authResult.codexWritten ? "✓" : authResult.codexCleared ? "⚠ missing id_token (cleared)" : "⚠ missing id_token";
|
|
2515
|
+
p.log.message("Updated active auth files:");
|
|
2516
|
+
p.log.message(" OpenCode: ✓");
|
|
2517
|
+
p.log.message(` Pi Agent: ${piMark}`);
|
|
2518
|
+
p.log.message(` Codex CLI: ${codexMark}`);
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
} catch (error) {
|
|
2522
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
2523
|
+
p.log.error(`Re-login failed: ${msg}`);
|
|
2524
|
+
}
|
|
2525
|
+
};
|
|
2526
|
+
const handleRemoveAccount = async () => {
|
|
2527
|
+
if (!configExists()) {
|
|
2528
|
+
p.log.warning("No accounts configured.");
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
const config = await loadConfig();
|
|
2532
|
+
if (config.accounts.length === 0) {
|
|
2533
|
+
p.log.warning("No accounts to remove.");
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
2537
|
+
const options = config.accounts.map((account) => ({
|
|
2538
|
+
value: account.accountId,
|
|
2539
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
2540
|
+
}));
|
|
2541
|
+
const selected = await p.select({
|
|
2542
|
+
message: "Select account to remove:",
|
|
2543
|
+
options
|
|
2544
|
+
});
|
|
2545
|
+
if (p.isCancel(selected)) {
|
|
2546
|
+
p.log.info("Cancelled.");
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
const accountId = selected;
|
|
2550
|
+
const confirmed = await p.confirm({
|
|
2551
|
+
message: `Are you sure you want to remove account ${accountId}?`,
|
|
2552
|
+
initialValue: false
|
|
2553
|
+
});
|
|
2554
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
2555
|
+
p.log.info("Cancelled.");
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
try {
|
|
2559
|
+
await removeStoredCredentials(accountId);
|
|
2560
|
+
} catch {}
|
|
2561
|
+
const previousAccountId = config.accounts[config.current]?.accountId;
|
|
2562
|
+
config.accounts = config.accounts.filter((a) => a.accountId !== accountId);
|
|
2563
|
+
if (config.accounts.length === 0) config.current = 0;
|
|
2564
|
+
else if (accountId === previousAccountId) config.current = 0;
|
|
2565
|
+
else {
|
|
2566
|
+
const newIndex = config.accounts.findIndex((a) => a.accountId === previousAccountId);
|
|
2567
|
+
config.current = newIndex >= 0 ? newIndex : 0;
|
|
2568
|
+
}
|
|
2569
|
+
await saveConfig(config);
|
|
2570
|
+
p.log.success(`Removed account ${accountId}`);
|
|
2571
|
+
};
|
|
2572
|
+
const handleLabelAccount = async () => {
|
|
2573
|
+
if (!configExists()) {
|
|
2574
|
+
p.log.warning("No accounts configured.");
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
const config = await loadConfig();
|
|
2578
|
+
if (config.accounts.length === 0) {
|
|
2579
|
+
p.log.warning("No accounts to label.");
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
const currentAccountId = config.accounts[config.current]?.accountId;
|
|
2583
|
+
const options = config.accounts.map((account) => ({
|
|
2584
|
+
value: account.accountId,
|
|
2585
|
+
label: getAccountDisplay(account.accountId, account.accountId === currentAccountId, account.label)
|
|
2586
|
+
}));
|
|
2587
|
+
const selected = await p.select({
|
|
2588
|
+
message: "Select account to label:",
|
|
2589
|
+
options
|
|
2590
|
+
});
|
|
2591
|
+
if (p.isCancel(selected)) {
|
|
2592
|
+
p.log.info("Cancelled.");
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
const accountId = selected;
|
|
2596
|
+
const account = config.accounts.find((a) => a.accountId === accountId);
|
|
2597
|
+
const labelInput = await p.text({
|
|
2598
|
+
message: "Enter new label (or leave empty to remove label):",
|
|
2599
|
+
placeholder: "e.g. Work, Personal",
|
|
2600
|
+
initialValue: account?.label ?? ""
|
|
2601
|
+
});
|
|
2602
|
+
if (p.isCancel(labelInput)) {
|
|
2603
|
+
p.log.info("Cancelled.");
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
const newLabel = labelInput?.trim() || void 0;
|
|
2607
|
+
const target = config.accounts.find((a) => a.accountId === accountId);
|
|
2608
|
+
if (target) target.label = newLabel;
|
|
2609
|
+
await saveConfig(config);
|
|
2610
|
+
if (newLabel) p.log.success(`Account ${accountId} labeled as "${newLabel}".`);
|
|
2611
|
+
else p.log.success(`Label removed from account ${accountId}.`);
|
|
2612
|
+
};
|
|
2613
|
+
const handleStatus = async () => {
|
|
2614
|
+
const status = await getStatus();
|
|
2615
|
+
if (status.accounts.length === 0) {
|
|
2616
|
+
p.log.warning("No accounts configured. Use 'Add account' to get started.");
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
p.log.info("Account status:");
|
|
2620
|
+
for (const account of status.accounts) {
|
|
2621
|
+
const marker = account.isCurrent ? "→ " : " ";
|
|
2622
|
+
const name = account.label ? `${account.label} (${account.accountId})` : account.accountId;
|
|
2623
|
+
const secureStore = account.secureStoreExists ? "" : " [no secure store entry]";
|
|
2624
|
+
const idToken = account.hasIdToken ? "" : " [no id_token]";
|
|
2625
|
+
p.log.message(`${marker}${name} — ${account.expiresIn}${secureStore}${idToken}`);
|
|
2626
|
+
}
|
|
2627
|
+
const ocStatus = status.opencodeAuth.exists ? `active: ${status.opencodeAuth.accountId ?? "unknown"}` : "not found";
|
|
2628
|
+
const cxStatus = status.codexAuth.exists ? `active: ${status.codexAuth.accountId ?? "unknown"}` : "not found";
|
|
2629
|
+
const piStatus = status.piAuth.exists ? `active: ${status.piAuth.accountId ?? "unknown"}` : "not found";
|
|
2630
|
+
p.log.info(`Auth files:`);
|
|
2631
|
+
p.log.message(` OpenCode: ${ocStatus}`);
|
|
2632
|
+
p.log.message(` Codex CLI: ${cxStatus}`);
|
|
2633
|
+
p.log.message(` Pi Agent: ${piStatus}`);
|
|
2634
|
+
};
|
|
2635
|
+
const runInteractiveMode = async () => {
|
|
2636
|
+
p.intro("cdx - OpenAI Account Switcher");
|
|
2637
|
+
let running = true;
|
|
2638
|
+
while (running) {
|
|
2639
|
+
const storedAccounts = await getStoredAccountIds();
|
|
2640
|
+
let currentInfo = "";
|
|
2641
|
+
if (configExists()) try {
|
|
2642
|
+
const config = await loadConfig();
|
|
2643
|
+
const current = config.accounts[config.current];
|
|
2644
|
+
if (current) currentInfo = ` (current: ${current.label ?? current.accountId})`;
|
|
2645
|
+
} catch {}
|
|
2646
|
+
const action = await p.select({
|
|
2647
|
+
message: `What would you like to do?${currentInfo}`,
|
|
2648
|
+
options: [
|
|
2649
|
+
{
|
|
2650
|
+
value: "list",
|
|
2651
|
+
label: `List accounts (${storedAccounts.length} in secure store)`
|
|
2652
|
+
},
|
|
2653
|
+
{
|
|
2654
|
+
value: "switch",
|
|
2655
|
+
label: "Switch account"
|
|
2656
|
+
},
|
|
2657
|
+
{
|
|
2658
|
+
value: "add",
|
|
2659
|
+
label: "Add account (OAuth login)"
|
|
2660
|
+
},
|
|
2661
|
+
{
|
|
2662
|
+
value: "relogin",
|
|
2663
|
+
label: "Re-login account"
|
|
2664
|
+
},
|
|
2665
|
+
{
|
|
2666
|
+
value: "remove",
|
|
2667
|
+
label: "Remove account"
|
|
2668
|
+
},
|
|
2669
|
+
{
|
|
2670
|
+
value: "label",
|
|
2671
|
+
label: "Label account"
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
value: "status",
|
|
2675
|
+
label: "Account status & token expiry"
|
|
2676
|
+
},
|
|
2677
|
+
{
|
|
2678
|
+
value: "exit",
|
|
2679
|
+
label: "Exit"
|
|
2680
|
+
}
|
|
2681
|
+
]
|
|
2682
|
+
});
|
|
2683
|
+
if (p.isCancel(action)) {
|
|
2684
|
+
running = false;
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
switch (action) {
|
|
2688
|
+
case "list":
|
|
2689
|
+
await handleListAccounts();
|
|
2690
|
+
break;
|
|
2691
|
+
case "switch":
|
|
2692
|
+
await handleSwitchAccount();
|
|
2693
|
+
break;
|
|
2694
|
+
case "add":
|
|
2695
|
+
await handleAddAccount();
|
|
2696
|
+
break;
|
|
2697
|
+
case "relogin":
|
|
2698
|
+
await handleReloginAccount();
|
|
2699
|
+
break;
|
|
2700
|
+
case "remove":
|
|
2701
|
+
await handleRemoveAccount();
|
|
2702
|
+
break;
|
|
2703
|
+
case "label":
|
|
2704
|
+
await handleLabelAccount();
|
|
2705
|
+
break;
|
|
2706
|
+
case "status":
|
|
2707
|
+
await handleStatus();
|
|
2708
|
+
break;
|
|
2709
|
+
case "exit":
|
|
2710
|
+
running = false;
|
|
2711
|
+
break;
|
|
2712
|
+
}
|
|
2713
|
+
if (running && action !== "exit") p.log.message("");
|
|
2714
|
+
}
|
|
2715
|
+
p.outro("Goodbye!");
|
|
2716
|
+
};
|
|
2717
|
+
//#endregion
|
|
2718
|
+
//#region lib/commands/interactive.ts
|
|
2719
|
+
const registerDefaultInteractiveAction = (program) => {
|
|
2720
|
+
program.action(async () => {
|
|
2721
|
+
try {
|
|
2722
|
+
await runInteractiveMode();
|
|
2723
|
+
} catch (error) {
|
|
2724
|
+
exitWithCommandError(error);
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
};
|
|
2728
|
+
//#endregion
|
|
2729
|
+
//#region lib/keychain-acl.ts
|
|
2730
|
+
const getDefaultMap = (services) => {
|
|
2731
|
+
const map = /* @__PURE__ */ new Map();
|
|
2732
|
+
for (const service of services) map.set(service, {
|
|
2733
|
+
service,
|
|
2734
|
+
mode: "missing",
|
|
2735
|
+
applications: []
|
|
2736
|
+
});
|
|
2737
|
+
return map;
|
|
2738
|
+
};
|
|
2739
|
+
const parseItemEntries = (block) => {
|
|
2740
|
+
const entries = [];
|
|
2741
|
+
const entryRegex = /entry\s+\d+:\n([\s\S]*?)(?=\n\s*entry\s+\d+:|$)/g;
|
|
2742
|
+
let match;
|
|
2743
|
+
while ((match = entryRegex.exec(block)) !== null) if (match[1]) entries.push(match[1]);
|
|
2744
|
+
return entries;
|
|
2745
|
+
};
|
|
2746
|
+
const parseApplicationsFromEntry = (entry) => {
|
|
2747
|
+
const applications = [];
|
|
2748
|
+
const appRegex = /^\s*\d+:\s+(.+?)(?:\s+\([^\n]*\))?\s*$/gm;
|
|
2749
|
+
let match;
|
|
2750
|
+
while ((match = appRegex.exec(entry)) !== null) {
|
|
2751
|
+
const app = match[1]?.trim();
|
|
2752
|
+
if (app) applications.push(app);
|
|
2753
|
+
}
|
|
2754
|
+
return applications;
|
|
2755
|
+
};
|
|
2756
|
+
const parseKeychainDecryptAccessFromDump = (dumpOutput, services) => {
|
|
2757
|
+
const dedupedServices = [...new Set(services.filter((service) => service.length > 0))];
|
|
2758
|
+
const result = getDefaultMap(dedupedServices);
|
|
2759
|
+
if (dedupedServices.length === 0 || !dumpOutput.trim()) return result;
|
|
2760
|
+
const targetServices = new Set(dedupedServices);
|
|
2761
|
+
const blocks = dumpOutput.split(/\n(?=keychain:\s+")/g);
|
|
2762
|
+
for (const block of blocks) {
|
|
2763
|
+
if (!block.startsWith("keychain:")) continue;
|
|
2764
|
+
const service = block.match(/"svce"<blob>="([^"]+)"/)?.[1];
|
|
2765
|
+
if (!service || !targetServices.has(service)) continue;
|
|
2766
|
+
const entries = parseItemEntries(block);
|
|
2767
|
+
let mode = "missing";
|
|
2768
|
+
const applications = [];
|
|
2769
|
+
for (const entry of entries) {
|
|
2770
|
+
const authorizationsLine = entry.match(/authorizations\s*\(\d+\):\s*([^\n]+)/)?.[1] ?? "";
|
|
2771
|
+
if (!/\bdecrypt\b/.test(authorizationsLine)) continue;
|
|
2772
|
+
if (/applications:\s*<null>/.test(entry)) {
|
|
2773
|
+
mode = "all-apps";
|
|
2774
|
+
applications.length = 0;
|
|
2775
|
+
break;
|
|
2776
|
+
}
|
|
2777
|
+
const entryApplications = parseApplicationsFromEntry(entry);
|
|
2778
|
+
if (entryApplications.length > 0) {
|
|
2779
|
+
mode = "explicit-list";
|
|
2780
|
+
for (const app of entryApplications) if (!applications.includes(app)) applications.push(app);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
result.set(service, {
|
|
2784
|
+
service,
|
|
2785
|
+
mode,
|
|
2786
|
+
applications
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
return result;
|
|
2790
|
+
};
|
|
2791
|
+
const getKeychainDecryptAccessByServiceAsync = async (services) => {
|
|
2792
|
+
const dedupedServices = [...new Set(services.filter((service) => service.length > 0))];
|
|
2793
|
+
const defaultResult = getDefaultMap(dedupedServices);
|
|
2794
|
+
if (process.platform !== "darwin" || dedupedServices.length === 0) return defaultResult;
|
|
2795
|
+
const dumpResult = await runSecuritySafeAsync(["dump-keychain", "-a"]);
|
|
2796
|
+
if (!dumpResult.success) return defaultResult;
|
|
2797
|
+
return parseKeychainDecryptAccessFromDump(dumpResult.output, dedupedServices);
|
|
2798
|
+
};
|
|
2799
|
+
//#endregion
|
|
2800
|
+
//#region lib/secrets/probe.ts
|
|
2801
|
+
const toError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
2802
|
+
const createProbePayload = (accountId, now) => ({
|
|
2803
|
+
refresh: `probe-refresh-${accountId}`,
|
|
2804
|
+
access: `probe-access-${accountId}`,
|
|
2805
|
+
expires: now + 6e4,
|
|
2806
|
+
accountId
|
|
2807
|
+
});
|
|
2808
|
+
const payloadMatches = (expected, actual) => expected.accountId === actual.accountId && expected.refresh === actual.refresh && expected.access === actual.access && expected.expires === actual.expires;
|
|
2809
|
+
const runSecretStoreWriteReadProbe = async (secretStore, options = {}) => {
|
|
2810
|
+
const probeAccountId = options.probeAccountId ?? `cdx-doctor-probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
2811
|
+
const payload = createProbePayload(probeAccountId, options.now ?? Date.now());
|
|
2812
|
+
let saveSucceeded = false;
|
|
2813
|
+
let result = { ok: true };
|
|
2814
|
+
try {
|
|
2815
|
+
await secretStore.save(probeAccountId, payload);
|
|
2816
|
+
saveSucceeded = true;
|
|
2817
|
+
} catch (error) {
|
|
2818
|
+
result = {
|
|
2819
|
+
ok: false,
|
|
2820
|
+
stage: "save",
|
|
2821
|
+
error: toError(error)
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
if (result.ok) try {
|
|
2825
|
+
if (!payloadMatches(payload, await secretStore.load(probeAccountId))) result = {
|
|
2826
|
+
ok: false,
|
|
2827
|
+
stage: "verify",
|
|
2828
|
+
error: /* @__PURE__ */ new Error("Secure-store probe loaded payload does not match the saved payload.")
|
|
2829
|
+
};
|
|
2830
|
+
} catch (error) {
|
|
2831
|
+
result = {
|
|
2832
|
+
ok: false,
|
|
2833
|
+
stage: "load",
|
|
2834
|
+
error: toError(error)
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
if (saveSucceeded) try {
|
|
2838
|
+
await secretStore.delete(probeAccountId);
|
|
2839
|
+
} catch (error) {
|
|
2840
|
+
if (result.ok) result = {
|
|
2841
|
+
ok: false,
|
|
2842
|
+
stage: "delete",
|
|
2843
|
+
error: toError(error)
|
|
2844
|
+
};
|
|
2845
|
+
}
|
|
2846
|
+
return result;
|
|
2847
|
+
};
|
|
2848
|
+
//#endregion
|
|
2849
|
+
//#region lib/commands/doctor.ts
|
|
2850
|
+
const hasRuntimeTrustedApp = (trustedApplications, runtimeExecutablePath) => {
|
|
2851
|
+
const runtimeBaseName = path.basename(runtimeExecutablePath).toLowerCase();
|
|
2852
|
+
return trustedApplications.some((trustedApp) => {
|
|
2853
|
+
if (trustedApp === runtimeExecutablePath) return true;
|
|
2854
|
+
return path.basename(trustedApp).toLowerCase() === runtimeBaseName;
|
|
2855
|
+
});
|
|
2856
|
+
};
|
|
2857
|
+
const getSecretStoreProbeHeading = (platform) => {
|
|
2858
|
+
if (platform === "linux") return "Linux secure-store probe";
|
|
2859
|
+
if (platform === "darwin") return "macOS secure-store probe";
|
|
2860
|
+
if (platform === "win32") return "Windows secure-store probe";
|
|
2861
|
+
return null;
|
|
2862
|
+
};
|
|
2863
|
+
const createProbeAdapterForCurrentPlatform = () => {
|
|
2864
|
+
const currentAdapter = getSecretStoreAdapter();
|
|
2865
|
+
if (process.platform === "darwin" && currentAdapter.id === "macos-legacy-keychain") return createSecretStoreAdapterFromSelection("legacy-keychain", "darwin");
|
|
2866
|
+
return createRuntimeSecretStoreAdapter(process.platform);
|
|
2867
|
+
};
|
|
2868
|
+
const getSecretStoreProbeGuidance = (platform) => {
|
|
2869
|
+
if (platform === "linux") return "Suggested fix: ensure Secret Service is running/unlocked (for example gnome-keyring + secret-tool), then retry login.";
|
|
2870
|
+
if (platform === "darwin") return "Suggested fix: ensure Keychain Access is unlocked and allows this runtime/toolchain to store/read passwords, then retry login.";
|
|
2871
|
+
if (platform === "win32") return "Suggested fix: ensure Windows Credential Manager is available for this user session, then retry login.";
|
|
2872
|
+
return null;
|
|
2873
|
+
};
|
|
2874
|
+
const isInteractiveTerminal$1 = () => Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
2875
|
+
const runCommandCapture$1 = async (command, args) => await new Promise((resolve) => {
|
|
2876
|
+
const child = spawn(command, args, { stdio: [
|
|
2877
|
+
"ignore",
|
|
2878
|
+
"pipe",
|
|
2879
|
+
"pipe"
|
|
2880
|
+
] });
|
|
2881
|
+
let stdout = "";
|
|
2882
|
+
let stderr = "";
|
|
2883
|
+
let spawnError = null;
|
|
2884
|
+
child.stdout?.on("data", (chunk) => {
|
|
2885
|
+
stdout += chunk.toString();
|
|
2886
|
+
});
|
|
2887
|
+
child.stderr?.on("data", (chunk) => {
|
|
2888
|
+
stderr += chunk.toString();
|
|
2889
|
+
});
|
|
2890
|
+
child.once("error", (error) => {
|
|
2891
|
+
spawnError = error.message;
|
|
2892
|
+
});
|
|
2893
|
+
child.once("close", (code) => {
|
|
2894
|
+
resolve({
|
|
2895
|
+
ok: spawnError === null && code === 0,
|
|
2896
|
+
stdout: stdout.trim(),
|
|
2897
|
+
stderr: stderr.trim(),
|
|
2898
|
+
...spawnError ? { error: spawnError } : {}
|
|
2899
|
+
});
|
|
2900
|
+
});
|
|
2901
|
+
});
|
|
2902
|
+
const runCommandCaptureWithInput = async (command, args, input) => await new Promise((resolve) => {
|
|
2903
|
+
const child = spawn(command, args, { stdio: [
|
|
2904
|
+
"pipe",
|
|
2905
|
+
"pipe",
|
|
2906
|
+
"pipe"
|
|
2907
|
+
] });
|
|
2908
|
+
let stdout = "";
|
|
2909
|
+
let stderr = "";
|
|
2910
|
+
let spawnError = null;
|
|
2911
|
+
child.stdout?.on("data", (chunk) => {
|
|
2912
|
+
stdout += chunk.toString();
|
|
2913
|
+
});
|
|
2914
|
+
child.stderr?.on("data", (chunk) => {
|
|
2915
|
+
stderr += chunk.toString();
|
|
2916
|
+
});
|
|
2917
|
+
child.once("error", (error) => {
|
|
2918
|
+
spawnError = error.message;
|
|
2919
|
+
});
|
|
2920
|
+
child.stdin?.write(input);
|
|
2921
|
+
child.stdin?.end();
|
|
2922
|
+
child.once("close", (code) => {
|
|
2923
|
+
resolve({
|
|
2924
|
+
ok: spawnError === null && code === 0,
|
|
2925
|
+
stdout: stdout.trim(),
|
|
2926
|
+
stderr: stderr.trim(),
|
|
2927
|
+
...spawnError ? { error: spawnError } : {}
|
|
2928
|
+
});
|
|
2929
|
+
});
|
|
2930
|
+
});
|
|
2931
|
+
const runCommandDetached = async (command, args) => {
|
|
2932
|
+
try {
|
|
2933
|
+
spawn(command, args, {
|
|
2934
|
+
detached: true,
|
|
2935
|
+
stdio: "ignore"
|
|
2936
|
+
}).unref();
|
|
2937
|
+
return { ok: true };
|
|
2938
|
+
} catch (error) {
|
|
2939
|
+
return {
|
|
2940
|
+
ok: false,
|
|
2941
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2945
|
+
const extractCommandFailureDetails$1 = (result) => result.error || result.stderr || result.stdout || void 0;
|
|
2946
|
+
const isCommandAvailable$1 = async (commandName) => {
|
|
2947
|
+
return (await runCommandCapture$1("sh", ["-lc", `command -v ${commandName} >/dev/null 2>&1`])).ok;
|
|
2948
|
+
};
|
|
2949
|
+
const GNOME_KEYRING_CMDLINE_PATTERN$1 = /(^|\/)gnome-keyring-daemon(\s|$)/;
|
|
2950
|
+
const checkGnomeKeyringRunning$1 = async () => {
|
|
2951
|
+
if (await isCommandAvailable$1("ps")) {
|
|
2952
|
+
const psResult = await runCommandCapture$1("ps", [
|
|
2953
|
+
"-A",
|
|
2954
|
+
"-o",
|
|
2955
|
+
"args="
|
|
2956
|
+
]);
|
|
2957
|
+
if (psResult.ok) {
|
|
2958
|
+
if (psResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).some((line) => GNOME_KEYRING_CMDLINE_PATTERN$1.test(line))) return { ok: true };
|
|
2959
|
+
return {
|
|
2960
|
+
ok: false,
|
|
2961
|
+
details: "No gnome-keyring-daemon process found."
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (await isCommandAvailable$1("pgrep")) {
|
|
2966
|
+
const pgrepResult = await runCommandCapture$1("pgrep", ["-f", "gnome-keyring-daemon"]);
|
|
2967
|
+
if (pgrepResult.ok) return { ok: true };
|
|
2968
|
+
return {
|
|
2969
|
+
ok: false,
|
|
2970
|
+
details: extractCommandFailureDetails$1(pgrepResult) ?? "No gnome-keyring-daemon process found."
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
return {
|
|
2974
|
+
ok: false,
|
|
2975
|
+
details: "Neither ps nor pgrep is available to check gnome-keyring-daemon."
|
|
2976
|
+
};
|
|
2977
|
+
};
|
|
2978
|
+
const parseSystemctlEnabledState$1 = (output) => {
|
|
2979
|
+
const normalized = output.trim().toLowerCase();
|
|
2980
|
+
if ([
|
|
2981
|
+
"enabled",
|
|
2982
|
+
"enabled-runtime",
|
|
2983
|
+
"static",
|
|
2984
|
+
"indirect",
|
|
2985
|
+
"generated"
|
|
2986
|
+
].includes(normalized)) return "enabled";
|
|
2987
|
+
if ([
|
|
2988
|
+
"disabled",
|
|
2989
|
+
"masked",
|
|
2990
|
+
"not-found",
|
|
2991
|
+
"linked",
|
|
2992
|
+
"linked-runtime"
|
|
2993
|
+
].includes(normalized)) return "disabled";
|
|
2994
|
+
return "unknown";
|
|
2995
|
+
};
|
|
2996
|
+
const getLinuxGnomeKeyringAutoStartStatus$1 = async () => {
|
|
2997
|
+
if (!await isCommandAvailable$1("systemctl")) return {
|
|
2998
|
+
state: "unknown",
|
|
2999
|
+
details: "systemctl is not available; autostart detection depends on your desktop/session config."
|
|
3000
|
+
};
|
|
3001
|
+
const units = ["gnome-keyring-daemon.socket", "gnome-keyring-daemon.service"];
|
|
3002
|
+
let sawDisabled = false;
|
|
3003
|
+
const details = [];
|
|
3004
|
+
for (const unit of units) {
|
|
3005
|
+
const result = await runCommandCapture$1("systemctl", [
|
|
3006
|
+
"--user",
|
|
3007
|
+
"is-enabled",
|
|
3008
|
+
unit
|
|
3009
|
+
]);
|
|
3010
|
+
const state = parseSystemctlEnabledState$1(result.stdout || result.stderr);
|
|
3011
|
+
if (state === "enabled") return {
|
|
3012
|
+
state: "enabled",
|
|
3013
|
+
details: `${unit} is enabled (${result.stdout || "enabled"}).`
|
|
3014
|
+
};
|
|
3015
|
+
if (state === "disabled") {
|
|
3016
|
+
sawDisabled = true;
|
|
3017
|
+
details.push(`${unit}: ${result.stdout || result.stderr || "disabled"}`);
|
|
3018
|
+
continue;
|
|
3019
|
+
}
|
|
3020
|
+
const maybeDetail = extractCommandFailureDetails$1(result);
|
|
3021
|
+
if (maybeDetail) details.push(`${unit}: ${maybeDetail}`);
|
|
3022
|
+
}
|
|
3023
|
+
if (sawDisabled) return {
|
|
3024
|
+
state: "disabled",
|
|
3025
|
+
details: details.join("; ")
|
|
3026
|
+
};
|
|
3027
|
+
return {
|
|
3028
|
+
state: "unknown",
|
|
3029
|
+
details: details.join("; ") || "Unable to determine gnome-keyring autostart state."
|
|
3030
|
+
};
|
|
3031
|
+
};
|
|
3032
|
+
const applyKeyringEnvAssignments = (raw) => {
|
|
3033
|
+
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3034
|
+
for (const line of lines) {
|
|
3035
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*);?$/);
|
|
3036
|
+
if (!match) continue;
|
|
3037
|
+
const key = match[1];
|
|
3038
|
+
const value = match[2].replace(/;$/, "");
|
|
3039
|
+
if (key) process.env[key] = value;
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
const startGnomeKeyringNow$1 = async () => {
|
|
3043
|
+
const directStart = await runCommandCapture$1("gnome-keyring-daemon", ["--start", "--components=secrets"]);
|
|
3044
|
+
if (directStart.ok) {
|
|
3045
|
+
applyKeyringEnvAssignments(directStart.stdout);
|
|
3046
|
+
const runningCheck = await checkGnomeKeyringRunning$1();
|
|
3047
|
+
if (runningCheck.ok) return { ok: true };
|
|
3048
|
+
return {
|
|
3049
|
+
ok: false,
|
|
3050
|
+
details: runningCheck.details ?? "gnome-keyring-daemon start command succeeded, but process was not detected afterwards."
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
if (await isCommandAvailable$1("systemctl")) {
|
|
3054
|
+
const serviceStart = await runCommandCapture$1("systemctl", [
|
|
3055
|
+
"--user",
|
|
3056
|
+
"start",
|
|
3057
|
+
"gnome-keyring-daemon.service"
|
|
3058
|
+
]);
|
|
3059
|
+
if (serviceStart.ok) {
|
|
3060
|
+
const runningCheck = await checkGnomeKeyringRunning$1();
|
|
3061
|
+
if (runningCheck.ok) return { ok: true };
|
|
3062
|
+
return {
|
|
3063
|
+
ok: false,
|
|
3064
|
+
details: runningCheck.details ?? "systemctl start succeeded, but gnome-keyring-daemon was not detected afterwards."
|
|
3065
|
+
};
|
|
3066
|
+
}
|
|
3067
|
+
return {
|
|
3068
|
+
ok: false,
|
|
3069
|
+
details: extractCommandFailureDetails$1(directStart) ?? extractCommandFailureDetails$1(serviceStart) ?? "Failed to start gnome-keyring-daemon."
|
|
3070
|
+
};
|
|
3071
|
+
}
|
|
3072
|
+
return {
|
|
3073
|
+
ok: false,
|
|
3074
|
+
details: extractCommandFailureDetails$1(directStart) ?? "Failed to start gnome-keyring-daemon."
|
|
3075
|
+
};
|
|
3076
|
+
};
|
|
3077
|
+
const enableGnomeKeyringAutoStart = async () => {
|
|
3078
|
+
if (!await isCommandAvailable$1("systemctl")) return {
|
|
3079
|
+
ok: false,
|
|
3080
|
+
details: "systemctl is not available, so cdx cannot automatically enable startup in this session manager."
|
|
3081
|
+
};
|
|
3082
|
+
const units = ["gnome-keyring-daemon.socket", "gnome-keyring-daemon.service"];
|
|
3083
|
+
const failures = [];
|
|
3084
|
+
for (const unit of units) {
|
|
3085
|
+
const result = await runCommandCapture$1("systemctl", [
|
|
3086
|
+
"--user",
|
|
3087
|
+
"enable",
|
|
3088
|
+
unit
|
|
3089
|
+
]);
|
|
3090
|
+
if (result.ok) return {
|
|
3091
|
+
ok: true,
|
|
3092
|
+
details: `${unit} enabled.`
|
|
3093
|
+
};
|
|
3094
|
+
const detail = extractCommandFailureDetails$1(result);
|
|
3095
|
+
failures.push(`${unit}: ${detail ?? "enable failed"}`);
|
|
3096
|
+
}
|
|
3097
|
+
return {
|
|
3098
|
+
ok: false,
|
|
3099
|
+
details: failures.join("; ")
|
|
3100
|
+
};
|
|
3101
|
+
};
|
|
3102
|
+
const maybeOfferToStartGnomeKeyring = async () => {
|
|
3103
|
+
const autoStartStatus = await getLinuxGnomeKeyringAutoStartStatus$1();
|
|
3104
|
+
if (autoStartStatus.state === "enabled") {
|
|
3105
|
+
const shouldStartNow = await p.confirm({
|
|
3106
|
+
message: "gnome-keyring autostart appears enabled, but it is not running right now. Start it now?",
|
|
3107
|
+
initialValue: true
|
|
3108
|
+
});
|
|
3109
|
+
if (p.isCancel(shouldStartNow) || !shouldStartNow) return false;
|
|
3110
|
+
const startResult = await startGnomeKeyringNow$1();
|
|
3111
|
+
if (!startResult.ok) {
|
|
3112
|
+
process.stdout.write(` failed to start gnome-keyring-daemon: ${startResult.details ?? "unknown error"}\n`);
|
|
3113
|
+
return false;
|
|
3114
|
+
}
|
|
3115
|
+
process.stdout.write(" started gnome-keyring-daemon for this session.\n");
|
|
3116
|
+
return true;
|
|
3117
|
+
}
|
|
3118
|
+
if (autoStartStatus.details) process.stdout.write(` autostart check: ${autoStartStatus.details}\n`);
|
|
3119
|
+
const action = await p.select({
|
|
3120
|
+
message: autoStartStatus.state === "disabled" ? "gnome-keyring autostart seems disabled. What should cdx do?" : "Could not confirm gnome-keyring autostart. What should cdx do?",
|
|
3121
|
+
options: [
|
|
3122
|
+
{
|
|
3123
|
+
value: "start-now",
|
|
3124
|
+
label: "Start now only"
|
|
3125
|
+
},
|
|
3126
|
+
{
|
|
3127
|
+
value: "enable-and-start",
|
|
3128
|
+
label: "Enable on system start and start now"
|
|
3129
|
+
},
|
|
3130
|
+
{
|
|
3131
|
+
value: "skip",
|
|
3132
|
+
label: "Skip"
|
|
3133
|
+
}
|
|
3134
|
+
],
|
|
3135
|
+
initialValue: "start-now"
|
|
3136
|
+
});
|
|
3137
|
+
if (p.isCancel(action) || action === "skip") return false;
|
|
3138
|
+
if (action === "enable-and-start") {
|
|
3139
|
+
const enableResult = await enableGnomeKeyringAutoStart();
|
|
3140
|
+
if (!enableResult.ok) {
|
|
3141
|
+
process.stdout.write(` failed to enable autostart: ${enableResult.details ?? "unknown error"}\n`);
|
|
3142
|
+
return false;
|
|
3143
|
+
}
|
|
3144
|
+
process.stdout.write(` autostart enabled${enableResult.details ? ` (${enableResult.details})` : ""}.\n`);
|
|
3145
|
+
}
|
|
3146
|
+
const startResult = await startGnomeKeyringNow$1();
|
|
3147
|
+
if (!startResult.ok) {
|
|
3148
|
+
process.stdout.write(` failed to start gnome-keyring-daemon: ${startResult.details ?? "unknown error"}\n`);
|
|
3149
|
+
return false;
|
|
3150
|
+
}
|
|
3151
|
+
process.stdout.write(" started gnome-keyring-daemon for this session.\n");
|
|
3152
|
+
return true;
|
|
3153
|
+
};
|
|
3154
|
+
const runLinuxSecretStoreChecklist = async () => {
|
|
3155
|
+
const gnomeKeyringInstalled = await isCommandAvailable$1("gnome-keyring-daemon");
|
|
3156
|
+
const secretToolInstalled = await isCommandAvailable$1("secret-tool");
|
|
3157
|
+
const gnomeKeyringRunning = await checkGnomeKeyringRunning$1();
|
|
3158
|
+
return [
|
|
3159
|
+
{
|
|
3160
|
+
id: "gnome-keyring-installed",
|
|
3161
|
+
question: "Is gnome-keyring installed?",
|
|
3162
|
+
ok: gnomeKeyringInstalled,
|
|
3163
|
+
hint: "Install the `gnome-keyring` package, then log out/in (or restart your session)."
|
|
3164
|
+
},
|
|
3165
|
+
{
|
|
3166
|
+
id: "secret-tool-installed",
|
|
3167
|
+
question: "Is secret-tool installed?",
|
|
3168
|
+
ok: secretToolInstalled,
|
|
3169
|
+
hint: "Install the package that provides `secret-tool` (often `libsecret-tools`)."
|
|
3170
|
+
},
|
|
3171
|
+
{
|
|
3172
|
+
id: "gnome-keyring-running",
|
|
3173
|
+
question: "Is gnome-keyring running?",
|
|
3174
|
+
ok: gnomeKeyringRunning.ok,
|
|
3175
|
+
details: gnomeKeyringRunning.details,
|
|
3176
|
+
hint: "Start/unlock gnome-keyring-daemon in your session (cdx can do this interactively)."
|
|
3177
|
+
}
|
|
3178
|
+
];
|
|
3179
|
+
};
|
|
3180
|
+
const runSecretToolRoundTripCheck = async () => {
|
|
3181
|
+
const id = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
3182
|
+
const service = `cdx-doctor-secret-service-${id}`;
|
|
3183
|
+
const account = `cdx-doctor-account-${id}`;
|
|
3184
|
+
const value = `cdx-doctor-value-${id}`;
|
|
3185
|
+
const storeResult = await runCommandCaptureWithInput("secret-tool", [
|
|
3186
|
+
"store",
|
|
3187
|
+
"--label=cdx doctor Secret Service probe",
|
|
3188
|
+
"service",
|
|
3189
|
+
service,
|
|
3190
|
+
"account",
|
|
3191
|
+
account
|
|
3192
|
+
], `${value}\n`);
|
|
3193
|
+
if (!storeResult.ok) return {
|
|
3194
|
+
ok: false,
|
|
3195
|
+
details: extractCommandFailureDetails$1(storeResult) ?? "secret-tool store failed."
|
|
3196
|
+
};
|
|
3197
|
+
const lookupResult = await runCommandCapture$1("secret-tool", [
|
|
3198
|
+
"lookup",
|
|
3199
|
+
"service",
|
|
3200
|
+
service,
|
|
3201
|
+
"account",
|
|
3202
|
+
account
|
|
3203
|
+
]);
|
|
3204
|
+
if (!lookupResult.ok) return {
|
|
3205
|
+
ok: false,
|
|
3206
|
+
details: extractCommandFailureDetails$1(lookupResult) ?? "secret-tool lookup failed."
|
|
3207
|
+
};
|
|
3208
|
+
if (lookupResult.stdout.trim() !== value) return {
|
|
3209
|
+
ok: false,
|
|
3210
|
+
details: "secret-tool lookup returned an unexpected value after store."
|
|
3211
|
+
};
|
|
3212
|
+
const clearResult = await runCommandCapture$1("secret-tool", [
|
|
3213
|
+
"clear",
|
|
3214
|
+
"service",
|
|
3215
|
+
service,
|
|
3216
|
+
"account",
|
|
3217
|
+
account
|
|
3218
|
+
]);
|
|
3219
|
+
if (!clearResult.ok) return {
|
|
3220
|
+
ok: false,
|
|
3221
|
+
details: extractCommandFailureDetails$1(clearResult) ?? "secret-tool clear failed."
|
|
3222
|
+
};
|
|
3223
|
+
return { ok: true };
|
|
3224
|
+
};
|
|
3225
|
+
const runLinuxSecretStoreDeepRemediation = async () => {
|
|
3226
|
+
if (!isInteractiveTerminal$1()) return;
|
|
3227
|
+
const hasSecretTool = await isCommandAvailable$1("secret-tool");
|
|
3228
|
+
const hasSeahorse = await isCommandAvailable$1("seahorse");
|
|
3229
|
+
const shouldStart = await p.confirm({
|
|
3230
|
+
message: "Run deeper Linux Secret Service remediation now? (interactive actions, no copy/paste commands)",
|
|
3231
|
+
initialValue: true
|
|
3232
|
+
});
|
|
3233
|
+
if (p.isCancel(shouldStart) || !shouldStart) return;
|
|
3234
|
+
while (true) {
|
|
3235
|
+
const action = await p.select({
|
|
3236
|
+
message: "Choose next remediation action:",
|
|
3237
|
+
options: [
|
|
3238
|
+
{
|
|
3239
|
+
value: "secret-tool-test",
|
|
3240
|
+
label: hasSecretTool ? "Run Secret Service write/read/clear test now" : "Run Secret Service write/read/clear test now (secret-tool not found)"
|
|
3241
|
+
},
|
|
3242
|
+
{
|
|
3243
|
+
value: "open-keyring-manager",
|
|
3244
|
+
label: hasSeahorse ? "Open keyring manager now" : "Open keyring manager now (seahorse not found)"
|
|
3245
|
+
},
|
|
3246
|
+
{
|
|
3247
|
+
value: "retry-probe",
|
|
3248
|
+
label: "Retry cdx secure-store probe now"
|
|
3249
|
+
},
|
|
3250
|
+
{
|
|
3251
|
+
value: "done",
|
|
3252
|
+
label: "Done"
|
|
3253
|
+
}
|
|
3254
|
+
],
|
|
3255
|
+
initialValue: "secret-tool-test"
|
|
3256
|
+
});
|
|
3257
|
+
if (p.isCancel(action) || action === "done") return;
|
|
3258
|
+
if (action === "secret-tool-test") {
|
|
3259
|
+
if (!hasSecretTool) {
|
|
3260
|
+
process.stdout.write(" secret-tool is not installed; install it first, then rerun this remediation action.\n");
|
|
3261
|
+
continue;
|
|
3262
|
+
}
|
|
3263
|
+
const result = await runSecretToolRoundTripCheck();
|
|
3264
|
+
if (result.ok) process.stdout.write(" secret-tool roundtrip test passed.\n");
|
|
3265
|
+
else process.stdout.write(` secret-tool roundtrip test failed: ${result.details ?? "unknown error"}\n`);
|
|
3266
|
+
continue;
|
|
3267
|
+
}
|
|
3268
|
+
if (action === "open-keyring-manager") {
|
|
3269
|
+
if (!hasSeahorse) {
|
|
3270
|
+
process.stdout.write(" keyring manager app was not found (seahorse). Install it to manage collections/locks interactively.\n");
|
|
3271
|
+
continue;
|
|
3272
|
+
}
|
|
3273
|
+
const opened = await runCommandDetached("seahorse", []);
|
|
3274
|
+
if (!opened.ok) process.stdout.write(` failed to open keyring manager: ${opened.error ?? "unknown error"}\n`);
|
|
3275
|
+
else process.stdout.write(" opened keyring manager. Unlock/create the default keyring, then return here and retry probe.\n");
|
|
3276
|
+
continue;
|
|
3277
|
+
}
|
|
3278
|
+
const probeResult = await runSecretStoreWriteReadProbe(createProbeAdapterForCurrentPlatform());
|
|
3279
|
+
if (probeResult.ok) {
|
|
3280
|
+
process.stdout.write(" cdx secure-store probe now passes.\n");
|
|
3281
|
+
return;
|
|
3282
|
+
}
|
|
3283
|
+
process.stdout.write(` probe still failing (${probeResult.stage}): ${probeResult.error.message}\n`);
|
|
3284
|
+
}
|
|
3285
|
+
};
|
|
3286
|
+
const maybeRunLinuxSecretStoreChecklist = async () => {
|
|
3287
|
+
if (!isInteractiveTerminal$1()) {
|
|
3288
|
+
process.stdout.write(" Tip: run `cdx doctor` in an interactive terminal to start guided Linux secret-store checks.\n");
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
3291
|
+
const shouldRunChecklist = await p.confirm({
|
|
3292
|
+
message: "Run guided Linux secret-store checks now? (gnome-keyring installed, secret-tool installed, gnome-keyring running)",
|
|
3293
|
+
initialValue: true
|
|
3294
|
+
});
|
|
3295
|
+
if (p.isCancel(shouldRunChecklist) || !shouldRunChecklist) {
|
|
3296
|
+
process.stdout.write(" Guided Linux checks skipped.\n");
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
process.stdout.write(" Guided Linux checks:\n");
|
|
3300
|
+
const checklist = await runLinuxSecretStoreChecklist();
|
|
3301
|
+
let passed = 0;
|
|
3302
|
+
for (let i = 0; i < checklist.length; i++) {
|
|
3303
|
+
const item = checklist[i];
|
|
3304
|
+
if (item.ok) {
|
|
3305
|
+
passed += 1;
|
|
3306
|
+
process.stdout.write(` ${i + 1}/3 ${item.question} yes\n`);
|
|
3307
|
+
continue;
|
|
3308
|
+
}
|
|
3309
|
+
process.stdout.write(` ${i + 1}/3 ${item.question} no\n`);
|
|
3310
|
+
if (item.details) process.stdout.write(` details: ${item.details}\n`);
|
|
3311
|
+
if (item.hint) process.stdout.write(` hint: ${item.hint}\n`);
|
|
3312
|
+
if (item.id === "gnome-keyring-running") {
|
|
3313
|
+
if (await maybeOfferToStartGnomeKeyring()) {
|
|
3314
|
+
const runningNow = await checkGnomeKeyringRunning$1();
|
|
3315
|
+
if (runningNow.ok) {
|
|
3316
|
+
passed += 1;
|
|
3317
|
+
process.stdout.write(" re-check: gnome-keyring-daemon is now running.\n");
|
|
3318
|
+
} else process.stdout.write(` re-check still failing: ${runningNow.details ?? "process not detected"}\n`);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
process.stdout.write(` Guided checklist summary: ${passed}/${checklist.length} checks passed.\n`);
|
|
3323
|
+
if (passed === checklist.length) {
|
|
3324
|
+
process.stdout.write(" Note: basic checks passed, but secure-store probe still failed. This can happen when the keyring is locked, has no default collection, or your D-Bus/session setup prevents Secret Service writes.\n");
|
|
3325
|
+
await runLinuxSecretStoreDeepRemediation();
|
|
3326
|
+
}
|
|
3327
|
+
};
|
|
3328
|
+
const registerDoctorCommand = (program) => {
|
|
3329
|
+
program.command("doctor").description("Show auth file paths and runtime capabilities").option("--check-keychain-acl", "Run keychain trusted-app/ACL checks on macOS (can be slow)").action(async (options) => {
|
|
3330
|
+
try {
|
|
3331
|
+
const status = await getStatus();
|
|
3332
|
+
const paths = getPaths();
|
|
3333
|
+
const resolveLabel = (accountId) => {
|
|
3334
|
+
if (!accountId) return "unknown";
|
|
3335
|
+
return status.accounts.find((account) => account.accountId === accountId)?.label ?? accountId;
|
|
3336
|
+
};
|
|
3337
|
+
process.stdout.write("\nAuth files:\n");
|
|
3338
|
+
const ocStatus = status.opencodeAuth.exists ? `active: ${resolveLabel(status.opencodeAuth.accountId)}` : "not found";
|
|
3339
|
+
process.stdout.write(` OpenCode: ${ocStatus}\n`);
|
|
3340
|
+
process.stdout.write(` Path: ${paths.authPath}\n`);
|
|
3341
|
+
const cxStatus = status.codexAuth.exists ? `active: ${resolveLabel(status.codexAuth.accountId)}` : "not found";
|
|
3342
|
+
process.stdout.write(` Codex CLI: ${cxStatus}\n`);
|
|
3343
|
+
process.stdout.write(` Path: ${paths.codexAuthPath}\n`);
|
|
3344
|
+
const piStatus = status.piAuth.exists ? `active: ${resolveLabel(status.piAuth.accountId)}` : "not found";
|
|
3345
|
+
process.stdout.write(` Pi Agent: ${piStatus}\n`);
|
|
3346
|
+
process.stdout.write(` Path: ${paths.piAuthPath}\n`);
|
|
3347
|
+
process.stdout.write("\nCapabilities:\n");
|
|
3348
|
+
process.stdout.write(` Platform: ${status.capabilities.platform}\n`);
|
|
3349
|
+
process.stdout.write(` Path profile: ${status.capabilities.pathProfile}\n`);
|
|
3350
|
+
const secretStoreState = status.capabilities.secretStore.available ? "available" : `unavailable${status.capabilities.secretStore.reason ? ` (${status.capabilities.secretStore.reason})` : ""}`;
|
|
3351
|
+
process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
|
|
3352
|
+
const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
|
|
3353
|
+
process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
|
|
3354
|
+
if (process.platform === "win32") {
|
|
3355
|
+
const secretStore = getSecretStoreAdapter();
|
|
3356
|
+
process.stdout.write("\nWindows secure-store checks:\n");
|
|
3357
|
+
if (status.accounts.length === 0) process.stdout.write(" No accounts configured in config.\n");
|
|
3358
|
+
else {
|
|
3359
|
+
let okCount = 0;
|
|
3360
|
+
for (const account of status.accounts) {
|
|
3361
|
+
const accountLabel = resolveLabel(account.accountId);
|
|
3362
|
+
try {
|
|
3363
|
+
await secretStore.load(account.accountId);
|
|
3364
|
+
okCount += 1;
|
|
3365
|
+
process.stdout.write(` ${accountLabel}: credential payload load OK\n`);
|
|
3366
|
+
} catch (error) {
|
|
3367
|
+
if (isMissingSecretStoreEntryError(error)) {
|
|
3368
|
+
process.stdout.write(` ⚠ ${accountLabel}: missing secure-store entry for configured account\n`);
|
|
3369
|
+
continue;
|
|
3370
|
+
}
|
|
3371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3372
|
+
process.stdout.write(` ⚠ ${accountLabel}: secure-store load failed (${message})\n`);
|
|
3373
|
+
}
|
|
3374
|
+
}
|
|
3375
|
+
process.stdout.write(` Summary: ${okCount}/${status.accounts.length} configured account(s) passed secure-store load checks.\n`);
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
const probeHeading = getSecretStoreProbeHeading(process.platform);
|
|
3379
|
+
if (probeHeading) {
|
|
3380
|
+
process.stdout.write(`\n${probeHeading}:\n`);
|
|
3381
|
+
const probeResult = await runSecretStoreWriteReadProbe(createProbeAdapterForCurrentPlatform());
|
|
3382
|
+
if (probeResult.ok) process.stdout.write(" write/read/delete probe: OK\n");
|
|
3383
|
+
else {
|
|
3384
|
+
process.stdout.write(` ⚠ ${probeResult.stage} failed: ${probeResult.error.message}\n`);
|
|
3385
|
+
const guidance = getSecretStoreProbeGuidance(process.platform);
|
|
3386
|
+
if (guidance) process.stdout.write(` ${guidance}\n`);
|
|
3387
|
+
if (process.platform === "linux") await maybeRunLinuxSecretStoreChecklist();
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
if (process.platform === "darwin" && !options.checkKeychainAcl) {
|
|
3391
|
+
process.stdout.write(" ┌─ Optional keychain ACL check\n");
|
|
3392
|
+
process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
|
|
3393
|
+
process.stdout.write(" │ Verifies whether your current runtime is trusted by Keychain.\n");
|
|
3394
|
+
process.stdout.write(" └─ Expected duration: ~30-60 seconds\n");
|
|
3395
|
+
}
|
|
3396
|
+
if (process.platform === "darwin" && options.checkKeychainAcl) {
|
|
3397
|
+
const secretStore = getSecretStoreAdapter();
|
|
3398
|
+
const accountsWithSecrets = status.accounts.filter((account) => account.secureStoreExists);
|
|
3399
|
+
if (accountsWithSecrets.length > 0) {
|
|
3400
|
+
const runtimeExecutablePath = process.execPath;
|
|
3401
|
+
const services = accountsWithSecrets.map((account) => secretStore.getServiceName(account.accountId));
|
|
3402
|
+
process.stdout.write("\nKeychain ACL checks:\n");
|
|
3403
|
+
process.stdout.write(` Runtime executable: ${runtimeExecutablePath}\n`);
|
|
3404
|
+
const aclSpinner = p.spinner();
|
|
3405
|
+
const accountWord = accountsWithSecrets.length === 1 ? "account" : "accounts";
|
|
3406
|
+
aclSpinner.start(`Checking keychain ACLs for ${accountsWithSecrets.length} ${accountWord}...`);
|
|
3407
|
+
const decryptAccessByService = await getKeychainDecryptAccessByServiceAsync(services);
|
|
3408
|
+
aclSpinner.stop("Keychain ACL checks complete.");
|
|
3409
|
+
for (const account of accountsWithSecrets) {
|
|
3410
|
+
const service = secretStore.getServiceName(account.accountId);
|
|
3411
|
+
const decryptAccess = decryptAccessByService.get(service);
|
|
3412
|
+
const accountLabel = resolveLabel(account.accountId);
|
|
3413
|
+
if (!decryptAccess || decryptAccess.mode === "missing") {
|
|
3414
|
+
process.stdout.write(` ${accountLabel}: unable to read decrypt trusted apps (service: ${service})\n`);
|
|
3415
|
+
continue;
|
|
3416
|
+
}
|
|
3417
|
+
if (decryptAccess.mode === "all-apps") {
|
|
3418
|
+
process.stdout.write(` ${accountLabel}: decrypt access allows all apps (<null>)\n`);
|
|
3419
|
+
continue;
|
|
3420
|
+
}
|
|
3421
|
+
const runtimeTrusted = hasRuntimeTrustedApp(decryptAccess.applications, runtimeExecutablePath);
|
|
3422
|
+
const trustedAppsList = decryptAccess.applications.join(", ");
|
|
3423
|
+
if (runtimeTrusted) {
|
|
3424
|
+
process.stdout.write(` ${accountLabel}: runtime is in trusted apps\n`);
|
|
3425
|
+
continue;
|
|
3426
|
+
}
|
|
3427
|
+
process.stdout.write(` ⚠ ${accountLabel}: runtime not found in trusted apps\n`);
|
|
3428
|
+
process.stdout.write(` Service: ${service}\n`);
|
|
3429
|
+
process.stdout.write(` Trusted apps: ${trustedAppsList || "(none)"}\n`);
|
|
3430
|
+
process.stdout.write(" This secret may have been created with a different runtime/toolchain (for example node vs bun).\n");
|
|
3431
|
+
process.stdout.write(" Suggested fix: run `cdx migrate-secrets` to recreate keychain entries with the current runtime ACL.\n");
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
process.stdout.write("\n");
|
|
3436
|
+
} catch (error) {
|
|
3437
|
+
exitWithCommandError(error);
|
|
3438
|
+
}
|
|
3439
|
+
});
|
|
3440
|
+
};
|
|
3441
|
+
//#endregion
|
|
3442
|
+
//#region lib/commands/help.ts
|
|
3443
|
+
const registerHelpCommand = (program) => {
|
|
3444
|
+
program.command("help").description("Show available commands and usage information").argument("[command]", "Show help for a specific command").action((commandName) => {
|
|
3445
|
+
if (commandName) {
|
|
3446
|
+
const command = program.commands.find((entry) => entry.name() === commandName || entry.aliases().includes(commandName));
|
|
3447
|
+
if (command) {
|
|
3448
|
+
command.outputHelp();
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
process.stderr.write(`Unknown command: ${commandName}\n`);
|
|
3452
|
+
program.outputHelp();
|
|
3453
|
+
process.exit(1);
|
|
3454
|
+
}
|
|
3455
|
+
program.outputHelp();
|
|
3456
|
+
});
|
|
3457
|
+
};
|
|
3458
|
+
//#endregion
|
|
3459
|
+
//#region lib/commands/keyring.ts
|
|
3460
|
+
const APT_INSTALL_COMMAND = "sudo apt-get update && sudo apt-get install -y gnome-keyring libsecret-tools dbus-user-session xdg-utils libpam-gnome-keyring";
|
|
3461
|
+
const GNOME_KEYRING_CMDLINE_PATTERN = /(^|\/)gnome-keyring-daemon(\s|$)/;
|
|
3462
|
+
const isInteractiveTerminal = () => Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
3463
|
+
const runCommandCapture = async (command, args) => await new Promise((resolve) => {
|
|
3464
|
+
const child = spawn(command, args, { stdio: [
|
|
3465
|
+
"ignore",
|
|
3466
|
+
"pipe",
|
|
3467
|
+
"pipe"
|
|
3468
|
+
] });
|
|
3469
|
+
let stdout = "";
|
|
3470
|
+
let stderr = "";
|
|
3471
|
+
let spawnError = null;
|
|
3472
|
+
child.stdout?.on("data", (chunk) => {
|
|
3473
|
+
stdout += chunk.toString();
|
|
3474
|
+
});
|
|
3475
|
+
child.stderr?.on("data", (chunk) => {
|
|
3476
|
+
stderr += chunk.toString();
|
|
3477
|
+
});
|
|
3478
|
+
child.once("error", (error) => {
|
|
3479
|
+
spawnError = error.message;
|
|
3480
|
+
});
|
|
3481
|
+
child.once("close", (code) => {
|
|
3482
|
+
resolve({
|
|
3483
|
+
ok: spawnError === null && code === 0,
|
|
3484
|
+
stdout: stdout.trim(),
|
|
3485
|
+
stderr: stderr.trim(),
|
|
3486
|
+
...spawnError ? { error: spawnError } : {}
|
|
3487
|
+
});
|
|
3488
|
+
});
|
|
3489
|
+
});
|
|
3490
|
+
const runCommandInherit = async (command, args) => await new Promise((resolve) => {
|
|
3491
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
3492
|
+
child.once("error", () => resolve(1));
|
|
3493
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
3494
|
+
});
|
|
3495
|
+
const extractCommandFailureDetails = (result) => result.error || result.stderr || result.stdout || void 0;
|
|
3496
|
+
const isCommandAvailable = async (commandName) => {
|
|
3497
|
+
return (await runCommandCapture("sh", ["-lc", `command -v ${commandName} >/dev/null 2>&1`])).ok;
|
|
3498
|
+
};
|
|
3499
|
+
const parseSystemctlEnabledState = (output) => {
|
|
3500
|
+
const normalized = output.trim().toLowerCase();
|
|
3501
|
+
if ([
|
|
3502
|
+
"enabled",
|
|
3503
|
+
"enabled-runtime",
|
|
3504
|
+
"static",
|
|
3505
|
+
"indirect",
|
|
3506
|
+
"generated"
|
|
3507
|
+
].includes(normalized)) return "enabled";
|
|
3508
|
+
if ([
|
|
3509
|
+
"disabled",
|
|
3510
|
+
"masked",
|
|
3511
|
+
"not-found",
|
|
3512
|
+
"linked",
|
|
3513
|
+
"linked-runtime"
|
|
3514
|
+
].includes(normalized)) return "disabled";
|
|
3515
|
+
return "unknown";
|
|
3516
|
+
};
|
|
3517
|
+
const applyEnvAssignments = (raw) => {
|
|
3518
|
+
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
3519
|
+
for (const line of lines) {
|
|
3520
|
+
const match = line.match(/^([A-Z0-9_]+)=(.*);?$/);
|
|
3521
|
+
if (!match) continue;
|
|
3522
|
+
const key = match[1];
|
|
3523
|
+
const value = match[2].replace(/;$/, "");
|
|
3524
|
+
if (key) process.env[key] = value;
|
|
3525
|
+
}
|
|
3526
|
+
};
|
|
3527
|
+
const checkGnomeKeyringRunning = async () => {
|
|
3528
|
+
if (await isCommandAvailable("ps")) {
|
|
3529
|
+
const psResult = await runCommandCapture("ps", [
|
|
3530
|
+
"-A",
|
|
3531
|
+
"-o",
|
|
3532
|
+
"args="
|
|
3533
|
+
]);
|
|
3534
|
+
if (psResult.ok) {
|
|
3535
|
+
if (psResult.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).some((line) => GNOME_KEYRING_CMDLINE_PATTERN.test(line))) return { ok: true };
|
|
3536
|
+
return {
|
|
3537
|
+
ok: false,
|
|
3538
|
+
details: "No gnome-keyring-daemon process found."
|
|
3539
|
+
};
|
|
3540
|
+
}
|
|
3541
|
+
}
|
|
3542
|
+
if (await isCommandAvailable("pgrep")) {
|
|
3543
|
+
const pgrepResult = await runCommandCapture("pgrep", ["-f", "gnome-keyring-daemon"]);
|
|
3544
|
+
if (pgrepResult.ok) return { ok: true };
|
|
3545
|
+
return {
|
|
3546
|
+
ok: false,
|
|
3547
|
+
details: extractCommandFailureDetails(pgrepResult) ?? "No gnome-keyring-daemon process found."
|
|
3548
|
+
};
|
|
3549
|
+
}
|
|
3550
|
+
return {
|
|
3551
|
+
ok: false,
|
|
3552
|
+
details: "Neither ps nor pgrep is available to check gnome-keyring-daemon."
|
|
3553
|
+
};
|
|
3554
|
+
};
|
|
3555
|
+
const getLinuxGnomeKeyringAutoStartStatus = async () => {
|
|
3556
|
+
if (!await isCommandAvailable("systemctl")) return {
|
|
3557
|
+
state: "unknown",
|
|
3558
|
+
details: "systemctl is not available; autostart detection depends on your desktop/session config."
|
|
3559
|
+
};
|
|
3560
|
+
const units = ["gnome-keyring-daemon.socket", "gnome-keyring-daemon.service"];
|
|
3561
|
+
let sawDisabled = false;
|
|
3562
|
+
const details = [];
|
|
3563
|
+
for (const unit of units) {
|
|
3564
|
+
const result = await runCommandCapture("systemctl", [
|
|
3565
|
+
"--user",
|
|
3566
|
+
"is-enabled",
|
|
3567
|
+
unit
|
|
3568
|
+
]);
|
|
3569
|
+
const state = parseSystemctlEnabledState(result.stdout || result.stderr);
|
|
3570
|
+
if (state === "enabled") return {
|
|
3571
|
+
state: "enabled",
|
|
3572
|
+
details: `${unit} is enabled (${result.stdout || "enabled"}).`
|
|
3573
|
+
};
|
|
3574
|
+
if (state === "disabled") {
|
|
3575
|
+
sawDisabled = true;
|
|
3576
|
+
details.push(`${unit}: ${result.stdout || result.stderr || "disabled"}`);
|
|
3577
|
+
continue;
|
|
3578
|
+
}
|
|
3579
|
+
const maybeDetail = extractCommandFailureDetails(result);
|
|
3580
|
+
if (maybeDetail) details.push(`${unit}: ${maybeDetail}`);
|
|
3581
|
+
}
|
|
3582
|
+
if (sawDisabled) return {
|
|
3583
|
+
state: "disabled",
|
|
3584
|
+
details: details.join("; ")
|
|
3585
|
+
};
|
|
3586
|
+
return {
|
|
3587
|
+
state: "unknown",
|
|
3588
|
+
details: details.join("; ") || "Unable to determine gnome-keyring autostart state."
|
|
3589
|
+
};
|
|
3590
|
+
};
|
|
3591
|
+
const startGnomeKeyringNow = async () => {
|
|
3592
|
+
const directStart = await runCommandCapture("gnome-keyring-daemon", ["--start", "--components=secrets"]);
|
|
3593
|
+
if (directStart.ok) {
|
|
3594
|
+
applyEnvAssignments(directStart.stdout);
|
|
3595
|
+
const runningCheck = await checkGnomeKeyringRunning();
|
|
3596
|
+
if (runningCheck.ok) return { ok: true };
|
|
3597
|
+
return {
|
|
3598
|
+
ok: false,
|
|
3599
|
+
details: runningCheck.details ?? "gnome-keyring-daemon start command succeeded, but process was not detected afterwards."
|
|
3600
|
+
};
|
|
3601
|
+
}
|
|
3602
|
+
if (await isCommandAvailable("systemctl")) {
|
|
3603
|
+
const serviceStart = await runCommandCapture("systemctl", [
|
|
3604
|
+
"--user",
|
|
3605
|
+
"start",
|
|
3606
|
+
"gnome-keyring-daemon.service"
|
|
3607
|
+
]);
|
|
3608
|
+
if (serviceStart.ok) {
|
|
3609
|
+
const runningCheck = await checkGnomeKeyringRunning();
|
|
3610
|
+
if (runningCheck.ok) return { ok: true };
|
|
3611
|
+
return {
|
|
3612
|
+
ok: false,
|
|
3613
|
+
details: runningCheck.details ?? "systemctl start succeeded, but gnome-keyring-daemon was not detected afterwards."
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3616
|
+
return {
|
|
3617
|
+
ok: false,
|
|
3618
|
+
details: extractCommandFailureDetails(directStart) ?? extractCommandFailureDetails(serviceStart) ?? "Failed to start gnome-keyring-daemon."
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
return {
|
|
3622
|
+
ok: false,
|
|
3623
|
+
details: extractCommandFailureDetails(directStart) ?? "Failed to start gnome-keyring-daemon."
|
|
3624
|
+
};
|
|
3625
|
+
};
|
|
3626
|
+
const detectLinuxDistro = async () => {
|
|
3627
|
+
try {
|
|
3628
|
+
const pairs = (await readFile("/etc/os-release", "utf8")).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).filter((line) => !line.startsWith("#")).map((line) => {
|
|
3629
|
+
const idx = line.indexOf("=");
|
|
3630
|
+
if (idx === -1) return null;
|
|
3631
|
+
const key = line.slice(0, idx);
|
|
3632
|
+
let value = line.slice(idx + 1);
|
|
3633
|
+
value = value.replace(/^"|"$/g, "");
|
|
3634
|
+
return {
|
|
3635
|
+
key,
|
|
3636
|
+
value
|
|
3637
|
+
};
|
|
3638
|
+
}).filter((entry) => Boolean(entry));
|
|
3639
|
+
const map = new Map(pairs.map((entry) => [entry.key, entry.value]));
|
|
3640
|
+
const id = (map.get("ID") ?? "unknown").toLowerCase();
|
|
3641
|
+
return {
|
|
3642
|
+
id,
|
|
3643
|
+
idLike: (map.get("ID_LIKE") ?? "").toLowerCase().split(/\s+/).map((item) => item.trim()).filter(Boolean),
|
|
3644
|
+
prettyName: map.get("PRETTY_NAME") ?? map.get("NAME") ?? id
|
|
3645
|
+
};
|
|
3646
|
+
} catch {
|
|
3647
|
+
return {
|
|
3648
|
+
id: "unknown",
|
|
3649
|
+
idLike: [],
|
|
3650
|
+
prettyName: "Unknown Linux"
|
|
3651
|
+
};
|
|
3652
|
+
}
|
|
3653
|
+
};
|
|
3654
|
+
const isDebianUbuntuMint = (distro) => {
|
|
3655
|
+
if ([
|
|
3656
|
+
"debian",
|
|
3657
|
+
"ubuntu",
|
|
3658
|
+
"linuxmint",
|
|
3659
|
+
"mint"
|
|
3660
|
+
].includes(distro.id)) return true;
|
|
3661
|
+
return distro.idLike.some((item) => [
|
|
3662
|
+
"debian",
|
|
3663
|
+
"ubuntu",
|
|
3664
|
+
"linuxmint",
|
|
3665
|
+
"mint"
|
|
3666
|
+
].includes(item));
|
|
3667
|
+
};
|
|
3668
|
+
const printChecklist = (title, items) => {
|
|
3669
|
+
process.stdout.write(`${title}:\n`);
|
|
3670
|
+
let passed = 0;
|
|
3671
|
+
items.forEach((item, index) => {
|
|
3672
|
+
if (item.ok) {
|
|
3673
|
+
passed += 1;
|
|
3674
|
+
process.stdout.write(` ${index + 1}. ${item.question}: yes\n`);
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
process.stdout.write(` ${index + 1}. ${item.question}: no\n`);
|
|
3678
|
+
if (item.details) process.stdout.write(` details: ${item.details}\n`);
|
|
3679
|
+
if (item.hint) process.stdout.write(` hint: ${item.hint}\n`);
|
|
3680
|
+
});
|
|
3681
|
+
process.stdout.write(` Summary: ${passed}/${items.length} checks passed.\n`);
|
|
3682
|
+
return passed;
|
|
3683
|
+
};
|
|
3684
|
+
const runLinuxKeyringCheck = async (options = {}) => {
|
|
3685
|
+
const distro = await detectLinuxDistro();
|
|
3686
|
+
process.stdout.write("\nLinux keyring diagnostics:\n");
|
|
3687
|
+
process.stdout.write(` Distro: ${distro.prettyName} (id=${distro.id})\n`);
|
|
3688
|
+
process.stdout.write(` DBUS_SESSION_BUS_ADDRESS: ${process.env.DBUS_SESSION_BUS_ADDRESS ?? "<unset>"}\n`);
|
|
3689
|
+
process.stdout.write(` XDG_RUNTIME_DIR: ${process.env.XDG_RUNTIME_DIR ?? "<unset>"}\n`);
|
|
3690
|
+
const gnomeKeyringInstalled = await isCommandAvailable("gnome-keyring-daemon");
|
|
3691
|
+
const secretToolInstalled = await isCommandAvailable("secret-tool");
|
|
3692
|
+
const xdgOpenInstalled = await isCommandAvailable("xdg-open");
|
|
3693
|
+
const dbusSendInstalled = await isCommandAvailable("dbus-send");
|
|
3694
|
+
const running = await checkGnomeKeyringRunning();
|
|
3695
|
+
const autostart = await getLinuxGnomeKeyringAutoStartStatus();
|
|
3696
|
+
const checklistPassed = printChecklist("\nDependency and runtime checks", [
|
|
3697
|
+
{
|
|
3698
|
+
question: "gnome-keyring-daemon installed",
|
|
3699
|
+
ok: gnomeKeyringInstalled,
|
|
3700
|
+
hint: "Install package: gnome-keyring"
|
|
3701
|
+
},
|
|
3702
|
+
{
|
|
3703
|
+
question: "secret-tool installed",
|
|
3704
|
+
ok: secretToolInstalled,
|
|
3705
|
+
hint: "Install package: libsecret-tools"
|
|
3706
|
+
},
|
|
3707
|
+
{
|
|
3708
|
+
question: "dbus-send installed",
|
|
3709
|
+
ok: dbusSendInstalled,
|
|
3710
|
+
hint: "Install package: dbus"
|
|
3711
|
+
},
|
|
3712
|
+
{
|
|
3713
|
+
question: "xdg-open installed",
|
|
3714
|
+
ok: xdgOpenInstalled,
|
|
3715
|
+
hint: "Install package: xdg-utils"
|
|
3716
|
+
},
|
|
3717
|
+
{
|
|
3718
|
+
question: "gnome-keyring-daemon running",
|
|
3719
|
+
ok: running.ok,
|
|
3720
|
+
details: running.details,
|
|
3721
|
+
hint: "Start daemon: gnome-keyring-daemon --start --components=secrets"
|
|
3722
|
+
}
|
|
3723
|
+
]);
|
|
3724
|
+
process.stdout.write("\nAutostart status:\n");
|
|
3725
|
+
process.stdout.write(` gnome-keyring autostart: ${autostart.state}`);
|
|
3726
|
+
if (autostart.details) process.stdout.write(` (${autostart.details})`);
|
|
3727
|
+
process.stdout.write("\n");
|
|
3728
|
+
if (options.allowInteractiveStart !== false && isInteractiveTerminal() && gnomeKeyringInstalled && !running.ok) {
|
|
3729
|
+
const shouldStart = await p.confirm({
|
|
3730
|
+
message: "gnome-keyring-daemon is not running. Start it now for this session?",
|
|
3731
|
+
initialValue: true
|
|
3732
|
+
});
|
|
3733
|
+
if (!p.isCancel(shouldStart) && shouldStart) {
|
|
3734
|
+
const started = await startGnomeKeyringNow();
|
|
3735
|
+
if (started.ok) process.stdout.write(" Started gnome-keyring-daemon for this session.\n");
|
|
3736
|
+
else process.stdout.write(` Failed to start gnome-keyring-daemon (${started.details ?? "unknown error"}).\n`);
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
process.stdout.write("\nSecret-store write/read/delete probe:\n");
|
|
3740
|
+
const probeResult = await runSecretStoreWriteReadProbe(createRuntimeSecretStoreAdapter("linux"));
|
|
3741
|
+
if (probeResult.ok) process.stdout.write(" write/read/delete probe: OK\n");
|
|
3742
|
+
else {
|
|
3743
|
+
process.stdout.write(` ${probeResult.stage} failed: ${probeResult.error.message}\n`);
|
|
3744
|
+
process.stdout.write(" Suggested fix: ensure Secret Service is running and unlocked (gnome-keyring + secret-tool), then run `cdx keyring check` again.\n");
|
|
3745
|
+
}
|
|
3746
|
+
const checksOk = checklistPassed === 5 && probeResult.ok;
|
|
3747
|
+
process.stdout.write(`\nResult: ${checksOk ? "OK" : "NOT READY"}\n\n`);
|
|
3748
|
+
return checksOk;
|
|
3749
|
+
};
|
|
3750
|
+
const runLinuxKeyringInstall = async (options) => {
|
|
3751
|
+
const distro = await detectLinuxDistro();
|
|
3752
|
+
process.stdout.write("\nLinux keyring setup:\n");
|
|
3753
|
+
process.stdout.write(` Distro: ${distro.prettyName} (id=${distro.id})\n`);
|
|
3754
|
+
if (!isDebianUbuntuMint(distro)) {
|
|
3755
|
+
process.stdout.write("\nAutomatic install is currently only implemented for Debian/Ubuntu/Mint.\n");
|
|
3756
|
+
process.stdout.write("Install these packages manually, then run `cdx keyring check`:\n");
|
|
3757
|
+
process.stdout.write(" - gnome-keyring\n");
|
|
3758
|
+
process.stdout.write(" - libsecret-tools\n");
|
|
3759
|
+
process.stdout.write(" - dbus-user-session\n");
|
|
3760
|
+
process.stdout.write(" - xdg-utils\n");
|
|
3761
|
+
process.stdout.write(" - libpam-gnome-keyring (optional, for PAM auto-unlock)\n\n");
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
process.stdout.write("\nInstall command:\n");
|
|
3765
|
+
process.stdout.write(` ${APT_INSTALL_COMMAND}\n`);
|
|
3766
|
+
let shouldRun = true;
|
|
3767
|
+
if (!options.yes) {
|
|
3768
|
+
if (!isInteractiveTerminal()) {
|
|
3769
|
+
process.stdout.write("\nNon-interactive terminal detected. Re-run with --yes to execute install automatically.\n\n");
|
|
3770
|
+
return;
|
|
3771
|
+
}
|
|
3772
|
+
const confirmed = await p.confirm({
|
|
3773
|
+
message: "Run this install command now?",
|
|
3774
|
+
initialValue: true
|
|
3775
|
+
});
|
|
3776
|
+
shouldRun = !p.isCancel(confirmed) && confirmed;
|
|
3777
|
+
}
|
|
3778
|
+
if (!shouldRun) {
|
|
3779
|
+
process.stdout.write("\nInstall skipped.\n\n");
|
|
3780
|
+
return;
|
|
3781
|
+
}
|
|
3782
|
+
const exitCode = await runCommandInherit("sh", ["-lc", APT_INSTALL_COMMAND]);
|
|
3783
|
+
if (exitCode !== 0) throw new Error(`Install command failed with exit code ${exitCode}.`);
|
|
3784
|
+
process.stdout.write("\nInstall completed.\n");
|
|
3785
|
+
if (!options.skipCheck) {
|
|
3786
|
+
if (!await runLinuxKeyringCheck({ allowInteractiveStart: true })) process.exitCode = 1;
|
|
3787
|
+
} else process.stdout.write("Run `cdx keyring check` to verify setup.\n\n");
|
|
3788
|
+
};
|
|
3789
|
+
const registerKeyringCommand = (program) => {
|
|
3790
|
+
const keyring = program.command("keyring").description("Setup and diagnose Linux gnome-keyring/Secret Service support");
|
|
3791
|
+
keyring.command("check").description("Run focused Linux keyring dependency and probe checks").action(async () => {
|
|
3792
|
+
try {
|
|
3793
|
+
if (process.platform !== "linux") {
|
|
3794
|
+
process.stdout.write("This command currently targets Linux only.\n\n");
|
|
3795
|
+
return;
|
|
3796
|
+
}
|
|
3797
|
+
if (!await runLinuxKeyringCheck({ allowInteractiveStart: true })) process.exitCode = 1;
|
|
3798
|
+
} catch (error) {
|
|
3799
|
+
exitWithCommandError(error);
|
|
3800
|
+
}
|
|
3801
|
+
});
|
|
3802
|
+
keyring.command("install").description("Install gnome-keyring dependencies on Debian/Ubuntu/Mint").option("--yes", "Run installation without interactive confirmation").option("--skip-check", "Skip automatic post-install verification").action(async (options) => {
|
|
3803
|
+
try {
|
|
3804
|
+
if (process.platform !== "linux") {
|
|
3805
|
+
process.stdout.write("This command currently targets Linux only.\n\n");
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
await runLinuxKeyringInstall(options);
|
|
3809
|
+
} catch (error) {
|
|
3810
|
+
exitWithCommandError(error);
|
|
3811
|
+
}
|
|
3812
|
+
});
|
|
3813
|
+
};
|
|
3814
|
+
//#endregion
|
|
3815
|
+
//#region lib/commands/label.ts
|
|
3816
|
+
const registerLabelCommand = (program) => {
|
|
3817
|
+
program.command("label").description("Add or change label for an account").argument("[account]", "Account ID or current label to relabel").argument("[new-label]", "New label to assign").action(async (account, newLabel) => {
|
|
3818
|
+
try {
|
|
3819
|
+
if (account && newLabel) {
|
|
3820
|
+
const config = await loadConfig();
|
|
3821
|
+
const target = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
3822
|
+
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
3823
|
+
target.label = newLabel;
|
|
3824
|
+
await saveConfig(config);
|
|
3825
|
+
process.stdout.write(`Account ${target.accountId} labeled as "${newLabel}".\n`);
|
|
3826
|
+
return;
|
|
3827
|
+
}
|
|
3828
|
+
await handleLabelAccount();
|
|
3829
|
+
} catch (error) {
|
|
3830
|
+
exitWithCommandError(error);
|
|
3831
|
+
}
|
|
3832
|
+
});
|
|
3833
|
+
};
|
|
3834
|
+
//#endregion
|
|
3835
|
+
//#region lib/commands/login.ts
|
|
3836
|
+
const registerLoginCommand = (program, deps = {}) => {
|
|
3837
|
+
const runLogin = deps.performLogin ?? performLogin;
|
|
3838
|
+
program.command("login").description("Add a new OpenAI account via OAuth").option("--device-flow", "Use OAuth device flow instead of browser callback flow (manual/browser flow is recommended; device flow may fail on some VPS due to Cloudflare)").action(async (options) => {
|
|
3839
|
+
try {
|
|
3840
|
+
if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
|
|
3841
|
+
process.stderr.write("Login failed.\n");
|
|
3842
|
+
process.exit(1);
|
|
3843
|
+
}
|
|
3844
|
+
} catch (error) {
|
|
3845
|
+
exitWithCommandError(error);
|
|
3846
|
+
}
|
|
3847
|
+
});
|
|
3848
|
+
};
|
|
3849
|
+
//#endregion
|
|
3850
|
+
//#region lib/secrets/migrate.ts
|
|
3851
|
+
const asErrorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
3852
|
+
const migrateLegacyMacOSSecrets = async (options = {}) => {
|
|
3853
|
+
if ((options.platform ?? process.platform) !== "darwin") throw new Error("'migrate-secrets' is only available on macOS (darwin).");
|
|
3854
|
+
const loadConfigFn = options.loadConfigFn ?? loadConfig;
|
|
3855
|
+
const saveConfigFn = options.saveConfigFn ?? saveConfig;
|
|
3856
|
+
const sourceAdapter = options.sourceAdapter ?? createMacOSLegacyKeychainAdapter();
|
|
3857
|
+
const targetAdapter = options.targetAdapter ?? createRuntimeSecretStoreAdapter("darwin");
|
|
3858
|
+
const config = await loadConfigFn();
|
|
3859
|
+
const accountResults = [];
|
|
3860
|
+
for (const account of config.accounts) {
|
|
3861
|
+
const accountId = account.accountId;
|
|
3862
|
+
try {
|
|
3863
|
+
if (!await sourceAdapter.exists(accountId)) {
|
|
3864
|
+
accountResults.push({
|
|
3865
|
+
accountId,
|
|
3866
|
+
label: account.label,
|
|
3867
|
+
status: "skipped",
|
|
3868
|
+
message: "No legacy keychain entry found."
|
|
3869
|
+
});
|
|
3870
|
+
continue;
|
|
3871
|
+
}
|
|
3872
|
+
const loaded = await sourceAdapter.load(accountId);
|
|
3873
|
+
const payload = loaded.accountId === accountId ? loaded : {
|
|
3874
|
+
...loaded,
|
|
3875
|
+
accountId
|
|
3876
|
+
};
|
|
3877
|
+
await sourceAdapter.delete(accountId);
|
|
3878
|
+
try {
|
|
3879
|
+
await targetAdapter.save(accountId, payload);
|
|
3880
|
+
} catch (error) {
|
|
3881
|
+
try {
|
|
3882
|
+
await sourceAdapter.save(accountId, payload);
|
|
3883
|
+
} catch {}
|
|
3884
|
+
throw error;
|
|
3885
|
+
}
|
|
3886
|
+
accountResults.push({
|
|
3887
|
+
accountId,
|
|
3888
|
+
label: account.label,
|
|
3889
|
+
status: "migrated",
|
|
3890
|
+
message: "Legacy entry migrated to cross-keychain backend."
|
|
3891
|
+
});
|
|
3892
|
+
} catch (error) {
|
|
3893
|
+
accountResults.push({
|
|
3894
|
+
accountId,
|
|
3895
|
+
label: account.label,
|
|
3896
|
+
status: "failed",
|
|
3897
|
+
message: asErrorMessage(error)
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
const failed = accountResults.filter((entry) => entry.status === "failed").length;
|
|
3902
|
+
const skipped = accountResults.filter((entry) => entry.status === "skipped").length;
|
|
3903
|
+
const migrated = accountResults.filter((entry) => entry.status === "migrated").length;
|
|
3904
|
+
let configUpdated = false;
|
|
3905
|
+
if (failed === 0) {
|
|
3906
|
+
let changed = false;
|
|
3907
|
+
for (const account of config.accounts) {
|
|
3908
|
+
const expectedService = targetAdapter.getServiceName(account.accountId);
|
|
3909
|
+
if (account.keychainService !== expectedService) {
|
|
3910
|
+
account.keychainService = expectedService;
|
|
3911
|
+
changed = true;
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
if (config.secretStore !== "auto") {
|
|
3915
|
+
config.secretStore = "auto";
|
|
3916
|
+
changed = true;
|
|
3917
|
+
}
|
|
3918
|
+
if (changed) {
|
|
3919
|
+
await saveConfigFn(config);
|
|
3920
|
+
configUpdated = true;
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
return {
|
|
3924
|
+
migrated,
|
|
3925
|
+
skipped,
|
|
3926
|
+
failed,
|
|
3927
|
+
configUpdated,
|
|
3928
|
+
accountResults
|
|
3929
|
+
};
|
|
3930
|
+
};
|
|
3931
|
+
//#endregion
|
|
3932
|
+
//#region lib/commands/migrate-secrets.ts
|
|
3933
|
+
const statusPrefix = (result) => {
|
|
3934
|
+
if (result.status === "migrated") return "✓";
|
|
3935
|
+
if (result.status === "skipped") return "-";
|
|
3936
|
+
return "✗";
|
|
3937
|
+
};
|
|
3938
|
+
const formatName = (result) => result.label ? `${result.label} (${result.accountId})` : result.accountId;
|
|
3939
|
+
const registerMigrateSecretsCommand = (program) => {
|
|
3940
|
+
program.command("migrate-secrets").description("Migrate macOS legacy keychain entries to cross-keychain and update config").action(async () => {
|
|
3941
|
+
try {
|
|
3942
|
+
const result = await migrateLegacyMacOSSecrets();
|
|
3943
|
+
process.stdout.write("\nSecret migration results:\n");
|
|
3944
|
+
for (const accountResult of result.accountResults) process.stdout.write(` ${statusPrefix(accountResult)} ${formatName(accountResult)}: ${accountResult.message}\n`);
|
|
3945
|
+
process.stdout.write("\nSummary:\n");
|
|
3946
|
+
process.stdout.write(` Migrated: ${result.migrated}\n`);
|
|
3947
|
+
process.stdout.write(` Skipped: ${result.skipped}\n`);
|
|
3948
|
+
process.stdout.write(` Failed: ${result.failed}\n`);
|
|
3949
|
+
if (result.failed === 0) {
|
|
3950
|
+
process.stdout.write(result.configUpdated ? " Config: updated (secretStore=auto, service names normalized)\n\n" : " Config: already up to date\n\n");
|
|
3951
|
+
return;
|
|
3952
|
+
}
|
|
3953
|
+
process.stdout.write(" Config: not updated because at least one account failed\n\n");
|
|
3954
|
+
throw new Error(`Migration finished with ${result.failed} failed account(s). Resolve them and run 'cdx migrate-secrets' again.`);
|
|
3955
|
+
} catch (error) {
|
|
3956
|
+
exitWithCommandError(error);
|
|
3957
|
+
}
|
|
3958
|
+
});
|
|
3959
|
+
};
|
|
3960
|
+
//#endregion
|
|
3961
|
+
//#region lib/commands/output.ts
|
|
3962
|
+
const formatCodexMark = (result) => {
|
|
3963
|
+
if (result.codexWritten) return "✓";
|
|
3964
|
+
if (result.codexCleared) return "⚠ missing id_token (cleared)";
|
|
3965
|
+
return "⚠ missing id_token";
|
|
3966
|
+
};
|
|
3967
|
+
const writeSwitchSummary = (displayName, result) => {
|
|
3968
|
+
const piMark = result.piWritten ? "✓" : "✗";
|
|
3969
|
+
const codexMark = formatCodexMark(result);
|
|
3970
|
+
process.stdout.write(`Switched to account ${displayName}\n`);
|
|
3971
|
+
process.stdout.write(" OpenCode: ✓\n");
|
|
3972
|
+
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
3973
|
+
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
3974
|
+
};
|
|
3975
|
+
const writeUpdatedAuthSummary = (result) => {
|
|
3976
|
+
const piMark = result.piWritten ? "✓" : "✗";
|
|
3977
|
+
const codexMark = formatCodexMark(result);
|
|
3978
|
+
process.stdout.write("Updated active auth files:\n");
|
|
3979
|
+
process.stdout.write(" OpenCode: ✓\n");
|
|
3980
|
+
process.stdout.write(` Pi Agent: ${piMark}\n`);
|
|
3981
|
+
process.stdout.write(` Codex CLI: ${codexMark}\n`);
|
|
3982
|
+
};
|
|
3983
|
+
//#endregion
|
|
3984
|
+
//#region lib/commands/refresh.ts
|
|
3985
|
+
const registerReloginCommand = (program) => {
|
|
3986
|
+
program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").option("--device-flow", "Use OAuth device flow instead of browser callback flow (manual/browser flow is recommended; device flow may fail on some VPS due to Cloudflare)").argument("[account]", "Account ID or label to re-login").action(async (account, options) => {
|
|
3987
|
+
try {
|
|
3988
|
+
const authFlow = options.deviceFlow ? "device" : "auto";
|
|
3989
|
+
if (account) {
|
|
3990
|
+
const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
3991
|
+
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
3992
|
+
const displayName = target.label ?? target.accountId;
|
|
3993
|
+
let expiryState = "unknown";
|
|
3994
|
+
let secureStoreState = "";
|
|
3995
|
+
const secretStore = getSecretStoreAdapter();
|
|
3996
|
+
if (await secretStore.exists(target.accountId)) try {
|
|
3997
|
+
expiryState = formatExpiry((await secretStore.load(target.accountId)).expires);
|
|
3998
|
+
} catch {
|
|
3999
|
+
expiryState = "unknown";
|
|
4000
|
+
}
|
|
4001
|
+
else secureStoreState = " [no secure store entry]";
|
|
4002
|
+
process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
|
|
4003
|
+
const result = await performRefresh(target.accountId, target.label, { authFlow });
|
|
4004
|
+
if (!result) {
|
|
4005
|
+
process.stderr.write("Re-login failed.\n");
|
|
4006
|
+
process.exit(1);
|
|
4007
|
+
}
|
|
4008
|
+
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
4009
|
+
if (authResult) writeUpdatedAuthSummary(authResult);
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
await handleReloginAccount({ authFlow });
|
|
4013
|
+
} catch (error) {
|
|
4014
|
+
exitWithCommandError(error);
|
|
4015
|
+
}
|
|
4016
|
+
});
|
|
4017
|
+
};
|
|
4018
|
+
//#endregion
|
|
4019
|
+
//#region lib/usage.ts
|
|
4020
|
+
const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
|
|
4021
|
+
const USER_AGENT = "cdx-cli";
|
|
4022
|
+
/**
|
|
4023
|
+
* Hits the undocumented OpenAI usage endpoint (may change without notice).
|
|
4024
|
+
*/
|
|
4025
|
+
const fetchUsageRaw = async (accessToken, accountId) => {
|
|
4026
|
+
const headers = {
|
|
4027
|
+
Authorization: `Bearer ${accessToken}`,
|
|
4028
|
+
"User-Agent": USER_AGENT,
|
|
4029
|
+
Accept: "application/json"
|
|
4030
|
+
};
|
|
4031
|
+
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
|
|
4032
|
+
return fetch(USAGE_ENDPOINT, { headers });
|
|
4033
|
+
};
|
|
4034
|
+
/**
|
|
4035
|
+
* Fetches usage for an account. On 401, refreshes the token and retries once.
|
|
4036
|
+
*/
|
|
4037
|
+
const fetchUsage = async (accountId) => {
|
|
4038
|
+
const secretStore = getSecretStoreAdapter();
|
|
4039
|
+
let payload;
|
|
4040
|
+
try {
|
|
4041
|
+
payload = await secretStore.load(accountId);
|
|
4042
|
+
} catch (err) {
|
|
4043
|
+
return {
|
|
4044
|
+
ok: false,
|
|
4045
|
+
error: {
|
|
4046
|
+
type: "auth_failed",
|
|
4047
|
+
message: err instanceof Error ? err.message : "Failed to load credentials"
|
|
4048
|
+
}
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
try {
|
|
4052
|
+
let response = await fetchUsageRaw(payload.access, payload.accountId);
|
|
4053
|
+
if (response.status === 401) {
|
|
4054
|
+
const refreshResult = await refreshAccessToken(payload.refresh);
|
|
4055
|
+
if (refreshResult.type === "failed") return {
|
|
4056
|
+
ok: false,
|
|
4057
|
+
error: {
|
|
4058
|
+
type: "auth_failed",
|
|
4059
|
+
message: "Token expired and refresh failed. Try 'cdx login' to re-authenticate."
|
|
4060
|
+
}
|
|
4061
|
+
};
|
|
4062
|
+
const updatedPayload = {
|
|
4063
|
+
...payload,
|
|
4064
|
+
access: refreshResult.access,
|
|
4065
|
+
refresh: refreshResult.refresh,
|
|
4066
|
+
expires: refreshResult.expires,
|
|
4067
|
+
idToken: refreshResult.idToken ?? payload.idToken
|
|
4068
|
+
};
|
|
4069
|
+
await secretStore.save(accountId, updatedPayload);
|
|
4070
|
+
response = await fetchUsageRaw(updatedPayload.access, updatedPayload.accountId);
|
|
4071
|
+
if (!response.ok) return {
|
|
4072
|
+
ok: false,
|
|
4073
|
+
error: {
|
|
4074
|
+
type: "auth_failed",
|
|
4075
|
+
message: `Usage API returned ${response.status} after token refresh.`
|
|
4076
|
+
}
|
|
4077
|
+
};
|
|
4078
|
+
} else if (!response.ok) return {
|
|
4079
|
+
ok: false,
|
|
4080
|
+
error: {
|
|
4081
|
+
type: "unexpected",
|
|
4082
|
+
message: `Usage API returned ${response.status}: ${response.statusText}`
|
|
4083
|
+
}
|
|
4084
|
+
};
|
|
4085
|
+
return {
|
|
4086
|
+
ok: true,
|
|
4087
|
+
data: await response.json()
|
|
4088
|
+
};
|
|
4089
|
+
} catch (err) {
|
|
4090
|
+
return {
|
|
4091
|
+
ok: false,
|
|
4092
|
+
error: {
|
|
4093
|
+
type: "network_error",
|
|
4094
|
+
message: err instanceof Error ? err.message : "Network request failed"
|
|
4095
|
+
}
|
|
4096
|
+
};
|
|
4097
|
+
}
|
|
4098
|
+
};
|
|
4099
|
+
const formatWindowLabel = (seconds) => {
|
|
4100
|
+
const hours = seconds / 3600;
|
|
4101
|
+
if (hours >= 24) {
|
|
4102
|
+
const days = Math.round(hours / 24);
|
|
4103
|
+
return days === 7 ? "weekly" : `${days}d`;
|
|
4104
|
+
}
|
|
4105
|
+
return `${Math.round(hours)}h`;
|
|
4106
|
+
};
|
|
4107
|
+
const formatResetCountdown = (resetAtUnix) => {
|
|
4108
|
+
const diff = resetAtUnix * 1e3 - Date.now();
|
|
4109
|
+
if (diff <= 0) return "now";
|
|
4110
|
+
const minutes = Math.floor(diff / 6e4);
|
|
4111
|
+
const hours = Math.floor(minutes / 60);
|
|
4112
|
+
const remainingMinutes = minutes % 60;
|
|
4113
|
+
if (hours > 0) return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
4114
|
+
return `${minutes}m`;
|
|
4115
|
+
};
|
|
4116
|
+
const formatPercentageBar = (usedPercent) => {
|
|
4117
|
+
const width = 20;
|
|
4118
|
+
const filled = Math.round(usedPercent / 100 * width);
|
|
4119
|
+
const empty = width - filled;
|
|
4120
|
+
return `[${"█".repeat(filled)}${"░".repeat(empty)}] ${usedPercent}% used`;
|
|
4121
|
+
};
|
|
4122
|
+
const formatWindow = (label, w) => {
|
|
4123
|
+
return [
|
|
4124
|
+
`${label} (${formatWindowLabel(w.limit_window_seconds)} window):`,
|
|
4125
|
+
` ${formatPercentageBar(w.used_percent)}`,
|
|
4126
|
+
` Resets in: ${formatResetCountdown(w.reset_at)}`
|
|
4127
|
+
];
|
|
4128
|
+
};
|
|
4129
|
+
const formatUsage = (usage) => {
|
|
4130
|
+
const lines = [];
|
|
4131
|
+
const plan = usage.plan_type ?? "unknown";
|
|
4132
|
+
lines.push(`Plan: ${plan}`);
|
|
4133
|
+
lines.push("");
|
|
4134
|
+
if (usage.rate_limit?.primary_window) lines.push(...formatWindow("Primary", usage.rate_limit.primary_window));
|
|
4135
|
+
if (usage.rate_limit?.secondary_window) lines.push(...formatWindow("Secondary", usage.rate_limit.secondary_window));
|
|
4136
|
+
if (usage.credits) {
|
|
4137
|
+
lines.push("");
|
|
4138
|
+
if (usage.credits.unlimited) lines.push("Credits: unlimited");
|
|
4139
|
+
else if (usage.credits.has_credits && usage.credits.balance !== void 0) lines.push(`Credits: $${Number(usage.credits.balance).toFixed(2)}`);
|
|
4140
|
+
else if (!usage.credits.has_credits) lines.push("Credits: none");
|
|
4141
|
+
}
|
|
4142
|
+
return lines.join("\n");
|
|
4143
|
+
};
|
|
4144
|
+
const formatUsageBars = (usage, indent = " ") => {
|
|
4145
|
+
const windows = [];
|
|
4146
|
+
if (usage.rate_limit?.primary_window) windows.push({
|
|
4147
|
+
label: formatWindowLabel(usage.rate_limit.primary_window.limit_window_seconds),
|
|
4148
|
+
window: usage.rate_limit.primary_window
|
|
4149
|
+
});
|
|
4150
|
+
if (usage.rate_limit?.secondary_window) windows.push({
|
|
4151
|
+
label: formatWindowLabel(usage.rate_limit.secondary_window.limit_window_seconds),
|
|
4152
|
+
window: usage.rate_limit.secondary_window
|
|
4153
|
+
});
|
|
4154
|
+
const maxLabelLen = Math.max(...windows.map((w) => w.label.length), 0);
|
|
4155
|
+
return windows.map(({ label, window: w }) => {
|
|
4156
|
+
return `${indent}${label.padEnd(maxLabelLen)} ${formatPercentageBar(w.used_percent)} resets in ${formatResetCountdown(w.reset_at)}`;
|
|
4157
|
+
});
|
|
4158
|
+
};
|
|
4159
|
+
const formatUsageOverview = (entries) => {
|
|
4160
|
+
const lines = [];
|
|
4161
|
+
for (let i = 0; i < entries.length; i++) {
|
|
4162
|
+
const entry = entries[i];
|
|
4163
|
+
const marker = entry.isCurrent ? "→ " : " ";
|
|
4164
|
+
if (entry.result.ok) {
|
|
4165
|
+
const usage = entry.result.data;
|
|
4166
|
+
const plan = usage.plan_type ?? "unknown";
|
|
4167
|
+
lines.push(`${marker}${entry.displayName} (${plan})`);
|
|
4168
|
+
lines.push(...formatUsageBars(usage));
|
|
4169
|
+
} else lines.push(`${marker}${entry.displayName}: [error] ${entry.result.error.message}`);
|
|
4170
|
+
if (i < entries.length - 1) lines.push("");
|
|
4171
|
+
}
|
|
4172
|
+
return lines.join("\n");
|
|
4173
|
+
};
|
|
4174
|
+
//#endregion
|
|
4175
|
+
//#region lib/commands/status.ts
|
|
4176
|
+
const registerStatusCommand = (program) => {
|
|
4177
|
+
program.command("status").description("Show account status, token expiry, and usage").action(async () => {
|
|
4178
|
+
try {
|
|
4179
|
+
const status = await getStatus();
|
|
4180
|
+
if (status.accounts.length === 0) {
|
|
4181
|
+
process.stdout.write("No accounts configured. Use 'cdx login' to add one.\n");
|
|
4182
|
+
return;
|
|
4183
|
+
}
|
|
4184
|
+
process.stdout.write("\n");
|
|
4185
|
+
for (let i = 0; i < status.accounts.length; i++) {
|
|
4186
|
+
const account = status.accounts[i];
|
|
4187
|
+
const marker = account.isCurrent ? "→ " : " ";
|
|
4188
|
+
const warnings = [];
|
|
4189
|
+
if (!account.secureStoreExists) warnings.push("[no secure store entry]");
|
|
4190
|
+
if (!account.hasIdToken) warnings.push("[no id_token]");
|
|
4191
|
+
const warnStr = warnings.length > 0 ? ` ${warnings.join(" ")}` : "";
|
|
4192
|
+
const displayName = account.label ?? account.accountId;
|
|
4193
|
+
process.stdout.write(`${marker}${displayName}${warnStr}\n`);
|
|
4194
|
+
if (account.label) process.stdout.write(` ${account.accountId}\n`);
|
|
4195
|
+
process.stdout.write(` ${account.expiresIn}\n`);
|
|
4196
|
+
if (i < status.accounts.length - 1) process.stdout.write("\n");
|
|
4197
|
+
}
|
|
4198
|
+
const usageSpinner = p.spinner();
|
|
4199
|
+
const accountWord = status.accounts.length === 1 ? "account" : "accounts";
|
|
4200
|
+
usageSpinner.start(`Fetching usage for ${status.accounts.length} ${accountWord}...`);
|
|
4201
|
+
const usageResults = await Promise.allSettled(status.accounts.map((account) => fetchUsage(account.accountId)));
|
|
4202
|
+
const failedUsageCount = usageResults.filter((result) => result.status === "rejected" || result.status === "fulfilled" && !result.value.ok).length;
|
|
4203
|
+
if (failedUsageCount === 0) usageSpinner.stop("Usage loaded.");
|
|
4204
|
+
else {
|
|
4205
|
+
const failedWord = failedUsageCount === 1 ? "account" : "accounts";
|
|
4206
|
+
usageSpinner.stop(`Usage loaded (${failedUsageCount} ${failedWord} failed).`);
|
|
4207
|
+
}
|
|
4208
|
+
process.stdout.write("\nUsage:\n");
|
|
4209
|
+
for (let i = 0; i < status.accounts.length; i++) {
|
|
4210
|
+
const account = status.accounts[i];
|
|
4211
|
+
const marker = account.isCurrent ? "→ " : " ";
|
|
4212
|
+
const displayName = account.label ?? account.accountId;
|
|
4213
|
+
process.stdout.write(`${marker}${displayName}\n`);
|
|
4214
|
+
if (account.label) process.stdout.write(` ${account.accountId}\n`);
|
|
4215
|
+
const usageResult = usageResults[i];
|
|
4216
|
+
if (usageResult.status === "rejected") {
|
|
4217
|
+
const message = usageResult.reason instanceof Error ? usageResult.reason.message : "Fetch failed";
|
|
4218
|
+
process.stdout.write(` [usage unavailable] ${message}\n`);
|
|
4219
|
+
} else if (usageResult.value.ok) {
|
|
4220
|
+
const bars = formatUsageBars(usageResult.value.data);
|
|
4221
|
+
if (bars.length === 0) process.stdout.write(" Usage data unavailable\n");
|
|
4222
|
+
for (const bar of bars) process.stdout.write(`${bar}\n`);
|
|
4223
|
+
} else process.stdout.write(` [usage unavailable] ${usageResult.value.error.message}\n`);
|
|
4224
|
+
if (i < status.accounts.length - 1) process.stdout.write("\n");
|
|
4225
|
+
}
|
|
4226
|
+
process.stdout.write("\n");
|
|
4227
|
+
} catch (error) {
|
|
4228
|
+
exitWithCommandError(error);
|
|
4229
|
+
}
|
|
4230
|
+
});
|
|
4231
|
+
};
|
|
4232
|
+
//#endregion
|
|
4233
|
+
//#region lib/commands/switch.ts
|
|
4234
|
+
const switchNext = async () => {
|
|
4235
|
+
const config = await loadConfig();
|
|
4236
|
+
const nextIndex = (config.current + 1) % config.accounts.length;
|
|
4237
|
+
const nextAccount = config.accounts[nextIndex];
|
|
4238
|
+
if (!nextAccount?.accountId) throw new Error("Account entry missing accountId.");
|
|
4239
|
+
const payload = await getSecretStoreAdapter().load(nextAccount.accountId);
|
|
4240
|
+
const result = await writeAllAuthFiles(payload);
|
|
4241
|
+
config.current = nextIndex;
|
|
4242
|
+
await saveConfig(config);
|
|
4243
|
+
writeSwitchSummary(nextAccount.label ?? payload.accountId, result);
|
|
4244
|
+
};
|
|
4245
|
+
const switchToAccount = async (identifier) => {
|
|
4246
|
+
const config = await loadConfig();
|
|
4247
|
+
const index = config.accounts.findIndex((account) => account.accountId === identifier || account.label === identifier);
|
|
4248
|
+
if (index === -1) throw new Error(`Account "${identifier}" not found. Use 'cdx login' to add it.`);
|
|
4249
|
+
const account = config.accounts[index];
|
|
4250
|
+
const result = await writeAllAuthFiles(await getSecretStoreAdapter().load(account.accountId));
|
|
4251
|
+
config.current = index;
|
|
4252
|
+
await saveConfig(config);
|
|
4253
|
+
writeSwitchSummary(account.label ?? account.accountId, result);
|
|
4254
|
+
};
|
|
4255
|
+
const registerSwitchCommand = (program) => {
|
|
4256
|
+
program.command("switch").description("Switch OpenAI account (interactive picker, by name, or --next)").argument("[account-id]", "Account ID to switch to directly").option("-n, --next", "Cycle to the next configured account").action(async (accountId, options) => {
|
|
4257
|
+
try {
|
|
4258
|
+
if (options.next) await switchNext();
|
|
4259
|
+
else if (accountId) await switchToAccount(accountId);
|
|
4260
|
+
else await handleSwitchAccount();
|
|
4261
|
+
} catch (error) {
|
|
4262
|
+
exitWithCommandError(error);
|
|
4263
|
+
}
|
|
4264
|
+
});
|
|
4265
|
+
};
|
|
4266
|
+
//#endregion
|
|
4267
|
+
//#region lib/commands/usage.ts
|
|
4268
|
+
const registerUsageCommand = (program) => {
|
|
4269
|
+
program.command("usage").description("Show OpenAI usage for all accounts (or detailed view for one)").argument("[account]", "Account ID or label (shows detailed single-account view)").action(async (account) => {
|
|
4270
|
+
try {
|
|
4271
|
+
const config = await loadConfig();
|
|
4272
|
+
if (account) {
|
|
4273
|
+
const found = config.accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
4274
|
+
if (!found) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
4275
|
+
const result = await fetchUsage(found.accountId);
|
|
4276
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
4277
|
+
const displayName = found.label ? `${found.label} (${found.accountId})` : found.accountId;
|
|
4278
|
+
process.stdout.write(`\n${displayName}\n${formatUsage(result.data)}\n\n`);
|
|
4279
|
+
return;
|
|
4280
|
+
}
|
|
4281
|
+
if (config.accounts.length === 0) throw new Error("No accounts configured. Use 'cdx login' to add one.");
|
|
4282
|
+
const results = await Promise.allSettled(config.accounts.map((entry) => fetchUsage(entry.accountId)));
|
|
4283
|
+
const entries = config.accounts.map((entry, index) => {
|
|
4284
|
+
const settled = results[index];
|
|
4285
|
+
const displayName = entry.label ? `${entry.label} (${entry.accountId})` : entry.accountId;
|
|
4286
|
+
const result = settled.status === "fulfilled" ? settled.value : {
|
|
4287
|
+
ok: false,
|
|
4288
|
+
error: {
|
|
4289
|
+
type: "network_error",
|
|
4290
|
+
message: settled.reason?.message ?? "Fetch failed"
|
|
4291
|
+
}
|
|
4292
|
+
};
|
|
4293
|
+
return {
|
|
4294
|
+
displayName,
|
|
4295
|
+
isCurrent: index === config.current,
|
|
4296
|
+
result
|
|
4297
|
+
};
|
|
4298
|
+
});
|
|
4299
|
+
process.stdout.write(`\n${formatUsageOverview(entries)}\n\n`);
|
|
4300
|
+
} catch (error) {
|
|
4301
|
+
exitWithCommandError(error);
|
|
4302
|
+
}
|
|
4303
|
+
});
|
|
4304
|
+
};
|
|
4305
|
+
//#endregion
|
|
4306
|
+
//#region lib/runtime/update-manager.ts
|
|
4307
|
+
const detectRuntime = (input = {}) => {
|
|
4308
|
+
const hasDenoGlobal = input.hasDenoGlobal ?? ("Deno" in globalThis && typeof globalThis.Deno !== "undefined");
|
|
4309
|
+
const versions = input.versions ?? process.versions;
|
|
4310
|
+
if (hasDenoGlobal) return "deno";
|
|
4311
|
+
if (typeof versions.bun === "string" && versions.bun.length > 0) return "bun";
|
|
4312
|
+
if (typeof versions.node === "string" && versions.node.length > 0) return "node";
|
|
4313
|
+
return "unknown";
|
|
4314
|
+
};
|
|
4315
|
+
const normalizePath = (value) => value.replaceAll("\\", "/").toLowerCase();
|
|
4316
|
+
const detectInstallManagerFromPath = (executablePath) => {
|
|
4317
|
+
if (!executablePath) return "unknown";
|
|
4318
|
+
const normalizedPath = normalizePath(executablePath);
|
|
4319
|
+
if (normalizedPath.includes("/.bun/install/global/node_modules/")) return "bun";
|
|
4320
|
+
if (normalizedPath.includes("/lib/node_modules/") || normalizedPath.includes("/npm/node_modules/")) return "npm";
|
|
4321
|
+
if (normalizedPath.includes("/.deno/") || normalizedPath.includes("/deno/bin/")) return "deno";
|
|
4322
|
+
return "unknown";
|
|
4323
|
+
};
|
|
4324
|
+
const classifyInstallContextFromPath = (executablePath) => {
|
|
4325
|
+
if (!executablePath) return "unknown";
|
|
4326
|
+
if (detectInstallManagerFromPath(executablePath) !== "unknown") return "global";
|
|
4327
|
+
const normalizedPath = normalizePath(executablePath);
|
|
4328
|
+
if (normalizedPath.endsWith("/cdx.ts") || normalizedPath.includes("/codex-switcher/") || normalizedPath.includes("/node_modules/.bin/")) return "local-or-dev";
|
|
4329
|
+
return "unknown";
|
|
4330
|
+
};
|
|
4331
|
+
const resolveFromRuntime = (runtime) => {
|
|
4332
|
+
if (runtime === "bun") return "bun";
|
|
4333
|
+
if (runtime === "node") return "npm";
|
|
4334
|
+
if (runtime === "deno") return "deno";
|
|
4335
|
+
return null;
|
|
4336
|
+
};
|
|
4337
|
+
const resolveUpdateManager = (input) => {
|
|
4338
|
+
if (input.requestedManager !== "auto") return {
|
|
4339
|
+
ok: true,
|
|
4340
|
+
manager: input.requestedManager,
|
|
4341
|
+
source: "explicit"
|
|
4342
|
+
};
|
|
4343
|
+
if (input.installManager !== "unknown") return {
|
|
4344
|
+
ok: true,
|
|
4345
|
+
manager: input.installManager,
|
|
4346
|
+
source: "install-manager"
|
|
4347
|
+
};
|
|
4348
|
+
const fromRuntime = resolveFromRuntime(input.runtime);
|
|
4349
|
+
if (fromRuntime) return {
|
|
4350
|
+
ok: true,
|
|
4351
|
+
manager: fromRuntime,
|
|
4352
|
+
source: "runtime"
|
|
4353
|
+
};
|
|
4354
|
+
return { ok: false };
|
|
4355
|
+
};
|
|
4356
|
+
const buildUpdateInstallCommand = (manager, packageName) => {
|
|
4357
|
+
if (manager === "bun") return {
|
|
4358
|
+
command: "bun",
|
|
4359
|
+
args: [
|
|
4360
|
+
"add",
|
|
4361
|
+
"-g",
|
|
4362
|
+
`${packageName}@latest`
|
|
4363
|
+
]
|
|
4364
|
+
};
|
|
4365
|
+
if (manager === "npm") return {
|
|
4366
|
+
command: "npm",
|
|
4367
|
+
args: [
|
|
4368
|
+
"i",
|
|
4369
|
+
"-g",
|
|
4370
|
+
`${packageName}@latest`
|
|
4371
|
+
]
|
|
4372
|
+
};
|
|
4373
|
+
return {
|
|
4374
|
+
command: "deno",
|
|
4375
|
+
args: [
|
|
4376
|
+
"install",
|
|
4377
|
+
"-g",
|
|
4378
|
+
"-f",
|
|
4379
|
+
"-A",
|
|
4380
|
+
"-n",
|
|
4381
|
+
"cdx",
|
|
4382
|
+
`npm:${packageName}@latest`
|
|
4383
|
+
]
|
|
4384
|
+
};
|
|
4385
|
+
};
|
|
4386
|
+
//#endregion
|
|
4387
|
+
//#region lib/commands/update-self.ts
|
|
4388
|
+
const PACKAGE_NAME = "@bjesuiter/codex-switcher";
|
|
4389
|
+
const quoteIfNeeded = (value) => {
|
|
4390
|
+
if (value.includes(" ")) return JSON.stringify(value);
|
|
4391
|
+
return value;
|
|
4392
|
+
};
|
|
4393
|
+
const formatShellCommand = (command, args) => [command, ...args].map(quoteIfNeeded).join(" ");
|
|
4394
|
+
const executeUpdate = async (command, args) => {
|
|
4395
|
+
await new Promise((resolve, reject) => {
|
|
4396
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
4397
|
+
child.once("error", (error) => {
|
|
4398
|
+
reject(error);
|
|
4399
|
+
});
|
|
4400
|
+
child.once("close", (code) => {
|
|
4401
|
+
if (code === 0) {
|
|
4402
|
+
resolve();
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4405
|
+
reject(/* @__PURE__ */ new Error(`Update command failed with exit code ${code ?? "unknown"}.`));
|
|
4406
|
+
});
|
|
4407
|
+
});
|
|
4408
|
+
};
|
|
4409
|
+
const executeCapture = async (command, args) => await new Promise((resolve) => {
|
|
4410
|
+
const child = spawn(command, args, { stdio: [
|
|
4411
|
+
"ignore",
|
|
4412
|
+
"pipe",
|
|
4413
|
+
"pipe"
|
|
4414
|
+
] });
|
|
4415
|
+
let stdout = "";
|
|
4416
|
+
child.stdout?.on("data", (chunk) => {
|
|
4417
|
+
stdout += chunk.toString();
|
|
4418
|
+
});
|
|
4419
|
+
child.once("error", () => {
|
|
4420
|
+
resolve({
|
|
4421
|
+
ok: false,
|
|
4422
|
+
output: ""
|
|
4423
|
+
});
|
|
4424
|
+
});
|
|
4425
|
+
child.once("close", (code) => {
|
|
4426
|
+
resolve({
|
|
4427
|
+
ok: code === 0,
|
|
4428
|
+
output: stdout.trim()
|
|
4429
|
+
});
|
|
4430
|
+
});
|
|
4431
|
+
});
|
|
4432
|
+
const getInstalledCdxVersion = async () => {
|
|
4433
|
+
const attempts = [{
|
|
4434
|
+
command: "cdx",
|
|
4435
|
+
args: ["--version"]
|
|
4436
|
+
}];
|
|
4437
|
+
if (process.argv[0] && process.argv[1]) attempts.push({
|
|
4438
|
+
command: process.argv[0],
|
|
4439
|
+
args: [process.argv[1], "--version"]
|
|
4440
|
+
});
|
|
4441
|
+
for (const attempt of attempts) {
|
|
4442
|
+
const result = await executeCapture(attempt.command, attempt.args);
|
|
4443
|
+
if (!result.ok || !result.output) continue;
|
|
4444
|
+
const version = result.output.split(/\r?\n/).at(-1)?.trim();
|
|
4445
|
+
if (version) return version;
|
|
4446
|
+
}
|
|
4447
|
+
return null;
|
|
4448
|
+
};
|
|
4449
|
+
const registerUpdateSelfCommand = (program) => {
|
|
4450
|
+
program.command("update-self").aliases([
|
|
4451
|
+
"self-update",
|
|
4452
|
+
"update",
|
|
4453
|
+
"updte"
|
|
4454
|
+
]).description("Update cdx to the latest version").option("--manager <manager>", "Select update manager (auto|bun|npm|deno)", "auto").option("--dry-run", "Print selected manager and update command without executing").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
|
|
4455
|
+
try {
|
|
4456
|
+
const requestedManager = options.manager ?? "auto";
|
|
4457
|
+
if (![
|
|
4458
|
+
"auto",
|
|
4459
|
+
"bun",
|
|
4460
|
+
"npm",
|
|
4461
|
+
"deno"
|
|
4462
|
+
].includes(requestedManager)) {
|
|
4463
|
+
process.stderr.write(`Invalid value '${requestedManager}' for --manager. Allowed values: auto, bun, npm, deno.\n`);
|
|
4464
|
+
process.exit(1);
|
|
4465
|
+
}
|
|
4466
|
+
const runtime = detectRuntime();
|
|
4467
|
+
const executablePath = process.argv[1];
|
|
4468
|
+
const installManager = detectInstallManagerFromPath(executablePath);
|
|
4469
|
+
const installContext = classifyInstallContextFromPath(executablePath);
|
|
4470
|
+
if (requestedManager === "auto" && installManager === "unknown" && installContext === "local-or-dev") {
|
|
4471
|
+
process.stderr.write("Refusing to auto-update from a local/dev checkout. Re-run with --manager bun|npm|deno.\n");
|
|
4472
|
+
process.exit(1);
|
|
4473
|
+
}
|
|
4474
|
+
const resolvedManager = resolveUpdateManager({
|
|
4475
|
+
requestedManager,
|
|
4476
|
+
runtime,
|
|
4477
|
+
installManager
|
|
4478
|
+
});
|
|
4479
|
+
process.stdout.write(`Runtime: ${runtime}\n`);
|
|
4480
|
+
process.stdout.write(`Detected install manager: ${installManager}\n`);
|
|
4481
|
+
if (!resolvedManager.ok) {
|
|
4482
|
+
process.stderr.write("Could not determine update manager automatically. Re-run with --manager bun|npm|deno.\n");
|
|
4483
|
+
process.stderr.write("Manual update commands:\n");
|
|
4484
|
+
process.stderr.write(` bun: ${formatShellCommand("bun", [
|
|
4485
|
+
"add",
|
|
4486
|
+
"-g",
|
|
4487
|
+
`${PACKAGE_NAME}@latest`
|
|
4488
|
+
])}\n`);
|
|
4489
|
+
process.stderr.write(` npm: ${formatShellCommand("npm", [
|
|
4490
|
+
"i",
|
|
4491
|
+
"-g",
|
|
4492
|
+
`${PACKAGE_NAME}@latest`
|
|
4493
|
+
])}\n`);
|
|
4494
|
+
process.stderr.write(` deno: ${formatShellCommand("deno", [
|
|
4495
|
+
"install",
|
|
4496
|
+
"-g",
|
|
4497
|
+
"-f",
|
|
4498
|
+
"-A",
|
|
4499
|
+
"-n",
|
|
4500
|
+
"cdx",
|
|
4501
|
+
`npm:${PACKAGE_NAME}@latest`
|
|
4502
|
+
])}\n`);
|
|
4503
|
+
process.exit(1);
|
|
4504
|
+
}
|
|
4505
|
+
const selectedManager = resolvedManager.manager;
|
|
4506
|
+
const command = buildUpdateInstallCommand(selectedManager, PACKAGE_NAME);
|
|
4507
|
+
const printableCommand = formatShellCommand(command.command, command.args);
|
|
4508
|
+
process.stdout.write(`Selected manager: ${selectedManager}\n`);
|
|
4509
|
+
process.stdout.write(`Source: ${resolvedManager.source}\n`);
|
|
4510
|
+
process.stdout.write(`Command: ${printableCommand}\n`);
|
|
4511
|
+
if (options.dryRun) return;
|
|
4512
|
+
if (!options.yes) {
|
|
4513
|
+
const confirmed = await p.confirm({
|
|
4514
|
+
message: `Run update command now?\n${printableCommand}`,
|
|
4515
|
+
initialValue: true
|
|
4516
|
+
});
|
|
4517
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
4518
|
+
process.stderr.write("Update cancelled.\n");
|
|
4519
|
+
process.exit(1);
|
|
4520
|
+
}
|
|
4521
|
+
}
|
|
4522
|
+
await executeUpdate(command.command, command.args);
|
|
4523
|
+
const installedVersion = await getInstalledCdxVersion();
|
|
4524
|
+
process.stdout.write("Update completed.\n");
|
|
4525
|
+
process.stdout.write(`Installed version: ${installedVersion ?? "unknown"}\n`);
|
|
4526
|
+
} catch (error) {
|
|
4527
|
+
exitWithCommandError(error);
|
|
4528
|
+
}
|
|
4529
|
+
});
|
|
4530
|
+
};
|
|
4531
|
+
//#endregion
|
|
4532
|
+
//#region lib/commands/version.ts
|
|
4533
|
+
const registerVersionCommand = (program, version) => {
|
|
4534
|
+
program.command("version").description("Show CLI version").action(() => {
|
|
4535
|
+
process.stdout.write(`${version}\n`);
|
|
4536
|
+
});
|
|
4537
|
+
};
|
|
4538
|
+
//#endregion
|
|
4539
|
+
//#region cdx.ts
|
|
4540
|
+
const interactiveMode = runInteractiveMode;
|
|
4541
|
+
const parseSecretStoreSelection = (value) => {
|
|
4542
|
+
if (value === "auto" || value === "legacy-keychain") return value;
|
|
4543
|
+
throw new InvalidArgumentError(`Invalid value '${value}' for --secret-store. Allowed values: auto, legacy-keychain.`);
|
|
4544
|
+
};
|
|
4545
|
+
const getCompletionParseArgs = (argv) => {
|
|
4546
|
+
const completeIndex = argv.findIndex((arg) => arg === "complete");
|
|
4547
|
+
if (completeIndex === -1) return null;
|
|
4548
|
+
const separatorIndex = argv.findIndex((arg) => arg === "--");
|
|
4549
|
+
if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
|
|
4550
|
+
return argv.slice(separatorIndex + 1);
|
|
4551
|
+
};
|
|
4552
|
+
const readCompletionAccounts = () => {
|
|
4553
|
+
try {
|
|
4554
|
+
const { configPath } = getPaths();
|
|
4555
|
+
if (!existsSync(configPath)) return [];
|
|
4556
|
+
const raw = readFileSync(configPath, "utf8");
|
|
4557
|
+
const parsed = JSON.parse(raw);
|
|
4558
|
+
if (!Array.isArray(parsed.accounts)) return [];
|
|
4559
|
+
const accounts = [];
|
|
4560
|
+
for (const account of parsed.accounts) {
|
|
4561
|
+
if (typeof account.accountId !== "string" || !account.accountId.trim()) continue;
|
|
4562
|
+
accounts.push({
|
|
4563
|
+
accountId: account.accountId,
|
|
4564
|
+
...typeof account.label === "string" && account.label.trim() ? { label: account.label } : {}
|
|
4565
|
+
});
|
|
4566
|
+
}
|
|
4567
|
+
return accounts;
|
|
4568
|
+
} catch {
|
|
4569
|
+
return [];
|
|
4570
|
+
}
|
|
4571
|
+
};
|
|
4572
|
+
const addConfiguredAccountCompletions = (complete) => {
|
|
4573
|
+
const accounts = readCompletionAccounts();
|
|
4574
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4575
|
+
for (const account of accounts) {
|
|
4576
|
+
if (!seen.has(account.accountId)) {
|
|
4577
|
+
seen.add(account.accountId);
|
|
4578
|
+
const description = account.label ? `Account ID (${account.label})` : "Account ID";
|
|
4579
|
+
complete(account.accountId, description);
|
|
4580
|
+
}
|
|
4581
|
+
if (account.label && !seen.has(account.label)) {
|
|
4582
|
+
seen.add(account.label);
|
|
4583
|
+
complete(account.label, `Label for ${account.accountId}`);
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
};
|
|
4587
|
+
const attachAccountArgumentCompletion = (completion, commandName, argumentName) => {
|
|
4588
|
+
const command = completion.commands.get(commandName);
|
|
4589
|
+
if (!command) return;
|
|
4590
|
+
command.argument(argumentName, (complete) => {
|
|
4591
|
+
addConfiguredAccountCompletions(complete);
|
|
4592
|
+
});
|
|
4593
|
+
};
|
|
4594
|
+
const configureTabCompletion = (completion) => {
|
|
4595
|
+
const secretStoreOption = completion.options.get("secret-store");
|
|
4596
|
+
if (secretStoreOption) secretStoreOption.handler = (complete) => {
|
|
4597
|
+
complete("auto", "Automatic backend selection");
|
|
4598
|
+
complete("legacy-keychain", "macOS legacy keychain backend");
|
|
4599
|
+
};
|
|
4600
|
+
attachAccountArgumentCompletion(completion, "switch", "account-id");
|
|
4601
|
+
attachAccountArgumentCompletion(completion, "relogin", "account");
|
|
4602
|
+
attachAccountArgumentCompletion(completion, "usage", "account");
|
|
4603
|
+
attachAccountArgumentCompletion(completion, "label", "account");
|
|
4604
|
+
const helpCommand = completion.commands.get("help");
|
|
4605
|
+
if (helpCommand) helpCommand.argument("command", (complete) => {
|
|
4606
|
+
for (const [name, command] of completion.commands.entries()) {
|
|
4607
|
+
if (name === "") continue;
|
|
4608
|
+
complete(name, command.description || "Command");
|
|
4609
|
+
}
|
|
4610
|
+
});
|
|
4611
|
+
};
|
|
4612
|
+
const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
|
|
4613
|
+
if (platform !== "darwin") return null;
|
|
4614
|
+
if (selection === "legacy-keychain") return "⚠ macOS keychain is using the legacy security CLI backend. Touch ID may not be offered for keychain prompts.";
|
|
4615
|
+
if (selection === "auto" && backendId === "macos") return "⚠ macOS keychain is using the cross-keychain CLI fallback (`security`). Touch ID may not be offered for keychain prompts.";
|
|
4616
|
+
return null;
|
|
4617
|
+
};
|
|
4618
|
+
const maybeWarnAboutMacOSKeychainPromptMode = async (selection) => {
|
|
4619
|
+
let backendId = null;
|
|
4620
|
+
if (selection === "auto" && process.platform === "darwin") try {
|
|
4621
|
+
backendId = await resolveMacOSCrossKeychainBackendId();
|
|
4622
|
+
} catch {
|
|
4623
|
+
backendId = null;
|
|
4624
|
+
}
|
|
4625
|
+
const warning = getMacOSKeychainPromptWarning(selection, process.platform, backendId);
|
|
4626
|
+
if (warning) process.stderr.write(`${warning}\n`);
|
|
4627
|
+
};
|
|
4628
|
+
const createProgram = (deps = {}) => {
|
|
4629
|
+
const program = new Command();
|
|
4630
|
+
program.name("cdx").description("OpenAI account switcher - manage multiple OpenAI Pro subscriptions").version(version, "-v, --version").option("--secret-store <mode>", "Select secret-store backend (auto|legacy-keychain)", parseSecretStoreSelection);
|
|
4631
|
+
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
|
4632
|
+
const options = actionCommand.optsWithGlobals();
|
|
4633
|
+
const configuredSelection = options.secretStore ? void 0 : await loadConfiguredSecretStoreSelection();
|
|
4634
|
+
const selection = options.secretStore ?? configuredSelection ?? "auto";
|
|
4635
|
+
setSecretStoreAdapter(createSecretStoreAdapterFromSelection(selection));
|
|
4636
|
+
await maybeWarnAboutMacOSKeychainPromptMode(selection);
|
|
4637
|
+
});
|
|
4638
|
+
program.hook("postAction", () => {
|
|
4639
|
+
resetSecretStoreAdapter();
|
|
4640
|
+
});
|
|
4641
|
+
registerLoginCommand(program, deps);
|
|
4642
|
+
registerReloginCommand(program);
|
|
4643
|
+
registerSwitchCommand(program);
|
|
4644
|
+
registerLabelCommand(program);
|
|
4645
|
+
registerMigrateSecretsCommand(program);
|
|
4646
|
+
registerKeyringCommand(program);
|
|
4647
|
+
registerStatusCommand(program);
|
|
4648
|
+
registerDoctorCommand(program);
|
|
4649
|
+
registerUsageCommand(program);
|
|
4650
|
+
registerUpdateSelfCommand(program);
|
|
4651
|
+
registerHelpCommand(program);
|
|
4652
|
+
registerVersionCommand(program, version);
|
|
4653
|
+
registerDefaultInteractiveAction(program);
|
|
4654
|
+
return program;
|
|
4655
|
+
};
|
|
4656
|
+
const main = async () => {
|
|
4657
|
+
const program = createProgram();
|
|
4658
|
+
const completion = tab(program);
|
|
4659
|
+
configureTabCompletion(completion);
|
|
4660
|
+
const completionArgs = getCompletionParseArgs(process.argv);
|
|
4661
|
+
if (completionArgs) {
|
|
4662
|
+
completion.parse(completionArgs);
|
|
4663
|
+
return;
|
|
4664
|
+
}
|
|
4665
|
+
await program.parseAsync(process.argv);
|
|
4666
|
+
};
|
|
4667
|
+
if (import.meta.main) main().catch((error) => {
|
|
4668
|
+
exitWithCommandError(error);
|
|
4669
|
+
});
|
|
4670
|
+
//#endregion
|
|
4671
|
+
export { createProgram, createRuntimeSecretStoreAdapter, createSecretStoreAdapterFromSelection, createTestPaths, getMacOSKeychainPromptWarning, getPaths, getSecretStoreAdapter, interactiveMode, loadConfig, resetPaths, resetSecretStoreAdapter, resolveMacOSCrossKeychainBackendId, runInteractiveMode, saveConfig, setPaths, setSecretStoreAdapter, switchNext, switchToAccount, writeAllAuthFiles, writeAuthFile, writeCodexAuthFile, writePiAuthFile };
|