@kidd-cli/core 0.1.2 → 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 (51) hide show
  1. package/dist/{config-BvGapuFJ.js → config-Db_sjFU-.js} +60 -65
  2. package/dist/config-Db_sjFU-.js.map +1 -0
  3. package/dist/create-http-client-tZJWlWp1.js +165 -0
  4. package/dist/create-http-client-tZJWlWp1.js.map +1 -0
  5. package/dist/{create-store-BQUX0tAn.js → create-store-D-fQpCql.js} +32 -4
  6. package/dist/create-store-D-fQpCql.js.map +1 -0
  7. package/dist/index.d.ts +21 -6
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +18 -17
  10. package/dist/index.js.map +1 -1
  11. package/dist/lib/config.js +2 -2
  12. package/dist/lib/project.d.ts +1 -1
  13. package/dist/lib/project.d.ts.map +1 -1
  14. package/dist/lib/project.js +1 -1
  15. package/dist/lib/store.d.ts +2 -1
  16. package/dist/lib/store.d.ts.map +1 -1
  17. package/dist/lib/store.js +2 -2
  18. package/dist/middleware/auth.d.ts +223 -14
  19. package/dist/middleware/auth.d.ts.map +1 -1
  20. package/dist/middleware/auth.js +973 -408
  21. package/dist/middleware/auth.js.map +1 -1
  22. package/dist/middleware/http.d.ts +10 -16
  23. package/dist/middleware/http.d.ts.map +1 -1
  24. package/dist/middleware/http.js +21 -221
  25. package/dist/middleware/http.js.map +1 -1
  26. package/dist/{middleware-D3psyhYo.js → middleware-BFBKNSPQ.js} +13 -2
  27. package/dist/{middleware-D3psyhYo.js.map → middleware-BFBKNSPQ.js.map} +1 -1
  28. package/dist/{project-NPtYX2ZX.js → project-DuXgjaa_.js} +19 -16
  29. package/dist/project-DuXgjaa_.js.map +1 -0
  30. package/dist/{types-kjpRau0U.d.ts → types-BaZ5WqVM.d.ts} +78 -13
  31. package/dist/types-BaZ5WqVM.d.ts.map +1 -0
  32. package/dist/{types-Cz9h927W.d.ts → types-C0CYivzY.d.ts} +1 -1
  33. package/dist/{types-Cz9h927W.d.ts.map → types-C0CYivzY.d.ts.map} +1 -1
  34. package/package.json +5 -12
  35. package/dist/config-BvGapuFJ.js.map +0 -1
  36. package/dist/create-store-BQUX0tAn.js.map +0 -1
  37. package/dist/lib/output.d.ts +0 -62
  38. package/dist/lib/output.d.ts.map +0 -1
  39. package/dist/lib/output.js +0 -276
  40. package/dist/lib/output.js.map +0 -1
  41. package/dist/lib/prompts.d.ts +0 -24
  42. package/dist/lib/prompts.d.ts.map +0 -1
  43. package/dist/lib/prompts.js +0 -3
  44. package/dist/project-NPtYX2ZX.js.map +0 -1
  45. package/dist/prompts-lLfUSgd6.js +0 -63
  46. package/dist/prompts-lLfUSgd6.js.map +0 -1
  47. package/dist/types-CqKJhsYk.d.ts +0 -135
  48. package/dist/types-CqKJhsYk.d.ts.map +0 -1
  49. package/dist/types-DFtYg5uZ.d.ts +0 -26
  50. package/dist/types-DFtYg5uZ.d.ts.map +0 -1
  51. package/dist/types-kjpRau0U.d.ts.map +0 -1
@@ -1,18 +1,40 @@
1
- import { n as decorateContext, t as middleware } from "../middleware-D3psyhYo.js";
2
- import "../project-NPtYX2ZX.js";
3
- import { t as createStore } from "../create-store-BQUX0tAn.js";
1
+ import { n as decorateContext, t as middleware } from "../middleware-BFBKNSPQ.js";
2
+ import "../project-DuXgjaa_.js";
3
+ import { t as createStore } from "../create-store-D-fQpCql.js";
4
+ import { t as createHttpClient } from "../create-http-client-tZJWlWp1.js";
4
5
  import { join } from "node:path";
5
6
  import { ok } from "@kidd-cli/utils/fp";
6
7
  import { match as match$1 } from "ts-pattern";
7
8
  import { platform } from "node:os";
8
9
  import { readFileSync } from "node:fs";
10
+ import { Buffer } from "node:buffer";
11
+ import { execFile } from "node:child_process";
12
+ import { createServer } from "node:http";
9
13
  import { parse } from "dotenv";
10
14
  import { attempt as attempt$1 } from "es-toolkit";
11
15
  import { z } from "zod";
12
- import { execFile } from "node:child_process";
13
- import { randomBytes } from "node:crypto";
14
- import { createServer } from "node:http";
16
+ import { createHash, randomBytes } from "node:crypto";
17
+
18
+ //#region src/middleware/http/build-auth-headers.ts
19
+ /**
20
+ * Convert auth credentials into HTTP headers.
21
+ *
22
+ * Uses exhaustive pattern matching to map each credential variant to
23
+ * the appropriate header format.
24
+ *
25
+ * @module
26
+ */
27
+ /**
28
+ * Convert an auth credential into HTTP headers.
29
+ *
30
+ * @param credential - The credential to convert.
31
+ * @returns A record of header name to header value.
32
+ */
33
+ function buildAuthHeaders(credential) {
34
+ return match$1(credential).with({ type: "bearer" }, (c) => ({ Authorization: `Bearer ${c.token}` })).with({ type: "basic" }, (c) => ({ Authorization: `Basic ${Buffer.from(`${c.username}:${c.password}`).toString("base64")}` })).with({ type: "api-key" }, (c) => ({ [c.headerName]: c.key })).with({ type: "custom" }, (c) => ({ ...c.headers })).exhaustive();
35
+ }
15
36
 
37
+ //#endregion
16
38
  //#region src/middleware/auth/constants.ts
17
39
  /**
18
40
  * Default filename for file-based credential storage.
@@ -23,6 +45,26 @@ const DEFAULT_AUTH_FILENAME = "auth.json";
23
45
  */
24
46
  const TOKEN_VAR_SUFFIX = "_TOKEN";
