@typeship-ax/cli 0.6.0 → 0.9.1

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 (121) hide show
  1. package/AGENTS.md +31 -0
  2. package/README.md +26 -9
  3. package/api.json +6861 -3083
  4. package/api.md +508 -277
  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-agent.d.ts +9 -1
  12. package/dist/cli-agent.d.ts.map +1 -1
  13. package/dist/cli-agent.js +26 -9
  14. package/dist/cli.js +600 -582
  15. package/dist/console-login-check.d.ts +21 -0
  16. package/dist/console-login-check.d.ts.map +1 -0
  17. package/dist/console-login-check.js +107 -0
  18. package/dist/console-login-contract.d.ts +45 -0
  19. package/dist/console-login-contract.d.ts.map +1 -0
  20. package/dist/console-login-contract.js +40 -0
  21. package/dist/core/http.d.ts +21 -92
  22. package/dist/core/http.d.ts.map +1 -1
  23. package/dist/core/http.js +143 -221
  24. package/dist/core/pagination.d.ts.map +1 -1
  25. package/dist/core/pagination.js +6 -34
  26. package/dist/credential-storage.d.ts +24 -0
  27. package/dist/credential-storage.d.ts.map +1 -0
  28. package/dist/credential-storage.js +207 -0
  29. package/dist/dates.d.ts +0 -2
  30. package/dist/dates.d.ts.map +1 -1
  31. package/dist/dates.js +0 -1
  32. package/dist/docs.d.ts +36 -0
  33. package/dist/docs.d.ts.map +1 -0
  34. package/dist/docs.js +258 -0
  35. package/dist/errors.d.ts +42 -34
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/errors.js +30 -20
  38. package/dist/index.d.ts +27 -12
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +40 -14
  41. package/dist/named-credentials.d.ts +21 -0
  42. package/dist/named-credentials.d.ts.map +1 -0
  43. package/dist/named-credentials.js +86 -0
  44. package/dist/oauth-login.d.ts +39 -0
  45. package/dist/oauth-login.d.ts.map +1 -0
  46. package/dist/oauth-login.js +171 -0
  47. package/dist/oauth-request.d.ts +21 -0
  48. package/dist/oauth-request.d.ts.map +1 -0
  49. package/dist/oauth-request.js +119 -0
  50. package/dist/oauth-session.d.ts +106 -0
  51. package/dist/oauth-session.d.ts.map +1 -0
  52. package/dist/oauth-session.js +244 -0
  53. package/dist/ops.d.ts +18 -0
  54. package/dist/ops.d.ts.map +1 -1
  55. package/dist/ops.js +31 -17
  56. package/dist/polling-login.d.ts +57 -0
  57. package/dist/polling-login.d.ts.map +1 -0
  58. package/dist/polling-login.js +204 -0
  59. package/dist/resources/account.d.ts +4 -4
  60. package/dist/resources/account.d.ts.map +1 -1
  61. package/dist/resources/account.js +1 -0
  62. package/dist/resources/api-keys.d.ts +13 -8
  63. package/dist/resources/api-keys.d.ts.map +1 -1
  64. package/dist/resources/api-keys.js +5 -1
  65. package/dist/resources/definition-revisions.d.ts +58 -0
  66. package/dist/resources/definition-revisions.d.ts.map +1 -0
  67. package/dist/resources/definition-revisions.js +114 -0
  68. package/dist/resources/definitions.d.ts +35 -0
  69. package/dist/resources/definitions.d.ts.map +1 -0
  70. package/dist/resources/definitions.js +60 -0
  71. package/dist/resources/generate.d.ts +18 -7
  72. package/dist/resources/generate.d.ts.map +1 -1
  73. package/dist/resources/generate.js +13 -5
  74. package/dist/resources/generations.d.ts +6 -6
  75. package/dist/resources/generations.d.ts.map +1 -1
  76. package/dist/resources/generations.js +3 -1
  77. package/dist/resources/projects.d.ts +111 -35
  78. package/dist/resources/projects.d.ts.map +1 -1
  79. package/dist/resources/projects.js +125 -15
  80. package/dist/resources/targets.d.ts +97 -0
  81. package/dist/resources/targets.d.ts.map +1 -0
  82. package/dist/resources/targets.js +197 -0
  83. package/dist/schemas.d.ts.map +1 -1
  84. package/dist/schemas.js +135 -62
  85. package/dist/types.d.ts +2072 -267
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/types.js +20 -3
  88. package/package.json +2 -1
  89. package/src/api-identity.ts +98 -0
  90. package/src/auth-profiles.ts +114 -0
  91. package/src/cli-agent.ts +28 -11
  92. package/src/cli.ts +528 -560
  93. package/src/console-login-check.ts +88 -0
  94. package/src/console-login-contract.ts +65 -0
  95. package/src/core/http.ts +156 -305
  96. package/src/core/pagination.ts +6 -30
  97. package/src/credential-storage.ts +183 -0
  98. package/src/dates.ts +0 -1
  99. package/src/docs.ts +239 -0
  100. package/src/errors.ts +52 -41
  101. package/src/index.ts +49 -14
  102. package/src/named-credentials.ts +74 -0
  103. package/src/oauth-login.ts +184 -0
  104. package/src/oauth-request.ts +90 -0
  105. package/src/oauth-session.ts +258 -0
  106. package/src/ops.ts +56 -17
  107. package/src/polling-login.ts +165 -0
  108. package/src/resources/account.ts +6 -3
  109. package/src/resources/api-keys.ts +27 -7
  110. package/src/resources/definition-revisions.ts +207 -0
  111. package/src/resources/definitions.ts +122 -0
  112. package/src/resources/generate.ts +29 -6
  113. package/src/resources/generations.ts +9 -4
  114. package/src/resources/projects.ts +274 -41
  115. package/src/resources/targets.ts +378 -0
  116. package/src/schemas.ts +135 -62
  117. package/src/types.ts +2273 -322
  118. package/dist/resources/spec-revisions.d.ts +0 -47
  119. package/dist/resources/spec-revisions.d.ts.map +0 -1
  120. package/dist/resources/spec-revisions.js +0 -90
  121. package/src/resources/spec-revisions.ts +0 -150
package/src/cli.ts CHANGED
@@ -6,39 +6,65 @@
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";
13
22
  import { fileURLToPath } from "node:url";
