@2kw/ai 5.2.0-dev.8 → 5.3.0-dev.5

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Thin fetch wrappers for the auth service (Better Auth) endpoints the CLI
3
+ * needs. Deliberately dependency-free: endpoints are stable REST paths and a
4
+ * full better-auth client would drag react-oriented tooling into the CLI.
5
+ *
6
+ * Failures leave through exactly three exits, and callers branch on which:
7
+ *
8
+ * 1. **Mapped {@link DeviceTokenResult} values** — {@link pollDeviceToken} only.
9
+ * `pending` / `slow_down` / `denied` / `expired` are ordinary device-flow
10
+ * states, so they are returned rather than thrown and the poll loop reads as
11
+ * the state machine it is instead of a try/catch.
12
+ * 2. **{@link AuthServiceError}** — the service answered, and the answer was no.
13
+ * Carries the HTTP `status` and any OAuth `code`. A response came back, so
14
+ * replaying the identical request is usually pointless.
15
+ * 3. **A raw fetch `TypeError`** ("fetch failed") — the transport never
16
+ * completed: DNS, TLS, connection refused. This module neither catches nor
17
+ * wraps it; it propagates untouched, and it is the retry-worthy case.
18
+ *
19
+ * So `err instanceof AuthServiceError` separates "the server rejected us" from
20
+ * "we never reached the server" — the split retry and diagnostics need.
21
+ * `classifyAuthFailure` in errors.ts already covers the transport half.
22
+ */
23
+ /**
24
+ * OAuth client id sent with every device-flow request.
25
+ *
26
+ * Cross-repo coupling: this must equal the auth service's configured app slug
27
+ * (`APP_1_SLUG`, default `backbone` — see compose.yaml), which the service's
28
+ * `validateClient` checks the incoming `client_id` against. Change it on one
29
+ * side only and every login fails with `invalid_client`.
30
+ */
31
+ export const CLI_CLIENT_ID = "backbone";
32
+ /** RFC 8628 grant type used when exchanging a device code for a session. */
33
+ export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
34
+ /**
35
+ * A failed auth-service call. Carries the HTTP status and, when the service
36
+ * supplied one, the machine-readable OAuth error code (e.g. "invalid_client").
37
+ */
38
+ export class AuthServiceError extends Error {
39
+ status;
40
+ code;
41
+ constructor(message, status, code) {
42
+ super(message);
43
+ this.status = status;
44
+ this.code = code;
45
+ this.name = "AuthServiceError";
46
+ }
47
+ }
48
+ const JSON_HEADERS = { "content-type": "application/json" };
49
+ /** Build an absolute endpoint URL, tolerating trailing slashes on authUrl. */
50
+ function api(authUrl, path) {
51
+ return `${authUrl.replace(/\/+$/, "")}/api/auth${path}`;
52
+ }
53
+ function bearer(sessionToken) {
54
+ return { Authorization: `Bearer ${sessionToken}` };
55
+ }
56
+ function str(value) {
57
+ return typeof value === "string" ? value : undefined;
58
+ }
59
+ function num(value) {
60
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
61
+ }
62
+ /** View a parsed body as a keyed object; anything else (string, null) reads as empty. */
63
+ function rec(value) {
64
+ return value !== null && typeof value === "object" ? value : {};
65
+ }
66
+ /**
67
+ * Parse a JSON body, degrading to `{}` rather than throwing on an empty or
68
+ * non-JSON payload (a gateway's HTML 502, say). Returns `unknown` because these
69
+ * endpoints legitimately answer with arrays as well as objects.
70
+ */
71
+ async function readJson(res) {
72
+ try {
73
+ return await res.json();
74
+ }
75
+ catch {
76
+ return {};
77
+ }
78
+ }
79
+ /**
80
+ * Return the parsed body of a successful response, or throw an AuthServiceError
81
+ * built from whatever the service reported.
82
+ */
83
+ async function requireOk(res, what) {
84
+ const body = await readJson(res);
85
+ if (res.ok)
86
+ return body;
87
+ const err = rec(body);
88
+ const detail = str(err.error_description) ?? str(err.message) ?? `HTTP ${res.status}`;
89
+ throw new AuthServiceError(`${what}: ${detail}`, res.status, str(err.error));
90
+ }
91
+ /**
92
+ * Start a device authorization: returns the user code to display and the URL
93
+ * to open, plus the polling parameters for {@link pollDeviceToken}.
94
+ *
95
+ * The payload is validated rather than cast — the login flow prints these
96
+ * strings and polls with `device_code`, so a malformed 200 must fail here
97
+ * instead of surfacing as the literal "undefined" in a user's terminal.
98
+ */
99
+ export async function requestDeviceCode(authUrl, clientId, fetchFn = fetch) {
100
+ const res = await fetchFn(api(authUrl, "/device/code"), {
101
+ method: "POST",
102
+ headers: { ...JSON_HEADERS },
103
+ body: JSON.stringify({ client_id: clientId }),
104
+ });
105
+ const body = rec(await requireOk(res, "Device authorization request failed"));
106
+ const deviceCode = str(body.device_code);
107
+ const userCode = str(body.user_code);
108
+ const verificationUri = str(body.verification_uri);
109
+ if (!deviceCode || !userCode || !verificationUri) {
110
+ throw new AuthServiceError("Auth service returned an incomplete device authorization " +
111
+ "(device_code, user_code, and verification_uri are required).", res.status);
112
+ }
113
+ return {
114
+ device_code: deviceCode,
115
+ user_code: userCode,
116
+ verification_uri: verificationUri,
117
+ // RFC 8628 marks the pre-filled URI optional; fall back to the bare
118
+ // verification URI so callers always have something to open.
119
+ verification_uri_complete: str(body.verification_uri_complete) ?? verificationUri,
120
+ interval: num(body.interval),
121
+ expires_in: num(body.expires_in),
122
+ };
123
+ }
124
+ /**
125
+ * Poll once for the session token behind a device code.
126
+ *
127
+ * Unlike the other wrappers this does not treat a 400 as fatal: the RFC 8628
128
+ * flow reports its in-progress and terminal states through the error field of a
129
+ * 400 body, so those are mapped to results the caller's loop can act on. Only
130
+ * an outcome that is neither a token nor a known state throws.
131
+ */
132
+ export async function pollDeviceToken(authUrl, clientId, deviceCode, fetchFn = fetch) {
133
+ const res = await fetchFn(api(authUrl, "/device/token"), {
134
+ method: "POST",
135
+ headers: { ...JSON_HEADERS },
136
+ body: JSON.stringify({
137
+ grant_type: DEVICE_GRANT_TYPE,
138
+ device_code: deviceCode,
139
+ client_id: clientId,
140
+ }),
141
+ });
142
+ const body = rec(await readJson(res));
143
+ const accessToken = str(body.access_token);
144
+ if (res.ok && accessToken)
145
+ return { status: "ok", accessToken };
146
+ switch (str(body.error)) {
147
+ case "authorization_pending":
148
+ return { status: "pending" };
149
+ case "slow_down":
150
+ return { status: "slow_down" };
151
+ case "access_denied":
152
+ return { status: "denied" };
153
+ case "expired_token":
154
+ return { status: "expired" };
155
+ }
156
+ // Neither a token nor a state we know. Say which, because "failed: HTTP 200"
157
+ // reads as a contradiction when the real fault is a well-formed empty answer.
158
+ const detail = str(body.error_description) ?? str(body.message);
159
+ throw new AuthServiceError(detail
160
+ ? `Device token exchange failed: ${detail}`
161
+ : `Unrecognized device token response (HTTP ${res.status}, no access_token, no error).`, res.status, str(body.error));
162
+ }
163
+ /**
164
+ * Exchange a browser session for a short-lived organization JWT — the token the
165
+ * API itself accepts. Scoped to the session's currently active organization.
166
+ */
167
+ export async function fetchJwt(authUrl, sessionToken, fetchFn = fetch) {
168
+ const res = await fetchFn(api(authUrl, "/token"), { headers: bearer(sessionToken) });
169
+ const body = rec(await requireOk(res, "Could not obtain an organization token"));
170
+ const token = str(body.token);
171
+ if (!token) {
172
+ throw new AuthServiceError("Auth service returned no token for this session.", res.status);
173
+ }
174
+ return token;
175
+ }
176
+ /**
177
+ * Organizations the session's user belongs to, projected to what the CLI shows.
178
+ *
179
+ * Entries without a usable string `id` are dropped: an org that cannot be named
180
+ * in `set-active` is not selectable, and coercing one into the list would put
181
+ * the string "undefined" in front of the user.
182
+ */
183
+ export async function listOrganizations(authUrl, sessionToken, fetchFn = fetch) {
184
+ const res = await fetchFn(api(authUrl, "/organization/list"), {
185
+ headers: bearer(sessionToken),
186
+ });
187
+ const body = await requireOk(res, "Could not list organizations");
188
+ if (!Array.isArray(body))
189
+ return [];
190
+ return body.flatMap((entry) => {
191
+ const org = rec(entry);
192
+ const id = str(org.id);
193
+ if (!id)
194
+ return [];
195
+ // A nameless org is still selectable — show the id rather than drop it.
196
+ return [{ id, name: str(org.name) ?? id, slug: str(org.slug) ?? id }];
197
+ });
198
+ }
199
+ /**
200
+ * Switch the session's active organization. The choice is server-side state, so
201
+ * every JWT minted afterwards is scoped to it.
202
+ */
203
+ export async function setActiveOrganization(authUrl, sessionToken, organizationId, fetchFn = fetch) {
204
+ const res = await fetchFn(api(authUrl, "/organization/set-active"), {
205
+ method: "POST",
206
+ headers: { ...JSON_HEADERS, ...bearer(sessionToken) },
207
+ body: JSON.stringify({ organizationId }),
208
+ });
209
+ await requireOk(res, "Could not set the active organization");
210
+ }
211
+ /** Who the stored session belongs to — used by `auth status` and login output. */
212
+ export async function getSessionInfo(authUrl, sessionToken, fetchFn = fetch) {
213
+ const res = await fetchFn(api(authUrl, "/get-session"), {
214
+ headers: bearer(sessionToken),
215
+ });
216
+ const body = rec(await requireOk(res, "Could not read the current session"));
217
+ const email = str(rec(body.user).email);
218
+ if (!email) {
219
+ throw new AuthServiceError("Auth service returned a session without a user email.", res.status);
220
+ }
221
+ return { email };
222
+ }
223
+ /**
224
+ * Ask the auth service to revoke the session.
225
+ *
226
+ * A 200 is *not* proof of revocation: Better Auth answers `{success: true}`
227
+ * unconditionally and only deletes the session when the bearer actually
228
+ * resolves to one. Callers should therefore clear local credentials regardless
229
+ * of the outcome, and must not report "revoked server-side" on the strength of
230
+ * this call alone.
231
+ */
232
+ export async function revokeSession(authUrl, sessionToken, fetchFn = fetch) {
233
+ const res = await fetchFn(api(authUrl, "/sign-out"), {
234
+ method: "POST",
235
+ headers: { ...JSON_HEADERS, ...bearer(sessionToken) },
236
+ body: "{}",
237
+ });
238
+ await requireOk(res, "Could not sign out");
239
+ }
240
+ //# sourceMappingURL=auth-service.js.map
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The stateful half of browser login: the RFC 8628 polling loop that waits for
3
+ * the user to approve a device code, and the cache that keeps the short-lived
4
+ * organization JWT out of the auth service between rapid commands.
5
+ *
6
+ * auth-service.ts owns single requests; this module owns what happens across
7
+ * several of them, so it is where the retry policy lives. It keeps the three
8
+ * exits that module documents apart: an {@link AuthServiceError} means the
9
+ * service answered and said no (replaying it would only repeat the answer),
10
+ * while a raw fetch `TypeError` means the request never landed (worth retrying).
11
+ */
12
+ import { fetchJwt, type DeviceTokenResult, type OrgSummary } from "./auth-service.js";
13
+ import { type ResolvedConfig } from "./config.js";
14
+ /**
15
+ * The user's own answer to a device login: they refused it, or they left it
16
+ * long enough to lapse.
17
+ *
18
+ * Typed rather than a bare Error because the login command has to tell these
19
+ * two outcomes apart from everything else the polling loop can raise (a refused
20
+ * client id, a dead network). Those are faults and belong in the shared error
21
+ * rendering; these are answers, and get a plain line and a non-zero exit.
22
+ */
23
+ export declare class DeviceApprovalError extends Error {
24
+ readonly reason: "denied" | "expired";
25
+ constructor(message: string, reason: "denied" | "expired");
26
+ }
27
+ /**
28
+ * The stored session is gone — revoked, or expired server-side. Distinct from
29
+ * AuthServiceError so callers can offer the one fix that works (sign in again)
30
+ * instead of reporting a bare 401.
31
+ */
32
+ export declare class SessionExpiredError extends Error {
33
+ constructor();
34
+ }
35
+ /**
36
+ * Read a JWT's `exp` claim as epoch millis.
37
+ *
38
+ * Only the payload is decoded and nothing is verified — the CLI is not the
39
+ * audience for this token, it merely needs to know when to stop reusing it.
40
+ * Anything that is not a three-segment JWT with a numeric `exp` throws, because
41
+ * a silently-assumed expiry would either hand a dead token to the API or
42
+ * re-exchange on every single command.
43
+ */
44
+ export declare function decodeJwtExpMs(jwt: string): number;
45
+ /**
46
+ * Whether a cached JWT has enough life left to be worth reusing — an unknown
47
+ * expiry counts as stale, so a cache entry written by an older CLI is simply
48
+ * re-exchanged rather than trusted.
49
+ */
50
+ export declare function isJwtFresh(expMs: number | undefined, nowMs: number): boolean;
51
+ /** Inputs to {@link runDevicePolling}; `poll` and `sleep` are injectable for tests. */
52
+ export interface DevicePollingOptions {
53
+ /** One device-token poll — normally a bound `pollDeviceToken`. */
54
+ poll: () => Promise<DeviceTokenResult>;
55
+ /** Seconds between polls, per the device-code response. Defaults to 5. */
56
+ intervalSec?: number;
57
+ /** Total seconds the device code stays valid. Defaults to 1800. */
58
+ expiresInSec?: number;
59
+ /** Delay implementation; defaults to a real timer. */
60
+ sleep?: (ms: number) => Promise<void>;
61
+ }
62
+ /**
63
+ * Poll until the user approves the login in their browser, and return the
64
+ * session token.
65
+ *
66
+ * Failure handling follows the auth-service taxonomy. A transport error is
67
+ * retried (up to {@link MAX_CONSECUTIVE_POLL_ERRORS} in a row, the count reset
68
+ * by any poll that completes) because a laptop that drops its Wi-Fi mid-login
69
+ * should not have to start over. An {@link AuthServiceError} is not retried —
70
+ * the service answered, and asking again gets the same answer.
71
+ *
72
+ * The deadline is spent from the poll budget rather than read off the wall
73
+ * clock, so an injected sleep makes the whole loop deterministic.
74
+ *
75
+ * @throws {DeviceApprovalError} when the user denies the login, or the code
76
+ * expires — either because the service said so or because the budget ran out.
77
+ * @throws {AuthServiceError} when the service refused the exchange outright.
78
+ * @throws {TypeError} the last transport error, once retrying is pointless.
79
+ */
80
+ export declare function runDevicePolling(opts: DevicePollingOptions): Promise<string>;
81
+ /** Asks one question and resolves with what the user typed. */
82
+ export type Asker = (question: string) => Promise<string>;
83
+ /** Injection points for {@link selectOrganization}; both default to the real thing. */
84
+ export interface SelectOrgDeps {
85
+ /** Question function — tests (and any non-readline caller) supply their own;
86
+ * the default reads a line from stdin. */
87
+ ask?: Asker;
88
+ /** Whether stdin can be prompted; defaults to `process.stdin.isTTY`. */
89
+ isTTY?: boolean;
90
+ }
91
+ /**
92
+ * Decide which organization the new session should act as.
93
+ *
94
+ * A single membership needs no ceremony, so it is chosen silently; several
95
+ * memberships get a numbered prompt that repeats until the answer names one of
96
+ * them. Lives here rather than in the login command because org switching
97
+ * reuses the identical choice.
98
+ *
99
+ * Two guards keep the prompt from becoming a hang. Without a terminal there is
100
+ * nobody to answer, and readline on a closed or piped stdin resolves nothing
101
+ * and never ends — so that case is refused up front, with the fix in the
102
+ * message. The attempt cap covers what the first guard cannot see: an input
103
+ * that does answer, always invalidly (a pipe that yields "" forever).
104
+ *
105
+ * @throws {Error} when the account has no organizations at all, when there is
106
+ * no terminal to prompt, or when the attempts run out.
107
+ */
108
+ export declare function selectOrganization(orgs: OrgSummary[], deps?: SelectOrgDeps): Promise<OrgSummary>;
109
+ /** Injection points for {@link ensureJwt}; all default to the real thing. */
110
+ export interface EnsureJwtDeps {
111
+ /** Session-to-JWT exchange; defaults to {@link fetchJwt}. */
112
+ fetchJwtFn?: typeof fetchJwt;
113
+ /** Cache writer; defaults to {@link updateContext}. */
114
+ persist?: (contextName: string, patch: {
115
+ cachedJwt: string;
116
+ cachedJwtExp: number;
117
+ }) => void;
118
+ /** Clock; defaults to `Date.now`. */
119
+ now?: () => number;
120
+ /** Skip the cache and always exchange — e.g. right after switching org. */
121
+ force?: boolean;
122
+ }
123
+ type SessionConfig = Extract<ResolvedConfig, {
124
+ kind: "session";
125
+ }>;
126
+ /**
127
+ * Return an organization JWT for a session context, exchanging the session for
128
+ * a new one whenever the cached token is missing, stale, or explicitly bypassed.
129
+ *
130
+ * A fresh token is written both to the config store and back onto `cfg`: a
131
+ * single command may reach here more than once (a retry after a 401, say), and
132
+ * the in-memory copy is what those later reads see. Caching is best-effort — a
133
+ * token that cannot be cached is still a token that works.
134
+ *
135
+ * @throws {SessionExpiredError} when the stored session is no longer valid.
136
+ */
137
+ export declare function ensureJwt(cfg: SessionConfig, deps?: EnsureJwtDeps): Promise<string>;
138
+ export {};
139
+ //# sourceMappingURL=auth-session.d.ts.map
@@ -0,0 +1,275 @@
1
+ /**
2
+ * The stateful half of browser login: the RFC 8628 polling loop that waits for
3
+ * the user to approve a device code, and the cache that keeps the short-lived
4
+ * organization JWT out of the auth service between rapid commands.
5
+ *
6
+ * auth-service.ts owns single requests; this module owns what happens across
7
+ * several of them, so it is where the retry policy lives. It keeps the three
8
+ * exits that module documents apart: an {@link AuthServiceError} means the
9
+ * service answered and said no (replaying it would only repeat the answer),
10
+ * while a raw fetch `TypeError` means the request never landed (worth retrying).
11
+ */
12
+ import { Buffer } from "node:buffer";
13
+ import { createInterface } from "node:readline/promises";
14
+ import chalk from "chalk";
15
+ import { AuthServiceError, fetchJwt, } from "./auth-service.js";
16
+ import { updateContext } from "./config.js";
17
+ /**
18
+ * How much life a cached JWT must have left to be reused. Also absorbs modest
19
+ * client-clock skew: a token minted against a server clock a few seconds ahead
20
+ * of ours must not be handed to the API a moment before it becomes valid.
21
+ */
22
+ const JWT_FRESHNESS_MARGIN_MS = 30_000;
23
+ /** Consecutive-transport-failure count at which the loop gives up: two are
24
+ * ridden out, the third is rethrown. */
25
+ const MAX_CONSECUTIVE_POLL_ERRORS = 3;
26
+ /** RFC 8628 §3.2 recommends 5 s when the service states no interval. */
27
+ const DEFAULT_POLL_INTERVAL_SEC = 5;
28
+ /** Ceiling on the whole login wait when the service states no expiry. */
29
+ const DEFAULT_POLL_EXPIRES_SEC = 1800;
30
+ /** RFC 8628 §3.5: each slow_down widens the polling interval by 5 s. */
31
+ const SLOW_DOWN_BACKOFF_MS = 5_000;
32
+ const DENIED_MESSAGE = "Login was denied in the browser.";
33
+ const EXPIRED_MESSAGE = 'Login attempt expired — run "2kw auth login" again.';
34
+ /** Nothing to select from, and nothing the CLI can do about it — orgs are
35
+ * created in the web app. */
36
+ const NO_ORG_MESSAGE = "Your account belongs to no organization yet — create one in the web app first.";
37
+ /**
38
+ * The user's own answer to a device login: they refused it, or they left it
39
+ * long enough to lapse.
40
+ *
41
+ * Typed rather than a bare Error because the login command has to tell these
42
+ * two outcomes apart from everything else the polling loop can raise (a refused
43
+ * client id, a dead network). Those are faults and belong in the shared error
44
+ * rendering; these are answers, and get a plain line and a non-zero exit.
45
+ */
46
+ export class DeviceApprovalError extends Error {
47
+ reason;
48
+ constructor(message, reason) {
49
+ super(message);
50
+ this.reason = reason;
51
+ this.name = "DeviceApprovalError";
52
+ }
53
+ }
54
+ /**
55
+ * The stored session is gone — revoked, or expired server-side. Distinct from
56
+ * AuthServiceError so callers can offer the one fix that works (sign in again)
57
+ * instead of reporting a bare 401.
58
+ */
59
+ export class SessionExpiredError extends Error {
60
+ constructor() {
61
+ super('Session expired or revoked — run "2kw auth login" to sign in again.');
62
+ this.name = "SessionExpiredError";
63
+ }
64
+ }
65
+ /** Wait `ms`, used when the caller injects no sleep of its own. */
66
+ function defaultSleep(ms) {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+ /**
70
+ * Read a JWT's `exp` claim as epoch millis.
71
+ *
72
+ * Only the payload is decoded and nothing is verified — the CLI is not the
73
+ * audience for this token, it merely needs to know when to stop reusing it.
74
+ * Anything that is not a three-segment JWT with a numeric `exp` throws, because
75
+ * a silently-assumed expiry would either hand a dead token to the API or
76
+ * re-exchange on every single command.
77
+ */
78
+ export function decodeJwtExpMs(jwt) {
79
+ const parts = jwt.split(".");
80
+ if (parts.length !== 3) {
81
+ throw new Error("Malformed token: expected three dot-separated JWT segments.");
82
+ }
83
+ let payload;
84
+ try {
85
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
86
+ }
87
+ catch {
88
+ throw new Error("Malformed token: the JWT payload is not valid JSON.");
89
+ }
90
+ const exp = payload !== null && typeof payload === "object"
91
+ ? payload.exp
92
+ : undefined;
93
+ if (typeof exp !== "number" || !Number.isFinite(exp)) {
94
+ throw new Error("Malformed token: the JWT payload carries no numeric exp claim.");
95
+ }
96
+ return exp * 1000;
97
+ }
98
+ /**
99
+ * Whether a cached JWT has enough life left to be worth reusing — an unknown
100
+ * expiry counts as stale, so a cache entry written by an older CLI is simply
101
+ * re-exchanged rather than trusted.
102
+ */
103
+ export function isJwtFresh(expMs, nowMs) {
104
+ return expMs !== undefined && expMs - nowMs > JWT_FRESHNESS_MARGIN_MS;
105
+ }
106
+ /**
107
+ * Poll until the user approves the login in their browser, and return the
108
+ * session token.
109
+ *
110
+ * Failure handling follows the auth-service taxonomy. A transport error is
111
+ * retried (up to {@link MAX_CONSECUTIVE_POLL_ERRORS} in a row, the count reset
112
+ * by any poll that completes) because a laptop that drops its Wi-Fi mid-login
113
+ * should not have to start over. An {@link AuthServiceError} is not retried —
114
+ * the service answered, and asking again gets the same answer.
115
+ *
116
+ * The deadline is spent from the poll budget rather than read off the wall
117
+ * clock, so an injected sleep makes the whole loop deterministic.
118
+ *
119
+ * @throws {DeviceApprovalError} when the user denies the login, or the code
120
+ * expires — either because the service said so or because the budget ran out.
121
+ * @throws {AuthServiceError} when the service refused the exchange outright.
122
+ * @throws {TypeError} the last transport error, once retrying is pointless.
123
+ */
124
+ export async function runDevicePolling(opts) {
125
+ const { poll, sleep = defaultSleep } = opts;
126
+ // A zero or negative interval would spin the loop without ever spending the
127
+ // budget, so treat anything non-positive as "not stated".
128
+ const intervalSec = opts.intervalSec !== undefined && opts.intervalSec > 0
129
+ ? opts.intervalSec
130
+ : DEFAULT_POLL_INTERVAL_SEC;
131
+ let intervalMs = intervalSec * 1000;
132
+ let remainingMs = (opts.expiresInSec ?? DEFAULT_POLL_EXPIRES_SEC) * 1000;
133
+ let consecutiveErrors = 0;
134
+ for (;;) {
135
+ let result;
136
+ try {
137
+ result = await poll();
138
+ consecutiveErrors = 0;
139
+ }
140
+ catch (err) {
141
+ // Exit #2: the service answered. Retrying just replays the refusal.
142
+ if (err instanceof AuthServiceError)
143
+ throw err;
144
+ // Exit #3: the request never completed. Retry-worthy, within reason.
145
+ consecutiveErrors += 1;
146
+ if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS)
147
+ throw err;
148
+ }
149
+ switch (result?.status) {
150
+ case "ok":
151
+ return result.accessToken;
152
+ case "denied":
153
+ throw new DeviceApprovalError(DENIED_MESSAGE, "denied");
154
+ case "expired":
155
+ throw new DeviceApprovalError(EXPIRED_MESSAGE, "expired");
156
+ case "slow_down":
157
+ intervalMs += SLOW_DOWN_BACKOFF_MS;
158
+ break;
159
+ default:
160
+ // "pending", or a tolerated transport error: wait and ask again.
161
+ break;
162
+ }
163
+ remainingMs -= intervalMs;
164
+ if (remainingMs <= 0)
165
+ throw new DeviceApprovalError(EXPIRED_MESSAGE, "expired");
166
+ await sleep(intervalMs);
167
+ }
168
+ }
169
+ /** Attempts allowed at the prompt before the loop is called a lost cause. */
170
+ const MAX_ORG_PROMPT_ATTEMPTS = 5;
171
+ const NO_TTY_MESSAGE = "Cannot prompt for an organization: no interactive terminal. " +
172
+ "Pass an org explicitly or run in a terminal.";
173
+ const TOO_MANY_ATTEMPTS_MESSAGE = `No organization chosen after ${MAX_ORG_PROMPT_ATTEMPTS} attempts.`;
174
+ /**
175
+ * Decide which organization the new session should act as.
176
+ *
177
+ * A single membership needs no ceremony, so it is chosen silently; several
178
+ * memberships get a numbered prompt that repeats until the answer names one of
179
+ * them. Lives here rather than in the login command because org switching
180
+ * reuses the identical choice.
181
+ *
182
+ * Two guards keep the prompt from becoming a hang. Without a terminal there is
183
+ * nobody to answer, and readline on a closed or piped stdin resolves nothing
184
+ * and never ends — so that case is refused up front, with the fix in the
185
+ * message. The attempt cap covers what the first guard cannot see: an input
186
+ * that does answer, always invalidly (a pipe that yields "" forever).
187
+ *
188
+ * @throws {Error} when the account has no organizations at all, when there is
189
+ * no terminal to prompt, or when the attempts run out.
190
+ */
191
+ export async function selectOrganization(orgs, deps = {}) {
192
+ const { ask, isTTY = Boolean(process.stdin.isTTY) } = deps;
193
+ if (orgs.length === 0)
194
+ throw new Error(NO_ORG_MESSAGE);
195
+ if (orgs.length === 1)
196
+ return orgs[0];
197
+ // Checked before anything is printed: an injected asker owns its own input,
198
+ // but the default one is about to open readline on a stdin nobody will type
199
+ // into.
200
+ if (!ask && !isTTY)
201
+ throw new Error(NO_TTY_MESSAGE);
202
+ console.log("");
203
+ console.log("Select an organization:");
204
+ orgs.forEach((org, i) => {
205
+ console.log(` ${i + 1}) ${org.name}`);
206
+ });
207
+ // Only open a real readline when we have to: an injected asker means the
208
+ // caller owns the input, and an unused interface would hold stdin open.
209
+ const rl = ask ? undefined : createInterface({ input: process.stdin, output: process.stdout });
210
+ const askFn = ask ?? ((question) => rl.question(question));
211
+ try {
212
+ for (let attempt = 0; attempt < MAX_ORG_PROMPT_ATTEMPTS; attempt++) {
213
+ const answer = (await askFn(`Organization [1-${orgs.length}]: `)).trim();
214
+ // Digits only — parseInt would happily read "2 or so" as 2.
215
+ if (/^\d+$/.test(answer)) {
216
+ const choice = Number(answer);
217
+ if (choice >= 1 && choice <= orgs.length)
218
+ return orgs[choice - 1];
219
+ }
220
+ console.log(chalk.yellow(`Enter a number between 1 and ${orgs.length}.`));
221
+ }
222
+ throw new Error(TOO_MANY_ATTEMPTS_MESSAGE);
223
+ }
224
+ finally {
225
+ rl?.close();
226
+ }
227
+ }
228
+ /**
229
+ * Return an organization JWT for a session context, exchanging the session for
230
+ * a new one whenever the cached token is missing, stale, or explicitly bypassed.
231
+ *
232
+ * A fresh token is written both to the config store and back onto `cfg`: a
233
+ * single command may reach here more than once (a retry after a 401, say), and
234
+ * the in-memory copy is what those later reads see. Caching is best-effort — a
235
+ * token that cannot be cached is still a token that works.
236
+ *
237
+ * @throws {SessionExpiredError} when the stored session is no longer valid.
238
+ */
239
+ export async function ensureJwt(cfg, deps = {}) {
240
+ const { fetchJwtFn = fetchJwt, persist = updateContext, now = Date.now, force = false } = deps;
241
+ if (!force && cfg.cachedJwt && isJwtFresh(cfg.cachedJwtExp, now())) {
242
+ return cfg.cachedJwt;
243
+ }
244
+ let jwt;
245
+ try {
246
+ jwt = await fetchJwtFn(cfg.authUrl, cfg.sessionToken);
247
+ }
248
+ catch (err) {
249
+ // Only a 401 means "this session is done". Every other failure — a 502, a
250
+ // dead network — says nothing about the session, so it must not tell the
251
+ // user to sign in again.
252
+ if (err instanceof AuthServiceError && err.status === 401) {
253
+ // The stale cache is dropped where this lands rather than here:
254
+ // handleError in errors.ts clears cachedJwt/cachedJwtExp for the active
255
+ // context, so every command path gets it, including the raw-fetch ones.
256
+ throw new SessionExpiredError();
257
+ }
258
+ throw err;
259
+ }
260
+ try {
261
+ const expMs = decodeJwtExpMs(jwt);
262
+ cfg.cachedJwtExp = expMs;
263
+ cfg.cachedJwt = jwt;
264
+ persist(cfg.contextName, { cachedJwt: jwt, cachedJwtExp: expMs });
265
+ }
266
+ catch {
267
+ // An unparseable token or a read-only config store must not fail a command
268
+ // that holds a working credential — skip the cache and use it this once.
269
+ // Cleared as a pair so a half-written entry can never be read back as fresh.
270
+ cfg.cachedJwt = undefined;
271
+ cfg.cachedJwtExp = undefined;
272
+ }
273
+ return jwt;
274
+ }
275
+ //# sourceMappingURL=auth-session.js.map