@typeship-ax/cli 0.8.0 → 0.10.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 (106) hide show
  1. package/AGENTS.md +31 -0
  2. package/README.md +26 -9
  3. package/api.json +3509 -800
  4. package/api.md +444 -494
  5. package/dist/api-identity.d.ts +40 -0
  6. package/dist/api-identity.d.ts.map +1 -0
  7. package/dist/api-identity.js +128 -0
  8. package/dist/auth-profiles.d.ts +30 -0
  9. package/dist/auth-profiles.d.ts.map +1 -0
  10. package/dist/auth-profiles.js +138 -0
  11. package/dist/cli.js +522 -355
  12. package/dist/console-login-check.d.ts +21 -0
  13. package/dist/console-login-check.d.ts.map +1 -0
  14. package/dist/console-login-check.js +107 -0
  15. package/dist/console-login-contract.d.ts +45 -0
  16. package/dist/console-login-contract.d.ts.map +1 -0
  17. package/dist/console-login-contract.js +40 -0
  18. package/dist/core/http.d.ts +17 -2
  19. package/dist/core/http.d.ts.map +1 -1
  20. package/dist/core/http.js +78 -17
  21. package/dist/credential-storage.d.ts +24 -0
  22. package/dist/credential-storage.d.ts.map +1 -0
  23. package/dist/credential-storage.js +207 -0
  24. package/dist/docs.d.ts +25 -0
  25. package/dist/docs.d.ts.map +1 -1
  26. package/dist/docs.js +144 -0
  27. package/dist/errors.d.ts +18 -10
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +24 -14
  30. package/dist/index.d.ts +10 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +20 -4
  33. package/dist/named-credentials.d.ts +21 -0
  34. package/dist/named-credentials.d.ts.map +1 -0
  35. package/dist/named-credentials.js +86 -0
  36. package/dist/oauth-login.d.ts +39 -0
  37. package/dist/oauth-login.d.ts.map +1 -0
  38. package/dist/oauth-login.js +171 -0
  39. package/dist/oauth-request.d.ts +21 -0
  40. package/dist/oauth-request.d.ts.map +1 -0
  41. package/dist/oauth-request.js +119 -0
  42. package/dist/oauth-session.d.ts +106 -0
  43. package/dist/oauth-session.d.ts.map +1 -0
  44. package/dist/oauth-session.js +244 -0
  45. package/dist/ops.d.ts +14 -1
  46. package/dist/ops.d.ts.map +1 -1
  47. package/dist/ops.js +34 -30
  48. package/dist/polling-login.d.ts +57 -0
  49. package/dist/polling-login.d.ts.map +1 -0
  50. package/dist/polling-login.js +204 -0
  51. package/dist/resources/account.d.ts +2 -2
  52. package/dist/resources/account.d.ts.map +1 -1
  53. package/dist/resources/account.js +1 -0
  54. package/dist/resources/api-keys.d.ts +3 -3
  55. package/dist/resources/api-keys.d.ts.map +1 -1
  56. package/dist/resources/api-keys.js +2 -0
  57. package/dist/resources/definition-revisions.d.ts +5 -5
  58. package/dist/resources/definition-revisions.d.ts.map +1 -1
  59. package/dist/resources/definition-revisions.js +4 -0
  60. package/dist/resources/definitions.d.ts +15 -4
  61. package/dist/resources/definitions.d.ts.map +1 -1
  62. package/dist/resources/definitions.js +11 -2
  63. package/dist/resources/generate.d.ts +14 -3
  64. package/dist/resources/generate.d.ts.map +1 -1
  65. package/dist/resources/generate.js +10 -2
  66. package/dist/resources/generations.d.ts +3 -3
  67. package/dist/resources/generations.d.ts.map +1 -1
  68. package/dist/resources/generations.js +2 -0
  69. package/dist/resources/projects.d.ts +56 -20
  70. package/dist/resources/projects.d.ts.map +1 -1
  71. package/dist/resources/projects.js +40 -4
  72. package/dist/resources/targets.d.ts +84 -10
  73. package/dist/resources/targets.d.ts.map +1 -1
  74. package/dist/resources/targets.js +127 -2
  75. package/dist/schemas.d.ts.map +1 -1
  76. package/dist/schemas.js +55 -26
  77. package/dist/types.d.ts +602 -123
  78. package/dist/types.d.ts.map +1 -1
  79. package/dist/types.js +24 -0
  80. package/package.json +2 -1
  81. package/src/api-identity.ts +98 -0
  82. package/src/auth-profiles.ts +114 -0
  83. package/src/cli.ts +444 -332
  84. package/src/console-login-check.ts +88 -0
  85. package/src/console-login-contract.ts +65 -0
  86. package/src/core/http.ts +88 -19
  87. package/src/credential-storage.ts +183 -0
  88. package/src/docs.ts +138 -0
  89. package/src/errors.ts +26 -15
  90. package/src/index.ts +29 -4
  91. package/src/named-credentials.ts +74 -0
  92. package/src/oauth-login.ts +184 -0
  93. package/src/oauth-request.ts +90 -0
  94. package/src/oauth-session.ts +258 -0
  95. package/src/ops.ts +48 -31
  96. package/src/polling-login.ts +165 -0
  97. package/src/resources/account.ts +3 -0
  98. package/src/resources/api-keys.ts +5 -0
  99. package/src/resources/definition-revisions.ts +9 -0
  100. package/src/resources/definitions.ts +25 -0
  101. package/src/resources/generate.ts +23 -0
  102. package/src/resources/generations.ts +5 -0
  103. package/src/resources/projects.ts +95 -7
  104. package/src/resources/targets.ts +241 -0
  105. package/src/schemas.ts +55 -26
  106. package/src/types.ts +640 -123
package/src/cli.ts CHANGED
@@ -6,7 +6,16 @@
6
6
  // Exit codes: 0 success, 1 API/transport error, 2 usage error.
7
7
 
8
8
  import { spawnSync } from "node:child_process";