14
- import { TypeshipClient, formatDebugEvent, type DebugEvent } from "./index.js";
15
- import { GLOBALS, OPS, buildArgs, findOp, missingRequired, type OpSpec, type ParamSpec } from "./ops.js";
23
+ import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
24
+ import { GLOBALS, OMITTED_OPS, OPS, buildArgs, findOp, missingRequired, type OmittedOpSpec, type OpSpec, type ParamSpec } from "./ops.js";
16
25
  import {
17
26
  MCP_CLIENTS, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, collectionProperty, detectHarness, envelope,
18
27
  exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
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";
31
+ import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
22
32
 
23
33
 
24
34
  const BIN = "typeship";
25
35
  const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
36
+ const NAMED_SCHEMES: CredentialSchemes = {"apiKey":{"kind":"bearer","options":["bearerToken"]}};
26
37
  const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
27
38
  const BASIC: { envUser: string; envPass: string } | null = null;
39
+ /** Operations omitted from the generated package by its plan cap. */
28
40
  const EXCLUDED_OPS = 0;
29
- const VERSION = "0.6.0";
30
- const API_VERSION = "0.6.0";
41
+ /** Generated CLI operations that are intentionally unavailable to MCP. */
42
+ const MCP_EXCLUDED_OPS = 0;
43
+ const VERSION = "0.9.1";
44
+ const API_VERSION = "1.0.0";
31
45
  const SPEC_FORMAT = "openapi";
46
+ const IDENTITY_POLICY: IdentityPolicy = {};
47
+ let LOGIN_IDENTITY: VerifiedIdentity | undefined;
32
48
  const WHOAMI: { resource: string; method: string } | null = {"resource":"account","method":"retrieve"};
33
49
  const ENVIRONMENTS: Record<string, string> = {};
34
50
  const HAS_MCP = false;
35
51
  const PKG_NAME = "@typeship-ax/cli";
36
52
  const UPDATE_NOTICE = false;
37
- const API_DESCRIPTION: string | null = "Generate production SDKs, CLIs, and MCP servers from an OpenAPI or\nGraphQL spec, and keep every selected output current.\n\nEvery operation but one requires an API key, created in the console and\nsent as `Authorization: Bearer ak_...`. A browser session is not a\ncredential for this API. The exception is POST /generate, which works\nanonymously with the free plan's limits.\n";
53
+ const API_DESCRIPTION: string | null = "Resolve an OpenAPI or GraphQL Definition, diagnose it, and keep every\nselected SDK, CLI, and MCP Target current.\n\nEvery operation but one requires a bearer credential: an organization\nAPI key from the console, or an OAuth access token carrying the operation's\nread, generate, or write capability and the organization selected during\nconsent. OAuth grants cannot switch organizations after consent. A browser\nsession is not a credential for this API. The exception is POST /generate,\nwhich works anonymously with the free plan's limits.\n";
38
54
  const DOCS_URL_DEFAULT: string | null = "https://typeship.dev";
55
+ const DOCS_INDEX_URL_DEFAULT: string | null = null;
39
56
  const RELAY: { mintUrl: string; project: string } | null = null;
40
57
  const SUPPORT_URL: string | null = null;
41
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;
42
68
  const OAUTH_CLIENT_ID: string | null = null;
43
69
  const OAUTH_SCOPES: string[] = [];
44
70
  const OAUTH_TOKEN_PARAMS: Record<string, string> = {};
@@ -65,11 +91,12 @@ interface Parsed {
65
91
  * is known. */
66
92
  const CORE_BOOLEAN_FLAGS = new Set(["all", "version", "non-interactive", "debug", "validate", "yes", "force", "json"]);
67
93
  const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
68
- login: ["with-token", "no-browser"],
94
+ login: ["with-token", "no-browser", "device"],
95
+ logout: ["local"],
69
96
  upgrade: ["check"],
70
97
  mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
71
98
  docs: ["web", "schema"],
72
- init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md"],
99
+ init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md", "no-browser"],
73
100
  auth: ["live"],
74
101
  doctor: [],
75
102
  };
@@ -258,7 +285,7 @@ function humanError(body: ReturnType<typeof envelope>, code: number): string {
258
285
  lines.push(paintErr("red", BIN + ":") + " " + (issue?.message ?? "failed"));
259
286
  for (const extra of body.issues.slice(1)) lines.push(" " + extra.message);
260
287
  for (const step of body.next_steps ?? []) lines.push(" " + step);
261
- const detail = body.detail as { status?: number; body?: unknown; violations?: unknown } | undefined;
288
+ const detail = body.detail as { status?: number; body?: unknown; violations?: unknown; request_id?: string } | undefined;
262
289
  if (detail?.violations !== undefined) {
263
290
  for (const v of (detail.violations as { path?: string; message?: string }[]).slice(0, 8)) lines.push(" " + paintErr("dim", (v.path ? v.path + ": " : "") + (v.message ?? "")));
264
291
  } else if (detail?.body !== undefined) {
@@ -267,7 +294,7 @@ function humanError(body: ReturnType<typeof envelope>, code: number): string {
267
294
  const compact = typeof detail.body === "string" ? detail.body : JSON.stringify(detail.body);
268
295
  const firstWords = (issue?.message ?? "").slice(0, 40);
269
296
  if (compact && compact !== "{}" && !(firstWords && compact.includes(firstWords))) lines.push(" " + paintErr("dim", "API said: " + (compact.length > 300 ? compact.slice(0, 297) + "…" : compact)));
270
- const requestId = (detail.body as { request_id?: unknown; requestId?: unknown } | null)?.request_id ?? (detail.body as { requestId?: unknown } | null)?.requestId;
297
+ const requestId = detail.request_id ?? (detail.body as { request_id?: unknown; requestId?: unknown } | null)?.request_id ?? (detail.body as { requestId?: unknown } | null)?.requestId;
271
298
  if (typeof requestId === "string") lines.push(" " + paintErr("dim", "request id: " + requestId));
272
299
  }
273
300
  const tags = [issue?.code ?? "ERROR", ...(typeof detail?.status === "number" && detail.status > 0 ? ["HTTP " + detail.status] : []), "exit " + code, "pipe stderr or --mode agent for JSON"];
@@ -287,7 +314,7 @@ function fail(code: number, message: string, extra?: unknown, nextSteps?: string
287
314
  : /^(Missing required|Expected \d+ argument)/.test(message) ? "MISSING_ARGUMENT"
288
315
  : "INVALID_USAGE";
289
316
  return failWith({
290
- code: code === 2 ? usageCode : "COMMAND_FAILED",
317
+ code: code === 2 ? usageCode : "CALL_FAILED",
291
318
  message,
292
319
  ...(extra !== undefined ? { detail: extra } : {}),
293
320
  nextSteps: nextSteps ?? (code === 2 ? ["Run '" + USAGE_HINT + "' for the flags this command takes."] : []),
@@ -303,35 +330,84 @@ function failApi(error: unknown, hadCredential: boolean): never {
303
330
  // credentials — written by `login`, cleared by `logout`
304
331
  // ---------------------------------------------------------------------------
305
332
 
306
- interface StoredCreds {
307
- scalars?: Record<string, string>;
308
- basic?: { username: string; password: string };
309
- oauth?: { accessToken: string; refreshToken?: string; expiresAt?: number };
310
- /** Set when this CLI minted the stored credential for itself (browser
311
- * approval), so `logout` knows it may revoke it. A pasted key is not. */
312
- minted?: { via: "browser"; key_name: string; org_id?: string };
313
- }
314
-
315
- function configDir(): string {
333
+ function configRoot(): string {
316
334
  const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
317
335
  return join(base, BIN);
318
336
  }
337
+ let PROFILE: ProfileContext;
338
+ function configDir(): string { return PROFILE.directory; }
319
339
 
320
340
  function credsPath(): string {
321
- return join(configDir(), "credentials.json");
341
+ return credentialStore().path;
322
342
  }
323
343
 
324
- function readCreds(): StoredCreds | null {
325
- try {
326
- return JSON.parse(readFileSync(credsPath(), "utf8")) as StoredCreds;
327
- } catch {
328
- 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;
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;
329
373
  }
374
+ return Object.keys(expected).length ? expected : undefined;
330
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 };
382
+ }
383
+ function loginIdentityReport(): Record<string, unknown> { return LOGIN_IDENTITY ? { verified_identity: LOGIN_IDENTITY.values, identity_checked_at: LOGIN_IDENTITY.checkedAt } : {}; }
331
384
 
332
- function writeCreds(creds: StoredCreds): void {
333
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
334
- 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 }));
335
411
  }
336
412
 
337
413
  function readStdin(): Promise<string> {
@@ -391,118 +467,41 @@ function promptHidden(promptText: string): Promise<string> {
391
467
  });
392
468
  }
393
469
 
394
- async function oauthForm(url: string, params: Record<string, string>): Promise<{ status: number; body: Record<string, unknown> | null }> {
395
- const response = await fetch(url, {
396
- method: "POST",
397
- headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
398
- body: new URLSearchParams(params).toString(),
399
- });
400
- let body: Record<string, unknown> | null = null;
401
- try { body = await response.json() as Record<string, unknown>; } catch { /* non-JSON error body */ }
402
- return { status: response.status, body };
403
- }
404
-
405
- /** RFC 8628 device flow: discover the device endpoint from the token URL's
406
- * well-known metadata, show the code, poll until authorized. */
407
- async function deviceLogin(clientId: string, agent = false): Promise<void> {
408
- if (!OAUTH_TOKEN_URL) fail(2, "This API declares no OAuth token URL.");
409
- const origin = new URL(OAUTH_TOKEN_URL).origin;
410
- let deviceEndpoint: string | undefined;
411
- let tokenEndpoint = OAUTH_TOKEN_URL;
412
- for (const wellKnown of ["/.well-known/oauth-authorization-server", "/.well-known/openid-configuration"]) {
413
- try {
414
- const response = await fetch(origin + wellKnown, { headers: { Accept: "application/json" } });
415
- if (!response.ok) continue;
416
- const meta = await response.json() as { device_authorization_endpoint?: string; token_endpoint?: string };
417
- if (meta.device_authorization_endpoint) {
418
- deviceEndpoint = meta.device_authorization_endpoint;
419
- if (meta.token_endpoint) tokenEndpoint = meta.token_endpoint;
420
- break;
421
- }
422
- } catch { /* try the next well-known path */ }
423
- }
424
- if (!deviceEndpoint) {
425
- fail(1, "The authorization server does not advertise a device flow. Run '" + BIN + " login' with a pasted credential instead.");
426
- }
427
- const start = await oauthForm(deviceEndpoint!, {
428
- client_id: clientId,
429
- ...(OAUTH_SCOPES.length > 0 ? { scope: OAUTH_SCOPES.join(" ") } : {}),
430
- ...OAUTH_TOKEN_PARAMS,
431
- });
432
- const startBody = start.body as { device_code?: string; user_code?: string; verification_uri?: string; verification_uri_complete?: string; interval?: number; expires_in?: number } | null;
433
- if (start.status !== 200 || !startBody?.device_code) {
434
- fail(1, "Device authorization failed (HTTP " + start.status + ").", start.body);
435
- }
436
- const uri = startBody!.verification_uri_complete ?? startBody!.verification_uri;
437
- process.stderr.write("Open " + paintErr("cyan", String(uri)) + " and enter code: " + paintErr("bold", String(startBody!.user_code)) + "\n");
438
- // Under an agent the same facts also go out as one JSON line (stderr,
439
- // so stdout stays the single result document), for the agent to hand
440
- // the URL and code to the user while this polls.
441
- 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");
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 as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string } | null;
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") continue;
467
- if (errorCode === "slow_down") { intervalMs += 5000; continue; }
468
- fail(1, "Device login failed: " + (errorCode ?? "HTTP " + poll.status), poll.body);
469
- }
470
- fail(1, "Device login timed out before the code was entered.");
471
- }
472
-
473
- /** Stored OAuth access token, refreshed through the token URL when expired.
474
- * A failed refresh returns the stale token; the API's 401 explains better
475
- * than a local guess. */
476
- async function refreshedOauthToken(stored: StoredCreds): Promise<string | undefined> {
477
- const oauth = stored.oauth;
478
- if (!oauth) return undefined;
479
- const expired = oauth.expiresAt !== undefined && Date.now() > oauth.expiresAt - 60_000;
480
- if (!expired || !oauth.refreshToken || !OAUTH_TOKEN_URL) return oauth.accessToken;
481
- const clientId = process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID;
482
- const result = await oauthForm(OAUTH_TOKEN_URL, {
483
- grant_type: "refresh_token",
484
- refresh_token: oauth.refreshToken,
485
- ...(clientId ? { client_id: clientId } : {}),
486
- });
487
- const body = result.body as { access_token?: string; refresh_token?: string; expires_in?: number } | null;
488
- if (result.status === 200 && body?.access_token) {
489
- const next = {
490
- accessToken: body.access_token,
491
- refreshToken: body.refresh_token ?? oauth.refreshToken,
492
- expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined,
493
- };
494
- writeCreds({ ...stored, oauth: next });
495
- return next.accessToken;
496
- }
497
- 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);
498
498
  }
499
499
 
