@typeship-ax/cli 0.22.0 → 0.23.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 (138) hide show
  1. package/AGENTS.md +12 -8
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +369 -327
  5. package/dist/arguments.d.ts +47 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +254 -0
  8. package/dist/cli-agent.d.ts +31 -8
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +146 -28
  11. package/dist/cli.js +483 -237
  12. package/dist/core/http.d.ts +162 -19
  13. package/dist/core/http.d.ts.map +1 -1
  14. package/dist/core/http.js +381 -48
  15. package/dist/core/pagination.d.ts +42 -6
  16. package/dist/core/pagination.d.ts.map +1 -1
  17. package/dist/core/pagination.js +111 -17
  18. package/dist/credential-storage.d.ts +10 -3
  19. package/dist/credential-storage.d.ts.map +1 -1
  20. package/dist/credential-storage.js +15 -6
  21. package/dist/dates.d.ts +1 -1
  22. package/dist/dates.js +1 -1
  23. package/dist/errors.d.ts +20 -84
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +20 -108
  26. package/dist/fields.d.ts +29 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +101 -0
  29. package/dist/index.d.ts +28 -18
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +35 -25
  32. package/dist/named-credentials.d.ts +19 -0
  33. package/dist/named-credentials.d.ts.map +1 -1
  34. package/dist/named-credentials.js +81 -1
  35. package/dist/oauth-login.d.ts +8 -2
  36. package/dist/oauth-login.d.ts.map +1 -1
  37. package/dist/oauth-login.js +31 -19
  38. package/dist/oauth-request.d.ts +7 -1
  39. package/dist/oauth-request.d.ts.map +1 -1
  40. package/dist/oauth-request.js +26 -4
  41. package/dist/oauth-session.d.ts +13 -1
  42. package/dist/oauth-session.d.ts.map +1 -1
  43. package/dist/oauth-session.js +34 -18
  44. package/dist/ops.d.ts +53 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +49 -40
  47. package/dist/polling-login.d.ts +8 -2
  48. package/dist/polling-login.d.ts.map +1 -1
  49. package/dist/polling-login.js +25 -11
  50. package/dist/resources/api-keys.d.ts +10 -7
  51. package/dist/resources/api-keys.d.ts.map +1 -1
  52. package/dist/resources/api-keys.js +10 -31
  53. package/dist/resources/deliveries.d.ts +88 -4
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +95 -18
  56. package/dist/resources/drafts.d.ts +15 -15
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +11 -64
  59. package/dist/resources/files.d.ts +4 -4
  60. package/dist/resources/files.d.ts.map +1 -1
  61. package/dist/resources/files.js +3 -12
  62. package/dist/resources/generations.d.ts +14 -14
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +21 -45
  65. package/dist/resources/organization.d.ts +4 -4
  66. package/dist/resources/organization.d.ts.map +1 -1
  67. package/dist/resources/organization.js +3 -10
  68. package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
  69. package/dist/resources/packages.d.ts.map +1 -0
  70. package/dist/resources/{generate.js → packages.js} +13 -29
  71. package/dist/resources/projects.d.ts +50 -50
  72. package/dist/resources/projects.d.ts.map +1 -1
  73. package/dist/resources/projects.js +60 -116
  74. package/dist/resources/releases.d.ts +21 -16
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +18 -39
  77. package/dist/resources/spec-revisions.d.ts +15 -6
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +6 -28
  80. package/dist/resources/specs.d.ts +7 -7
  81. package/dist/resources/specs.d.ts.map +1 -1
  82. package/dist/resources/specs.js +6 -34
  83. package/dist/resources/targets.d.ts +48 -48
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +58 -114
  86. package/dist/schemas.d.ts.map +1 -1
  87. package/dist/schemas.js +83 -81
  88. package/dist/search.d.ts +54 -0
  89. package/dist/search.d.ts.map +1 -0
  90. package/dist/search.js +421 -0
  91. package/dist/types.d.ts +499 -339
  92. package/dist/types.d.ts.map +1 -1
  93. package/dist/types.js +18 -18
  94. package/package.json +5 -2
  95. package/src/arguments.ts +242 -0
  96. package/src/cli-agent.ts +156 -30
  97. package/src/cli.ts +444 -211
  98. package/src/core/http.ts +457 -58
  99. package/src/core/pagination.ts +129 -18
  100. package/src/credential-storage.ts +16 -6
  101. package/src/dates.ts +1 -1
  102. package/src/errors.ts +46 -115
  103. package/src/fields.ts +91 -0
  104. package/src/index.ts +45 -28
  105. package/src/named-credentials.ts +66 -1
  106. package/src/oauth-login.ts +36 -21
  107. package/src/oauth-request.ts +32 -6
  108. package/src/oauth-session.ts +37 -19
  109. package/src/ops.ts +82 -44
  110. package/src/polling-login.ts +24 -11
  111. package/src/resources/api-keys.ts +34 -48
  112. package/src/resources/deliveries.ts +211 -30
  113. package/src/resources/drafts.ts +60 -107
  114. package/src/resources/files.ts +19 -20
  115. package/src/resources/generations.ts +57 -75
  116. package/src/resources/organization.ts +11 -16
  117. package/src/resources/{generate.ts → packages.ts} +43 -51
  118. package/src/resources/projects.ts +145 -200
  119. package/src/resources/releases.ts +48 -65
  120. package/src/resources/spec-revisions.ts +38 -47
  121. package/src/resources/specs.ts +39 -59
  122. package/src/resources/targets.ts +143 -193
  123. package/src/schemas.ts +83 -81
  124. package/src/search.ts +434 -0
  125. package/src/types.ts +538 -357
  126. package/dist/console-login-check.d.ts +0 -21
  127. package/dist/console-login-check.d.ts.map +0 -1
  128. package/dist/console-login-check.js +0 -107
  129. package/dist/console-login-contract.d.ts +0 -45
  130. package/dist/console-login-contract.d.ts.map +0 -1
  131. package/dist/console-login-contract.js +0 -40
  132. package/dist/resources/generate.d.ts.map +0 -1
  133. package/dist/resources/publications.d.ts +0 -47
  134. package/dist/resources/publications.d.ts.map +0 -1
  135. package/dist/resources/publications.js +0 -70
  136. package/src/console-login-check.ts +0 -88
  137. package/src/console-login-contract.ts +0 -65
  138. package/src/resources/publications.ts +0 -140
package/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // typeship — command-line client. Generated by typeship — https://typeship.dev
2
+ // Typeship — command-line client. Generated by Typeship — https://typeship.dev
3
3
  // Flags-only for API commands (CI-safe); `login` is the one interactive
4
4
  // exception (hidden prompt on a TTY, --with-token/--token for scripts).
5
5
  // Prints raw JSON to stdout; errors as JSON on stderr.
@@ -9,40 +9,52 @@ import { spawnSync } from "node:child_process";
9
9
  import { oauthBrowserLogin, type OAuthLoginSession } from "./oauth-login.js";
10
10
  import { oauthDeviceLogin, customBrowserApproval, loginEndpoint, type ApprovedCredential } from "./polling-login.js";
11
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";
12
+ import { type FileCredentialStore, sessionCredential, assertCredentialDestination, assertStoredIdentity, credentialIdentityBinding, type CredentialDestination, oauthSessionToken, sessionBinding, type SessionConfiguration, type StoredCredentials as StoredCreds } from "./oauth-session.js";
14
13
  import { createCredentialStore } from "./credential-storage.js";
15
14
  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";
15
+ import { parseNamedCredentials, readNamedCredentialsFile, resolveNamedCredentials, namedCredentialAvailability, missingCredentials, oauthSessionSchemes, parseExtraHeaders, applyExtraHeaders, type NamedCredentials, type CredentialSchemes } from "./named-credentials.js";
17
16
  import { resolveProfile, listProfiles, selectProfile, removeProfile, readProfileConfig, updateProfileConfig, type ProfileContext } from "./auth-profiles.js";
18
17
  import { randomBytes } from "node:crypto";
19
18
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
20
19
  import { homedir, hostname } from "node:os";
21
- import { basename, dirname, join } from "node:path";
20
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
22
21
  import { fileURLToPath } from "node:url";
23
22
  import { TypeshipClient, formatDebugEvent, type ClientOptions, type DebugEvent } from "./index.js";
24
- import { asApiResult, validateAgainstSchema, ValidationError, type Violation } from "./core/http.js";
23
+ import { asApiResult, mediaTypeForPath, validateAgainstSchema, ValidationError, type Violation } from "./core/http.js";
25
24
  import { SCHEMAS, DEFS } from "./schemas.js";
26
25
  import { GLOBALS, OMITTED_OPS, OPS, buildArgs, findOp, missingRequired, type OmittedOpSpec, type OpSpec, type ParamSpec } from "./ops.js";
27
26
  import {
28
- MCP_CLIENTS, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, collectionProperty, detectHarness, envelope,
27
+ MCP_CLIENTS, requiredScopes, agentGuide, agentBlock, agentInstructionsFile, agentMode, bundleProperty, claimProperty, classifyApiError, classifyAuthFailure, collectionProperty, detectHarness, envelope,
29
28
  exitCodeFor, findMcpClient, installSkills, mcpConfigured, pendingClaims, recordClaim, summarizeDoctor, upsertAgentBlock, writeBundle, writeMcpConfig,
30
29
  type AgentContext, type CommandSummary, type DoctorCheck, type EnvelopeInput, type IssueCode, type McpEntry, type McpWriteResult,
31
30
  } from "./cli-agent.js";
32
31
  import { relativeDate } from "./dates.js";
32
+ import { checkValue, type ArgumentIssue } from "./arguments.js";
33
33
  import { docsReadCommand, docsReadTarget, fetchDocsText, resolveDocsContentUrl, searchConnectedGuides } from "./docs.js";
34
+ import { projectFields, unmatchedFields, unmatchedFieldsMessage } from "./fields.js";
35
+ import { SEARCH_PAGE_SIZE, rankOperations } from "./search.js";
34
36
 
35
37
 
36
38
  const BIN = "typeship";
39
+ /** The MCP server's key in client configs: the API's name, even when this
40
+ * command was renamed away from a vendor's. It reads this command's login. */
41
+ const MCP_SERVER_KEY = "typeship";
37
42
  const DEFAULT_BASE_URL = "https://typeship.dev/api/v1";
38
43
  const NAMED_SCHEMES: CredentialSchemes = {"apiKey":{"kind":"bearer","options":["bearerToken"]}};
39
44
  const AUTH_SCALARS: { option: string; flag: string; env: string }[] = [{"option":"bearerToken","flag":"token","env":"TYPESHIP_TOKEN"}];
45
+ /** Hosted MCP request headers as name → env reference, never a literal. */
46
+ const HOSTED_MCP_HEADERS: Record<string, string> = {"Authorization":"Bearer ${TYPESHIP_TOKEN}"};
47
+ const HOSTED_MCP_NOTE: string | null = null;
40
48
  const BASIC: { envUser: string; envPass: string } | null = null;
49
+ /** The spec declares no security, so the token is offered, never required. */
50
+ const AUTH_UNDECLARED = false;
51
+ /** Repeated --header "Name: value" flags for this invocation. */
52
+ let HEADER_FLAGS: string[] = [];
41
53
  /** Operations omitted from the generated package by its plan cap. */
42
54
  const EXCLUDED_OPS = 0;
43
55
  /** Generated CLI operations that are intentionally unavailable to MCP. */
44
56
  const MCP_EXCLUDED_OPS = 0;
45
- const VERSION = "0.22.0";
57
+ const VERSION = "0.23.1";
46
58
  const API_VERSION = "1.0.0";
47
59
  const SPEC_FORMAT = "openapi";
48
60
  const IDENTITY_POLICY: IdentityPolicy = {};
@@ -51,10 +63,13 @@ const WHOAMI: { resource: string; method: string } | null = null;
51
63
  const ENVIRONMENTS: Record<string, string> = {};
52
64
  const HAS_MCP = false;
53
65
  const PKG_NAME = "@typeship-ax/cli";
66
+ /** False when the package name was derived rather than chosen: the npm
67
+ * package of that name may be someone else's, so upgrade never installs it. */
68
+ const PKG_CONFIRMED = true;
54
69
  const UPDATE_NOTICE = false;
55
70
  const API_DESCRIPTION: string | null = "Resolve an OpenAPI or GraphQL Spec, diagnose it, and keep every\nselected CLI, MCP, and SDK 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\nExamples use Parcel, a fictional delivery service. Replace its domains,\nrepository names, and resource identifiers with your own. The hosted\npetstore Spec is a runnable sample.\n";
56
- const DOCS_URL_DEFAULT: string | null = "https://typeship.dev";
57
- const DOCS_INDEX_URL_DEFAULT: string | null = null;
71
+ const DOCS_URL_DEFAULT: string | null = "https://typeship.dev/docs";
72
+ const DOCS_INDEX_URL_DEFAULT: string | null = "https://typeship.dev/llms.txt";
58
73
  const RELAY: { mintUrl: string; project: string } | null = null;
59
74
  const SUPPORT_URL: string | null = null;
60
75
  const OAUTH_TOKEN_URL: string | null = null;
@@ -64,6 +79,9 @@ const OAUTH_DISCOVERY_URLS: string[] = [];
64
79
  const OAUTH_DEVICE_URL: string | null = null;
65
80
  const HAS_OAUTH_LOGIN = OAUTH_TOKEN_URL !== null || OAUTH_DISCOVERY_URLS.length > 0;
66
81
  const OAUTH_LOGIN_METHOD: string = "device";
82
+ /** The loopback port browser login listens on unless the redirect URI names
83
+ * one: fixed per CLI, so it can be registered with the provider. */
84
+ const OAUTH_DEFAULT_REDIRECT_PORT = 49910;
67
85
  const OAUTH_REDIRECT_URI: string | undefined = undefined;
68
86
  const OAUTH_ORGANIZATION_PARAMETER: "organization" | "organization_id" | undefined = undefined;
