@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.
Files changed (47) hide show
  1. package/README.md +41 -27
  2. package/dist/account-lock.js +112 -0
  3. package/dist/accounts.js +100 -0
  4. package/dist/cli-info.js +2 -2
  5. package/dist/commands.js +27 -102
  6. package/dist/controller.js +3 -0
  7. package/dist/credential-commands.js +40 -14
  8. package/dist/credential-safety.js +2 -1
  9. package/dist/external-browser.js +53 -0
  10. package/dist/oauth-http.js +114 -0
  11. package/dist/openai-account-command.js +124 -0
  12. package/dist/openai-account.js +91 -0
  13. package/dist/openai-oauth-callback.js +186 -0
  14. package/dist/openai-oauth-tokens.js +65 -0
  15. package/dist/openai-oauth.js +203 -0
  16. package/dist/permission-command.js +107 -0
  17. package/dist/permissions.js +113 -0
  18. package/dist/provider-commands.js +22 -62
  19. package/dist/provider-label.js +10 -0
  20. package/dist/providers/anthropic.js +1 -1
  21. package/dist/providers/index.js +2 -1
  22. package/dist/providers/ollama.js +1 -1
  23. package/dist/providers/openai-codex.js +114 -0
  24. package/dist/providers/openai-stream.js +13 -9
  25. package/dist/providers/openai-wire.js +4 -4
  26. package/dist/providers/openai.js +2 -2
  27. package/dist/providers/sse.js +1 -1
  28. package/dist/settings-command.js +13 -6
  29. package/dist/tools/shell.js +1 -1
  30. package/dist/transcript.js +0 -3
  31. package/dist/tui/app-workflows.js +17 -17
  32. package/dist/tui/app.js +6 -11
  33. package/dist/tui/approve.js +6 -34
  34. package/dist/tui/blocks.js +1 -3
  35. package/dist/tui/complete.js +4 -4
  36. package/dist/tui/components/menu.js +4 -3
  37. package/dist/tui/components/misc.js +0 -6
  38. package/dist/tui/components/status.js +6 -7
  39. package/dist/tui/feedback.js +11 -9
  40. package/dist/tui/help.js +29 -0
  41. package/dist/tui/modal.js +22 -11
  42. package/dist/tui/overlay.js +15 -4
  43. package/dist/tui/picker.js +1 -1
  44. package/dist/tui/session-view.js +12 -6
  45. package/dist/version.js +14 -0
  46. package/docs/assets/brand/jeco-256.png +0 -0
  47. package/package.json +5 -1
@@ -1,7 +1,9 @@
1
- // Credential command flows shared by setup and provider selection.
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: "credential available for this session", tone: "info" });
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: `credential saved · ${storeLabel()}`, tone: "info" });
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 credential · ${error.message}`, tone: "error" });
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("credential", "values are never shown", session.palette),
57
+ title: heading("authentication", "secrets are never shown", session.palette),
57
58
  options: PROVIDERS.map((provider) => ({
58
- label: provider.keyVar,
59
- 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(),
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
- const name = provider.keyVar;
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 · update it outside jecode and restart`,
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 || action === actions.length - 1)
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 ? "saved credential removed" : "no saved credential to remove",
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 forget: ${error.message}`, tone: "error" });
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
+ }