500
- async function storePastedToken(stored: StoredCreds, token: string): Promise<void> {
500
+ async function storePastedToken(token: string, flags: Map<string, string | boolean>): Promise<void> {
501
501
  const first = AUTH_SCALARS[0];
502
502
  if (!first) fail(2, "This API declares no credential the CLI can store. Use --username/--password if it uses basic auth.");
503
- stored.scalars = { ...stored.scalars, [first!.option]: token };
504
- writeCreds(stored);
505
- 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() });
506
505
  await flushExit(0);
507
506
  }
508
507
 
@@ -513,92 +512,126 @@ async function storePastedToken(stored: StoredCreds, token: string): Promise<voi
513
512
  * until the API hands back a key minted for this CLI, store it. The key
514
513
  * never crosses the chat: the agent relays a URL, the person clicks once.
515
514
  */
516
- /** The approval endpoint follows the base URL: a preview or local deployment
517
- * 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. */
518
517
  function cliAuthUrl(flags: Map<string, string | boolean>): string {
518
+ const auth = loginEndpoint(CLI_AUTH_URL!);
519
519
  const base = resolveBaseUrl(flags);
520
- try {
521
- if (CLI_AUTH_URL && DEFAULT_BASE_URL && base) {
522
- const defaultOrigin = new URL(DEFAULT_BASE_URL).origin;
523
- const origin = new URL(base).origin;
524
- if (origin !== defaultOrigin && CLI_AUTH_URL.startsWith(defaultOrigin)) return origin + CLI_AUTH_URL.slice(defaultOrigin.length);
525
- }
526
- } catch { /* fall through to the configured URL */ }
527
- 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
+ } }));
528
535
  }
529
536
 
530
- /** Browser approval, start to key: opens (or prints) the approval URL and
531
- * polls until a person decides. Returns the minted credential; every
532
- * failure exits with the envelope. Shared by `login` and `init`. */
533
- 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> {
534
539
  const first = AUTH_SCALARS[0]!;
535
- const authUrl = cliAuthUrl(flags);
536
- const verifier = randomBytes(32).toString("base64url");
537
- const challenge = createHash("sha256").update(verifier).digest("base64url");
538
- const name = BIN + " CLI on " + hostname();
539
- const source = headless && !process.stdin.isTTY ? "agent" : "cli";
540
- let start: { session?: string; verification_url?: string; expires_in?: number; interval?: number; error?: string };
541
- try {
542
- const response = await fetch(authUrl + "/start", {
543
- method: "POST",
544
- headers: { "Content-Type": "application/json" },
545
- body: JSON.stringify({ code_challenge: challenge, name, source }),
546
- signal: AbortSignal.timeout(15_000),
547
- });
548
- start = await response.json() as typeof start;
549
- if (!response.ok || !start.session || !start.verification_url) {
550
- failWith({ code: "COMMAND_FAILED", message: "Could not start the browser login: " + (start.error ?? "HTTP " + response.status), nextSteps: ["Pass the credential directly: '" + BIN + " login --" + first.flag + " <value>'."] });
551
- }
552
- } catch (e) {
553
- 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>'."] });
554
- }
555
- const url = start!.verification_url!;
556
- const expiresIn = start!.expires_in ?? 600;
557
- const intervalMs = Math.max(1, start!.interval ?? 3) * 1000;
558
- process.stderr.write("Approve this CLI in your browser: " + paintErr("cyan", url) + "\n");
559
- if (headless) {
560
- 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");
561
- } else {
562
- openInBrowser(url);
563
- }
564
- const deadline = Date.now() + expiresIn * 1000;
565
- while (Date.now() < deadline) {
566
- await new Promise((r) => setTimeout(r, intervalMs));
567
- 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 () => {
568
544
  try {
569
- const response = await fetch(authUrl + "/status", {
570
- method: "POST",
571
- headers: { "Content-Type": "application/json" },
572
- body: JSON.stringify({ session: start!.session, code_verifier: verifier }),
573
- signal: AbortSignal.timeout(15_000),
574
- });
575
- 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");
576
552
  } catch {
577
- continue; // a blip; the next tick tries again
578
- }
579
- if (poll.status === "pending") continue;
580
- if (poll.status === "complete" && poll.api_key) {
581
- return { api_key: poll.api_key, key_name: poll.key_name ?? name, ...(poll.org_id ? { org_id: poll.org_id } : {}) };
553
+ process.stderr.write("The rejected approval's new credential could not be revoked. Revoke it in your API account.\n");
582
554
  }
583
- 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 + "."] });
584
- if (poll.status === "expired") break;
585
- failWith({ code: "COMMAND_FAILED", message: "Browser login stopped: " + (poll.status ?? "unknown status"), nextSteps: ["Run '" + BIN + " login' again."] });
586
- }
587
- 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
+ });
588
556
  }
589
557
 
590
- /** Store what the browser approval minted, marked as this CLI's own. */
591
- function storeMinted(stored: StoredCreds, minted: { api_key: string; key_name: string; org_id?: string }): void {
592
- const first = AUTH_SCALARS[0]!;
593
- stored.scalars = { ...stored.scalars, [first.option]: minted.api_key };
594
- stored.minted = { via: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}) };
595
- 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);
596
564
  }
597
565
 
598
- async function browserLogin(stored: StoredCreds, headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
599
- const minted = await browserApprove(headless, flags);
600
- storeMinted(stored, minted);
601
- 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);
602
635
  await flushExit(0);
603
636
  }
604
637
 
@@ -610,17 +643,30 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
610
643
  ...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
611
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)"] : []),
612
645
  " " + BIN + " login --with-token read the credential from stdin (CI)",
613
- ...(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"] : []),
614
649
  ...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
615
- ...(CLI_AUTH_URL ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
650
+ ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
616
651
  "",
617
- "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.",
618
660
  ];
619
661
  process.stdout.write(lines.join("\n") + "\n");
620
662
  await flushExit(0);
621
663
  }
622
- const stored: StoredCreds = readCreds() ?? {};
623
-
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);
624
670
  const scalarValues: Record<string, string> = {};
625
671
  for (const a of AUTH_SCALARS) {
626
672
  const v = parsed.flags.get(a.flag);
@@ -629,31 +675,39 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
629
675
  const username = parsed.flags.get("username");
630
676
  const password = parsed.flags.get("password");
631
677
  const gotBasic = BASIC !== null && typeof username === "string" && typeof password === "string";
632
- if (Object.keys(scalarValues).length > 0 || gotBasic) {
633
- if (Object.keys(scalarValues).length > 0) stored.scalars = { ...stored.scalars, ...scalarValues };
634
- if (gotBasic) stored.basic = { username: username as string, password: password as string };
635
- writeCreds(stored);
636
- 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() });
637
686
  await flushExit(0);
638
687
  }
639
688
 
640
689
  if (parsed.flags.get("with-token") === true) {
641
690
  const token = (await readStdin()).trim();
642
691
  if (!token) fail(2, "--with-token expects the credential on stdin.");
643
- await storePastedToken(stored, token);
692
+ await storePastedToken(token, parsed.flags);
644
693
  }
645
694
 
646
695
  const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined)
647
696
  ?? process.env["TYPESHIP_CLIENT_ID"] ?? OAUTH_CLIENT_ID ?? undefined;
648
- if (OAUTH_TOKEN_URL && clientId !== undefined && !explicitNonInteractive(parsed)) {
649
- 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);
650
704
  }
651
705
 
652
706
  // Browser approval: the API mints a key for this CLI once a person
653
707
  // approves in the browser. Works under an agent too (it prints the URL and
654
708
  // polls); only the explicit non-interactive switch turns it off.
655
709
  if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
656
- 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);
657
711
  }
658
712
 
659
713
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
@@ -664,7 +718,7 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
664
718
  nextSteps: [
665
719
  ...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
666
720
  "Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
667
- ...(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)."] : []),
668
722
  ...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
669
723
  ],
670
724
  });
@@ -673,27 +727,39 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
673
727
  if (!first) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
674
728
  const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
675
729
  if (!token) fail(2, "Nothing entered.");
676
- await storePastedToken(stored, token);
730
+ await storePastedToken(token, parsed.flags);
677
731
  }
678
732
 
679
- async function cmdLogout(): Promise<void> {
680
- const stored = readCreds();
681
- 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;
682
741
  // A key this CLI minted for itself (browser approval) is revoked on the
683
742
  // way out, so logging out ends the credential and not just the file. A
684
743
  // pasted or CI key is someone else's to revoke, and is left alone.
685
744
  let revoked: boolean | null = null;
686
745
  const first = AUTH_SCALARS[0];
687
746
  const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
688
- if (ownKey && CLI_AUTH_URL) {
747
+ if (ownKey) {
689
748
  try {
690
- const response = await fetch(CLI_AUTH_URL + "/revoke", { method: "POST", headers: { Authorization: "Bearer " + ownKey }, signal: AbortSignal.timeout(15_000) });
691
- 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;
692
751
  } catch {
693
752
  revoked = false;
694
753
  }
695
754
  }
696
- 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
+ }
697
763
  out({ ok: true, removed: existed ? credsPath() : null, ...(revoked === null ? {} : { revoked, key_name: stored?.minted?.key_name }) });
698
764
  await flushExit(0);
699
765
  }
@@ -701,21 +767,13 @@ async function cmdLogout(): Promise<void> {
701
767
  async function cmdWhoami(parsed: Parsed): Promise<void> {
702
768
  const op = WHOAMI ? OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method) : undefined;
703
769
  if (op) {
704
- const client = await makeClient(parsed.flags);
770
+ const client = await makeClient(parsed.flags, op);
705
771
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
706
772
  const result = await target[op.method]!();
707
773
  if (result.ok) { out(result.data ?? { ok: true }); await flushExit(0); }
708
774
  failApi(result.error, LAST_CLIENT_HAD_CREDENTIAL);
709
775
  }
710
- const stored = readCreds();
711
- let source = "none";
712
- if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) {
713
- source = "flags";
714
- } else {
715
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
716
- if (envScalar) source = "env:" + envScalar.env;
717
- else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
718
- }
776
+ const source = credentialSource(parsed.flags) ?? "none";
719
777
  out({ authenticated: source !== "none", source, credentials: existsSync(credsPath()) ? credsPath() : null });