9
- import { createHash, randomBytes } from "node:crypto";
9
+ import { oauthBrowserLogin, type OAuthLoginSession } from "./oauth-login.js";
10
+ import { oauthDeviceLogin, customBrowserApproval, loginEndpoint, type ApprovedCredential } from "./polling-login.js";
11
+ import { oauthStatusRequest } from "./oauth-request.js";
12
+ import { checkConsoleBrowserLogin } from "./console-login-check.js";
13
+ import { type FileCredentialStore, assertCredentialDestination, assertStoredIdentity, credentialIdentityBinding, type CredentialDestination, oauthSessionToken, sessionBinding, type SessionConfiguration, type StoredCredentials as StoredCreds } from "./oauth-session.js";
14
+ import { createCredentialStore } from "./credential-storage.js";
15
+ import { identityPolicyOf, identityFetch, identityResult, verifyApiIdentity, verifyClientIdentity, readApiIdentity, assertApiIdentity, type ApiIdentity, type IdentityConfiguration, type IdentityPolicy, type VerifiedIdentity } from "./api-identity.js";
16
+ import { parseNamedCredentials, readNamedCredentialsFile, resolveNamedCredentials, namedCredentialAvailability, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
17
+ import { resolveProfile, listProfiles, selectProfile, removeProfile, readProfileConfig, updateProfileConfig, type ProfileContext } from "./auth-profiles.js";
18
+ import { randomBytes } from "node:crypto";
10
19
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
11
20
  import { homedir, hostname } from "node:os";
12
21
  import { basename, dirname, join } from "node:path";
@@ -19,20 +28,23 @@ import {
19
28
  type AgentContext, type CommandSummary, type DoctorCheck, type EnvelopeInput, type IssueCode, type McpEntry, type McpWriteResult,
20
29
  } from "./cli-agent.js";
21
30
  import { relativeDate } from "./dates.js";
22
- import { fetchDocsText, resolveDocsContentUrl } from "./docs.js";
31
+ import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
23
32
 
24
33
 
25
34
  const BIN = "typeship";
26
35
  const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
36
+ const NAMED_SCHEMES: CredentialSchemes = {"apiKey":{"kind":"bearer","options":["bearerToken"]}};
27
37
  const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
28
38
  const BASIC: { envUser: string; envPass: string } | null = null;
29
39
  /** Operations omitted from the generated package by its plan cap. */
30
40
  const EXCLUDED_OPS = 0;
31
41
  /** Generated CLI operations that are intentionally unavailable to MCP. */
32
42
  const MCP_EXCLUDED_OPS = 0;
33
- const VERSION = "0.8.0";
43
+ const VERSION = "0.10.0";
34
44
  const API_VERSION = "1.0.0";
35
45
  const SPEC_FORMAT = "openapi";
46
+ const IDENTITY_POLICY: IdentityPolicy = {};
47
+ let LOGIN_IDENTITY: VerifiedIdentity | undefined;
36
48
  const WHOAMI: { resource: string; method: string } | null = {"resource":"account","method":"retrieve"};
37
49
  const ENVIRONMENTS: Record<string, string> = {};
38
50
  const HAS_MCP = false;
@@ -44,6 +56,15 @@ const DOCS_INDEX_URL_DEFAULT: string | null = null;
44
56
  const RELAY: { mintUrl: string; project: string } | null = null;
45
57
  const SUPPORT_URL: string | null = null;
46
58
  const OAUTH_TOKEN_URL: string | null = null;
59
+ const OAUTH_ISSUER: string | null = null;
60
+ const OAUTH_DISCOVERY_URL: string | undefined = undefined;
61
+ const OAUTH_DISCOVERY_URLS: string[] = [];
62
+ const OAUTH_DEVICE_URL: string | null = null;
63
+ const HAS_OAUTH_LOGIN = OAUTH_TOKEN_URL !== null || OAUTH_DISCOVERY_URLS.length > 0;
64
+ const OAUTH_LOGIN_METHOD: string = "device";
65
+ const OAUTH_REDIRECT_URI: string | undefined = undefined;
66
+ const OAUTH_ORGANIZATION_PARAMETER: "organization" | "organization_id" | undefined = undefined;
67
+ const OAUTH_AUTHORIZATION_URL: string | undefined = undefined;
47
68
  const OAUTH_CLIENT_ID: string | null = null;
48
69
  const OAUTH_SCOPES: string[] = [];
49
70
  const OAUTH_TOKEN_PARAMS: Record<string, string> = {};
@@ -70,11 +91,12 @@ interface Parsed {
70
91
  * is known. */
71
92
  const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json"]);
72
93
  const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
73
- login: ["with-token", "no-browser"],
94
+ login: ["with-token", "no-browser", "device"],
95
+ logout: ["local"],
74
96
  upgrade: ["check"],
75
97
  mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
76
98
  docs: ["web", "schema"],
77
- init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md"],
99
+ init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md", "no-browser"],
78
100
  auth: ["live"],
79
101
  doctor: [],
80
102
  };
@@ -308,35 +330,84 @@ function failApi(error: unknown, hadCredential: boolean): never {
308
330
  // credentials — written by `login`, cleared by `logout`
309
331
  // ---------------------------------------------------------------------------
310
332
 
311
- interface StoredCreds {
312
- scalars?: Record<string, string>;
313
- basic?: { username: string; password: string };
314
- oauth?: { accessToken: string; refreshToken?: string; expiresAt?: number };
315
- /** Set when this CLI minted the stored credential for itself (browser
316
- * approval), so `logout` knows it may revoke it. A pasted key is not. */
317
- minted?: { via: "browser"; key_name: string; org_id?: string };
318
- }
319
-
320
- function configDir(): string {
333
+ function configRoot(): string {
321
334
  const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
322
335
  return join(base, BIN);
323
336
  }
337
+ let PROFILE: ProfileContext;
338
+ function configDir(): string { return PROFILE.directory; }
324
339
 
325
340
  function credsPath(): string {
326
- return join(configDir(), "credentials.json");
341
+ return credentialStore().path;
327
342
  }
328
343
 
329
- function readCreds(): StoredCreds | null {
330
- try {
331
- return JSON.parse(readFileSync(credsPath(), "utf8")) as StoredCreds;
332
- } catch {
333
- return null;
344
+ function credentialStore(): FileCredentialStore { return createCredentialStore(configDir(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
345
+ function readCreds(): StoredCreds | null { return credentialStore().read(); }
346
+ function sessionConfiguration(baseUrl: string, config = readConfig()): SessionConfiguration {
347
+ return {
348
+ apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name,
349
+ identity: identityConfiguration(),
350
+ clientId: process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID,
351
+ issuer: OAUTH_ISSUER, discoveryUrl: OAUTH_DISCOVERY_URL, tokenUrl: OAUTH_TOKEN_URL,
352
+ authorizationUrl: OAUTH_AUTHORIZATION_URL, scopes: OAUTH_SCOPES,
353
+ audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
354
+ organizationParameter: OAUTH_ORGANIZATION_PARAMETER,
355
+ };
356
+ }
357
+
358
+ function identityConfiguration(): IdentityConfiguration | undefined {
359
+ return Object.keys(IDENTITY_POLICY).length && WHOAMI ? { operation: WHOAMI.resource + "." + WHOAMI.method, fields: IDENTITY_POLICY } : undefined;
360
+ }
361
+ function expectedLoginIdentity(flags: Map<string, string | boolean>): ApiIdentity | undefined {
362
+ const expected: ApiIdentity = {};
363
+ for (const kind of ["subject", "account", "organization"] as const) {
364
+ const value = flags.get(kind);
365
+ if (value === undefined) continue;
366
+ if (typeof value !== "string" || !value || !IDENTITY_POLICY[kind]) fail(2, "--" + kind + " requires an expected ID and a configured identity field for that ID.");
367
+ expected[kind] = value as string;
334
368
  }
369
+ const selection = requestedLoginOrganization(flags);
370
+ if (selection) {
371
+ if (expected.organization !== undefined && expected.organization !== selection.id) fail(2, "--organization must match --login-organization when both are provided.");
372
+ expected.organization = selection.id;
373
+ }
374
+ return Object.keys(expected).length ? expected : undefined;
375
+ }
376
+ function requestedLoginOrganization(flags: Map<string, string | boolean>): { parameter: "organization" | "organization_id"; id: string } | undefined {
377
+ const id = flags.get("login-organization");
378
+ if (id === undefined) return;
379
+ if (!OAUTH_ORGANIZATION_PARAMETER || !IDENTITY_POLICY.organization) fail(2, "--login-organization requires auth.oauth_organization_parameter and identity_organization configured by the API owner.");
380
+ if (typeof id !== "string" || !id || id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(id)) fail(2, "--login-organization requires a nonempty provider organization ID without whitespace.");
381
+ return { parameter: OAUTH_ORGANIZATION_PARAMETER!, id: id as string };
335
382
  }
383
+ function loginIdentityReport(): Record<string, unknown> { return LOGIN_IDENTITY ? { verified_identity: LOGIN_IDENTITY.values, identity_checked_at: LOGIN_IDENTITY.checkedAt } : {}; }
336
384
 
337
- function writeCreds(creds: StoredCreds): void {
338
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
339
- writeFileSync(credsPath(), JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
385
+ /** Each login replaces this profile's credentials and retains its API destination. */
386
+ async function saveLoginCredentials(credentials: StoredCreds, flags: Map<string, string | boolean>, destination?: CredentialDestination, onIdentityFailure?: () => Promise<void>): Promise<void> {
387
+ const config = readConfig();
388
+ const apiBaseUrl = destination?.apiBaseUrl ?? resolveBaseUrl(flags, config);
389
+ if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
390
+ const url = new URL(apiBaseUrl!);
391
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail(2, "The API base URL must be HTTP or HTTPS without credentials, query, or fragment.");
392
+ const bound = destination ? { apiBaseUrl: destination.apiBaseUrl, environment: destination.environment, profile: destination.profile } : { apiBaseUrl: url.href, environment: config.environment, profile: PROFILE.name };
393
+ const next: StoredCreds = { ...credentials, destination: bound };
394
+ const identity = identityConfiguration();
395
+ if (identity && WHOAMI) {
396
+ const op = OPS.find((op) => op.resource === WHOAMI.resource && op.method === WHOAMI.method);
397
+ if (!op) fail(2, "The identity operation is unavailable. Regenerate this product with the configured identity read.");
398
+ const transport = identityFetch(bound.apiBaseUrl);
399
+ const verified = await verifyApiIdentity(async (anonymous) => {
400
+ const client = anonymous ? new TypeshipClient({ baseUrl: bound.apiBaseUrl, fetch: transport, maxRetries: 0, timeoutMs: 10_000 }) : await makeClient(flags, op!, next, true);
401
+ return identityResult(client, op!);
402
+ }, identity.fields, expectedLoginIdentity(flags)).catch(async (error) => {
403
+ await onIdentityFailure?.();
404
+ throw error;
405
+ });
406
+ next.identity = { ...verified, binding: credentialIdentityBinding(next, identity) };
407
+ LOGIN_IDENTITY = verified;
408
+ }
409
+ await credentialStore().update(() => next);
410
+ await updateProfileConfig(configDir(), (current) => ({ ...current, baseUrl: bound.apiBaseUrl }));
340
411
  }
341
412
 
342
413
  function readStdin(): Promise<string> {
@@ -396,118 +467,41 @@ function promptHidden(promptText: string): Promise<string> {
396
467
  });
397
468
  }
398
469
 
399
- async function oauthForm(url: string, params: Record<string, string>): Promise<{ status: number; body: Record<string, unknown> | null }> {
400
- const response = await fetch(url, {
401
- method: "POST",
402
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
403
- body: new URLSearchParams(params).toString(),
404
- });
405
- let body: Record<string, unknown> | null = null;
406
- try { body = await response.json() as Record<string, unknown>; } catch { /* non-JSON error body */ }
407
- return { status: response.status, body };
408
- }
409
-
410
- /** RFC 8628 device flow: discover the device endpoint from the token URL's
411
- * well-known metadata, show the code, poll until authorized. */
412
- async function deviceLogin(clientId: string, agent = false): Promise<void> {
413
- if (!OAUTH_TOKEN_URL) fail(2, "This API declares no OAuth token URL.");
414
- const origin = new URL(OAUTH_TOKEN_URL).origin;
415
- let deviceEndpoint: string | undefined;
416
- let tokenEndpoint = OAUTH_TOKEN_URL;
417
- for (const wellKnown of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
418
- try {
419
- const response = await fetch(origin + wellKnown, { headers: { Accept: "application/json" } });
420
- if (!response.ok) continue;
421
- const meta = await response.json() as { device_authorization_endpoint?: string; token_endpoint?: string };
422
- if (meta.device_authorization_endpoint) {
423
- deviceEndpoint = meta.device_authorization_endpoint;
424
- if (meta.token_endpoint) tokenEndpoint = meta.token_endpoint;
425
- break;
426
- }
427
- } catch { /* try the next well-known path */ }
428
- }
429
- if (!deviceEndpoint) {
430
- fail(1, "The authorization server does not advertise a device flow. Run '" + BIN + " login' with a pasted credential instead.");
431
- }
432
- const start = await oauthForm(deviceEndpoint!, {
433
- client_id: clientId,
434
- ...(OAUTH_SCOPES.length > 0 ? { scope: OAUTH_SCOPES.join(" ") } : {}),
435
- ...OAUTH_TOKEN_PARAMS,
436
- });
437
- const startBody = start.body as { device_code?: string; user_code?: string; verification_uri?: string; verification_uri_complete?: string; interval?: number; expires_in?: number } | null;
438
- if (start.status !== 200 || !startBody?.device_code) {
439
- fail(1, "Device authorization failed (HTTP " + start.status + ").", start.body);
440
- }
441
- const uri = startBody!.verification_uri_complete ?? startBody!.verification_uri;
442
- process.stderr.write("Open " + paintErr("cyan", String(uri)) + " and enter code: " + paintErr("bold", String(startBody!.user_code)) + "\n");
443
- // Under an agent the same facts also go out as one JSON line (stderr,
444
- // so stdout stays the single result document), for the agent to hand
445
- // the URL and code to the user while this polls.
446
- if (agent) process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: uri, user_code: startBody!.user_code, expires_in: startBody!.expires_in ?? 900 }) + "\n");
447
- let intervalMs = (startBody!.interval ?? 5) * 1000;
448
- const deadline = Date.now() + (startBody!.expires_in ?? 900) * 1000;
449
- while (Date.now() < deadline) {
450
- await new Promise((r) => setTimeout(r, intervalMs));
451
- const poll = await oauthForm(tokenEndpoint, {
452
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
453
- device_code: startBody!.device_code!,
454
- client_id: clientId,
455
- });
456
- const tokenBody = poll.body as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string } | null;
457
- if (poll.status === 200 && tokenBody?.access_token) {
458
- writeCreds({
459
- ...(readCreds() ?? {}),
460
- oauth: {
461
- accessToken: tokenBody.access_token,
462
- refreshToken: tokenBody.refresh_token,
463
- expiresAt: tokenBody.expires_in ? Date.now() + tokenBody.expires_in * 1000 : undefined,
464
- },
465
- });
466
- process.stderr.write(paintErr("green", "Logged in.") + "\n");
467
- out({ ok: true, method: "device", credentials: credsPath() });
468
- await flushExit(0);
469
- }
470
- const errorCode = tokenBody?.error;
471
- if (errorCode === "authorization_pending") continue;
472
- if (errorCode === "slow_down") { intervalMs += 5000; continue; }
473
- fail(1, "Device login failed: " + (errorCode ?? "HTTP " + poll.status), poll.body);
474
- }
475
- fail(1, "Device login timed out before the code was entered.");
476
- }
477
-
478
- /** Stored OAuth access token, refreshed through the token URL when expired.
479
- * A failed refresh returns the stale token; the API's 401 explains better
480
- * than a local guess. */
481
- async function refreshedOauthToken(stored: StoredCreds): Promise<string | undefined> {
482
- const oauth = stored.oauth;
483
- if (!oauth) return undefined;
484
- const expired = oauth.expiresAt !== undefined && Date.now() > oauth.expiresAt - 60_000;
485
- if (!expired || !oauth.refreshToken || !OAUTH_TOKEN_URL) return oauth.accessToken;
486
- const clientId = process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
487
- const result = await oauthForm(OAUTH_TOKEN_URL, {
488
- grant_type: "refresh_token",
489
- refresh_token: oauth.refreshToken,
490
- ...(clientId ? { client_id: clientId } : {}),
491
- });
492
- const body = result.body as { access_token?: string; refresh_token?: string; expires_in?: number } | null;
493
- if (result.status === 200 && body?.access_token) {
494
- const next = {
495
- accessToken: body.access_token,
496
- refreshToken: body.refresh_token ?? oauth.refreshToken,
497
- expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined,
498
- };
499
- writeCreds({ ...stored, oauth: next });
500
- return next.accessToken;
501
- }
502
- return oauth.accessToken;
470
+ async function withLoginCancellation<T>(work: (signal: AbortSignal) => Promise<T>): Promise<T> {
471
+ const controller = new AbortController();
472
+ const cancel = () => controller.abort();
473
+ process.once("SIGINT", cancel); process.once("SIGTERM", cancel);
474
+ try { return await work(controller.signal); }
475
+ finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
476
+ }
477
+
478
+ async function deviceLogin(clientId: string, parsed: Parsed): Promise<void> {
479
+ const apiBaseUrl = resolveBaseUrl(parsed.flags);
480
+ if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
481
+ const loginConfiguration = sessionConfiguration(apiBaseUrl!);
482
+ await credentialStore().prepare();
483
+ const session = await withLoginCancellation((signal) => oauthDeviceLogin({
484
+ clientId, issuer: OAUTH_ISSUER, discoveryUrls: OAUTH_DISCOVERY_URLS,
485
+ deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: OAUTH_SCOPES,
486
+ audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
487
+ }, { signal, authorize({ verificationUri, userCode, expiresIn }) {
488
+ process.stderr.write("Open " + paintErr("cyan", verificationUri) + " and enter code: " + paintErr("bold", userCode) + "\n");
489
+ if (isAgentMode(parsed)) process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: verificationUri, user_code: userCode, expires_in: expiresIn }) + "\n");
490
+ } }));
491
+ await saveLoginCredentials({ oauth: { ...session,
492
+ sessionId: randomBytes(16).toString("hex"), binding: sessionBinding(loginConfiguration),
493
+ apiBaseUrl, configuredClientId: loginConfiguration.clientId ?? null,
494
+ } }, parsed.flags, loginConfiguration);
495
+ process.stderr.write(paintErr("green", "Logged in.") + "\n");
496
+ out({ ok: true, method: "device", credentials: credsPath(), ...loginIdentityReport() });
497
+ await flushExit(0);
503
498
  }
504
499
 
505
- async function storePastedToken(stored: StoredCreds, token: string): Promise<void> {
500
+ async function storePastedToken(token: string, flags: Map<string, string | boolean>): Promise<void> {
506
501
  const first = AUTH_SCALARS[0];
507
502
  if (!first) fail(2, "This API declares no credential the CLI can store. Use --username/--password if it uses basic auth.");
508
- stored.scalars = { ...stored.scalars, [first!.option]: token };
509
- writeCreds(stored);
510
- out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath() });
503
+ await saveLoginCredentials({ scalars: { [first!.option]: token } }, flags);
504
+ out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath(), ...loginIdentityReport() });
511
505
  await flushExit(0);
512
506
  }