25
47
  /**
48
+ * Default port for the local OAuth callback server (`0` = ephemeral).
49
+ */
50
+ const DEFAULT_OAUTH_PORT = 0;
51
+ /**
52
+ * Default callback path for the local OAuth server.
53
+ */
54
+ const DEFAULT_OAUTH_CALLBACK_PATH = "/callback";
55
+ /**
56
+ * Default timeout for the OAuth PKCE flow in milliseconds (2 minutes).
57
+ */
58
+ const DEFAULT_OAUTH_TIMEOUT = 12e4;
59
+ /**
60
+ * Default poll interval for the device code flow in milliseconds (5 seconds).
61
+ */
62
+ const DEFAULT_DEVICE_CODE_POLL_INTERVAL = 5e3;
63
+ /**
64
+ * Default timeout for the device code flow in milliseconds (5 minutes).
65
+ */
66
+ const DEFAULT_DEVICE_CODE_TIMEOUT = 3e5;
67
+ /**
26
68
  * Derive the default environment variable name from a CLI name.
27
69
  *
28
70
  * Converts kebab-case to SCREAMING_SNAKE_CASE and appends `_TOKEN`.
@@ -36,7 +78,503 @@ function deriveTokenVar(cliName) {
36
78
  }
37
79
 
38
80
  //#endregion
39
- //#region src/middleware/auth/resolve-dotenv.ts
81
+ //#region src/middleware/auth/credential.ts
82
+ /**
83
+ * Check whether a token string is a non-empty, non-whitespace value.
84
+ *
85
+ * Acts as a type guard: when it returns true, TypeScript narrows the
86
+ * token to `string`. Consolidates the repeated `!token || token.trim() === ''`
87
+ * guard found across strategy resolvers.
88
+ *
89
+ * @param token - The token string to check.
90
+ * @returns True when the token is a non-empty string.
91
+ */
92
+ function isValidToken(token) {
93
+ if (!token) return false;
94
+ if (token.trim() === "") return false;
95
+ return true;
96
+ }
97
+ /**
98
+ * Construct a bearer credential from a raw token string.
99
+ *
100
+ * @param token - The access token value.
101
+ * @returns A BearerCredential with `type: 'bearer'`.
102
+ */
103
+ function createBearerCredential(token) {
104
+ return {
105
+ token,
106
+ type: "bearer"
107
+ };
108
+ }
109
+ /**
110
+ * POST form-encoded parameters to a URL.
111
+ *
112
+ * Wraps the duplicated `fetch` call with `Content-Type: application/x-www-form-urlencoded`
113
+ * found in the OAuth and device code strategies. Returns null on network or
114
+ * request failure instead of throwing.
115
+ *
116
+ * @param url - The endpoint URL.
117
+ * @param params - The URL-encoded form parameters.
118
+ * @param signal - Optional AbortSignal for timeout/cancellation.
119
+ * @returns The fetch Response on success, null on failure.
120
+ */
121
+ async function postFormEncoded(url, params, signal) {
122
+ try {
123
+ return await fetch(url, {
124
+ body: params.toString(),
125
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
126
+ method: "POST",
127
+ signal
128
+ });
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ //#endregion
135
+ //#region src/middleware/auth/oauth-server.ts
136
+ /**
137
+ * Shared utilities for OAuth-based auth resolvers.
138
+ *
139
+ * Extracted from the local HTTP server, browser-launch, and
140
+ * lifecycle patterns shared by the PKCE and device-code flows.
141
+ *
142
+ * @module
143
+ */
144
+ const CLOSE_PAGE_HTML = [
145
+ "<!DOCTYPE html>",
146
+ "<html>",
147
+ "<body><p>Authentication complete. You can close this tab.</p></body>",
148
+ "</html>"
149
+ ].join("\n");
150
+ /**
151
+ * Create a deferred promise with externally accessible resolve.
152
+ *
153
+ * Uses a mutable state container to capture the promise resolver --
154
+ * this is an intentional exception to immutability rules because the
155
+ * Promise constructor API requires synchronous resolver capture.
156
+ *
157
+ * @returns A deferred object with promise and resolve.
158
+ */
159
+ function createDeferred() {
160
+ const state = { resolve: null };
161
+ return {
162
+ promise: new Promise((resolve) => {
163
+ state.resolve = resolve;
164
+ }),
165
+ resolve: (value) => {
166
+ if (state.resolve) state.resolve(value);
167
+ }
168
+ };
169
+ }
170
+ /**
171
+ * Create a clearable timeout.
172
+ *
173
+ * Returns a promise that resolves after `ms` milliseconds and a `clear`
174
+ * function that cancels the timer so it does not hold the event loop open.
175
+ *
176
+ * Uses a mutable state container to capture the timer id -- this is an
177
+ * intentional exception to immutability rules because `setTimeout`
178
+ * returns an opaque handle that must be stored for later cancellation.
179
+ *
180
+ * @param ms - Duration in milliseconds.
181
+ * @returns A Timeout with `promise` and `clear`.
182
+ */
183
+ function createTimeout(ms) {
184
+ const state = { id: null };
185
+ return {
186
+ clear: () => {
187
+ if (state.id !== null) {
188
+ clearTimeout(state.id);
189
+ state.id = null;
190
+ }
191
+ },
192
+ promise: new Promise((resolve) => {
193
+ state.id = setTimeout(resolve, ms);
194
+ })
195
+ };
196
+ }
197
+ /**
198
+ * Track socket connections on a server so they can be destroyed on close.
199
+ *
200
+ * Mutates the provided socket set -- this is an intentional exception to
201
+ * immutability rules because the HTTP server API is inherently stateful.
202
+ *
203
+ * @param server - The HTTP server.
204
+ * @param sockets - The set to track sockets in.
205
+ */
206
+ function trackConnections(server, sockets) {
207
+ server.on("connection", (socket) => {
208
+ sockets.add(socket);
209
+ socket.on("close", () => {
210
+ sockets.delete(socket);
211
+ });
212
+ });
213
+ }
214
+ /**
215
+ * Close a server and destroy all active connections immediately.
216
+ *
217
+ * `server.close()` only stops accepting new connections -- existing
218
+ * keep-alive connections hold the event loop open. This helper
219
+ * destroys every tracked socket so the process can exit cleanly.
220
+ *
221
+ * @param server - The HTTP server to close.
222
+ * @param sockets - The set of tracked sockets.
223
+ */
224
+ function destroyServer(server, sockets) {
225
+ server.close();
226
+ Array.from(sockets, (socket) => socket.destroy());
227
+ sockets.clear();
228
+ }
229
+ /**
230
+ * Send an HTML success page and end the response.
231
+ *
232
+ * @param res - The server response object.
233
+ */
234
+ function sendSuccessPage(res) {
235
+ res.writeHead(200, { "Content-Type": "text/html" });
236
+ res.end(CLOSE_PAGE_HTML);
237
+ }
238
+ /**
239
+ * Check whether a URL is safe for use as an OAuth endpoint.
240
+ *
241
+ * Requires HTTPS for all URLs except loopback addresses, where
242
+ * HTTP is permitted per RFC 8252 §8.3 (native app redirect URIs).
243
+ *
244
+ * @param url - The URL string to validate.
245
+ * @returns True when the URL uses HTTPS or HTTP on a loopback address.
246
+ */
247
+ function isSecureAuthUrl(url) {
248
+ try {
249
+ const parsed = new URL(url);
250
+ if (parsed.protocol === "https:") return true;
251
+ if (parsed.protocol !== "http:") return false;
252
+ return isLoopbackHost(parsed.hostname);
253
+ } catch {
254
+ return false;
255
+ }
256
+ }
257
+ /**
258
+ * Open a URL in the user's default browser using a platform-specific command.
259
+ *
260
+ * Validates that the URL uses the HTTP or HTTPS protocol before opening
261
+ * to prevent dangerous schemes like `javascript:` or `data:`. Silently
262
+ * returns if the URL is invalid.
263
+ *
264
+ * On Windows, `start` is a `cmd.exe` built-in -- not a standalone executable --
265
+ * so it must be invoked via `cmd /c start "" <url>`. The empty string argument
266
+ * prevents `cmd` from interpreting the URL as a window title.
267
+ *
268
+ * @param url - The URL to open (must use http: or https: protocol).
269
+ */
270
+ function openBrowser(url) {
271
+ if (!isHttpUrl(url)) return;
272
+ const { command, args } = match$1(platform()).with("darwin", () => ({
273
+ args: [url],
274
+ command: "open"
275
+ })).with("win32", () => ({
276
+ args: [
277
+ "/c",
278
+ "start",
279
+ "",
280
+ escapeCmdMeta(url)
281
+ ],
282
+ command: "cmd"
283
+ })).otherwise(() => ({
284
+ args: [url],
285
+ command: "xdg-open"
286
+ }));
287
+ execFile(command, args).on("error", () => void 0);
288
+ }
289
+ /**
290
+ * Start a local HTTP server on `127.0.0.1` with socket tracking.
291
+ *
292
+ * Returns a handle containing the server, tracked sockets, and a port
293
+ * promise that resolves once the server is listening.
294
+ *
295
+ * @param options - Server configuration.
296
+ * @returns A LocalServerHandle with port, server, and sockets.
297
+ */
298
+ function startLocalServer(options) {
299
+ const portDeferred = createDeferred();
300
+ const sockets = /* @__PURE__ */ new Set();
301
+ const server = createServer(options.onRequest);
302
+ trackConnections(server, sockets);
303
+ server.on("error", () => {
304
+ destroyServer(server, sockets);
305
+ portDeferred.resolve(null);
306
+ });
307
+ server.listen(options.port, "127.0.0.1", () => {
308
+ const addr = server.address();
309
+ if (addr === null || typeof addr === "string") {
310
+ destroyServer(server, sockets);
311
+ portDeferred.resolve(null);
312
+ return;
313
+ }
314
+ portDeferred.resolve(addr.port);
315
+ });
316
+ return {
317
+ port: portDeferred.promise,
318
+ server,
319
+ sockets
320
+ };
321
+ }
322
+ /**
323
+ * Check whether a URL uses the HTTP or HTTPS protocol.
324
+ *
325
+ * Rejects dangerous schemes like `javascript:`, `data:`, and `file:`
326
+ * to prevent browser-based attacks when opening untrusted URLs.
327
+ *
328
+ * @private
329
+ * @param url - The URL string to validate.
330
+ * @returns True when the URL uses http: or https: protocol.
331
+ */
332
+ function isHttpUrl(url) {
333
+ try {
334
+ const parsed = new URL(url);
335
+ return parsed.protocol === "https:" || parsed.protocol === "http:";
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+ /**
341
+ * Check whether a hostname is a loopback address.
342
+ *
343
+ * RFC 8252 §8.3 permits HTTP for loopback interfaces during
344
+ * native app authorization flows.
345
+ *
346
+ * @private
347
+ * @param hostname - The hostname to check.
348
+ * @returns True when the hostname is a loopback address.
349
+ */
350
+ function isLoopbackHost(hostname) {
351
+ return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "localhost";
352
+ }
353
+ /**
354
+ * Escape `cmd.exe` metacharacters in a URL string.
355
+ *
356
+ * Characters like `&`, `|`, `<`, `>`, and `^` are interpreted as
357
+ * command separators or redirectors by `cmd.exe`. Prefixing each
358
+ * with `^` neutralises the special meaning.
359
+ *
360
+ * @private
361
+ * @param url - The URL to escape.
362
+ * @returns The escaped URL string.
363
+ */
364
+ function escapeCmdMeta(url) {
365
+ return url.replaceAll(/[&|<>^]/g, "^$&");
366
+ }
367
+
368
+ //#endregion
369
+ //#region src/middleware/auth/strategies/device-code.ts
370
+ /**
371
+ * OAuth 2.0 Device Authorization Grant resolver (RFC 8628).
372
+ *
373
+ * Requests a device code, displays the verification URL and user code,
374
+ * and polls the token endpoint until the user completes authorization
375
+ * or the flow times out.
376
+ *
377
+ * @module
378
+ */
379
+ /**
380
+ * RFC 8628 slow_down backoff increment in milliseconds.
381
+ */
382
+ const SLOW_DOWN_INCREMENT = 5e3;
383
+ /**
384
+ * RFC 8628 device code grant type URN.
385
+ */
386
+ const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
387
+ /**
388
+ * Resolve a bearer credential via OAuth 2.0 Device Authorization Grant.
389
+ *
390
+ * 1. POSTs to the device authorization endpoint to obtain a device code
391
+ * 2. Displays the verification URL and user code via prompts
392
+ * 3. Optionally opens the verification URL in the browser
393
+ * 4. Polls the token endpoint until authorization completes or times out
394
+ *
395
+ * @param options - Device code flow configuration.
396
+ * @returns A bearer credential on success, null on failure or timeout.
397
+ */
398
+ async function resolveFromDeviceCode(options) {
399
+ if (!isSecureAuthUrl(options.deviceAuthUrl)) return null;
400
+ if (!isSecureAuthUrl(options.tokenUrl)) return null;
401
+ const deadline = Date.now() + options.timeout;
402
+ const signal = AbortSignal.timeout(options.timeout);
403
+ const authResponse = await requestDeviceAuth({
404
+ clientId: options.clientId,
405
+ deviceAuthUrl: options.deviceAuthUrl,
406
+ scopes: options.scopes,
407
+ signal
408
+ });
409
+ if (!authResponse) return null;
410
+ await displayUserCode(options.prompts, authResponse.verificationUri, authResponse.userCode);
411
+ if (options.openBrowserOnStart !== false) openBrowser(authResponse.verificationUri);
412
+ const interval = resolveInterval(authResponse.interval, options.pollInterval);
413
+ return pollForToken({
414
+ clientId: options.clientId,
415
+ deadline,
416
+ deviceCode: authResponse.deviceCode,
417
+ interval,
418
+ signal,
419
+ tokenUrl: options.tokenUrl
420
+ });
421
+ }
422
+ /**
423
+ * Request a device code from the authorization server.
424
+ *
425
+ * @private
426
+ * @param options - Device auth request parameters.
427
+ * @returns The parsed device auth response, or null on failure.
428
+ */
429
+ async function requestDeviceAuth(options) {
430
+ const body = new URLSearchParams({ client_id: options.clientId });
431
+ if (options.scopes.length > 0) body.set("scope", options.scopes.join(" "));
432
+ const response = await postFormEncoded(options.deviceAuthUrl, body, options.signal);
433
+ if (!response) return null;
434
+ if (!response.ok) return null;
435
+ try {
436
+ return parseDeviceAuthResponse(await response.json());
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+ /**
442
+ * Parse a device authorization response body.
443
+ *
444
+ * @private
445
+ * @param data - The raw response data.
446
+ * @returns The parsed response, or null if required fields are missing.
447
+ */
448
+ function parseDeviceAuthResponse(data) {
449
+ if (typeof data !== "object" || data === null) return null;
450
+ const record = data;
451
+ if (typeof record.device_code !== "string" || record.device_code === "") return null;
452
+ if (typeof record.user_code !== "string" || record.user_code === "") return null;
453
+ if (typeof record.verification_uri !== "string" || record.verification_uri === "") return null;
454
+ const interval = resolveServerInterval(record.interval);
455
+ return {
456
+ deviceCode: record.device_code,
457
+ interval,
458
+ userCode: record.user_code,
459
+ verificationUri: record.verification_uri
460
+ };
461
+ }
462
+ /**
463
+ * Display the verification URL and user code to the user.
464
+ *
465
+ * Uses `prompts.text()` to show the information and wait for
466
+ * the user to press Enter to acknowledge.
467
+ *
468
+ * @private
469
+ * @param prompts - The prompts instance.
470
+ * @param verificationUri - The URL the user should visit.
471
+ * @param userCode - The code the user should enter.
472
+ */
473
+ async function displayUserCode(prompts, verificationUri, userCode) {
474
+ try {
475
+ await prompts.text({
476
+ defaultValue: "",
477
+ message: `Open ${verificationUri} and enter code: ${userCode} (press Enter to continue)`
478
+ });
479
+ } catch {}
480
+ }
481
+ /**
482
+ * Resolve the poll interval, preferring server-provided value.
483
+ *
484
+ * @private
485
+ * @param serverInterval - The interval from the server response (in ms), or null.
486
+ * @param configInterval - The configured default interval.
487
+ * @returns The resolved interval in milliseconds.
488
+ */
489
+ function resolveInterval(serverInterval, configInterval) {
490
+ if (serverInterval !== null) return serverInterval;
491
+ return configInterval;
492
+ }
493
+ /**
494
+ * Poll the token endpoint for an access token using recursive tail-call style.
495
+ *
496
+ * Handles RFC 8628 error codes:
497
+ * - `authorization_pending` -- continue polling
498
+ * - `slow_down` -- increase interval by 5 seconds, continue
499
+ * - `expired_token` -- return null
500
+ * - `access_denied` -- return null
501
+ *
502
+ * @private
503
+ * @param options - Polling parameters.
504
+ * @returns A bearer credential on success, null on failure or timeout.
505
+ */
506
+ async function pollForToken(options) {
507
+ if (Date.now() >= options.deadline) return null;
508
+ await sleep(options.interval);
509
+ if (Date.now() >= options.deadline) return null;
510
+ return match$1(await requestToken({
511
+ clientId: options.clientId,
512
+ deviceCode: options.deviceCode,
513
+ signal: options.signal,
514
+ tokenUrl: options.tokenUrl
515
+ })).with({ status: "success" }, (r) => r.credential).with({ status: "pending" }, () => pollForToken(options)).with({ status: "slow_down" }, () => pollForToken({
516
+ ...options,
517
+ interval: options.interval + SLOW_DOWN_INCREMENT
518
+ })).with({ status: "denied" }, () => null).with({ status: "expired" }, () => null).with({ status: "error" }, () => null).exhaustive();
519
+ }
520
+ /**
521
+ * Convert a server-provided interval value to milliseconds.
522
+ *
523
+ * @private
524
+ * @param value - The raw interval value from the server response.
525
+ * @returns The interval in milliseconds, or null if not a number.
526
+ */
527
+ function resolveServerInterval(value) {
528
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
529
+ return Math.max(1e3, Math.min(value * 1e3, 6e4));
530
+ }
531
+ /**
532
+ * Sleep for a given duration.
533
+ *
534
+ * @private
535
+ * @param ms - Duration in milliseconds.
536
+ * @returns A promise that resolves after the delay.
537
+ */
538
+ function sleep(ms) {
539
+ return new Promise((resolve) => {
540
+ setTimeout(resolve, ms);
541
+ });
542
+ }
543
+ /**
544
+ * Request an access token from the token endpoint.
545
+ *
546
+ * @private
547
+ * @param options - Token request parameters.
548
+ * @returns A discriminated result indicating the outcome.
549
+ */
550
+ async function requestToken(options) {
551
+ const body = new URLSearchParams({
552
+ client_id: options.clientId,
553
+ device_code: options.deviceCode,
554
+ grant_type: DEVICE_CODE_GRANT_TYPE
555
+ });
556
+ const response = await postFormEncoded(options.tokenUrl, body, options.signal);
557
+ if (!response) return { status: "error" };
558
+ try {
559
+ const data = await response.json();
560
+ if (typeof data !== "object" || data === null) return { status: "error" };
561
+ const record = data;
562
+ if (response.ok && typeof record.access_token === "string" && record.access_token !== "") {
563
+ if (typeof record.token_type === "string" && record.token_type.toLowerCase() !== "bearer") return { status: "error" };
564
+ return {
565
+ credential: createBearerCredential(record.access_token),
566
+ status: "success"
567
+ };
568
+ }
569
+ if (typeof record.error !== "string") return { status: "error" };
570
+ return match$1(record.error).with("authorization_pending", () => ({ status: "pending" })).with("slow_down", () => ({ status: "slow_down" })).with("expired_token", () => ({ status: "expired" })).with("access_denied", () => ({ status: "denied" })).otherwise(() => ({ status: "error" }));
571
+ } catch {
572
+ return { status: "error" };
573
+ }
574
+ }
575
+
576
+ //#endregion
577
+ //#region src/middleware/auth/strategies/dotenv.ts
40
578
  /**
41
579
  * Resolve a bearer credential from a `.env` file without mutating `process.env`.
42
580
  *
@@ -53,15 +591,12 @@ function resolveFromDotenv(options) {
53
591
  const [readError, content] = attempt$1(() => readFileSync(options.path, "utf8"));
54
592
  if (readError || content === null) return null;
55
593
  const token = parse(content)[options.tokenVar];
56
- if (!token) return null;
57
- return {
58
- token,
59
- type: "bearer"
60
- };
594
+ if (!isValidToken(token)) return null;
595
+ return createBearerCredential(token);
61
596
  }
62
597
 
63
598
  //#endregion
64
- //#region src/middleware/auth/resolve-env.ts
599
+ //#region src/middleware/auth/strategies/env.ts
65
600
  /**
66
601
  * Resolve a bearer credential from a process environment variable.
67
602
  *
@@ -70,11 +605,8 @@ function resolveFromDotenv(options) {
70
605
  */
71
606
  function resolveFromEnv(options) {
72
607
  const token = process.env[options.tokenVar];
73
- if (!token) return null;
74
- return {
75
- token,
76
- type: "bearer"
77
- };
608
+ if (!isValidToken(token)) return null;
609
+ return createBearerCredential(token);
78
610
  }
79
611
 
80
612
  //#endregion
@@ -121,7 +653,7 @@ const authCredentialSchema = z.discriminatedUnion("type", [
121
653
  ]);
122
654
 
123
655
  //#endregion
124
- //#region src/middleware/auth/resolve-file.ts
656
+ //#region src/middleware/auth/strategies/file.ts
125
657
  /**
126
658
  * Resolve credentials from a JSON file on disk.
127
659
  *
@@ -131,337 +663,224 @@ const authCredentialSchema = z.discriminatedUnion("type", [
131
663
  *
132
664
  * @param options - Options with the filename and directory name.
133
665
  * @returns A validated auth credential, or null if not found or invalid.
134
- */
135
- function resolveFromFile(options) {
136
- const data = createStore({ dirName: options.dirName }).load(options.filename);
137
- if (data === null) return null;
138
- const result = authCredentialSchema.safeParse(data);
139
- if (!result.success) return null;
140
- return result.data;
141
- }
142
-
143
- //#endregion
144
- //#region src/middleware/auth/resolve-oauth.ts
145
- /**
146
- * Maximum request body size in bytes (16 KB).
147
- *
148
- * Limits memory consumption from the local OAuth callback server
149
- * to prevent resource exhaustion from oversized payloads.
150
- *
151
- * @private
152
- */
153
- const MAX_BODY_BYTES = 16384;
154
- const CLOSE_PAGE_HTML = [
155
- "<!DOCTYPE html>",
156
- "<html>",
157
- "<body><p>Authentication complete. You can close this tab.</p></body>",
158
- "</html>"
159
- ].join("\n");
160
- /**
161
- * Resolve a bearer credential via an OAuth browser flow.
162
- *
163
- * Starts a minimal HTTP server on a local port, opens the user's browser
164
- * to the auth URL with a callback parameter, and waits for the token
165
- * to arrive via POST body.
166
- *
167
- * Only POST requests with a JSON body containing a `token` field are
168
- * accepted. Query-string tokens are rejected to avoid leaking credentials
169
- * in server logs, browser history, and referrer headers.
170
- *
171
- * @param options - OAuth flow configuration.
172
- * @returns A bearer credential on success, null on timeout.
173
- */
174
- async function resolveFromOAuth(options) {
175
- const controller = new AbortController();
176
- const state = randomBytes(32).toString("hex");
177
- const timeout = createTimeout(options.timeout);
178
- const tokenPromise = listenForToken({
179
- callbackPath: options.callbackPath,
180
- port: options.port,
181
- signal: controller.signal,
182
- state
183
- });
184
- const timeoutPromise = timeout.promise.then(() => {
185
- controller.abort();
186
- return null;
187
- });
188
- const serverPort = await getServerPort(tokenPromise);
189
- if (serverPort === null) {
190
- controller.abort();
191
- timeout.clear();
192
- return null;
193
- }
194
- const callbackUrl = `http://127.0.0.1:${String(serverPort)}${options.callbackPath}`;
195
- openBrowser(`${options.authUrl}?callback_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`);
196
- const result = await Promise.race([tokenPromise.result, timeoutPromise]);
197
- timeout.clear();
198
- controller.abort();
199
- return result;
200
- }
201
- /**
202
- * Start an HTTP server that listens for an OAuth callback token.
203
- *
204
- * The server accepts POST requests with a JSON body `{ "token": "..." }`
205
- * on the configured callback path. All other requests receive a 400.
206
- *
207
- * @private
208
- * @param options - Listener configuration.
209
- * @returns A TokenListener with port and result promises.
210
- */
211
- function listenForToken(options) {
212
- const portResolvers = createDeferred();
213
- const resultResolvers = createDeferred();
214
- const sockets = /* @__PURE__ */ new Set();
215
- const server = createServer((req, res) => {
216
- extractTokenFromBody(req, options.callbackPath, options.state, (token) => {
217
- if (!token) {
218
- res.writeHead(400);
219
- res.end();
220
- return;
221
- }
222
- sendSuccessPage(res);
223
- destroyServer(server, sockets);
224
- resultResolvers.resolve({
225
- token,
226
- type: "bearer"
227
- });
228
- });
229
- });
230
- trackConnections(server, sockets);
231
- server.on("error", () => {
232
- destroyServer(server, sockets);
233
- portResolvers.resolve(null);
234
- resultResolvers.resolve(null);
235
- });
236
- options.signal.addEventListener("abort", () => {
237
- destroyServer(server, sockets);
238
- resultResolvers.resolve(null);
239
- });
240
- server.listen(options.port, "127.0.0.1", () => {
241
- const addr = server.address();
242
- if (addr === null || typeof addr === "string") {
243
- destroyServer(server, sockets);
244
- portResolvers.resolve(null);
245
- resultResolvers.resolve(null);
246
- return;
247
- }
248
- portResolvers.resolve(addr.port);
249
- });
250
- return {
251
- port: portResolvers.promise,
252
- result: resultResolvers.promise
253
- };
666
+ */
667
+ function resolveFromFile(options) {
668
+ const data = createStore({ dirName: options.dirName }).load(options.filename);
669
+ if (data === null) return null;
670
+ const result = authCredentialSchema.safeParse(data);
671
+ if (!result.success) return null;
672
+ return result.data;
254
673
  }
674
+
675
+ //#endregion
676
+ //#region src/middleware/auth/strategies/oauth.ts
255
677
  /**
256
- * Track socket connections on a server so they can be destroyed on close.
678
+ * OAuth 2.0 Authorization Code + PKCE resolver (RFC 7636 + RFC 8252).
257
679
  *
258
- * Mutates the provided socket set this is an intentional exception to
259
- * immutability rules because the HTTP server API is inherently stateful.
680
+ * Opens the user's browser to the authorization URL with a PKCE challenge,
681
+ * listens for a GET redirect with an authorization code on a local server,
682
+ * and exchanges the code at the token endpoint with the code verifier.
260
683
  *
261
- * @private
262
- * @param server - The HTTP server.
263
- * @param sockets - The set to track sockets in.
684
+ * @module
264
685
  */
265
- function trackConnections(server, sockets) {
266
- server.on("connection", (socket) => {
267
- sockets.add(socket);
268
- socket.on("close", () => {
269
- sockets.delete(socket);
270
- });
271
- });
272
- }
273
686
  /**
274
- * Close a server and destroy all active connections immediately.
687
+ * Resolve a bearer credential via OAuth 2.0 Authorization Code + PKCE.
275
688
  *
276
- * `server.close()` only stops accepting new connections existing
277
- * keep-alive connections hold the event loop open. This helper
278
- * destroys every tracked socket so the process can exit cleanly.
689
+ * 1. Generates a `code_verifier` and derives the `code_challenge`
690
+ * 2. Starts a local HTTP server on `127.0.0.1`
691
+ * 3. Opens the browser to the authorization URL with PKCE params
692
+ * 4. Receives the authorization code via GET redirect
693
+ * 5. Exchanges the code at the token endpoint with the verifier
694
+ * 6. Returns the access token as a bearer credential
279
695
  *
280
- * @private
281
- * @param server - The HTTP server to close.
282
- * @param sockets - The set of tracked sockets.
696
+ * @param options - PKCE flow configuration.
697
+ * @returns A bearer credential on success, null on failure or timeout.
283
698
  */
284
- function destroyServer(server, sockets) {
285
- server.close();
286
- Array.from(sockets, (socket) => socket.destroy());
287
- sockets.clear();
699
+ async function resolveFromOAuth(options) {
700
+ if (!isSecureAuthUrl(options.authUrl)) return null;
701
+ if (!isSecureAuthUrl(options.tokenUrl)) return null;
702
+ const codeVerifier = generateCodeVerifier();
703
+ const codeChallenge = deriveCodeChallenge(codeVerifier);
704
+ const state = randomBytes(32).toString("hex");
705
+ const timeout = createTimeout(options.timeout);
706
+ const codeDeferred = createDeferred();
707
+ const handle = startLocalServer({
708
+ onRequest: (req, res) => {
709
+ handleCallback(req, res, options.callbackPath, state, codeDeferred.resolve);
710
+ },
711
+ port: options.port
712
+ });
713
+ const serverPort = await handle.port;
714
+ if (serverPort === null) {
715
+ timeout.clear();
716
+ return null;
717
+ }
718
+ const redirectUri = `http://127.0.0.1:${String(serverPort)}${options.callbackPath}`;
719
+ openBrowser(buildAuthUrl({
720
+ authUrl: options.authUrl,
721
+ clientId: options.clientId,
722
+ codeChallenge,
723
+ redirectUri,
724
+ scopes: options.scopes,
725
+ state
726
+ }));
727
+ const timeoutPromise = timeout.promise.then(() => {
728
+ codeDeferred.resolve(null);
729
+ destroyServer(handle.server, handle.sockets);
730
+ return null;
731
+ });
732
+ const code = await Promise.race([codeDeferred.promise, timeoutPromise]);
733
+ timeout.clear();
734
+ if (!code) {
735
+ destroyServer(handle.server, handle.sockets);
736
+ return null;
737
+ }
738
+ destroyServer(handle.server, handle.sockets);
739
+ return await exchangeCodeForToken({
740
+ clientId: options.clientId,
741
+ code,
742
+ codeVerifier,
743
+ redirectUri,
744
+ tokenUrl: options.tokenUrl
745
+ });
288
746
  }
289
747
  /**
290
- * Create a deferred promise with externally accessible resolve.
291
- *
292
- * Uses a mutable state container to capture the promise resolver —
293
- * this is an intentional exception to immutability rules because the
294
- * Promise constructor API requires synchronous resolver capture.
748
+ * Generate a cryptographically random code verifier for PKCE.
295
749
  *
296
750
  * @private
297
- * @returns A deferred object with promise and resolve.
751
+ * @returns A base64url-encoded random string.
298
752
  */
299
- function createDeferred() {
300
- const state = { resolve: null };
301
- return {
302
- promise: new Promise((resolve) => {
303
- state.resolve = resolve;
304
- }),
305
- resolve: (value) => {
306
- if (state.resolve) state.resolve(value);
307
- }
308
- };
753
+ function generateCodeVerifier() {
754
+ return randomBytes(32).toString("base64url");
309
755
  }
310
756
  /**
311
- * Create a clearable timeout.
312
- *
313
- * Returns a promise that resolves after `ms` milliseconds and a `clear`
314
- * function that cancels the timer so it does not hold the event loop open.
315
- *
316
- * Uses a mutable state container to capture the timer id — this is an
317
- * intentional exception to immutability rules because `setTimeout`
318
- * returns an opaque handle that must be stored for later cancellation.
757
+ * Derive a S256 code challenge from a code verifier.
319
758
  *
320
759
  * @private
321
- * @param ms - Duration in milliseconds.
322
- * @returns A Timeout with `promise` and `clear`.
760
+ * @param verifier - The code verifier string.
761
+ * @returns The base64url-encoded SHA-256 hash.
323
762
  */
324
- function createTimeout(ms) {
325
- const state = { id: null };
326
- return {
327
- clear: () => {
328
- if (state.id !== null) {
329
- clearTimeout(state.id);
330
- state.id = null;
331
- }
332
- },
333
- promise: new Promise((resolve) => {
334
- state.id = setTimeout(resolve, ms);
335
- })
336
- };
763
+ function deriveCodeChallenge(verifier) {
764
+ return createHash("sha256").update(verifier).digest("base64url");
337
765
  }
338
766
  /**
339
- * Get the server port from a token listener.
767
+ * Build the full authorization URL with PKCE query parameters.
340
768
  *
341
769
  * @private
342
- * @param listener - The token listener.
343
- * @returns The port number, or null if the server failed to start.
770
+ * @param options - Authorization URL components.
771
+ * @returns The complete authorization URL string.
344
772
  */
345
- async function getServerPort(listener) {
346
- return listener.port;
773
+ function buildAuthUrl(options) {
774
+ const url = new URL(options.authUrl);
775
+ url.searchParams.set("response_type", "code");
776
+ url.searchParams.set("client_id", options.clientId);
777
+ url.searchParams.set("redirect_uri", options.redirectUri);
778
+ url.searchParams.set("code_challenge", options.codeChallenge);
779
+ url.searchParams.set("code_challenge_method", "S256");
780
+ url.searchParams.set("state", options.state);
781
+ if (options.scopes.length > 0) url.searchParams.set("scope", options.scopes.join(" "));
782
+ return url.toString();
347
783
  }
348
784
  /**
349
- * Extract a token from the POST body of an incoming HTTP request.
785
+ * Handle an incoming HTTP request on the callback server.
350
786
  *
351
- * Only POST requests to the callback path with `application/json`
352
- * Content-Type and a JSON body containing `token` and matching `state`
353
- * fields are accepted. Query-string tokens are intentionally rejected
354
- * to prevent credential leakage through browser history, server logs,
355
- * and referrer headers.
356
- *
357
- * The `Content-Type` check prevents CORS-safelisted simple requests
358
- * (which skip preflight) from delivering forged payloads — `text/plain`
359
- * is safelisted, but `application/json` is not (Fetch Standard §2.2.2).
360
- *
361
- * Body size is capped at {@link MAX_BODY_BYTES} to prevent resource
362
- * exhaustion from oversized payloads.
787
+ * Accepts GET requests to the callback path with `code` and `state`
788
+ * query parameters. Validates the state nonce and resolves the
789
+ * authorization code.
363
790
  *
364
791
  * @private
365
- * @param req - The incoming request.
792
+ * @param req - The incoming HTTP request.
793
+ * @param res - The server response.
366
794
  * @param callbackPath - The expected callback path.
367
- * @param expectedState - The state nonce to validate against.
368
- * @param callback - Called with the extracted token or null.
795
+ * @param expectedState - The state nonce to validate.
796
+ * @param resolve - Callback to deliver the authorization code.
369
797
  */
370
- function extractTokenFromBody(req, callbackPath, expectedState, callback) {
371
- if (new URL(req.url ?? "/", "http://localhost").pathname !== callbackPath) {
372
- callback(null);
373
- return;
374
- }
375
- if (req.method !== "POST") {
376
- callback(null);
798
+ function handleCallback(req, res, callbackPath, expectedState, resolve) {
799
+ const result = extractCodeFromUrl(req.url, callbackPath, expectedState);
800
+ if (!result.ok) {
801
+ res.writeHead(400);
802
+ res.end();
803
+ if (result.isOAuthError) resolve(null);
377
804
  return;
378
805
  }
379
- if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
380
- callback(null);
381
- return;
382
- }
383
- const chunks = [];
384
- const received = { bytes: 0 };
385
- req.on("data", (chunk) => {
386
- received.bytes += chunk.length;
387
- if (received.bytes > MAX_BODY_BYTES) {
388
- req.destroy();
389
- callback(null);
390
- return;
391
- }
392
- chunks.push(chunk);
393
- });
394
- req.on("end", () => {
395
- callback(parseTokenFromJson(Buffer.concat(chunks).toString("utf8"), expectedState));
396
- });
397
- req.on("error", () => {
398
- callback(null);
399
- });
806
+ sendSuccessPage(res);
807
+ resolve(result.code);
400
808
  }
401
809
  /**
402
- * Parse a token string from a JSON body and validate the state nonce.
403
- *
404
- * Expects `{ "token": "<value>", "state": "<value>" }`. Returns null
405
- * for invalid JSON, missing/empty token fields, or mismatched state.
810
+ * Extract an authorization code from a request URL.
406
811
  *
407
- * @private
408
- * @param body - The raw request body string.
409
- * @param expectedState - The state nonce that must match.
410
- * @returns The token string or null.
411
- */
412
- function parseTokenFromJson(body, expectedState) {
413
- try {
414
- const parsed = JSON.parse(body);
415
- if (typeof parsed !== "object" || parsed === null) return null;
416
- const record = parsed;
417
- if (typeof record.token !== "string" || record.token === "") return null;
418
- if (record.state !== expectedState) return null;
419
- return record.token;
420
- } catch {
421
- return null;
422
- }
423
- }
424
- /**
425
- * Send an HTML success page and end the response.
812
+ * Validates that the request path matches the callback path,
813
+ * the `state` parameter matches the expected nonce, and a
814
+ * `code` parameter is present. Detects OAuth error responses
815
+ * (e.g. `?error=access_denied`) and flags them so the caller
816
+ * can resolve immediately instead of waiting for the timeout.
426
817
  *
427
818
  * @private
428
- * @param res - The server response object.
819
+ * @param reqUrl - The raw request URL string.
820
+ * @param callbackPath - The expected callback path.
821
+ * @param expectedState - The state nonce to validate.
822
+ * @returns An extraction result with the code or error flag.
429
823
  */
430
- function sendSuccessPage(res) {
431
- res.writeHead(200, { "Content-Type": "text/html" });
432
- res.end(CLOSE_PAGE_HTML);
824
+ function extractCodeFromUrl(reqUrl, callbackPath, expectedState) {
825
+ const url = new URL(reqUrl ?? "/", "http://localhost");
826
+ if (url.pathname !== callbackPath) return {
827
+ isOAuthError: false,
828
+ ok: false
829
+ };
830
+ if (url.searchParams.get("state") !== expectedState) return {
831
+ isOAuthError: false,
832
+ ok: false
833
+ };
834
+ if (url.searchParams.get("error")) return {
835
+ isOAuthError: true,
836
+ ok: false
837
+ };
838
+ const code = url.searchParams.get("code");
839
+ if (!code) return {
840
+ isOAuthError: false,
841
+ ok: false
842
+ };
843
+ return {
844
+ code,
845
+ ok: true
846
+ };
433
847
  }
434
848
  /**
435
- * Open a URL in the user's default browser using a platform-specific command.
849
+ * Exchange an authorization code for an access token at the token endpoint.
436
850
  *
437
- * On Windows, `start` is a `cmd.exe` built-in — not a standalone executable —
438
- * so it must be invoked via `cmd /c start "" <url>`. The empty string argument
439
- * prevents `cmd` from interpreting the URL as a window title.
851
+ * Sends a POST request with `application/x-www-form-urlencoded` body
852
+ * containing the authorization code, redirect URI, client ID, and
853
+ * PKCE code verifier.
440
854
  *
441
855
  * @private
442
- * @param url - The URL to open.
856
+ * @param options - Token exchange parameters.
857
+ * @returns A bearer credential on success, null on failure.
443
858
  */
444
- function openBrowser(url) {
445
- const { command, args } = match$1(platform()).with("darwin", () => ({
446
- args: [url],
447
- command: "open"
448
- })).with("win32", () => ({
449
- args: [
450
- "/c",
451
- "start",
452
- "",
453
- url
454
- ],
455
- command: "cmd"
456
- })).otherwise(() => ({
457
- args: [url],
458
- command: "xdg-open"
459
- }));
460
- execFile(command, args);
859
+ async function exchangeCodeForToken(options) {
860
+ const body = new URLSearchParams({
861
+ client_id: options.clientId,
862
+ code: options.code,
863
+ code_verifier: options.codeVerifier,
864
+ grant_type: "authorization_code",
865
+ redirect_uri: options.redirectUri
866
+ });
867
+ const response = await postFormEncoded(options.tokenUrl, body);
868
+ if (!response) return null;
869
+ if (!response.ok) return null;
870
+ try {
871
+ const data = await response.json();
872
+ if (typeof data !== "object" || data === null) return null;
873
+ const record = data;
874
+ if (typeof record.access_token !== "string" || record.access_token === "") return null;
875
+ if (typeof record.token_type === "string" && record.token_type.toLowerCase() !== "bearer") return null;
876
+ return createBearerCredential(record.access_token);
877
+ } catch {
878
+ return null;
879
+ }
461
880
  }
462
881
 
463
882
  //#endregion
464
- //#region src/middleware/auth/resolve-prompt.ts
883
+ //#region src/middleware/auth/strategies/token.ts
465
884
  /**
466
885
  * Resolve a bearer credential by interactively prompting the user.
467
886
  *
@@ -473,24 +892,18 @@ function openBrowser(url) {
473
892
  * @param options - Options with the prompt message and prompts instance.
474
893
  * @returns A bearer credential on input, null on cancellation.
475
894
  */
476
- async function resolveFromPrompt(options) {
895
+ async function resolveFromToken(options) {
477
896
  try {
478
897
  const token = await options.prompts.password({ message: options.message });
479
- if (!token) return null;
480
- return {
481
- token,
482
- type: "bearer"
483
- };
898
+ if (!isValidToken(token)) return null;
899
+ return createBearerCredential(token);
484
900
  } catch {
485
901
  return null;
486
902
  }
487
903
  }
488
904
 
489
905
  //#endregion
490
- //#region src/middleware/auth/resolve-credentials.ts
491
- const DEFAULT_OAUTH_PORT = 0;
492
- const DEFAULT_OAUTH_CALLBACK_PATH = "/callback";
493
- const DEFAULT_OAUTH_TIMEOUT = 12e4;
906
+ //#region src/middleware/auth/chain.ts
494
907
  const DEFAULT_PROMPT_MESSAGE = "Enter your API key";
495
908
  /**
496
909
  * Chain credential resolvers, returning the first non-null result.
@@ -502,11 +915,22 @@ const DEFAULT_PROMPT_MESSAGE = "Enter your API key";
502
915
  * @param options - Options with resolvers, CLI name, and prompts instance.
503
916
  * @returns The first resolved credential, or null if all resolvers fail.
504
917
  */
505
- async function resolveCredentials(options) {
918
+ async function runStrategyChain(options) {
506
919
  const defaultTokenVar = deriveTokenVar(options.cliName);
507
920
  return tryResolvers(options.resolvers, 0, defaultTokenVar, options);
508
921
  }
509
922
  /**
923
+ * Return the given value when defined, otherwise the fallback.
924
+ *
925
+ * @param value - The optional value.
926
+ * @param fallback - The default value.
927
+ * @returns The resolved value.
928
+ */
929
+ function withDefault(value, fallback) {
930
+ if (value !== void 0) return value;
931
+ return fallback;
932
+ }
933
+ /**
510
934
  * Recursively try resolvers until one returns a credential or the list is exhausted.
511
935
  *
512
936
  * @private
@@ -534,56 +958,44 @@ async function tryResolvers(configs, index, defaultTokenVar, context) {
534
958
  * @returns The resolved credential, or null.
535
959
  */
536
960
  async function dispatchResolver(config, defaultTokenVar, context) {
537
- return match$1(config).with({ source: "env" }, (c) => resolveFromEnv({ tokenVar: resolveOptionalString(c.tokenVar, defaultTokenVar) })).with({ source: "dotenv" }, (c) => resolveFromDotenv({
538
- path: resolveOptionalString(c.path, join(process.cwd(), ".env")),
539
- tokenVar: resolveOptionalString(c.tokenVar, defaultTokenVar)
961
+ return match$1(config).with({ source: "env" }, (c) => resolveFromEnv({ tokenVar: withDefault(c.tokenVar, defaultTokenVar) })).with({ source: "dotenv" }, (c) => resolveFromDotenv({
962
+ path: withDefault(c.path, join(process.cwd(), ".env")),
963
+ tokenVar: withDefault(c.tokenVar, defaultTokenVar)
540
964
  })).with({ source: "file" }, (c) => resolveFromFile({
541
- dirName: resolveOptionalString(c.dirName, `.${context.cliName}`),
542
- filename: resolveOptionalString(c.filename, DEFAULT_AUTH_FILENAME)
965
+ dirName: withDefault(c.dirName, `.${context.cliName}`),
966
+ filename: withDefault(c.filename, DEFAULT_AUTH_FILENAME)
543
967
  })).with({ source: "oauth" }, (c) => resolveFromOAuth({
544
968
  authUrl: c.authUrl,
545
- callbackPath: resolveOptionalString(c.callbackPath, DEFAULT_OAUTH_CALLBACK_PATH),
546
- port: resolveOptionalNumber(c.port, DEFAULT_OAUTH_PORT),
547
- timeout: resolveOptionalNumber(c.timeout, DEFAULT_OAUTH_TIMEOUT)
548
- })).with({ source: "prompt" }, (c) => resolveFromPrompt({
549
- message: resolveOptionalString(c.message, DEFAULT_PROMPT_MESSAGE),
969
+ callbackPath: withDefault(c.callbackPath, DEFAULT_OAUTH_CALLBACK_PATH),
970
+ clientId: c.clientId,
971
+ port: withDefault(c.port, DEFAULT_OAUTH_PORT),
972
+ scopes: withDefault(c.scopes, []),
973
+ timeout: withDefault(c.timeout, DEFAULT_OAUTH_TIMEOUT),
974
+ tokenUrl: c.tokenUrl
975
+ })).with({ source: "device-code" }, (c) => resolveFromDeviceCode({
976
+ clientId: c.clientId,
977
+ deviceAuthUrl: c.deviceAuthUrl,
978
+ openBrowserOnStart: withDefault(c.openBrowser, true),
979
+ pollInterval: withDefault(c.pollInterval, DEFAULT_DEVICE_CODE_POLL_INTERVAL),
980
+ prompts: context.prompts,
981
+ scopes: withDefault(c.scopes, []),
982
+ timeout: withDefault(c.timeout, DEFAULT_DEVICE_CODE_TIMEOUT),
983
+ tokenUrl: c.tokenUrl
984
+ })).with({ source: "token" }, (c) => resolveFromToken({
985
+ message: withDefault(c.message, DEFAULT_PROMPT_MESSAGE),
550
986
  prompts: context.prompts
551
987
  })).with({ source: "custom" }, (c) => c.resolver()).exhaustive();
552
988
  }
553
- /**
554
- * Resolve an optional string value, falling back to a default.
555
- *
556
- * @private
557
- * @param value - The optional value.
558
- * @param fallback - The default value.
559
- * @returns The resolved string.
560
- */
561
- function resolveOptionalString(value, fallback) {
562
- if (value !== void 0) return value;
563
- return fallback;
564
- }
565
- /**
566
- * Resolve an optional number value, falling back to a default.
567
- *
568
- * @private
569
- * @param value - The optional value.
570
- * @param fallback - The default value.
571
- * @returns The resolved number.
572
- */
573
- function resolveOptionalNumber(value, fallback) {
574
- if (value !== void 0) return value;
575
- return fallback;
576
- }
577
989
 
578
990
  //#endregion
579
- //#region src/middleware/auth/create-auth-context.ts
991
+ //#region src/middleware/auth/context.ts
580
992
  /**
581
993
  * Create an {@link AuthContext} value for `ctx.auth`.
582
994
  *
583
995
  * No credential data is stored on the returned object. `credential()`
584
996
  * resolves passively on every call, `authenticated()` checks existence,
585
- * and `authenticate()` runs the configured interactive resolvers, saves
586
- * the credential to the global file store, and returns a Result.
997
+ * `login()` runs the configured interactive resolvers, saves the
998
+ * credential to the global file store, and `logout()` removes it.
587
999
  *
588
1000
  * @param options - Factory options.
589
1001
  * @returns An AuthContext instance.
@@ -612,49 +1024,65 @@ function createAuthContext(options) {
612
1024
  * Run configured resolvers interactively and persist the credential.
613
1025
  *
614
1026
  * @private
615
- * @returns A Result with the credential on success or a LoginError on failure.
1027
+ * @returns A Result with the credential on success or an AuthError on failure.
616
1028
  */
617
- async function authenticate() {
618
- const resolved = await resolveCredentials({
1029
+ async function login() {
1030
+ const resolved = await runStrategyChain({
619
1031
  cliName,
620
1032
  prompts,
621
1033
  resolvers
622
1034
  });
623
- if (resolved === null) return loginError({
1035
+ if (resolved === null) return authError({
624
1036
  message: "No credential resolved from any source",
625
1037
  type: "no_credential"
626
1038
  });
627
1039
  const [saveError] = createStore({ dirName: `.${cliName}` }).save(DEFAULT_AUTH_FILENAME, resolved);
628
- if (saveError) return loginError({
1040
+ if (saveError) return authError({
629
1041
  message: `Failed to save credential: ${saveError.message}`,
630
1042
  type: "save_failed"
631
1043
  });
632
1044
  return ok(resolved);
633
1045
  }
1046
+ /**
1047
+ * Remove the stored credential from disk.
1048
+ *
1049
+ * @private
1050
+ * @returns A Result with the removed file path on success or an AuthError on failure.
1051
+ */
1052
+ async function logout() {
1053
+ const [removeError, filePath] = createStore({ dirName: `.${cliName}` }).remove(DEFAULT_AUTH_FILENAME);
1054
+ if (removeError) return authError({
1055
+ message: `Failed to remove credential: ${removeError.message}`,
1056
+ type: "remove_failed"
1057
+ });
1058
+ return ok(filePath);
1059
+ }
634
1060
  return {
635
- authenticate,
636
1061
  authenticated,
637
- credential
1062
+ credential,
1063
+ login,
1064
+ logout
638
1065
  };
639
1066
  }
640
1067
  /**
641
- * Construct a failure Result tuple with a {@link LoginError}.
1068
+ * Construct a failure Result tuple with an {@link AuthError}.
642
1069
  *
643
1070
  * @private
644
- * @param error - The login error.
645
- * @returns A Result tuple `[LoginError, null]`.
1071
+ * @param error - The auth error.
1072
+ * @returns A Result tuple `[AuthError, null]`.
646
1073
  */
647
- function loginError(error) {
1074
+ function authError(error) {
648
1075
  return [error, null];
649
1076
  }
650
1077
 
651
1078
  //#endregion
652
1079
  //#region src/middleware/auth/auth.ts
653
1080
  /**
654
- * Auth middleware factory.
1081
+ * Auth middleware factory with resolver builder functions.
655
1082
  *
656
1083
  * Decorates `ctx.auth` with functions to resolve credentials on demand
657
- * and run interactive authentication.
1084
+ * and run interactive authentication. Also supports creating authenticated
1085
+ * HTTP clients via the `http` option.
658
1086
  *
659
1087
  * @module
660
1088
  */
@@ -662,50 +1090,207 @@ function loginError(error) {
662
1090
  * Create an auth middleware that decorates `ctx.auth`.
663
1091
  *
664
1092
  * No credential data is stored on the context. `ctx.auth.credential()`
665
- * resolves passively from two sources on every call:
1093
+ * resolves passively from three sources on every call:
666
1094
  * 1. File — `~/.cli-name/auth.json`
667
- * 2. Env — `CLI_NAME_TOKEN`
1095
+ * 2. Dotenv`.env` file (when configured)
1096
+ * 3. Env — `CLI_NAME_TOKEN`
668
1097
  *
669
1098
  * Interactive resolvers (OAuth, prompt, custom) only run when the
670
- * command handler explicitly calls `ctx.auth.authenticate()`.
1099
+ * command handler explicitly calls `ctx.auth.login()`.
1100
+ *
1101
+ * When `options.http` is provided, the middleware also creates HTTP
1102
+ * client(s) with automatic credential header injection and decorates
1103
+ * them onto `ctx[namespace]`.
671
1104
  *
672
1105
  * @param options - Auth middleware configuration.
673
- * @returns A Middleware that decorates ctx.auth.
1106
+ * @returns A Middleware that decorates ctx.auth (and optionally HTTP clients).
674
1107
  */
675
- function auth(options) {
1108
+ function createAuth(options) {
676
1109
  const { resolvers } = options;
677
1110
  return middleware((ctx, next) => {
678
1111
  const cliName = ctx.meta.name;
679
- decorateContext(ctx, "auth", createAuthContext({
1112
+ const authContext = createAuthContext({
680
1113
  cliName,
681
1114
  prompts: ctx.prompts,
682
- resolveCredential: () => resolvePassive(cliName, resolvers),
1115
+ resolveCredential: () => resolveStoredCredential(cliName, resolvers),
683
1116
  resolvers
684
- }));
1117
+ });
1118
+ decorateContext(ctx, "auth", authContext);
1119
+ if (options.http !== void 0) normalizeHttpOptions(options.http).reduce((context, httpConfig) => {
1120
+ const client = createHttpClient({
1121
+ baseUrl: httpConfig.baseUrl,
1122
+ defaultHeaders: httpConfig.headers,
1123
+ resolveHeaders: () => credentialToHeaders(authContext.credential())
1124
+ });
1125
+ return decorateContext(context, httpConfig.namespace, client);
1126
+ }, ctx);
685
1127
  return next();
686
1128
  });
687
1129
  }
688
1130
  /**
689
- * Attempt to resolve a credential from passive (non-interactive) sources.
1131
+ * Auth middleware factory with resolver builder methods.
1132
+ *
1133
+ * Use as `auth({ resolvers: [...] })` to create middleware, or use
1134
+ * the builder methods (`auth.env()`, `auth.oauth()`, etc.) to construct
1135
+ * resolver configs with a cleaner API.
1136
+ */
1137
+ const auth = Object.assign(createAuth, {
1138
+ apiKey: buildToken,
1139
+ custom: buildCustom,
1140
+ deviceCode: buildDeviceCode,
1141
+ dotenv: buildDotenv,
1142
+ env: buildEnv,
1143
+ file: buildFile,
1144
+ oauth: buildOAuth,
1145
+ token: buildToken
1146
+ });
1147
+ /**
1148
+ * Build an env resolver config.
1149
+ *
1150
+ * @private
1151
+ * @param options - Optional env resolver options.
1152
+ * @returns An EnvSourceConfig with `source: 'env'`.
1153
+ */
1154
+ function buildEnv(options) {
1155
+ return {
1156
+ source: "env",
1157
+ ...options
1158
+ };
1159
+ }
1160
+ /**
1161
+ * Build a dotenv resolver config.
690
1162
  *
691
- * Checks the file store first, then falls back to the environment variable.
692
- * Scans the resolver list for `env` and `file` source configs to respect
693
- * user-configured overrides (e.g. a custom `tokenVar` or `dirName`).
1163
+ * @private
1164
+ * @param options - Optional dotenv resolver options.
1165
+ * @returns A DotenvSourceConfig with `source: 'dotenv'`.
1166
+ */
1167
+ function buildDotenv(options) {
1168
+ return {
1169
+ source: "dotenv",
1170
+ ...options
1171
+ };
1172
+ }
1173
+ /**
1174
+ * Build a file resolver config.
1175
+ *
1176
+ * @private
1177
+ * @param options - Optional file resolver options.
1178
+ * @returns A FileSourceConfig with `source: 'file'`.
1179
+ */
1180
+ function buildFile(options) {
1181
+ return {
1182
+ source: "file",
1183
+ ...options
1184
+ };
1185
+ }
1186
+ /**
1187
+ * Build an OAuth resolver config.
1188
+ *
1189
+ * @private
1190
+ * @param options - OAuth resolver options (clientId, authUrl, tokenUrl required).
1191
+ * @returns An OAuthSourceConfig with `source: 'oauth'`.
1192
+ */
1193
+ function buildOAuth(options) {
1194
+ return {
1195
+ source: "oauth",
1196
+ ...options
1197
+ };
1198
+ }
1199
+ /**
1200
+ * Build a device code resolver config.
1201
+ *
1202
+ * @private
1203
+ * @param options - Device code resolver options (clientId, deviceAuthUrl, tokenUrl required).
1204
+ * @returns A DeviceCodeSourceConfig with `source: 'device-code'`.
1205
+ */
1206
+ function buildDeviceCode(options) {
1207
+ return {
1208
+ source: "device-code",
1209
+ ...options
1210
+ };
1211
+ }
1212
+ /**
1213
+ * Build a token resolver config.
1214
+ *
1215
+ * Prompts the user for a token interactively. Aliased as `auth.apiKey()`.
1216
+ *
1217
+ * @private
1218
+ * @param options - Optional token resolver options.
1219
+ * @returns A TokenSourceConfig with `source: 'token'`.
1220
+ */
1221
+ function buildToken(options) {
1222
+ return {
1223
+ source: "token",
1224
+ ...options
1225
+ };
1226
+ }
1227
+ /**
1228
+ * Build a custom resolver config from a resolver function.
1229
+ *
1230
+ * @private
1231
+ * @param resolver - The custom resolver function.
1232
+ * @returns A CustomSourceConfig with `source: 'custom'`.
1233
+ */
1234
+ function buildCustom(resolver) {
1235
+ return {
1236
+ resolver,
1237
+ source: "custom"
1238
+ };
1239
+ }
1240
+ /**
1241
+ * Normalize the `http` option into an array of configs.
1242
+ *
1243
+ * @private
1244
+ * @param http - A single config or array of configs.
1245
+ * @returns An array of AuthHttpOptions.
1246
+ */
1247
+ function normalizeHttpOptions(http) {
1248
+ if ("baseUrl" in http) return [http];
1249
+ return http;
1250
+ }
1251
+ /**
1252
+ * Convert a credential into auth headers, returning an empty record
1253
+ * when no credential is available.
1254
+ *
1255
+ * @private
1256
+ * @param credential - The credential or null.
1257
+ * @returns A record of auth headers.
1258
+ */
1259
+ function credentialToHeaders(credential) {
1260
+ if (credential === null) return {};
1261
+ return buildAuthHeaders(credential);
1262
+ }
1263
+ /**
1264
+ * Attempt to resolve a credential from stored (non-interactive) sources.
1265
+ *
1266
+ * Checks the file store first, then dotenv, then falls back to the
1267
+ * environment variable. Scans the resolver list for `file`, `dotenv`,
1268
+ * and `env` source configs to respect user-configured overrides
1269
+ * (e.g. a custom `tokenVar`, `dirName`, or dotenv `path`).
694
1270
  *
695
1271
  * @private
696
1272
  * @param cliName - The CLI name, used to derive paths and env var names.
697
1273
  * @param resolvers - The configured resolver list for extracting overrides.
698
1274
  * @returns The resolved credential, or null.
699
1275
  */
700
- function resolvePassive(cliName, resolvers) {
1276
+ function resolveStoredCredential(cliName, resolvers) {
701
1277
  const fileConfig = findResolverBySource(resolvers, "file");
1278
+ const dotenvConfig = findResolverBySource(resolvers, "dotenv");
702
1279
  const envConfig = findResolverBySource(resolvers, "env");
1280
+ const defaultTokenVar = deriveTokenVar(cliName);
703
1281
  const fromFile = resolveFromFile({
704
- dirName: resolveFileDir(fileConfig, cliName),
705
- filename: resolveFileFilename(fileConfig)
1282
+ dirName: withDefault(extractProp(fileConfig, "dirName"), `.${cliName}`),
1283
+ filename: withDefault(extractProp(fileConfig, "filename"), DEFAULT_AUTH_FILENAME)
706
1284
  });
707
1285
  if (fromFile) return fromFile;
708
- return resolveFromEnv({ tokenVar: resolveEnvTokenVar(envConfig, cliName) });
1286
+ if (dotenvConfig !== void 0) {
1287
+ const fromDotenv = resolveFromDotenv({
1288
+ path: withDefault(extractProp(dotenvConfig, "path"), join(process.cwd(), ".env")),
1289
+ tokenVar: withDefault(extractProp(dotenvConfig, "tokenVar"), defaultTokenVar)
1290
+ });
1291
+ if (fromDotenv) return fromDotenv;
1292
+ }
1293
+ return resolveFromEnv({ tokenVar: withDefault(extractProp(envConfig, "tokenVar"), defaultTokenVar) });
709
1294
  }
710
1295
  /**
711
1296
  * Find the first resolver config matching a given source type.
@@ -719,39 +1304,19 @@ function findResolverBySource(resolvers, source) {
719
1304
  return resolvers.find((r) => r.source === source);
720
1305
  }
721
1306
  /**
722
- * Resolve the file store directory name from a file resolver config.
723
- *
724
- * @private
725
- * @param config - The file resolver config, or undefined.
726
- * @param cliName - The CLI name for deriving the default.
727
- * @returns The directory name.
728
- */
729
- function resolveFileDir(config, cliName) {
730
- if (config !== void 0 && config.dirName !== void 0) return config.dirName;
731
- return `.${cliName}`;
732
- }
733
- /**
734
- * Resolve the file store filename from a file resolver config.
1307
+ * Safely extract a property from an optional config object.
735
1308
  *
736
- * @private
737
- * @param config - The file resolver config, or undefined.
738
- * @returns The filename.
739
- */
740
- function resolveFileFilename(config) {
741
- if (config !== void 0 && config.filename !== void 0) return config.filename;
742
- return DEFAULT_AUTH_FILENAME;
743
- }
744
- /**
745
- * Resolve the environment variable name from an env resolver config.
1309
+ * Returns the property value when the config is defined, or undefined
1310
+ * when the config itself is undefined.
746
1311
  *
747
1312
  * @private
748
- * @param config - The env resolver config, or undefined.
749
- * @param cliName - The CLI name for deriving the default.
750
- * @returns The token variable name.
1313
+ * @param config - The config object, or undefined.
1314
+ * @param key - The property key to extract.
1315
+ * @returns The property value, or undefined.
751
1316
  */
752
- function resolveEnvTokenVar(config, cliName) {
753
- if (config !== void 0 && config.tokenVar !== void 0) return config.tokenVar;
754
- return deriveTokenVar(cliName);
1317
+ function extractProp(config, key) {
1318
+ if (config === void 0) return;
1319
+ return config[key];
755
1320
  }
756
1321
 
757
1322
  //#endregion