720
778
  await flushExit(source === "none" ? 1 : 0);
721
779
  }
@@ -734,18 +792,7 @@ function configFilePath(): string {
734
792
  return join(configDir(), "config.json");
735
793
  }
736
794
 
737
- function readConfig(): CliConfigFile {
738
- try {
739
- return JSON.parse(readFileSync(configFilePath(), "utf8")) as CliConfigFile;
740
- } catch {
741
- return {};
742
- }
743
- }
744
-
745
- function writeConfig(config: CliConfigFile): void {
746
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
747
- writeFileSync(configFilePath(), JSON.stringify(config, null, 2) + "\n");
748
- }
795
+ function readConfig(): CliConfigFile { return readProfileConfig(configDir()); }
749
796
 
750
797
  const CONFIG_KEYS = ["base-url", "environment", "docs-url"];
751
798
 
@@ -779,6 +826,7 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
779
826
  if (sub === undefined || sub === "list") {
780
827
  const config = readConfig();
781
828
  out({
829
+ profile: PROFILE.name, profile_source: PROFILE.source,
782
830
  base_url: config.baseUrl ?? null,
783
831
  environment: config.environment ?? null,
784
832
  docs_url: config.docsUrl ?? DOCS_URL_DEFAULT,
@@ -801,10 +849,13 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
801
849
  await flushExit(0);
802
850
  }
803
851
  if (sub === "unset") {
804
- if (key === "base-url") delete config.baseUrl;
805
- else if (key === "environment") delete config.environment;
806
- else delete config.docsUrl;
807
- 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
+ });
808
859
  out({ ok: true });
809
860
  await flushExit(0);
810
861
  }
@@ -815,17 +866,14 @@ async function cmdConfig(parsed: Parsed): Promise<void> {
815
866
  } catch {
816
867
  fail(2, key + " must be a valid URL");
817
868
  }
818
- if (key === "base-url") config.baseUrl = value;
819
- else config.docsUrl = value;
820
869
  } else {
821
870
  if (!(value! in ENVIRONMENTS)) {
822
871
  fail(2, Object.keys(ENVIRONMENTS).length > 0
823
872
  ? "environment must be one of: " + Object.keys(ENVIRONMENTS).join(", ")
824
873
  : "The spec declares no named environments; use 'config set base-url' instead.");
825
874
  }
826
- config.environment = value;
827
875
  }
828
- writeConfig(config);
876
+ await updateProfileConfig(configDir(), (current) => key === "base-url" ? { ...current, baseUrl: value } : key === "docs-url" ? { ...current, docsUrl: value } : { ...current, environment: value, baseUrl: undefined });
829
877
  out({ ok: true, [key.replace(/-/g, "_")]: value });
830
878
  await flushExit(0);
831
879
  }
@@ -849,16 +897,6 @@ function mcpServerPath(): { path: string; warning: string | null } {
849
897
  return { path, warning };
850
898
  }
851
899
 
852
- function claudeDesktopConfigPath(): string {
853
- if (process.platform === "darwin") {
854
- return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
855
- }
856
- if (process.platform === "win32") {
857
- return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
858
- }
859
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "Claude", "claude_desktop_config.json");
860
- }
861
-
862
900
  /** The mcp entry for a client: the hosted endpoint when the API has one
863
901
  * (auth env var as a reference, never a literal), else the local stdio
864
902
  * server. --url overrides. */
@@ -878,7 +916,12 @@ function mcpEntryFor(url: string | undefined, readOnly = false): { entry: McpEnt
878
916
  }
879
917
  const server = mcpServerPath();
880
918
  if (server.warning) warnings.push(server.warning);
881
- 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 };
882
925
  }
883
926
 
884
927
  const MCP_CLIENT_FLAGS: Record<string, string> = {
@@ -902,11 +945,20 @@ async function cmdMcp(parsed: Parsed): Promise<void> {
902
945
  " " + BIN + " mcp install --claude --read-only register a read-only server (writes are not callable)",
903
946
  "",
904
947
  (MCP_URL ? "Default entry: the hosted endpoint " + MCP_URL + " with the auth env var as a reference (never a literal key)." : "Default entry: this package's local stdio server, which reads credentials saved by '" + BIN + " login' or the CLI's auth env vars."),
905
- "The old spelling '" + BIN + " mcp --claude' still works. --all skips Cursor until it speaks MCP 2026-07-28.",
948
+ "--all skips Cursor until it speaks MCP 2026-07-28.",
906
949
  ];
907
950
  process.stdout.write(lines.join("\n") + "\n");
908
951
  await flushExit(0);
909
952
  }
953
+ const sub = parsed.positionals[1];
954
+ if (sub !== undefined && sub !== "install") {
955
+ fail(2, "Unknown mcp command: " + sub + ". Try '" + BIN + " mcp install --all'.");
956
+ }
957
+ const requestedInstall = parsed.flags.get("all") === true
958
+ || Object.keys(MCP_CLIENT_FLAGS).some((flag) => parsed.flags.get(flag) === true);
959
+ if (requestedInstall && sub !== "install") {
960
+ fail(2, "Installing an MCP entry requires '" + BIN + " mcp install'.");
961
+ }
910
962
  const url = typeof parsed.flags.get("url") === "string" ? parsed.flags.get("url") as string : undefined;
911
963
  if (url) {
912
964
  try {
@@ -966,8 +1018,11 @@ function agentContext(): AgentContext {
966
1018
  apiTitle: API_TITLE,
967
1019
  version: VERSION,
968
1020
  envPrefix: ENV_PREFIX,
969
- 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"] : [])],
970
1022
  docsUrl: docsSiteUrl(),
1023
+ docsIndexUrl: docsIndexUrl(),
1024
+ generatedOperationCount: OPS.length,
1025
+ omittedOperations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
971
1026
  mcpUrl: MCP_URL,
972
1027
  skillsRepo: SKILLS_REPO,
973
1028
  hasMcp: HAS_MCP,
@@ -1028,13 +1083,21 @@ function helpJson(): Record<string, unknown> {
1028
1083
  };
1029
1084
  }),
1030
1085
  })),
1086
+ ...(EXCLUDED_OPS > 0 ? {
1087
+ coverage: {
1088
+ generated_operations: OPS.length,
1089
+ total_operations: OPS.length + EXCLUDED_OPS,
1090
+ omitted_operations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
1091
+ reason: "plan_limit",
1092
+ },
1093
+ } : {}),
1031
1094
  discovery: {
1032
1095
  search: BIN + " docs search <term> --json",
1033
1096
  operation: BIN + " docs <resource> <command> --json",
1034
1097
  note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
1035
1098
  },
1036
1099
  builtins: BUILTIN_COMMANDS,
1037
- 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>")],
1038
1101
  auth_env_vars: agentContext().authEnvVars,
1039
1102
  };
1040
1103
  }
@@ -1051,29 +1114,44 @@ async function cmdAgentGuide(parsed: Parsed): Promise<void> {
1051
1114
  /** Which credential the CLI would send, without sending it. --live calls the identity endpoint too. */
1052
1115
  async function cmdAuth(parsed: Parsed): Promise<void> {
1053
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
+ }
1054
1130
  if (parsed.help || sub !== "check") {
1055
1131
  process.stdout.write([
1056
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.",
1057
1137
  " --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
1058
1138
  "",
1059
1139
  "Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
1060
1140
  ].join("\n") + "\n");
1061
1141
  await flushExit(parsed.help ? 0 : 2);
1062
1142
  }
1063
- const stored = readCreds();
1064
- let source = "none";
1065
- if (AUTH_SCALARS.some((a) => typeof parsed.flags.get(a.flag) === "string")) source = "flags";
1066
- else {
1067
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
1068
- if (envScalar) source = "env:" + envScalar.env;
1069
- else if (stored && (stored.scalars || stored.basic || stored.oauth)) source = "login";
1070
- }
1143
+ const source = credentialSource(parsed.flags) ?? "none";
1071
1144
  const authenticated = source !== "none";
1145
+ const savedIdentity = source === "login" ? readCreds() : null;
1146
+ if (savedIdentity) assertStoredIdentity(savedIdentity, identityConfiguration());
1072
1147
  const report: Record<string, unknown> = {
1073
1148
  status: authenticated ? "ok" : "action_required",
1074
1149
  authenticated,
1075
1150
  source,
1076
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,
1077
1155
  auth_env_vars: agentContext().authEnvVars,
1078
1156
  base_url: resolveBaseUrl(parsed.flags) ?? null,
1079
1157
  next_steps: authenticated ? [] : [
@@ -1084,10 +1162,13 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1084
1162
  if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
1085
1163
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1086
1164
  if (op) {
1087
- const client = await makeClient(parsed.flags);
1165
+ const client = await makeClient(parsed.flags, op);
1088
1166
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; data?: unknown; error?: unknown }>>>)[op.resource]!;
1089
1167
  const result = await target[op.method]!();
1090
- 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
+ }
1091
1172
  else {
1092
1173
  const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1093
1174
  report.status = "action_required";
@@ -1109,10 +1190,11 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1109
1190
  const nodeMajor = Number(process.versions.node.split(".")[0]);
1110
1191
  checks.push({ name: "node", ok: nodeMajor >= 18, detail: process.version, ...(nodeMajor >= 18 ? {} : { fix: "Install Node 18 or newer." }) });
1111
1192
  checks.push({ name: "cli", ok: true, detail: BIN + " " + VERSION + " (" + PKG_NAME + ")" });
1112
- const stored = readCreds();
1113
- const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
1114
- const hasCred = Boolean(envScalar) || Boolean(stored && (stored.scalars || stored.basic || stored.oauth));
1115
- 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'.") }) });
1116
1198
  const baseUrl = resolveBaseUrl(parsed.flags);
1117
1199
  if (baseUrl) {
1118
1200
  try {
@@ -1128,7 +1210,7 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1128
1210
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1129
1211
  if (op) {
1130
1212
  try {
1131
- const client = await makeClient(parsed.flags);
1213
+ const client = await makeClient(parsed.flags, op);
1132
1214
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; error?: unknown }>>>)[op.resource]!;
1133
1215
  const result = await target[op.method]!();
1134
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." });
@@ -1181,24 +1263,33 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1181
1263
  const harness = detectHarness();
1182
1264
 
1183
1265
  // 1. credential
1184
- const stored: StoredCreds = readCreds() ?? {};
1185
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() ?? {};
1186
1269
  const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined)
1187
1270
  ?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined);
1188
- if (given && first) {
1189
- stored.scalars = { ...stored.scalars, [first.option]: given };
1190
- 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);
1191
1276
  report.credential = { status: "stored", path: credsPath() };
1192
1277
  } else if (first && process.env[first.env]) {
1193
1278
  report.credential = { status: "env", variable: first.env };
1194
- } 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) {
1195
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() };
1196
1286
  } else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1197