513
507
 
@@ -518,92 +512,126 @@ async function storePastedToken(stored: StoredCreds, token: string): Promise<voi
518
512
  * until the API hands back a key minted for this CLI, store it. The key
519
513
  * never crosses the chat: the agent relays a URL, the person clicks once.
520
514
  */
521
- /** The approval endpoint follows the base URL: a preview or local deployment
522
- * of the API approves its own logins. */
515
+ /** A same-origin API override uses that deployment's approval route. A
516
+ * separately hosted identity service retains its explicitly configured URL. */
523
517
  function cliAuthUrl(flags: Map<string, string | boolean>): string {
518
+ const auth = loginEndpoint(CLI_AUTH_URL!);
524
519
  const base = resolveBaseUrl(flags);
525
- try {
526
- if (CLI_AUTH_URL && DEFAULT_BASE_URL && base) {
527
- const defaultOrigin = new URL(DEFAULT_BASE_URL).origin;
528
- const origin = new URL(base).origin;
529
- if (origin !== defaultOrigin && CLI_AUTH_URL.startsWith(defaultOrigin)) return origin + CLI_AUTH_URL.slice(defaultOrigin.length);
530
- }
531
- } catch { /* fall through to the configured URL */ }
532
- return CLI_AUTH_URL!;
520
+ if (DEFAULT_BASE_URL && base && auth.origin === new URL(DEFAULT_BASE_URL).origin) {
521
+ const selected = loginEndpoint(base);
522
+ auth.protocol = selected.protocol; auth.host = selected.host;
523
+ }
524
+ return auth.href;
525
+ }
526
+
527
+ async function browserApprove(headless: boolean, flags: Map<string, string | boolean>): Promise<ApprovedCredential> {
528
+ return withLoginCancellation((signal) => customBrowserApproval({
529
+ authUrl: cliAuthUrl(flags), name: BIN + " CLI on " + hostname(), source: headless && !process.stdin.isTTY ? "agent" : "cli",
530
+ }, { signal, authorize({ verificationUrl, expiresIn }) {
531
+ process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", verificationUrl) + "\n");
532
+ if (headless) process.stderr.write(JSON.stringify({ event: "browser_approval", verification_url: verificationUrl, expires_in: expiresIn, note: "Give this URL to the user; polling until they approve or it expires." }) + "\n");
533
+ else openInBrowser(verificationUrl);
534
+ } }));
533
535
  }
534
536
 
