@giovannijecha/jecode 0.1.8 → 0.2.0
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 +41 -27
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +27 -102
- package/dist/controller.js +3 -0
- package/dist/credential-commands.js +40 -14
- package/dist/credential-safety.js +2 -1
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +91 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/permission-command.js +107 -0
- package/dist/permissions.js +113 -0
- package/dist/provider-commands.js +22 -62
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +114 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -4
- package/dist/providers/openai.js +2 -2
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +13 -6
- package/dist/tools/shell.js +1 -1
- package/dist/transcript.js +0 -3
- package/dist/tui/app-workflows.js +17 -17
- package/dist/tui/app.js +6 -11
- package/dist/tui/approve.js +6 -34
- package/dist/tui/blocks.js +1 -3
- package/dist/tui/complete.js +4 -4
- package/dist/tui/components/menu.js +4 -3
- package/dist/tui/components/misc.js +0 -6
- package/dist/tui/components/status.js +6 -7
- package/dist/tui/feedback.js +11 -9
- package/dist/tui/help.js +29 -0
- package/dist/tui/modal.js +22 -11
- package/dist/tui/overlay.js +15 -4
- package/dist/tui/picker.js +1 -1
- package/dist/tui/session-view.js +12 -6
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Credential command flows shared by
|
|
1
|
+
// Credential command flows shared by settings and provider selection.
|
|
2
2
|
import { heading } from "./tui/picker.js";
|
|
3
3
|
import { EMPTY } from "./tui/editor.js";
|
|
4
4
|
import { PROVIDERS } from "./providers/index.js";
|
|
5
|
+
import { openAIAccountHint } from "./openai-account.js";
|
|
6
|
+
import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
|
|
5
7
|
import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
6
8
|
/** Ask for a key, then ask separately whether it may be written to disk. */
|
|
7
9
|
export async function askForKey(name, host, pal) {
|
|
@@ -31,21 +33,20 @@ export async function askForKey(name, host, pal) {
|
|
|
31
33
|
});
|
|
32
34
|
if (index === 0) {
|
|
33
35
|
hold(name, value);
|
|
34
|
-
host.emit({ kind: "notice", text: "
|
|
36
|
+
host.emit({ kind: "notice", text: "API key ready · this session", tone: "info" });
|
|
35
37
|
return true;
|
|
36
38
|
}
|
|
37
39
|
if (index === 1) {
|
|
38
40
|
try {
|
|
39
41
|
await keep(name, value);
|
|
40
|
-
host.emit({ kind: "notice", text:
|
|
42
|
+
host.emit({ kind: "notice", text: "API key saved", tone: "info" });
|
|
41
43
|
return true;
|
|
42
44
|
}
|
|
43
45
|
catch (error) {
|
|
44
|
-
host.emit({ kind: "notice", text: `could not save
|
|
46
|
+
host.emit({ kind: "notice", text: `could not save API key · ${error.message}`, tone: "error" });
|
|
45
47
|
return false;
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
|
-
host.emit({ kind: "notice", text: "credential discarded", tone: "warn" });
|
|
49
50
|
return false;
|
|
50
51
|
}
|
|
51
52
|
export async function credentialsCommand(session, host) {
|
|
@@ -53,10 +54,12 @@ export async function credentialsCommand(session, host) {
|
|
|
53
54
|
if (choose === undefined)
|
|
54
55
|
return;
|
|
55
56
|
const index = await choose({
|
|
56
|
-
title: heading("
|
|
57
|
+
title: heading("authentication", "secrets are never shown", session.palette),
|
|
57
58
|
options: PROVIDERS.map((provider) => ({
|
|
58
|
-
label: provider.keyVar
|
|
59
|
-
hint:
|
|
59
|
+
label: provider.auth.kind === "api-key" ? provider.auth.keyVar : `${provider.auth.label} account`,
|
|
60
|
+
hint: provider.auth.kind === "api-key"
|
|
61
|
+
? credentialSource(provider.auth.keyVar) ?? "missing"
|
|
62
|
+
: openAIAccountHint(),
|
|
60
63
|
})),
|
|
61
64
|
index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
|
|
62
65
|
});
|
|
@@ -65,12 +68,16 @@ export async function credentialsCommand(session, host) {
|
|
|
65
68
|
const provider = PROVIDERS[index];
|
|
66
69
|
if (provider === undefined)
|
|
67
70
|
return;
|
|
68
|
-
|
|
71
|
+
if (provider.auth.kind === "oauth") {
|
|
72
|
+
await openAIAccountCommand(session, host);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const name = provider.auth.keyVar;
|
|
69
76
|
const source = credentialSource(name);
|
|
70
77
|
if (source === "environment") {
|
|
71
78
|
host.emit({
|
|
72
79
|
kind: "notice",
|
|
73
|
-
text: `${name} comes from the environment ·
|
|
80
|
+
text: `${name} comes from the environment · restart after changing it`,
|
|
74
81
|
tone: "info",
|
|
75
82
|
});
|
|
76
83
|
if (hasSaved(name))
|
|
@@ -80,14 +87,13 @@ export async function credentialsCommand(session, host) {
|
|
|
80
87
|
const actions = [
|
|
81
88
|
{ label: source === undefined ? "add credential" : "replace credential", key: "r" },
|
|
82
89
|
...(hasSaved(name) ? [{ label: "forget saved copy", hint: storeLabel(), key: "f" }] : []),
|
|
83
|
-
{ label: "close", key: "c" },
|
|
84
90
|
];
|
|
85
91
|
const action = await choose({
|
|
86
92
|
title: heading(name, source ?? "missing", session.palette),
|
|
87
93
|
options: actions,
|
|
88
94
|
index: 0,
|
|
89
95
|
});
|
|
90
|
-
if (action === undefined
|
|
96
|
+
if (action === undefined)
|
|
91
97
|
return;
|
|
92
98
|
if (actions[action]?.key === "f") {
|
|
93
99
|
await forget(name, host);
|
|
@@ -95,6 +101,26 @@ export async function credentialsCommand(session, host) {
|
|
|
95
101
|
}
|
|
96
102
|
await askForKey(name, host, session.palette);
|
|
97
103
|
}
|
|
104
|
+
/** Offer the authentication flow owned by a provider, if that is its blocker. */
|
|
105
|
+
export async function ensureProviderAuthentication(provider, session, host) {
|
|
106
|
+
const blocked = provider.blocked();
|
|
107
|
+
if (blocked === undefined)
|
|
108
|
+
return true;
|
|
109
|
+
if (provider.auth.kind === "oauth") {
|
|
110
|
+
return provider.auth.account === "openai-codex"
|
|
111
|
+
? ensureOpenAIAccount(session, host)
|
|
112
|
+
: false;
|
|
113
|
+
}
|
|
114
|
+
if (!blocked.startsWith(`${provider.auth.keyVar} `)) {
|
|
115
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
await askForKey(provider.auth.keyVar, host, session.palette);
|
|
119
|
+
return provider.blocked() === undefined;
|
|
120
|
+
}
|
|
121
|
+
export function authenticationNeed(provider) {
|
|
122
|
+
return provider.auth.kind === "oauth" ? `${provider.auth.label} sign-in` : "an API key";
|
|
123
|
+
}
|
|
98
124
|
async function offerForget(name, session, host, hint) {
|
|
99
125
|
if (host.choose === undefined)
|
|
100
126
|
return;
|
|
@@ -114,12 +140,12 @@ async function forget(name, host) {
|
|
|
114
140
|
const removed = await forgetSaved(name);
|
|
115
141
|
host.emit({
|
|
116
142
|
kind: "notice",
|
|
117
|
-
text: removed ? "
|
|
143
|
+
text: removed ? "API key removed" : "no saved API key",
|
|
118
144
|
tone: removed ? "info" : "warn",
|
|
119
145
|
});
|
|
120
146
|
}
|
|
121
147
|
catch (error) {
|
|
122
|
-
host.emit({ kind: "notice", text: `could not
|
|
148
|
+
host.emit({ kind: "notice", text: `could not remove API key · ${error.message}`, tone: "error" });
|
|
123
149
|
}
|
|
124
150
|
}
|
|
125
151
|
function chooser(host) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The shell needs a useful process environment, not the application's secrets.
|
|
2
2
|
import { credentialValues } from "./credentials.js";
|
|
3
|
+
import { accountValues } from "./accounts.js";
|
|
3
4
|
const REDACTED = "[credential redacted]";
|
|
4
5
|
const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
|
|
5
6
|
const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
|
|
@@ -60,7 +61,7 @@ function sensitiveEnvironmentName(name) {
|
|
|
60
61
|
COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
|
|
61
62
|
}
|
|
62
63
|
function secrets(source) {
|
|
63
|
-
const values = new Set(credentialValues());
|
|
64
|
+
const values = new Set([...credentialValues(), ...accountValues()]);
|
|
64
65
|
for (const [name, value] of Object.entries(source)) {
|
|
65
66
|
if (value !== undefined && value !== "" && sensitiveEnvironment(name, value))
|
|
66
67
|
values.add(value);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Open one HTTPS URL without involving a shell or interpolating commands.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
export async function openExternal(url) {
|
|
5
|
+
const target = new URL(url);
|
|
6
|
+
if (target.protocol !== "https:")
|
|
7
|
+
throw new Error("only HTTPS links may be opened");
|
|
8
|
+
const command = browserCommand(target.href);
|
|
9
|
+
if (command === undefined)
|
|
10
|
+
return false;
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command.file, command.args, {
|
|
13
|
+
detached: true,
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
child.once("error", () => resolve(false));
|
|
18
|
+
child.once("spawn", () => {
|
|
19
|
+
child.unref();
|
|
20
|
+
resolve(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function browserCommand(url) {
|
|
25
|
+
if (process.platform === "win32") {
|
|
26
|
+
return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
27
|
+
}
|
|
28
|
+
if (process.platform === "darwin")
|
|
29
|
+
return { file: "open", args: [url] };
|
|
30
|
+
if (isWsl())
|
|
31
|
+
return { file: "explorer.exe", args: [url] };
|
|
32
|
+
return { file: "xdg-open", args: [url] };
|
|
33
|
+
}
|
|
34
|
+
export function headlessEnvironment() {
|
|
35
|
+
if (isWsl() || process.env["SSH_CONNECTION"] !== undefined || process.env["SSH_TTY"] !== undefined) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return process.platform === "linux" &&
|
|
39
|
+
process.env["DISPLAY"] === undefined &&
|
|
40
|
+
process.env["WAYLAND_DISPLAY"] === undefined;
|
|
41
|
+
}
|
|
42
|
+
function isWsl() {
|
|
43
|
+
if (process.platform !== "linux")
|
|
44
|
+
return false;
|
|
45
|
+
if (process.env["WSL_DISTRO_NAME"] !== undefined || process.env["WSL_INTEROP"] !== undefined)
|
|
46
|
+
return true;
|
|
47
|
+
try {
|
|
48
|
+
return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// A narrow HTTP boundary for OAuth authority requests.
|
|
2
|
+
//
|
|
3
|
+
// These requests never redirect, never retry, and never retain an unbounded
|
|
4
|
+
// response. Authorization codes and refresh tokens must not leak into errors.
|
|
5
|
+
const AUTH_ORIGIN = "https://auth.openai.com";
|
|
6
|
+
const TIMEOUT_MS = 15_000;
|
|
7
|
+
const MAX_BODY_CHARS = 64_000;
|
|
8
|
+
export async function oauthRequest(url, body, signal, accepted = [200]) {
|
|
9
|
+
const target = new URL(url);
|
|
10
|
+
if (target.origin !== AUTH_ORIGIN || target.username !== "" || target.password !== "") {
|
|
11
|
+
throw new Error("OAuth request target is not allowed");
|
|
12
|
+
}
|
|
13
|
+
const timeout = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => timeout.abort(new Error("OpenAI sign-in timed out")), TIMEOUT_MS);
|
|
15
|
+
const combined = signal === undefined
|
|
16
|
+
? timeout.signal
|
|
17
|
+
: AbortSignal.any([signal, timeout.signal]);
|
|
18
|
+
const secrets = bodySecrets(body);
|
|
19
|
+
let response;
|
|
20
|
+
let text;
|
|
21
|
+
try {
|
|
22
|
+
response = await fetch(target, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": body.contentType, accept: "application/json" },
|
|
25
|
+
body: body.contentType === "application/json"
|
|
26
|
+
? JSON.stringify(body.value)
|
|
27
|
+
: body.value.toString(),
|
|
28
|
+
cache: "no-store",
|
|
29
|
+
redirect: "manual",
|
|
30
|
+
signal: combined,
|
|
31
|
+
});
|
|
32
|
+
if (response.status >= 300 && response.status < 400) {
|
|
33
|
+
await response.body?.cancel().catch(() => undefined);
|
|
34
|
+
throw new Error(`OpenAI sign-in redirect rejected (${response.status})`);
|
|
35
|
+
}
|
|
36
|
+
text = await boundedText(response);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (timeout.signal.aborted)
|
|
40
|
+
throw timeout.signal.reason;
|
|
41
|
+
if (signal?.aborted === true)
|
|
42
|
+
throw abortReason(signal);
|
|
43
|
+
if (error instanceof Error && error.message.startsWith("OpenAI sign-in"))
|
|
44
|
+
throw error;
|
|
45
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
46
|
+
throw new Error(`OpenAI sign-in network error: ${detail}`);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
const value = parse(text);
|
|
52
|
+
if (!accepted.includes(response.status)) {
|
|
53
|
+
throw new Error(`OpenAI sign-in failed (${response.status})${errorDetail(value, secrets)}`);
|
|
54
|
+
}
|
|
55
|
+
return { status: response.status, value };
|
|
56
|
+
}
|
|
57
|
+
async function boundedText(response) {
|
|
58
|
+
if (response.body === null)
|
|
59
|
+
return "";
|
|
60
|
+
const reader = response.body.getReader();
|
|
61
|
+
const decoder = new TextDecoder();
|
|
62
|
+
let text = "";
|
|
63
|
+
try {
|
|
64
|
+
while (true) {
|
|
65
|
+
const { done, value } = await reader.read();
|
|
66
|
+
if (done)
|
|
67
|
+
return text + decoder.decode();
|
|
68
|
+
text += decoder.decode(value, { stream: true });
|
|
69
|
+
if (text.length > MAX_BODY_CHARS) {
|
|
70
|
+
await reader.cancel().catch(() => undefined);
|
|
71
|
+
throw new Error("OpenAI sign-in returned too much data");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function parse(text) {
|
|
80
|
+
if (text.trim() === "")
|
|
81
|
+
return {};
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new Error("OpenAI sign-in returned an invalid response");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function errorDetail(value, secrets) {
|
|
90
|
+
if (!record(value))
|
|
91
|
+
return "";
|
|
92
|
+
const detail = [value["error_description"], value["message"], value["error"]]
|
|
93
|
+
.find((entry) => typeof entry === "string");
|
|
94
|
+
if (typeof detail !== "string" || detail.trim() === "")
|
|
95
|
+
return "";
|
|
96
|
+
let safe = detail.replace(/[\r\n]+/g, " ");
|
|
97
|
+
for (const secret of secrets)
|
|
98
|
+
safe = safe.replaceAll(secret, "[credential redacted]");
|
|
99
|
+
return ` · ${safe.slice(0, 300)}`;
|
|
100
|
+
}
|
|
101
|
+
function bodySecrets(body) {
|
|
102
|
+
const values = body.contentType === "application/x-www-form-urlencoded"
|
|
103
|
+
? [...body.value.values()]
|
|
104
|
+
: record(body.value)
|
|
105
|
+
? Object.values(body.value).filter((value) => typeof value === "string")
|
|
106
|
+
: [];
|
|
107
|
+
return values.filter((value) => value.length >= 8);
|
|
108
|
+
}
|
|
109
|
+
function abortReason(signal) {
|
|
110
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
111
|
+
}
|
|
112
|
+
function record(value) {
|
|
113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
114
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Interactive ChatGPT account management for the OpenAI Codex provider.
|
|
2
|
+
import { openAICodexAccount } from "./accounts.js";
|
|
3
|
+
import { headlessEnvironment, openExternal } from "./external-browser.js";
|
|
4
|
+
import { openAIAccountHint, removeOpenAIAccount, saveOpenAIAccount, } from "./openai-account.js";
|
|
5
|
+
import { beginBrowserLogin, beginDeviceLogin, } from "./openai-oauth.js";
|
|
6
|
+
import { heading } from "./tui/picker.js";
|
|
7
|
+
const DEFAULT_DEPENDENCIES = {
|
|
8
|
+
beginBrowser: beginBrowserLogin,
|
|
9
|
+
beginDevice: beginDeviceLogin,
|
|
10
|
+
openUrl: openExternal,
|
|
11
|
+
headless: headlessEnvironment,
|
|
12
|
+
};
|
|
13
|
+
export async function openAIAccountCommand(session, host, dependencies = DEFAULT_DEPENDENCIES) {
|
|
14
|
+
if (host.choose === undefined)
|
|
15
|
+
return false;
|
|
16
|
+
const connected = openAICodexAccount() !== undefined;
|
|
17
|
+
const actions = connected
|
|
18
|
+
? [
|
|
19
|
+
{ label: "reconnect ChatGPT", hint: "replace the current sign-in", key: "r" },
|
|
20
|
+
{ label: "sign out", hint: "remove the saved account", key: "s" },
|
|
21
|
+
]
|
|
22
|
+
: [{ label: "connect ChatGPT", hint: "sign in with OpenAI", key: "c" }];
|
|
23
|
+
const action = await host.choose({
|
|
24
|
+
title: heading("ChatGPT", openAIAccountHint(), session.palette),
|
|
25
|
+
options: actions,
|
|
26
|
+
index: 0,
|
|
27
|
+
});
|
|
28
|
+
if (action === undefined)
|
|
29
|
+
return false;
|
|
30
|
+
if (connected && actions[action]?.key === "s") {
|
|
31
|
+
const result = await removeOpenAIAccount(host.signal);
|
|
32
|
+
host.emit({
|
|
33
|
+
kind: "notice",
|
|
34
|
+
text: result.revokeFailed ? "ChatGPT disconnected · remote sign-out could not be confirmed" : "ChatGPT disconnected",
|
|
35
|
+
tone: result.revokeFailed ? "warn" : "info",
|
|
36
|
+
});
|
|
37
|
+
return result.removed;
|
|
38
|
+
}
|
|
39
|
+
return connectOpenAIAccount(session, host, dependencies);
|
|
40
|
+
}
|
|
41
|
+
export async function ensureOpenAIAccount(session, host, dependencies = DEFAULT_DEPENDENCIES) {
|
|
42
|
+
if (openAICodexAccount() !== undefined)
|
|
43
|
+
return true;
|
|
44
|
+
return connectOpenAIAccount(session, host, dependencies);
|
|
45
|
+
}
|
|
46
|
+
async function connectOpenAIAccount(session, host, dependencies) {
|
|
47
|
+
if (host.choose === undefined)
|
|
48
|
+
return false;
|
|
49
|
+
const methods = [
|
|
50
|
+
{ label: "sign in with browser", hint: "desktop terminal", key: "b" },
|
|
51
|
+
{ label: "sign in with device code", hint: "WSL, SSH, or headless", key: "d" },
|
|
52
|
+
];
|
|
53
|
+
const choice = await host.choose({
|
|
54
|
+
title: heading("connect ChatGPT", "OpenAI OAuth", session.palette),
|
|
55
|
+
description: "Choose the flow for this terminal. Your password stays on OpenAI's website.",
|
|
56
|
+
options: methods,
|
|
57
|
+
index: dependencies.headless() ? 1 : 0,
|
|
58
|
+
});
|
|
59
|
+
if (choice === undefined)
|
|
60
|
+
return false;
|
|
61
|
+
host.status?.("Starting ChatGPT sign-in");
|
|
62
|
+
let login;
|
|
63
|
+
try {
|
|
64
|
+
login = choice === 1
|
|
65
|
+
? await dependencies.beginDevice(host.signal)
|
|
66
|
+
: await dependencies.beginBrowser();
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
host.status?.(undefined);
|
|
70
|
+
}
|
|
71
|
+
return waitForLogin(login, session, host, dependencies.openUrl);
|
|
72
|
+
}
|
|
73
|
+
async function waitForLogin(login, session, host, openUrl) {
|
|
74
|
+
if (host.choose === undefined)
|
|
75
|
+
return false;
|
|
76
|
+
const local = new AbortController();
|
|
77
|
+
const signal = host.signal === undefined
|
|
78
|
+
? local.signal
|
|
79
|
+
: AbortSignal.any([host.signal, local.signal]);
|
|
80
|
+
const completion = login.complete(signal).then((account) => ({ kind: "account", account }), (error) => ({ kind: "error", error: error }));
|
|
81
|
+
await openUrl(login.url);
|
|
82
|
+
try {
|
|
83
|
+
while (true) {
|
|
84
|
+
const selection = host.choose(waitingPicker(login, session));
|
|
85
|
+
const outcome = await Promise.race([
|
|
86
|
+
completion,
|
|
87
|
+
selection.then((index) => ({ kind: "selection", index })),
|
|
88
|
+
]);
|
|
89
|
+
if (outcome.kind === "account") {
|
|
90
|
+
host.dismiss?.();
|
|
91
|
+
await saveOpenAIAccount(outcome.account, host.signal);
|
|
92
|
+
host.emit({ kind: "notice", text: "ChatGPT connected", tone: "info" });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
if (outcome.kind === "error") {
|
|
96
|
+
host.dismiss?.();
|
|
97
|
+
throw outcome.error;
|
|
98
|
+
}
|
|
99
|
+
if (outcome.index === 0) {
|
|
100
|
+
await openUrl(login.url);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
local.abort(new Error("ChatGPT sign-in cancelled"));
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await login.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function waitingPicker(login, session) {
|
|
112
|
+
const detail = login.code === undefined
|
|
113
|
+
? "Finish sign-in in your browser. Jecode will continue automatically."
|
|
114
|
+
: `Enter ${login.code} on OpenAI's device page. Jecode will continue automatically.`;
|
|
115
|
+
return {
|
|
116
|
+
title: heading("ChatGPT sign-in", login.code ?? "waiting for browser", session.palette),
|
|
117
|
+
description: detail,
|
|
118
|
+
options: [
|
|
119
|
+
{ label: "open browser again", key: "o" },
|
|
120
|
+
{ label: "cancel sign-in", key: "c" },
|
|
121
|
+
],
|
|
122
|
+
index: 0,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The live ChatGPT account: persistence, proactive refresh, and logout.
|
|
2
|
+
import { openAICodexAccount, updateOpenAICodexAccount } from "./accounts.js";
|
|
3
|
+
import { refreshOpenAITokens, revokeOpenAITokens } from "./openai-oauth.js";
|
|
4
|
+
const REFRESH_EARLY_MS = 5 * 60_000;
|
|
5
|
+
let refreshing;
|
|
6
|
+
export function openAIAccountHint() {
|
|
7
|
+
const account = openAICodexAccount();
|
|
8
|
+
if (account === undefined)
|
|
9
|
+
return "not connected";
|
|
10
|
+
const identity = account.email ?? "connected";
|
|
11
|
+
return account.plan === undefined ? identity : `${identity} · ${account.plan}`;
|
|
12
|
+
}
|
|
13
|
+
export async function saveOpenAIAccount(account, signal) {
|
|
14
|
+
await updateOpenAICodexAccount(async () => account, signal);
|
|
15
|
+
}
|
|
16
|
+
export async function removeOpenAIAccount(signal) {
|
|
17
|
+
let account;
|
|
18
|
+
await updateOpenAICodexAccount(async (current) => {
|
|
19
|
+
account = current;
|
|
20
|
+
return undefined;
|
|
21
|
+
}, signal);
|
|
22
|
+
if (account === undefined)
|
|
23
|
+
return { removed: false, revokeFailed: false };
|
|
24
|
+
let revokeFailed = false;
|
|
25
|
+
try {
|
|
26
|
+
await revokeOpenAITokens(account, signal);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
revokeFailed = true;
|
|
30
|
+
}
|
|
31
|
+
return { removed: true, revokeFailed };
|
|
32
|
+
}
|
|
33
|
+
export async function openAIAuthorization(forceToken, signal, onStatus) {
|
|
34
|
+
const account = openAICodexAccount();
|
|
35
|
+
if (account === undefined)
|
|
36
|
+
throw new Error("ChatGPT account is not connected");
|
|
37
|
+
const mustRefresh = forceToken !== undefined || expiresSoon(account);
|
|
38
|
+
const ready = mustRefresh
|
|
39
|
+
? await refreshAccount(forceToken, signal, onStatus)
|
|
40
|
+
: account;
|
|
41
|
+
return { accessToken: ready.accessToken, accountId: ready.accountId };
|
|
42
|
+
}
|
|
43
|
+
async function refreshAccount(forceToken, signal, onStatus) {
|
|
44
|
+
if (refreshing !== undefined)
|
|
45
|
+
return abortable(refreshing, signal);
|
|
46
|
+
onStatus?.("Refreshing ChatGPT sign-in");
|
|
47
|
+
const task = updateOpenAICodexAccount(async (current) => {
|
|
48
|
+
if (current === undefined)
|
|
49
|
+
throw new Error("ChatGPT account is not connected");
|
|
50
|
+
if (forceToken !== undefined && current.accessToken !== forceToken)
|
|
51
|
+
return current;
|
|
52
|
+
if (forceToken === undefined && !expiresSoon(current))
|
|
53
|
+
return current;
|
|
54
|
+
return refreshOpenAITokens(current, signal);
|
|
55
|
+
}, signal).then((account) => {
|
|
56
|
+
if (account === undefined)
|
|
57
|
+
throw new Error("ChatGPT account is not connected");
|
|
58
|
+
return account;
|
|
59
|
+
});
|
|
60
|
+
refreshing = task;
|
|
61
|
+
try {
|
|
62
|
+
return await abortable(task, signal);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (refreshing === task)
|
|
66
|
+
refreshing = undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function expiresSoon(account) {
|
|
70
|
+
return account.expiresAt - Date.now() <= REFRESH_EARLY_MS;
|
|
71
|
+
}
|
|
72
|
+
function abortable(promise, signal) {
|
|
73
|
+
if (signal === undefined)
|
|
74
|
+
return promise;
|
|
75
|
+
if (signal.aborted)
|
|
76
|
+
return Promise.reject(abortReason(signal));
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const onAbort = () => reject(abortReason(signal));
|
|
79
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
80
|
+
promise.then((value) => {
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
resolve(value);
|
|
83
|
+
}, (error) => {
|
|
84
|
+
signal.removeEventListener("abort", onAbort);
|
|
85
|
+
reject(error);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function abortReason(signal) {
|
|
90
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
91
|
+
}
|