69
87
  const OAUTH_AUTHORIZATION_URL: string | undefined = undefined;
@@ -74,7 +92,7 @@ const MCP_URL: string | null = "https://typeship.dev/mcp";
74
92
  const SKILLS_REPO: string | null = "typeship-ax/skills";
75
93
  const CLI_AUTH_URL: string | null = "https://typeship.dev/api/auth/cli";
76
94
  const ENV_PREFIX = "TYPESHIP";
77
- const API_TITLE = "typeship";
95
+ const API_TITLE = "Typeship";
78
96
 
79
97
  interface Parsed {
80
98
  positionals: string[];
@@ -99,7 +117,7 @@ const BUILTIN_BOOLEAN_FLAGS: Record<string, string[]> = {
99
117
  mcp: ["claude", "cursor", "claude-desktop", "codex", "vscode", "windsurf", "gemini", "opencode", "zed", "all", "read-only"],
100
118
  docs: ["web", "schema"],
101
119
  init: ["all", "yes", "no-skills", "no-mcp", "no-agents-md", "no-browser"],
102
- auth: ["live"],
120
+ auth: ["live", "offline"],
103
121
  doctor: [],
104
122
  };
105
123
 
@@ -222,25 +240,46 @@ function out(value: unknown): void {
222
240
  /** --fields a,b.c: the dotted paths to keep in API results (null = everything). Set in main(). */
223
241
  let FIELDS: string[][] | null = null;
224
242
 
225
- /** Keep only FIELDS of a result: arrays item by item, objects by dotted path; scalars untouched. */
226
- function project(value: unknown, paths: string[][] | null = FIELDS): unknown {
227
- if (paths === null) return value;
228
- if (Array.isArray(value)) return value.map((item) => project(item, paths));
229
- if (value === null || typeof value !== "object") return value;
230
- const groups = new Map<string, string[][]>();
231
- for (const [key, ...rest] of paths) {
232
- if (key === undefined) continue;
233
- const group = groups.get(key);
234
- if (group) group.push(rest); else groups.set(key, [rest]);
235
- }
236
- const out: Record<string, unknown> = {};
237
- for (const [key, rests] of groups) {
238
- const child = (value as Record<string, unknown>)[key];
239
- if (child === undefined) continue;
240
- if (rests.some((rest) => rest.length === 0)) out[key] = child;
241
- else if (child !== null && typeof child === "object") out[key] = project(child, rests);
242
- }
243
- return out;
243
+ /** Whether the command being run writes: an --fields mistake on a write
244
+ * must not tempt anyone into running it again. Set in main(). */
245
+ let FIELDS_AFTER_WRITE = false;
246
+
247
+ /** Keep only FIELDS of a result: arrays item by item, objects by dotted path;
248
+ * scalars untouched. A path that matches nothing is an error naming the keys
249
+ * that exist, never a silent {}. */
250
+ function project(value: unknown, perItem: boolean): unknown {
251
+ if (FIELDS === null) return value;
252
+ const unmatched = unmatchedFields(value, FIELDS);
253
+ if (unmatched.length > 0) failUnmatchedFields(unmatched, perItem, value);
254
+ return projectFields(value, FIELDS);
255
+ }
256
+
257
+ /** --fields over a stream (--all, events): paths no item has matched yet.
258
+ * The stream is printed as it arrives, so the check fails at its end. */
259
+ function streamFieldsCheck(): { item(value: unknown): unknown; finish(): void } {
260
+ let pending: ReturnType<typeof unmatchedFields> | null = null;
261
+ return {
262
+ item(value: unknown): unknown {
263
+ if (FIELDS === null) return value;
264
+ const unmatched = unmatchedFields(value, FIELDS);
265
+ pending = pending === null ? unmatched : pending.filter((p) => unmatched.some((u) => u.path === p.path));
266
+ return projectFields(value, FIELDS);
267
+ },
268
+ finish(): void {
269
+ if (pending !== null && pending.length > 0) failUnmatchedFields(pending, true, undefined);
270
+ },
271
+ };
272
+ }
273
+
274
+ function failUnmatchedFields(unmatched: ReturnType<typeof unmatchedFields>, perItem: boolean, result: unknown): never {
275
+ return failWith({
276
+ code: "FIELDS_UNMATCHED",
277
+ message: unmatchedFieldsMessage(unmatched, perItem),
278
+ nextSteps: FIELDS_AFTER_WRITE
279
+ ? ["This command has already run; do not run it again to change --fields." + (result !== undefined ? " Its full result is in detail.result." : "")]
280
+ : ["Run the command again with --fields from the available keys" + (perItem ? " (fields apply to each item)" : "") + ", or without --fields for the whole result."],
281
+ detail: { unmatched, ...(FIELDS_AFTER_WRITE && result !== undefined ? { result } : {}) },
282
+ });
244
283
  }
245
284
 
246
285
  /** Thrown after scheduling exit so sync callers stop; main() swallows it. */
@@ -323,7 +362,7 @@ let USAGE_HINT = BIN + " --help";
323
362
  function fail(code: number, message: string, extra?: unknown, nextSteps?: string[]): never {
324
363
  const usageCode: IssueCode = /^Unknown command/.test(message) ? "UNKNOWN_COMMAND"
325
364
  : /^Unknown flag/.test(message) ? "UNKNOWN_FLAG"
326
- : /^(Missing required|Expected \d+ argument)/.test(message) ? "MISSING_ARGUMENT"
365
+ : /^(Missing required|Expected \d+ (or \d+ )?argument)/.test(message) ? "MISSING_ARGUMENT"
327
366
  : "INVALID_USAGE";
328
367
  return failWith({
329
368
  code: code === 2 ? usageCode : "CALL_FAILED",
@@ -334,8 +373,26 @@ function fail(code: number, message: string, extra?: unknown, nextSteps?: string
334
373
  }
335
374
 
336
375
  /** An SDK error result as an envelope: status-derived code, the API's body as detail, concrete next steps. */
376
+ /** OAuth scopes of the operation being run, so a 403 can name them. */
377
+ let CURRENT_SCOPES: string[] = [];
337
378
  function failApi(error: unknown, hadCredential: boolean): never {
338
- return failWith(classifyApiError(error, { bin: BIN, hadCredential, docsUrl: DOCS_URL_DEFAULT }));
379
+ return failWith(classifyAuthFailure(error, authFailureContext()) ?? classifyApiError(error, { bin: BIN, envPrefix: ENV_PREFIX, hadCredential, docsUrl: DOCS_URL_DEFAULT, requiredScopes: CURRENT_SCOPES, canLogin: HAS_OAUTH_LOGIN }));
380
+ }
381
+
382
+ /** The SDK raises NotModifiedError for a 304: a conditional request
383
+ * matched. For the CLI that is a result, not a failure. */
384
+ function isNotModified(error: unknown): error is { etag?: string } {
385
+ return (error as { name?: string } | null)?.name === "NotModifiedError";
386
+ }
387
+
388
+ function printNotModified(error: { etag?: string }): Promise<never> {
389
+ out({ ok: true, not_modified: true, ...(error.etag ? { etag: error.etag } : {}) });
390
+ return flushExit(0);
391
+ }
392
+
393
+ /** What a login, saved-session or credential-store failure names as alternatives. */
394
+ function authFailureContext(): { bin: string; envVars: string[]; storeVariable: string } {
395
+ return { bin: BIN, storeVariable: ENV_PREFIX + "_CREDENTIAL_STORE", envVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? [ENV_PREFIX + "_CREDENTIALS"] : [])] };
339
396
  }
340
397
 
341
398
  // ---------------------------------------------------------------------------
@@ -353,7 +410,7 @@ function credsPath(): string {
353
410
  return credentialStore().path;
354
411
  }
355
412
 
356
- function credentialStore(): FileCredentialStore { return createCredentialStore(configDir(), process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
413
+ function credentialStore(): FileCredentialStore { return createCredentialStore(configDir(), BIN, process.env["TYPESHIP_CREDENTIAL_STORE"], "TYPESHIP_CREDENTIAL_STORE"); }
357
414
  function readCreds(): StoredCreds | null { return credentialStore().read(); }
358
415
  function sessionConfiguration(baseUrl: string, config = readConfig()): SessionConfiguration {
359
416
  return {
@@ -492,9 +549,10 @@ async function deviceLogin(clientId: string, parsed: Parsed): Promise<void> {
492
549
  if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
493
550
  const loginConfiguration = sessionConfiguration(apiBaseUrl!);
494
551
  await credentialStore().prepare();
552
+ if (!OAUTH_DEVICE_URL && !OAUTH_ISSUER && !OAUTH_DISCOVERY_URL) fail(2, "This API does not declare device authorization. Run '" + BIN + " login' without --device to sign in through the browser.");
495
553
  const session = await withLoginCancellation((signal) => oauthDeviceLogin({
496
554
  clientId, issuer: OAUTH_ISSUER, discoveryUrls: OAUTH_DISCOVERY_URLS,
497
- deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: OAUTH_SCOPES,
555
+ deviceUrl: OAUTH_DEVICE_URL, tokenUrl: OAUTH_TOKEN_URL, scopes: loginScopes(parsed.flags),
498
556
  audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
499
557
  }, { signal, authorize({ verificationUri, userCode, expiresIn }) {
500
558
  process.stderr.write("Open " + paintErr("cyan", verificationUri) + " and enter code: " + paintErr("bold", userCode) + "\n");
@@ -509,11 +567,40 @@ async function deviceLogin(clientId: string, parsed: Parsed): Promise<void> {
509
567
  await flushExit(0);
510
568
  }
511
569
 
512
- async function storePastedToken(token: string, flags: Map<string, string | boolean>): Promise<void> {
513
- const first = AUTH_SCALARS[0];
514
- if (!first) fail(2, "This API declares no credential the CLI can store. Use --username/--password if it uses basic auth.");
515
- await saveLoginCredentials({ scalars: { [first!.option]: token } }, flags);
516
- out({ ok: true, method: "paste", stored_as: first!.flag, credentials: credsPath(), ...loginIdentityReport() });
570
+ type LoginValue = string | { username: string; password: string };
571
+ /** What --with-token, the prompt and browser approval store. */
572
+ interface LoginTarget { label: string; basic: boolean; storedAs: string; save(value: LoginValue): StoredCreds }
573
+
574
+ /** The scheme named by --scheme, else the convenience credential that the
575
+ * most operations accept on its own (declared order breaks ties), else null. */
576
+ function loginTarget(flags: Map<string, string | boolean>): LoginTarget | null {
577
+ const requested = flags.get("scheme");
578
+ if (requested !== undefined) {
579
+ if (typeof requested !== "string" || !Object.hasOwn(NAMED_SCHEMES, requested)) fail(2, "--scheme expects one of this API's security schemes: " + (Object.keys(NAMED_SCHEMES).join(", ") || "none") + ".");
580
+ const name = requested as string;
581
+ return { label: name, basic: NAMED_SCHEMES[name]!.kind === "basic", storedAs: name, save(value) {
582
+ try { return { named: parseNamedCredentials({ [name]: value }, NAMED_SCHEMES) }; } catch (error) { fail(2, (error as Error).message); }
583
+ } };
584
+ }
585
+ const candidates = [...AUTH_SCALARS.map((a) => a.option), ...(BASIC ? ["basicAuth"] : [])];
586
+ if (!candidates.length) return null;
587
+ const uses = (option: string) => OPS.filter((op) => op.credentialOptions?.some((alternative) => alternative.length === 1 && alternative[0] === option)).length;
588
+ const option = candidates.reduce((best, candidate) => uses(candidate) > uses(best) ? candidate : best);
589
+ if (option === "basicAuth") return { label: "username and password", basic: true, storedAs: "basic", save: (value) => ({ basic: value as { username: string; password: string } }) };
590
+ const scalar = AUTH_SCALARS.find((a) => a.option === option)!;
591
+ return { label: scalar.flag.replace(/-/g, " "), basic: false, storedAs: scalar.flag, save: (value) => ({ scalars: { [scalar.option]: value as string } }) };
592
+ }
593
+
594
+ /** Basic credentials on stdin are one line: username:password. */
595
+ function basicFromText(text: string): { username: string; password: string } {
596
+ const separator = text.indexOf(":");
597
+ if (separator <= 0 || separator === text.length - 1) fail(2, "--with-token expects username:password on stdin for Basic auth.");
598
+ return { username: text.slice(0, separator), password: text.slice(separator + 1) };
599
+ }
600
+
601
+ async function storePastedToken(value: LoginValue, target: LoginTarget, flags: Map<string, string | boolean>): Promise<void> {
602
+ await saveLoginCredentials(target.save(value), flags);
603
+ out({ ok: true, method: "paste", stored_as: target.storedAs, credentials: credsPath(), ...loginIdentityReport() });
517
604
  await flushExit(0);
518
605
  }
519
606
 
@@ -547,10 +634,9 @@ async function browserApprove(headless: boolean, flags: Map<string, string | boo
547
634
  }
548
635
 
549
636
  /** Store what the browser approval minted, marked as this CLI's own. */
550
- async function storeMinted(minted: ApprovedCredential, flags: Map<string, string | boolean>): Promise<void> {
551
- const first = AUTH_SCALARS[0]!;
637
+ async function storeMinted(minted: ApprovedCredential, target: LoginTarget, flags: Map<string, string | boolean>): Promise<void> {
552
638
  await saveLoginCredentials({
553
- scalars: { [first.option]: minted.api_key },
639
+ ...target.save(minted.api_key),
554
640
  minted: { via: "browser", key_name: minted.key_name, revocationUrl: minted.revocationUrl, ...(minted.org_id ? { org_id: minted.org_id } : {}) },
555
641
  }, flags, undefined, async () => {
556
642
  try {
@@ -567,16 +653,37 @@ async function storeMinted(minted: ApprovedCredential, flags: Map<string, string
567
653
  });
568
654
  }
569
655
 
570
- async function browserLogin(headless: boolean, flags: Map<string, string | boolean>): Promise<void> {
656
+ async function browserLogin(headless: boolean, target: LoginTarget, flags: Map<string, string | boolean>): Promise<void> {
571
657
  await credentialStore().prepare();
572
658
  const minted = await browserApprove(headless, flags);
573
- await storeMinted(minted, flags);
574
- out({ ok: true, method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
659
+ await storeMinted(minted, target, flags);
660
+ out({ ok: true, method: "browser", stored_as: target.storedAs, key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), credentials: credsPath(), ...loginIdentityReport() });
575
661
  await flushExit(0);
576
662
  }
577
663
 
664
+ /** The callback URL browser login uses: the configured redirect URI, else
665
+ * http://127.0.0.1:<port>/callback. --redirect-port or
666
+ * TYPESHIP_OAUTH_REDIRECT_PORT picks another port. */
667
+ function oauthRedirectUri(flags: Map<string, string | boolean>): string {
668
+ const requested = typeof flags.get("redirect-port") === "string" ? flags.get("redirect-port") as string : process.env["TYPESHIP_OAUTH_REDIRECT_PORT"];
669
+ const redirect = new URL(OAUTH_REDIRECT_URI ?? "http://127.0.0.1:" + OAUTH_DEFAULT_REDIRECT_PORT + "/callback");
670
+ if (requested !== undefined) {
671
+ if (!/^[1-9][0-9]{0,4}$/.test(requested) || Number(requested) > 65535) fail(2, "--redirect-port expects a port number from 1 to 65535.");
672
+ redirect.port = requested;
673
+ }
674
+ return redirect.href;
675
+ }
676
+
677
+ /** --scopes a,b narrows (or widens) what this login asks for. */
678
+ function loginScopes(flags: Map<string, string | boolean>): string[] {
679
+ const requested = flags.get("scopes");
680
+ if (requested === undefined) return OAUTH_SCOPES;
681
+ if (typeof requested !== "string" || !requested.trim()) fail(2, "--scopes expects a comma- or space-separated list of scopes.");
682
+ return [...new Set((requested as string).split(/[\s,]+/).filter(Boolean))];
683
+ }
684
+
578
685
  async function acquireOAuthBrowserSession(parsed: Parsed, clientId: string): Promise<void> {
579
- if (!OAUTH_ISSUER) fail(2, "Browser OAuth requires the exact auth.oauth_issuer configured by the API owner.");
686
+ if (!OAUTH_ISSUER && !OAUTH_AUTHORIZATION_URL) fail(2, "Browser OAuth needs an authorization URL: the API Spec's authorizationCode flow, or auth.oauth_server.issuer configured by the API owner.");
580
687
  const apiBaseUrl = resolveBaseUrl(parsed.flags);
581
688
  if (!apiBaseUrl) fail(2, "Set the API base URL before logging in.");
582
689
  const loginConfiguration = sessionConfiguration(apiBaseUrl!);
@@ -588,7 +695,7 @@ async function acquireOAuthBrowserSession(parsed: Parsed, clientId: string): Pro
588
695
  } }, parsed.flags, loginConfiguration);
589
696
  }
590
697
 
591
- /** Normal login and Console verification use the same native exchange. */
698
+ /** The native browser exchange. */
592
699
  async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeoutMs?: number): Promise<OAuthLoginSession> {
593
700
  const controller = new AbortController();
594
701
  const cancel = () => controller.abort();
@@ -596,9 +703,9 @@ async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeou
596
703
  process.once("SIGTERM", cancel);
597
704
  try {
598
705
  return await oauthBrowserLogin({
599
- issuer: OAUTH_ISSUER!, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
706
+ issuer: OAUTH_ISSUER, clientId, discoveryUrl: OAUTH_DISCOVERY_URL,
600
707
  authorizationUrl: OAUTH_AUTHORIZATION_URL, tokenUrl: OAUTH_TOKEN_URL ?? undefined,
601
- redirectUri: OAUTH_REDIRECT_URI, scopes: OAUTH_SCOPES,
708
+ redirectUri: oauthRedirectUri(parsed.flags), scopes: loginScopes(parsed.flags),
602
709
  audience: OAUTH_TOKEN_PARAMS.audience, resource: OAUTH_TOKEN_PARAMS.resource,
603
710
  organization: requestedLoginOrganization(parsed.flags),
604
711
  }, { signal: controller.signal, timeoutMs, authorize(url) {
@@ -609,56 +716,23 @@ async function startOAuthBrowserSession(parsed: Parsed, clientId: string, timeou
609
716
  } finally { process.off("SIGINT", cancel); process.off("SIGTERM", cancel); }
610
717
  }
611
718
 
612
- async function cmdConsoleLoginCheck(parsed: Parsed): Promise<void> {
613
- const file = parsed.flags.get("console-check");
614
- 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.");
615
- const baseUrl = resolveBaseUrl(parsed.flags);
616
- 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;
617
- const op = WHOAMI && OPS.find((value) => value.resource === WHOAMI.resource && value.method === WHOAMI.method);
618
- 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.");
619
- const envOptions: Record<string, unknown> = {}, flagOptions: Record<string, unknown> = {};
620
- for (const scalar of AUTH_SCALARS) {
621
- if (process.env[scalar.env] !== undefined) envOptions[scalar.option] = process.env[scalar.env];
622
- if (typeof parsed.flags.get(scalar.flag) === "string") flagOptions[scalar.option] = parsed.flags.get(scalar.flag);
623
- }
624
- if (BASIC && process.env[BASIC.envUser] && process.env[BASIC.envPass]) envOptions.basicAuth = { username: process.env[BASIC.envUser], password: process.env[BASIC.envPass] };
625
- 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") };
626
- 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;
627
- const result = await checkConsoleBrowserLogin({
628
- file: file as string, configuration: {
629
- baseUrl: baseUrl!, environment, operation: op!.resource + "." + op!.method, requirements: op!.security!,
630
- 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 },
631
- issuer: OAUTH_ISSUER!, clientId: clientId!, discoveryUrl: OAUTH_DISCOVERY_URL ?? null,
632
- authorizationUrl: OAUTH_AUTHORIZATION_URL ?? null, tokenUrl: OAUTH_TOKEN_URL,
633
- redirectUri: OAUTH_REDIRECT_URI ?? "http://127.0.0.1/callback", scopes: OAUTH_SCOPES,
634
- audience: OAUTH_TOKEN_PARAMS.audience ?? null, resource: OAUTH_TOKEN_PARAMS.resource ?? null,
635
- }, schemes: NAMED_SCHEMES,
636
- credentials: resolveNamedCredentials(NAMED_SCHEMES, [{ options: envOptions, named: environmentCredentials() }, { options: flagOptions, named: flagCredentials(parsed.flags) }]),
637
- login: (timeoutMs) => startOAuthBrowserSession(parsed, clientId!, timeoutMs),
638
- async verify(credentials, expectations) {
639
- const kind = (value: "user" | "account" | "organization") => value === "user" ? "subject" : value;
640
- const policy = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), entry.pointer])) as IdentityPolicy;
641
- const expected = Object.fromEntries(expectations.map((entry) => [kind(entry.kind), String(entry.expected)])) as ApiIdentity;
642
- await verifyClientIdentity((options) => new TypeshipClient(options as unknown as ClientOptions), { baseUrl: baseUrl!, credentials }, op!, policy, expected);
643
- },
644
- progress: (message) => process.stderr.write(message + "\n"),
645
- });
646
- out(result);
647
- await flushExit(0);
648
- }
649
719
 
650
720
  async function cmdLogin(parsed: Parsed): Promise<void> {
651
721
  if (parsed.help) {
722
+ const target = loginTarget(new Map());
652
723
  const lines = [
653
724
  BIN + " login — store credentials at " + credsPath(),
654
725
  "",
655
726
  ...AUTH_SCALARS.map((a) => " " + BIN + " login --" + a.flag + " <value>"),
656
- ...(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)"] : []),
657
- " " + BIN + " login --with-token read the credential from stdin (CI)",
658
- ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + OAUTH_LOGIN_METHOD + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "")] : []),
659
- ...(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"] : []),
727
+ ...(CLI_AUTH_URL && target && !target.basic ? [" " + BIN + " login approve in the browser: a key is minted for you (add --no-browser to print the link instead of opening it)"] : []),
728
+ ...(target ? [" " + BIN + " login --with-token read the " + target.label + " from stdin" + (target.basic ? " as one username:password line" : "") + " (CI)"] : []),
729
+ ...(Object.keys(NAMED_SCHEMES).length > 1 || (!target && Object.keys(NAMED_SCHEMES).length) ? [" " + BIN + " login --scheme <name> choose the scheme --with-token, the prompt" + (CLI_AUTH_URL ? " and browser approval" : "") + " store" + (target ? " (default: " + target.storedAs + ")" : "") + "; a Basic scheme reads username:password"] : []),
730
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --client-id <id> OAuth " + (OAUTH_LOGIN_METHOD === "browser" ? "browser (authorization code + PKCE)" : "device") + " login" + (OAUTH_CLIENT_ID ? " (a default id is built in)" : "; register an application with the provider for its client ID, or set " + ENV_PREFIX + "_CLIENT_ID")] : []),
731
+ ...(HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" ? [" " + BIN + " login --no-browser print the sign-in URL instead of opening a browser", " " + BIN + " login --redirect-port <n> listen on another loopback port (default callback " + oauthRedirectUri(new Map()) + "; register it with the provider)"] : []),
732
+ ...(HAS_OAUTH_LOGIN ? [" " + BIN + " login --scopes <a,b> request these scopes instead of " + (OAUTH_SCOPES.length ? OAUTH_SCOPES.join(" ") : "the provider's defaults")] : []),
733
+ ...(HAS_OAUTH_LOGIN && (OAUTH_DEVICE_URL || OAUTH_ISSUER || OAUTH_DISCOVERY_URL) ? [" " + BIN + " login --device use device authorization when your provider supports it"] : []),
660
734
  ...(BASIC ? [" " + BIN + " login --username <u> --password <p>"] : []),
661
- ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN ? [] : [" " + BIN + " login interactive prompt (TTY only)"]),
735
+ ...(CLI_AUTH_URL || HAS_OAUTH_LOGIN || !target ? [] : [" " + BIN + " login interactive prompt" + (target.basic ? " for username and hidden password" : "") + " (TTY only)"]),
662
736
  "",
663
737
  "Precedence per scheme: flags > env vars > stored credentials. Named inputs beat convenience flags within the same source.",
664
738
  " " + BIN + " login --credentials @<JSON-file> store named credentials (use - for stdin)",
@@ -674,9 +748,8 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
674
748
  }
675
749
  if (parsed.flags.has("login-organization")) {
676
750
  expectedLoginIdentity(parsed.flags);
677
- 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.");
751
+ if (!HAS_OAUTH_LOGIN || OAUTH_LOGIN_METHOD !== "browser" || parsed.flags.has("device") || parsed.flags.has("with-token") || explicitNonInteractive(parsed)) fail(2, "--login-organization is available only for interactive OAuth browser login, including --no-browser.");
678
752
  }
679
- if (parsed.flags.has("console-check")) { await cmdConsoleLoginCheck(parsed); return; }
680
753
  const named = flagCredentials(parsed.flags);
681
754
  const scalarValues: Record<string, string> = {};
682
755
  for (const a of AUTH_SCALARS) {
@@ -698,9 +771,11 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
698
771
  }
699
772
 
700
773
  if (parsed.flags.get("with-token") === true) {
774
+ const target = loginTarget(parsed.flags);
775
+ if (!target) fail(2, "This API declares no credential the CLI can store. See '" + BIN + " login --help'.");
701
776
  const token = (await readStdin()).trim();
702
777
  if (!token) fail(2, "--with-token expects the credential on stdin.");
703
- await storePastedToken(token, parsed.flags);
778
+ await storePastedToken(target!.basic ? basicFromText(token) : token, target!, parsed.flags);
704
779
  }
705
780
 
706
781
  const clientId = (typeof parsed.flags.get("client-id") === "string" ? parsed.flags.get("client-id") as string : undefined)
@@ -717,8 +792,9 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
717
792
  // Browser approval: the API mints a key for this CLI once a person
718
793
  // approves in the browser. Works under an agent too (it prints the URL and
719
794
  // polls); only the explicit non-interactive switch turns it off.
720
- if (CLI_AUTH_URL && AUTH_SCALARS[0] && !explicitNonInteractive(parsed)) {
721
- await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
795
+ const target = loginTarget(parsed.flags);
796
+ if (CLI_AUTH_URL && target && !target.basic && !explicitNonInteractive(parsed)) {
797
+ await browserLogin(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, target, parsed.flags);
722
798
  }
723
799
 
724
800
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
@@ -728,17 +804,24 @@ async function cmdLogin(parsed: Parsed): Promise<void> {
728
804
  message: "login needs a terminal to prompt, and there is none.",
729
805
  nextSteps: [
730
806
  ...AUTH_SCALARS.map((a) => "Pass the credential: '" + BIN + " login --" + a.flag + " <value>', or set " + a.env + " in the environment."),
731
- "Pipe it: echo \"$TOKEN\" | " + BIN + " login --with-token",
807
+ ...(BASIC ? ["Pass Basic credentials: '" + BIN + " login --username <u> --password <p>', or set " + BASIC.envUser + " and " + BASIC.envPass + " in the environment."] : []),
808
+ ...(target ? ["Pipe it: echo \"" + (target.basic ? "$USERNAME:$PASSWORD" : "$TOKEN") + "\" | " + BIN + " login --with-token" + (parsed.flags.has("scheme") ? " --scheme " + target.storedAs : "")] : []),
732
809
  ...(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)."] : []),
733
810
  ...(CLI_AUTH_URL ? ["Browser approval: '" + BIN + " login --no-browser' prints a link for the user to approve and waits."] : []),
734
811
  ],
735
812
  });
736
813
  }
737
- const first = AUTH_SCALARS[0];
738
- if (!first) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
739
- const token = (await promptHidden("Paste " + first.flag.replace(/-/g, " ") + " (input hidden): ")).trim();
814
+ if (!target) fail(2, "This API declares no credential the CLI can prompt for. See '" + BIN + " login --help'.");
815
+ if (target!.basic) {
816
+ process.stderr.write("Username: ");
817
+ const username = (await readLine()).trim();
818
+ const password = await promptHidden("Password (input hidden): ");
819
+ if (!username || !password) fail(2, "Enter both a username and a password.");
820
+ await storePastedToken({ username, password }, target!, parsed.flags);
821
+ }
822
+ const token = (await promptHidden("Paste " + target!.label + " (input hidden): ")).trim();
740
823
  if (!token) fail(2, "Nothing entered.");
741
- await storePastedToken(token, parsed.flags);
824
+ await storePastedToken(token, target!, parsed.flags);
742
825
  }
743
826
 
744
827
  async function cmdLogout(parsed: Parsed): Promise<void> {
@@ -753,8 +836,8 @@ async function cmdLogout(parsed: Parsed): Promise<void> {
753
836
  // way out, so logging out ends the credential and not just the file. A
754
837
  // pasted or CI key is someone else's to revoke, and is left alone.
755
838
  let revoked: boolean | null = null;
756
- const first = AUTH_SCALARS[0];
757
- const ownKey = first && stored?.minted?.via === "browser" ? stored.scalars?.[first.option] : undefined;
839
+ // The approval stored exactly one credential, as a scalar or a named scheme.
840
+ const ownKey = stored?.minted?.via === "browser" ? [...Object.values(stored.scalars ?? {}), ...Object.values(stored.named ?? {})].find((value): value is string => typeof value === "string") : undefined;
758
841
  if (ownKey) {
759
842
  try {
760
843
  const response = await oauthStatusRequest(loginEndpoint(stored!.minted!.revocationUrl!), { method: "POST", headers: { Authorization: "Bearer " + ownKey } }, 15_000);
@@ -915,12 +998,12 @@ function mcpEntryFor(url: string | undefined, readOnly = false): { entry: McpEnt
915
998
  const warnings: string[] = [];
916
999
  const hosted = url ?? MCP_URL ?? undefined;
917
1000
  if (hosted) {
918
- const envVar = AUTH_SCALARS[0]?.env;
919
1001
  // The hosted endpoint serves its read-only twin at <url>/readonly; a
920
1002
  // remote server of someone else's may not, so say so.
921
1003
  const target = readOnly ? hosted.replace(/\/+$/, "") + "/readonly" : hosted;
922
- if (readOnly && url !== undefined && !MCP_URL) warnings.push("--read-only appended /readonly to the URL, which typeship-hosted endpoints serve; check that this server does too.");
923
- return { entry: { url: target, ...(envVar ? { headers: { Authorization: "Bearer ${" + envVar + "}" } } : {}) }, warnings };
1004
+ if (readOnly && url !== undefined && !MCP_URL) warnings.push("--read-only appended /readonly to the URL; check that this server serves a read-only endpoint there.");
1005
+ if (HOSTED_MCP_NOTE) warnings.push(HOSTED_MCP_NOTE);
1006
+ return { entry: { url: target, ...(Object.keys(HOSTED_MCP_HEADERS).length ? { headers: { ...HOSTED_MCP_HEADERS } } : {}) }, warnings };
924
1007
  }
925
1008
  if (!HAS_MCP) {
926
1009
  fail(2, "This package was generated without the MCP server target. Regenerate with it, or pass --url for a remote endpoint.");
@@ -992,7 +1075,7 @@ async function cmdMcp(parsed: Parsed): Promise<void> {
992
1075
 
993
1076
  if (wanted.size === 0) {
994
1077
  out({
995
- server: BIN,
1078
+ server: MCP_SERVER_KEY,
996
1079
  entry,
997
1080
  ...(warnings.length > 0 ? { warnings } : {}),
998
1081
  detected: MCP_CLIENTS.filter((c) => c.detect(cwd)).map((c) => c.id),
@@ -1003,11 +1086,11 @@ async function cmdMcp(parsed: Parsed): Promise<void> {
1003
1086
  const results: McpWriteResult[] = [];
1004
1087
  for (const id of wanted) {
1005
1088
  const client = findMcpClient(id)!;
1006
- results.push(writeMcpConfig(client, cwd, BIN, entry));
1089
+ results.push(writeMcpConfig(client, cwd, MCP_SERVER_KEY, entry));
1007
1090
  }
1008
1091
  out({
1009
1092
  ok: true,
1010
- server: BIN,
1093
+ server: MCP_SERVER_KEY,
1011
1094
  entry,
1012
1095
  written: results.filter((r) => r.written).map((r) => r.file),
1013
1096
  clients: results,
@@ -1029,10 +1112,11 @@ function agentContext(): AgentContext {
1029
1112
  version: VERSION,
1030
1113
  envPrefix: ENV_PREFIX,
1031
1114
  authEnvVars: [...AUTH_SCALARS.map((a) => a.env), ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []), ...(Object.keys(NAMED_SCHEMES).length ? ["TYPESHIP_CREDENTIALS"] : [])],
1115
+ ...(AUTH_UNDECLARED ? { authNotDeclared: true } : {}),
1032
1116
  docsUrl: docsSiteUrl(),
1033
1117
  docsIndexUrl: docsIndexUrl(),
1034
1118
  generatedOperationCount: OPS.length,
1035
- omittedOperations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
1119
+ omittedOperationCount: OMITTED_OPS.length,
1036
1120
  mcpUrl: MCP_URL,
1037
1121
  skillsRepo: SKILLS_REPO,
1038
1122
  hasMcp: HAS_MCP,
@@ -1097,8 +1181,6 @@ function helpJson(): Record<string, unknown> {
1097
1181
  coverage: {
1098
1182
  generated_operations: OPS.length,
1099
1183
  total_operations: OPS.length + EXCLUDED_OPS,
1100
- omitted_operations: OMITTED_OPS.map((op) => ({ command: op.command.join(" "), tool: op.tool, method: op.httpMethod, path: op.path })),
1101
- reason: "plan_limit",
1102
1184
  },
1103
1185
  } : {}),
1104
1186
  discovery: {
@@ -1107,7 +1189,7 @@ function helpJson(): Record<string, unknown> {
1107
1189
  note: "Choose an operation from this index, then read only that operation's complete schemas and example arguments.",
1108
1190
  },
1109
1191
  builtins: BUILTIN_COMMANDS,
1110
- 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>")],
1192
+ global_flags: ["--help", "--version", "--debug", "--non-interactive", "--mode agent|human", "--yes", "--force", "--color on|off|auto", "--credentials @<JSON-file>|-", "--header 'Name: value'", "--timeout <seconds>", "--base-url <url>", "--profile <name>", "--data '<json>' | @<file> | -", "--fields <a,b.c>", "--all", "--validate", "--out <dir>", ...AUTH_SCALARS.map((a) => "--" + a.flag + " <value>")],
1111
1193
  auth_env_vars: agentContext().authEnvVars,
1112
1194
  };
1113
1195
  }
@@ -1139,24 +1221,26 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1139
1221
  }
1140
1222
  if (parsed.help || sub !== "check") {
1141
1223
  process.stdout.write([
1142
- BIN + " auth check [--live] — report the credential the CLI would use, as JSON: {status: ok|action_required, authenticated, source, ...}",
1224
+ BIN + " auth check [--offline] — report the credential the CLI would use and verify it with the API's identity read, as JSON: {status: ok|action_required, authenticated, verification: verified|unverified|rejected, source, ...}",
1143
1225
  " " + BIN + " auth profiles list profiles without unlocking credentials",
1144
1226
  " " + BIN + " auth use <name> select the default profile",
1145
1227
  " " + BIN + " auth remove <name> remove a profile after logout",
1146
1228
  " --profile <name> overrides TYPESHIP_PROFILE, then the saved selection, then default.",
1147
- " --live also call the API's identity endpoint" + (WHOAMI ? "" : " (none in this API; --live is a no-op)"),
1229
+ " --offline skip the identity read; the credential is then reported as unverified" + (WHOAMI ? "" : " (this API has no identity read, so credentials are always unverified)"),
1148
1230
  "",
1149
1231
  "Precedence: flags > env vars > stored credentials (" + credsPath() + ").",
1150
1232
  ].join("\n") + "\n");
1151
1233
  await flushExit(parsed.help ? 0 : 2);
1152
1234
  }
1153
1235
  const source = credentialSource(parsed.flags) ?? "none";
1154
- const authenticated = source !== "none";
1236
+ const present = source !== "none";
1155
1237
  const savedIdentity = source === "login" ? readCreds() : null;
1156
1238
  if (savedIdentity) assertStoredIdentity(savedIdentity, identityConfiguration());
1239
+ // A credential counts as authenticated only once the API accepted it.
1157
1240
  const report: Record<string, unknown> = {
1158
- status: authenticated ? "ok" : "action_required",
1159
- authenticated,
1241
+ status: present ? "ok" : "action_required",
1242
+ authenticated: false,
1243
+ verification: present ? "unverified" : "none",
1160
1244
  source,
1161
1245
  credentials_path: existsSync(credsPath()) ? credsPath() : null,
1162
1246
  credential_storage: credentialStore().backend,
@@ -1164,12 +1248,13 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1164
1248
  profile: PROFILE.name, profile_source: PROFILE.source,
1165
1249
  auth_env_vars: agentContext().authEnvVars,
1166
1250
  base_url: resolveBaseUrl(parsed.flags) ?? null,
1167
- next_steps: authenticated ? [] : [
1251
+ next_steps: present ? [WHOAMI ? "Run '" + BIN + " auth check' without --offline to verify the credential." : "This API has no identity read, so the credential was not checked. Run a read command to confirm the API accepts it."] : [
1168
1252
  ...AUTH_SCALARS.map((a) => "Set " + a.env + " in the environment, or run '" + BIN + " login --" + a.flag + " <value>'."),
1169
- "Then run '" + BIN + " auth check --live'.",
1253
+ ...(AUTH_UNDECLARED ? ["The API Spec does not declare authentication. Add another header with --header \"Name: value\" or TYPESHIP_HEADERS."] : []),
1254
+ ...(WHOAMI ? ["Then run '" + BIN + " auth check'."] : []),
1170
1255
  ],
1171
1256
  };
1172
- if (authenticated && parsed.flags.get("live") === true && WHOAMI) {
1257
+ if (present && parsed.flags.get("offline") !== true && WHOAMI) {
1173
1258
  const op = OPS.find((o) => o.resource === WHOAMI.resource && o.method === WHOAMI.method);
1174
1259
  if (op) {
1175
1260
  const client = await makeClient(parsed.flags, op);
@@ -1178,9 +1263,12 @@ async function cmdAuth(parsed: Parsed): Promise<void> {
1178
1263
  if (result.ok) {
1179
1264
  if (identityConfiguration() && savedIdentity?.identity) assertApiIdentity(savedIdentity.identity.values, readApiIdentity(result.data, IDENTITY_POLICY));
1180
1265
  report.identity = result.data;
1266
+ // An identity read that also answers anonymous callers proves nothing.
1267
+ if (op.auth !== "none") { report.authenticated = true; report.verification = "verified"; report.next_steps = []; }
1181
1268
  }
1182
1269
  else {
1183
- const why = classifyApiError(result.error, { bin: BIN, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1270
+ const why = classifyApiError(result.error, { bin: BIN, envPrefix: ENV_PREFIX, hadCredential: true, docsUrl: DOCS_URL_DEFAULT });
1271
+ report.verification = "rejected";
1184
1272
  report.status = "action_required";
1185
1273
  report.live = { ok: false, code: why.code, message: why.message };
1186
1274
  report.next_steps = why.nextSteps ?? [];
@@ -1209,7 +1297,13 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1209
1297
  if (baseUrl) {
1210
1298
  try {
1211
1299
  const response = await fetch(baseUrl, { method: "GET", signal: AbortSignal.timeout(8_000) });
1212
- checks.push({ name: "base_url", ok: true, detail: baseUrl + " → HTTP " + response.status });
1300
+ void response.body?.cancel().catch(() => {});
1301
+ const reachable = response.status >= 200 && response.status < 300;
1302
+ checks.push({ name: "base_url", ok: reachable, detail: baseUrl + " → HTTP " + response.status, ...(reachable ? {} : { fix: response.status === 404 || response.status === 405
1303
+ ? "The API does not answer at this base URL. Check --base-url / " + ENV_PREFIX + "_BASE_URL, or run '" + BIN + " upgrade' if this CLI is older than the API."
1304
+ : response.status === 401 || response.status === 403
1305
+ ? "The API answered without a credential with HTTP " + response.status + "; the identity check below tests the credential."
1306
+ : "The API answered HTTP " + response.status + ". Check the base URL and the API's status." }) });
1213
1307
  } catch (e) {
1214
1308
  checks.push({ name: "base_url", ok: false, detail: baseUrl + ": " + (e as Error).message, fix: "Check the network, or set --base-url / " + ENV_PREFIX + "_BASE_URL." });
1215
1309
  }
@@ -1223,7 +1317,13 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1223
1317
  const client = await makeClient(parsed.flags, op);
1224
1318
  const target = (client as unknown as Record<string, Record<string, () => Promise<{ ok: boolean; error?: unknown }>>>)[op.resource]!;
1225
1319
  const result = await asApiResult(target[op.method]!());
1226
- 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." });
1320
+ const status = (result.error as { status?: unknown } | undefined)?.status;
1321
+ checks.push(result.ok ? { name: "identity", ok: true, detail: op.command.join(" ") + " ok" } : { name: "identity", ok: false, detail: classifyApiError(result.error, { bin: BIN, envPrefix: ENV_PREFIX, hadCredential: true, docsUrl: DOCS_URL_DEFAULT }).message,
1322
+ // Only 401 and 403 are about the credential. A 404 or 405 means the
1323
+ // API no longer has this endpoint where this CLI version expects it.
1324
+ fix: status === 401 || status === 403 ? "The credential was rejected; run '" + BIN + " login' with a current one."
1325
+ : status === 404 || status === 405 ? "The API does not know " + wireOf(op) + ", which this CLI version calls. Run '" + BIN + " upgrade', and check the base URL."
1326
+ : "The identity read failed; the detail says why." });
1227
1327
  } catch (e) {
1228
1328
  checks.push({ name: "identity", ok: false, detail: (e as Error).message });
1229
1329
  }
@@ -1236,7 +1336,7 @@ async function cmdDoctor(parsed: Parsed): Promise<void> {
1236
1336
  }
1237
1337
  if (HAS_MCP || MCP_URL) {
1238
1338
  const cwd = process.cwd();
1239
- const configured = MCP_CLIENTS.filter((c) => c.detect(cwd) && mcpConfigured(c, cwd, BIN)).map((c) => c.id);
1339
+ const configured = MCP_CLIENTS.filter((c) => c.detect(cwd) && mcpConfigured(c, cwd, MCP_SERVER_KEY)).map((c) => c.id);
1240
1340
  const detected = MCP_CLIENTS.filter((c) => c.detect(cwd) && !c.incompatible).map((c) => c.id);
1241
1341
  checks.push({ name: "mcp_clients", ok: detected.length === 0 || configured.length > 0, detail: "detected: " + (detected.join(", ") || "none") + "; configured: " + (configured.join(", ") || "none"), ...(detected.length > 0 && configured.length === 0 ? { fix: "Run '" + BIN + " mcp install --all'." } : {}) });
1242
1342
  }
@@ -1276,13 +1376,18 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1276
1376
  const first = AUTH_SCALARS[0];
1277
1377
  const named = flagCredentials(parsed.flags), envNamed = environmentCredentials();
1278
1378
  const stored: StoredCreds = Object.keys(named).length || Object.keys(envNamed).length || first && process.env[first.env] ? {} : readCreds() ?? {};
1279
- const given = (typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined)
1280
- ?? (first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined);
1379
+ // -k stores the credential login would store; --<flag> stores that scalar.
1380
+ const target = loginTarget(parsed.flags);
1381
+ const keyValue = typeof parsed.flags.get("k") === "string" ? parsed.flags.get("k") as string : undefined;
1382
+ const flagValue = first && typeof parsed.flags.get(first.flag) === "string" ? parsed.flags.get(first.flag) as string : undefined;
1281
1383
  if (Object.keys(named).length) {
1282
1384
  await saveLoginCredentials({ named }, parsed.flags);
1283
1385
  report.credential = { status: "stored", path: credsPath() };
1284
- } else if (given && first) {
1285
- await saveLoginCredentials({ scalars: { [first.option]: given } }, parsed.flags);
1386
+ } else if (keyValue && target && !target.basic) {
1387
+ await saveLoginCredentials(target.save(keyValue), parsed.flags);
1388
+ report.credential = { status: "stored", path: credsPath() };
1389
+ } else if ((keyValue ?? flagValue) && first) {
1390
+ await saveLoginCredentials({ scalars: { [first.option]: (keyValue ?? flagValue)! } }, parsed.flags);
1286
1391
  report.credential = { status: "stored", path: credsPath() };
1287
1392
  } else if (first && process.env[first.env]) {
1288
1393
  report.credential = { status: "env", variable: first.env };
@@ -1293,13 +1398,13 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1293
1398
  } else if (first && HAS_OAUTH_LOGIN && OAUTH_LOGIN_METHOD === "browser" && (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID) && !explicitNonInteractive(parsed)) {
1294
1399
  await acquireOAuthBrowserSession(parsed, (process.env[ENV_PREFIX + "_CLIENT_ID"] ?? OAUTH_CLIENT_ID)!);
1295
1400
  report.credential = { status: "stored", method: "oauth_browser", path: credsPath() };
1296
- } else if (first && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1401
+ } else if (target && !target.basic && CLI_AUTH_URL && !explicitNonInteractive(parsed)) {
1297
1402
  // Nothing anywhere: approve a credential in the browser, as `login`
1298
1403
  // would, then carry on. Under an agent the URL is printed for the person
1299
1404
  // and polled; only the explicit non-interactive switch skips this.
1300
1405
  await credentialStore().prepare();
1301
1406
  const minted = await browserApprove(isAgentMode(parsed) || parsed.flags.get("no-browser") === true, parsed.flags);
1302
- await storeMinted(minted, parsed.flags);
1407
+ await storeMinted(minted, target, parsed.flags);
1303
1408
  report.credential = { status: "minted", method: "browser", key_name: minted.key_name, ...(minted.org_id ? { org_id: minted.org_id } : {}), path: credsPath() };
1304
1409
  } else {
1305
1410
  report.credential = { status: "none" };
@@ -1317,7 +1422,7 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1317
1422
  } else {
1318
1423
  const { entry, warnings } = mcpEntryFor(undefined);
1319
1424
  const clients = MCP_CLIENTS.filter((c) => !c.incompatible && c.detect(cwd) && !(c.id === "claude-desktop" && entry.url));
1320
- const results = clients.map((c) => writeMcpConfig(c, cwd, BIN, entry));
1425
+ const results = clients.map((c) => writeMcpConfig(c, cwd, MCP_SERVER_KEY, entry));
1321
1426
  report.mcp = { status: results.length > 0 ? "written" : "no-clients", entry, clients: results, ...(warnings.length > 0 ? { warnings } : {}) };
1322
1427
  if (results.length === 0) nextSteps.push("No MCP client was found on this machine; run '" + BIN + " mcp' to print the entry.");
1323
1428
  }
@@ -1331,7 +1436,7 @@ async function cmdInit(parsed: Parsed): Promise<void> {
1331
1436
  report.agents_md = { status: result.updated ? "updated" : "written", file: result.file };
1332
1437
  }
1333
1438
 
1334
- nextSteps.push("Run '" + BIN + " auth check --live'" + (WHOAMI ? "" : " (or any read command)") + " to confirm the connection.");
1439
+ nextSteps.push("Run '" + BIN + " auth check'" + (WHOAMI ? "" : " (or any read command)") + " to confirm the connection.");
1335
1440
  nextSteps.push("Run '" + BIN + " agent-guide' for the conventions, or '" + BIN + " --help' for commands.");
1336
1441
  out({ ...report, next_steps: nextSteps });
1337
1442
  await flushExit(0);
@@ -1357,6 +1462,7 @@ function registryBase(): string {
1357
1462
  }
1358
1463
 
1359
1464
  async function latestVersion(timeoutMs: number): Promise<string | null> {
1465
+ if (!PKG_CONFIRMED) return null;
1360
1466
  try {
1361
1467
  const response = await fetch(registryBase() + "/" + PKG_NAME, {
1362
1468
  headers: { Accept: "application/vnd.npm.install-v1+json" },
@@ -1383,6 +1489,9 @@ async function cmdUpgrade(parsed: Parsed): Promise<void> {
1383
1489
  process.stdout.write(lines.join("\n") + "\n");
1384
1490
  await flushExit(0);
1385
1491
  }
1492
+ if (!PKG_CONFIRMED) {
1493
+ fail(1, "This build's package name (" + PKG_NAME + ") was not confirmed when it was generated, so upgrade does not look it up on npm, where that name may belong to another package. This package updates by regeneration from its API spec; get the latest from the API provider.");
1494
+ }
1386
1495
  const latest = await latestVersion(5000);
1387
1496
  if (latest === null) {
1388
1497
  fail(1, PKG_NAME + " is not on the registry (" + registryBase() + "). This package updates by regeneration from its API spec; get the latest from the API provider.");
@@ -1434,7 +1543,7 @@ function completionFlagsFor(op: OpSpec): { flags: string[]; values: Record<strin
1434
1543
  return { flags, values };
1435
1544
  }
1436
1545
 
1437
- 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)];
1546
+ const COMPLETION_GLOBAL_FLAGS = ["--help", "--version", "--non-interactive", "--color", "--credentials", "--header", "--timeout", "--base-url", "--profile", "--data", "--fields", "--all", "--validate", "--debug", "--mode", "--yes", "--force", "--out", ...AUTH_SCALARS.map((a) => "--" + a.flag)];
1438
1547
  const BUILTIN_WORDS: Record<string, string[]> = {
1439
1548
  config: ["list", "get", "set", "unset", "path"],
1440
1549
  completion: ["bash", "zsh", "fish"],
@@ -1574,34 +1683,6 @@ async function fetchDocs(pathOrFile: string): Promise<string | null> {
1574
1683
  }
1575
1684
  }
1576
1685
 
1577
- function searchTerms(query: string): string[] {
1578
- return [...new Set(query.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((term) => term.length >= 2))];
1579
- }
1580
-
1581
- /** Token search across names, prose, paths, and arguments. A phrase such as
1582
- * "create project" should find the projects create command, even though that exact
1583
- * substring never occurs in the generated command. */
1584
- function referenceSearchScore(op: OpSpec, query: string): number {
1585
- const terms = searchTerms(query);
1586
- if (terms.length === 0) return 0;
1587
- const names = [...op.command, op.tool].join("_").toLowerCase().split(/[^a-z0-9]+/);
1588
- const summary = (op.summary ?? "").toLowerCase();
1589
- const description = (op.description ?? "").toLowerCase();
1590
- const path = op.path.toLowerCase();
1591
- const params = op.params.flatMap((p) => [p.name.toLowerCase(), p.flag.toLowerCase()]);
1592
- let score = 0;
1593
- for (const term of terms) {
1594
- if (names.includes(term)) score += 10;
1595
- if (summary.split(/[^a-z0-9]+/).includes(term)) score += 5;
1596
- else if (summary.includes(term)) score += 3;
1597
- if (path.includes(term)) score += 3;
1598
- if (params.includes(term)) score += 3;
1599
- else if (params.some((param) => param.includes(term))) score += 1;
1600
- if (description.includes(term)) score += 1;
1601
- }
1602
- return score;
1603
- }
1604
-
1605
1686
  function referenceFor(op: OpSpec, includeSchemas = false): string {
1606
1687
  const lines: string[] = [];
1607
1688
  lines.push(paintOut("bold", usageLine(op)));
@@ -1690,23 +1771,22 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1690
1771
  const term = parsed.positionals.slice(2).join(" ");
1691
1772
  if (!term) fail(2, "docs search expects a term");
1692
1773
  const jsonOutput = parsed.flags.get("json") === true || parsed.flags.get("format") === "json";
1693
- const refMatches = OPS.map((op) => ({ op, score: referenceSearchScore(op, term) }))
1694
- .filter((match) => match.score > 0)
1695
- .sort((a, b) => b.score - a.score || a.op.command.join(" ").localeCompare(b.op.command.join(" ")))
1696
- .map((match) => match.op);
1774
+ // The MCP server's search_docs ranking, so both surfaces agree.
1775
+ const refMatches = rankOperations(OPS, term).map((match) => match.op);
1697
1776
  const { guides: proseMatches, status: docsStatus } = await searchConnectedGuides(docsSiteUrl(), docsIndexUrl(), fetchDocs, term);
1698
1777
  if (jsonOutput) {
1699
1778
  out({
1700
1779
  schema_version: "1",
1701
1780
  query: term,
1702
- reference: refMatches.slice(0, 15).map((op) => ({
1781
+ reference: refMatches.slice(0, SEARCH_PAGE_SIZE).map((op) => ({
1703
1782
  command: op.command.join(" "),
1704
1783
  method: op.httpMethod,
1705
1784
  path: op.path,
1706
1785
  ...(op.summary ? { summary: op.summary } : {}),
1786
+ ...(op.deprecated ? { deprecated: true } : {}),
1707
1787
  details_command: BIN + " docs " + op.command.join(" ") + " --json",
1708
1788
  })),
1709
- guides: proseMatches.slice(0, 15).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
1789
+ guides: proseMatches.slice(0, SEARCH_PAGE_SIZE).map((match) => ({ ...match, read_command: docsReadCommand(BIN, match.url) })),
1710
1790
  totals: { reference: refMatches.length, guides: proseMatches.length },
1711
1791
  guides_status: docsStatus,
1712
1792
  ...(docsStatus === "not_configured" ? { next_steps: ["Run '" + BIN + " config set docs-url <url>' to add guide search; the API reference was still searched."] } : {}),
@@ -1717,11 +1797,11 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1717
1797
  const lines: string[] = [];
1718
1798
  if (refMatches.length > 0) {
1719
1799
  lines.push(paintOut("bold", "Reference:"));
1720
- for (const op of refMatches.slice(0, 15)) lines.push(" " + padPaint("cyan", op.command.join(" "), 34) + (op.summary ?? wireOf(op)));
1800
+ for (const op of refMatches.slice(0, SEARCH_PAGE_SIZE)) lines.push(" " + padPaint("cyan", op.command.join(" "), 34) + (op.deprecated ? "(deprecated) " : "") + (op.summary ?? wireOf(op)));
1721
1801
  }
1722
1802
  if (proseMatches.length > 0) {
1723
1803
  lines.push(...(lines.length > 0 ? [""] : []), paintOut("bold", "Guides:"));
1724
- for (const match of proseMatches.slice(0, 15)) lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
1804
+ for (const match of proseMatches.slice(0, SEARCH_PAGE_SIZE)) lines.push(" " + paintOut("cyan", match.title + (match.section ? " / " + match.section : "")), " " + match.excerpt, " " + docsReadCommand(BIN, match.url));
1725
1805
  } else if (docsStatus === "not_configured") {
1726
1806
  lines.push(...(lines.length > 0 ? [""] : []), "(no docs site configured for guide search: '" + BIN + " config set docs-url <url>')");
1727
1807
  } else if (docsStatus === "unavailable") {
@@ -1778,7 +1858,7 @@ async function cmdDocs(parsed: Parsed): Promise<void> {
1778
1858
  }
1779
1859
 
1780
1860
  const lines: string[] = [];
1781
- lines.push(paintOut("bold", "typeship") + " (v" + API_VERSION + ")");
1861
+ lines.push(paintOut("bold", "Typeship") + " (v" + API_VERSION + ")");
1782
1862
  if (API_DESCRIPTION) lines.push("", API_DESCRIPTION.trim());
1783
1863
  lines.push("", paintOut("bold", "Reference:") + " " + BIN + " docs <resource> <command>");
1784
1864
  const byResource = new Map<string, number>();
@@ -1834,7 +1914,7 @@ function shellQuote(value: string): string {
1834
1914
  }
1835
1915
 
1836
1916
  function usageLine(op: OpSpec): string {
1837
- const paths = op.params.filter((p) => p.kind === "path").map((p) => "<" + p.name + ">").join(" ");
1917
+ const paths = op.params.filter((p) => p.kind === "path").map((p) => p.credential ? "[<" + p.name + ">]" : "<" + p.name + ">").join(" ");
1838
1918
  return BIN + " " + op.command[0] + " " + op.command[1] + (paths ? " " + paths : "");
1839
1919
  }
1840
1920
 
@@ -1882,7 +1962,7 @@ function helpSentence(description: string | undefined): string {
1882
1962
  /** Type column text for a param: string, number, string[], a|b|c, enum, object, json, path. */
1883
1963
  function typeLabel(p: ParamSpec): string {
1884
1964
  if (p.nullable) return typeLabel({ ...p, nullable: false }) + "|null";
1885
- if (p.type === "file") return "path (uploaded)";
1965
+ if (p.type === "file") return p.multiple ? "paths (uploaded, repeatable)" : "path (uploaded)";
1886
1966
  if (p.format && p.type === "string") return p.format;
1887
1967
  const inlineEnum = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : undefined;
1888
1968
  if (p.type === "array") {
@@ -1957,7 +2037,7 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
1957
2037
  }
1958
2038
  const width = termWidth();
1959
2039
  const lines: string[] = [];
1960
- lines.push(paintOut("bold", BIN) + ": " + "typeship API" + " (v" + "1.0.0" + "), package " + "0.22.0");
2040
+ lines.push(paintOut("bold", BIN) + ": " + "Typeship API" + " (v" + "1.0.0" + "), package " + "0.23.1");
1961
2041
  lines.push("");
1962
2042
  lines.push(paintOut("bold", "Usage:") + " " + BIN + " <resource> <command> [args] [--flags]");
1963
2043
  lines.push("");
@@ -1976,18 +2056,18 @@ function printRoot(stream: NodeJS.WriteStream = process.stdout): void {
1976
2056
  }
1977
2057
  if (EXCLUDED_OPS > 0) {
1978
2058
  lines.push("");
1979
- lines.push(...labeled(paintOut("yellow", "Plan limit:") + " ", "generated " + OPS.length + " of " + (OPS.length + EXCLUDED_OPS) + " operations", width, 14));
1980
- lines.push(...labeled("Omitted: ", OMITTED_OPS.map((op) => op.command.join(" ") + " (" + op.httpMethod + " " + op.path + ")").join(", "), width, 14));
1981
- lines.push(...labeled("Upgrade: ", "https://typeship.dev/pricing, then regenerate without the operation cap", width, 14));
2059
+ lines.push(...labeled(paintOut("yellow", "Coverage:") + " ", "this build includes " + OPS.length + " of " + (OPS.length + EXCLUDED_OPS) + " operations; api.json lists the rest", width, 14));
1982
2060
  }
1983
2061
  lines.push("");
1984
- 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 parameters and JSON bodies)" +
2062
+ const flagsText = "-v/--version, -h/--help, --debug, --non-interactive, --color on|off|auto, --base-url <url>, --profile <name>, --credentials @<file>|-, --header \"Name: value\", --timeout <seconds>, --data '<json>', --fields <a,b.c>, --all (paginated lists), --validate (schema-check parameters and JSON bodies)" +
1985
2063
  (AUTH_SCALARS.length > 0 ? ", " + AUTH_SCALARS.map((a) => "--" + a.flag + " <value>").join(", ") : "");
1986
2064
  lines.push(...labeled(paintOut("bold", "Global flags:") + " ", flagsText, width, 14).map((l, i) => (i === 0 ? l : l)));
1987
2065
  lines.push(...labeled("Credential env vars: ", [
1988
2066
  "TYPESHIP_CREDENTIALS", ...AUTH_SCALARS.map((a) => a.env),
1989
2067
  ...(BASIC ? [BASIC.envUser, BASIC.envPass] : []),
1990
2068
  ].join(", ") || "none", width, 21));
2069
+ if (AUTH_UNDECLARED) lines.push(...labeled("Auth: ", "not declared by the API Spec; " + AUTH_SCALARS[0]!.env + " is sent as Authorization: Bearer when set", width, 6));
2070
+ lines.push(...labeled("Extra headers: ", "--header \"Name: value\" (repeatable) or TYPESHIP_HEADERS", width, 15));
1991
2071
  lines.push(...labeled("Endpoint env var: ", "TYPESHIP_BASE_URL", width, 18));
1992
2072
  lines.push(...labeled("Sign-in: ", BIN + " login | logout | whoami | auth check (stored at " + credsPath() + ")", width, 9));
1993
2073
  lines.push(...labeled("Setup: ", BIN + " init (connect this machine)" + " | " + BIN + " config (defaults)" + (HAS_MCP || MCP_URL ? " | " + BIN + " mcp install --all (agent clients)" : "") + " | " + BIN + " doctor | " + BIN + " upgrade | " + BIN + " completion <shell>", width, 7));
@@ -2030,6 +2110,7 @@ function commandExtras(op: OpSpec): [string, string][] {
2030
2110
  const extras: [string, string][] = [];
2031
2111
  if (op.auth !== "none" && Object.keys(NAMED_SCHEMES).length) extras.push(["--credentials @<file>|-", "named credentials as JSON; use - for stdin"]);
2032
2112
  if (op.hasBody && op.bodyKind === "binary") extras.push(["--file <path>", "raw request body, uploaded as-is (- reads stdin)"]);
2113
+ if (op.rawResponse) extras.push(["--output <file>", "write the " + (op.rawResponse === "binary" ? "binary " : "") + "response body to a file (- for stdout) and print what was written"]);
2033
2114
  else if (op.hasBody) extras.push(["--data '<json>'", "raw JSON body" + (op.bodyStyle === "fields" ? " (merged under field flags)" : "") + "; @<file> reads a file, - reads stdin"]);
2034
2115
  if (op.select) extras.push(["--select '<selection>'", "GraphQL selection set replacing the default, e.g. '{ id name }'"]);
2035
2116
  if (op.paginated) extras.push(["--all", "stream every item from every page (NDJSON)"]);
@@ -2044,6 +2125,7 @@ function commandExtras(op: OpSpec): [string, string][] {
2044
2125
  function exampleLine(op: OpSpec): string {
2045
2126
  const parts = [BIN, op.command[0], op.command[1]];
2046
2127
  for (const p of op.params) {
2128
+ if (p.credential) continue; // defaulted from the configured credential
2047
2129
  const hasExample = p.type !== "file" && Object.hasOwn(op.exampleArguments, p.name);
2048
2130
  if (!p.required && !hasExample) continue;
2049
2131
  const generated = p.type === "file" ? undefined : op.exampleArguments[p.name];
@@ -2074,6 +2156,7 @@ function exampleLine(op: OpSpec): string {
2074
2156
 
2075
2157
  /** " (no auth needed)" for an anonymous operation in an API that otherwise authenticates. */
2076
2158
  function authNote(op: OpSpec): string {
2159
+ if (AUTH_UNDECLARED) return op.auth === "none" ? " (no auth needed)" : " (auth not declared)";
2077
2160
  const apiHasAuth = AUTH_SCALARS.length > 0 || BASIC !== null || HAS_OAUTH_LOGIN;
2078
2161
  return apiHasAuth && op.auth === "none" ? " (no auth needed)" : apiHasAuth && op.auth === "optional" ? " (auth optional)" : "";
2079
2162
  }
@@ -2083,6 +2166,8 @@ function printOp(op: OpSpec): void {
2083
2166
  lines.push(paintOut("bold", usageLine(op)));
2084
2167
  if (op.summary) lines.push(helpSentence(op.summary));
2085
2168
  lines.push(wireOf(op) + authNote(op));
2169
+ const scopes = requiredScopes(op.security);
2170
+ if (scopes.length) lines.push("Requires OAuth scopes: " + scopes.join(", ") + (HAS_OAUTH_LOGIN ? " (login --scopes " + scopes.join(",") + ")" : ""));
2086
2171
  lines.push("");
2087
2172
  const rows = op.params.filter((p) => p.kind !== "path");
2088
2173
  const extras = commandExtras(op);
@@ -2135,7 +2220,7 @@ function readStdinBytes(): Promise<Uint8Array<ArrayBuffer>> {
2135
2220
  /** A local file as an upload part; the SDK's multipart encoder takes Blobs. */
2136
2221
  function fileFromPath(flag: string, path: string): File {
2137
2222
  try {
2138
- return new File([readFileSync(path)], basename(path));
2223
+ return new File([readFileSync(path)], basename(path), { type: mediaTypeForPath(path) });
2139
2224
  } catch (e) {
2140
2225
  return fail(2, "--" + flag + ": cannot read " + path + " (" + (e as Error).message + ")");
2141
2226
  }
@@ -2182,6 +2267,7 @@ function coerce(spec: ParamSpec, raw: string | boolean, repeated?: string[]): un
2182
2267
  if (spec.nullable && raw === "null" && repeated === undefined) return null;
2183
2268
  if (spec.type === "file") {
2184
2269
  if (raw === true) fail(2, "--" + spec.flag + " expects a file path");
2270
+ if (spec.multiple) return (repeated ?? [String(raw)]).map((path) => fileFromPath(spec.flag, path));
2185
2271
  return fileFromPath(spec.flag, String(raw));
2186
2272
  }
2187
2273
  if (spec.type === "boolean") {
@@ -2252,18 +2338,24 @@ function validateParameters(op: OpSpec, values: Record<string, unknown>, flags:
2252
2338
 
2253
2339
  /** Whether the last client built carried any credential; failApi tells NO_AUTH from AUTH_INVALID with it. */
2254
2340
  let LAST_CLIENT_HAD_CREDENTIAL = false;
2341
+ /** The last client's Basic credentials, for path arguments that default to the username. */
2342
+ let LAST_CLIENT_BASIC: { basicAuth?: unknown; credentials?: unknown } = {};
2343
+
2344
+ /** The configured Basic-auth username: the named scheme's, else basicAuth's. */
2345
+ function credentialUsername(scheme: string): string | undefined {
2346
+ const named = (LAST_CLIENT_BASIC.credentials as Record<string, unknown> | undefined)?.[scheme];
2347
+ const username = named && typeof named === "object" ? (named as { username?: unknown }).username
2348
+ : (LAST_CLIENT_BASIC.basicAuth as { username?: unknown } | undefined)?.username;
2349
+ return typeof username === "string" && username !== "" ? username : undefined;
2350
+ }
2255
2351
 
2256
2352
  /** Check the same complete alternatives the request runtime can select, after
2257
2353
  * per-scheme flag, environment, profile, and OAuth resolution. */
2258
2354
  function requireOperationCredentials(op: OpSpec, options: ClientOptions & Record<string, unknown>): void {
2259
2355
  if (op.auth !== "required") return;
2260
- const supplied = (value: unknown): boolean => typeof value === "function" || (typeof value === "string" && value.length > 0)
2261
- || (value !== null && typeof value === "object" && "username" in value && "password" in value && typeof value.username === "string" && value.username.length > 0 && typeof value.password === "string" && value.password.length > 0);
2262
- const named = Object.fromEntries(Object.entries(options.credentials ?? {}).filter(([, value]) => supplied(value))) as NamedCredentials;
2263
- const available = namedCredentialAvailability(NAMED_SCHEMES, new Set(Object.keys(options).filter((key) => supplied(options[key]))), named);
2264
- if (op.credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => available.has(option)))) return;
2265
- const alternatives = (op.credentialOptions ?? []).filter((alternative) => alternative.length > 0 && alternative.every((option) => option.startsWith("credentials.")));
2266
- const missing = alternatives.map((alternative) => alternative.filter((option) => !available.has(option)).map((option) => option.slice("credentials.".length)));
2356
+ const gap = missingCredentials(NAMED_SCHEMES, op.credentialOptions, options);
2357
+ if (!gap) return;
2358
+ const { alternatives, missing } = gap;
2267
2359
  const names = new Set(missing.flat());
2268
2360
  const relevant = AUTH_SCALARS.filter((scalar) => [...names].some((name) => NAMED_SCHEMES[name]?.options.includes(scalar.option)));
2269
2361
  const needsBasic = [...names].some((name) => NAMED_SCHEMES[name]?.options.includes("basicAuth"));
@@ -2276,7 +2368,8 @@ function requireOperationCredentials(op: OpSpec, options: ClientOptions & Record
2276
2368
  nextSteps: alternatives.length ? [
2277
2369
  ...relevant.map((a) => "Set " + a.env + " in the environment, pass --" + a.flag + " <value>, or run '" + BIN + " login'."),
2278
2370
  ...(needsBasic && BASIC ? ["Set " + BASIC.envUser + " and " + BASIC.envPass + ", or pass --username and --password."] : []),
2279
- "Supply all schemes in one alternative through " + "TYPESHIP_CREDENTIALS" + " or --credentials @<JSON-file>: " + alternatives.map((alternative) => alternative.map((option) => option.slice("credentials.".length)).join(" + ")).join(" OR ") + ".",
2371
+ ...(HAS_OAUTH_LOGIN && [...names].some((name) => OAUTH_SESSION_SCHEMES.includes(name)) ? ["Sign in with OAuth: '" + BIN + " login'."] : []),
2372
+ "Supply all schemes in one alternative through " + "TYPESHIP_CREDENTIALS" + " or --credentials @<JSON-file>: " + alternatives.map((alternative) => alternative.join(" + ")).join(" OR ") + ".",
2280
2373
  ] : ["Check the operation's security schemes in the API Spec and regenerate with a supported, compatible alternative."],
2281
2374
  });
2282
2375
  }
@@ -2307,6 +2400,23 @@ function environmentCredentials(): NamedCredentials {
2307
2400
  return value === undefined ? {} : parseNamedCredentials(value, NAMED_SCHEMES);
2308
2401
  }
2309
2402
 
2403
+ /** --timeout <seconds> or TYPESHIP_TIMEOUT: the per-attempt deadline
2404
+ * (default 60 seconds) for slow operations. */
2405
+ function requestTimeoutMs(flags: Map<string, string | boolean>): number | undefined {
2406
+ const raw = typeof flags.get("timeout") === "string" ? flags.get("timeout") as string : process.env["TYPESHIP_TIMEOUT"];
2407
+ if (raw === undefined) return undefined;
2408
+ const seconds = Number(raw);
2409
+ if (!/^\d+(\.\d+)?$/.test(raw.trim()) || !Number.isFinite(seconds) || seconds <= 0 || seconds > 3600) fail(2, "--timeout expects seconds between 0 and 3600, such as --timeout 120.");
2410
+ return Math.round(seconds * 1000);
2411
+ }
2412
+
2413
+ /** --header flags and TYPESHIP_HEADERS: sent on every API request, after
2414
+ * (and in place of) any generated header of the same name. */
2415
+ function extraRequestHeaders(): Record<string, string> {
2416
+ try { return parseExtraHeaders(process.env["TYPESHIP_HEADERS"], HEADER_FLAGS, "TYPESHIP_HEADERS"); }
2417
+ catch (error) { fail(2, (error as Error).message); }
2418
+ }
2419
+
2310
2420
  /** Where a credential would come from, without sending it: "flags", "env:<VAR>", "login", or null. */
2311
2421
  function credentialSource(flags: Map<string, string | boolean>): string | null {
2312
2422
  if (Object.keys(flagCredentials(flags)).length) return "flags";
@@ -2330,7 +2440,29 @@ function resolveBaseUrl(flags: Map<string, string | boolean>, config = readConfi
2330
2440
  ?? DEFAULT_BASE_URL ?? undefined;
2331
2441
  }
2332
2442
 
2443
+ /** OAuth schemes the login session authenticates by name; empty when the
2444
+ * session is the convenience bearer token (see oauthSessionSchemes). */
2445
+ const OAUTH_SESSION_SCHEMES = oauthSessionSchemes(NAMED_SCHEMES);
2446
+
2447
+ /** Send a login session to the OAuth scheme, never to a separate http bearer
2448
+ * scheme. Explicit credentials for the same scheme keep precedence. */
2449
+ function applyOAuthSession(options: ClientOptions & Record<string, unknown>, token: string | (() => Promise<string>), replace = false): void {
2450
+ if (!OAUTH_SESSION_SCHEMES.length) {
2451
+ if (replace || options.bearerToken === undefined) options.bearerToken = token;
2452
+ return;
2453
+ }
2454
+ const credentials = (options.credentials ??= {}) as Record<string, unknown>;
2455
+ for (const name of OAUTH_SESSION_SCHEMES) if (replace || !Object.hasOwn(credentials, name)) credentials[name] = token;
2456
+ }
2457
+ function withOAuthSession(options: ClientOptions & Record<string, unknown>, accessToken: string): ClientOptions & Record<string, unknown> {
2458
+ const next: ClientOptions & Record<string, unknown> = { ...options, credentials: { ...(options.credentials as Record<string, unknown>) } as ClientOptions["credentials"] };
2459
+ applyOAuthSession(next, accessToken, true);
2460
+ if (!OAUTH_SESSION_SCHEMES.length) next.credentials = { ...(next.credentials as Record<string, unknown>), ...resolveNamedCredentials(NAMED_SCHEMES, [{ options: { bearerToken: accessToken } }]) } as ClientOptions["credentials"];
2461
+ return next;
2462
+ }
2463
+
2333
2464
  async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, candidate?: StoredCreds, forIdentity = false): Promise<TypeshipClient> {
2465
+ CURRENT_SCOPES = requiredScopes(op.security);
2334
2466
  const flagNamed = flagCredentials(flags), envNamed = environmentCredentials();
2335
2467
  const explicitOptions = new Set(AUTH_SCALARS.filter((a) => typeof flags.get(a.flag) === "string" || process.env[a.env] !== undefined).map((a) => a.option));
2336
2468
  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");
@@ -2369,25 +2501,29 @@ async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, cand
2369
2501
  options.credentials = resolveNamedCredentials(NAMED_SCHEMES, [
2370
2502
  { named: stored?.named, options: { ...stored?.scalars, ...(stored?.basic ? { basicAuth: stored.basic } : {}) } },
2371
2503
  { named: envNamed, options: envOptions }, { named: flagNamed, options: flagOptions },
2372
- ...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
2504
+ ...(forIdentity && candidate ? [{ named: candidate.named, options: { ...candidate.scalars, ...(candidate.basic ? { basicAuth: candidate.basic } : {}), ...(candidate.oauth && !OAUTH_SESSION_SCHEMES.length ? { bearerToken: candidate.oauth.accessToken } : {}) } }] : []),
2373
2505
  ]);
2374
- if (stored?.oauth && (forIdentity || options.bearerToken === undefined)) {
2506
+ if (stored?.oauth) {
2375
2507
  const sessionId = stored.oauth.sessionId;
2376
- options.bearerToken = forIdentity ? stored.oauth.accessToken : () => oauthSessionToken(credentialStore(), {
2508
+ const token = forIdentity ? stored.oauth.accessToken : sessionCredential((rejected) => oauthSessionToken(credentialStore(), {
2377
2509
  ...sessionConfiguration(baseUrl, config),
2378
- ...(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) } : {}),
2379
- }, sessionId, OAUTH_TOKEN_PARAMS);
2510
+ ...(identityConfiguration() && WHOAMI ? { verifyIdentity: (accessToken: string) => verifyClientIdentity((values) => new TypeshipClient(values as unknown as ClientOptions), withOAuthSession(options, accessToken), WHOAMI!, IDENTITY_POLICY) } : {}),
2511
+ }, sessionId, OAUTH_TOKEN_PARAMS, rejected));
2512
+ applyOAuthSession(options, token, forIdentity);
2380
2513
  }
2381
2514
  if (flags.get("debug") === true || process.env["TYPESHIP_DEBUG"] === "1") {
2382
2515
  options.debug = (event: DebugEvent) => process.stderr.write(paintErr("dim", formatDebugEvent(BIN, event)) + "\n");
2383
2516
  }
2384
2517
  if (flags.get("validate") === true) options.validate = true;
2518
+ const timeout = requestTimeoutMs(flags);
2519
+ if (timeout !== undefined) options.timeoutMs = timeout;
2385
2520
  for (const g of GLOBALS) {
2386
2521
  const flagValue = flags.get(g.flag);
2387
2522
  const value = typeof flagValue === "string" ? flagValue : process.env["TYPESHIP_" + g.envSuffix];
2388
2523
  if (value !== undefined) options[g.option] = value;
2389
2524
  }
2390
2525
  LAST_CLIENT_HAD_CREDENTIAL = Object.keys(options.credentials ?? {}).length > 0 || AUTH_SCALARS.some((a) => options[a.option] !== undefined) || options.basicAuth !== undefined || options.bearerToken !== undefined;
2526
+ LAST_CLIENT_BASIC = { basicAuth: options.basicAuth, credentials: options.credentials };
2391
2527
  requireOperationCredentials(op, options);
2392
2528
  // Identify the package and version. Optional harness and caller details
2393
2529
  // let the API distinguish agent traffic from other non-interactive use.
@@ -2401,6 +2537,8 @@ async function makeClient(flags: Map<string, string | boolean>, op: OpSpec, cand
2401
2537
  "User-Agent": PKG_NAME + "-cli/" + VERSION + (details ? " (" + details + ")" : ""),
2402
2538
  };
2403
2539
  if (forIdentity) { options.fetch = identityFetch(baseUrl); options.maxRetries = 0; options.timeoutMs = 10_000; }
2540
+ const extraHeaders = extraRequestHeaders();
2541
+ if (Object.keys(extraHeaders).length) options.onRequest = (context) => { applyExtraHeaders(context.headers, extraHeaders); };
2404
2542
  return new TypeshipClient(options);
2405
2543
  }
2406
2544
 
@@ -2438,9 +2576,9 @@ function failOmitted(op: OmittedOpSpec): never {
2438
2576
  return failWith({
2439
2577
  status: "action_required",
2440
2578
  code: "PLAN_LIMIT",
2441
- message: "The command '" + BIN + " " + op.command.join(" ") + "' exists in the API Spec but was omitted from this generated package by its plan limit.",
2442
- detail: { operation: op.tool, method: op.httpMethod, path: op.path, generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS },
2443
- 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."],
2579
+ message: "The command '" + BIN + " " + op.command.join(" ") + "' is in the API but not in this package, which was generated with " + OPS.length + " of its " + (OPS.length + EXCLUDED_OPS) + " operations.",
2580
+ detail: { operation: op.tool, ...(op.graphql ? { graphql: op.graphql.kind + " " + op.graphql.field } : { method: op.httpMethod, path: op.path }), generated_operations: OPS.length, total_operations: OPS.length + EXCLUDED_OPS },
2581
+ nextSteps: ["The package's publisher can regenerate it with every operation.", "Do not invent or retry an omitted command against this package."],
2444
2582
  });
2445
2583
  }
2446
2584
 
@@ -2449,6 +2587,9 @@ const BUILTIN_COMMANDS = ["login","logout","whoami","config","mcp","docs","upgra
2449
2587
  async function main(): Promise<void> {
2450
2588
  const argv = process.argv.slice(2);
2451
2589
  const parsed = parseArgv(argv);
2590
+ const headerFlag = parsed.flags.get("header");
2591
+ HEADER_FLAGS = parsed.repeated.get("header") ?? (typeof headerFlag === "string" ? [headerFlag] : []);
2592
+ if (headerFlag === true) fail(2, "--header expects \"Name: value\".");
2452
2593
  if (!parsed.help) validateCredentialsInput(parsed.flags);
2453
2594
  const profileFlag = parsed.flags.get("profile");
2454
2595
  if (profileFlag !== undefined && typeof profileFlag !== "string") fail(2, "--profile requires a profile name.");
@@ -2466,7 +2607,7 @@ async function main(): Promise<void> {
2466
2607
  // "acme 1.0.0 (acme 1.0.0)" would say the name twice; when the API's
2467
2608
  // title is the bin, name the API version as such.
2468
2609
  const apiLabel = API_TITLE.toLowerCase().replace(/[^a-z0-9]/g, "") === BIN.toLowerCase().replace(/[^a-z0-9]/g, "") ? "API " + API_VERSION : API_TITLE + " " + API_VERSION;
2469
- process.stdout.write(BIN + " " + VERSION + " (" + apiLabel + ", generated by typeship)\n");
2610
+ process.stdout.write(BIN + " " + VERSION + " (" + apiLabel + ", generated by Typeship)\n");
2470
2611
  await flushExit(0);
2471
2612
  }
2472
2613
  const [resourceCmd, methodCmd] = parsed.positionals;
@@ -2531,12 +2672,18 @@ async function main(): Promise<void> {
2531
2672
 
2532
2673
  const pathSpecs = op.params.filter((p) => p.kind === "path");
2533
2674
  const pathValues = parsed.positionals.slice(2);
2534
- if (pathValues.length !== pathSpecs.length) {
2535
- fail(2, "Expected " + pathSpecs.length + " argument(s): " + usageLine(op));
2675
+ // A path argument that is the Basic-auth username (Twilio's AccountSid)
2676
+ // may be left out; it defaults to the configured credential below.
2677
+ const pathGiven = pathValues.length === pathSpecs.length ? pathSpecs
2678
+ : pathValues.length === pathSpecs.filter((p) => !p.credential).length ? pathSpecs.filter((p) => !p.credential)
2679
+ : null;
2680
+ if (!pathGiven) {
2681
+ const optional = pathSpecs.filter((p) => p.credential).length;
2682
+ fail(2, "Expected " + (optional ? (pathSpecs.length - optional) + " or " : "") + pathSpecs.length + " argument(s): " + usageLine(op));
2536
2683
  }
2537
2684
 
2538
2685
  const values: Record<string, unknown> = {};
2539
- pathSpecs.forEach((spec, i) => { values[spec.name] = pathValues[i]; });
2686
+ pathGiven!.forEach((spec, i) => { values[spec.name] = pathValues[i]; });
2540
2687
 
2541
2688
  let dataBody: unknown;
2542
2689
  const dataRaw = parsed.flags.get("data");
@@ -2557,7 +2704,7 @@ async function main(): Promise<void> {
2557
2704
  // Mirrors opReservedFlags() in the generator: API parameters never use these
2558
2705
  // names (colliding ones are emitted as --<kind>-<name>), so an unknown flag
2559
2706
  // check can be exact.
2560
- 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)]);
2707
+ const RESERVED_FLAGS = new Set(["data", "credentials", "header", "timeout", "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)]);
2561
2708
  for (const spec of op.params) {
2562
2709
  if (spec.kind === "path") continue;
2563
2710
  const raw = parsed.flags.get(spec.flag);
@@ -2566,7 +2713,7 @@ async function main(): Promise<void> {
2566
2713
  // every value (each coerced to the element type); loosely typed (json)
2567
2714
  // params become an array of the parsed values.
2568
2715
  const all = parsed.repeated.get(spec.flag);
2569
- values[spec.name] = spec.type === "array"
2716
+ values[spec.name] = spec.type === "array" || (spec.type === "file" && spec.multiple)
2570
2717
  ? coerce(spec, raw, all)
2571
2718
  : all !== undefined && spec.type === "json"
2572
2719
  ? all.map((v) => coerce(spec, v))
@@ -2575,12 +2722,39 @@ async function main(): Promise<void> {
2575
2722
  for (const key of parsed.flags.keys()) {
2576
2723
  if (RESERVED_FLAGS.has(key)) continue;
2577
2724
  if (key === "file" && op.bodyKind === "binary") continue;
2725
+ if (key === "output" && op.rawResponse) continue;
2578
2726
  if (!op.params.some((p) => p.flag === key)) {
2579
2727
  const suggestion = didYouMean(key, [...op.params.filter((p) => p.kind !== "path").map((p) => p.flag), ...RESERVED_FLAGS]);
2580
2728
  fail(2, "Unknown flag --" + key + "." + (suggestion ? " Did you mean --" + suggestion + "?" : ""));
2581
2729
  }
2582
2730
  }
2583
2731
 
2732
+ // Object and array values (a GraphQL input, a JSON flag, --data fields)
2733
+ // get the checks the MCP server applies: nested types, enums, required
2734
+ // properties, patterns and a closed object's unknown keys, all at once.
2735
+ const argumentSchemas = (op.inputSchema.properties ?? {}) as Record<string, Record<string, unknown>>;
2736
+ const nestedIssues: ArgumentIssue[] = [];
2737
+ const checkNested = (name: string, value: unknown): unknown =>
2738
+ value !== null && typeof value === "object" && !(value instanceof Blob) && argumentSchemas[name]
2739
+ ? checkValue(value, argumentSchemas[name]!, name, nestedIssues) : value;
2740
+ for (const spec of op.params) {
2741
+ if (spec.kind !== "path" && spec.type !== "file" && values[spec.name] !== undefined) values[spec.name] = checkNested(spec.name, values[spec.name]);
2742
+ }
2743
+ if (op.bodyStyle === "fields" && dataBody !== null && typeof dataBody === "object" && !Array.isArray(dataBody) && !(dataBody instanceof Blob)) {
2744
+ const body = dataBody as Record<string, unknown>;
2745
+ for (const name of Object.keys(body)) {
2746
+ if (op.params.some((p) => p.kind === "body" && p.name === name && p.type !== "file")) body[name] = checkNested(name, body[name]);
2747
+ }
2748
+ }
2749
+ if (nestedIssues.length > 0) {
2750
+ failWith({
2751
+ code: nestedIssues.every((issue) => issue.code === "MISSING_ARGUMENT") ? "MISSING_ARGUMENT" : "INVALID_USAGE",
2752
+ message: nestedIssues.length + (nestedIssues.length === 1 ? " problem" : " problems") + " in the arguments; nothing was sent: " + nestedIssues.map((issue) => issue.message).join("; "),
2753
+ detail: { issues: nestedIssues },
2754
+ nextSteps: ["Fix the values listed in detail.issues and run again.", "Run '" + USAGE_HINT + "' for each argument's type."],
2755
+ });
2756
+ }
2757
+
2584
2758
  const missing = missingRequired(op, values).filter((name) =>
2585
2759
  !(op.bodyStyle === "fields" && dataBody !== undefined && typeof dataBody === "object" && dataBody !== null && name in (dataBody as object)),
2586
2760
  );
@@ -2600,6 +2774,7 @@ async function main(): Promise<void> {
2600
2774
  if (typeof fieldsRaw === "string") {
2601
2775
  FIELDS = fieldsRaw.split(",").map((f) => f.trim()).filter((f) => f !== "").map((f) => f.split("."));
2602
2776
  if (FIELDS.length === 0) fail(2, "--fields expects at least one field path");
2777
+ FIELDS_AFTER_WRITE = op.safety !== "read";
2603
2778
  }
2604
2779
 
2605
2780
  // --out <dir> materializes a file-shaped response (see cli-agent.ts bundleProperty).
@@ -2611,13 +2786,42 @@ async function main(): Promise<void> {
2611
2786
  }
2612
2787
 
2613
2788
  validateParameters(op, values, parsed.flags);
2789
+ // Raw bytes on a terminal are unreadable and can garble it: ask for a
2790
+ // destination before the request runs (it may be billed, like speech).
2791
+ const outputFlag = op.rawResponse ? parsed.flags.get("output") : undefined;
2792
+ if (outputFlag === true) fail(2, "--output expects a file path, or - for stdout.");
2793
+ if (op.rawResponse === "binary" && outputFlag === undefined && process.stdout.isTTY) {
2794
+ fail(2, op.command.join(" ") + " returns binary data. Pass --output <file>, or redirect stdout to a file.");
2795
+ }
2796
+ // --all on a list the generator could not page would print one page and
2797
+ // exit 0, which reads as "that is everything".
2798
+ if (!op.paginated && parsed.flags.get("all") === true) {
2799
+ fail(2, op.command.join(" ") + " does not paginate, so --all has nothing to walk. Run it without --all; it returns the whole response.");
2800
+ }
2614
2801
  const client = await makeClient(parsed.flags, op);
2802
+ for (const spec of pathSpecs) {
2803
+ if (!spec.credential || values[spec.name] !== undefined) continue;
2804
+ const username = credentialUsername(spec.credential.scheme);
2805
+ if (username === undefined) {
2806
+ fail(2, "Missing required: <" + spec.name + ">. It defaults to the Basic-auth username (" + spec.credential.env + "), and none is configured.", undefined,
2807
+ ["Pass " + spec.name + " as an argument: " + usageLine(op) + ".", "Or set " + spec.credential.env + (BASIC ? " and " + BASIC.envPass : "") + ", or run '" + BIN + " login'."]);
2808
+ }
2809
+ values[spec.name] = username;
2810
+ }
2615
2811
 
2616
2812
  // Destructive commands need --force. A person gets asked; an agent gets
2617
2813
  // an action_required envelope with the exact command to run, so nothing
2618
2814
  // is deleted on a guess.
2619
2815
  if (op.safety === "destructive" && !assumeYes(parsed)) {
2620
- const rerun = BIN + " " + process.argv.slice(2).map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ") + " --force";
2816
+ // Credential flags are replaced by placeholders: the rerun is shown to
2817
+ // agents and logged, so it must never repeat a key.
2818
+ const secretFlags = new Set([...AUTH_SCALARS.map((a) => "--" + a.flag), "--header"]);
2819
+ const rerun = BIN + " " + process.argv.slice(2).map((a, i, all) => {
2820
+ const eq = a.indexOf("=");
2821
+ if (a.startsWith("--") && eq > 0 && secretFlags.has(a.slice(0, eq))) return a.slice(0, eq) + "=<" + a.slice(2, eq) + ">";
2822
+ if (i > 0 && secretFlags.has(all[i - 1]!)) return "<" + all[i - 1]!.slice(2) + ">";
2823
+ return /\s/.test(a) ? JSON.stringify(a) : a;
2824
+ }).join(" ") + " --force";
2621
2825
  if (nonInteractive(parsed) || !process.stdin.isTTY) {
2622
2826
  failWith({
2623
2827
  status: "action_required",
@@ -2634,20 +2838,29 @@ async function main(): Promise<void> {
2634
2838
  const selectValue = typeof parsed.flags.get("select") === "string" ? (parsed.flags.get("select") as string) : undefined;
2635
2839
  const args = buildArgs(op, values, dataBody, selectValue);
2636
2840
  const target = (client as unknown as Record<string, Record<string, (...a: unknown[]) => unknown>>)[op.resource]!;
2637
- const callResult = target[op.method]!(...args);
2841
+ // --stream true on an operation with a streaming twin prints its events
2842
+ // as NDJSON as they arrive, instead of failing on the event stream.
2843
+ const streaming = op.streamMethod !== undefined && values[op.streamMethod.flag] === op.streamMethod.value;
2844
+ const callResult = target[streaming ? op.streamMethod!.method : op.method]!(...args);
2638
2845
 
2639
2846
  if (op.paginated && parsed.flags.get("all") === true) {
2847
+ const fieldsCheck = streamFieldsCheck();
2640
2848
  try {
2641
2849
  for await (const item of callResult as AsyncIterable<unknown>) {
2642
- process.stdout.write(JSON.stringify(project(item)) + "\n");
2850
+ process.stdout.write(JSON.stringify(fieldsCheck.item(item)) + "\n");
2643
2851
  }
2852
+ fieldsCheck.finish();
2644
2853
  await flushExit(0);
2645
2854
  } catch (e) {
2855
+ if (isNotModified(e)) await printNotModified(e);
2646
2856
  failApi(e, LAST_CLIENT_HAD_CREDENTIAL);
2647
2857
  }
2648
2858
  }
2649
2859
 
2650
2860
  let result = await asApiResult(callResult as Promise<unknown>);
2861
+ // 304 Not Modified: the conditional request matched, so there is no
2862
+ // body. Say so, with the ETag to send next time.
2863
+ if (!result.ok && isNotModified(result.error)) await printNotModified(result.error);
2651
2864
  if (result.ok && op.httpMethod === "POST" && op.path === "/projects/{project_id}/generate") {
2652
2865
  const batch = result.data as { data: Array<{ id: string }> };
2653
2866
  const generations = (client as unknown as { generations: { wait(id: string): Promise<unknown> } }).generations;
@@ -2660,15 +2873,33 @@ async function main(): Promise<void> {
2660
2873
  result = { ...result, data: { ...batch, data: completed } };
2661
2874
  }
2662
2875
  if (result.ok) {
2663
- if (op.sse) {
2876
+ if (op.sse || streaming) {
2664
2877
  // Server-sent events as NDJSON, one line per event, until the stream ends.
2878
+ const fieldsCheck = streamFieldsCheck();
2665
2879
  try {
2666
2880
  for await (const event of result.data as AsyncIterable<{ event?: string; id?: string; data: string }>) {
2667
- process.stdout.write(JSON.stringify(project(event)) + "\n");
2881
+ process.stdout.write(JSON.stringify(fieldsCheck.item(event)) + "\n");
2668
2882
  }
2669
2883
  } catch (e) {
2670
2884
  failApi(e, LAST_CLIENT_HAD_CREDENTIAL);
2671
2885
  }
2886
+ fieldsCheck.finish();
2887
+ await flushExit(0);
2888
+ }
2889
+ // A non-JSON body (audio, a file, CSV): raw bytes to stdout, or to the
2890
+ // --output file with a JSON note of what was written. Never "{}".
2891
+ if (op.rawResponse || result.data instanceof Blob) {
2892
+ const data = result.data;
2893
+ const bytes = data instanceof Blob
2894
+ ? new Uint8Array(await data.arrayBuffer())
2895
+ : new TextEncoder().encode(typeof data === "string" ? data : JSON.stringify(data ?? ""));
2896
+ if (typeof outputFlag === "string" && outputFlag !== "-") {
2897
+ try { writeFileSync(outputFlag, bytes); } catch (e) { fail(1, "--output: cannot write " + outputFlag + " (" + (e as Error).message + ")"); }
2898
+ const mediaType = data instanceof Blob && data.type ? data.type : op.rawResponse === "text" ? "text/plain" : "application/octet-stream";
2899
+ out({ saved_to: resolvePath(outputFlag), bytes: bytes.length, media_type: mediaType });
2900
+ } else {
2901
+ process.stdout.write(bytes);
2902
+ }
2672
2903
  await flushExit(0);
2673
2904
  }
2674
2905
  // A claim-shaped response (claim.url): say where to claim it, and leave
@@ -2687,7 +2918,7 @@ async function main(): Promise<void> {
2687
2918
  const page = result.data as { items: unknown[]; hasNextPage(): boolean; nextPageParams(): Record<string, unknown> | null; response: { requestId?: string } };
2688
2919
  const next = page.nextPageParams();
2689
2920
  out({
2690
- items: project(page.items),
2921
+ items: project(page.items, true),
2691
2922
  hasMore: next !== null,
2692
2923
  ...(next !== null ? { nextPage: next, nextCommand: nextCommandFor(op, pathValues, next) } : {}),
2693
2924
  ...(page.response.requestId ? { request_id: page.response.requestId } : {}),
@@ -2696,7 +2927,7 @@ async function main(): Promise<void> {
2696
2927
  // Batch-style collection envelopes ({data: [...]}) use item-relative
2697
2928
  // fields, matching paginated results, while retaining envelope metadata.
2698
2929
  const data = result.data as Record<string, unknown>;
2699
- out({ ...data, [collectionField]: project(data[collectionField]) });
2930
+ out({ ...data, [collectionField]: project(data[collectionField], true) });
2700
2931
  } else if (outDir !== undefined && bundleField !== null) {
2701
2932
  // --out: a file-shaped response (an array of {path, content}) lands
2702
2933
  // on disk; stdout gets the rest of the response plus a summary.
@@ -2704,9 +2935,9 @@ async function main(): Promise<void> {
2704
2935
  const files = (data[bundleField] as { path: string; content: string }[] | undefined) ?? [];
2705
2936
  const written = writeBundle(outDir, files);
2706
2937
  const { [bundleField]: _omitted, ...rest } = data;
2707
- out({ ...(project(rest) as Record<string, unknown>), out: written });
2938
+ out({ ...(project(rest, false) as Record<string, unknown>), out: written });
2708
2939
  } else {
2709
- out(project(result.data ?? { ok: true }));
2940
+ out(result.data === undefined || result.data === null ? { ok: true } : project(result.data, Array.isArray(result.data)));
2710
2941
  }
2711
2942
  await flushExit(0);
2712
2943
  }
@@ -2724,6 +2955,8 @@ function errorMessage(error: unknown): string {
2724
2955
  main().catch((e) => {
2725
2956
  if (e instanceof ExitPending) return;
2726
2957
  try {
2958
+ const auth = classifyAuthFailure(e, authFailureContext());
2959
+ if (auth) failWith(auth);
2727
2960
  fail(1, errorMessage(e));
2728
2961
  } catch {
2729
2962
  // exit already scheduled