535
- /** Browser approval, start to key: opens (or prints) the approval URL and
536
- * polls until a person decides. Returns the minted credential; every
537
- * failure exits with the envelope. Shared by `login` and `init`. */
538
- async function browserApprove(headless: boolean, flags: Map<string, string | boolean>): Promise<{ api_key: string; key_name: string; org_id?: string }> {
537
+ /** Store what the browser approval minted, marked as this CLI's own. */
538
+ async function storeMinted(minted: ApprovedCredential, flags: Map<string, string | boolean>): Promise<void> {
539
539
  const first = AUTH_SCALARS[0]!;
540
- const authUrl = cliAuthUrl(flags);
541
- const verifier = randomBytes(32).toString("base64url");
542
- const challenge = createHash("sha256").update(verifier).digest("base64url");
543
- const name = BIN + " CLI on " + hostname();
544
- const source = headless && !process.stdin.isTTY ? "agent" : "cli";
545
- let start: { session?: string; verification_url?: string; expires_in?: number; interval?: number; error?: string };
546
- try {
547
- const response = await fetch(authUrl + "/start", {
548
- method: "POST",
549
- headers: { "Content-Type": "application/json" },
550
- body: JSON.stringify({ code_challenge: challenge, name, source }),
551
- signal: AbortSignal.timeout(15_000),
552
- });
553
- start = await response.json() as typeof start;
554
- if (!response.ok || !start.session || !start.verification_url) {
555
- failWith({ code: "CALL_FAILED", message: "Could not start the browser login: " + (start.error ?? "HTTP " + response.status), nextSteps: ["Pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
556
- }
557
- } catch (e) {
558
- failWith({ code: "NETWORK_ERROR", message: "Could not reach " + authUrl + "/start: " + (e as Error).message, nextSteps: ["Check the network, or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
559
- }
560
- const url = start!.verification_url!;
561
- const expiresIn = start!.expires_in ?? 600;
562
- const intervalMs = Math.max(1, start!.interval ?? 3) * 1000;
563
- process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", url) + "\n");
564
- if (headless) {
565
- process.stderr.write(JSON.stringify({ event: "browser_approval", verification_url: url, expires_in: expiresIn, note: "Give this URL to the user; polling until they approve or it expires." }) + "\n");
566
- } else {
567
- openInBrowser(url);
568
- }
569
- const deadline = Date.now() + expiresIn * 1000;
570
- while (Date.now() < deadline) {
571
- await new Promise((r) => setTimeout(r, intervalMs));
572
- let poll: { status?: string; api_key?: string; key_name?: string; org_id?: string };
540
+ await saveLoginCredentials({
541
+ scalars: { [first.option]: minted.api_key },
542
+ minted: { via: "browser", key_name: minted.key_name, revocationUrl: minted.revocationUrl, ...(minted.org_id ? { org_id: minted.org_id } : {}) },
543
+ }, flags, undefined, async () => {
573
544
  try {
574
- const response = await fetch(authUrl + "/status", {
575
- method: "POST",
576
- headers: { "Content-Type": "application/json" },
577
- body: JSON.stringify({ session: start!.session, code_verifier: verifier }),
578
- signal: AbortSignal.timeout(15_000),
579
- });
580
- poll = await response.json() as typeof poll;
545
+ const saved = credentialStore().read();
546
+ if ([...Object.values(saved?.scalars ?? {}), ...Object.values(saved?.named ?? {}), saved?.oauth?.accessToken, saved?.oauth?.refreshToken].includes(minted.api_key)) {
547
+ process.stderr.write("The rejected approval returned an existing saved credential; it was not revoked.\n");
548
+ return;
549
+ }
550
+ const response = await oauthStatusRequest(loginEndpoint(minted.revocationUrl), { method: "POST", headers: { Authorization: "Bearer " + minted.api_key } }, 15_000);
551
+ if (response.status < 200 || response.status >= 300) throw new Error("Revocation failed");
581
552
  } catch {
582
- continue; // a blip; the next tick tries again
553
+ process.stderr.write("The rejected approval's new credential could not be revoked. Revoke it in your API account.\n");
583
554
  }
584
- if (poll.status === "pending") continue;
585
- if (poll.status === "complete" && poll.api_key) {
586
- return { api_key: poll.api_key, key_name: poll.key_name ?? name, ...(poll.org_id ? { org_id: poll.org_id } : {}) };
587
- }
588
- if (poll.status === "denied") failWith({ status: "action_required", code: "AUTH_INVALID", message: "The request was denied in the browser.", nextSteps: ["Run '" + BIN + " login' again if that was a mistake, or pass a credential directly with --" + first.flag + "."] });
589
- if (poll.status === "expired") break;
590
- failWith({ code: "CALL_FAILED", message: "Browser login stopped: " + (poll.status ?? "unknown status"), nextSteps: ["Run '" + BIN + " login' again."] });
591
- }
592
- failWith({ status: "action_required", code: "TTY_REQUIRED", message: "The browser approval expired after " + expiresIn + "s without a decision.", nextSteps: ["Run '" + BIN + " login' again and approve the link within ten minutes.", "Or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
555
+ });
593
556
  }
594
557
 
595
- /** Store what the browser approval minted, marked as this CLI's own. */
596
- function storeMinted(stored: StoredCreds, minted: { api_key: string; key_name: string; org_id?: string }): void {
597
- const first = AUTH_SCALARS[0]!;
598
- stored.scalars = { ...stored.scalars, [first.option]: minted.api_key };
599
- stored.minted = { via: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}) };
600
- writeCreds(stored);
558
+ async function browserLogin(headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
559
+ await credentialStore().prepare();
560
+ const minted = await browserApprove(headless, flags);
561
+ await storeMinted(minted, flags);
562
+ out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
563
+ await flushExit(0);
601
564
  }
602
565
 
603
- async function browserLogin(stored: StoredCreds, headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
604
- const minted = await browserApprove(headless, flags);
605
- storeMinted(stored, minted);
606
- out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath() });
566
+ async function acquireOAuthBrowserSession(parsed: Parsed, clientId: string): Promise<void> {
567
+ if (!OAUTH_ISSUER) fail(2, "Browser OAuth requires the exact auth.oauth_issuer configured by the API owner.");
568
+ const apiBaseUrl = resolveBaseUrl(parsed.flags);
569
+ if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
570
+ const loginConfiguration = sessionConfiguration(apiBaseUrl!);
571
+ await credentialStore().prepare();
572
+ const session = await startOAuthBrowserSession(parsed, clientId);
573
+ await saveLoginCredentials({ oauth: { ...session, apiBaseUrl,
574
+ sessionId: randomBytes(16).toString("hex"), binding: sessionBinding(loginConfiguration),
575
+ configuredClientId: loginConfiguration.clientId ?? null,
576
+ } }, parsed.flags, loginConfiguration);
577
+ }
578
+
579
+ /** Normal login and Console verification use the same native exchange. */
580
+ async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeoutMs?: number): Promise<OAuthLoginSession> {
581
+ const controller = new AbortController();
582
+ const cancel = () => controller.abort();
583
+ process.once("SIGINT", cancel);
584
+ process.once("SIGTERM", cancel);
585
+ try {
586
+ return await oauthBrowserLogin({
587
+ issuer: OAUTH_ISSUER!, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
588
+ authorizationUrl: OAUTH_AUTHORIZATION_URL, tokenUrl: OAUTH_TOKEN_URL ?? undefined,
589
+ redirectUri: OAUTH_REDIRECT_URI, scopes: OAUTH_SCOPES,
590
+ audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
591
+ organization: requestedLoginOrganization(parsed.flags),
592
+ }, { signal: controller.signal, timeoutMs, authorize(url) {
593
+ process.stderr.write("Sign in to your existing account: " + url + "\n");
594
+ if (isAgentMode(parsed) || parsed.flags.get("no-browser") === true) process.stderr.write(JSON.stringify({ event: "oauth_browser", authorization_url: url, note: "Open this URL in a browser on the same computer as the CLI." }) + "\n");
595
+ else openInBrowser(url);
596
+ } });
597
+ } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
598
+ }
599
+
600
+ async function cmdConsoleLoginCheck(parsed: Parsed): Promise<void> {
601
+ const file = parsed.flags.get("console-check");
602
+ if (typeof file !== "string" || !file || parsed.flags.get("device") === true || parsed.flags.get("with-token") === true || explicitNonInteractive(parsed)) fail(2, "--console-check requires a downloaded JSON file and browser login. Use --no-browser to print the sign-in link.");
603
+ const baseUrl = resolveBaseUrl(parsed.flags);
604
+ const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined) ?? process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
605
+ const op = WHOAMI && OPS.find((value) => value.resource === WHOAMI.resource && value.method === WHOAMI.method);
606
+ if (!baseUrl || !OAUTH_ISSUER || !clientId || OAUTH_LOGIN_METHOD !== "browser" || !op || op.auth !== "required" || !op.security?.length) fail(2, "Configure browser OAuth and a required-authentication identity read, then regenerate this CLI.");
607
+ const envOptions: Record<string, unknown> = {}, flagOptions: Record<string, unknown> = {};
608
+ for (const scalar of AUTH_SCALARS) {
609
+ if (process.env[scalar.env] !== undefined) envOptions[scalar.option] = process.env[scalar.env];
610
+ if (typeof parsed.flags.get(scalar.flag) === "string") flagOptions[scalar.option] = parsed.flags.get(scalar.flag);
611
+ }
612
+ if (BASIC && process.env[BASIC.envUser] && process.env[BASIC.envPass]) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
613
+ if (BASIC && typeof parsed.flags.get("username") === "string" && typeof parsed.flags.get("password") === "string") flagOptions.basicAuth = { username: parsed.flags.get("username"), password: parsed.flags.get("password") };
614
+ const environment = (typeof parsed.flags.get("environment") === "string" ? parsed.flags.get("environment") as string : Object.entries(ENVIRONMENTS).find(([, url]) => new URL(url).href === new URL(baseUrl!).href)?.[0]) ?? null;
615
+ const result = await checkConsoleBrowserLogin({
616
+ file: file as string, configuration: {
617
+ baseUrl: baseUrl!, environment, operation: op!.resource + "." + op!.method, requirements: op!.security!,
618
+ request: { method: op!.graphql ? "POST" : op!.httpMethod, path: op!.graphql ? "" : op!.path, graphqlField: op!.graphql?.field ?? null, graphqlQuery: op!.graphql ? op!.graphql.docPrefix + op!.graphql.defaultSelection + " }" : null },
619
+ issuer: OAUTH_ISSUER!, clientId: clientId!, discoveryUrl: OAUTH_DISCOVERY_URL ?? null,
620
+ authorizationUrl: OAUTH_AUTHORIZATION_URL ?? null, tokenUrl: OAUTH_TOKEN_URL,
621
+ redirectUri: OAUTH_REDIRECT_URI ?? "http://127.0.0.1/callback", scopes: OAUTH_SCOPES,
622
+ audience: OAUTH_TOKEN_PARAMS.audience ?? null, resource: OAUTH_TOKEN_PARAMS.resource ?? null,
623
+ }, schemes: NAMED_SCHEMES,
624
+ credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ options: envOptions, named: environmentCredentials() }, { options: flagOptions, named: flagCredentials(parsed.flags) }]),
625
+ login: (timeoutMs) => startOAuthBrowserSession(parsed, clientId!, timeoutMs),
626
+ async verify(credentials, expectations) {
627
+ const kind = (value: "user" | "account" | "organization") => value === "user" ? "subject" : value;
628
+ const policy = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), entry.pointer])) as IdentityPolicy;
629
+ const expected = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), String(entry.expected)])) as ApiIdentity;
630
+ await verifyClientIdentity((options) => new TypeshipClient(options as unknown as ClientOptions), { baseUrl: baseUrl!, credentials }, op!, policy, expected);
631
+ },
632
+ progress: (message) => process.stderr.write(message + "\n"),
633
+ });
634
+ out(result);
607
635
  await flushExit(0);
608
636
  }
609
637
 
@@ -615,17 +643,30 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
615
643
  ...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
616
644
  ...(CLI_AUTH_URL ? [" " + BIN + " login approve in the browser: a key is minted for you (add --no-browser to print the link instead of opening it)"] : []),
617
645
  " " + BIN + " login --with-token read the credential from stdin (CI)",
