@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/dist/cli.js CHANGED
@@ -5,8 +5,17 @@
5
5
  // Prints raw JSON to stdout; errors as JSON on stderr.
6
6
  // Exit codes: 0 success, 1 API/transport error, 2 usage error.
7
7
  import { spawnSync } from "node:child_process";
8
- import { createHash, randomBytes } from "node:crypto";
9
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { oauthBrowserLogin } from "./oauth-login.js";
9
+ import { oauthDeviceLogin, customBrowserApproval, loginEndpoint } from "./polling-login.js";
10
+ import { oauthStatusRequest } from "./oauth-request.js";
11
+ import { checkConsoleBrowserLogin } from "./console-login-check.js";
12
+ import { assertCredentialDestination, assertStoredIdentity, credentialIdentityBinding, oauthSessionToken, sessionBinding } from "./oauth-session.js";
13
+ import { createCredentialStore } from "./credential-storage.js";
14
+ import { identityFetch, identityResult, verifyApiIdentity, verifyClientIdentity, readApiIdentity, assertApiIdentity } from "./api-identity.js";
15
+ import { parseNamedCredentials, readNamedCredentialsFile, resolveNamedCredentials, namedCredentialAvailability } from "./named-credentials.js";
16
+ import { resolveProfile, listProfiles, selectProfile, removeProfile, readProfileConfig, updateProfileConfig } from "./auth-profiles.js";
17
+ import { randomBytes } from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
10
19
  import { homedir, hostname } from "node:os";
11
20
  import { basename, join } from "node:path";
12
21
  import { fileURLToPath } from "node:url";
@@ -14,18 +23,21 @@ import { TypeshipClient, formatDebugEvent } from "./index.js";
14
23
  import { GLOBALS, OMITTED_OPS, OPS, buildArgs, findOp, missingRequired } from "./ops.js";
15
24
  import { MCP_CLIENTS, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, collectionProperty, detectHarness, envelope, exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig, } from "./cli-agent.js";
16
25
  import { relativeDate } from "./dates.js";
17
- import { fetchDocsText, resolveDocsContentUrl } from "./docs.js";
26
+ import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
18
27
  const BIN = "typeship";
19
28
  const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
29
+ const NAMED_SCHEMES = { "apiKey": { "kind": "bearer", "options": ["bearerToken"] } };
20
30
  const AUTH_SCALARS = [{ "option": "bearerToken", "flag": "token", "env": "TYPESHIP_TOKEN" }];
21
31
  const BASIC = null;
22
32
  /** Operations omitted from the generated package by its plan cap. */
23
33
  const EXCLUDED_OPS = 0;
24
34
  /** Generated CLI operations that are intentionally unavailable to MCP. */
25
35
  const MCP_EXCLUDED_OPS = 0;
26
- const VERSION = "0.8.0";
36
+ const VERSION = "0.10.0";
27
37
  const API_VERSION = "1.0.0";
28
38
  const SPEC_FORMAT = "openapi";
39
+ const IDENTITY_POLICY = {};
40
+ let LOGIN_IDENTITY;
29
41
  const WHOAMI = { "resource": "account", "method": "retrieve" };
30
42
  const ENVIRONMENTS = {};
31
43
  const HAS_MCP = false;
@@ -37,6 +49,15 @@ const DOCS_INDEX_URL_DEFAULT = null;
37
49
  const RELAY = null;
38
50
  const SUPPORT_URL = null;
39
51
  const OAUTH_TOKEN_URL = null;
52
+ const OAUTH_ISSUER = null;
53
+ const OAUTH_DISCOVERY_URL = undefined;
54
+ const OAUTH_DISCOVERY_URLS = [];
55
+ const OAUTH_DEVICE_URL = null;
56
+ const HAS_OAUTH_LOGIN = OAUTH_TOKEN_URL !== null || OAUTH_DISCOVERY_URLS.length > 0;
57
+ const OAUTH_LOGIN_METHOD = "device";
58
+ const OAUTH_REDIRECT_URI = undefined;
59
+ const OAUTH_ORGANIZATION_PARAMETER = undefined;
60
+ const OAUTH_AUTHORIZATION_URL = undefined;
40
61
  const OAUTH_CLIENT_ID = null;
41
62
  const OAUTH_SCOPES = [];
42
63
  const OAUTH_TOKEN_PARAMS = {};
@@ -53,11 +74,12 @@ const API_TITLE = "typeship";
53
74
  * is known. */
54
75
  const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json"]);
55
76
  const BUILTIN_BOOLEAN_FLAGS = {
56
- login: ["with-token", "no-browser"],
77
+ login: ["with-token", "no-browser", "device"],
78
+ logout: ["local"],
57
79
  upgrade: ["check"],
58
80
  mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
59
81
  docs: ["web", "schema"],
60
- init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md"],
82
+ init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md", "no-browser"],
61
83
  auth: ["live"],
62
84
  doctor: [],
63
85
  };
