@bjesuiter/codex-switcher 1.5.1 → 1.7.2
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/README.md +6 -3
- package/cdx.mjs +653 -116
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,12 +6,11 @@ Switch the coding-agents [pi](https://pi.dev/), [codex](https://developers.opena
|
|
|
6
6
|
|
|
7
7
|
## Latest Changes
|
|
8
8
|
|
|
9
|
-
### 1.
|
|
9
|
+
### 1.7.2
|
|
10
10
|
|
|
11
11
|
#### Fixes
|
|
12
12
|
|
|
13
|
-
-
|
|
14
|
-
- Clarify keychain ACL diagnostics wording to distinguish entries created by `cdx` (Bun runtime) vs the legacy Apple `security` CLI path, including why mismatches can trigger repeated keychain password prompts.
|
|
13
|
+
- Improve device OAuth failure diagnostics during login/relogin. When device flow startup or polling fails, `cdx` now prints technical details (HTTP status, OAuth error code, and response/body snippets where available) instead of only showing a generic "not available right now" message.
|
|
15
14
|
|
|
16
15
|
see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
|
|
17
16
|
|
|
@@ -154,8 +153,11 @@ cdx migrate-secrets
|
|
|
154
153
|
|---------|-------------|
|
|
155
154
|
| `cdx` | Interactive mode |
|
|
156
155
|
| `cdx login` | Add a new OpenAI account via OAuth |
|
|
156
|
+
| `cdx login --device-flow` | Add account using OAuth device flow (no local browser callback needed) |
|
|
157
157
|
| `cdx relogin` | Re-authenticate an existing account via OAuth |
|
|
158
|
+
| `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
|
|
158
159
|
| `cdx relogin <account>` | Re-authenticate a specific account by ID or label |
|
|
160
|
+
| `cdx relogin <account> --device-flow` | Re-authenticate specific account using OAuth device flow |
|
|
159
161
|
| `cdx switch` | Switch account (interactive picker) |
|
|
160
162
|
| `cdx switch --next` | Cycle to next account |
|
|
161
163
|
| `cdx switch <id>` | Switch to specific account |
|
|
@@ -187,6 +189,7 @@ source <(cdx complete bash)
|
|
|
187
189
|
```
|
|
188
190
|
|
|
189
191
|
`cdx` also supports shell parse completion requests via `cdx complete -- ...`.
|
|
192
|
+
Completions include command names, options, `--secret-store` values, and account ID/label suggestions for commands like `switch`, `relogin`, `usage`, and `label`.
|
|
190
193
|
|
|
191
194
|
## How It Works
|
|
192
195
|
|
package/cdx.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import tab from "@bomb.sh/tab/commander";
|
|
3
4
|
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -9,12 +9,13 @@ import * as p from "@clack/prompts";
|
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { deletePassword, getPassword, listBackends, setPassword, useBackend } from "@bjesuiter/cross-keychain";
|
|
11
11
|
import { createInterface } from "node:readline/promises";
|
|
12
|
-
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
13
12
|
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { Decrypter, Encrypter } from "age-encryption";
|
|
14
|
+
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
14
15
|
import http from "node:http";
|
|
15
16
|
|
|
16
17
|
//#region package.json
|
|
17
|
-
var version = "1.
|
|
18
|
+
var version = "1.7.2";
|
|
18
19
|
|
|
19
20
|
//#endregion
|
|
20
21
|
//#region lib/platform/path-resolver.ts
|
|
@@ -271,13 +272,24 @@ const getBrowserLauncherCapability = (platform = process.platform) => {
|
|
|
271
272
|
available: isCommandAvailable(launcher.command, platform)
|
|
272
273
|
};
|
|
273
274
|
};
|
|
274
|
-
const openBrowserUrl = (url,
|
|
275
|
-
const
|
|
275
|
+
const openBrowserUrl = (url, options = {}) => {
|
|
276
|
+
const platform = options.platform ?? process.platform;
|
|
277
|
+
const spawnImpl = options.spawnImpl ?? spawn;
|
|
278
|
+
const commandAvailable = options.isCommandAvailableImpl ?? isCommandAvailable;
|
|
279
|
+
const launcher = getBrowserLauncher(platform, url);
|
|
280
|
+
if (!commandAvailable(launcher.command, platform)) return {
|
|
281
|
+
ok: false,
|
|
282
|
+
launcher,
|
|
283
|
+
reason: "launcher_missing",
|
|
284
|
+
error: `${launcher.command} not found in PATH`
|
|
285
|
+
};
|
|
276
286
|
try {
|
|
277
|
-
spawnImpl(launcher.command, launcher.args, {
|
|
287
|
+
const child = spawnImpl(launcher.command, launcher.args, {
|
|
278
288
|
detached: true,
|
|
279
289
|
stdio: "ignore"
|
|
280
|
-
})
|
|
290
|
+
});
|
|
291
|
+
child.once("error", () => {});
|
|
292
|
+
child.unref();
|
|
281
293
|
return {
|
|
282
294
|
ok: true,
|
|
283
295
|
launcher
|
|
@@ -286,6 +298,7 @@ const openBrowserUrl = (url, spawnImpl = spawn) => {
|
|
|
286
298
|
return {
|
|
287
299
|
ok: false,
|
|
288
300
|
launcher,
|
|
301
|
+
reason: "spawn_failed",
|
|
289
302
|
error: error instanceof Error ? error.message : String(error)
|
|
290
303
|
};
|
|
291
304
|
}
|
|
@@ -503,18 +516,18 @@ const parsePayload$2 = (accountId, raw) => {
|
|
|
503
516
|
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
504
517
|
return parsed;
|
|
505
518
|
};
|
|
506
|
-
const withService$
|
|
519
|
+
const withService$1 = async (accountId, run, options = {}) => {
|
|
507
520
|
await ensureLinuxBackend(options);
|
|
508
521
|
return run(getLinuxCrossKeychainService(accountId));
|
|
509
522
|
};
|
|
510
|
-
const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$
|
|
523
|
+
const saveLinuxCrossKeychainPayload = async (accountId, payload) => withService$1(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
511
524
|
const loadLinuxCrossKeychainPayload = async (accountId) => {
|
|
512
|
-
const raw = await withService$
|
|
525
|
+
const raw = await withService$1(accountId, (service) => getPassword(service, accountId));
|
|
513
526
|
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
514
527
|
return parsePayload$2(accountId, raw);
|
|
515
528
|
};
|
|
516
|
-
const deleteLinuxCrossKeychainPayload = async (accountId) => withService$
|
|
517
|
-
const linuxCrossKeychainPayloadExists = async (accountId) => withService$
|
|
529
|
+
const deleteLinuxCrossKeychainPayload = async (accountId) => withService$1(accountId, (service) => deletePassword(service, accountId));
|
|
530
|
+
const linuxCrossKeychainPayloadExists = async (accountId) => withService$1(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
518
531
|
|
|
519
532
|
//#endregion
|
|
520
533
|
//#region lib/secrets/macos-cross-keychain.ts
|
|
@@ -568,23 +581,27 @@ const parsePayload$1 = (accountId, raw) => {
|
|
|
568
581
|
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
569
582
|
return parsed;
|
|
570
583
|
};
|
|
571
|
-
const withService
|
|
584
|
+
const withService = async (accountId, run, options = {}) => {
|
|
572
585
|
await ensureMacOSBackend(options);
|
|
573
586
|
return run(getMacOSCrossKeychainService(accountId));
|
|
574
587
|
};
|
|
575
|
-
const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService
|
|
588
|
+
const saveMacOSCrossKeychainPayload = async (accountId, payload) => withService(accountId, (service) => setPassword(service, accountId, JSON.stringify(payload)), { forWrite: true });
|
|
576
589
|
const loadMacOSCrossKeychainPayload = async (accountId) => {
|
|
577
|
-
const raw = await withService
|
|
590
|
+
const raw = await withService(accountId, (service) => getPassword(service, accountId));
|
|
578
591
|
if (raw === null) throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
579
592
|
return parsePayload$1(accountId, raw);
|
|
580
593
|
};
|
|
581
|
-
const deleteMacOSCrossKeychainPayload = async (accountId) => withService
|
|
582
|
-
const macosCrossKeychainPayloadExists = async (accountId) => withService
|
|
594
|
+
const deleteMacOSCrossKeychainPayload = async (accountId) => withService(accountId, (service) => deletePassword(service, accountId));
|
|
595
|
+
const macosCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
583
596
|
|
|
584
597
|
//#endregion
|
|
585
598
|
//#region lib/secrets/windows-cross-keychain.ts
|
|
586
599
|
const SERVICE_PREFIX = "cdx-openai-";
|
|
587
600
|
const WINDOWS_FALLBACK_SCOPE = "win32:cross-keychain:windows";
|
|
601
|
+
const WINDOWS_VAULT_FILE = "accounts.windows.age";
|
|
602
|
+
const WINDOWS_VAULT_VERSION = 1;
|
|
603
|
+
const WINDOWS_VAULT_KEY_SERVICE = "cdx-openai-vault-passphrase";
|
|
604
|
+
const WINDOWS_VAULT_KEY_ACCOUNT = "windows-v1";
|
|
588
605
|
let backendInitPromise = null;
|
|
589
606
|
let selectedBackend = null;
|
|
590
607
|
const tryUseBackend = async (backendId) => {
|
|
@@ -617,33 +634,143 @@ const ensureWindowsBackend = async (options = {}) => {
|
|
|
617
634
|
}
|
|
618
635
|
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.");
|
|
619
636
|
};
|
|
620
|
-
const
|
|
621
|
-
|
|
637
|
+
const withWindowsBackend = async (run, options = {}) => {
|
|
638
|
+
await ensureWindowsBackend(options);
|
|
639
|
+
return run();
|
|
640
|
+
};
|
|
641
|
+
const getWindowsVaultPath = () => path.join(getPaths().configDir, WINDOWS_VAULT_FILE);
|
|
642
|
+
const createVaultPassphrase = () => randomBytes(32).toString("hex");
|
|
643
|
+
const getVaultPassphrase = async (options = {}) => {
|
|
644
|
+
const current = await getPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT);
|
|
645
|
+
if (current) return current;
|
|
646
|
+
if (!options.createIfMissing) return null;
|
|
647
|
+
const generated = createVaultPassphrase();
|
|
648
|
+
await setPassword(WINDOWS_VAULT_KEY_SERVICE, WINDOWS_VAULT_KEY_ACCOUNT, generated);
|
|
649
|
+
return generated;
|
|
650
|
+
};
|
|
651
|
+
const createEmptyVault = () => ({
|
|
652
|
+
version: WINDOWS_VAULT_VERSION,
|
|
653
|
+
accounts: {}
|
|
654
|
+
});
|
|
655
|
+
const parsePayload = (accountId, input) => {
|
|
656
|
+
if (!input || typeof input !== "object") throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
657
|
+
const parsed = input;
|
|
658
|
+
if (!parsed.refresh || !parsed.access || !parsed.expires || !parsed.accountId) throw new Error(`Stored credential payload for account ${accountId} is missing required fields.`);
|
|
659
|
+
return {
|
|
660
|
+
refresh: parsed.refresh,
|
|
661
|
+
access: parsed.access,
|
|
662
|
+
expires: parsed.expires,
|
|
663
|
+
accountId: parsed.accountId,
|
|
664
|
+
...parsed.idToken ? { idToken: parsed.idToken } : {}
|
|
665
|
+
};
|
|
666
|
+
};
|
|
667
|
+
const parseVault = (raw, source) => {
|
|
668
|
+
let parsed;
|
|
669
|
+
try {
|
|
670
|
+
parsed = JSON.parse(raw);
|
|
671
|
+
} catch {
|
|
672
|
+
throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
|
|
673
|
+
}
|
|
674
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`Stored Windows credential vault (${source}) is not valid JSON.`);
|
|
675
|
+
const vault = parsed;
|
|
676
|
+
const rawAccounts = vault.accounts;
|
|
677
|
+
if (!rawAccounts || typeof rawAccounts !== "object") throw new Error(`Stored Windows credential vault (${source}) is missing account data.`);
|
|
678
|
+
const accounts = {};
|
|
679
|
+
for (const [accountId, payload] of Object.entries(rawAccounts)) accounts[accountId] = parsePayload(accountId, payload);
|
|
680
|
+
return {
|
|
681
|
+
version: typeof vault.version === "number" ? vault.version : WINDOWS_VAULT_VERSION,
|
|
682
|
+
accounts
|
|
683
|
+
};
|
|
684
|
+
};
|
|
685
|
+
const decryptVault = async (ciphertext, passphrase, source) => {
|
|
686
|
+
const decrypter = new Decrypter();
|
|
687
|
+
decrypter.addPassphrase(passphrase);
|
|
688
|
+
let plaintext;
|
|
689
|
+
try {
|
|
690
|
+
plaintext = await decrypter.decrypt(ciphertext, "text");
|
|
691
|
+
} catch {
|
|
692
|
+
throw new Error(`Failed to decrypt Windows credential vault (${source}). Stored passphrase or vault file may be invalid.`);
|
|
693
|
+
}
|
|
694
|
+
return parseVault(plaintext, source);
|
|
695
|
+
};
|
|
696
|
+
const encryptVault = async (vault, passphrase) => {
|
|
697
|
+
const encrypter = new Encrypter();
|
|
698
|
+
encrypter.setPassphrase(passphrase);
|
|
699
|
+
return encrypter.encrypt(JSON.stringify(vault));
|
|
700
|
+
};
|
|
701
|
+
const loadVault = async (passphrase) => {
|
|
702
|
+
const vaultPath = getWindowsVaultPath();
|
|
703
|
+
let ciphertext;
|
|
704
|
+
try {
|
|
705
|
+
ciphertext = await readFile(vaultPath);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (error?.code === "ENOENT") return createEmptyVault();
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
if (ciphertext.length === 0) return createEmptyVault();
|
|
711
|
+
return decryptVault(ciphertext, passphrase, vaultPath);
|
|
712
|
+
};
|
|
713
|
+
const saveVault = async (vault, passphrase) => {
|
|
714
|
+
const { configDir } = getPaths();
|
|
715
|
+
const vaultPath = getWindowsVaultPath();
|
|
716
|
+
await mkdir(configDir, { recursive: true });
|
|
717
|
+
await writeFile(vaultPath, await encryptVault(vault, passphrase));
|
|
718
|
+
};
|
|
719
|
+
const loadLegacyPayload = async (accountId) => {
|
|
720
|
+
const raw = await getPassword(getWindowsCrossKeychainService(accountId), accountId);
|
|
721
|
+
if (raw === null) return null;
|
|
622
722
|
let parsed;
|
|
623
723
|
try {
|
|
624
724
|
parsed = JSON.parse(raw);
|
|
625
725
|
} catch {
|
|
626
726
|
throw new Error(`Stored credential payload for account ${accountId} is not valid JSON.`);
|
|
627
727
|
}
|
|
628
|
-
|
|
629
|
-
return parsed;
|
|
728
|
+
return parsePayload(accountId, parsed);
|
|
630
729
|
};
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
730
|
+
const deleteLegacyPayload = async (accountId) => {
|
|
731
|
+
const service = getWindowsCrossKeychainService(accountId);
|
|
732
|
+
try {
|
|
733
|
+
await deletePassword(service, accountId);
|
|
734
|
+
} catch {}
|
|
634
735
|
};
|
|
635
|
-
const
|
|
636
|
-
|
|
736
|
+
const getWindowsCrossKeychainService = (accountId) => `${SERVICE_PREFIX}${accountId}`;
|
|
737
|
+
const saveWindowsCrossKeychainPayload = async (accountId, payload) => withWindowsBackend(async () => {
|
|
738
|
+
const passphrase = await getVaultPassphrase({ createIfMissing: true });
|
|
739
|
+
if (!passphrase) throw new Error("Unable to resolve Windows credential vault passphrase.");
|
|
740
|
+
const vault = await loadVault(passphrase);
|
|
741
|
+
vault.accounts[accountId] = payload;
|
|
742
|
+
await saveVault(vault, passphrase);
|
|
743
|
+
await deleteLegacyPayload(accountId);
|
|
637
744
|
}, { forWrite: true });
|
|
638
|
-
const loadWindowsCrossKeychainPayload = async (accountId) => {
|
|
639
|
-
const
|
|
640
|
-
if (
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
await
|
|
745
|
+
const loadWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
|
|
746
|
+
const passphrase = await getVaultPassphrase();
|
|
747
|
+
if (passphrase) {
|
|
748
|
+
const payload = (await loadVault(passphrase)).accounts[accountId];
|
|
749
|
+
if (payload) return payload;
|
|
750
|
+
}
|
|
751
|
+
const legacyPayload = await loadLegacyPayload(accountId);
|
|
752
|
+
if (legacyPayload) return legacyPayload;
|
|
753
|
+
throw new Error(`No stored credentials found for account ${accountId}.`);
|
|
754
|
+
});
|
|
755
|
+
const deleteWindowsCrossKeychainPayload = async (accountId) => withWindowsBackend(async () => {
|
|
756
|
+
const passphrase = await getVaultPassphrase();
|
|
757
|
+
if (passphrase) {
|
|
758
|
+
const vault = await loadVault(passphrase);
|
|
759
|
+
if (vault.accounts[accountId]) {
|
|
760
|
+
delete vault.accounts[accountId];
|
|
761
|
+
if (Object.keys(vault.accounts).length === 0) await rm(getWindowsVaultPath(), { force: true });
|
|
762
|
+
else await saveVault(vault, passphrase);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
await deleteLegacyPayload(accountId);
|
|
766
|
+
});
|
|
767
|
+
const windowsCrossKeychainPayloadExists = async (accountId) => withWindowsBackend(async () => {
|
|
768
|
+
const passphrase = await getVaultPassphrase();
|
|
769
|
+
if (passphrase) {
|
|
770
|
+
if ((await loadVault(passphrase)).accounts[accountId]) return true;
|
|
771
|
+
}
|
|
772
|
+
return await loadLegacyPayload(accountId) !== null;
|
|
645
773
|
});
|
|
646
|
-
const windowsCrossKeychainPayloadExists = async (accountId) => withService(accountId, async (service) => await getPassword(service, accountId) !== null);
|
|
647
774
|
|
|
648
775
|
//#endregion
|
|
649
776
|
//#region lib/secrets/store.ts
|
|
@@ -874,6 +1001,7 @@ const getSecretStoreCapability = () => {
|
|
|
874
1001
|
//#region lib/oauth/constants.ts
|
|
875
1002
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
876
1003
|
const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
1004
|
+
const DEVICE_CODE_URL = "https://auth.openai.com/oauth/device/code";
|
|
877
1005
|
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
878
1006
|
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
879
1007
|
const SCOPE = "openid profile email offline_access";
|
|
@@ -904,6 +1032,138 @@ const createAuthorizationFlow = async () => {
|
|
|
904
1032
|
url: url.toString()
|
|
905
1033
|
};
|
|
906
1034
|
};
|
|
1035
|
+
const truncateForLog = (value, maxLength = 300) => {
|
|
1036
|
+
if (value.length <= maxLength) return value;
|
|
1037
|
+
return `${value.slice(0, maxLength)}…`;
|
|
1038
|
+
};
|
|
1039
|
+
const startDeviceAuthorizationFlow = async () => {
|
|
1040
|
+
try {
|
|
1041
|
+
const res = await fetch(DEVICE_CODE_URL, {
|
|
1042
|
+
method: "POST",
|
|
1043
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1044
|
+
body: new URLSearchParams({
|
|
1045
|
+
client_id: CLIENT_ID,
|
|
1046
|
+
scope: SCOPE
|
|
1047
|
+
})
|
|
1048
|
+
});
|
|
1049
|
+
if (!res.ok) {
|
|
1050
|
+
let oauthError;
|
|
1051
|
+
let responseBody;
|
|
1052
|
+
try {
|
|
1053
|
+
const json = await res.json();
|
|
1054
|
+
oauthError = json.error;
|
|
1055
|
+
responseBody = truncateForLog(JSON.stringify({
|
|
1056
|
+
...json.error ? { error: json.error } : {},
|
|
1057
|
+
...json.error_description ? { error_description: json.error_description } : {}
|
|
1058
|
+
}));
|
|
1059
|
+
} catch {
|
|
1060
|
+
try {
|
|
1061
|
+
responseBody = truncateForLog(await res.text());
|
|
1062
|
+
} catch {
|
|
1063
|
+
responseBody = void 0;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
return {
|
|
1067
|
+
type: "failed",
|
|
1068
|
+
error: `Device code request failed with HTTP ${res.status} ${res.statusText}`,
|
|
1069
|
+
status: res.status,
|
|
1070
|
+
...oauthError ? { oauthError } : {},
|
|
1071
|
+
...responseBody ? { responseBody } : {}
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
const json = await res.json();
|
|
1075
|
+
if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return {
|
|
1076
|
+
type: "failed",
|
|
1077
|
+
error: "Device code response is missing required fields.",
|
|
1078
|
+
responseBody: truncateForLog(JSON.stringify(json))
|
|
1079
|
+
};
|
|
1080
|
+
return {
|
|
1081
|
+
type: "success",
|
|
1082
|
+
flow: {
|
|
1083
|
+
deviceCode: json.device_code,
|
|
1084
|
+
userCode: json.user_code,
|
|
1085
|
+
verificationUri: json.verification_uri,
|
|
1086
|
+
verificationUriComplete: json.verification_uri_complete,
|
|
1087
|
+
expiresIn: json.expires_in,
|
|
1088
|
+
interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
return {
|
|
1093
|
+
type: "failed",
|
|
1094
|
+
error: `Device code request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
const pollDeviceAuthorizationToken = async (deviceCode) => {
|
|
1099
|
+
try {
|
|
1100
|
+
const res = await fetch(TOKEN_URL, {
|
|
1101
|
+
method: "POST",
|
|
1102
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1103
|
+
body: new URLSearchParams({
|
|
1104
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1105
|
+
device_code: deviceCode,
|
|
1106
|
+
client_id: CLIENT_ID
|
|
1107
|
+
})
|
|
1108
|
+
});
|
|
1109
|
+
if (res.ok) {
|
|
1110
|
+
const json = await res.json();
|
|
1111
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return {
|
|
1112
|
+
type: "failed",
|
|
1113
|
+
error: "Device token response is missing access_token/refresh_token/expires_in.",
|
|
1114
|
+
responseBody: truncateForLog(JSON.stringify(json))
|
|
1115
|
+
};
|
|
1116
|
+
return {
|
|
1117
|
+
type: "success",
|
|
1118
|
+
access: json.access_token,
|
|
1119
|
+
refresh: json.refresh_token,
|
|
1120
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1121
|
+
idToken: json.id_token
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
let errorCode;
|
|
1125
|
+
let interval;
|
|
1126
|
+
let responseBody;
|
|
1127
|
+
try {
|
|
1128
|
+
const json = await res.json();
|
|
1129
|
+
errorCode = json.error;
|
|
1130
|
+
interval = json.interval;
|
|
1131
|
+
responseBody = truncateForLog(JSON.stringify({
|
|
1132
|
+
...json.error ? { error: json.error } : {},
|
|
1133
|
+
...json.error_description ? { error_description: json.error_description } : {},
|
|
1134
|
+
...typeof json.interval === "number" ? { interval: json.interval } : {}
|
|
1135
|
+
}));
|
|
1136
|
+
} catch {
|
|
1137
|
+
try {
|
|
1138
|
+
responseBody = truncateForLog(await res.text());
|
|
1139
|
+
} catch {
|
|
1140
|
+
responseBody = void 0;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (errorCode === "authorization_pending") return {
|
|
1144
|
+
type: "pending",
|
|
1145
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 5
|
|
1146
|
+
};
|
|
1147
|
+
if (errorCode === "slow_down") return {
|
|
1148
|
+
type: "slow_down",
|
|
1149
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 10
|
|
1150
|
+
};
|
|
1151
|
+
if (errorCode === "access_denied") return { type: "access_denied" };
|
|
1152
|
+
if (errorCode === "expired_token") return { type: "expired" };
|
|
1153
|
+
return {
|
|
1154
|
+
type: "failed",
|
|
1155
|
+
error: `Device token polling failed with HTTP ${res.status} ${res.statusText}`,
|
|
1156
|
+
status: res.status,
|
|
1157
|
+
...errorCode ? { oauthError: errorCode } : {},
|
|
1158
|
+
...responseBody ? { responseBody } : {}
|
|
1159
|
+
};
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
return {
|
|
1162
|
+
type: "failed",
|
|
1163
|
+
error: `Device token polling request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
907
1167
|
const exchangeAuthorizationCode = async (code, verifier) => {
|
|
908
1168
|
const res = await fetch(TOKEN_URL, {
|
|
909
1169
|
method: "POST",
|
|
@@ -1067,12 +1327,236 @@ const startOAuthServer = (state) => {
|
|
|
1067
1327
|
|
|
1068
1328
|
//#endregion
|
|
1069
1329
|
//#region lib/oauth/login.ts
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
if (
|
|
1073
|
-
|
|
1074
|
-
|
|
1330
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1331
|
+
const isLikelyRemoteEnvironment = () => {
|
|
1332
|
+
if (process.platform !== "linux") return false;
|
|
1333
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
|
|
1334
|
+
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
1335
|
+
};
|
|
1336
|
+
const parseOAuthCallbackInput = (input) => {
|
|
1337
|
+
const trimmed = input.trim();
|
|
1338
|
+
if (!trimmed) return null;
|
|
1339
|
+
if (!trimmed.includes("://") && !trimmed.includes("code=") && !trimmed.includes("?")) return { code: trimmed };
|
|
1340
|
+
try {
|
|
1341
|
+
const parsedUrl = new URL(trimmed);
|
|
1342
|
+
const code = parsedUrl.searchParams.get("code");
|
|
1343
|
+
if (code) return {
|
|
1344
|
+
code,
|
|
1345
|
+
state: parsedUrl.searchParams.get("state") ?? void 0
|
|
1346
|
+
};
|
|
1347
|
+
} catch {}
|
|
1348
|
+
const queryLike = trimmed.startsWith("?") || trimmed.startsWith("#") ? trimmed.slice(1) : trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed;
|
|
1349
|
+
const params = new URLSearchParams(queryLike);
|
|
1350
|
+
const code = params.get("code");
|
|
1351
|
+
if (!code) return null;
|
|
1352
|
+
return {
|
|
1353
|
+
code,
|
|
1354
|
+
state: params.get("state") ?? void 0
|
|
1355
|
+
};
|
|
1356
|
+
};
|
|
1357
|
+
const promptBrowserFallbackChoice = async () => {
|
|
1358
|
+
const remoteHint = isLikelyRemoteEnvironment();
|
|
1359
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1360
|
+
const selected = remoteHint ? "device" : "manual";
|
|
1361
|
+
p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
|
|
1362
|
+
return selected;
|
|
1363
|
+
}
|
|
1364
|
+
const options = remoteHint ? [
|
|
1365
|
+
{
|
|
1366
|
+
value: "device",
|
|
1367
|
+
label: "Use device OAuth flow",
|
|
1368
|
+
hint: "Recommended on SSH/remote servers"
|
|
1369
|
+
},
|
|
1370
|
+
{
|
|
1371
|
+
value: "manual",
|
|
1372
|
+
label: "Finish manually by copying URL",
|
|
1373
|
+
hint: "Open URL on any machine and paste callback URL/code back here"
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
value: "cancel",
|
|
1377
|
+
label: "Cancel login"
|
|
1378
|
+
}
|
|
1379
|
+
] : [
|
|
1380
|
+
{
|
|
1381
|
+
value: "manual",
|
|
1382
|
+
label: "Finish manually by copying URL",
|
|
1383
|
+
hint: "Open URL on any machine and paste callback URL/code back here"
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
value: "device",
|
|
1387
|
+
label: "Use device OAuth flow",
|
|
1388
|
+
hint: "Best for headless/remote environments"
|
|
1389
|
+
},
|
|
1390
|
+
{
|
|
1391
|
+
value: "cancel",
|
|
1392
|
+
label: "Cancel login"
|
|
1393
|
+
}
|
|
1394
|
+
];
|
|
1395
|
+
const selection = await p.select({
|
|
1396
|
+
message: "Browser launcher is unavailable. How do you want to continue?",
|
|
1397
|
+
options
|
|
1398
|
+
});
|
|
1399
|
+
if (p.isCancel(selection)) return "cancel";
|
|
1400
|
+
return selection;
|
|
1401
|
+
};
|
|
1402
|
+
const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
|
|
1403
|
+
p.log.info("Manual login selected.");
|
|
1404
|
+
p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
|
|
1405
|
+
p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
|
|
1406
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1407
|
+
const response = await p.text({
|
|
1408
|
+
message: "Paste callback URL or authorization code:",
|
|
1409
|
+
placeholder: "http://localhost:1455/auth/callback?code=...&state=..."
|
|
1410
|
+
});
|
|
1411
|
+
if (p.isCancel(response)) {
|
|
1412
|
+
p.log.info("Login cancelled.");
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
const parsed = parseOAuthCallbackInput(String(response));
|
|
1416
|
+
if (!parsed) {
|
|
1417
|
+
p.log.warning("Could not parse input. Please paste a callback URL or code.");
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
if (parsed.state && parsed.state !== expectedState) {
|
|
1421
|
+
p.log.error("State mismatch in callback URL. Please retry the login flow.");
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
return parsed.code;
|
|
1425
|
+
}
|
|
1426
|
+
p.log.error("Failed to parse callback input after multiple attempts.");
|
|
1427
|
+
return null;
|
|
1428
|
+
};
|
|
1429
|
+
const runDeviceOAuthFlow = async (useSpinner) => {
|
|
1430
|
+
const deviceFlowResult = await startDeviceAuthorizationFlow();
|
|
1431
|
+
if (deviceFlowResult.type !== "success") {
|
|
1432
|
+
p.log.error("Device OAuth flow is not available right now.");
|
|
1433
|
+
p.log.error(`Technical details: ${deviceFlowResult.error}`);
|
|
1434
|
+
if (typeof deviceFlowResult.status === "number") p.log.error(`HTTP status: ${deviceFlowResult.status}`);
|
|
1435
|
+
if (deviceFlowResult.oauthError) p.log.error(`OAuth error: ${deviceFlowResult.oauthError}`);
|
|
1436
|
+
if (deviceFlowResult.responseBody) p.log.error(`Response: ${deviceFlowResult.responseBody}`);
|
|
1437
|
+
return null;
|
|
1438
|
+
}
|
|
1439
|
+
const deviceFlow = deviceFlowResult.flow;
|
|
1440
|
+
p.log.info("Using device OAuth flow.");
|
|
1441
|
+
p.log.message(`Verification URL: ${deviceFlow.verificationUri}`);
|
|
1442
|
+
p.log.message(`User code: ${deviceFlow.userCode}`);
|
|
1443
|
+
const launchResult = openBrowserUrl(deviceFlow.verificationUriComplete ?? deviceFlow.verificationUri);
|
|
1444
|
+
if (!launchResult.ok) {
|
|
1445
|
+
const msg = launchResult.error ?? "unknown error";
|
|
1446
|
+
p.log.warning(`Could not auto-open verification URL via ${launchResult.launcher.label} (${msg}).`);
|
|
1447
|
+
}
|
|
1448
|
+
const spinner = useSpinner ? p.spinner() : null;
|
|
1449
|
+
if (spinner) spinner.start("Waiting for device authorization...");
|
|
1450
|
+
else p.log.message("Waiting for device authorization...");
|
|
1451
|
+
let intervalMs = Math.max(deviceFlow.interval, 1) * 1e3;
|
|
1452
|
+
const deadline = Date.now() + deviceFlow.expiresIn * 1e3;
|
|
1453
|
+
while (Date.now() < deadline) {
|
|
1454
|
+
await sleep(intervalMs);
|
|
1455
|
+
const pollResult = await pollDeviceAuthorizationToken(deviceFlow.deviceCode);
|
|
1456
|
+
if (pollResult.type === "success") {
|
|
1457
|
+
if (spinner) spinner.stop("Device authorization completed.");
|
|
1458
|
+
else p.log.success("Device authorization completed.");
|
|
1459
|
+
return pollResult;
|
|
1460
|
+
}
|
|
1461
|
+
if (pollResult.type === "pending") {
|
|
1462
|
+
intervalMs = Math.max(pollResult.interval, 1) * 1e3;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
if (pollResult.type === "slow_down") {
|
|
1466
|
+
intervalMs = Math.max(pollResult.interval, Math.ceil(intervalMs / 1e3) + 5) * 1e3;
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
if (pollResult.type === "access_denied") {
|
|
1470
|
+
if (spinner) spinner.stop("Device authorization was denied.");
|
|
1471
|
+
else p.log.error("Device authorization was denied.");
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
if (pollResult.type === "expired") {
|
|
1475
|
+
if (spinner) spinner.stop("Device authorization expired.");
|
|
1476
|
+
else p.log.error("Device authorization expired.");
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
if (spinner) spinner.stop("Device authorization failed.");
|
|
1480
|
+
else p.log.error("Device authorization failed.");
|
|
1481
|
+
if (pollResult.type === "failed") {
|
|
1482
|
+
if (pollResult.error) p.log.error(`Technical details: ${pollResult.error}`);
|
|
1483
|
+
if (typeof pollResult.status === "number") p.log.error(`HTTP status: ${pollResult.status}`);
|
|
1484
|
+
if (pollResult.oauthError) p.log.error(`OAuth error: ${pollResult.oauthError}`);
|
|
1485
|
+
if (pollResult.responseBody) p.log.error(`Response: ${pollResult.responseBody}`);
|
|
1486
|
+
}
|
|
1487
|
+
return null;
|
|
1075
1488
|
}
|
|
1489
|
+
if (spinner) spinner.stop("Device authorization timed out.");
|
|
1490
|
+
else p.log.error("Device authorization timed out.");
|
|
1491
|
+
return null;
|
|
1492
|
+
};
|
|
1493
|
+
const requestTokenViaOAuth = async (flow, options) => {
|
|
1494
|
+
if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
|
|
1495
|
+
const server = await startOAuthServer(flow.state);
|
|
1496
|
+
if (!server.ready) {
|
|
1497
|
+
p.log.error("Failed to start local server on port 1455.");
|
|
1498
|
+
p.log.info("Please ensure the port is not in use.");
|
|
1499
|
+
return null;
|
|
1500
|
+
}
|
|
1501
|
+
const spinner = options.useSpinner ? p.spinner() : null;
|
|
1502
|
+
let spinnerStarted = false;
|
|
1503
|
+
p.log.info("Opening browser for authentication...");
|
|
1504
|
+
const launchResult = openBrowserUrl(flow.url);
|
|
1505
|
+
if (!launchResult.ok) {
|
|
1506
|
+
const msg = launchResult.error ?? "unknown error";
|
|
1507
|
+
p.log.warning(`Could not auto-open browser via ${launchResult.launcher.label} (${msg}).`);
|
|
1508
|
+
}
|
|
1509
|
+
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
1510
|
+
if (launchResult.ok) {
|
|
1511
|
+
if (spinner) {
|
|
1512
|
+
spinner.start("Waiting for authentication...");
|
|
1513
|
+
spinnerStarted = true;
|
|
1514
|
+
}
|
|
1515
|
+
const result = await server.waitForCode();
|
|
1516
|
+
server.close();
|
|
1517
|
+
if (!result) {
|
|
1518
|
+
if (spinner) spinner.stop("Authentication timed out or failed.");
|
|
1519
|
+
else p.log.warning("Authentication timed out or failed.");
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
if (spinner) spinner.message("Exchanging authorization code...");
|
|
1523
|
+
else p.log.message("Exchanging authorization code...");
|
|
1524
|
+
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
1525
|
+
if (tokenResult.type !== "success") {
|
|
1526
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
1527
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
if (spinner) spinner.stop("Authentication completed.");
|
|
1531
|
+
return tokenResult;
|
|
1532
|
+
}
|
|
1533
|
+
const fallbackChoice = await promptBrowserFallbackChoice();
|
|
1534
|
+
if (fallbackChoice === "cancel") {
|
|
1535
|
+
server.close();
|
|
1536
|
+
p.log.info("Login cancelled.");
|
|
1537
|
+
return null;
|
|
1538
|
+
}
|
|
1539
|
+
if (fallbackChoice === "device") {
|
|
1540
|
+
server.close();
|
|
1541
|
+
return runDeviceOAuthFlow(options.useSpinner);
|
|
1542
|
+
}
|
|
1543
|
+
server.close();
|
|
1544
|
+
const code = await promptManualAuthorizationCode(flow.url, flow.state);
|
|
1545
|
+
if (!code) return null;
|
|
1546
|
+
if (spinner) if (spinnerStarted) spinner.message("Exchanging authorization code...");
|
|
1547
|
+
else {
|
|
1548
|
+
spinner.start("Exchanging authorization code...");
|
|
1549
|
+
spinnerStarted = true;
|
|
1550
|
+
}
|
|
1551
|
+
else p.log.message("Exchanging authorization code...");
|
|
1552
|
+
const tokenResult = await exchangeAuthorizationCode(code, flow.pkce.verifier);
|
|
1553
|
+
if (tokenResult.type !== "success") {
|
|
1554
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
1555
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
if (spinner) spinner.stop("Authentication completed.");
|
|
1559
|
+
return tokenResult;
|
|
1076
1560
|
};
|
|
1077
1561
|
const addAccountToConfig = async (accountId, label) => {
|
|
1078
1562
|
let config;
|
|
@@ -1100,54 +1584,35 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
|
|
|
1100
1584
|
const displayName = label ?? targetAccountId;
|
|
1101
1585
|
p.log.step(`Re-authenticating account "${displayName}"...`);
|
|
1102
1586
|
const useSpinner = options.useSpinner ?? true;
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
openBrowser(flow.url);
|
|
1121
|
-
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
1122
|
-
if (spinner) spinner.start("Waiting for authentication...");
|
|
1123
|
-
const result = await server.waitForCode();
|
|
1124
|
-
server.close();
|
|
1125
|
-
if (!result) {
|
|
1126
|
-
if (spinner) spinner.stop("Authentication timed out or failed.");
|
|
1127
|
-
else p.log.warning("Authentication timed out or failed.");
|
|
1128
|
-
return null;
|
|
1129
|
-
}
|
|
1130
|
-
if (spinner) spinner.message("Exchanging authorization code...");
|
|
1131
|
-
else p.log.message("Exchanging authorization code...");
|
|
1132
|
-
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
1133
|
-
if (tokenResult.type === "failed") {
|
|
1134
|
-
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
1135
|
-
else p.log.error("Failed to exchange authorization code.");
|
|
1136
|
-
return null;
|
|
1587
|
+
const authFlow = options.authFlow ?? "auto";
|
|
1588
|
+
let tokenResult = null;
|
|
1589
|
+
if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(useSpinner);
|
|
1590
|
+
else {
|
|
1591
|
+
let flow;
|
|
1592
|
+
try {
|
|
1593
|
+
flow = await createAuthorizationFlow();
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1596
|
+
p.log.error(`Failed to create authorization flow: ${msg}`);
|
|
1597
|
+
process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
tokenResult = await requestTokenViaOAuth(flow, {
|
|
1601
|
+
useSpinner,
|
|
1602
|
+
authFlow
|
|
1603
|
+
});
|
|
1137
1604
|
}
|
|
1605
|
+
if (!tokenResult) return null;
|
|
1138
1606
|
const newAccountId = extractAccountId(tokenResult.access);
|
|
1139
1607
|
if (!newAccountId) {
|
|
1140
|
-
|
|
1141
|
-
else p.log.error("Failed to extract account ID from token.");
|
|
1608
|
+
p.log.error("Failed to extract account ID from token.");
|
|
1142
1609
|
return null;
|
|
1143
1610
|
}
|
|
1144
1611
|
if (newAccountId !== targetAccountId) {
|
|
1145
|
-
|
|
1146
|
-
else p.log.error("Authentication completed for a different account.");
|
|
1612
|
+
p.log.error("Authentication completed for a different account.");
|
|
1147
1613
|
throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
|
|
1148
1614
|
}
|
|
1149
|
-
if (
|
|
1150
|
-
else p.log.message("Updating credentials...");
|
|
1615
|
+
if (!useSpinner) p.log.message("Updating credentials...");
|
|
1151
1616
|
const payload = {
|
|
1152
1617
|
refresh: tokenResult.refresh,
|
|
1153
1618
|
access: tokenResult.access,
|
|
@@ -1156,46 +1621,29 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
|
|
|
1156
1621
|
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
1157
1622
|
};
|
|
1158
1623
|
await getSecretStoreAdapter().save(newAccountId, payload);
|
|
1159
|
-
|
|
1160
|
-
else p.log.success("Credentials refreshed!");
|
|
1624
|
+
p.log.success("Credentials refreshed!");
|
|
1161
1625
|
p.log.success(`Account "${displayName}" credentials updated in secure store.`);
|
|
1162
1626
|
return { accountId: newAccountId };
|
|
1163
1627
|
} finally {
|
|
1164
1628
|
clearInterval(keepAlive);
|
|
1165
1629
|
}
|
|
1166
1630
|
};
|
|
1167
|
-
const performLogin = async () => {
|
|
1631
|
+
const performLogin = async (options = {}) => {
|
|
1168
1632
|
p.intro("cdx login - Add OpenAI account");
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
if (
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
p.log.info("Opening browser for authentication...");
|
|
1178
|
-
openBrowser(flow.url);
|
|
1179
|
-
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
1180
|
-
spinner.start("Waiting for authentication...");
|
|
1181
|
-
const result = await server.waitForCode();
|
|
1182
|
-
server.close();
|
|
1183
|
-
if (!result) {
|
|
1184
|
-
spinner.stop("Authentication timed out or failed.");
|
|
1185
|
-
return null;
|
|
1186
|
-
}
|
|
1187
|
-
spinner.message("Exchanging authorization code...");
|
|
1188
|
-
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
1189
|
-
if (tokenResult.type === "failed") {
|
|
1190
|
-
spinner.stop("Failed to exchange authorization code.");
|
|
1191
|
-
return null;
|
|
1192
|
-
}
|
|
1633
|
+
const authFlow = options.authFlow ?? "auto";
|
|
1634
|
+
let tokenResult = null;
|
|
1635
|
+
if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(true);
|
|
1636
|
+
else tokenResult = await requestTokenViaOAuth(await createAuthorizationFlow(), {
|
|
1637
|
+
useSpinner: true,
|
|
1638
|
+
authFlow
|
|
1639
|
+
});
|
|
1640
|
+
if (!tokenResult) return null;
|
|
1193
1641
|
const accountId = extractAccountId(tokenResult.access);
|
|
1194
1642
|
if (!accountId) {
|
|
1195
|
-
|
|
1643
|
+
p.log.error("Failed to extract account ID from token.");
|
|
1196
1644
|
return null;
|
|
1197
1645
|
}
|
|
1198
|
-
|
|
1646
|
+
p.log.message("Saving credentials...");
|
|
1199
1647
|
const payload = {
|
|
1200
1648
|
refresh: tokenResult.refresh,
|
|
1201
1649
|
access: tokenResult.access,
|
|
@@ -1204,7 +1652,7 @@ const performLogin = async () => {
|
|
|
1204
1652
|
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
1205
1653
|
};
|
|
1206
1654
|
await getSecretStoreAdapter().save(accountId, payload);
|
|
1207
|
-
|
|
1655
|
+
p.log.success("Login successful!");
|
|
1208
1656
|
const labelInput = await p.text({
|
|
1209
1657
|
message: "Enter a label for this account (or press Enter to skip):",
|
|
1210
1658
|
placeholder: "e.g. Work, Personal"
|
|
@@ -1456,7 +1904,7 @@ const handleSwitchAccount = async () => {
|
|
|
1456
1904
|
const handleAddAccount = async () => {
|
|
1457
1905
|
await performLogin();
|
|
1458
1906
|
};
|
|
1459
|
-
const handleReloginAccount = async () => {
|
|
1907
|
+
const handleReloginAccount = async (reloginOptions = {}) => {
|
|
1460
1908
|
if (!configExists()) {
|
|
1461
1909
|
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
1462
1910
|
return;
|
|
@@ -1485,7 +1933,10 @@ const handleReloginAccount = async () => {
|
|
|
1485
1933
|
const displayName = account?.label ?? accountId;
|
|
1486
1934
|
p.log.info(`Current token status for ${displayName}: ${expiryState}`);
|
|
1487
1935
|
try {
|
|
1488
|
-
const result = await performRefresh(accountId, account?.label, {
|
|
1936
|
+
const result = await performRefresh(accountId, account?.label, {
|
|
1937
|
+
useSpinner: false,
|
|
1938
|
+
authFlow: reloginOptions.authFlow
|
|
1939
|
+
});
|
|
1489
1940
|
if (!result) p.log.warning("Re-login was not completed.");
|
|
1490
1941
|
else {
|
|
1491
1942
|
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
@@ -1814,6 +2265,30 @@ const registerDoctorCommand = (program) => {
|
|
|
1814
2265
|
process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
|
|
1815
2266
|
const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
|
|
1816
2267
|
process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
|
|
2268
|
+
if (process.platform === "win32") {
|
|
2269
|
+
const secretStore = getSecretStoreAdapter();
|
|
2270
|
+
process.stdout.write("\nWindows secure-store checks:\n");
|
|
2271
|
+
if (status.accounts.length === 0) process.stdout.write(" No accounts configured in config.\n");
|
|
2272
|
+
else {
|
|
2273
|
+
let okCount = 0;
|
|
2274
|
+
for (const account of status.accounts) {
|
|
2275
|
+
const accountLabel = resolveLabel(account.accountId);
|
|
2276
|
+
try {
|
|
2277
|
+
await secretStore.load(account.accountId);
|
|
2278
|
+
okCount += 1;
|
|
2279
|
+
process.stdout.write(` ${accountLabel}: credential payload load OK\n`);
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
if (isMissingSecretStoreEntryError(error)) {
|
|
2282
|
+
process.stdout.write(` ⚠ ${accountLabel}: missing secure-store entry for configured account\n`);
|
|
2283
|
+
continue;
|
|
2284
|
+
}
|
|
2285
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2286
|
+
process.stdout.write(` ⚠ ${accountLabel}: secure-store load failed (${message})\n`);
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
process.stdout.write(` Summary: ${okCount}/${status.accounts.length} configured account(s) passed secure-store load checks.\n`);
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
1817
2292
|
if (process.platform === "darwin" && !options.checkKeychainAcl) {
|
|
1818
2293
|
process.stdout.write(" ┌─ Optional keychain ACL check\n");
|
|
1819
2294
|
process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
|
|
@@ -1909,9 +2384,9 @@ const registerLabelCommand = (program) => {
|
|
|
1909
2384
|
//#region lib/commands/login.ts
|
|
1910
2385
|
const registerLoginCommand = (program, deps = {}) => {
|
|
1911
2386
|
const runLogin = deps.performLogin ?? performLogin;
|
|
1912
|
-
program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
|
|
2387
|
+
program.command("login").description("Add a new OpenAI account via OAuth").option("--device-flow", "Use OAuth device flow instead of browser callback flow").action(async (options) => {
|
|
1913
2388
|
try {
|
|
1914
|
-
if (!await runLogin()) {
|
|
2389
|
+
if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
|
|
1915
2390
|
process.stderr.write("Login failed.\n");
|
|
1916
2391
|
process.exit(1);
|
|
1917
2392
|
}
|
|
@@ -2061,8 +2536,9 @@ const writeUpdatedAuthSummary = (result) => {
|
|
|
2061
2536
|
//#endregion
|
|
2062
2537
|
//#region lib/commands/refresh.ts
|
|
2063
2538
|
const registerReloginCommand = (program) => {
|
|
2064
|
-
program.command("relogin").description("Re-authenticate an existing account with full OAuth login (no duplicate account)").argument("[account]", "Account ID or label to re-login").action(async (account) => {
|
|
2539
|
+
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").argument("[account]", "Account ID or label to re-login").action(async (account, options) => {
|
|
2065
2540
|
try {
|
|
2541
|
+
const authFlow = options.deviceFlow ? "device" : "auto";
|
|
2066
2542
|
if (account) {
|
|
2067
2543
|
const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
2068
2544
|
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
@@ -2077,7 +2553,7 @@ const registerReloginCommand = (program) => {
|
|
|
2077
2553
|
}
|
|
2078
2554
|
else secureStoreState = " [no secure store entry]";
|
|
2079
2555
|
process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
|
|
2080
|
-
const result = await performRefresh(target.accountId, target.label);
|
|
2556
|
+
const result = await performRefresh(target.accountId, target.label, { authFlow });
|
|
2081
2557
|
if (!result) {
|
|
2082
2558
|
process.stderr.write("Re-login failed.\n");
|
|
2083
2559
|
process.exit(1);
|
|
@@ -2086,7 +2562,7 @@ const registerReloginCommand = (program) => {
|
|
|
2086
2562
|
if (authResult) writeUpdatedAuthSummary(authResult);
|
|
2087
2563
|
return;
|
|
2088
2564
|
}
|
|
2089
|
-
await handleReloginAccount();
|
|
2565
|
+
await handleReloginAccount({ authFlow });
|
|
2090
2566
|
} catch (error) {
|
|
2091
2567
|
exitWithCommandError(error);
|
|
2092
2568
|
}
|
|
@@ -2406,6 +2882,66 @@ const getCompletionParseArgs = (argv) => {
|
|
|
2406
2882
|
if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
|
|
2407
2883
|
return argv.slice(separatorIndex + 1);
|
|
2408
2884
|
};
|
|
2885
|
+
const readCompletionAccounts = () => {
|
|
2886
|
+
try {
|
|
2887
|
+
const { configPath } = getPaths();
|
|
2888
|
+
if (!existsSync(configPath)) return [];
|
|
2889
|
+
const raw = readFileSync(configPath, "utf8");
|
|
2890
|
+
const parsed = JSON.parse(raw);
|
|
2891
|
+
if (!Array.isArray(parsed.accounts)) return [];
|
|
2892
|
+
const accounts = [];
|
|
2893
|
+
for (const account of parsed.accounts) {
|
|
2894
|
+
if (typeof account.accountId !== "string" || !account.accountId.trim()) continue;
|
|
2895
|
+
accounts.push({
|
|
2896
|
+
accountId: account.accountId,
|
|
2897
|
+
...typeof account.label === "string" && account.label.trim() ? { label: account.label } : {}
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
return accounts;
|
|
2901
|
+
} catch {
|
|
2902
|
+
return [];
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
const addConfiguredAccountCompletions = (complete) => {
|
|
2906
|
+
const accounts = readCompletionAccounts();
|
|
2907
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2908
|
+
for (const account of accounts) {
|
|
2909
|
+
if (!seen.has(account.accountId)) {
|
|
2910
|
+
seen.add(account.accountId);
|
|
2911
|
+
const description = account.label ? `Account ID (${account.label})` : "Account ID";
|
|
2912
|
+
complete(account.accountId, description);
|
|
2913
|
+
}
|
|
2914
|
+
if (account.label && !seen.has(account.label)) {
|
|
2915
|
+
seen.add(account.label);
|
|
2916
|
+
complete(account.label, `Label for ${account.accountId}`);
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
const attachAccountArgumentCompletion = (completion, commandName, argumentName) => {
|
|
2921
|
+
const command = completion.commands.get(commandName);
|
|
2922
|
+
if (!command) return;
|
|
2923
|
+
command.argument(argumentName, (complete) => {
|
|
2924
|
+
addConfiguredAccountCompletions(complete);
|
|
2925
|
+
});
|
|
2926
|
+
};
|
|
2927
|
+
const configureTabCompletion = (completion) => {
|
|
2928
|
+
const secretStoreOption = completion.options.get("secret-store");
|
|
2929
|
+
if (secretStoreOption) secretStoreOption.handler = (complete) => {
|
|
2930
|
+
complete("auto", "Automatic backend selection");
|
|
2931
|
+
complete("legacy-keychain", "macOS legacy keychain backend");
|
|
2932
|
+
};
|
|
2933
|
+
attachAccountArgumentCompletion(completion, "switch", "account-id");
|
|
2934
|
+
attachAccountArgumentCompletion(completion, "relogin", "account");
|
|
2935
|
+
attachAccountArgumentCompletion(completion, "usage", "account");
|
|
2936
|
+
attachAccountArgumentCompletion(completion, "label", "account");
|
|
2937
|
+
const helpCommand = completion.commands.get("help");
|
|
2938
|
+
if (helpCommand) helpCommand.argument("command", (complete) => {
|
|
2939
|
+
for (const [name, command] of completion.commands.entries()) {
|
|
2940
|
+
if (name === "") continue;
|
|
2941
|
+
complete(name, command.description || "Command");
|
|
2942
|
+
}
|
|
2943
|
+
});
|
|
2944
|
+
};
|
|
2409
2945
|
const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
|
|
2410
2946
|
if (platform !== "darwin") return null;
|
|
2411
2947
|
if (selection === "legacy-keychain") return "⚠ macOS keychain is using the legacy security CLI backend. Touch ID may not be offered for keychain prompts.";
|
|
@@ -2451,6 +2987,7 @@ const createProgram = (deps = {}) => {
|
|
|
2451
2987
|
const main = async () => {
|
|
2452
2988
|
const program = createProgram();
|
|
2453
2989
|
const completion = tab(program);
|
|
2990
|
+
configureTabCompletion(completion);
|
|
2454
2991
|
const completionArgs = getCompletionParseArgs(process.argv);
|
|
2455
2992
|
if (completionArgs) {
|
|
2456
2993
|
completion.parse(completionArgs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bjesuiter/codex-switcher",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI tool to switch between multiple OpenAI accounts for OpenCode",
|
|
6
6
|
"bin": {
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"@bomb.sh/tab": "^0.0.13",
|
|
25
25
|
"@clack/prompts": "^1.0.0",
|
|
26
26
|
"@openauthjs/openauth": "^0.4.3",
|
|
27
|
+
"age-encryption": "^0.3.0",
|
|
27
28
|
"commander": "^14.0.3"
|
|
28
29
|
}
|
|
29
30
|
}
|