618
- ...(OAUTH_TOKEN_URL ? [" " + BIN + " login --client-id <id> OAuth device flow" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
646
+ " " + BIN + " login --console-check <file> test browser login for your Typeship Console without replacing saved logins",
647
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + OAUTH_LOGIN_METHOD + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
648
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --no-browser print the approval URL instead of opening a browser", " " + BIN + " login --device use device authorization when your provider supports it"] : []),
619
649
  ...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
620
- ...(CLI_AUTH_URL ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
650
+ ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
621
651
  "",
622
- "Precedence at request time: flags > env vars > stored credentials.",
652
+ "Precedence per scheme: flags > env vars > stored credentials. Named inputs beat convenience flags within the same source.",
653
+ " " + BIN + " login --credentials @<JSON-file> store named credentials (use - for stdin)",
654
+ "Named schemes: " + Object.entries(NAMED_SCHEMES).map(([name, scheme]) => name + " (" + scheme.kind + ")").join(", "),
655
+ "JSON values are tokens/API keys, or {username, password} for Basic auth. Runtime env: TYPESHIP_CREDENTIALS.",
656
+ "--profile <name> selects an isolated login. Use auth profiles to list profiles and auth use <name> to select a default.",
657
+ ...(Object.keys(IDENTITY_POLICY).length ? ["Login verifies your API identity before saving. Add --subject <id>, --account <id> or --organization <id> to require a particular mapped identity."] : []),
658
+ ...(OAUTH_ORGANIZATION_PARAMETER ? ["--login-organization <id> requests that provider organization during browser login and requires the API to confirm the same ID."] : []),
659
+ "Saved credentials use OS protection by default. TYPESHIP_CREDENTIAL_STORE=file explicitly opts into plaintext storage.",
623
660
  ];
624
661
  process.stdout.write(lines.join("\n") + "\n");
625
662
  await flushExit(0);
626
663
  }
627
- const stored: StoredCreds = readCreds() ?? {};
628
-
664
+ if (parsed.flags.has("login-organization")) {
665
+ expectedLoginIdentity(parsed.flags);
666
+ if (!HAS_OAUTH_LOGIN || OAUTH_LOGIN_METHOD !== "browser" || parsed.flags.has("device") || parsed.flags.has("console-check") || parsed.flags.has("with-token") || explicitNonInteractive(parsed)) fail(2, "--login-organization is available only for interactive OAuth browser login, including --no-browser.");
667
+ }
668
+ if (parsed.flags.has("console-check")) { await cmdConsoleLoginCheck(parsed); return; }
669
+ const named = flagCredentials(parsed.flags);
629
670
  const scalarValues: Record<string, string> = {};
630
671
  for (const a of AUTH_SCALARS) {
631
672
  const v = parsed.flags.get(a.flag);
@@ -634,31 +675,39 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
634
675
  const username = parsed.flags.get("username");
635
676
  const password = parsed.flags.get("password");
636
677
  const gotBasic = BASIC !== null && typeof username === "string" && typeof password === "string";
637
- if (Object.keys(scalarValues).length > 0 || gotBasic) {
638
- if (Object.keys(scalarValues).length > 0) stored.scalars = { ...stored.scalars, ...scalarValues };
639
- if (gotBasic) stored.basic = { username: username as string, password: password as string };
640
- writeCreds(stored);
641
- out({ ok: true, method: "flags", credentials: credsPath() });
678
+ if (Object.keys(scalarValues).length > 0 || gotBasic || Object.keys(named).length > 0) {
679
+ if (parsed.flags.has("login-organization")) fail(2, "--login-organization cannot be combined with pasted credentials.");
680
+ await saveLoginCredentials({
681
+ ...(Object.keys(named).length ? { named } : {}),
682
+ ...(Object.keys(scalarValues).length > 0 ? { scalars: scalarValues } : {}),
683
+ ...(gotBasic ? { basic: { username: username as string, password: password as string } } : {}),
684
+ }, parsed.flags);
685
+ out({ ok: true, method: "flags", credentials: credsPath(), ...loginIdentityReport() });
642
686
  await flushExit(0);
643
687
  }
644
688
 
645
689
  if (parsed.flags.get("with-token") === true) {
646
690
  const token = (await readStdin()).trim();
647
691
  if (!token) fail(2, "--with-token expects the credential on stdin.");
648
- await storePastedToken(stored, token);
692
+ await storePastedToken(token, parsed.flags);
649
693
  }
650
694
 
651
695
  const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined)
652
696
  ?? process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID ?? undefined;
653
- if (OAUTH_TOKEN_URL && clientId !== undefined && !explicitNonInteractive(parsed)) {
654
- await deviceLogin(clientId, isAgentMode(parsed));
697
+ if (HAS_OAUTH_LOGIN && clientId !== undefined && !explicitNonInteractive(parsed)) {
698
+ if (OAUTH_LOGIN_METHOD === "browser" && parsed.flags.get("device") !== true) {
699
+ await acquireOAuthBrowserSession(parsed, clientId);
700
+ out({ ok: true, method: "oauth_browser", credentials: credsPath(), ...loginIdentityReport() });
701
+ await flushExit(0);
702
+ }
703
+ await deviceLogin(clientId, parsed);
655
704
  }
656
705
 
657
706
  // Browser approval: the API mints a key for this CLI once a person
658
707
  // approves in the browser. Works under an agent too (it prints the URL and
659
708
  // polls); only the explicit non-interactive switch turns it off.
660
709
  if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
661
- await browserLogin(stored, isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
710
+ await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
662
711
  }
663
712
 
664
713
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
@@ -669,7 +718,7 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
669
718
  nextSteps: [
670
719
  ...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
671
720
  "Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
672
- ...(OAUTH_TOKEN_URL ? ["Device flow: '" + BIN + " login --client-id <id>' (prints a URL and code for the user)."] : []),
721
+ ...(HAS_OAUTH_LOGIN ? ["OAuth login (or --device when supported by your provider): '" + BIN + " login --client-id <id>' (opens or prints the provider sign-in URL)."] : []),
673
722
  ...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
674
723
  ],
675
724
  });
@@ -678,27 +727,39 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
678
727
  if (!first) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
679
728
  const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
680
729
  if (!token) fail(2, "Nothing entered.");
681
- await storePastedToken(stored, token);
730
+ await storePastedToken(token, parsed.flags);
682
731
  }
683
732
 
684
- async function cmdLogout(): Promise<void> {
685
- const stored = readCreds();
686
- const existed = existsSync(credsPath());
733
+ async function cmdLogout(parsed: Parsed): Promise<void> {
734
+ if (parsed.flags.get("local") === true) {
735
+ const removed = await credentialStore().clear();
736
+ out({ ok: true, removed: removed ? credsPath() : null, revoked: false, revocation_skipped: true });
737
+ await flushExit(0);
738
+ }
739
+ const stored = await credentialStore().take();
740
+ const existed = stored !== null;
687
741
  // A key this CLI minted for itself (browser approval) is revoked on the
688
742
  // way out, so logging out ends the credential and not just the file. A
689
743
  // pasted or CI key is someone else's to revoke, and is left alone.
690
744
  let revoked: boolean | null = null;
691
745
  const first = AUTH_SCALARS[0];
692
746
  const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
693
- if (ownKey && CLI_AUTH_URL) {
747
+ if (ownKey) {
694
748
  try {
695
- const response = await fetch(CLI_AUTH_URL + "/revoke", { method: "POST", headers: { Authorization: "Bearer " + ownKey }, signal: AbortSignal.timeout(15_000) });
696
- revoked = response.ok;
749
+ const response = await oauthStatusRequest(loginEndpoint(stored!.minted!.revocationUrl!), { method: "POST", headers: { Authorization: "Bearer " + ownKey } }, 15_000);
750
+ revoked = response.status >= 200 && response.status < 300;
697
751
  } catch {
698
752
  revoked = false;
699
753
  }
700
754
  }
701
- rmSync(credsPath(), { force: true });
755
+ if (stored?.oauth?.revocationUrl) {
756
+ try {
757
+ const oauth = stored.oauth;
758
+ const token = oauth.refreshToken ?? oauth.accessToken;
759
+ const result = await oauthStatusRequest(loginEndpoint(oauth.revocationUrl!), { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token, token_type_hint: oauth.refreshToken ? "refresh_token" : "access_token", ...(oauth.clientId ? { client_id: oauth.clientId } : {}) }) });
760
+ revoked = result.status >= 200 && result.status < 300;
761
+ } catch { revoked = false; }
762
+ }
702
763
  out({ ok: true, removed: existed ? credsPath() : null, ...(revoked === null ? {} : { revoked, key_name: stored?.minted?.key_name }) });
703
764
  await flushExit(0);
704
765
  }
@@ -706,21 +767,13 @@ async function cmdLogout(): Promise<void> {
706
767
  async function cmdWhoami(parsed: Parsed): Promise<void> {
707
768
  const op = WHOAMI ? OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method) : undefined;
708
769
  if (op) {
709
- const client = await makeClient(parsed.flags);
770
+ const client = await makeClient(parsed.flags, op);
710
771
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
711
772
  const result = await target[op.method]!();
712
773
  if (result.ok) { out(result.data ?? { ok: true }); await flushExit(0); }
713
774
  failApi(result.error, LAST_CLIENT_HAD_CREDENTIAL);
714
775
  }
715
- const stored = readCreds();
716
- let source = "none";
717
- if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) {
718
- source = "flags";
719
- } else {
720
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
721
- if (envScalar) source = "env:" + envScalar.env;
722
- else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
723
- }
776
+ const source = credentialSource(parsed.flags) ?? "none";
724
777
  out({ authenticated: source !== "none", source, credentials: existsSync(credsPath()) ? credsPath() : null });
725
778
  await flushExit(source === "none" ? 1 : 0);
726
779
  }
@@ -739,18 +792,7 @@ function configFilePath(): string {
739
792
  return join(configDir(), "config.json");
740
793
  }
741
794
 
742
- function readConfig(): CliConfigFile {
743
- try {
744
- return JSON.parse(readFileSync(configFilePath(), "utf8")) as CliConfigFile;
745
- } catch {
746
- return {};
747
- }
748
- }
749
-
750
- function writeConfig(config: CliConfigFile): void {
751
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
752
- writeFileSync(configFilePath(), JSON.stringify(config, null, 2) + "\n");
753
- }
795
+ function readConfig(): CliConfigFile { return readProfileConfig(configDir()); }
754
796
 
755
797
  const CONFIG_KEYS = ["base-url", "environment", "docs-url"];
756
798
 
@@ -784,6 +826,7 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
784
826
  if (sub === undefined || sub === "list") {
785
827
  const config = readConfig();
786
828
  out({
829
+ profile: PROFILE.name, profile_source: PROFILE.source,
787
830
  base_url: config.baseUrl ?? null,
788
831
  environment: config.environment ?? null,
789
832
  docs_url: config.docsUrl ?? DOCS_URL_DEFAULT,
@@ -806,10 +849,13 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
806
849
  await flushExit(0);
807
850
  }
808
851
  if (sub === "unset") {
809
- if (key === "base-url") delete config.baseUrl;
810
- else if (key === "environment") delete config.environment;
811
- else delete config.docsUrl;
812
- writeConfig(config);
852
+ await updateProfileConfig(configDir(), (current) => {
853
+ const next = { ...current };
854
+ if (key === "base-url") delete next.baseUrl;
855
+ else if (key === "environment") delete next.environment;
856
+ else delete next.docsUrl;
857
+ return next;
858
+ });
813
859
  out({ ok: true });
814
860
  await flushExit(0);
815
861
  }
@@ -820,17 +866,14 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
820
866
  } catch {
821
867
  fail(2, key + " must be a valid URL");
822
868
  }
823
- if (key === "base-url") config.baseUrl = value;
824
- else config.docsUrl = value;
825
869
  } else {
826
870
  if (!(value! in ENVIRONMENTS)) {
827
871
  fail(2, Object.keys(ENVIRONMENTS).length > 0
828
872
  ? "environment must be one of: " + Object.keys(ENVIRONMENTS).join(", ")
829
873
  : "The spec declares no named environments; use 'config set base-url' instead.");
830
874
  }
831
- config.environment = value;
832
875
  }