@@ -305,24 +327,92 @@ function fail(code, message, extra, nextSteps) {
305
327
  function failApi(error, hadCredential) {
306
328
  return failWith(classifyApiError(error, { bin: BIN, hadCredential, docsUrl: DOCS_URL_DEFAULT }));
307
329
  }
308
- function configDir() {
330
+ // ---------------------------------------------------------------------------
331
+ // credentials — written by `login`, cleared by `logout`
332
+ // ---------------------------------------------------------------------------
333
+ function configRoot() {
309
334
  const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
310
335
  return join(base, BIN);
311
336
  }
337
+ let PROFILE;
338
+ function configDir() { return PROFILE.directory; }
312
339
  function credsPath() {
313
- return join(configDir(), "credentials.json");
340
+ return credentialStore().path;
314
341
  }
315
- function readCreds() {
316
- try {
317
- return JSON.parse(readFileSync(credsPath(), "utf8"));
342
+ function credentialStore() { return createCredentialStore(configDir(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
343
+ function readCreds() { return credentialStore().read(); }
344
+ function sessionConfiguration(baseUrl, config = readConfig()) {
345
+ return {
346
+ apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name,
347
+ identity: identityConfiguration(),
348
+ clientId: process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID,
349
+ issuer: OAUTH_ISSUER, discoveryUrl: OAUTH_DISCOVERY_URL, tokenUrl: OAUTH_TOKEN_URL,
350
+ authorizationUrl: OAUTH_AUTHORIZATION_URL, scopes: OAUTH_SCOPES,
351
+ audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
352
+ organizationParameter: OAUTH_ORGANIZATION_PARAMETER,
353
+ };
354
+ }
355
+ function identityConfiguration() {
356
+ return Object.keys(IDENTITY_POLICY).length && WHOAMI ? { operation: WHOAMI.resource + "." + WHOAMI.method, fields: IDENTITY_POLICY } : undefined;
357
+ }
358
+ function expectedLoginIdentity(flags) {
359
+ const expected = {};
360
+ for (const kind of ["subject", "account", "organization"]) {
361
+ const value = flags.get(kind);
362
+ if (value === undefined)
363
+ continue;
364
+ if (typeof value !== "string" || !value || !IDENTITY_POLICY[kind])
365
+ fail(2, "--" + kind + " requires an expected ID and a configured identity field for that ID.");
366
+ expected[kind] = value;
318
367
  }
319
- catch {
320
- return null;
368
+ const selection = requestedLoginOrganization(flags);
369
+ if (selection) {
370
+ if (expected.organization !== undefined && expected.organization !== selection.id)
371
+ fail(2, "--organization must match --login-organization when both are provided.");
372
+ expected.organization = selection.id;
321
373
  }
374
+ return Object.keys(expected).length ? expected : undefined;
322
375
  }
323
- function writeCreds(creds) {
324
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
325
- writeFileSync(credsPath(), JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
376
+ function requestedLoginOrganization(flags) {
377
+ const id = flags.get("login-organization");
378
+ if (id === undefined)
379
+ return;
380
+ if (!OAUTH_ORGANIZATION_PARAMETER || !IDENTITY_POLICY.organization)
381
+ fail(2, "--login-organization requires auth.oauth_organization_parameter and identity_organization configured by the API owner.");
382
+ if (typeof id !== "string" || !id || id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(id))
383
+ fail(2, "--login-organization requires a nonempty provider organization ID without whitespace.");
384
+ return { parameter: OAUTH_ORGANIZATION_PARAMETER, id: id };
385
+ }
386
+ function loginIdentityReport() { return LOGIN_IDENTITY ? { verified_identity: LOGIN_IDENTITY.values, identity_checked_at: LOGIN_IDENTITY.checkedAt } : {}; }
387
+ /** Each login replaces this profile's credentials and retains its API destination. */
388
+ async function saveLoginCredentials(credentials, flags, destination, onIdentityFailure) {
389
+ const config = readConfig();
390
+ const apiBaseUrl = destination?.apiBaseUrl ?? resolveBaseUrl(flags, config);
391
+ if (!apiBaseUrl)
392
+ fail(2, "Set the API base URL before logging in.");
393
+ const url = new URL(apiBaseUrl);
394
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
395
+ fail(2, "The API base URL must be HTTP or HTTPS without credentials, query, or fragment.");
396
+ const bound = destination ? { apiBaseUrl: destination.apiBaseUrl, environment: destination.environment, profile: destination.profile } : { apiBaseUrl: url.href, environment: config.environment, profile: PROFILE.name };
397
+ const next = { ...credentials, destination: bound };
398
+ const identity = identityConfiguration();
399
+ if (identity && WHOAMI) {
400
+ const op = OPS.find((op) => op.resource === WHOAMI.resource && op.method === WHOAMI.method);
401
+ if (!op)
402
+ fail(2, "The identity operation is unavailable. Regenerate this product with the configured identity read.");
403
+ const transport = identityFetch(bound.apiBaseUrl);
404
+ const verified = await verifyApiIdentity(async (anonymous) => {
405
+ const client = anonymous ? new TypeshipClient({ baseUrl: bound.apiBaseUrl, fetch: transport, maxRetries: 0, timeoutMs: 10_000 }) : await makeClient(flags, op, next, true);
406
+ return identityResult(client, op);
407
+ }, identity.fields, expectedLoginIdentity(flags)).catch(async (error) => {
408
+ await onIdentityFailure?.();
409
+ throw error;
410
+ });
411
+ next.identity = { ...verified, binding: credentialIdentityBinding(next, identity) };
412
+ LOGIN_IDENTITY = verified;
413
+ }
414
+ await credentialStore().update(() => next);
415
+ await updateProfileConfig(configDir(), (current) => ({ ...current, baseUrl: bound.apiBaseUrl }));
326
416
  }
327
417
  function readStdin() {
328
418
  return new Promise((resolve) => {
@@ -384,130 +474,48 @@ function promptHidden(promptText) {
384
474
  stdin.on("data", onData);
385
475
  });
386
476
  }
387
- async function oauthForm(url, params) {
388
- const response = await fetch(url, {
389
- method: "POST",
390
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
391
- body: new URLSearchParams(params).toString(),
392
- });
393
- let body = null;
477
+ async function withLoginCancellation(work) {
478
+ const controller = new AbortController();
479
+ const cancel = () => controller.abort();
480
+ process.once("SIGINT", cancel);
481
+ process.once("SIGTERM", cancel);
394
482
  try {
395
- body = await response.json();
396
- }
397
- catch { /* non-JSON error body */ }
398
- return { status: response.status, body };
399
- }
400
- /** RFC 8628 device flow: discover the device endpoint from the token URL's
401
- * well-known metadata, show the code, poll until authorized. */
402
- async function deviceLogin(clientId, agent = false) {
403
- if (!OAUTH_TOKEN_URL)
404
- fail(2, "This API declares no OAuth token URL.");
405
- const origin = new URL(OAUTH_TOKEN_URL).origin;
406
- let deviceEndpoint;
407
- let tokenEndpoint = OAUTH_TOKEN_URL;
408
- for (const wellKnown of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
409
- try {
410
- const response = await fetch(origin + wellKnown, { headers: { Accept: "application/json" } });
411
- if (!response.ok)
412
- continue;
413
- const meta = await response.json();
414
- if (meta.device_authorization_endpoint) {
415
- deviceEndpoint = meta.device_authorization_endpoint;
416
- if (meta.token_endpoint)
417
- tokenEndpoint = meta.token_endpoint;
418
- break;
419
- }
420
- }
421
- catch { /* try the next well-known path */ }
422
- }
423
- if (!deviceEndpoint) {
424
- fail(1, "The authorization server does not advertise a device flow. Run '" + BIN + " login' with a pasted credential instead.");
425
- }
426
- const start = await oauthForm(deviceEndpoint, {
427
- client_id: clientId,
428
- ...(OAUTH_SCOPES.length > 0 ? { scope: OAUTH_SCOPES.join(" ") } : {}),
429
- ...OAUTH_TOKEN_PARAMS,
430
- });
431
- const startBody = start.body;
432
- if (start.status !== 200 || !startBody?.device_code) {
433
- fail(1, "Device authorization failed (HTTP " + start.status + ").", start.body);
434
- }
435
- const uri = startBody.verification_uri_complete ?? startBody.verification_uri;
436
- process.stderr.write("Open " + paintErr("cyan", String(uri)) + " and enter code: " + paintErr("bold", String(startBody.user_code)) + "\n");
437
- // Under an agent the same facts also go out as one JSON line (stderr,
438
- // so stdout stays the single result document), for the agent to hand
439
- // the URL and code to the user while this polls.
440
- if (agent)
441
- process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: uri, user_code: startBody.user_code, expires_in: startBody.expires_in ?? 900 }) + "\n");
442
- let intervalMs = (startBody.interval ?? 5) * 1000;
443
- const deadline = Date.now() + (startBody.expires_in ?? 900) * 1000;
444
- while (Date.now() < deadline) {
445
- await new Promise((r) => setTimeout(r, intervalMs));
446
- const poll = await oauthForm(tokenEndpoint, {
447
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
448
- device_code: startBody.device_code,
449
- client_id: clientId,
450
- });
451
- const tokenBody = poll.body;
452
- if (poll.status === 200 && tokenBody?.access_token) {
453
- writeCreds({
454
- ...(readCreds() ?? {}),
455
- oauth: {
456
- accessToken: tokenBody.access_token,
457
- refreshToken: tokenBody.refresh_token,
458
- expiresAt: tokenBody.expires_in ? Date.now() + tokenBody.expires_in * 1000 : undefined,
459
- },
460
- });
461
- process.stderr.write(paintErr("green", "Logged in.") + "\n");
462
- out({ ok: true, method: "device", credentials: credsPath() });
463
- await flushExit(0);
464
- }
465
- const errorCode = tokenBody?.error;
466
- if (errorCode === "authorization_pending")
467
- continue;
468
- if (errorCode === "slow_down") {
469
- intervalMs += 5000;
470
- continue;
471
- }
472
- fail(1, "Device login failed: " + (errorCode ?? "HTTP " + poll.status), poll.body);
473
- }
474
- fail(1, "Device login timed out before the code was entered.");
475
- }
476
- /** Stored OAuth access token, refreshed through the token URL when expired.
477
- * A failed refresh returns the stale token; the API's 401 explains better
478
- * than a local guess. */
479
- async function refreshedOauthToken(stored) {
480
- const oauth = stored.oauth;
481
- if (!oauth)
482
- return undefined;
483
- const expired = oauth.expiresAt !== undefined && Date.now() > oauth.expiresAt - 60_000;
484
- if (!expired || !oauth.refreshToken || !OAUTH_TOKEN_URL)
485
- 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;
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;
483
+ return await work(controller.signal);
484
+ }
485
+ finally {
486
+ process.off("SIGINT", cancel);
487
+ process.off("SIGTERM", cancel);
488
+ }
489
+ }
490
+ async function deviceLogin(clientId, parsed) {
491
+ const apiBaseUrl = resolveBaseUrl(parsed.flags);
492
+ if (!apiBaseUrl)
493
+ fail(2, "Set the API base URL before logging in.");
494
+ const loginConfiguration = sessionConfiguration(apiBaseUrl);
495
+ await credentialStore().prepare();
496
+ const session = await withLoginCancellation((signal) => oauthDeviceLogin({
497
+ clientId, issuer: OAUTH_ISSUER, discoveryUrls: OAUTH_DISCOVERY_URLS,
498
+ deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: OAUTH_SCOPES,
499
+ audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
500
+ }, { signal, authorize({ verificationUri, userCode, expiresIn }) {
501
+ process.stderr.write("Open " + paintErr("cyan", verificationUri) + " and enter code: " + paintErr("bold", userCode) + "\n");
502
+ if (isAgentMode(parsed))
503
+ process.stderr.write(JSON.stringify({ event: "device_code", verification_uri: verificationUri, user_code: userCode, expires_in: expiresIn }) + "\n");
504
+ } }));
505
+ await saveLoginCredentials({ oauth: { ...session,
506
+ sessionId: randomBytes(16).toString("hex"), binding: sessionBinding(loginConfiguration),
507
+ apiBaseUrl, configuredClientId: loginConfiguration.clientId ?? null,
508
+ } }, parsed.flags, loginConfiguration);
509
+ process.stderr.write(paintErr("green", "Logged in.") + "\n");
510
+ out({ ok: true, method: "device", credentials: credsPath(), ...loginIdentityReport() });
511
+ await flushExit(0);
503
512
  }
504
- async function storePastedToken(stored, token) {
513
+ async function storePastedToken(token, flags) {
505
514
  const first = AUTH_SCALARS[0];
506
515
  if (!first)
507
516
  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() });
517
+ await saveLoginCredentials({ scalars: { [first.option]: token } }, flags);
518
+ out({ ok: true, method: "paste", stored_as: first.flag, credentials: credsPath(), ...loginIdentityReport() });
511
519
  await flushExit(0);
512
520
  }
513
521
  /**
@@ -517,97 +525,139 @@ async function storePastedToken(stored, token) {
517
525
  * until the API hands back a key minted for this CLI, store it. The key
518
526
  * never crosses the chat: the agent relays a URL, the person clicks once.
519
527
  */
520
- /** The approval endpoint follows the base URL: a preview or local deployment
521
- * of the API approves its own logins. */
528
+ /** A same-origin API override uses that deployment's approval route. A
529
+ * separately hosted identity service retains its explicitly configured URL. */
522
530
  function cliAuthUrl(flags) {
531
+ const auth = loginEndpoint(CLI_AUTH_URL);
523
532
  const base = resolveBaseUrl(flags);
524
- try {
525
- if (CLI_AUTH_URL && DEFAULT_BASE_URL && base) {
526
- const defaultOrigin = new URL(DEFAULT_BASE_URL).origin;
527
- const origin = new URL(base).origin;
528
- if (origin !== defaultOrigin && CLI_AUTH_URL.startsWith(defaultOrigin))
529
- return origin + CLI_AUTH_URL.slice(defaultOrigin.length);
530
- }
533
+ if (DEFAULT_BASE_URL && base && auth.origin === new URL(DEFAULT_BASE_URL).origin) {
534
+ const selected = loginEndpoint(base);
535
+ auth.protocol = selected.protocol;
536
+ auth.host = selected.host;
531
537
  }
532
- catch { /* fall through to the configured URL */ }
533
- return CLI_AUTH_URL;
538
+ return auth.href;
534
539
  }
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
540
  async function browserApprove(headless, flags) {
541
+ return withLoginCancellation((signal) => customBrowserApproval({
542
+ authUrl: cliAuthUrl(flags), name: BIN + " CLI on " + hostname(), source: headless && !process.stdin.isTTY ? "agent" : "cli",
543
+ }, { signal, authorize({ verificationUrl, expiresIn }) {
544
+ process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", verificationUrl) + "\n");
545
+ if (headless)
546
+ 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");
547
+ else
548
+ openInBrowser(verificationUrl);
549
+ } }));
550
+ }
551
+ /** Store what the browser approval minted, marked as this CLI's own. */
552
+ async function storeMinted(minted, flags) {
539
553
  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;
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();
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
- }
558
- catch (e) {
559
- failWith({ code: "NETWORK_ERROR", message: "Could not reach " + authUrl + "/start: " + e.message, nextSteps: ["Check the network, or pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
560
- }
561
- const url = start.verification_url;
562
- const expiresIn = start.expires_in ?? 600;
563
- const intervalMs = Math.max(1, start.interval ?? 3) * 1000;
564
- process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", url) + "\n");
565
- if (headless) {
566
- 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");
567
- }
568
- else {
569
- openInBrowser(url);
570
- }
571
- const deadline = Date.now() + expiresIn * 1000;
572
- while (Date.now() < deadline) {
573
- await new Promise((r) => setTimeout(r, intervalMs));
574
- let poll;
554
+ await saveLoginCredentials({
555
+ scalars: { [first.option]: minted.api_key },
556
+ minted: { via: "browser", key_name: minted.key_name, revocationUrl: minted.revocationUrl, ...(minted.org_id ? { org_id: minted.org_id } : {}) },
557
+ }, flags, undefined, async () => {
575
558
  try {
576
- const response = await fetch(authUrl + "/status", {
577
- method: "POST",
578
- headers: { "Content-Type": "application/json" },
579
- body: JSON.stringify({ session: start.session, code_verifier: verifier }),
580
- signal: AbortSignal.timeout(15_000),
581
- });
582
- poll = await response.json();
559
+ const saved = credentialStore().read();
560
+ if ([...Object.values(saved?.scalars ?? {}), ...Object.values(saved?.named ?? {}), saved?.oauth?.accessToken, saved?.oauth?.refreshToken].includes(minted.api_key)) {
561
+ process.stderr.write("The rejected approval returned an existing saved credential; it was not revoked.\n");
562
+ return;
563
+ }
564
+ const response = await oauthStatusRequest(loginEndpoint(minted.revocationUrl), { method: "POST", headers: { Authorization: "Bearer " + minted.api_key } }, 15_000);
565
+ if (response.status < 200 || response.status >= 300)
566
+ throw new Error("Revocation failed");
583
567
  }
584
568
  catch {
585
- continue; // a blip; the next tick tries again
586
- }
587
- if (poll.status === "pending")
588
- continue;
589
- if (poll.status === "complete" && poll.api_key) {
590
- return { api_key: poll.api_key, key_name: poll.key_name ?? name, ...(poll.org_id ? { org_id: poll.org_id } : {}) };
569
+ process.stderr.write("The rejected approval's new credential could not be revoked. Revoke it in your API account.\n");
591
570
  }
592
- if (poll.status === "denied")
593
- 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 + "."] });
594
- if (poll.status === "expired")
595
- break;
596
- failWith({ code: "CALL_FAILED", message: "Browser login stopped: " + (poll.status ?? "unknown status"), nextSteps: ["Run '" + BIN + " login' again."] });
597
- }
598
- 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>'."] });
599
- }
600
- /** Store what the browser approval minted, marked as this CLI's own. */
601
- function storeMinted(stored, minted) {
602
- const first = AUTH_SCALARS[0];
603
- stored.scalars = { ...stored.scalars, [first.option]: minted.api_key };
604
- stored.minted = { via: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}) };
605
- writeCreds(stored);
571
+ });
606
572
  }
607
- async function browserLogin(stored, headless, flags) {
573
+ async function browserLogin(headless, flags) {
574
+ await credentialStore().prepare();
608
575
  const minted = await browserApprove(headless, flags);
609
- storeMinted(stored, minted);
610
- out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath() });
576
+ await storeMinted(minted, flags);
577
+ out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
578
+ await flushExit(0);
579
+ }
580
+ async function acquireOAuthBrowserSession(parsed, clientId) {
581
+ if (!OAUTH_ISSUER)
582
+ fail(2, "Browser OAuth requires the exact auth.oauth_issuer configured by the API owner.");
583
+ const apiBaseUrl = resolveBaseUrl(parsed.flags);
584
+ if (!apiBaseUrl)
585
+ fail(2, "Set the API base URL before logging in.");
586
+ const loginConfiguration = sessionConfiguration(apiBaseUrl);
587
+ await credentialStore().prepare();
588
+ const session = await startOAuthBrowserSession(parsed, clientId);
589
+ await saveLoginCredentials({ oauth: { ...session, apiBaseUrl,
590
+ sessionId: randomBytes(16).toString("hex"), binding: sessionBinding(loginConfiguration),
591
+ configuredClientId: loginConfiguration.clientId ?? null,
592
+ } }, parsed.flags, loginConfiguration);
593
+ }
594
+ /** Normal login and Console verification use the same native exchange. */
595
+ async function startOAuthBrowserSession(parsed, clientId, timeoutMs) {
596
+ const controller = new AbortController();
597
+ const cancel = () => controller.abort();
598
+ process.once("SIGINT", cancel);
599
+ process.once("SIGTERM", cancel);
600
+ try {
601
+ return await oauthBrowserLogin({
602
+ issuer: OAUTH_ISSUER, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
603
+ authorizationUrl: OAUTH_AUTHORIZATION_URL, tokenUrl: OAUTH_TOKEN_URL ?? undefined,
604
+ redirectUri: OAUTH_REDIRECT_URI, scopes: OAUTH_SCOPES,
605
+ audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
606
+ organization: requestedLoginOrganization(parsed.flags),
607
+ }, { signal: controller.signal, timeoutMs, authorize(url) {
608
+ process.stderr.write("Sign in to your existing account: " + url + "\n");
609
+ if (isAgentMode(parsed) || parsed.flags.get("no-browser") === true)
610
+ 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");
611
+ else
612
+ openInBrowser(url);
613
+ } });
614
+ }
615
+ finally {
616
+ process.off("SIGINT", cancel);
617
+ process.off("SIGTERM", cancel);
618
+ }
619
+ }
620
+ async function cmdConsoleLoginCheck(parsed) {
621
+ const file = parsed.flags.get("console-check");
622
+ if (typeof file !== "string" || !file || parsed.flags.get("device") === true || parsed.flags.get("with-token") === true || explicitNonInteractive(parsed))
623
+ fail(2, "--console-check requires a downloaded JSON file and browser login. Use --no-browser to print the sign-in link.");
624
+ const baseUrl = resolveBaseUrl(parsed.flags);
625
+ const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") : undefined) ?? process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
626
+ const op = WHOAMI && OPS.find((value) => value.resource === WHOAMI.resource && value.method === WHOAMI.method);
627
+ if (!baseUrl || !OAUTH_ISSUER || !clientId || OAUTH_LOGIN_METHOD !== "browser" || !op || op.auth !== "required" || !op.security?.length)
628
+ fail(2, "Configure browser OAuth and a required-authentication identity read, then regenerate this CLI.");
629
+ const envOptions = {}, flagOptions = {};
630
+ for (const scalar of AUTH_SCALARS) {
631
+ if (process.env[scalar.env] !== undefined)
632
+ envOptions[scalar.option] = process.env[scalar.env];
633
+ if (typeof parsed.flags.get(scalar.flag) === "string")
634
+ flagOptions[scalar.option] = parsed.flags.get(scalar.flag);
635
+ }
636
+ if (BASIC && process.env[BASIC.envUser] && process.env[BASIC.envPass])
637
+ envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
638
+ if (BASIC && typeof parsed.flags.get("username") === "string" && typeof parsed.flags.get("password") === "string")
639
+ flagOptions.basicAuth = { username: parsed.flags.get("username"), password: parsed.flags.get("password") };
640
+ const environment = (typeof parsed.flags.get("environment") === "string" ? parsed.flags.get("environment") : Object.entries(ENVIRONMENTS).find(([, url]) => new URL(url).href === new URL(baseUrl).href)?.[0]) ?? null;
641
+ const result = await checkConsoleBrowserLogin({
642
+ file: file, configuration: {
643
+ baseUrl: baseUrl, environment, operation: op.resource + "." + op.method, requirements: op.security,
644
+ 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 },
645
+ issuer: OAUTH_ISSUER, clientId: clientId, discoveryUrl: OAUTH_DISCOVERY_URL ?? null,
646
+ authorizationUrl: OAUTH_AUTHORIZATION_URL ?? null, tokenUrl: OAUTH_TOKEN_URL,
647
+ redirectUri: OAUTH_REDIRECT_URI ?? "http://127.0.0.1/callback", scopes: OAUTH_SCOPES,
648
+ audience: OAUTH_TOKEN_PARAMS.audience ?? null, resource: OAUTH_TOKEN_PARAMS.resource ?? null,
649
+ }, schemes: NAMED_SCHEMES,
650
+ credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ options: envOptions, named: environmentCredentials() }, { options: flagOptions, named: flagCredentials(parsed.flags) }]),
651
+ login: (timeoutMs) => startOAuthBrowserSession(parsed, clientId, timeoutMs),
652
+ async verify(credentials, expectations) {
653
+ const kind = (value) => value === "user" ? "subject" : value;
654
+ const policy = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), entry.pointer]));
655
+ const expected = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), String(entry.expected)]));
656
+ await verifyClientIdentity((options) => new TypeshipClient(options), { baseUrl: baseUrl, credentials }, op, policy, expected);
657
+ },
658
+ progress: (message) => process.stderr.write(message + "\n"),
659
+ });
660
+ out(result);
611
661
  await flushExit(0);
