@bjesuiter/codex-switcher 1.5.1 → 1.7.1
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 +10 -3
- package/cdx.mjs +579 -116
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,12 +6,15 @@ 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.1
|
|
10
|
+
|
|
11
|
+
#### Features
|
|
12
|
+
|
|
13
|
+
- Add a release helper script (`scripts/wait-for-npm-latest.ts`) plus `bun run wait-npm-latest` to poll npm until the package `latest` tag matches the target version.
|
|
10
14
|
|
|
11
15
|
#### Fixes
|
|
12
16
|
|
|
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.
|
|
17
|
+
- Fix Windows CI completion test behavior by providing `APPDATA` in the account-completion test environment, so account suggestions are resolved correctly on `windows-latest`.
|
|
15
18
|
|
|
16
19
|
see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
|
|
17
20
|
|
|
@@ -154,8 +157,11 @@ cdx migrate-secrets
|
|
|
154
157
|
|---------|-------------|
|
|
155
158
|
| `cdx` | Interactive mode |
|
|
156
159
|
| `cdx login` | Add a new OpenAI account via OAuth |
|
|
160
|
+
| `cdx login --device-flow` | Add account using OAuth device flow (no local browser callback needed) |
|
|
157
161
|
| `cdx relogin` | Re-authenticate an existing account via OAuth |
|
|
162
|
+
| `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
|
|
158
163
|
| `cdx relogin <account>` | Re-authenticate a specific account by ID or label |
|
|
164
|
+
| `cdx relogin <account> --device-flow` | Re-authenticate specific account using OAuth device flow |
|
|
159
165
|
| `cdx switch` | Switch account (interactive picker) |
|
|
160
166
|
| `cdx switch --next` | Cycle to next account |
|
|
161
167
|
| `cdx switch <id>` | Switch to specific account |
|
|
@@ -187,6 +193,7 @@ source <(cdx complete bash)
|
|
|
187
193
|
```
|
|
188
194
|
|
|
189
195
|
`cdx` also supports shell parse completion requests via `cdx complete -- ...`.
|
|
196
|
+
Completions include command names, options, `--secret-store` values, and account ID/label suggestions for commands like `switch`, `relogin`, `usage`, and `label`.
|
|
190
197
|
|
|
191
198
|
## How It Works
|
|
192
199
|
|
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.1";
|
|
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,75 @@ const createAuthorizationFlow = async () => {
|
|
|
904
1032
|
url: url.toString()
|
|
905
1033
|
};
|
|
906
1034
|
};
|
|
1035
|
+
const startDeviceAuthorizationFlow = async () => {
|
|
1036
|
+
try {
|
|
1037
|
+
const res = await fetch(DEVICE_CODE_URL, {
|
|
1038
|
+
method: "POST",
|
|
1039
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1040
|
+
body: new URLSearchParams({
|
|
1041
|
+
client_id: CLIENT_ID,
|
|
1042
|
+
scope: SCOPE
|
|
1043
|
+
})
|
|
1044
|
+
});
|
|
1045
|
+
if (!res.ok) return null;
|
|
1046
|
+
const json = await res.json();
|
|
1047
|
+
if (!json?.device_code || !json?.user_code || !json?.verification_uri || typeof json?.expires_in !== "number") return null;
|
|
1048
|
+
return {
|
|
1049
|
+
deviceCode: json.device_code,
|
|
1050
|
+
userCode: json.user_code,
|
|
1051
|
+
verificationUri: json.verification_uri,
|
|
1052
|
+
verificationUriComplete: json.verification_uri_complete,
|
|
1053
|
+
expiresIn: json.expires_in,
|
|
1054
|
+
interval: typeof json.interval === "number" && json.interval > 0 ? json.interval : 5
|
|
1055
|
+
};
|
|
1056
|
+
} catch {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const pollDeviceAuthorizationToken = async (deviceCode) => {
|
|
1061
|
+
try {
|
|
1062
|
+
const res = await fetch(TOKEN_URL, {
|
|
1063
|
+
method: "POST",
|
|
1064
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
1065
|
+
body: new URLSearchParams({
|
|
1066
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
1067
|
+
device_code: deviceCode,
|
|
1068
|
+
client_id: CLIENT_ID
|
|
1069
|
+
})
|
|
1070
|
+
});
|
|
1071
|
+
if (res.ok) {
|
|
1072
|
+
const json = await res.json();
|
|
1073
|
+
if (!json?.access_token || !json?.refresh_token || typeof json?.expires_in !== "number") return { type: "failed" };
|
|
1074
|
+
return {
|
|
1075
|
+
type: "success",
|
|
1076
|
+
access: json.access_token,
|
|
1077
|
+
refresh: json.refresh_token,
|
|
1078
|
+
expires: Date.now() + json.expires_in * 1e3,
|
|
1079
|
+
idToken: json.id_token
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
let errorCode;
|
|
1083
|
+
let interval;
|
|
1084
|
+
try {
|
|
1085
|
+
const json = await res.json();
|
|
1086
|
+
errorCode = json.error;
|
|
1087
|
+
interval = json.interval;
|
|
1088
|
+
} catch {}
|
|
1089
|
+
if (errorCode === "authorization_pending") return {
|
|
1090
|
+
type: "pending",
|
|
1091
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 5
|
|
1092
|
+
};
|
|
1093
|
+
if (errorCode === "slow_down") return {
|
|
1094
|
+
type: "slow_down",
|
|
1095
|
+
interval: typeof interval === "number" && interval > 0 ? interval : 10
|
|
1096
|
+
};
|
|
1097
|
+
if (errorCode === "access_denied") return { type: "access_denied" };
|
|
1098
|
+
if (errorCode === "expired_token") return { type: "expired" };
|
|
1099
|
+
return { type: "failed" };
|
|
1100
|
+
} catch {
|
|
1101
|
+
return { type: "failed" };
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
907
1104
|
const exchangeAuthorizationCode = async (code, verifier) => {
|
|
908
1105
|
const res = await fetch(TOKEN_URL, {
|
|
909
1106
|
method: "POST",
|
|
@@ -1067,12 +1264,225 @@ const startOAuthServer = (state) => {
|
|
|
1067
1264
|
|
|
1068
1265
|
//#endregion
|
|
1069
1266
|
//#region lib/oauth/login.ts
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
if (
|
|
1073
|
-
|
|
1074
|
-
|
|
1267
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1268
|
+
const isLikelyRemoteEnvironment = () => {
|
|
1269
|
+
if (process.platform !== "linux") return false;
|
|
1270
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) return true;
|
|
1271
|
+
return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY;
|
|
1272
|
+
};
|
|
1273
|
+
const parseOAuthCallbackInput = (input) => {
|
|
1274
|
+
const trimmed = input.trim();
|
|
1275
|
+
if (!trimmed) return null;
|
|
1276
|
+
if (!trimmed.includes("://") && !trimmed.includes("code=") && !trimmed.includes("?")) return { code: trimmed };
|
|
1277
|
+
try {
|
|
1278
|
+
const parsedUrl = new URL(trimmed);
|
|
1279
|
+
const code = parsedUrl.searchParams.get("code");
|
|
1280
|
+
if (code) return {
|
|
1281
|
+
code,
|
|
1282
|
+
state: parsedUrl.searchParams.get("state") ?? void 0
|
|
1283
|
+
};
|
|
1284
|
+
} catch {}
|
|
1285
|
+
const queryLike = trimmed.startsWith("?") || trimmed.startsWith("#") ? trimmed.slice(1) : trimmed.includes("?") ? trimmed.slice(trimmed.indexOf("?") + 1) : trimmed;
|
|
1286
|
+
const params = new URLSearchParams(queryLike);
|
|
1287
|
+
const code = params.get("code");
|
|
1288
|
+
if (!code) return null;
|
|
1289
|
+
return {
|
|
1290
|
+
code,
|
|
1291
|
+
state: params.get("state") ?? void 0
|
|
1292
|
+
};
|
|
1293
|
+
};
|
|
1294
|
+
const promptBrowserFallbackChoice = async () => {
|
|
1295
|
+
const remoteHint = isLikelyRemoteEnvironment();
|
|
1296
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1297
|
+
const selected = remoteHint ? "device" : "manual";
|
|
1298
|
+
p.log.info(`Non-interactive terminal detected. Falling back to ${selected === "device" ? "device OAuth flow" : "manual URL copy/paste flow"}.`);
|
|
1299
|
+
return selected;
|
|
1300
|
+
}
|
|
1301
|
+
const options = remoteHint ? [
|
|
1302
|
+
{
|
|
1303
|
+
value: "device",
|
|
1304
|
+
label: "Use device OAuth flow",
|
|
1305
|
+
hint: "Recommended on SSH/remote servers"
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
value: "manual",
|
|
1309
|
+
label: "Finish manually by copying URL",
|
|
1310
|
+
hint: "Open URL on any machine and paste callback URL/code back here"
|
|
1311
|
+
},
|
|
1312
|
+
{
|
|
1313
|
+
value: "cancel",
|
|
1314
|
+
label: "Cancel login"
|
|
1315
|
+
}
|
|
1316
|
+
] : [
|
|
1317
|
+
{
|
|
1318
|
+
value: "manual",
|
|
1319
|
+
label: "Finish manually by copying URL",
|
|
1320
|
+
hint: "Open URL on any machine and paste callback URL/code back here"
|
|
1321
|
+
},
|
|
1322
|
+
{
|
|
1323
|
+
value: "device",
|
|
1324
|
+
label: "Use device OAuth flow",
|
|
1325
|
+
hint: "Best for headless/remote environments"
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
value: "cancel",
|
|
1329
|
+
label: "Cancel login"
|
|
1330
|
+
}
|
|
1331
|
+
];
|
|
1332
|
+
const selection = await p.select({
|
|
1333
|
+
message: "Browser launcher is unavailable. How do you want to continue?",
|
|
1334
|
+
options
|
|
1335
|
+
});
|
|
1336
|
+
if (p.isCancel(selection)) return "cancel";
|
|
1337
|
+
return selection;
|
|
1338
|
+
};
|
|
1339
|
+
const promptManualAuthorizationCode = async (authorizationUrl, expectedState) => {
|
|
1340
|
+
p.log.info("Manual login selected.");
|
|
1341
|
+
p.log.message(`Open this URL in a browser:\n${authorizationUrl}`);
|
|
1342
|
+
p.log.message("After approving, copy the full callback URL (or just the 'code' value) and paste it below.");
|
|
1343
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1344
|
+
const response = await p.text({
|
|
1345
|
+
message: "Paste callback URL or authorization code:",
|
|
1346
|
+
placeholder: "http://localhost:1455/auth/callback?code=...&state=..."
|
|
1347
|
+
});
|
|
1348
|
+
if (p.isCancel(response)) {
|
|
1349
|
+
p.log.info("Login cancelled.");
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
const parsed = parseOAuthCallbackInput(String(response));
|
|
1353
|
+
if (!parsed) {
|
|
1354
|
+
p.log.warning("Could not parse input. Please paste a callback URL or code.");
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (parsed.state && parsed.state !== expectedState) {
|
|
1358
|
+
p.log.error("State mismatch in callback URL. Please retry the login flow.");
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
return parsed.code;
|
|
1075
1362
|
}
|
|
1363
|
+
p.log.error("Failed to parse callback input after multiple attempts.");
|
|
1364
|
+
return null;
|
|
1365
|
+
};
|
|
1366
|
+
const runDeviceOAuthFlow = async (useSpinner) => {
|
|
1367
|
+
const deviceFlow = await startDeviceAuthorizationFlow();
|
|
1368
|
+
if (!deviceFlow) {
|
|
1369
|
+
p.log.error("Device OAuth flow is not available right now.");
|
|
1370
|
+
return null;
|
|
1371
|
+
}
|
|
1372
|
+
p.log.info("Using device OAuth flow.");
|
|
1373
|
+
p.log.message(`Verification URL: ${deviceFlow.verificationUri}`);
|
|
1374
|
+
p.log.message(`User code: ${deviceFlow.userCode}`);
|
|
1375
|
+
const launchResult = openBrowserUrl(deviceFlow.verificationUriComplete ?? deviceFlow.verificationUri);
|
|
1376
|
+
if (!launchResult.ok) {
|
|
1377
|
+
const msg = launchResult.error ?? "unknown error";
|
|
1378
|
+
p.log.warning(`Could not auto-open verification URL via ${launchResult.launcher.label} (${msg}).`);
|
|
1379
|
+
}
|
|
1380
|
+
const spinner = useSpinner ? p.spinner() : null;
|
|
1381
|
+
if (spinner) spinner.start("Waiting for device authorization...");
|
|
1382
|
+
else p.log.message("Waiting for device authorization...");
|
|
1383
|
+
let intervalMs = Math.max(deviceFlow.interval, 1) * 1e3;
|
|
1384
|
+
const deadline = Date.now() + deviceFlow.expiresIn * 1e3;
|
|
1385
|
+
while (Date.now() < deadline) {
|
|
1386
|
+
await sleep(intervalMs);
|
|
1387
|
+
const pollResult = await pollDeviceAuthorizationToken(deviceFlow.deviceCode);
|
|
1388
|
+
if (pollResult.type === "success") {
|
|
1389
|
+
if (spinner) spinner.stop("Device authorization completed.");
|
|
1390
|
+
else p.log.success("Device authorization completed.");
|
|
1391
|
+
return pollResult;
|
|
1392
|
+
}
|
|
1393
|
+
if (pollResult.type === "pending") {
|
|
1394
|
+
intervalMs = Math.max(pollResult.interval, 1) * 1e3;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
if (pollResult.type === "slow_down") {
|
|
1398
|
+
intervalMs = Math.max(pollResult.interval, Math.ceil(intervalMs / 1e3) + 5) * 1e3;
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
if (pollResult.type === "access_denied") {
|
|
1402
|
+
if (spinner) spinner.stop("Device authorization was denied.");
|
|
1403
|
+
else p.log.error("Device authorization was denied.");
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
if (pollResult.type === "expired") {
|
|
1407
|
+
if (spinner) spinner.stop("Device authorization expired.");
|
|
1408
|
+
else p.log.error("Device authorization expired.");
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
if (spinner) spinner.stop("Device authorization failed.");
|
|
1412
|
+
else p.log.error("Device authorization failed.");
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
if (spinner) spinner.stop("Device authorization timed out.");
|
|
1416
|
+
else p.log.error("Device authorization timed out.");
|
|
1417
|
+
return null;
|
|
1418
|
+
};
|
|
1419
|
+
const requestTokenViaOAuth = async (flow, options) => {
|
|
1420
|
+
if (options.authFlow === "device") return runDeviceOAuthFlow(options.useSpinner);
|
|
1421
|
+
const server = await startOAuthServer(flow.state);
|
|
1422
|
+
if (!server.ready) {
|
|
1423
|
+
p.log.error("Failed to start local server on port 1455.");
|
|
1424
|
+
p.log.info("Please ensure the port is not in use.");
|
|
1425
|
+
return null;
|
|
1426
|
+
}
|
|
1427
|
+
const spinner = options.useSpinner ? p.spinner() : null;
|
|
1428
|
+
let spinnerStarted = false;
|
|
1429
|
+
p.log.info("Opening browser for authentication...");
|
|
1430
|
+
const launchResult = openBrowserUrl(flow.url);
|
|
1431
|
+
if (!launchResult.ok) {
|
|
1432
|
+
const msg = launchResult.error ?? "unknown error";
|
|
1433
|
+
p.log.warning(`Could not auto-open browser via ${launchResult.launcher.label} (${msg}).`);
|
|
1434
|
+
}
|
|
1435
|
+
p.log.message(`If your browser did not open, paste this URL:\n${flow.url}`);
|
|
1436
|
+
if (launchResult.ok) {
|
|
1437
|
+
if (spinner) {
|
|
1438
|
+
spinner.start("Waiting for authentication...");
|
|
1439
|
+
spinnerStarted = true;
|
|
1440
|
+
}
|
|
1441
|
+
const result = await server.waitForCode();
|
|
1442
|
+
server.close();
|
|
1443
|
+
if (!result) {
|
|
1444
|
+
if (spinner) spinner.stop("Authentication timed out or failed.");
|
|
1445
|
+
else p.log.warning("Authentication timed out or failed.");
|
|
1446
|
+
return null;
|
|
1447
|
+
}
|
|
1448
|
+
if (spinner) spinner.message("Exchanging authorization code...");
|
|
1449
|
+
else p.log.message("Exchanging authorization code...");
|
|
1450
|
+
const tokenResult = await exchangeAuthorizationCode(result.code, flow.pkce.verifier);
|
|
1451
|
+
if (tokenResult.type !== "success") {
|
|
1452
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
1453
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
1454
|
+
return null;
|
|
1455
|
+
}
|
|
1456
|
+
if (spinner) spinner.stop("Authentication completed.");
|
|
1457
|
+
return tokenResult;
|
|
1458
|
+
}
|
|
1459
|
+
const fallbackChoice = await promptBrowserFallbackChoice();
|
|
1460
|
+
if (fallbackChoice === "cancel") {
|
|
1461
|
+
server.close();
|
|
1462
|
+
p.log.info("Login cancelled.");
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
if (fallbackChoice === "device") {
|
|
1466
|
+
server.close();
|
|
1467
|
+
return runDeviceOAuthFlow(options.useSpinner);
|
|
1468
|
+
}
|
|
1469
|
+
server.close();
|
|
1470
|
+
const code = await promptManualAuthorizationCode(flow.url, flow.state);
|
|
1471
|
+
if (!code) return null;
|
|
1472
|
+
if (spinner) if (spinnerStarted) spinner.message("Exchanging authorization code...");
|
|
1473
|
+
else {
|
|
1474
|
+
spinner.start("Exchanging authorization code...");
|
|
1475
|
+
spinnerStarted = true;
|
|
1476
|
+
}
|
|
1477
|
+
else p.log.message("Exchanging authorization code...");
|
|
1478
|
+
const tokenResult = await exchangeAuthorizationCode(code, flow.pkce.verifier);
|
|
1479
|
+
if (tokenResult.type !== "success") {
|
|
1480
|
+
if (spinner) spinner.stop("Failed to exchange authorization code.");
|
|
1481
|
+
else p.log.error("Failed to exchange authorization code.");
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
if (spinner) spinner.stop("Authentication completed.");
|
|
1485
|
+
return tokenResult;
|
|
1076
1486
|
};
|
|
1077
1487
|
const addAccountToConfig = async (accountId, label) => {
|
|
1078
1488
|
let config;
|
|
@@ -1100,54 +1510,35 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
|
|
|
1100
1510
|
const displayName = label ?? targetAccountId;
|
|
1101
1511
|
p.log.step(`Re-authenticating account "${displayName}"...`);
|
|
1102
1512
|
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;
|
|
1513
|
+
const authFlow = options.authFlow ?? "auto";
|
|
1514
|
+
let tokenResult = null;
|
|
1515
|
+
if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(useSpinner);
|
|
1516
|
+
else {
|
|
1517
|
+
let flow;
|
|
1518
|
+
try {
|
|
1519
|
+
flow = await createAuthorizationFlow();
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1522
|
+
p.log.error(`Failed to create authorization flow: ${msg}`);
|
|
1523
|
+
process.stderr.write(`Failed to create authorization flow: ${msg}\n`);
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
tokenResult = await requestTokenViaOAuth(flow, {
|
|
1527
|
+
useSpinner,
|
|
1528
|
+
authFlow
|
|
1529
|
+
});
|
|
1137
1530
|
}
|
|
1531
|
+
if (!tokenResult) return null;
|
|
1138
1532
|
const newAccountId = extractAccountId(tokenResult.access);
|
|
1139
1533
|
if (!newAccountId) {
|
|
1140
|
-
|
|
1141
|
-
else p.log.error("Failed to extract account ID from token.");
|
|
1534
|
+
p.log.error("Failed to extract account ID from token.");
|
|
1142
1535
|
return null;
|
|
1143
1536
|
}
|
|
1144
1537
|
if (newAccountId !== targetAccountId) {
|
|
1145
|
-
|
|
1146
|
-
else p.log.error("Authentication completed for a different account.");
|
|
1538
|
+
p.log.error("Authentication completed for a different account.");
|
|
1147
1539
|
throw new Error(`Account mismatch: expected "${targetAccountId}" but got "${newAccountId}". Make sure you log in with the correct OpenAI account.`);
|
|
1148
1540
|
}
|
|
1149
|
-
if (
|
|
1150
|
-
else p.log.message("Updating credentials...");
|
|
1541
|
+
if (!useSpinner) p.log.message("Updating credentials...");
|
|
1151
1542
|
const payload = {
|
|
1152
1543
|
refresh: tokenResult.refresh,
|
|
1153
1544
|
access: tokenResult.access,
|
|
@@ -1156,46 +1547,29 @@ const performRefresh = async (targetAccountId, label, options = {}) => {
|
|
|
1156
1547
|
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
1157
1548
|
};
|
|
1158
1549
|
await getSecretStoreAdapter().save(newAccountId, payload);
|
|
1159
|
-
|
|
1160
|
-
else p.log.success("Credentials refreshed!");
|
|
1550
|
+
p.log.success("Credentials refreshed!");
|
|
1161
1551
|
p.log.success(`Account "${displayName}" credentials updated in secure store.`);
|
|
1162
1552
|
return { accountId: newAccountId };
|
|
1163
1553
|
} finally {
|
|
1164
1554
|
clearInterval(keepAlive);
|
|
1165
1555
|
}
|
|
1166
1556
|
};
|
|
1167
|
-
const performLogin = async () => {
|
|
1557
|
+
const performLogin = async (options = {}) => {
|
|
1168
1558
|
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
|
-
}
|
|
1559
|
+
const authFlow = options.authFlow ?? "auto";
|
|
1560
|
+
let tokenResult = null;
|
|
1561
|
+
if (authFlow === "device") tokenResult = await runDeviceOAuthFlow(true);
|
|
1562
|
+
else tokenResult = await requestTokenViaOAuth(await createAuthorizationFlow(), {
|
|
1563
|
+
useSpinner: true,
|
|
1564
|
+
authFlow
|
|
1565
|
+
});
|
|
1566
|
+
if (!tokenResult) return null;
|
|
1193
1567
|
const accountId = extractAccountId(tokenResult.access);
|
|
1194
1568
|
if (!accountId) {
|
|
1195
|
-
|
|
1569
|
+
p.log.error("Failed to extract account ID from token.");
|
|
1196
1570
|
return null;
|
|
1197
1571
|
}
|
|
1198
|
-
|
|
1572
|
+
p.log.message("Saving credentials...");
|
|
1199
1573
|
const payload = {
|
|
1200
1574
|
refresh: tokenResult.refresh,
|
|
1201
1575
|
access: tokenResult.access,
|
|
@@ -1204,7 +1578,7 @@ const performLogin = async () => {
|
|
|
1204
1578
|
...tokenResult.idToken ? { idToken: tokenResult.idToken } : {}
|
|
1205
1579
|
};
|
|
1206
1580
|
await getSecretStoreAdapter().save(accountId, payload);
|
|
1207
|
-
|
|
1581
|
+
p.log.success("Login successful!");
|
|
1208
1582
|
const labelInput = await p.text({
|
|
1209
1583
|
message: "Enter a label for this account (or press Enter to skip):",
|
|
1210
1584
|
placeholder: "e.g. Work, Personal"
|
|
@@ -1456,7 +1830,7 @@ const handleSwitchAccount = async () => {
|
|
|
1456
1830
|
const handleAddAccount = async () => {
|
|
1457
1831
|
await performLogin();
|
|
1458
1832
|
};
|
|
1459
|
-
const handleReloginAccount = async () => {
|
|
1833
|
+
const handleReloginAccount = async (reloginOptions = {}) => {
|
|
1460
1834
|
if (!configExists()) {
|
|
1461
1835
|
p.log.warning("No accounts configured. Use 'Add account' first.");
|
|
1462
1836
|
return;
|
|
@@ -1485,7 +1859,10 @@ const handleReloginAccount = async () => {
|
|
|
1485
1859
|
const displayName = account?.label ?? accountId;
|
|
1486
1860
|
p.log.info(`Current token status for ${displayName}: ${expiryState}`);
|
|
1487
1861
|
try {
|
|
1488
|
-
const result = await performRefresh(accountId, account?.label, {
|
|
1862
|
+
const result = await performRefresh(accountId, account?.label, {
|
|
1863
|
+
useSpinner: false,
|
|
1864
|
+
authFlow: reloginOptions.authFlow
|
|
1865
|
+
});
|
|
1489
1866
|
if (!result) p.log.warning("Re-login was not completed.");
|
|
1490
1867
|
else {
|
|
1491
1868
|
const authResult = await writeActiveAuthFilesIfCurrent(result.accountId);
|
|
@@ -1814,6 +2191,30 @@ const registerDoctorCommand = (program) => {
|
|
|
1814
2191
|
process.stdout.write(` Secret store: ${status.capabilities.secretStore.label} — ${secretStoreState}\n`);
|
|
1815
2192
|
const browserState = status.capabilities.browserLauncher.available ? "available" : "not found";
|
|
1816
2193
|
process.stdout.write(` Browser launcher: ${status.capabilities.browserLauncher.label} — ${browserState}\n`);
|
|
2194
|
+
if (process.platform === "win32") {
|
|
2195
|
+
const secretStore = getSecretStoreAdapter();
|
|
2196
|
+
process.stdout.write("\nWindows secure-store checks:\n");
|
|
2197
|
+
if (status.accounts.length === 0) process.stdout.write(" No accounts configured in config.\n");
|
|
2198
|
+
else {
|
|
2199
|
+
let okCount = 0;
|
|
2200
|
+
for (const account of status.accounts) {
|
|
2201
|
+
const accountLabel = resolveLabel(account.accountId);
|
|
2202
|
+
try {
|
|
2203
|
+
await secretStore.load(account.accountId);
|
|
2204
|
+
okCount += 1;
|
|
2205
|
+
process.stdout.write(` ${accountLabel}: credential payload load OK\n`);
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
if (isMissingSecretStoreEntryError(error)) {
|
|
2208
|
+
process.stdout.write(` ⚠ ${accountLabel}: missing secure-store entry for configured account\n`);
|
|
2209
|
+
continue;
|
|
2210
|
+
}
|
|
2211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2212
|
+
process.stdout.write(` ⚠ ${accountLabel}: secure-store load failed (${message})\n`);
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
process.stdout.write(` Summary: ${okCount}/${status.accounts.length} configured account(s) passed secure-store load checks.\n`);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
1817
2218
|
if (process.platform === "darwin" && !options.checkKeychainAcl) {
|
|
1818
2219
|
process.stdout.write(" ┌─ Optional keychain ACL check\n");
|
|
1819
2220
|
process.stdout.write(" │ Run: cdx doctor --check-keychain-acl\n");
|
|
@@ -1909,9 +2310,9 @@ const registerLabelCommand = (program) => {
|
|
|
1909
2310
|
//#region lib/commands/login.ts
|
|
1910
2311
|
const registerLoginCommand = (program, deps = {}) => {
|
|
1911
2312
|
const runLogin = deps.performLogin ?? performLogin;
|
|
1912
|
-
program.command("login").description("Add a new OpenAI account via OAuth").action(async () => {
|
|
2313
|
+
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
2314
|
try {
|
|
1914
|
-
if (!await runLogin()) {
|
|
2315
|
+
if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
|
|
1915
2316
|
process.stderr.write("Login failed.\n");
|
|
1916
2317
|
process.exit(1);
|
|
1917
2318
|
}
|
|
@@ -2061,8 +2462,9 @@ const writeUpdatedAuthSummary = (result) => {
|
|
|
2061
2462
|
//#endregion
|
|
2062
2463
|
//#region lib/commands/refresh.ts
|
|
2063
2464
|
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) => {
|
|
2465
|
+
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
2466
|
try {
|
|
2467
|
+
const authFlow = options.deviceFlow ? "device" : "auto";
|
|
2066
2468
|
if (account) {
|
|
2067
2469
|
const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
2068
2470
|
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
@@ -2077,7 +2479,7 @@ const registerReloginCommand = (program) => {
|
|
|
2077
2479
|
}
|
|
2078
2480
|
else secureStoreState = " [no secure store entry]";
|
|
2079
2481
|
process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
|
|
2080
|
-
const result = await performRefresh(target.accountId, target.label);
|
|
2482
|
+
const result = await performRefresh(target.accountId, target.label, { authFlow });
|
|
2081
2483
|
if (!result) {
|
|
2082
2484
|
process.stderr.write("Re-login failed.\n");
|
|
2083
2485
|
process.exit(1);
|
|
@@ -2086,7 +2488,7 @@ const registerReloginCommand = (program) => {
|
|
|
2086
2488
|
if (authResult) writeUpdatedAuthSummary(authResult);
|
|
2087
2489
|
return;
|
|
2088
2490
|
}
|
|
2089
|
-
await handleReloginAccount();
|
|
2491
|
+
await handleReloginAccount({ authFlow });
|
|
2090
2492
|
} catch (error) {
|
|
2091
2493
|
exitWithCommandError(error);
|
|
2092
2494
|
}
|
|
@@ -2406,6 +2808,66 @@ const getCompletionParseArgs = (argv) => {
|
|
|
2406
2808
|
if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
|
|
2407
2809
|
return argv.slice(separatorIndex + 1);
|
|
2408
2810
|
};
|
|
2811
|
+
const readCompletionAccounts = () => {
|
|
2812
|
+
try {
|
|
2813
|
+
const { configPath } = getPaths();
|
|
2814
|
+
if (!existsSync(configPath)) return [];
|
|
2815
|
+
const raw = readFileSync(configPath, "utf8");
|
|
2816
|
+
const parsed = JSON.parse(raw);
|
|
2817
|
+
if (!Array.isArray(parsed.accounts)) return [];
|
|
2818
|
+
const accounts = [];
|
|
2819
|
+
for (const account of parsed.accounts) {
|
|
2820
|
+
if (typeof account.accountId !== "string" || !account.accountId.trim()) continue;
|
|
2821
|
+
accounts.push({
|
|
2822
|
+
accountId: account.accountId,
|
|
2823
|
+
...typeof account.label === "string" && account.label.trim() ? { label: account.label } : {}
|
|
2824
|
+
});
|
|
2825
|
+
}
|
|
2826
|
+
return accounts;
|
|
2827
|
+
} catch {
|
|
2828
|
+
return [];
|
|
2829
|
+
}
|
|
2830
|
+
};
|
|
2831
|
+
const addConfiguredAccountCompletions = (complete) => {
|
|
2832
|
+
const accounts = readCompletionAccounts();
|
|
2833
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2834
|
+
for (const account of accounts) {
|
|
2835
|
+
if (!seen.has(account.accountId)) {
|
|
2836
|
+
seen.add(account.accountId);
|
|
2837
|
+
const description = account.label ? `Account ID (${account.label})` : "Account ID";
|
|
2838
|
+
complete(account.accountId, description);
|
|
2839
|
+
}
|
|
2840
|
+
if (account.label && !seen.has(account.label)) {
|
|
2841
|
+
seen.add(account.label);
|
|
2842
|
+
complete(account.label, `Label for ${account.accountId}`);
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2845
|
+
};
|
|
2846
|
+
const attachAccountArgumentCompletion = (completion, commandName, argumentName) => {
|
|
2847
|
+
const command = completion.commands.get(commandName);
|
|
2848
|
+
if (!command) return;
|
|
2849
|
+
command.argument(argumentName, (complete) => {
|
|
2850
|
+
addConfiguredAccountCompletions(complete);
|
|
2851
|
+
});
|
|
2852
|
+
};
|
|
2853
|
+
const configureTabCompletion = (completion) => {
|
|
2854
|
+
const secretStoreOption = completion.options.get("secret-store");
|
|
2855
|
+
if (secretStoreOption) secretStoreOption.handler = (complete) => {
|
|
2856
|
+
complete("auto", "Automatic backend selection");
|
|
2857
|
+
complete("legacy-keychain", "macOS legacy keychain backend");
|
|
2858
|
+
};
|
|
2859
|
+
attachAccountArgumentCompletion(completion, "switch", "account-id");
|
|
2860
|
+
attachAccountArgumentCompletion(completion, "relogin", "account");
|
|
2861
|
+
attachAccountArgumentCompletion(completion, "usage", "account");
|
|
2862
|
+
attachAccountArgumentCompletion(completion, "label", "account");
|
|
2863
|
+
const helpCommand = completion.commands.get("help");
|
|
2864
|
+
if (helpCommand) helpCommand.argument("command", (complete) => {
|
|
2865
|
+
for (const [name, command] of completion.commands.entries()) {
|
|
2866
|
+
if (name === "") continue;
|
|
2867
|
+
complete(name, command.description || "Command");
|
|
2868
|
+
}
|
|
2869
|
+
});
|
|
2870
|
+
};
|
|
2409
2871
|
const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
|
|
2410
2872
|
if (platform !== "darwin") return null;
|
|
2411
2873
|
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 +2913,7 @@ const createProgram = (deps = {}) => {
|
|
|
2451
2913
|
const main = async () => {
|
|
2452
2914
|
const program = createProgram();
|
|
2453
2915
|
const completion = tab(program);
|
|
2916
|
+
configureTabCompletion(completion);
|
|
2454
2917
|
const completionArgs = getCompletionParseArgs(process.argv);
|
|
2455
2918
|
if (completionArgs) {
|
|
2456
2919
|
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.1",
|
|
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
|
}
|