833
- writeConfig(config);
876
+ await updateProfileConfig(configDir(), (current) => key === "base-url" ? { ...current, baseUrl: value } : key === "docs-url" ? { ...current, docsUrl: value } : { ...current, environment: value, baseUrl: undefined });
834
877
  out({ ok: true, [key.replace(/-/g, "_")]: value });
835
878
  await flushExit(0);
836
879
  }
@@ -873,7 +916,12 @@ function mcpEntryFor(url: string | undefined, readOnly = false): { entry: McpEnt
873
916
  }
874
917
  const server = mcpServerPath();
875
918
  if (server.warning) warnings.push(server.warning);
876
- return { entry: { command: "node", args: [server.path, ...(readOnly ? ["--read-only"] : [])] }, warnings };
919
+ const environment = {
920
+ ...(process.env.XDG_CONFIG_HOME !== undefined ? { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME } : {}),
921
+ ...(process.env["TYPESHIP_CREDENTIAL_STORE"] !== undefined ? { "TYPESHIP_CREDENTIAL_STORE": process.env["TYPESHIP_CREDENTIAL_STORE"]! } : {}),
922
+ };
923
+ const pinned = PROFILE.source === "flag" || PROFILE.source === "environment";
924
+ return { entry: { command: "node", args: [server.path, ...(pinned ? ["--profile", PROFILE.name] : []), ...(readOnly ? ["--read-only"] : [])], ...(Object.keys(environment).length ? { env: environment } : {}) }, warnings };
877
925
  }
878
926
 
879
927
  const MCP_CLIENT_FLAGS: Record<string, string> = {
@@ -970,7 +1018,7 @@ function agentContext(): AgentContext {
970
1018
  apiTitle: API_TITLE,
971
1019
  version: VERSION,
972
1020
  envPrefix: ENV_PREFIX,
973
- authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : [])],
1021
+ authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? ["TYPESHIP_CREDENTIALS"] : [])],
974
1022
  docsUrl: docsSiteUrl(),
975
1023
  docsIndexUrl: docsIndexUrl(),
976
1024
  generatedOperationCount: OPS.length,
@@ -1049,7 +1097,7 @@ function helpJson(): Record<string, unknown> {
1049
1097
  note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
1050
1098
  },
1051
1099
  builtins: BUILTIN_COMMANDS,
1052
- global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--base-url <url>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
1100
+ global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--credentials @<JSON-file>|-", "--base-url <url>", "--profile <name>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
1053
1101
  auth_env_vars: agentContext().authEnvVars,
1054
1102
  };
1055
1103
  }
