@giovannijecha/jecode 0.1.9 → 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 CHANGED
@@ -25,7 +25,7 @@
25
25
  <a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
26
26
  </p>
27
27
 
28
- > Jecode is an early 0.1.x release. The core loop is usable today; commands and
28
+ > Jecode is an early 0.2.x release. The core loop is usable today; commands and
29
29
  > terminal interactions may still evolve before 1.0.
30
30
 
31
31
  ## Why Jecode
@@ -36,8 +36,8 @@
36
36
  diffs, approvals, reasoning, and status all share one full-screen TUI.
37
37
  - **Permission-aware.** Reads stay transparent; dangerous actions ask first.
38
38
  Session approvals can be reviewed and revoked.
39
- - **Provider-neutral.** Use Anthropic, OpenAI, or a local/remote Ollama server
40
- without changing the workflow.
39
+ - **Provider-neutral.** Use Anthropic or OpenAI API keys, a ChatGPT account, or
40
+ a local/remote Ollama server without changing the workflow.
41
41
  - **Lean by construction.** Jecode installs as plain JavaScript, runs on
42
42
  Node.js 22.18+ (22.x) or Node.js 24+, executes no installation scripts, and
43
43
  has zero third-party runtime dependencies.
@@ -145,15 +145,27 @@ published package runs no compilation or installation scripts.
145
145
  ## First session
146
146
 
147
147
  Jecode opens on an empty composer instead of forcing a setup wizard. Type
148
- **/settings** when you are ready to choose a provider, select a model, and add a
149
- credential. A credential can remain in memory for the current session or be
150
- saved explicitly under **~/.jecode**; it is never stored in the workspace.
148
+ **/settings** when you are ready to choose a provider, select a model, and
149
+ configure authentication. An API key can remain in memory for the current
150
+ session or be saved explicitly under **~/.jecode**; it is never stored in the
151
+ workspace.
151
152
 
152
- | Provider | Credential | Notes |
153
+ | Provider ID | Authentication | Notes |
153
154
  |---|---|---|
154
- | Anthropic | ANTHROPIC_API_KEY | Cloud |
155
- | OpenAI | OPENAI_API_KEY | Cloud |
156
- | Ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
155
+ | anthropic | ANTHROPIC_API_KEY | Anthropic API |
156
+ | openai | OPENAI_API_KEY | OpenAI API |
157
+ | openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
158
+ | ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
159
+
160
+ Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
161
+ Jecode offers a local browser callback and a device-code flow; WSL and remote
162
+ terminals default to the device code. The connection is saved only after the
163
+ flow completes. Availability and usage limits are determined by the ChatGPT
164
+ account and plan, not by OpenAI API credits. This integration is experimental
165
+ and is not an endorsement of Jecode by OpenAI.
166
+
167
+ Anthropic remains API-key only. Jecode does not reuse a Claude consumer
168
+ subscription or copy credentials from another client.
157
169
 
158
170
  Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
159
171
  in **/settings**. Existing users with an Ollama API key automatically use
