@bjesuiter/codex-switcher 1.5.0 → 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 +9 -17
- package/cdx.mjs +580 -116
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,27 +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
10
|
|
|
11
11
|
#### Features
|
|
12
12
|
|
|
13
|
-
- Add
|
|
14
|
-
- Add configurable secret-store selection with `--secret-store <mode>` (`auto` or `legacy-keychain`) plus persisted config support.
|
|
15
|
-
- Switch macOS `auto` secret storage to cross-keychain backend selection (prefers native backend, falls back when needed).
|
|
16
|
-
- Add `cdx migrate-secrets` to migrate legacy macOS keychain entries to cross-keychain and update config.
|
|
17
|
-
- Add optional macOS keychain ACL diagnostics in `cdx doctor --check-keychain-acl` to verify trusted runtime access.
|
|
18
|
-
- Add doctor/runtime warnings when macOS keychain access is using legacy/CLI fallback paths where Touch ID prompts may not be offered.
|
|
19
|
-
- Increase cross-keychain max password length handling (default `16384`) to support larger stored credential payloads.
|
|
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.
|
|
20
14
|
|
|
21
15
|
#### Fixes
|
|
22
16
|
|
|
23
|
-
-
|
|
24
|
-
- Remove Windows credential payload chunking now that larger payloads are supported directly in the secure store backend.
|
|
25
|
-
|
|
26
|
-
#### Internal
|
|
27
|
-
|
|
28
|
-
- Temporarily switch keyring dependency from `cross-keychain` to `@bjesuiter/cross-keychain@1.1.0-jb.0` until upstream support is available.
|
|
29
|
-
- Add Windows CI coverage including shell smoke checks and expanded secure-store integration tests (including Windows CRUD coverage).
|
|
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`.
|
|
30
18
|
|
|
31
19
|
see full changelog here: https://github.com/bjesuiter/codex-switcher/blob/main/CHANGELOG.md
|
|
32
20
|
|
|
@@ -169,8 +157,11 @@ cdx migrate-secrets
|
|
|
169
157
|
|---------|-------------|
|
|
170
158
|
| `cdx` | Interactive mode |
|
|
171
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) |
|
|
172
161
|
| `cdx relogin` | Re-authenticate an existing account via OAuth |
|
|
162
|
+
| `cdx relogin --device-flow` | Re-authenticate interactively using OAuth device flow |
|
|
173
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 |
|
|
174
165
|
| `cdx switch` | Switch account (interactive picker) |
|
|
175
166
|
| `cdx switch --next` | Cycle to next account |
|
|
176
167
|
| `cdx switch <id>` | Switch to specific account |
|
|
@@ -179,7 +170,7 @@ cdx migrate-secrets
|
|
|
179
170
|
| `cdx status` | Show account status, token expiry, and usage |
|
|
180
171
|
| `cdx migrate-secrets` | Migrate macOS legacy keychain entries to cross-keychain and switch config to `auto` |
|
|
181
172
|
| `cdx doctor` | Show auth file paths/state and runtime capabilities |
|
|
182
|
-
| `cdx doctor --check-keychain-acl` |
|
|
173
|
+
| `cdx doctor --check-keychain-acl` | Detect macOS keychain ACL/runtime mismatches (`cdx`/Bun vs legacy `security` CLI), warn about prompt-heavy setups, and suggest `cdx migrate-secrets` (slow) |
|
|
183
174
|
| `cdx usage` | Show usage overview for all accounts |
|
|
184
175
|
| `cdx usage <account>` | Show detailed usage for a specific account |
|
|
185
176
|
| `cdx help [command]` | Show help for all commands or one command |
|
|
@@ -202,6 +193,7 @@ source <(cdx complete bash)
|
|
|
202
193
|
```
|
|
203
194
|
|
|
204
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`.
|
|
205
197
|
|
|
206
198
|
## How It Works
|
|
207
199
|
|
|
@@ -215,7 +207,7 @@ source <(cdx complete bash)
|
|
|
215
207
|
- `--secret-store <mode>` always overrides config for the current run.
|
|
216
208
|
- If only a fallback secure-store backend is available on your platform, `cdx` asks for one-time explicit consent before the first credential write and explains the security trade-off.
|
|
217
209
|
- Non-interactive override (if you accept the risk): set `CDX_ALLOW_SECURE_STORE_FALLBACK=1`
|
|
218
|
-
- On macOS, `cdx doctor --check-keychain-acl`
|
|
210
|
+
- On macOS, `cdx doctor --check-keychain-acl` checks whether configured secrets were created for the current `cdx`/Bun runtime or by the legacy Apple `security` CLI flow. Legacy ACL entries can trigger frequent keychain password prompts; when a mismatch is detected, `cdx` suggests `cdx migrate-secrets`. This check can be slow.
|
|
219
211
|
- Cross-keychain payload size policy:
|
|
220
212
|
- Default max password length override is `16384`.
|
|
221
213
|
- Optional override: set `CDX_CROSS_KEYCHAIN_MAX_PASSWORD_LENGTH=<integer-above-4096>`.
|
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");
|
|
@@ -1855,6 +2256,7 @@ const registerDoctorCommand = (program) => {
|
|
|
1855
2256
|
process.stdout.write(` Service: ${service}\n`);
|
|
1856
2257
|
process.stdout.write(` Trusted apps: ${trustedAppsList || "(none)"}\n`);
|
|
1857
2258
|
process.stdout.write(" This secret may have been created with a different runtime/toolchain (for example node vs bun).\n");
|
|
2259
|
+
process.stdout.write(" Suggested fix: run `cdx migrate-secrets` to recreate keychain entries with the current runtime ACL.\n");
|
|
1858
2260
|
}
|
|
1859
2261
|
}
|
|
1860
2262
|
}
|
|
@@ -1908,9 +2310,9 @@ const registerLabelCommand = (program) => {
|
|
|
1908
2310
|
//#region lib/commands/login.ts
|
|
1909
2311
|
const registerLoginCommand = (program, deps = {}) => {
|
|
1910
2312
|
const runLogin = deps.performLogin ?? performLogin;
|
|
1911
|
-
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) => {
|
|
1912
2314
|
try {
|
|
1913
|
-
if (!await runLogin()) {
|
|
2315
|
+
if (!await runLogin({ authFlow: options.deviceFlow ? "device" : "auto" })) {
|
|
1914
2316
|
process.stderr.write("Login failed.\n");
|
|
1915
2317
|
process.exit(1);
|
|
1916
2318
|
}
|
|
@@ -2060,8 +2462,9 @@ const writeUpdatedAuthSummary = (result) => {
|
|
|
2060
2462
|
//#endregion
|
|
2061
2463
|
//#region lib/commands/refresh.ts
|
|
2062
2464
|
const registerReloginCommand = (program) => {
|
|
2063
|
-
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) => {
|
|
2064
2466
|
try {
|
|
2467
|
+
const authFlow = options.deviceFlow ? "device" : "auto";
|
|
2065
2468
|
if (account) {
|
|
2066
2469
|
const target = (await loadConfig()).accounts.find((entry) => entry.accountId === account || entry.label === account);
|
|
2067
2470
|
if (!target) throw new Error(`Account "${account}" not found. Use 'cdx login' to add it.`);
|
|
@@ -2076,7 +2479,7 @@ const registerReloginCommand = (program) => {
|
|
|
2076
2479
|
}
|
|
2077
2480
|
else secureStoreState = " [no secure store entry]";
|
|
2078
2481
|
process.stdout.write(`Current token status for ${displayName}: ${expiryState}${secureStoreState}\n`);
|
|
2079
|
-
const result = await performRefresh(target.accountId, target.label);
|
|
2482
|
+
const result = await performRefresh(target.accountId, target.label, { authFlow });
|
|
2080
2483
|
if (!result) {
|
|
2081
2484
|
process.stderr.write("Re-login failed.\n");
|
|
2082
2485
|
process.exit(1);
|
|
@@ -2085,7 +2488,7 @@ const registerReloginCommand = (program) => {
|
|
|
2085
2488
|
if (authResult) writeUpdatedAuthSummary(authResult);
|
|
2086
2489
|
return;
|
|
2087
2490
|
}
|
|
2088
|
-
await handleReloginAccount();
|
|
2491
|
+
await handleReloginAccount({ authFlow });
|
|
2089
2492
|
} catch (error) {
|
|
2090
2493
|
exitWithCommandError(error);
|
|
2091
2494
|
}
|
|
@@ -2405,6 +2808,66 @@ const getCompletionParseArgs = (argv) => {
|
|
|
2405
2808
|
if (separatorIndex === -1 || separatorIndex <= completeIndex) return null;
|
|
2406
2809
|
return argv.slice(separatorIndex + 1);
|
|
2407
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
|
+
};
|
|
2408
2871
|
const getMacOSKeychainPromptWarning = (selection, platform = process.platform, backendId = null) => {
|
|
2409
2872
|
if (platform !== "darwin") return null;
|
|
2410
2873
|
if (selection === "legacy-keychain") return "⚠ macOS keychain is using the legacy security CLI backend. Touch ID may not be offered for keychain prompts.";
|
|
@@ -2450,6 +2913,7 @@ const createProgram = (deps = {}) => {
|
|
|
2450
2913
|
const main = async () => {
|
|
2451
2914
|
const program = createProgram();
|
|
2452
2915
|
const completion = tab(program);
|
|
2916
|
+
configureTabCompletion(completion);
|
|
2453
2917
|
const completionArgs = getCompletionParseArgs(process.argv);
|
|
2454
2918
|
if (completionArgs) {
|
|
2455
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
|
}
|