@@ -1066,29 +1114,44 @@ async function cmdAgentGuide(parsed: Parsed): Promise<void> {
1066
1114
  /** Which credential the CLI would send, without sending it. --live calls the identity endpoint too. */
1067
1115
  async function cmdAuth(parsed: Parsed): Promise<void> {
1068
1116
  const sub = parsed.positionals[1];
1117
+ if (!parsed.help && sub === "profiles") {
1118
+ out({ profile: PROFILE.name, source: PROFILE.source, profiles: listProfiles(configRoot()).map((entry) => ({ name: entry.name, selected: entry.name === PROFILE.name, has_saved_credentials: entry.hasSavedCredentials, base_url: entry.config.baseUrl ?? (entry.config.environment ? ENVIRONMENTS[entry.config.environment] : undefined) ?? DEFAULT_BASE_URL, environment: entry.config.environment ?? null })) });
1119
+ await flushExit(0);
1120
+ }
1121
+ if (!parsed.help && (sub === "use" || sub === "remove")) {
1122
+ const name = parsed.positionals[2];
1123
+ if (!name) fail(2, "auth " + sub + " requires a profile name.");
1124
+ if (sub === "use") selectProfile(configRoot(), name!);
1125
+ else await removeProfile(configRoot(), name!);
1126
+ const effective = resolveProfile(configRoot(), { flag: typeof parsed.flags.get("profile") === "string" ? parsed.flags.get("profile") as string : undefined, environment: process.env["TYPESHIP_PROFILE"], allowMissing: true });
1127
+ out({ ok: true, ...(sub === "use" ? { selected: name } : { removed: name }), profile: effective.name, source: effective.source });
1128
+ await flushExit(0);
1129
+ }
1069
1130
  if (parsed.help || sub !== "check") {
1070
1131
  process.stdout.write([
1071
1132
  BIN + " auth check [--live] — report the credential the CLI would use, as JSON: {status: ok|action_required, authenticated, source, ...}",
1133
+ " " + BIN + " auth profiles list profiles without unlocking credentials",
1134
+ " " + BIN + " auth use <name> select the default profile",
1135
+ " " + BIN + " auth remove <name> remove a profile after logout",
1136
+ " --profile <name> overrides TYPESHIP_PROFILE, then the saved selection, then default.",
1072
1137
  " --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
1073
1138
  "",
1074
1139
  "Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
1075
1140
  ].join("\n") + "\n");
1076
1141
  await flushExit(parsed.help ? 0 : 2);
1077
1142
  }
1078
- const stored = readCreds();
1079
- let source = "none";
1080
- if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) source = "flags";
1081
- else {
1082
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
1083
- if (envScalar) source = "env:" + envScalar.env;
1084
- else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
1085
- }
1143
+ const source = credentialSource(parsed.flags) ?? "none";
1086
1144
  const authenticated = source !== "none";
1145
+ const savedIdentity = source === "login" ? readCreds() : null;
1146
+ if (savedIdentity) assertStoredIdentity(savedIdentity, identityConfiguration());
1087
1147
  const report: Record<string, unknown> = {
1088
1148
  status: authenticated ? "ok" : "action_required",
1089
1149
  authenticated,
1090
1150
  source,
1091
1151
  credentials_path: existsSync(credsPath()) ? credsPath() : null,
1152
+ credential_storage: credentialStore().backend,
1153
+ verified_identity: savedIdentity?.identity?.values ?? null, identity_checked_at: savedIdentity?.identity?.checkedAt ?? null,
1154
+ profile: PROFILE.name, profile_source: PROFILE.source,
1092
1155
  auth_env_vars: agentContext().authEnvVars,
1093
1156
  base_url: resolveBaseUrl(parsed.flags) ?? null,
1094
1157
  next_steps: authenticated ? [] : [
@@ -1099,10 +1162,13 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1099
1162
  if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
1100
1163
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1101
1164
  if (op) {
1102
- const client = await makeClient(parsed.flags);
1165
+ const client = await makeClient(parsed.flags, op);
1103
1166
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
1104
1167
  const result = await target[op.method]!();
1105
- if (result.ok) report.identity = result.data;
1168
+ if (result.ok) {
1169
+ if (identityConfiguration() && savedIdentity?.identity) assertApiIdentity(savedIdentity.identity.values, readApiIdentity(result.data, IDENTITY_POLICY));
1170
+ report.identity = result.data;
1171
+ }
1106
1172
  else {
1107
1173
  const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1108
1174
  report.status = "action_required";
@@ -1124,10 +1190,11 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1124
1190
  const nodeMajor = Number(process.versions.node.split(".")[0]);
1125
1191
  checks.push({ name: "node", ok: nodeMajor >= 18, detail: process.version, ...(nodeMajor >= 18 ? {} : { fix: "Install Node 18 or newer." }) });
1126
1192
  checks.push({ name: "cli", ok: true, detail: BIN + " " + VERSION + " (" + PKG_NAME + ")" });
1127
- const stored = readCreds();
1128
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
1129
- const hasCred = Boolean(envScalar) || Boolean(stored && (stored.scalars || stored.basic || stored.oauth));
1130
- checks.push({ name: "credentials", ok: hasCred, detail: envScalar ? "env:" + envScalar.env : hasCred ? credsPath() : "none", ...(hasCred ? {} : { fix: AUTH_SCALARS[0] ? "Set " + AUTH_SCALARS[0].env + " or run '" + BIN + " login'." : "Run '" + BIN + " login'." }) });
1193
+ let source: string | null = null;
1194
+ let storageProblem: string | undefined;
1195
+ try { source = credentialSource(parsed.flags); } catch (error) { storageProblem = (error as Error).message; }
1196
+ const hasCred = source !== null;
1197
+ checks.push({ name: "credentials", ok: hasCred, detail: storageProblem ?? (source === "login" ? credentialStore().backend : source ?? "none"), ...(hasCred ? {} : { fix: storageProblem ?? (AUTH_SCALARS[0] ? "Set " + AUTH_SCALARS[0].env + " or run '" + BIN + " login'." : "Run '" + BIN + " login'.") }) });
1131
1198
  const baseUrl = resolveBaseUrl(parsed.flags);
1132
1199
  if (baseUrl) {
1133
1200
  try {
@@ -1143,7 +1210,7 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1143
1210
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1144
1211
  if (op) {
1145
1212
  try {
1146
- const client = await makeClient(parsed.flags);
1213
+ const client = await makeClient(parsed.flags, op);
1147
1214
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; error?: unknown }>>>)[op.resource]!;
1148
1215
  const result = await target[op.method]!();
1149
1216
  checks.push(result.ok ? { name: "identity", ok: true, detail: op.command.join(" ") + " ok" } : { name: "identity", ok: false, detail: classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT }).message, fix: "The credential was rejected; run '" + BIN + " login' with a current one." });
@@ -1196,24 +1263,33 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1196
1263
  const harness = detectHarness();
1197
1264
 
1198
1265
  // 1. credential
1199
- const stored: StoredCreds = readCreds() ?? {};
1200
1266
  const first = AUTH_SCALARS[0];
1267
+ const named = flagCredentials(parsed.flags), envNamed = environmentCredentials();
1268
+ const stored: StoredCreds = Object.keys(named).length || Object.keys(envNamed).length || first && process.env[first.env] ? {} : readCreds() ?? {};
1201
1269
  const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined)
1202
1270
  ?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined);
1203
- if (given && first) {
1204
- stored.scalars = { ...stored.scalars, [first.option]: given };
1205
- writeCreds(stored);
1271
+ if (Object.keys(named).length) {
1272
+ await saveLoginCredentials({ named }, parsed.flags);
1273
+ report.credential = { status: "stored", path: credsPath() };
1274
+ } else if (given && first) {
1275
+ await saveLoginCredentials({ scalars: { [first.option]: given } }, parsed.flags);
1206
1276
  report.credential = { status: "stored", path: credsPath() };
1207
1277
  } else if (first && process.env[first.env]) {
1208
1278
  report.credential = { status: "env", variable: first.env };
1209
- } else if (stored.scalars || stored.basic || stored.oauth) {
1279
+ } else if (Object.keys(envNamed).length) {
1280
+ report.credential = { status: "env", variable: "TYPESHIP_CREDENTIALS" };
1281
+ } else if (stored.scalars || stored.basic || stored.oauth || stored.named) {
1210
1282
  report.credential = { status: "stored", path: credsPath() };
1283
+ } else if (first && HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" && (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID) && !explicitNonInteractive(parsed)) {
1284
+ await acquireOAuthBrowserSession(parsed, (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID)!);
1285
+ report.credential = { status: "stored", method: "oauth_browser", path: credsPath() };
1211
1286
  } else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1212
1287
  // Nothing anywhere: approve a credential in the browser, as `login`
1213
1288
  // would, then carry on. Under an agent the URL is printed for the person
1214
1289
  // and polled; only the explicit non-interactive switch skips this.
1290
+ await credentialStore().prepare();
1215
1291
  const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
1216
- storeMinted(stored, minted);
1292
+ await storeMinted(minted, parsed.flags);
1217
1293
  report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
1218
1294
  } else {
1219
1295
  report.credential = { status: "none" };
@@ -1348,7 +1424,7 @@ function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<strin
1348
1424
  return { flags, values };
1349
1425
  }
1350
1426
 
1351
- const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--base-url", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
1427
+ const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--credentials", "--base-url", "--profile", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
1352
1428
  const BUILTIN_WORDS: Record<string, string[]> = {
1353
1429
  config: ["list", "get", "set", "unset", "path"],
1354
1430
  completion: ["bash", "zsh", "fish"],
@@ -1608,30 +1684,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1608
1684
  .filter((match) => match.score > 0)
1609
1685
  .sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
1610
1686
  .map((match) => match.op);
1611
- const prose = await fetchDocs("llms-full.txt");
1612
- const proseMatches: { heading: string; excerpt: string; score: number }[] = [];
1613
- if (prose !== null) {
1614
- let heading = "";
1615
- const terms = searchTerms(term);
1616
- for (const line of prose.split("\n")) {
1617
- if (/^#{1,3} /.test(line)) heading = line.replace(/^#+ /, "").trim();
1618
- else {
1619
- const lowerHeading = heading.toLowerCase();
1620
- const lowerLine = line.toLowerCase();
1621
- const matched = terms.filter((word) => lowerHeading.includes(word) || lowerLine.includes(word));
1622
- if (matched.length > 0) {
1623
- const allTerms = matched.length === terms.length;
1624
- proseMatches.push({
1625
- heading,
1626
- excerpt: line.trim().slice(0, 160),
1627
- score: matched.length * 10 + (allTerms ? 50 : 0) + (lowerHeading.includes(term.toLowerCase()) || lowerLine.includes(term.toLowerCase()) ? 25 : 0),
1628
- });
1629
- }
1630
- }
1631
- }
1632
- proseMatches.sort((a, b) => b.score - a.score || a.heading.localeCompare(b.heading) || a.excerpt.localeCompare(b.excerpt));
1633
- }
1634
- const docsStatus = docsSiteUrl() === null ? "not_configured" : prose === null ? "unavailable" : "ok";
1687
+ const { guides: proseMatches, status: docsStatus } = await searchConnectedGuides(docsSiteUrl(), docsIndexUrl(), fetchDocs, term);
1635
1688
  if (jsonOutput) {
1636
1689
  out({
1637
1690
  schema_version: "1",
@@ -1643,7 +1696,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1643
1696
  ...(op.summary ? { summary: op.summary } : {}),
1644
1697
  details_command: BIN + " docs " + op.command.join(" ") + " --json",
1645
1698
  })),
1646
- guides: proseMatches.slice(0, 15).map(({ heading, excerpt }) => ({ heading, excerpt })),
1699
+ guides: proseMatches.slice(0, 15).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
1647
1700
  totals: { reference: refMatches.length, guides: proseMatches.length },
1648
1701
  guides_status: docsStatus,
1649
1702
  ...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
@@ -1658,7 +1711,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1658
1711
  }
1659
1712
  if (proseMatches.length > 0) {
1660
1713
  lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
1661
- for (const match of proseMatches.slice(0, 15)) lines.push(" " + padPaint("cyan", match.heading.slice(0, 32), 34) + match.excerpt.slice(0, 100));
1714
+ for (const match of proseMatches.slice(0, 15)) lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
1662
1715
  } else if (docsStatus === "not_configured") {
1663
1716
  lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
1664
1717
  } else if (docsStatus === "unavailable") {
@@ -1675,9 +1728,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1675
1728
  let target = page!;
1676
1729
  if (!/^https?:\/\//.test(target)) {
1677
1730
  const index = await fetchDocs("llms.txt");
1678
- const linked = index?.match(/\((https?:[^)]+)\)/g)?.map((m) => m.slice(1, -1)) ?? [];
1679
- const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
1680
- if (hit !== undefined) target = hit;
1731
+ target = docsReadTarget(index, docsSiteUrl(), docsIndexUrl(), target);
1681
1732
  }
1682
1733
  const text = await fetchDocs(target);
1683
1734
  if (text === null) {
@@ -1893,7 +1944,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
1893
1944
  }
1894
1945
  const width = termWidth();
1895
1946
  const lines: string[] = [];
1896
- lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.8.0");
1947
+ lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.10.0");
1897
1948
  lines.push("");
1898
1949
  lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
1899
1950
  lines.push("");
@@ -1917,11 +1968,11 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
1917
1968
  lines.push(...labeled("Upgrade: ", "https://typeship.dev/pricing, then regenerate without the operation cap", width, 14));
1918
1969
  }
1919
1970
  lines.push("");
1920
- const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check bodies)" +
1971
+ const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --profile <name>, --credentials @<file>|-, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check bodies)" +
1921
1972
  (AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
1922
1973
  lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
1923
1974
  lines.push(...labeled("Credential env vars: ", [
1924
- ...AUTH_SCALARS.map((a) => a.env),
1975
+ "TYPESHIP_CREDENTIALS", ...AUTH_SCALARS.map((a) => a.env),
1925
1976
  ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
1926
1977
  ].join(", ") || "none", width, 21));
1927
1978
  lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
@@ -1963,6 +2014,7 @@ function printResource(resource: string, stream: NodeJS.WriteStream = process.st
1963
2014
  /** Flags the CLI itself adds to an API command, as [flag, description] rows. */
1964
2015
  function commandExtras(op: OpSpec): [string, string][] {
1965
2016
  const extras: [string, string][] = [];
2017
+ if (op.auth !== "none" && Object.keys(NAMED_SCHEMES).length) extras.push(["--credentials @<file>|-", "named credentials as JSON; use - for stdin"]);
1966
2018
  if (op.hasBody && op.bodyKind === "binary") extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
1967
2019
  else if (op.hasBody) extras.push(["--data '<json>'", "raw JSON body" + (op.bodyStyle === "fields" ? " (merged under field flags)" : "") + "; @<file> reads a file, - reads stdin"]);
1968
2020
  if (op.select) extras.push(["--select '<selection>'", "GraphQL selection set replacing the default, e.g. '{ id name }'"]);
@@ -2006,7 +2058,7 @@ function exampleLine(op: OpSpec): string {
2006
2058
 
2007
2059
  /** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
2008
2060
  function authNote(op: OpSpec): string {
2009
- const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || OAUTH_TOKEN_URL !== null;
2061
+ const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || HAS_OAUTH_LOGIN;
2010
2062
  return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
2011
2063
  }
2012
2064
 
@@ -2158,21 +2210,48 @@ function coerce(spec: ParamSpec, raw: string | boolean, repeated?: string[]): un
2158
2210
  /** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
2159
2211
  let LAST_CLIENT_HAD_CREDENTIAL = false;
2160
2212
 
2213
+ function validateCredentialsInput(flags: Map<string, string | boolean>): void {
2214
+ const input = flags.get("credentials");
2215
+ if (BASIC && flags.has("username") !== flags.has("password")) fail(2, "Supply --username and --password together, or use a complete named Basic credential.");
2216
+ if (input === undefined) return;
2217
+ if (typeof input !== "string" || (input !== "-" && (!input.startsWith("@") || input.length < 2))) fail(2, "--credentials expects @<JSON-file> or - to read JSON from stdin.");
2218
+ if (input === "-" && (flags.get("data") === "-" || flags.get("file") === "-" || flags.get("with-token") === true)) fail(2, "Stdin cannot supply both named credentials and another input. Use a credential file instead.");
2219
+ }
2220
+ let namedFlagCache: NamedCredentials | undefined;
2221
+ function flagCredentials(flags: Map<string, string | boolean>): NamedCredentials {
2222
+ if (namedFlagCache) return namedFlagCache;
2223
+ validateCredentialsInput(flags);
2224
+ const input = flags.get("credentials");
2225
+ if (input === undefined) return {};
2226
+ try {
2227
+ const named = readNamedCredentialsFile(input as string, NAMED_SCHEMES);
2228
+ if (!Object.keys(named).length) fail(2, "The named credential file must contain at least one security scheme.");
2229
+ return namedFlagCache = named;
2230
+ }
2231
+ catch (error) { fail(2, (error as Error).message); }
2232
+ }
2233
+ function environmentCredentials(): NamedCredentials {
2234
+ if (BASIC && (process.env[BASIC.envUser] !== undefined) !== (process.env[BASIC.envPass] !== undefined)) fail(2, "Supply both Basic-auth environment variables together, or use a complete named Basic credential.");
2235
+ const value = process.env["TYPESHIP_CREDENTIALS"];
2236
+ return value === undefined ? {} : parseNamedCredentials(value, NAMED_SCHEMES);
2237
+ }
2238
+
2161
2239
  /** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
2162
2240
  function credentialSource(flags: Map<string, string | boolean>): string | null {
2241
+ if (Object.keys(flagCredentials(flags)).length) return "flags";
2163
2242
  if (AUTH_SCALARS.some((a) => typeof flags.get(a.flag) === "string")) return "flags";
2164
2243
  if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string") return "flags";
2244
+ if (Object.keys(environmentCredentials()).length) return "env:TYPESHIP_CREDENTIALS";
2165
2245
  const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
2166
2246
  if (envScalar) return "env:" + envScalar.env;
2167
2247
  if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) return "env:" + BASIC.envUser;
2168
2248
  const stored = readCreds();
2169
- if (stored && (stored.scalars || stored.basic || stored.oauth)) return "login";
2249
+ if (stored && (stored.scalars || stored.basic || stored.oauth || stored.named)) return "login";
2170
2250
  return null;
2171
2251
  }
2172
2252
 
2173
2253
  /** Base URL resolution: --base-url > env > config base-url > config environment > spec default. */
2174
- function resolveBaseUrl(flags: Map<string, string | boolean>): string | undefined {
2175
- const config = readConfig();
2254
+ function resolveBaseUrl(flags: Map<string, string | boolean>, config = readConfig()): string | undefined {
2176
2255
  return (typeof flags.get("base-url") === "string" ? flags.get("base-url") as string : undefined)
2177
2256
  ?? process.env["TYPESHIP_BASE_URL"]
2178
2257
  ?? config.baseUrl
@@ -2180,10 +2259,22 @@ function resolveBaseUrl(flags: Map<string, string | boolean>): string | undefine
2180
2259
  ?? DEFAULT_BASE_URL ?? undefined;
2181
2260
  }
2182
2261
 
2183
- async function makeClient(flags: Map<string, string | boolean>): Promise<TypeshipClient> {
2184
- const stored = readCreds();
2185
- const baseUrl = resolveBaseUrl(flags);
2262
+ async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, candidate?: StoredCreds, forIdentity = false): Promise<TypeshipClient> {
2263
+ const flagNamed = flagCredentials(flags), envNamed = environmentCredentials();
2264
+ const explicitOptions = new Set(AUTH_SCALARS.filter((a) => typeof flags.get(a.flag) === "string" || process.env[a.env] !== undefined).map((a) => a.option));
2265
+ if (BASIC && (typeof flags.get("username") === "string" || process.env[BASIC.envUser] !== undefined) && (typeof flags.get("password") === "string" || process.env[BASIC.envPass] !== undefined)) explicitOptions.add("basicAuth");
2266
+ namedCredentialAvailability(NAMED_SCHEMES, explicitOptions, { ...envNamed, ...flagNamed });
2267
+ const explicitCredentials = op.credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => explicitOptions.has(option)));
2268
+ // A complete explicit alternative for this endpoint does not unlock or mix
2269
+ // in saved credentials for another API, account, or authentication method.
2270
+ const stored = candidate ?? (op.auth === "none" || explicitCredentials ? null : readCreds());
2271
+ const config = readConfig();
2272
+ const baseUrl = resolveBaseUrl(flags, config);
2186
2273
  if (baseUrl === undefined) fail(2, "No base URL. Pass --base-url, set TYPESHIP_BASE_URL, or run '" + BIN + " config set base-url <url>'.");
2274
+ if (stored) {
2275
+ assertCredentialDestination(stored, { apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name });
2276
+ if (!forIdentity) assertStoredIdentity(stored, identityConfiguration());
2277
+ }
2187
2278
  const options: ClientOptions & Record<string, unknown> = { baseUrl };
2188
2279
  for (const a of AUTH_SCALARS) {
2189
2280
  const v = (typeof flags.get(a.flag) === "string" ? flags.get(a.flag) as string : undefined)
@@ -2197,9 +2288,24 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
2197
2288
  ?? process.env[BASIC.envPass] ?? stored?.basic?.password;
2198
2289
  if (username !== undefined && password !== undefined) options.basicAuth = { username, password };
2199
2290
  }
2200
- if (options.bearerToken === undefined && stored?.oauth) {
2201
- const token = await refreshedOauthToken(stored);
2202
- if (token !== undefined) options.bearerToken = token;
2291
+ const envOptions: Record<string, unknown> = {}, flagOptions: Record<string, unknown> = {};
2292
+ for (const a of AUTH_SCALARS) {
2293
+ if (process.env[a.env] !== undefined) envOptions[a.option] = process.env[a.env];
2294
+ if (typeof flags.get(a.flag) === "string") flagOptions[a.option] = flags.get(a.flag);
2295
+ }
2296
+ if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
2297
+ if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string") flagOptions.basicAuth = { username: flags.get("username"), password: flags.get("password") };
2298
+ options.credentials = resolveNamedCredentials(NAMED_SCHEMES, [
2299
+ { named: stored?.named, options: { ...stored?.scalars, ...(stored?.basic ? { basicAuth: stored.basic } : {}) } },
2300
+ { named: envNamed, options: envOptions }, { named: flagNamed, options: flagOptions },
2301
+ ...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
2302
+ ]);
2303
+ if (stored?.oauth && (forIdentity || options.bearerToken === undefined)) {
2304
+ const sessionId = stored.oauth.sessionId;
2305
+ options.bearerToken = forIdentity ? stored.oauth.accessToken : () => oauthSessionToken(credentialStore(), {
2306
+ ...sessionConfiguration(baseUrl, config),
2307
+ ...(identityConfiguration() && WHOAMI ? { verifyIdentity: (accessToken: string) => verifyClientIdentity((values) => new TypeshipClient(values as unknown as ClientOptions), { ...options, bearerToken: accessToken, credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ named: options.credentials as NamedCredentials }, { options: { bearerToken: accessToken } }]) }, WHOAMI!, IDENTITY_POLICY) } : {}),
2308
+ }, sessionId, OAUTH_TOKEN_PARAMS);
2203
2309
  }
2204
2310
  if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
2205
2311
  options.debug = (event: DebugEvent) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
@@ -2210,7 +2316,7 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
2210
2316
  const value = typeof flagValue === "string" ? flagValue : process.env["TYPESHIP_" + g.envSuffix];
2211
2317
  if (value !== undefined) options[g.option] = value;
2212
2318
  }
2213
- LAST_CLIENT_HAD_CREDENTIAL = AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
2319
+ LAST_CLIENT_HAD_CREDENTIAL = Object.keys(options.credentials ?? {}).length > 0 || AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
2214
2320
  // Who is calling: the CLI, under which agent harness, and whether an
2215
2321
  // agent is driving. "agent" means a harness was detected or the caller
2216
2322
  // said so (--mode agent / env); a bare non-TTY run (CI, a pipeline) is
@@ -2224,6 +2330,7 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
2224
2330
  ...(options.defaultHeaders as Record<string, string> | undefined),
2225
2331
  "User-Agent": PKG_NAME + "-cli/" + VERSION + " (typeship" + (harness ? "; harness=" + harness : "") + caller + ")",
2226
2332
  };
2333
+ if (forIdentity) { options.fetch = identityFetch(baseUrl); options.maxRetries = 0; options.timeoutMs = 10_000; }
2227
2334
  return new TypeshipClient(options);
2228
2335
  }