@@ -166,11 +178,11 @@ Type **/** to open searchable command completion inside the composer.
166
178
 
167
179
  | Command | What it does |
168
180
  |---|---|
169
- | /settings | Manage provider, connection, model, limits, motion, and credentials |
181
+ | /settings | Manage provider, connection, model, limits, motion, and authentication |
170
182
  | /effort | Change and save reasoning effort directly |
171
183
  | /providers | Switch the provider for the next turn |
172
184
  | /models | Search the live model catalogue |
173
- | /credentials | Add, replace, inspect, or forget saved credentials |
185
+ | /credentials | Manage API keys and the connected ChatGPT account |
174
186
  | /permissions | Manage session tool access and remembered approvals |
175
187
  | /new | Start a clean conversation and reset session tool permissions |
176
188
  | /export | Save a timestamped Markdown transcript in the launch directory |
@@ -207,14 +219,16 @@ settings, built-in defaults.
207
219
  | --ollama-host | OLLAMA_HOST | Cloud with an Ollama key, local without one |
208
220
  | --root | — | Current directory |
209
221
  | --effort | JECODE_EFFORT | high |
210
- | --max-tokens | JECODE_MAX_TOKENS | 64000 |
222
+ | --max-tokens | JECODE_MAX_TOKENS | 64000; not sent by openai-codex |
211
223
  | --max-steps | JECODE_MAX_STEPS | 40 |
212
224
  | --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
213
225
  | --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
214
226
 
215
227
  Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
216
- credentials live in **~/.jecode/credentials.json** with owner-only permissions
217
- where the operating system supports them. Environment credentials always win.
228
+ API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
229
+ separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
230
+ permissions where the operating system supports them. Environment API keys
231
+ always win.
218
232
 
219
233
  Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
220
234
  terminals and pipelines that disable colour.
@@ -244,6 +258,9 @@ untrusted data.
244
258
  commands receive no credential-like environment variables, and recognized
245
259
  credential values are redacted before tool output reaches the model, screen,
246
260
  history, or export.
261
+ - ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
262
+ flow. Refresh-token rotation is serialized across Jecode processes; OAuth
263
+ tokens are withheld and redacted like API keys.
247
264
  - Terminal control characters are neutralized before rendering.
248
265
  - Remote Ollama endpoints require HTTPS. Provider HTTP redirects are rejected
249
266
  rather than followed across an implicit trust boundary.
@@ -0,0 +1,112 @@
1
+ // A tiny cross-process lock for rotating OAuth credentials.
2
+ //
3
+ // Refresh tokens may rotate after one use. Two Jecode processes refreshing
4
+ // the same account concurrently would make one of them persist a dead token,
5
+ // so account mutations serialize through an atomic lock directory.
6
+ import { mkdir, open, readFile, rename, rmdir, stat, unlink } from "node:fs/promises";
7
+ import { randomUUID } from "node:crypto";
8
+ import * as path from "node:path";
9
+ const WAIT_MS = 50;
10
+ const WAIT_LIMIT_MS = 20_000;
11
+ const STALE_MS = 60_000;
12
+ export async function withAccountLock(accountFile, body, signal) {
13
+ throwIfAborted(signal);
14
+ const directory = `${accountFile}.lock`;
15
+ const token = `${process.pid}:${randomUUID()}`;
16
+ const started = Date.now();
17
+ while (!(await acquire(directory, token))) {
18
+ if (signal?.aborted === true)
19
+ throw abortReason(signal);
20
+ if (Date.now() - started >= WAIT_LIMIT_MS) {
21
+ throw new Error("timed out waiting for the account store");
22
+ }
23
+ await recoverStale(directory);
24
+ await wait(WAIT_MS, signal);
25
+ }
26
+ try {
27
+ throwIfAborted(signal);
28
+ return await body();
29
+ }
30
+ finally {
31
+ await release(directory, token);
32
+ }
33
+ }
34
+ async function acquire(directory, token) {
35
+ let created = false;
36
+ try {
37
+ await mkdir(directory, { mode: 0o700 });
38
+ created = true;
39
+ const owner = await open(path.join(directory, "owner"), "wx", 0o600);
40
+ try {
41
+ await owner.writeFile(token, "utf8");
42
+ await owner.sync();
43
+ }
44
+ finally {
45
+ await owner.close();
46
+ }
47
+ return true;
48
+ }
49
+ catch (error) {
50
+ if (!created && error.code === "EEXIST")
51
+ return false;
52
+ if (created)
53
+ await removeLock(directory);
54
+ throw error;
55
+ }
56
+ }
57
+ async function recoverStale(directory) {
58
+ try {
59
+ const details = await stat(directory);
60
+ if (Date.now() - details.mtimeMs < STALE_MS)
61
+ return;
62
+ const quarantined = `${directory}.${randomUUID()}.stale`;
63
+ await rename(directory, quarantined);
64
+ await unlink(path.join(quarantined, "owner")).catch(() => undefined);
65
+ await rmdir(quarantined).catch(() => undefined);
66
+ }
67
+ catch (error) {
68
+ const code = error.code;
69
+ if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM")
70
+ throw error;
71
+ }
72
+ }
73
+ async function release(directory, token) {
74
+ try {
75
+ const owner = path.join(directory, "owner");
76
+ if ((await readFile(owner, "utf8")) !== token)
77
+ return;
78
+ await unlink(owner);
79
+ await rmdir(directory);
80
+ }
81
+ catch {
82
+ // A recovered stale lock or an already-removed directory is no longer ours.
83
+ }
84
+ }
85
+ async function removeLock(directory) {
86
+ await unlink(path.join(directory, "owner")).catch(() => undefined);
87
+ await rmdir(directory).catch(() => undefined);
88
+ }
89
+ function wait(ms, signal) {
90
+ return new Promise((resolve, reject) => {
91
+ if (signal?.aborted === true) {
92
+ reject(abortReason(signal));
93
+ return;
94
+ }
95
+ const timer = setTimeout(() => {
96
+ signal?.removeEventListener("abort", onAbort);
97
+ resolve();
98
+ }, ms);
99
+ const onAbort = () => {
100
+ clearTimeout(timer);
101
+ reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
102
+ };
103
+ signal?.addEventListener("abort", onAbort, { once: true });
104
+ });
105
+ }
106
+ function abortReason(signal) {
107
+ return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
108
+ }
109
+ function throwIfAborted(signal) {
110
+ if (signal?.aborted === true)
111
+ throw abortReason(signal);
112
+ }
@@ -0,0 +1,100 @@
1
+ // OAuth accounts persisted under ~/.jecode, apart from API keys.
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import { readFileSync } from "node:fs";
4
+ import * as path from "node:path";
5
+ import { atomicWrite } from "./atomic.js";
6
+ import { withAccountLock } from "./account-lock.js";
7
+ import { userDataLabel, userDataPath } from "./user-data.js";
8
+ let cached;
9
+ export function openAICodexAccount() {
10
+ const account = store().accounts["openai-codex"];
11
+ return account === undefined ? undefined : { ...account };
12
+ }
13
+ export function accountValues() {
14
+ const account = store().accounts["openai-codex"];
15
+ return account === undefined ? [] : [account.accessToken, account.refreshToken];
16
+ }
17
+ export function accountsPath() {
18
+ return userDataPath("accounts.json");
19
+ }
20
+ export function accountsLabel() {
21
+ return userDataLabel("accounts.json");
22
+ }
23
+ export async function updateOpenAICodexAccount(change, signal) {
24
+ const file = accountsPath();
25
+ const directory = path.dirname(file);
26
+ await mkdir(directory, { recursive: true, mode: 0o700 });
27
+ if (process.platform !== "win32")
28
+ await chmod(directory, 0o700);
29
+ return withAccountLock(file, async () => {
30
+ const current = readStore(file);
31
+ const next = await change(current.accounts["openai-codex"]);
32
+ const accounts = { ...current.accounts };
33
+ if (next === undefined)
34
+ delete accounts["openai-codex"];
35
+ else
36
+ accounts["openai-codex"] = { ...next };
37
+ const updated = { version: 1, accounts };
38
+ await atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
39
+ cached = updated;
40
+ return next === undefined ? undefined : { ...next };
41
+ }, signal);
42
+ }
43
+ export function reloadAccounts() {
44
+ cached = undefined;
45
+ }
46
+ function store() {
47
+ if (cached === undefined)
48
+ cached = readStore(accountsPath());
49
+ return cached;
50
+ }
51
+ function readStore(file) {
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
54
+ return normalize(parsed);
55
+ }
56
+ catch {
57
+ return { version: 1, accounts: {} };
58
+ }
59
+ }
60
+ function normalize(value) {
61
+ if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
62
+ return { version: 1, accounts: {} };
63
+ }
64
+ const account = normalizeOpenAI(value["accounts"]["openai-codex"]);
65
+ return {
66
+ version: 1,
67
+ accounts: account === undefined ? {} : { "openai-codex": account },
68
+ };
69
+ }
70
+ function normalizeOpenAI(value) {
71
+ if (!record(value))
72
+ return undefined;
73
+ const accessToken = nonempty(value["accessToken"]);
74
+ const refreshToken = nonempty(value["refreshToken"]);
75
+ const accountId = nonempty(value["accountId"]);
76
+ const expiresAt = value["expiresAt"];
77
+ if (accessToken === undefined ||
78
+ refreshToken === undefined ||
79
+ accountId === undefined ||
80
+ typeof expiresAt !== "number" ||
81
+ !Number.isSafeInteger(expiresAt) ||
82
+ expiresAt <= 0)
83
+ return undefined;
84
+ const email = nonempty(value["email"]);
85
+ const plan = nonempty(value["plan"]);
86
+ return {
87
+ accessToken,
88
+ refreshToken,
89
+ expiresAt,
90
+ accountId,
91
+ ...(email === undefined ? {} : { email }),
92
+ ...(plan === undefined ? {} : { plan }),
93
+ };
94
+ }
95
+ function nonempty(value) {
96
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
97
+ }
98
+ function record(value) {
99
+ return typeof value === "object" && value !== null && !Array.isArray(value);
100
+ }
package/dist/cli-info.js CHANGED
@@ -8,10 +8,10 @@ Usage:
8
8
 
9
9
  Options:
10
10
  --root <path> workspace root (default: current directory)
11
- --provider <id> anthropic, openai, or ollama
11
+ --provider <id> anthropic, openai, openai-codex, or ollama
12
12
  --model <id> model for the selected provider
13
13
  --ollama-host <url> Ollama Cloud, local, or custom endpoint
14
- --effort <level> low, medium, high, or max
14
+ --effort <level> low, medium, high, xhigh, or max
15
15
  --max-tokens <number> output-token ceiling
16
16
  --max-steps <number> tool-loop ceiling
17
17
  --reduced-motion disable animated terminal states
package/dist/commands.js CHANGED
@@ -24,7 +24,7 @@ export const COMMANDS = [
24
24
  { name: "permissions", blurb: "manage session tool access" },
25
25
  { name: "settings", blurb: "change and save jecode defaults" },
26
26
  { name: "effort", blurb: "set the reasoning effort" },
27
- { name: "credentials", blurb: "inspect, replace, or forget API keys" },
27
+ { name: "credentials", blurb: "manage API keys and connected accounts" },
28
28
  { name: "models", blurb: "pick a model, from what the provider offers" },
29
29
  { name: "providers", blurb: "pick a provider" },
30
30
  ];
@@ -29,6 +29,9 @@ export async function runTurn(history, options, events, signal) {
29
29
  onStatus: (status) => events.onStatus?.(status),
30
30
  });
31
31
  const calls = assistant.content.filter(isToolCall);
32
+ if (assistant.content.length === 0) {
33
+ throw new Error(`${options.provider.id} completed without an answer or tool call`);
34
+ }
32
35
  if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
33
36
  throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
34
37
  }
@@ -2,6 +2,8 @@
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) {
@@ -52,10 +54,12 @@ export async function credentialsCommand(session, host) {
52
54
  if (choose === undefined)
53
55
  return;
54
56
  const index = await choose({
55
- title: heading("credential", "values are never shown", session.palette),
57
+ title: heading("authentication", "secrets are never shown", session.palette),
56
58
  options: PROVIDERS.map((provider) => ({
57
- label: provider.keyVar,
58
- hint: credentialSource(provider.keyVar) ?? "missing",
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(),
59
63
  })),
60
64
  index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
61
65
  });
@@ -64,7 +68,11 @@ export async function credentialsCommand(session, host) {
64
68
  const provider = PROVIDERS[index];
65
69
  if (provider === undefined)
66
70
  return;
67
- const name = provider.keyVar;
71
+ if (provider.auth.kind === "oauth") {
72
+ await openAIAccountCommand(session, host);
73
+ return;
74
+ }
75
+ const name = provider.auth.keyVar;
68
76
  const source = credentialSource(name);
69
77
  if (source === "environment") {
70
78
  host.emit({
@@ -93,6 +101,26 @@ export async function credentialsCommand(session, host) {
93
101
  }
94
102
  await askForKey(name, host, session.palette);
95
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
+ }
96
124
  async function offerForget(name, session, host, hint) {
97
125
  if (host.choose === undefined)
98
126
  return;
@@ -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
+ }