612
662
  }
613
663
  async function cmdLogin(parsed) {
@@ -618,16 +668,34 @@ async function cmdLogin(parsed) {
618
668
  ...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
619
669
  ...(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)"] : []),
620
670
  " " + BIN + " login --with-token read the credential from stdin (CI)",
621
- ...(OAUTH_TOKEN_URL ? [" " + BIN + " login --client-id <id> OAuth device flow" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
671
+ " " + BIN + " login --console-check <file> test browser login for your Typeship Console without replacing saved logins",
672
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + OAUTH_LOGIN_METHOD + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
673
+ ...(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"] : []),
622
674
  ...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
623
- ...(CLI_AUTH_URL ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
675
+ ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
624
676
  "",
625
- "Precedence at request time: flags > env vars > stored credentials.",
677
+ "Precedence per scheme: flags > env vars > stored credentials. Named inputs beat convenience flags within the same source.",
678
+ " " + BIN + " login --credentials @<JSON-file> store named credentials (use - for stdin)",
679
+ "Named schemes: " + Object.entries(NAMED_SCHEMES).map(([name, scheme]) => name + " (" + scheme.kind + ")").join(", "),
680
+ "JSON values are tokens/API keys, or {username, password} for Basic auth. Runtime env: TYPESHIP_CREDENTIALS.",
681
+ "--profile <name> selects an isolated login. Use auth profiles to list profiles and auth use <name> to select a default.",
682
+ ...(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."] : []),
683
+ ...(OAUTH_ORGANIZATION_PARAMETER ? ["--login-organization <id> requests that provider organization during browser login and requires the API to confirm the same ID."] : []),
684
+ "Saved credentials use OS protection by default. TYPESHIP_CREDENTIAL_STORE=file explicitly opts into plaintext storage.",
626
685
  ];
627
686
  process.stdout.write(lines.join("\n") + "\n");
628
687
  await flushExit(0);
629
688
  }
630
- const stored = readCreds() ?? {};
689
+ if (parsed.flags.has("login-organization")) {
690
+ expectedLoginIdentity(parsed.flags);
691
+ if (!HAS_OAUTH_LOGIN || OAUTH_LOGIN_METHOD !== "browser" || parsed.flags.has("device") || parsed.flags.has("console-check") || parsed.flags.has("with-token") || explicitNonInteractive(parsed))
692
+ fail(2, "--login-organization is available only for interactive OAuth browser login, including --no-browser.");
693
+ }
694
+ if (parsed.flags.has("console-check")) {
695
+ await cmdConsoleLoginCheck(parsed);
696
+ return;
697
+ }
698
+ const named = flagCredentials(parsed.flags);
631
699
  const scalarValues = {};
632
700
  for (const a of AUTH_SCALARS) {
633
701
  const v = parsed.flags.get(a.flag);
@@ -637,31 +705,38 @@ async function cmdLogin(parsed) {
637
705
  const username = parsed.flags.get("username");
638
706
  const password = parsed.flags.get("password");
639
707
  const gotBasic = BASIC !== null && typeof username === "string" && typeof password === "string";
640
- if (Object.keys(scalarValues).length > 0 || gotBasic) {
641
- if (Object.keys(scalarValues).length > 0)
642
- stored.scalars = { ...stored.scalars, ...scalarValues };
643
- if (gotBasic)
644
- stored.basic = { username: username, password: password };
645
- writeCreds(stored);
646
- out({ ok: true, method: "flags", credentials: credsPath() });
708
+ if (Object.keys(scalarValues).length > 0 || gotBasic || Object.keys(named).length > 0) {
709
+ if (parsed.flags.has("login-organization"))
710
+ fail(2, "--login-organization cannot be combined with pasted credentials.");
711
+ await saveLoginCredentials({
712
+ ...(Object.keys(named).length ? { named } : {}),
713
+ ...(Object.keys(scalarValues).length > 0 ? { scalars: scalarValues } : {}),
714
+ ...(gotBasic ? { basic: { username: username, password: password } } : {}),
715
+ }, parsed.flags);
716
+ out({ ok: true, method: "flags", credentials: credsPath(), ...loginIdentityReport() });
647
717
  await flushExit(0);
648
718
  }
649
719
  if (parsed.flags.get("with-token") === true) {
650
720
  const token = (await readStdin()).trim();
651
721
  if (!token)
652
722
  fail(2, "--with-token expects the credential on stdin.");
653
- await storePastedToken(stored, token);
723
+ await storePastedToken(token, parsed.flags);
654
724
  }
655
725
  const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") : undefined)
656
726
  ?? process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID ?? undefined;
657
- if (OAUTH_TOKEN_URL && clientId !== undefined && !explicitNonInteractive(parsed)) {
658
- await deviceLogin(clientId, isAgentMode(parsed));
727
+ if (HAS_OAUTH_LOGIN && clientId !== undefined && !explicitNonInteractive(parsed)) {
728
+ if (OAUTH_LOGIN_METHOD === "browser" && parsed.flags.get("device") !== true) {
729
+ await acquireOAuthBrowserSession(parsed, clientId);
730
+ out({ ok: true, method: "oauth_browser", credentials: credsPath(), ...loginIdentityReport() });
731
+ await flushExit(0);
732
+ }
733
+ await deviceLogin(clientId, parsed);
659
734
  }
660
735
  // Browser approval: the API mints a key for this CLI once a person
661
736
  // approves in the browser. Works under an agent too (it prints the URL and
662
737
  // polls); only the explicit non-interactive switch turns it off.
663
738
  if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
664
- await browserLogin(stored, isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
739
+ await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
665
740
  }
666
741
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
667
742
  failWith({
@@ -671,7 +746,7 @@ async function cmdLogin(parsed) {
671
746
  nextSteps: [
672
747
  ...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
673
748
  "Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
674
- ...(OAUTH_TOKEN_URL ? ["Device flow: '" + BIN + " login --client-id <id>' (prints a URL and code for the user)."] : []),
749
+ ...(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)."] : []),
675
750
  ...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
676
751
  ],
677
752
  });
@@ -682,34 +757,49 @@ async function cmdLogin(parsed) {
682
757
  const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
683
758
  if (!token)
684
759
  fail(2, "Nothing entered.");
685
- await storePastedToken(stored, token);
760
+ await storePastedToken(token, parsed.flags);
686
761
  }
687
- async function cmdLogout() {
688
- const stored = readCreds();
689
- const existed = existsSync(credsPath());
762
+ async function cmdLogout(parsed) {
763
+ if (parsed.flags.get("local") === true) {
764
+ const removed = await credentialStore().clear();
765
+ out({ ok: true, removed: removed ? credsPath() : null, revoked: false, revocation_skipped: true });
766
+ await flushExit(0);
767
+ }
768
+ const stored = await credentialStore().take();
769
+ const existed = stored !== null;
690
770
  // A key this CLI minted for itself (browser approval) is revoked on the
691
771
  // way out, so logging out ends the credential and not just the file. A
692
772
  // pasted or CI key is someone else's to revoke, and is left alone.
693
773
  let revoked = null;
694
774
  const first = AUTH_SCALARS[0];
695
775
  const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
696
- if (ownKey && CLI_AUTH_URL) {
776
+ if (ownKey) {
697
777
  try {
698
- const response = await fetch(CLI_AUTH_URL + "/revoke", { method: "POST", headers: { Authorization: "Bearer " + ownKey }, signal: AbortSignal.timeout(15_000) });
699
- revoked = response.ok;
778
+ const response = await oauthStatusRequest(loginEndpoint(stored.minted.revocationUrl), { method: "POST", headers: { Authorization: "Bearer " + ownKey } }, 15_000);
779
+ revoked = response.status >= 200 && response.status < 300;
780
+ }
781
+ catch {
782
+ revoked = false;
783
+ }
784
+ }
785
+ if (stored?.oauth?.revocationUrl) {
786
+ try {
787
+ const oauth = stored.oauth;
788
+ const token = oauth.refreshToken ?? oauth.accessToken;
789
+ 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 } : {}) }) });
790
+ revoked = result.status >= 200 && result.status < 300;
700
791
  }
701
792
  catch {
702
793
  revoked = false;
703
794
  }
704
795
  }
705
- rmSync(credsPath(), { force: true });
706
796
  out({ ok: true, removed: existed ? credsPath() : null, ...(revoked === null ? {} : { revoked, key_name: stored?.minted?.key_name }) });
707
797
  await flushExit(0);
708
798
  }
709
799
  async function cmdWhoami(parsed) {
710
800
  const op = WHOAMI ? OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method) : undefined;
711
801
  if (op) {
712
- const client = await makeClient(parsed.flags);
802
+ const client = await makeClient(parsed.flags, op);
713
803
  const target = client[op.resource];
714
804
  const result = await target[op.method]();
715
805
  if (result.ok) {
@@ -718,36 +808,14 @@ async function cmdWhoami(parsed) {
718
808
  }
719
809
  failApi(result.error, LAST_CLIENT_HAD_CREDENTIAL);
720
810
  }
721
- const stored = readCreds();
722
- let source = "none";
723
- if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) {
724
- source = "flags";
725
- }
726
- else {
727
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
728
- if (envScalar)
729
- source = "env:" + envScalar.env;
730
- else if (stored && (stored.scalars || stored.basic || stored.oauth))
731
- source = "login";
732
- }
811
+ const source = credentialSource(parsed.flags) ?? "none";
733
812
  out({ authenticated: source !== "none", source, credentials: existsSync(credsPath()) ? credsPath() : null });
734
813
  await flushExit(source === "none" ? 1 : 0);
735
814
  }
736
815
  function configFilePath() {
737
816
  return join(configDir(), "config.json");
738
817
  }
739
- function readConfig() {
740
- try {
741
- return JSON.parse(readFileSync(configFilePath(), "utf8"));
742
- }
743
- catch {
744
- return {};
745
- }
746
- }
747
- function writeConfig(config) {
748
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
749
- writeFileSync(configFilePath(), JSON.stringify(config, null, 2) + "\n");
750
- }
818
+ function readConfig() { return readProfileConfig(configDir()); }
751
819
  const CONFIG_KEYS = ["base-url", "environment", "docs-url"];
752
820
  function configGet(config, key) {
753
821
  return key === "base-url" ? config.baseUrl : key === "environment" ? config.environment : config.docsUrl;
@@ -778,6 +846,7 @@ async function cmdConfig(parsed) {
778
846
  if (sub === undefined || sub === "list") {
779
847
  const config = readConfig();
780
848
  out({
849
+ profile: PROFILE.name, profile_source: PROFILE.source,
781
850
  base_url: config.baseUrl ?? null,
782
851
  environment: config.environment ?? null,
783
852
  docs_url: config.docsUrl ?? DOCS_URL_DEFAULT,
@@ -800,13 +869,16 @@ async function cmdConfig(parsed) {
800
869
  await flushExit(0);
801
870
  }
802
871
  if (sub === "unset") {
803
- if (key === "base-url")
804
- delete config.baseUrl;
805
- else if (key === "environment")
806
- delete config.environment;
807
- else
808
- delete config.docsUrl;
809
- writeConfig(config);
872
+ await updateProfileConfig(configDir(), (current) => {
873
+ const next = { ...current };
874
+ if (key === "base-url")
875
+ delete next.baseUrl;
876
+ else if (key === "environment")
877
+ delete next.environment;
878
+ else
879
+ delete next.docsUrl;
880
+ return next;
881
+ });
810
882
  out({ ok: true });
811
883
  await flushExit(0);
812
884
  }
@@ -819,10 +891,6 @@ async function cmdConfig(parsed) {
819
891
  catch {
820
892
  fail(2, key + " must be a valid URL");
821
893
  }
822
- if (key === "base-url")
823
- config.baseUrl = value;
824
- else
825
- config.docsUrl = value;
826
894
  }
827
895
  else {
828
896
  if (!(value in ENVIRONMENTS)) {
@@ -830,9 +898,8 @@ async function cmdConfig(parsed) {
830
898
  ? "environment must be one of: " + Object.keys(ENVIRONMENTS).join(", ")
831
899
  : "The spec declares no named environments; use 'config set base-url' instead.");
832
900
  }
833
- config.environment = value;
834
901
  }
835
- writeConfig(config);
902
+ await updateProfileConfig(configDir(), (current) => key === "base-url" ? { ...current, baseUrl: value } : key === "docs-url" ? { ...current, docsUrl: value } : { ...current, environment: value, baseUrl: undefined });
836
903
  out({ ok: true, [key.replace(/-/g, "_")]: value });
837
904
  await flushExit(0);
838
905
  }
@@ -874,7 +941,12 @@ function mcpEntryFor(url, readOnly = false) {
874
941
  const server = mcpServerPath();
875
942
  if (server.warning)
876
943
  warnings.push(server.warning);
877
- return { entry: { command: "node", args: [server.path, ...(readOnly ? ["--read-only"] : [])] }, warnings };
944
+ const environment = {
945
+ ...(process.env.XDG_CONFIG_HOME !== undefined ? { XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME } : {}),
946
+ ...(process.env["TYPESHIP_CREDENTIAL_STORE"] !== undefined ? { "TYPESHIP_CREDENTIAL_STORE": process.env["TYPESHIP_CREDENTIAL_STORE"] } : {}),
947
+ };
948
+ const pinned = PROFILE.source === "flag" || PROFILE.source === "environment";
949
+ return { entry: { command: "node", args: [server.path, ...(pinned ? ["--profile", PROFILE.name] : []), ...(readOnly ? ["--read-only"] : [])], ...(Object.keys(environment).length ? { env: environment } : {}) }, warnings };
878
950
  }
879
951
  const MCP_CLIENT_FLAGS = {
880
952
  claude: "claude-code", cursor: "cursor", "claude-desktop": "claude-desktop", codex: "codex", vscode: "vscode",
@@ -973,7 +1045,7 @@ function agentContext() {
973
1045
  apiTitle: API_TITLE,
974
1046
  version: VERSION,
975
1047
  envPrefix: ENV_PREFIX,
976
- authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : [])],
1048
+ authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? ["TYPESHIP_CREDENTIALS"] : [])],
977
1049
  docsUrl: docsSiteUrl(),
978
1050
  docsIndexUrl: docsIndexUrl(),
979
1051
  generatedOperationCount: OPS.length,
@@ -1050,7 +1122,7 @@ function helpJson() {
1050
1122
  note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
1051
1123
  },
1052
1124
  builtins: BUILTIN_COMMANDS,
1053
- 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>")],
1125
+ 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>")],
1054
1126
  auth_env_vars: agentContext().authEnvVars,
1055
1127
  };
1056
1128
  }
@@ -1065,32 +1137,48 @@ async function cmdAgentGuide(parsed) {
1065
1137
  /** Which credential the CLI would send, without sending it. --live calls the identity endpoint too. */
1066
1138
  async function cmdAuth(parsed) {
1067
1139
  const sub = parsed.positionals[1];
1140
+ if (!parsed.help && sub === "profiles") {
1141
+ 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 })) });
1142
+ await flushExit(0);
1143
+ }
1144
+ if (!parsed.help && (sub === "use" || sub === "remove")) {
1145
+ const name = parsed.positionals[2];
1146
+ if (!name)
1147
+ fail(2, "auth " + sub + " requires a profile name.");
1148
+ if (sub === "use")
1149
+ selectProfile(configRoot(), name);
1150
+ else
1151
+ await removeProfile(configRoot(), name);
1152
+ const effective = resolveProfile(configRoot(), { flag: typeof parsed.flags.get("profile") === "string" ? parsed.flags.get("profile") : undefined, environment: process.env["TYPESHIP_PROFILE"], allowMissing: true });
1153
+ out({ ok: true, ...(sub === "use" ? { selected: name } : { removed: name }), profile: effective.name, source: effective.source });
1154
+ await flushExit(0);
1155
+ }
1068
1156
  if (parsed.help || sub !== "check") {
1069
1157
  process.stdout.write([
1070
1158
  BIN + " auth check [--live] — report the credential the CLI would use, as JSON: {status: ok|action_required, authenticated, source, ...}",
1159
+ " " + BIN + " auth profiles list profiles without unlocking credentials",
1160
+ " " + BIN + " auth use <name> select the default profile",
1161
+ " " + BIN + " auth remove <name> remove a profile after logout",
1162
+ " --profile <name> overrides TYPESHIP_PROFILE, then the saved selection, then default.",
1071
1163
  " --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
1072
1164
  "",
1073
1165
  "Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
1074
1166
  ].join("\n") + "\n");
1075
1167
  await flushExit(parsed.help ? 0 : 2);
1076
1168
  }
1077
- const stored = readCreds();
1078
- let source = "none";
1079
- if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string"))
1080
- source = "flags";
1081
- else {
1082
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
1083
- if (envScalar)
1084
- source = "env:" + envScalar.env;
1085
- else if (stored && (stored.scalars || stored.basic || stored.oauth))
1086
- source = "login";
1087
- }
1169
+ const source = credentialSource(parsed.flags) ?? "none";
1088
1170
  const authenticated = source !== "none";
1171
+ const savedIdentity = source === "login" ? readCreds() : null;
1172
+ if (savedIdentity)
1173
+ assertStoredIdentity(savedIdentity, identityConfiguration());
1089
1174
  const report = {
1090
1175
  status: authenticated ? "ok" : "action_required",
1091
1176
  authenticated,
1092
1177
  source,
1093
1178
  credentials_path: existsSync(credsPath()) ? credsPath() : null,
1179
+ credential_storage: credentialStore().backend,
1180
+ verified_identity: savedIdentity?.identity?.values ?? null, identity_checked_at: savedIdentity?.identity?.checkedAt ?? null,
1181
+ profile: PROFILE.name, profile_source: PROFILE.source,
1094
1182
  auth_env_vars: agentContext().authEnvVars,
1095
1183
  base_url: resolveBaseUrl(parsed.flags) ?? null,
1096
1184
  next_steps: authenticated ? [] : [
@@ -1101,11 +1189,14 @@ async function cmdAuth(parsed) {
1101
1189
  if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
1102
1190
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1103
1191
  if (op) {
1104
- const client = await makeClient(parsed.flags);
1192
+ const client = await makeClient(parsed.flags, op);
1105
1193
  const target = client[op.resource];
1106
1194
  const result = await target[op.method]();
1107
- if (result.ok)
1195
+ if (result.ok) {
1196
+ if (identityConfiguration() && savedIdentity?.identity)
1197
+ assertApiIdentity(savedIdentity.identity.values, readApiIdentity(result.data, IDENTITY_POLICY));
1108
1198
  report.identity = result.data;
1199
+ }
1109
1200
  else {
1110
1201
  const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1111
1202
  report.status = "action_required";
@@ -1126,10 +1217,16 @@ async function cmdDoctor(parsed) {
1126
1217
  const nodeMajor = Number(process.versions.node.split(".")[0]);
1127
1218
  checks.push({ name: "node", ok: nodeMajor >= 18, detail: process.version, ...(nodeMajor >= 18 ? {} : { fix: "Install Node 18 or newer." }) });
1128
1219
  checks.push({ name: "cli", ok: true, detail: BIN + " " + VERSION + " (" + PKG_NAME + ")" });
1129
- const stored = readCreds();
1130
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
1131
- const hasCred = Boolean(envScalar) || Boolean(stored && (stored.scalars || stored.basic || stored.oauth));
1132
- 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'." }) });
1220
+ let source = null;
1221
+ let storageProblem;
1222
+ try {
1223
+ source = credentialSource(parsed.flags);
1224
+ }
1225
+ catch (error) {
1226
+ storageProblem = error.message;
1227
+ }
1228
+ const hasCred = source !== null;
1229
+ 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'.") }) });
1133
1230
  const baseUrl = resolveBaseUrl(parsed.flags);
1134
1231
  if (baseUrl) {
1135
1232
  try {
@@ -1147,7 +1244,7 @@ async function cmdDoctor(parsed) {
1147
1244
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1148
1245
  if (op) {
1149
1246
  try {
1150
- const client = await makeClient(parsed.flags);
1247
+ const client = await makeClient(parsed.flags, op);
1151
1248
  const target = client[op.resource];
1152
1249
  const result = await target[op.method]();
1153
1250
  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." });
@@ -1200,27 +1297,39 @@ async function cmdInit(parsed) {
1200
1297
  const cwd = process.cwd();
1201
1298
  const harness = detectHarness();
1202
1299
  // 1. credential
1203
- const stored = readCreds() ?? {};
1204
1300
  const first = AUTH_SCALARS[0];
1301
+ const named = flagCredentials(parsed.flags), envNamed = environmentCredentials();
1302
+ const stored = Object.keys(named).length || Object.keys(envNamed).length || first && process.env[first.env] ? {} : readCreds() ?? {};
1205
1303
  const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") : undefined)
1206
1304
  ?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) : undefined);
1207
- if (given && first) {
1208
- stored.scalars = { ...stored.scalars, [first.option]: given };
1209
- writeCreds(stored);
1305
+ if (Object.keys(named).length) {
1306
+ await saveLoginCredentials({ named }, parsed.flags);
1307
+ report.credential = { status: "stored", path: credsPath() };
1308
+ }
1309
+ else if (given && first) {
1310
+ await saveLoginCredentials({ scalars: { [first.option]: given } }, parsed.flags);
1210
1311
  report.credential = { status: "stored", path: credsPath() };
1211
1312
  }
1212
1313
  else if (first && process.env[first.env]) {
1213
1314
  report.credential = { status: "env", variable: first.env };
1214
1315
  }
1215
- else if (stored.scalars || stored.basic || stored.oauth) {
1316
+ else if (Object.keys(envNamed).length) {
1317
+ report.credential = { status: "env", variable: "TYPESHIP_CREDENTIALS" };
1318
+ }
1319
+ else if (stored.scalars || stored.basic || stored.oauth || stored.named) {
1216
1320
  report.credential = { status: "stored", path: credsPath() };
1217
1321
  }
1322
+ else if (first && HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" && (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID) && !explicitNonInteractive(parsed)) {
1323
+ await acquireOAuthBrowserSession(parsed, (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID));
1324
+ report.credential = { status: "stored", method: "oauth_browser", path: credsPath() };
1325
+ }
1218
1326
  else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1219
1327
  // Nothing anywhere: approve a credential in the browser, as `login`
1220
1328
  // would, then carry on. Under an agent the URL is printed for the person
1221
1329
  // and polled; only the explicit non-interactive switch skips this.
1330
+ await credentialStore().prepare();
1222
1331
  const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
1223
- storeMinted(stored, minted);
1332
+ await storeMinted(minted, parsed.flags);
1224
1333
  report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
1225
1334
  }
1226
1335
  else {
@@ -1359,7 +1468,7 @@ function completionFlagsFor(op) {
1359
1468
  }
1360
1469
  return { flags, values };
1361
1470
  }
1362
- 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)];
1471
+ 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)];
1363
1472
  const BUILTIN_WORDS = {
1364
1473
  config: ["list", "get", "set", "unset", "path"],
1365
1474
  completion: ["bash", "zsh", "fish"],
@@ -1631,31 +1740,7 @@ async function cmdDocs(parsed) {
1631
1740
  .filter((match) => match.score > 0)
1632
1741
  .sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
1633
1742
  .map((match) => match.op);
1634
- const prose = await fetchDocs("llms-full.txt");
1635
- const proseMatches = [];
1636
- if (prose !== null) {
1637
- let heading = "";
1638
- const terms = searchTerms(term);
1639
- for (const line of prose.split("\n")) {
1640
- if (/^#{1,3} /.test(line))
1641
- heading = line.replace(/^#+ /, "").trim();
1642
- else {
1643
- const lowerHeading = heading.toLowerCase();
1644
- const lowerLine = line.toLowerCase();
1645
- const matched = terms.filter((word) => lowerHeading.includes(word) || lowerLine.includes(word));
1646
- if (matched.length > 0) {
1647
- const allTerms = matched.length === terms.length;
1648
- proseMatches.push({
1649
- heading,
1650
- excerpt: line.trim().slice(0, 160),
1651
- score: matched.length * 10 + (allTerms ? 50 : 0) + (lowerHeading.includes(term.toLowerCase()) || lowerLine.includes(term.toLowerCase()) ? 25 : 0),
1652
- });
1653
- }
1654
- }
1655
- }
1656
- proseMatches.sort((a, b) => b.score - a.score || a.heading.localeCompare(b.heading) || a.excerpt.localeCompare(b.excerpt));
1657
- }
1658
- const docsStatus = docsSiteUrl() === null ? "not_configured" : prose === null ? "unavailable" : "ok";
1743
+ const { guides: proseMatches, status: docsStatus } = await searchConnectedGuides(docsSiteUrl(), docsIndexUrl(), fetchDocs, term);
1659
1744
  if (jsonOutput) {
1660
1745
  out({
1661
1746
  schema_version: "1",
@@ -1667,7 +1752,7 @@ async function cmdDocs(parsed) {
1667
1752
  ...(op.summary ? { summary: op.summary } : {}),
1668
1753
  details_command: BIN + " docs " + op.command.join(" ") + " --json",
1669
1754
  })),
1670
- guides: proseMatches.slice(0, 15).map(({ heading, excerpt }) => ({ heading, excerpt })),
1755
+ guides: proseMatches.slice(0, 15).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
1671
1756
  totals: { reference: refMatches.length, guides: proseMatches.length },
1672
1757
  guides_status: docsStatus,
1673
1758
  ...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
@@ -1684,7 +1769,7 @@ async function cmdDocs(parsed) {
1684
1769
  if (proseMatches.length > 0) {
1685
1770
  lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
1686
1771
  for (const match of proseMatches.slice(0, 15))
1687
- lines.push(" " + padPaint("cyan", match.heading.slice(0, 32), 34) + match.excerpt.slice(0, 100));
1772
+ lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
1688
1773
  }
1689
1774
  else if (docsStatus === "not_configured") {
1690
1775
  lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
@@ -1704,10 +1789,7 @@ async function cmdDocs(parsed) {
1704
1789
  let target = page;
1705
1790
  if (!/^https?:\/\//.test(target)) {
1706
1791
  const index = await fetchDocs("llms.txt");
1707
- const linked = index?.match(/\((https?:[^)]+)\)/g)?.map((m) => m.slice(1, -1)) ?? [];
1708
- const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
1709
- if (hit !== undefined)
1710
- target = hit;
1792
+ target = docsReadTarget(index, docsSiteUrl(), docsIndexUrl(), target);
1711
1793
  }
1712
1794
  const text = await fetchDocs(target);
1713
1795
  if (text === null) {
@@ -1926,7 +2008,7 @@ function printRoot(stream = process.stdout) {
1926
2008
  }
1927
2009
  const width = termWidth();
1928
2010
  const lines = [];
1929
- lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.8.0");
2011
+ lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.10.0");
1930
2012
  lines.push("");
1931
2013
  lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
1932
2014
  lines.push("");
@@ -1951,11 +2033,11 @@ function printRoot(stream = process.stdout) {
1951
2033
  lines.push(...labeled("Upgrade: ", "https://typeship.dev/pricing, then regenerate without the operation cap", width, 14));
1952
2034
  }
1953
2035
  lines.push("");
1954
- 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)" +
2036
+ 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)" +
1955
2037
  (AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
1956
2038
  lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
1957
2039
  lines.push(...labeled("Credential env vars: ", [
1958
- ...AUTH_SCALARS.map((a) => a.env),
2040
+ "TYPESHIP_CREDENTIALS", ...AUTH_SCALARS.map((a) => a.env),
1959
2041
  ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
1960
2042
  ].join(", ") || "none", width, 21));
1961
2043
  lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
@@ -2000,6 +2082,8 @@ function printResource(resource, stream = process.stdout) {
2000
2082
  /** Flags the CLI itself adds to an API command, as [flag, description] rows. */
2001
2083
  function commandExtras(op) {
2002
2084
  const extras = [];
2085
+ if (op.auth !== "none" && Object.keys(NAMED_SCHEMES).length)
2086
+ extras.push(["--credentials @<file>|-", "named credentials as JSON; use - for stdin"]);
2003
2087
  if (op.hasBody && op.bodyKind === "binary")
2004
2088
  extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
2005
2089
  else if (op.hasBody)
@@ -2051,7 +2135,7 @@ function exampleLine(op) {
2051
2135
  }
2052
2136
  /** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
2053
2137
  function authNote(op) {
2054
- const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || OAUTH_TOKEN_URL !== null;
2138
+ const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || HAS_OAUTH_LOGIN;
2055
2139
  return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
2056
2140
  }
2057
2141
  function printOp(op) {
@@ -2228,36 +2312,88 @@ function coerce(spec, raw, repeated) {
2228
2312
  }
2229
2313
  /** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
2230
2314
  let LAST_CLIENT_HAD_CREDENTIAL = false;
2315
+ function validateCredentialsInput(flags) {
2316
+ const input = flags.get("credentials");
2317
+ if (BASIC && flags.has("username") !== flags.has("password"))
2318
+ fail(2, "Supply --username and --password together, or use a complete named Basic credential.");
2319
+ if (input === undefined)
2320
+ return;
2321
+ if (typeof input !== "string" || (input !== "-" && (!input.startsWith("@") || input.length < 2)))
2322
+ fail(2, "--credentials expects @<JSON-file> or - to read JSON from stdin.");
2323
+ if (input === "-" && (flags.get("data") === "-" || flags.get("file") === "-" || flags.get("with-token") === true))
2324
+ fail(2, "Stdin cannot supply both named credentials and another input. Use a credential file instead.");
2325
+ }
2326
+ let namedFlagCache;
2327
+ function flagCredentials(flags) {
2328
+ if (namedFlagCache)
2329
+ return namedFlagCache;
2330
+ validateCredentialsInput(flags);
2331
+ const input = flags.get("credentials");
2332
+ if (input === undefined)
2333
+ return {};
2334
+ try {
2335
+ const named = readNamedCredentialsFile(input, NAMED_SCHEMES);
2336
+ if (!Object.keys(named).length)
2337
+ fail(2, "The named credential file must contain at least one security scheme.");
2338
+ return namedFlagCache = named;
2339
+ }
2340
+ catch (error) {
2341
+ fail(2, error.message);
2342
+ }
2343
+ }
2344
+ function environmentCredentials() {
2345
+ if (BASIC && (process.env[BASIC.envUser] !== undefined) !== (process.env[BASIC.envPass] !== undefined))
2346
+ fail(2, "Supply both Basic-auth environment variables together, or use a complete named Basic credential.");
2347
+ const value = process.env["TYPESHIP_CREDENTIALS"];
2348
+ return value === undefined ? {} : parseNamedCredentials(value, NAMED_SCHEMES);
2349
+ }
2231
2350
  /** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
2232
2351
  function credentialSource(flags) {
2352
+ if (Object.keys(flagCredentials(flags)).length)
2353
+ return "flags";
2233
2354
  if (AUTH_SCALARS.some((a) => typeof flags.get(a.flag) === "string"))
2234
2355
  return "flags";
2235
2356
  if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string")
2236
2357
  return "flags";
2358
+ if (Object.keys(environmentCredentials()).length)
2359
+ return "env:TYPESHIP_CREDENTIALS";
2237
2360
  const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
2238
2361
  if (envScalar)
2239
2362
  return "env:" + envScalar.env;
2240
2363
  if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined)
2241
2364
  return "env:" + BASIC.envUser;
2242
2365
  const stored = readCreds();
2243
- if (stored && (stored.scalars || stored.basic || stored.oauth))
2366
+ if (stored && (stored.scalars || stored.basic || stored.oauth || stored.named))
2244
2367
  return "login";
2245
2368
  return null;
2246
2369
  }
2247
2370
  /** Base URL resolution: --base-url > env > config base-url > config environment > spec default. */
2248
- function resolveBaseUrl(flags) {
2249
- const config = readConfig();
2371
+ function resolveBaseUrl(flags, config = readConfig()) {
2250
2372
  return (typeof flags.get("base-url") === "string" ? flags.get("base-url") : undefined)
2251
2373
  ?? process.env["TYPESHIP_BASE_URL"]
2252
2374
  ?? config.baseUrl
2253
2375
  ?? (config.environment !== undefined ? ENVIRONMENTS[config.environment] : undefined)
2254
2376
  ?? DEFAULT_BASE_URL ?? undefined;
2255
2377
  }
2256
- async function makeClient(flags) {
2257
- const stored = readCreds();
2258
- const baseUrl = resolveBaseUrl(flags);
2378
+ async function makeClient(flags, op, candidate, forIdentity = false) {
2379
+ const flagNamed = flagCredentials(flags), envNamed = environmentCredentials();
2380
+ const explicitOptions = new Set(AUTH_SCALARS.filter((a) => typeof flags.get(a.flag) === "string" || process.env[a.env] !== undefined).map((a) => a.option));
2381
+ if (BASIC && (typeof flags.get("username") === "string" || process.env[BASIC.envUser] !== undefined) && (typeof flags.get("password") === "string" || process.env[BASIC.envPass] !== undefined))
2382
+ explicitOptions.add("basicAuth");
2383
+ namedCredentialAvailability(NAMED_SCHEMES, explicitOptions, { ...envNamed, ...flagNamed });
2384
+ const explicitCredentials = op.credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => explicitOptions.has(option)));
2385
+ // A complete explicit alternative for this endpoint does not unlock or mix
2386
+ // in saved credentials for another API, account, or authentication method.
2387
+ const stored = candidate ?? (op.auth === "none" || explicitCredentials ? null : readCreds());
2388
+ const config = readConfig();
2389
+ const baseUrl = resolveBaseUrl(flags, config);
2259
2390
  if (baseUrl === undefined)
2260
2391
  fail(2, "No base URL. Pass --base-url, set TYPESHIP_BASE_URL, or run '" + BIN + " config set base-url <url>'.");
2392
+ if (stored) {
2393
+ assertCredentialDestination(stored, { apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name });
2394
+ if (!forIdentity)
2395
+ assertStoredIdentity(stored, identityConfiguration());
2396
+ }
2261
2397
  const options = { baseUrl };
2262
2398
  for (const a of AUTH_SCALARS) {
2263
2399
  const v = (typeof flags.get(a.flag) === "string" ? flags.get(a.flag) : undefined)
@@ -2273,10 +2409,28 @@ async function makeClient(flags) {
2273
2409
  if (username !== undefined && password !== undefined)
2274
2410
  options.basicAuth = { username, password };
2275
2411
  }
2276
- if (options.bearerToken === undefined && stored?.oauth) {
2277
- const token = await refreshedOauthToken(stored);
2278
- if (token !== undefined)
2279
- options.bearerToken = token;
2412
+ const envOptions = {}, flagOptions = {};
2413
+ for (const a of AUTH_SCALARS) {
2414
+ if (process.env[a.env] !== undefined)
2415
+ envOptions[a.option] = process.env[a.env];
2416
+ if (typeof flags.get(a.flag) === "string")
2417
+ flagOptions[a.option] = flags.get(a.flag);
2418
+ }
2419
+ if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined)
2420
+ envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
2421
+ if (BASIC && typeof flags.get("username") === "string" && typeof flags.get("password") === "string")
2422
+ flagOptions.basicAuth = { username: flags.get("username"), password: flags.get("password") };
2423
+ options.credentials = resolveNamedCredentials(NAMED_SCHEMES, [
2424
+ { named: stored?.named, options: { ...stored?.scalars, ...(stored?.basic ? { basicAuth: stored.basic } : {}) } },
2425
+ { named: envNamed, options: envOptions }, { named: flagNamed, options: flagOptions },
2426
+ ...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
2427
+ ]);
2428
+ if (stored?.oauth && (forIdentity || options.bearerToken === undefined)) {
2429
+ const sessionId = stored.oauth.sessionId;
2430
+ options.bearerToken = forIdentity ? stored.oauth.accessToken : () => oauthSessionToken(credentialStore(), {
2431
+ ...sessionConfiguration(baseUrl, config),
2432
+ ...(identityConfiguration() && WHOAMI ? { verifyIdentity: (accessToken) => verifyClientIdentity((values) => new TypeshipClient(values), { ...options, bearerToken: accessToken, credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ named: options.credentials }, { options: { bearerToken: accessToken } }]) }, WHOAMI, IDENTITY_POLICY) } : {}),
2433
+ }, sessionId, OAUTH_TOKEN_PARAMS);
2280
2434
  }
2281
2435
  if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
2282
2436
  options.debug = (event) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
@@ -2289,7 +2443,7 @@ async function makeClient(flags) {
2289
2443
  if (value !== undefined)
2290
2444
  options[g.option] = value;
2291
2445
  }
2292
- LAST_CLIENT_HAD_CREDENTIAL = AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
2446
+ LAST_CLIENT_HAD_CREDENTIAL = Object.keys(options.credentials ?? {}).length > 0 || AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
2293
2447
  // Who is calling: the CLI, under which agent harness, and whether an
2294
2448
  // agent is driving. "agent" means a harness was detected or the caller
2295
2449
  // said so (--mode agent / env); a bare non-TTY run (CI, a pipeline) is
@@ -2303,6 +2457,11 @@ async function makeClient(flags) {
2303
2457
  ...options.defaultHeaders,
2304
2458
  "User-Agent": PKG_NAME + "-cli/" + VERSION + " (typeship" + (harness ? "; harness=" + harness : "") + caller + ")",
2305
2459
  };
2460
+ if (forIdentity) {
2461
+ options.fetch = identityFetch(baseUrl);
2462
+ options.maxRetries = 0;
2463
+ options.timeoutMs = 10_000;
2464
+ }
2306
2465
  return new TypeshipClient(options);
2307
2466
  }
2308
2467
  function editDistance(a, b) {
@@ -2348,6 +2507,14 @@ const BUILTIN_COMMANDS = ["login", "logout", "whoami", "config", "mcp", "docs",
2348
2507
  async function main() {
2349
2508
  const argv = process.argv.slice(2);
2350
2509
  const parsed = parseArgv(argv);
2510
+ if (!parsed.help)
2511
+ validateCredentialsInput(parsed.flags);
2512
+ const profileFlag = parsed.flags.get("profile");
2513
+ if (profileFlag !== undefined && typeof profileFlag !== "string")
2514
+ fail(2, "--profile requires a profile name.");
2515
+ PROFILE = resolveProfile(configRoot(), { flag: profileFlag, environment: process.env["TYPESHIP_PROFILE"], allowMissing: ["login", "config", "auth", "init", "help"].includes(parsed.positionals[0] ?? "") || parsed.help });
2516
+ if (!parsed.help && ["login", "init"].includes(parsed.positionals[0] ?? ""))
2517
+ expectedLoginIdentity(parsed.flags);
2351
2518
  if (parsed.positionals[0] === "help") {
2352
2519
  // help --json: the command surface as data (agents read this once).
2353
2520
  if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") {
@@ -2406,10 +2573,10 @@ async function main() {
2406
2573
  }
2407
2574
  if (resourceCmd === "logout") {
2408
2575
  if (parsed.help) {
2409
- process.stdout.write(BIN + " logout — remove " + credsPath() + "\n");
2576
+ process.stdout.write(BIN + " logout — remove " + credsPath() + "\n --local remove the local session without unlocking OS storage or revoking tokens\n");
2410
2577
  await flushExit(0);
2411
2578
  }
2412
- await cmdLogout();
2579
+ await cmdLogout(parsed);
2413
2580
  }
2414
2581
  if (resourceCmd === "whoami") {
2415
2582
  if (parsed.help) {
@@ -2482,7 +2649,7 @@ async function main() {
2482
2649
  // Mirrors opReservedFlags() in the generator: API parameters never use these
2483
2650
  // names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
2484
2651
  // check can be exact.
2485
- 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)]);
2652
+ 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)]);
2486
2653
  for (const spec of op.params) {
2487
2654
  if (spec.kind === "path")
2488
2655
  continue;
@@ -2538,7 +2705,7 @@ async function main() {
2538
2705
  }
2539
2706
  // The spec says this operation needs a credential and none resolved:
2540
2707
  // say so now, locally, instead of sending a request to learn it.
2541
- if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC || OAUTH_TOKEN_URL) && credentialSource(parsed.flags) === null) {
2708
+ if (op.auth === "required" && (AUTH_SCALARS.length > 0 || BASIC || HAS_OAUTH_LOGIN) && credentialSource(parsed.flags) === null) {
2542
2709
  failWith({
2543
2710
  status: "action_required",
2544
2711
  code: "NO_AUTH",
@@ -2569,7 +2736,7 @@ async function main() {
2569
2736
  if (answer !== "y" && answer !== "yes")
2570
2737
  failWith({ status: "action_required", code: "CONFIRMATION_REQUIRED", message: "Cancelled.", nextSteps: ["Run again with --force to skip the prompt: " + rerun] });
2571
2738
  }
2572
- const client = await makeClient(parsed.flags);
2739
+ const client = await makeClient(parsed.flags, op);
2573
2740
  const selectValue = typeof parsed.flags.get("select") === "string" ? parsed.flags.get("select") : undefined;
2574
2741
  const args = buildArgs(op, values, dataBody, selectValue);
2575
2742
  const target = client[op.resource];