2229
2336
 
@@ -2272,6 +2379,11 @@ const BUILTIN_COMMANDS = ["login","logout","whoami","config","mcp","docs","upgra
2272
2379
  async function main(): Promise<void> {
2273
2380
  const argv = process.argv.slice(2);
2274
2381
  const parsed = parseArgv(argv);
2382
+ if (!parsed.help) validateCredentialsInput(parsed.flags);
2383
+ const profileFlag = parsed.flags.get("profile");
2384
+ if (profileFlag !== undefined && typeof profileFlag !== "string") fail(2, "--profile requires a profile name.");
2385
+ PROFILE = resolveProfile(configRoot(), { flag: profileFlag as string | undefined, environment: process.env["TYPESHIP_PROFILE"], allowMissing: ["login", "config", "auth", "init", "help"].includes(parsed.positionals[0] ?? "") || parsed.help });
2386
+ if (!parsed.help && ["login", "init"].includes(parsed.positionals[0] ?? "")) expectedLoginIdentity(parsed.flags);
2275
2387
  if (parsed.positionals[0] === "help") {
2276
2388
  // help --json: the command surface as data (agents read this once).
2277
2389
  if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") { out(helpJson()); await flushExit(0); }
@@ -2309,8 +2421,8 @@ async function main(): Promise<void> {
2309
2421
  if (resourceCmd === "mcp") { await cmdMcp(parsed); }
2310
2422
  if (resourceCmd === "login") { await cmdLogin(parsed); }
2311
2423
  if (resourceCmd === "logout") {
2312
- if (parsed.help) { process.stdout.write(BIN + " logout — remove " + credsPath() + "\n"); await flushExit(0); }
2313
- await cmdLogout();
2424
+ if (parsed.help) { process.stdout.write(BIN + " logout — remove " + credsPath() + "\n --local remove the local session without unlocking OS storage or revoking tokens\n"); await flushExit(0); }
2425
+ await cmdLogout(parsed);
2314
2426
  }
2315
2427
  if (resourceCmd === "whoami") {
2316
2428
  if (parsed.help) {
@@ -2374,7 +2486,7 @@ async function main(): Promise<void> {
2374
2486
  // Mirrors opReservedFlags() in the generator: API parameters never use these
2375
2487
  // names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
2376
2488
  // check can be exact.
2377
- const RESERVED_FLAGS = new Set(["data", "all", "select", "base-url", "debug", "validate", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
2489
+ const RESERVED_FLAGS = new Set(["data", "credentials", "all", "select", "base-url", "profile", "debug", "validate", "non-interactive", "color", "version", "help", "yes", "force", "mode", "format", "json", "out", "fields", ...AUTH_SCALARS.map((a) => a.flag), ...(BASIC ? ["username", "password"] : []), ...GLOBALS.map((g) => g.flag)]);
2378
2490
  for (const spec of op.params) {
2379
2491
  if (spec.kind === "path") continue;
2380
2492
  const raw = parsed.flags.get(spec.flag);
@@ -2429,7 +2541,7 @@ async function main(): Promise<void> {
2429
2541
 
2430
2542
  // The spec says this operation needs a credential and none resolved:
2431
2543
  // say so now, locally, instead of sending a request to learn it.
2432
- if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC || OAUTH_TOKEN_URL) && credentialSource(parsed.flags) === null) {
2544
+ if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC || HAS_OAUTH_LOGIN) && credentialSource(parsed.flags) === null) {
2433
2545
  failWith({
2434
2546
  status: "action_required",
2435
2547
  code: "NO_AUTH",
@@ -2461,7 +2573,7 @@ async function main(): Promise<void> {
2461
2573
  if (answer !== "y" && answer !== "yes") failWith({ status: "action_required", code: "CONFIRMATION_REQUIRED", message: "Cancelled.", nextSteps: ["Run again with --force to skip the prompt: " + rerun] });
2462
2574
  }
2463
2575
 
2464
- const client = await makeClient(parsed.flags);
2576
+ const client = await makeClient(parsed.flags, op);
2465
2577
  const selectValue = typeof parsed.flags.get("select") === "string" ? (parsed.flags.get("select") as string) : undefined;
2466
2578
  const args = buildArgs(op, values, dataBody, selectValue);
2467
2579
  const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;