1287
  // Nothing anywhere: approve a credential in the browser, as `login`
1198
1288
  // would, then carry on. Under an agent the URL is printed for the person
1199
1289
  // and polled; only the explicit non-interactive switch skips this.
1290
+ await credentialStore().prepare();
1200
1291
  const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
1201
- storeMinted(stored, minted);
1292
+ await storeMinted(minted, parsed.flags);
1202
1293
  report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
1203
1294
  } else {
1204
1295
  report.credential = { status: "none" };
@@ -1303,7 +1394,7 @@ async function cmdUpgrade(parsed: Parsed): Promise<void> {
1303
1394
  if (manager === "npx") fail(1, "This run came through npx, which fetches the latest version each time; there is nothing to upgrade in place.");
1304
1395
  if (manager !== "npm") {
1305
1396
  const command = manager === "pnpm" ? "pnpm add -g " + PKG_NAME + "@" + latest : manager === "bun" ? "bun add -g " + PKG_NAME + "@" + latest : "yarn global add " + PKG_NAME + "@" + latest;
1306
- failWith({ status: "action_required", code: "COMMAND_FAILED", message: PKG_NAME + " was installed with " + manager + ", so npm can't upgrade it in place.", nextSteps: ["Run: " + command] });
1397
+ failWith({ status: "action_required", code: "CALL_FAILED", message: PKG_NAME + " was installed with " + manager + ", so npm can't upgrade it in place.", nextSteps: ["Run: " + command] });
1307
1398
  }
1308
1399
  process.stderr.write("Upgrading " + PKG_NAME + " " + VERSION + " -> " + latest + "\n");
1309
1400
  // Windows resolves npm to npm.cmd, which only a shell can start.
@@ -1319,7 +1410,7 @@ async function cmdUpgrade(parsed: Parsed): Promise<void> {
1319
1410
  // completion — shell completion scripts built from the op table
1320
1411
  // ---------------------------------------------------------------------------
1321
1412
 
1322
- const TOP_WORDS = ["login", "logout", "whoami", "config", "mcp", "upgrade", "docs", "webhooks", "feedback", "completion", "help", "version", "init", "agent-guide", "auth", "doctor"];
1413
+ const TOP_WORDS = ["login","logout","whoami","config","mcp","docs","upgrade","completion","help","version","init","agent-guide","auth","doctor"];
1323
1414
 
1324
1415
  /** Every flag an API command accepts, with the values a flag completes to (enums, on|off|auto, agent|human). */
1325
1416
  function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<string, string[]> } {
@@ -1333,13 +1424,13 @@ function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<strin
1333
1424
  return { flags, values };
1334
1425
  }
1335
1426
 
1336
- 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)];
1337
1428
  const BUILTIN_WORDS: Record<string, string[]> = {
1338
1429
  config: ["list", "get", "set", "unset", "path"],
1339
1430
  completion: ["bash", "zsh", "fish"],
1340
1431
  auth: ["check"],
1341
1432
  mcp: ["install"],
1342
- webhooks: ["listen", "fake"],
1433
+
1343
1434
  };
1344
1435
 
1345
1436
  /** bash (and zsh via bashcompinit): resources, commands, flags, and the values a flag takes. */
@@ -1439,16 +1530,19 @@ function docsSiteUrl(): string | null {
1439
1530
  return readConfig().docsUrl ?? DOCS_URL_DEFAULT;
1440
1531
  }
1441
1532
 
1533
+ function docsIndexUrl(): string | null {
1534
+ const configured = readConfig().docsUrl;
1535
+ return configured
1536
+ ? resolveDocsContentUrl(configured, null, "llms.txt")
1537
+ : DOCS_INDEX_URL_DEFAULT ?? resolveDocsContentUrl(DOCS_URL_DEFAULT, null, "llms.txt");
1538
+ }
1539
+
1442
1540
  /** Fetch llms.txt / llms-full.txt / a prose page from the docs site,
1443
1541
  * with a 1h cache in the config dir. Explicit command = explicit fetch;
1444
1542
  * nothing here runs unless the user asked for docs. */
1445
1543
  async function fetchDocs(pathOrFile: string): Promise<string | null> {
1446
1544
  const base = docsSiteUrl();
1447
- const url = /^https?:\/\//.test(pathOrFile)
1448
- ? pathOrFile
1449
- : base === null
1450
- ? null
1451
- : base.replace(/\/+$/, "") + "/" + pathOrFile.replace(/^\/+/, "");
1545
+ const url = resolveDocsContentUrl(base, docsIndexUrl(), pathOrFile);
1452
1546
  if (url === null) return null;
1453
1547
  const cacheFile = join(configDir(), "docs-cache", url.replace(/[^a-zA-Z0-9.]+/g, "_").slice(-120));
1454
1548
  try {
@@ -1456,12 +1550,8 @@ async function fetchDocs(pathOrFile: string): Promise<string | null> {
1456
1550
  if (Date.now() - stat.mtimeMs < 60 * 60 * 1000) return readFileSync(cacheFile, "utf8");
1457
1551
  } catch { /* not cached */ }
1458
1552
  try {
1459
- const response = await fetch(url, {
1460
- headers: { Accept: "text/markdown, text/plain, */*" },
1461
- signal: AbortSignal.timeout(10_000),
1462
- });
1463
- if (!response.ok) return null;
1464
- const text = await response.text();
1553
+ const text = await fetchDocsText(url);
1554
+ if (text === null) return null;
1465
1555
  mkdirSync(join(configDir(), "docs-cache"), { recursive: true, mode: 0o700 });
1466
1556
  writeFileSync(cacheFile, text);
1467
1557
  return text;
@@ -1594,30 +1684,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1594
1684
  .filter((match) => match.score > 0)
1595
1685
  .sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
1596
1686
  .map((match) => match.op);
1597
- const prose = await fetchDocs("llms-full.txt");
1598
- const proseMatches: { heading: string; excerpt: string; score: number }[] = [];
1599
- if (prose !== null) {
1600
- let heading = "";
1601
- const terms = searchTerms(term);
1602
- for (const line of prose.split("\n")) {
1603
- if (/^#{1,3} /.test(line)) heading = line.replace(/^#+ /, "").trim();
1604
- else {
1605
- const lowerHeading = heading.toLowerCase();
1606
- const lowerLine = line.toLowerCase();
1607
- const matched = terms.filter((word) => lowerHeading.includes(word) || lowerLine.includes(word));
1608
- if (matched.length > 0) {
1609
- const allTerms = matched.length === terms.length;
1610
- proseMatches.push({
1611
- heading,
1612
- excerpt: line.trim().slice(0, 160),
1613
- score: matched.length * 10 + (allTerms ? 50 : 0) + (lowerHeading.includes(term.toLowerCase()) || lowerLine.includes(term.toLowerCase()) ? 25 : 0),
1614
- });
1615
- }
1616
- }
1617
- }
1618
- proseMatches.sort((a, b) => b.score - a.score || a.heading.localeCompare(b.heading) || a.excerpt.localeCompare(b.excerpt));
1619
- }
1620
- const docsStatus = docsSiteUrl() === null ? "not_configured" : prose === null ? "unavailable" : "ok";
1687
+ const { guides: proseMatches, status: docsStatus } = await searchConnectedGuides(docsSiteUrl(), docsIndexUrl(), fetchDocs, term);
1621
1688
  if (jsonOutput) {
1622
1689
  out({
1623
1690
  schema_version: "1",
@@ -1629,7 +1696,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1629
1696
  ...(op.summary ? { summary: op.summary } : {}),
1630
1697
  details_command: BIN + " docs " + op.command.join(" ") + " --json",
1631
1698
  })),
1632
- 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) })),
1633
1700
  totals: { reference: refMatches.length, guides: proseMatches.length },
1634
1701
  guides_status: docsStatus,
1635
1702
  ...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
@@ -1644,7 +1711,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1644
1711
  }
1645
1712
  if (proseMatches.length > 0) {
1646
1713
  lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
1647
- 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));
1648
1715
  } else if (docsStatus === "not_configured") {
1649
1716
  lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
1650
1717
  } else if (docsStatus === "unavailable") {
@@ -1661,9 +1728,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1661
1728
  let target = page!;
1662
1729
  if (!/^https?:\/\//.test(target)) {
1663
1730
  const index = await fetchDocs("llms.txt");
1664
- const linked = index?.match(/\((https?:[^)]+)\)/g)?.map((m) => m.slice(1, -1)) ?? [];
1665
- const hit = linked.find((u) => u.toLowerCase().includes(target.toLowerCase()));
1666
- if (hit !== undefined) target = hit;
1731
+ target = docsReadTarget(index, docsSiteUrl(), docsIndexUrl(), target);
1667
1732
  }
1668
1733
  const text = await fetchDocs(target);
1669
1734
  if (text === null) {
@@ -1678,6 +1743,8 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1678
1743
  if (sub !== undefined) {
1679
1744
  const op = findOp(sub!, parsed.positionals[2] ?? "");
1680
1745
  if (!op) {
1746
+ const omitted = omittedCommand(sub!, parsed.positionals[2]);
1747
+ if (omitted) failOmitted(omitted);
1681
1748
  const list = OPS.filter((o) => o.command[0] === sub);
1682
1749
  if (list.length === 0) fail(2, "Unknown docs topic: " + sub + ". Run '" + BIN + " docs' for the overview.");
1683
1750
  const lines = [paintOut("bold", "Commands for " + sub + ":"), ""];
@@ -1730,185 +1797,11 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1730
1797
  // webhooks listen — forward relayed events to a local handler
1731
1798
  // ---------------------------------------------------------------------------
1732
1799
 
1733
- interface RelayedEvent {
1734
- seq: number;
1735
- method: string;
1736
- headers: Record<string, string>;
1737
- content_type: string | null;
1738
- body: string;
1739
- }
1740
1800
 
1741
- function eventTypeOf(body: string): string | null {
1742
- try {
1743
- const parsed = JSON.parse(body) as Record<string, unknown>;
1744
- for (const key of ["type", "event", "event_type", "eventType", "name"]) {
1745
- if (typeof parsed[key] === "string") return parsed[key] as string;
1746
- }
1747
- } catch { /* not JSON */ }
1748
- return null;
1749
- }
1750
1801
 
1751
- async function cmdWebhooksFake(parsed: Parsed): Promise<void> {
1752
- void parsed;
1753
- fail(2, "This API's spec declares no webhooks, so there is nothing to fake.");
1754
- }
1755
1802
 
1756
- async function cmdWebhooks(parsed: Parsed): Promise<void> {
1757
- const sub = parsed.positionals[1];
1758
- if (parsed.help || sub === undefined) {
1759
- const lines = [
1760
- BIN + " webhooks — work with this API's webhook events",
1761
- "",
1762
- " " + BIN + " webhooks listen --forward-to localhost:3000/webhooks",
1763
- " " + BIN + " webhooks listen --forward-to localhost:3000/hooks --events account.created,account.updated",
1764
- " " + BIN + " webhooks fake [<event>] [--key whsec_...] [--forward-to <url>]",
1765
- "",
1766
- "listen mints a private relay URL to point the API's webhook settings",
1767
- "at; events replay locally with their original headers, so signature",
1768
- "verification keeps working. No tunnels, no exposed ports.",
1769
- "fake sends (or prints) a signed sample event for local handler testing.",
1770
- ];
1771
- process.stdout.write(lines.join("\n") + "\n");
1772
- await flushExit(parsed.help ? 0 : 2);
1773
- }
1774
- if (sub === "fake") { await cmdWebhooksFake(parsed); }
1775
- if (sub !== "listen") fail(2, "Unknown webhooks command: " + String(sub) + ". Try '" + BIN + " webhooks listen' or '" + BIN + " webhooks fake'.");
1776
- if (!RELAY) {
1777
- fail(2, "The webhook relay isn't enabled for this package. The API provider can enable it in their typeship console; the next regeneration bakes it in.");
1778
- }
1779
- const forwardRaw = parsed.flags.get("forward-to");
1780
- if (typeof forwardRaw !== "string") {
1781
- fail(2, "webhooks listen requires --forward-to <url>, e.g. --forward-to localhost:3000/webhooks");
1782
- }
1783
- const forwardTo = /^https?:\/\//.test(forwardRaw as string) ? forwardRaw as string : "http://" + (forwardRaw as string);
1784
- const eventsRaw = parsed.flags.get("events");
1785
- const eventsFilter = typeof eventsRaw === "string"
1786
- ? eventsRaw.split(",").map((s) => s.trim()).filter(Boolean)
1787
- : null;
1788
1803
 
1789
- interface MintedSession {
1790
- session_id?: string;
1791
- ingest_url?: string;
1792
- poll_url?: string;
1793
- errors?: { message?: string }[];
1794
- }
1795
- let minted: MintedSession | null = null;
1796
- try {
1797
- const response = await fetch(RELAY!.mintUrl, {
1798
- method: "POST",
1799
- headers: { "Content-Type": "application/json", Accept: "application/json" },
1800
- body: JSON.stringify({ project: RELAY!.project }),
1801
- signal: AbortSignal.timeout(15_000),
1802
- });
1803
- minted = await response.json().catch(() => null) as MintedSession | null;
1804
- if (!response.ok || typeof minted?.ingest_url !== "string" || typeof minted.poll_url !== "string") {
1805
- const detail = minted?.errors?.[0]?.message ?? "HTTP " + response.status;
1806
- fail(1, "Couldn't start a relay session: " + detail);
1807
- }
1808
- } catch (e) {
1809
- if (e instanceof ExitPending) throw e;
1810
- fail(1, "Couldn't reach the relay: " + (e as Error).message);
1811
- }
1812
1804
 
1813
- process.stderr.write(paintErr("green", "Ready!") + " Forwarding relayed events to " + forwardTo + "\n");
1814
- process.stderr.write("Point this API's webhook endpoint at: " + paintErr("cyan", minted!.ingest_url!) + " (^C to quit)\n");
1815
-
1816
- let cursor = 0;
1817
- let pollFailures = 0;
1818
- for (;;) {
1819
- let payload: { events: RelayedEvent[]; next: number };
1820
- try {
1821
- const response = await fetch(minted!.poll_url! + "?after=" + cursor + "&wait=1", {
1822
- headers: { Accept: "application/json" },
1823
- signal: AbortSignal.timeout(30_000),
1824
- });
1825
- if (response.status === 404) fail(1, "The relay session expired. Run '" + BIN + " webhooks listen' again.");
1826
- if (!response.ok) throw new Error("HTTP " + response.status);
1827
- payload = await response.json() as typeof payload;
1828
- pollFailures = 0;
1829
- } catch (e) {
1830
- if (e instanceof ExitPending) throw e;
1831
- pollFailures++;
1832
- if (pollFailures > 5) fail(1, "Lost the relay: " + (e as Error).message);
1833
- await new Promise((resolve) => setTimeout(resolve, 2000 * pollFailures));
1834
- continue;
1835
- }
1836
- for (const event of payload.events) {
1837
- cursor = event.seq;
1838
- const type = eventTypeOf(event.body);
1839
- if (eventsFilter !== null && (type === null || !eventsFilter.includes(type))) continue;
1840
- const started = Date.now();
1841
- try {
1842
- const forwarded = await fetch(forwardTo, {
1843
- method: event.method,
1844
- headers: event.headers,
1845
- body: event.method === "GET" ? undefined : event.body,
1846
- signal: AbortSignal.timeout(30_000),
1847
- });
1848
- process.stderr.write(
1849
- (forwarded.ok ? paintErr("green", String(forwarded.status)) : paintErr("yellow", String(forwarded.status))) +
1850
- " " + (type ?? event.method) + " [" + event.seq + "] (" + (Date.now() - started) + "ms)\n",
1851
- );
1852
- } catch (e) {
1853
- process.stderr.write(paintErr("yellow", "unreachable") + " " + (type ?? event.method) + " [" + event.seq + "]: " + (e as Error).message + "\n");
1854
- }
1855
- }
1856
- if (payload.next > cursor) cursor = payload.next;
1857
- }
1858
- }
1859
-
1860
- async function cmdFeedback(parsed: Parsed): Promise<void> {
1861
- if (parsed.help) {
1862
- process.stdout.write(BIN + " feedback — open the API provider's issue tracker with environment details prefilled\n");
1863
- await flushExit(0);
1864
- }
1865
- if (!SUPPORT_URL) {
1866
- fail(2, "No support URL is configured for this CLI. The API provider can set one in their typeship console.");
1867
- }
1868
- let target = SUPPORT_URL!;
1869
- if (/github\.com\/[^/]+\/[^/]+\/issues\/new/.test(target)) {
1870
- const bodyLines = [
1871
- "<!-- describe the problem or request above the line -->",
1872
- "",
1873
- "---",
1874
- "- cli: " + BIN + " " + VERSION + " (api " + API_VERSION + ")",
1875
- "- node: " + process.version,
1876
- "- platform: " + process.platform + "/" + process.arch,
1877
- ];
1878
- const sep = target.includes("?") ? "&" : "?";
1879
- target += sep + "title=" + encodeURIComponent("[" + BIN + "] ") + "&body=" + encodeURIComponent(bodyLines.join("\n"));
1880
- }
1881
- if (!nonInteractive(parsed)) process.stderr.write("Opening " + paintErr("cyan", SUPPORT_URL!) + "\n");
1882
- const opened = openInBrowser(target, parsed);
1883
- if (opened.opened) out({ ok: true, opened: SUPPORT_URL });
1884
- await flushExit(0);
1885
- }
1886
-
1887
- /** Opt-in (console setting) once-a-day upgrade hint. Off by default:
1888
- * generated code phones nobody unless the project owner chose this. The
1889
- * notice prints the previous run's cached answer; at most once a day it
1890
- * refreshes the cache first, with a 1.5s cap so a slow registry can't
1891
- * hold a command hostage. */
1892
- async function maybeUpdateNotice(commandWord: string | undefined, quiet: boolean): Promise<void> {
1893
- if (!UPDATE_NOTICE || quiet) return;
1894
- if (commandWord === undefined || ["upgrade", "version", "help", "login", "logout"].includes(commandWord)) return;
1895
- const cacheFile = join(configDir(), "update-check.json");
1896
- let cache: { checkedAt?: number; latest?: string } = {};
1897
- try {
1898
- cache = JSON.parse(readFileSync(cacheFile, "utf8")) as typeof cache;
1899
- } catch { /* no cache yet */ }
1900
- if ((cache.checkedAt ?? 0) < Date.now() - 24 * 60 * 60 * 1000) {
1901
- const latest = await latestVersion(1500);
1902
- if (latest !== null) {
1903
- cache = { checkedAt: Date.now(), latest };
1904
- mkdirSync(configDir(), { recursive: true, mode: 0o700 });
1905
- writeFileSync(cacheFile, JSON.stringify(cache) + "\n");
1906
- }
1907
- }
1908
- if (cache.latest !== undefined && semverLess(VERSION, cache.latest)) {
1909
- process.stderr.write(paintErr("yellow", "A newer " + BIN + " is available: " + VERSION + " -> " + cache.latest + ". Run '" + BIN + " upgrade'.") + "\n");
1910
- }
1911
- }
1912
1805
 
1913
1806
  /** The command that fetches the next page: same positionals, the next page's query flags. */
1914
1807
  function nextCommandFor(op: OpSpec, pathValues: string[], next: Record<string, unknown>): string {
@@ -2051,7 +1944,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
2051
1944
  }
2052
1945
  const width = termWidth();
2053
1946
  const lines: string[] = [];
2054
- lines.push(paintOut("bold", BIN) + ": " + "typeship" + " (v" + "0.6.0" + ")");
1947
+ lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.9.1");
2055
1948
  lines.push("");
2056
1949
  lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
2057
1950
  lines.push("");
@@ -2068,12 +1961,18 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
2068
1961
  lines.push(" " + padPaint("cyan", resource, col) + wrapped[0]);
2069
1962
  for (const more of wrapped.slice(1)) lines.push(" ".repeat(2 + col) + more);
2070
1963
  }
1964
+ if (EXCLUDED_OPS > 0) {
1965
+ lines.push("");
1966
+ lines.push(...labeled(paintOut("yellow", "Plan limit:") + " ", "generated " + OPS.length + " of " + (OPS.length + EXCLUDED_OPS) + " operations", width, 14));
1967
+ lines.push(...labeled("Omitted: ", OMITTED_OPS.map((op) => op.command.join(" ") + " (" + op.httpMethod + " " + op.path + ")").join(", "), width, 14));
1968
+ lines.push(...labeled("Upgrade: ", "https://typeship.dev/pricing, then regenerate without the operation cap", width, 14));
1969
+ }
2071
1970
  lines.push("");
2072
- 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)" +
2073
1972
  (AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
2074
1973
  lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
2075
1974
  lines.push(...labeled("Credential env vars: ", [
2076
- ...AUTH_SCALARS.map((a) => a.env),
1975
+ "TYPESHIP_CREDENTIALS", ...AUTH_SCALARS.map((a) => a.env),
2077
1976
  ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
2078
1977
  ].join(", ") || "none", width, 21));
2079
1978
  lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
@@ -2083,9 +1982,9 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
2083
1982
  lines.push(...labeled("Docs: ", BIN + " docs [<resource> <command> | search <term> | read <page> | --web]", width, 6));
2084
1983
  if (RELAY) lines.push("Webhooks: " + BIN + " webhooks listen --forward-to <url> (local event forwarding)");
2085
1984
  if (SUPPORT_URL) lines.push("Feedback: " + BIN + " feedback (opens the provider's issue tracker)");
2086
- if (EXCLUDED_OPS > 0 && HAS_MCP) {
1985
+ if (MCP_EXCLUDED_OPS > 0 && HAS_MCP) {
2087
1986
  lines.push("");
2088
- lines.push("Note: " + EXCLUDED_OPS + " operation(s) with uploads or event streams are CLI/SDK-only, not MCP tools.");
1987
+ lines.push("Note: " + MCP_EXCLUDED_OPS + " generated operation(s) with uploads or event streams are CLI/SDK-only, not MCP tools.");
2089
1988
  }
2090
1989
  lines.push("");
2091
1990
  lines.push("Run '" + BIN + " <resource>' for a resource's commands, '" + BIN + " <resource> <command> --help' for flags.");
@@ -2115,6 +2014,7 @@ function printResource(resource: string, stream: NodeJS.WriteStream = process.st
2115
2014
  /** Flags the CLI itself adds to an API command, as [flag, description] rows. */
2116
2015
  function commandExtras(op: OpSpec): [string, string][] {
2117
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"]);
2118
2018
  if (op.hasBody && op.bodyKind === "binary") extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
2119
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"]);
2120
2020
  if (op.select) extras.push(["--select '<selection>'", "GraphQL selection set replacing the default, e.g. '{ id name }'"]);
@@ -2158,7 +2058,7 @@ function exampleLine(op: OpSpec): string {
2158
2058
 
2159
2059
  /** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
2160
2060
  function authNote(op: OpSpec): string {
2161
- const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || OAUTH_TOKEN_URL !== null;
2061
+ const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || HAS_OAUTH_LOGIN;
2162
2062
  return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
2163
2063
  }
2164
2064
 
@@ -2310,21 +2210,48 @@ function coerce(spec: ParamSpec, raw: string | boolean, repeated?: string[]): un
2310
2210
  /** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
2311
2211
  let LAST_CLIENT_HAD_CREDENTIAL = false;
2312
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
+
2313
2239
  /** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
2314
2240
  function credentialSource(flags: Map<string, string | boolean>): string | null {
2241
+ if (Object.keys(flagCredentials(flags)).length) return "flags";
2315
2242
  if (AUTH_SCALARS.some((a) => typeof flags.get(a.flag) === "string")) return "flags";
2316
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";
2317
2245
  const envScalar = AUTH_SCALARS.find((a) => process.env[a.env] !== undefined);
2318
2246
  if (envScalar) return "env:" + envScalar.env;
2319
2247
  if (BASIC && process.env[BASIC.envUser] !== undefined && process.env[BASIC.envPass] !== undefined) return "env:" + BASIC.envUser;
2320
2248
  const stored = readCreds();
2321
- if (stored && (stored.scalars || stored.basic || stored.oauth)) return "login";
2249
+ if (stored && (stored.scalars || stored.basic || stored.oauth || stored.named)) return "login";
2322
2250
  return null;
2323
2251
  }
2324
2252
 
2325
2253
  /** Base URL resolution: --base-url > env > config base-url > config environment > spec default. */
2326
- function resolveBaseUrl(flags: Map<string, string | boolean>): string | undefined {
2327
- const config = readConfig();
2254
+ function resolveBaseUrl(flags: Map<string, string | boolean>, config = readConfig()): string | undefined {
2328
2255
  return (typeof flags.get("base-url") === "string" ? flags.get("base-url") as string : undefined)
2329
2256
  ?? process.env["TYPESHIP_BASE_URL"]
2330
2257
  ?? config.baseUrl
@@ -2332,12 +2259,23 @@ function resolveBaseUrl(flags: Map<string, string | boolean>): string | undefine
2332
2259
  ?? DEFAULT_BASE_URL ?? undefined;
2333
2260
  }
2334
2261
 
2335
- async function makeClient(flags: Map<string, string | boolean>): Promise<TypeshipClient> {
2336
- const stored = readCreds();
2337
- const options: Record<string, unknown> = {};
2338
- 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);
2339
2273
  if (baseUrl === undefined) fail(2, "No base URL. Pass --base-url, set TYPESHIP_BASE_URL, or run '" + BIN + " config set base-url <url>'.");
2340
- options.baseUrl = baseUrl;
2274
+ if (stored) {
2275
+ assertCredentialDestination(stored, { apiBaseUrl: baseUrl, environment: config.environment, profile: PROFILE.name });
2276
+ if (!forIdentity) assertStoredIdentity(stored, identityConfiguration());
2277
+ }
2278
+ const options: ClientOptions & Record<string, unknown> = { baseUrl };
2341
2279
  for (const a of AUTH_SCALARS) {
2342
2280
  const v = (typeof flags.get(a.flag) === "string" ? flags.get(a.flag) as string : undefined)
2343
2281
  ?? process.env[a.env] ?? stored?.scalars?.[a.option];
@@ -2350,9 +2288,24 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
2350
2288
  ?? process.env[BASIC.envPass] ?? stored?.basic?.password;
2351
2289
  if (username !== undefined && password !== undefined) options.basicAuth = { username, password };
2352
2290
  }
2353
- if (options.bearerToken === undefined && stored?.oauth) {
2354
- const token = await refreshedOauthToken(stored);
2355
- 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);
2356
2309
  }
2357
2310
  if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
2358
2311
  options.debug = (event: DebugEvent) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
@@ -2363,7 +2316,7 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
2363
2316
  const value = typeof flagValue === "string" ? flagValue : process.env["TYPESHIP_" + g.envSuffix];
2364
2317
  if (value !== undefined) options[g.option] = value;
2365
2318
  }
2366
- 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;
2367
2320
  // Who is calling: the CLI, under which agent harness, and whether an
2368
2321
  // agent is driving. "agent" means a harness was detected or the caller
2369
2322
  // said so (--mode agent / env); a bare non-TTY run (CI, a pipeline) is
@@ -2377,7 +2330,8 @@ async function makeClient(flags: Map<string, string | boolean>): Promise<Typeshi
2377
2330
  ...(options.defaultHeaders as Record<string, string> | undefined),
2378
2331
  "User-Agent": PKG_NAME + "-cli/" + VERSION + " (typeship" + (harness ? "; harness=" + harness : "") + caller + ")",
2379
2332
  };
2380
- return new TypeshipClient(options as never);
2333
+ if (forIdentity) { options.fetch = identityFetch(baseUrl); options.maxRetries = 0; options.timeoutMs = 10_000; }
2334
+ return new TypeshipClient(options);
2381
2335
  }
2382
2336
 
2383
2337
  function editDistance(a: string, b: string): number {
@@ -2405,11 +2359,31 @@ function didYouMean(input: string, candidates: Iterable<string>): string | undef
2405
2359
  return best;
2406
2360
  }
2407
2361
 
2408
- const BUILTIN_COMMANDS = ["login", "logout", "whoami", "config", "mcp", "docs", "upgrade", "feedback", "completion", "webhooks", "help", "version", "init", "agent-guide", "auth", "doctor"];
2362
+ function omittedCommand(resource: string, method?: string): OmittedOpSpec | undefined {
2363
+ return OMITTED_OPS.find((op) => op.command[0] === resource &&
2364
+ (method === undefined || op.command[1] === method || op.commandAlias === method));
2365
+ }
2366
+
2367
+ function failOmitted(op: OmittedOpSpec): never {
2368
+ return failWith({
2369
+ status: "action_required",
2370
+ code: "PLAN_LIMIT",
2371
+ message: "The command '" + BIN + " " + op.command.join(" ") + "' exists in the API Definition but was omitted from this generated package by its plan limit.",
2372
+ detail: { operation: op.tool, method: op.httpMethod, path: op.path, generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS },
2373
+ nextSteps: ["Upgrade at https://typeship.dev/pricing and regenerate the package without the operation cap.", "Do not invent or retry an omitted command against this generated package."],
2374
+ });
2375
+ }
2376
+
2377
+ const BUILTIN_COMMANDS = ["login","logout","whoami","config","mcp","docs","upgrade","completion","help","version","init","agent-guide","auth","doctor"];
2409
2378
 
2410
2379
  async function main(): Promise<void> {
2411
2380
  const argv = process.argv.slice(2);
2412
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);
2413
2387
  if (parsed.positionals[0] === "help") {
2414
2388
  // help --json: the command surface as data (agents read this once).
2415
2389
  if (parsed.flags.get("json") === true || parsed.flags.get("format") === "json") { out(helpJson()); await flushExit(0); }
@@ -2430,25 +2404,25 @@ async function main(): Promise<void> {
2430
2404
  COLOR_ERR = colorEnabled(process.stderr, parsed);
2431
2405
  // Prose errors for a person: stderr is a terminal (or --mode human says
2432
2406
  // to behave as if), and nothing asked for JSON.
2433
- const forcedHuman = parsed.flags.get("mode") === "human" || parsed.flags.get("mode") === "interactive" || process.env["TYPESHIP_MODE"] === "human";
2407
+ const forcedHuman = parsed.flags.get("mode") === "human" || process.env["TYPESHIP_MODE"] === "human";
2434
2408
  HUMAN_ERRORS = (process.stderr.isTTY === true || forcedHuman) && !isAgentMode(parsed) && parsed.flags.get("format") !== "json" && parsed.flags.get("json") !== true;
2435
- await maybeUpdateNotice(resourceCmd, explicitNonInteractive(parsed));
2409
+
2436
2410
 
2437
2411
  if (resourceCmd === "init") { await cmdInit(parsed); }
2438
2412
  if (resourceCmd === "agent-guide") { await cmdAgentGuide(parsed); }
2439
2413
  if (resourceCmd === "auth") { await cmdAuth(parsed); }
2440
2414
  if (resourceCmd === "doctor") { await cmdDoctor(parsed); }
2441
2415
  if (resourceCmd === "upgrade") { await cmdUpgrade(parsed); }
2442
- if (resourceCmd === "webhooks") { await cmdWebhooks(parsed); }
2443
- if (resourceCmd === "feedback") { await cmdFeedback(parsed); }
2416
+
2417
+
2444
2418
  if (resourceCmd === "docs") { await cmdDocs(parsed); }
2445
2419
  if (resourceCmd === "completion") { await cmdCompletion(parsed); }
2446
2420
  if (resourceCmd === "config") { await cmdConfig(parsed); }
2447
2421
  if (resourceCmd === "mcp") { await cmdMcp(parsed); }
2448
2422
  if (resourceCmd === "login") { await cmdLogin(parsed); }
2449
2423
  if (resourceCmd === "logout") {
2450
- if (parsed.help) { process.stdout.write(BIN + " logout — remove " + credsPath() + "\n"); await flushExit(0); }
2451
- 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);
2452
2426
  }
2453
2427
  if (resourceCmd === "whoami") {
2454
2428
  if (parsed.help) {
@@ -2465,6 +2439,8 @@ async function main(): Promise<void> {
2465
2439
  if (!resourceCmd) { printRoot(parsed.help ? process.stdout : process.stderr); await flushExit(parsed.help ? 0 : 2); }
2466
2440
  const resourceExists = OPS.some((o) => o.command[0] === resourceCmd);
2467
2441
  if (!resourceExists) {
2442
+ const omitted = omittedCommand(resourceCmd, methodCmd);
2443
+ if (omitted) failOmitted(omitted);
2468
2444
  const suggestion = didYouMean(resourceCmd, [...new Set(OPS.map((o) => o.command[0])), ...BUILTIN_COMMANDS]);
2469
2445
  fail(2, "Unknown command: " + resourceCmd + "." + (suggestion ? " Did you mean '" + BIN + " " + suggestion + "'?" : ""),
2470
2446
  undefined, ["Run '" + BIN + " --help' for the commands."]);
@@ -2473,6 +2449,8 @@ async function main(): Promise<void> {
2473
2449
 
2474
2450
  const op = findOp(resourceCmd, methodCmd);
2475
2451
  if (!op) {
2452
+ const omitted = omittedCommand(resourceCmd, methodCmd);
2453
+ if (omitted) failOmitted(omitted);
2476
2454
  const suggestion = didYouMean(methodCmd, OPS.filter((o) => o.command[0] === resourceCmd).map((o) => o.command[1]));
2477
2455
  fail(2, "Unknown command: " + resourceCmd + " " + methodCmd + "." + (suggestion ? " Did you mean '" + BIN + " " + resourceCmd + " " + suggestion + "'?" : ""),
2478
2456
  undefined, ["Run '" + BIN + " " + resourceCmd + "' for its commands."]);
@@ -2508,7 +2486,7 @@ async function main(): Promise<void> {
2508
2486
  // Mirrors opReservedFlags() in the generator: API parameters never use these
2509
2487
  // names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
2510
2488
  // check can be exact.
2511
- 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)]);
2512
2490
  for (const spec of op.params) {
2513
2491
  if (spec.kind === "path") continue;
2514
2492
  const raw = parsed.flags.get(spec.flag);
@@ -2563,7 +2541,7 @@ async function main(): Promise<void> {
2563
2541
 
2564
2542
  // The spec says this operation needs a credential and none resolved:
2565
2543
  // say so now, locally, instead of sending a request to learn it.
2566
- 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) {
2567
2545
  failWith({
2568
2546
  status: "action_required",
2569
2547
  code: "NO_AUTH",
@@ -2595,7 +2573,7 @@ async function main(): Promise<void> {
2595
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] });
2596
2574
  }
2597
2575
 
2598
- const client = await makeClient(parsed.flags);
2576
+ const client = await makeClient(parsed.flags, op);
2599
2577
  const selectValue = typeof parsed.flags.get("select") === "string" ? (parsed.flags.get("select") as string) : undefined;
2600
2578
  const args = buildArgs(op, values, dataBody, selectValue);
2601
2579
  const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
@@ -2612,7 +2590,7 @@ async function main(): Promise<void> {
2612
2590
  }
2613
2591
  }
2614
2592
 
2615
- const result = await (callResult as Promise<{ ok: boolean; data?: unknown; error?: unknown }>);
2593
+ const result = await (callResult as Promise<{ ok: boolean; data?: unknown; error?: unknown; response?: { requestId?: string } }>);
2616
2594
  if (result.ok) {
2617
2595
  if (op.sse) {
2618
2596
  // Server-sent events as NDJSON, one line per event, until the stream ends.
@@ -2638,12 +2616,13 @@ async function main(): Promise<void> {
2638
2616
  // One page, plus what fetches the next: the raw arguments (the MCP
2639
2617
  // tool's nextPage shape) and the exact command, so a script or an
2640
2618
  // agent never has to reconstruct the cursor flag.
2641
- const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null };
2619
+ const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null; response: { requestId?: string } };
2642
2620
  const next = page.nextPageParams();
2643
2621
  out({
2644
2622
  items: project(page.items),
2645
2623
  hasMore: next !== null,
2646
2624
  ...(next !== null ? { nextPage: next, nextCommand: nextCommandFor(op, pathValues, next) } : {}),
2625
+ ...(page.response.requestId ? { request_id: page.response.requestId } : {}),
2647
2626
  });
2648
2627
  } else if (FIELDS !== null && collectionField !== null && result.data !== null && typeof result.data === "object" && !Array.isArray(result.data) && Array.isArray((result.data as Record<string, unknown>)[collectionField])) {
2649
2628
  // Batch-style collection envelopes ({data: [...]}) use item-relative
@@ -2674,17 +2653,6 @@ function errorMessage(error: unknown): string {
2674
2653
  return base;
2675
2654
  }
2676
2655
 
2677
- function serializeError(error: unknown): unknown {
2678
- if (error && typeof error === "object" && "violations" in error) {
2679
- return { violations: (error as { violations: unknown }).violations };
2680
- }
2681
- if (error && typeof error === "object" && "status" in error) {
2682
- const e = error as { status: number; body?: unknown };
2683
- return { status: e.status, body: e.body };
2684
- }
2685
- return undefined;
2686
- }
2687
-
2688
2656
  main().catch((e) => {
2689
2657
  if (e instanceof ExitPending) return;
2690
2658
  try {