@typeship-ax/cli 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/AGENTS.md +13 -9
  2. package/README.md +14 -27
  3. package/api.json +8898 -9026
  4. package/api.md +370 -328
  5. package/dist/arguments.d.ts +54 -0
  6. package/dist/arguments.d.ts.map +1 -0
  7. package/dist/arguments.js +265 -0
  8. package/dist/cli-agent.d.ts +73 -9
  9. package/dist/cli-agent.d.ts.map +1 -1
  10. package/dist/cli-agent.js +331 -44
  11. package/dist/cli.js +802 -291
  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 +36 -0
  27. package/dist/fields.d.ts.map +1 -0
  28. package/dist/fields.js +187 -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 +58 -5
  45. package/dist/ops.d.ts.map +1 -1
  46. package/dist/ops.js +110 -41
  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 +89 -5
  54. package/dist/resources/deliveries.d.ts.map +1 -1
  55. package/dist/resources/deliveries.js +96 -19
  56. package/dist/resources/drafts.d.ts +16 -16
  57. package/dist/resources/drafts.d.ts.map +1 -1
  58. package/dist/resources/drafts.js +12 -65
  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 +16 -16
  63. package/dist/resources/generations.d.ts.map +1 -1
  64. package/dist/resources/generations.js +23 -47
  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 +22 -17
  75. package/dist/resources/releases.d.ts.map +1 -1
  76. package/dist/resources/releases.js +19 -40
  77. package/dist/resources/spec-revisions.d.ts +16 -7
  78. package/dist/resources/spec-revisions.d.ts.map +1 -1
  79. package/dist/resources/spec-revisions.js +7 -29
  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 +49 -49
  84. package/dist/resources/targets.d.ts.map +1 -1
  85. package/dist/resources/targets.js +59 -115
  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/table.d.ts +28 -0
  92. package/dist/table.d.ts.map +1 -0
  93. package/dist/table.js +167 -0
  94. package/dist/type-docs.d.ts +61 -0
  95. package/dist/type-docs.d.ts.map +1 -0
  96. package/dist/type-docs.js +174 -0
  97. package/dist/types.d.ts +499 -339
  98. package/dist/types.d.ts.map +1 -1
  99. package/dist/types.js +18 -18
  100. package/package.json +5 -2
  101. package/src/arguments.ts +254 -0
  102. package/src/cli-agent.ts +351 -46
  103. package/src/cli.ts +753 -266
  104. package/src/core/http.ts +457 -58
  105. package/src/core/pagination.ts +129 -18
  106. package/src/credential-storage.ts +16 -6
  107. package/src/dates.ts +1 -1
  108. package/src/errors.ts +46 -115
  109. package/src/fields.ts +167 -0
  110. package/src/index.ts +45 -28
  111. package/src/named-credentials.ts +66 -1
  112. package/src/oauth-login.ts +36 -21
  113. package/src/oauth-request.ts +32 -6
  114. package/src/oauth-session.ts +37 -19
  115. package/src/ops.ts +146 -45
  116. package/src/polling-login.ts +24 -11
  117. package/src/resources/api-keys.ts +34 -48
  118. package/src/resources/deliveries.ts +213 -32
  119. package/src/resources/drafts.ts +62 -109
  120. package/src/resources/files.ts +19 -20
  121. package/src/resources/generations.ts +61 -79
  122. package/src/resources/organization.ts +11 -16
  123. package/src/resources/{generate.ts → packages.ts} +43 -51
  124. package/src/resources/projects.ts +145 -200
  125. package/src/resources/releases.ts +50 -67
  126. package/src/resources/spec-revisions.ts +40 -49
  127. package/src/resources/specs.ts +39 -59
  128. package/src/resources/targets.ts +144 -194
  129. package/src/schemas.ts +83 -81
  130. package/src/search.ts +434 -0
  131. package/src/table.ts +167 -0
  132. package/src/type-docs.ts +205 -0
  133. package/src/types.ts +538 -357
  134. package/dist/console-login-check.d.ts +0 -21
  135. package/dist/console-login-check.d.ts.map +0 -1
  136. package/dist/console-login-check.js +0 -107
  137. package/dist/console-login-contract.d.ts +0 -45
  138. package/dist/console-login-contract.d.ts.map +0 -1
  139. package/dist/console-login-contract.js +0 -40
  140. package/dist/resources/generate.d.ts.map +0 -1
  141. package/dist/resources/publications.d.ts +0 -47
  142. package/dist/resources/publications.d.ts.map +0 -1
  143. package/dist/resources/publications.js +0 -70
  144. package/src/console-login-check.ts +0 -88
  145. package/src/console-login-contract.ts +0 -65
  146. package/src/resources/publications.ts +0 -140
package/src/cli-agent.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The agent contract of a generated CLI. Generated by typeship — https://typeship.dev
2
+ * The agent contract of a generated CLI. Generated by Typeship — https://typeship.dev
3
3
  *
4
4
  * Everything a coding agent needs from a command-line tool and a human does
5
5
  * not: a JSON error envelope with stable codes and next steps, agent-mode
@@ -21,20 +21,25 @@ import { dirname, join, resolve } from "node:path";
21
21
  export type IssueCode =
22
22
  | "NO_AUTH" // no credential resolved and the API said 401
23
23
  | "AUTH_INVALID" // a credential was sent and the API said 401/403
24
+ | "INSUFFICIENT_SCOPE" // 403 (or missing_scope) on an operation that requires OAuth scopes
24
25
  | "PLAN_LIMIT" // 402: the account's plan stops here
25
26
  | "NOT_FOUND" // 404
26
27
  | "INVALID_REQUEST" // 400/422 the API rejected the input
27
- | "SPEC_INVALID" // 422 whose error code is spec_error
28
+ | "SPEC_INVALID" // 422 whose error code is spec_invalid (spec_error before the rename)
28
29
  | "RATE_LIMITED" // 429
29
30
  | "SERVER_ERROR" // 5xx
30
31
  | "NETWORK_ERROR" // no response: DNS, TLS, timeout, refused
31
32
  | "VALIDATION_FAILED" // --validate found parameters or a body that do not match the schema
32
33
  | "TTY_REQUIRED" // a prompt was needed and there is no terminal
34
+ | "LOGIN_FAILED" // browser, device or approval login did not complete
35
+ | "LOGIN_REQUIRED" // a saved login cannot be used as is; sign in again
36
+ | "CREDENTIAL_STORE_UNAVAILABLE" // the OS credential store or saved credential file cannot be used
33
37
  | "CONFIRMATION_REQUIRED" // a destructive command needs --force
34
38
  | "INVALID_USAGE" // wrong flags or arguments
35
39
  | "UNKNOWN_COMMAND"
36
40
  | "UNKNOWN_FLAG"
37
41
  | "MISSING_ARGUMENT"
42
+ | "FIELDS_UNMATCHED" // the call ran, but a --fields path matched nothing in the result
38
43
  | "CALL_FAILED"; // anything else
39
44
 
40
45
  export interface Issue {
@@ -80,6 +85,7 @@ export function exitCodeFor(code: IssueCode): 1 | 2 {
80
85
  case "UNKNOWN_COMMAND":
81
86
  case "UNKNOWN_FLAG":
82
87
  case "MISSING_ARGUMENT":
88
+ case "FIELDS_UNMATCHED":
83
89
  case "TTY_REQUIRED":
84
90
  case "CONFIRMATION_REQUIRED":
85
91
  return 2;
@@ -91,13 +97,13 @@ export function exitCodeFor(code: IssueCode): 1 | 2 {
91
97
  /** Interpret an SDK error result: HTTP status, body, transport, validation. */
92
98
  export function classifyApiError(
93
99
  error: unknown,
94
- context: { bin: string; hadCredential: boolean; docsUrl: string | null },
100
+ context: { bin: string; envPrefix: string; hadCredential: boolean; docsUrl: string | null; requiredScopes?: string[]; canLogin?: boolean },
95
101
  ): EnvelopeInput {
96
- const e = (error ?? {}) as { name?: string; message?: string; status?: number; body?: unknown; violations?: unknown; direction?: string; target?: string; response?: { requestId?: string } };
102
+ const e = (error ?? {}) as { name?: string; message?: string; status?: number; code?: unknown; body?: unknown; violations?: unknown; direction?: string; target?: string; rateLimit?: { retryAt?: Date }; response?: { requestId?: string } };
97
103
  // The message is the API's own words when it sent any, else the SDK's
98
- // (the spec's response description). The error class name rides in
99
- // detail, not in front of the message: "NotFoundError: No such account
100
- // (no such account)" said one thing three times.
104
+ // ("HTTP 404"). The error class name rides in detail, not in front of the
105
+ // message: "NotFoundError: No such account (no such account)" said one
106
+ // thing three times.
101
107
  const base = e.message ? e.message : String(error);
102
108
  if (e.violations !== undefined) {
103
109
  const nextStep = e.direction === "response"
@@ -107,12 +113,12 @@ export function classifyApiError(
107
113
  : "Correct the body fields named in detail.violations to match their constraints, then run the command again.";
108
114
  return { code: "VALIDATION_FAILED", message: base, detail: { ...(e.name ? { error: e.name } : {}), violations: e.violations }, nextSteps: [nextStep] };
109
115
  }
110
- if (e.name === "TransportError" || (typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
116
+ if (e.name === "TransportError" || (e.name !== "PaginationError" && typeof e.status !== "number" && /fetch|ECONN|ENOTFOUND|timed out|TLS|abort/i.test(base))) {
111
117
  return {
112
118
  code: "NETWORK_ERROR",
113
119
  message: base,
114
120
  nextSteps: [
115
- "Check the base URL (--base-url, the " + context.bin.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
121
+ "Check the base URL (--base-url, the " + context.envPrefix + "_BASE_URL variable, or '" + context.bin + " config base-url') and the network.",
116
122
  "Retry once with backoff; do not loop.",
117
123
  ],
118
124
  };
@@ -125,30 +131,93 @@ export function classifyApiError(
125
131
  ? (e.body as Record<string, unknown>).request_id ?? (e.body as Record<string, unknown>).requestId
126
132
  : undefined;
127
133
  const requestId = e.response?.requestId ?? (typeof bodyRequestId === "string" ? bodyRequestId : undefined);
128
- const detail = { status, ...(e.name ? { error: e.name } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
134
+ // An in-band GraphQL error keeps the vendor's code next to the normalized one.
135
+ const vendorCode = e.name === "GraphQLRequestError" ? graphqlVendorCode(error) : undefined;
136
+ const detail = { status, ...(e.name ? { error: e.name } : {}), ...(vendorCode ? { vendor_code: vendorCode } : {}), ...(requestId ? { request_id: requestId } : {}), ...(e.body !== undefined ? { body: e.body } : {}) };
129
137
  const same = apiMessage !== undefined && (apiMessage.toLowerCase() === base.toLowerCase() || base.toLowerCase().includes(apiMessage.toLowerCase()) || apiMessage.toLowerCase().includes(base.toLowerCase()));
130
138
  const message = apiMessage === undefined ? base : same ? apiMessage : apiMessage + " (" + base + ")";
131
139
  const upgradeUrl = extractUrl(e.body, ["upgrade_url", "upgradeUrl", "signup_url", "claim_url"]);
132
- if (status === 401) {
133
- return context.hadCredential
134
- ? { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential was rejected. Check it is current: '" + context.bin + " auth check', then '" + context.bin + " login --help' to store a new one."] }
135
- : { status: "action_required", code: "NO_AUTH", message, detail, nextSteps: ["No credential was sent. Set the auth env var, pass --token, or run '" + context.bin + " login'.", "'" + context.bin + " auth check' shows what the CLI would send."] };
140
+ // A rate limit can arrive as a 403 (GitHub); the SDK marks it either way.
141
+ if (status === 429 || e.rateLimit !== undefined) {
142
+ return { status: "action_required", code: "RATE_LIMITED", message, detail: { ...detail, ...(e.rateLimit?.retryAt ? { retry_at: isoSeconds(e.rateLimit.retryAt) } : {}) }, nextSteps: [rateLimitNextStep(e.rateLimit?.retryAt, "run the same command again")] };
136
143
  }
137
- if (status === 403) return { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
144
+ // A failure the API reported inside a 2xx body: classify by its code.
145
+ const scopes = context.requiredScopes ?? [];
146
+ const scopeFailure = (): EnvelopeInput => ({ code: "INSUFFICIENT_SCOPE", message: message + " This operation requires the OAuth scopes: " + scopes.join(", ") + ".", detail: { ...detail, required_scopes: scopes }, nextSteps: [
147
+ context.canLogin ? "Sign in again with those scopes: '" + context.bin + " login --scopes " + scopes.join(",") + "'." : "Use a token granted those scopes.",
148
+ "If the token already has them, the account may lack access to this resource.",
149
+ ] });
150
+ if (e.name === "PayloadError") {
151
+ if (scopes.length && /^missing_scope$/i.test(String(e.code))) return scopeFailure();
152
+ const reported = payloadFailureCode(e.code);
153
+ if (reported === "AUTH_INVALID") return { code: "AUTH_INVALID", message, detail, nextSteps: ["The API rejected the credential (" + String(e.code) + "). Check it is current: '" + context.bin + " auth check'."] };
154
+ if (reported === "RATE_LIMITED") return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: ["Wait, then run the same command again."] };
155
+ return { code: "CALL_FAILED", message, detail, nextSteps: ["The API reported a failure in a successful response; detail.body says why."] };
156
+ }
157
+ const unauthenticated = (): EnvelopeInput => context.hadCredential
158
+ ? { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential was rejected. Check it is current: '" + context.bin + " auth check', then '" + context.bin + " login --help' to store a new one."] }
159
+ : { status: "action_required", code: "NO_AUTH", message, detail, nextSteps: ["No credential was sent. Set the auth env var, pass --token, or run '" + context.bin + " login'.", "'" + context.bin + " auth check' shows what the CLI would send."] };
160
+ const forbidden = (): EnvelopeInput => scopes.length ? scopeFailure() : { code: "AUTH_INVALID", message, detail, nextSteps: ["The credential lacks access to this operation."] };
161
+ // An in-band GraphQL error (HTTP 200): classified by the vendor's code.
162
+ if (e.name === "GraphQLRequestError") {
163
+ switch (vendorCode === undefined ? undefined : graphqlErrorClass(vendorCode)) {
164
+ case "not_found": return { code: "NOT_FOUND", message, detail, nextSteps: ["Check the id in the arguments; list the resource first to find the right one."] };
165
+ case "unauthenticated": return unauthenticated();
166
+ case "forbidden": return forbidden();
167
+ case "bad_input": return { code: "INVALID_REQUEST", message, detail, nextSteps: ["The API rejected an argument or the selection; the errors in detail.body name it (message, path). Fix that and run the command again."] };
168
+ case "rate_limited": return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [rateLimitNextStep(undefined, "run the same command again")] };
169
+ default: return { code: "CALL_FAILED", message, detail };
170
+ }
171
+ }
172
+ if (status === 401) return unauthenticated();
173
+ if (status === 403) return forbidden();
138
174
  if (status === 402) {
139
175
  return { status: "action_required", code: "PLAN_LIMIT", message, detail, nextSteps: [upgradeUrl ? "Lift the limit at " + upgradeUrl + ", then run the same command again." : "The account's plan stops here; upgrade it, then run the same command again.", "Do not retry the same call as is."] };
140
176
  }
141
177
  if (status === 404) return { code: "NOT_FOUND", message, detail, nextSteps: notFoundNextSteps(message) };
142
- if (status === 429) {
143
- const retryAfter = extractRetryAfter(e);
144
- return { status: "action_required", code: "RATE_LIMITED", message, detail, nextSteps: [retryAfter ? "Wait " + retryAfter + " seconds, then run the same command again." : "Back off and retry once; the SDK already retried with the server's Retry-After."] };
145
- }
146
- if (status === 422 && apiCode === "spec_error") return { code: "SPEC_INVALID", message, detail, nextSteps: ["The API rejected the spec it was given; the message says why.", context.docsUrl ? "Look the message up: '" + context.bin + " docs search \"" + (apiMessage ?? "").slice(0, 60).replace(/"/g, "'") + "\"'." : "Fix the spec and run again."] };
178
+ if (status === 422 && (apiCode === "spec_invalid" || apiCode === "spec_error")) return { code: "SPEC_INVALID", message, detail, nextSteps: ["The API rejected the spec it was given; the message says why.", context.docsUrl ? "Look the message up: '" + context.bin + " docs search \"" + (apiMessage ?? "").slice(0, 60).replace(/"/g, "'") + "\"'." : "Fix the spec and run again."] };
147
179
  if (status === 400 || status === 422 || status === 409 || status === 413) return { code: "INVALID_REQUEST", message, detail, nextSteps: ["Read detail.body for the field the API named; run the command with --help for its flags."] };
148
180
  if (status >= 500) return { code: "SERVER_ERROR", message, detail, nextSteps: ["Retry once with backoff. If it persists, report detail.request_id."] };
149
181
  return { code: "CALL_FAILED", message, detail };
150
182
  }
151
183
 
184
+ /** Login, saved-session and credential-store failures, recognised by error
185
+ * name (so this module needs no login runtime) on the error or its cause.
186
+ * Undefined when the error is none of them. */
187
+ export function classifyAuthFailure(
188
+ error: unknown,
189
+ context: { bin: string; envVars: string[]; storeVariable: string },
190
+ ): EnvelopeInput | undefined {
191
+ const login = "'" + context.bin + " login'";
192
+ const environment = context.envVars.length ? ["Or supply credentials through the environment: " + context.envVars.join(", ") + "."] : [];
193
+ for (let node = error as { name?: unknown; message?: unknown; code?: unknown; cause?: unknown } | undefined, depth = 0; node && typeof node === "object" && depth < 4; node = node.cause as typeof node, depth++) {
194
+ const message = typeof node.message === "string" && node.message ? node.message : "Login failed.";
195
+ switch (node.name) {
196
+ case "DeviceFlowUnavailableError":
197
+ return { status: "action_required", code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " without --device to sign in through the browser.", ...environment] };
198
+ case "LoginPollingError":
199
+ if (node.code === "request_failed" && typeof (node as { providerError?: unknown }).providerError === "string") return { code: "LOGIN_FAILED", message, nextSteps: ["The provider rejected the login request. Check the client ID and that the application is registered for this sign-in flow, then run " + login + " again.", ...environment] };
200
+ if (node.code === "request_failed") return { code: "LOGIN_FAILED", message, nextSteps: ["Check the network and the login service, then run " + login + " again.", ...environment] };
201
+ if (node.code === "invalid_response") return { code: "LOGIN_FAILED", message, nextSteps: ["The login service's response did not match its contract. Retry " + login + " once; if it persists, report it to the API owner.", ...environment] };
202
+ return { code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " again and approve the new request.", ...environment] };
203
+ case "OAuthResponseError":
204
+ if (node.code === "request_failed" || node.code === "timed_out") return { code: "NETWORK_ERROR", message: "The OAuth provider could not be reached (" + String(node.code).replace("_", " ") + ").", nextSteps: ["Check the network and the provider, then run " + login + " again."] };
205
+ return { code: "LOGIN_FAILED", message: "The OAuth provider returned an unusable response (" + String(node.code).replace(/_/g, " ") + ").", nextSteps: ["Run " + login + " again. If it keeps failing, report it to the API owner.", ...environment] };
206
+ case "OAuthLoginError":
207
+ return { code: "LOGIN_FAILED", message, nextSteps: ["Run " + login + " again. If it keeps failing, the OAuth application settings may need attention from the API owner.", ...environment] };
208
+ case "OAuthSessionError":
209
+ return { status: "action_required", code: "LOGIN_REQUIRED", message, nextSteps: ["Run " + login + " to sign in again.", ...environment] };
210
+ case "CredentialStorageError":
211
+ return { status: "action_required", code: "CREDENTIAL_STORE_UNAVAILABLE", message, nextSteps: [
212
+ "Unlock or enable the OS credential store (macOS Keychain, Linux Secret Service or Windows DPAPI) and retry.",
213
+ ...environment,
214
+ "To use a plaintext file store instead, set " + context.storeVariable + "=file and run " + login + ".",
215
+ ] };
216
+ }
217
+ }
218
+ return undefined;
219
+ }
220
+
152
221
  /** A file lookup needs path guidance, not the generic advice for a missing
153
222
  * resource id. Prefer the API's own index when its message names one. */
154
223
  function notFoundNextSteps(message: string): string[] {
@@ -174,12 +243,46 @@ function extractUrl(body: unknown, keys: string[]): string | undefined {
174
243
  return undefined;
175
244
  }
176
245
 
177
- function extractRetryAfter(e: { headers?: unknown; body?: unknown }): string | undefined {
178
- const headers = e.headers as { get?: (name: string) => string | null } | undefined;
179
- const fromHeader = headers?.get?.("retry-after");
180
- if (fromHeader) return fromHeader;
181
- const match = /retry after (\d+)s/i.exec(JSON.stringify(e.body ?? ""));
182
- return match?.[1];
246
+ function isoSeconds(at: Date): string {
247
+ return new Date(Math.ceil(at.getTime() / 1000) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
248
+ }
249
+
250
+ /** "Wait until <time>" when the API said when the limit resets. */
251
+ export function rateLimitNextStep(retryAt: Date | undefined, then: string): string {
252
+ return retryAt instanceof Date && !Number.isNaN(retryAt.getTime())
253
+ ? "Rate limited: wait until " + isoSeconds(retryAt) + ", then " + then + "."
254
+ : "Rate limited: back off, then " + then + "; the SDK already honored any Retry-After within its ceiling.";
255
+ }
256
+
257
+ /** The vendor's own code on an in-band GraphQL error: the first error's
258
+ * extensions.code, or its type (GitHub). */
259
+ function graphqlVendorCode(error: unknown): string | undefined {
260
+ const first = (error as { errors?: { extensions?: { code?: unknown }; type?: unknown }[] } | null)?.errors?.[0];
261
+ const code = first?.extensions?.code ?? first?.type;
262
+ return typeof code === "string" && code !== "" ? code : undefined;
263
+ }
264
+
265
+ /** GraphQL error codes whose meaning is settled (Apollo's standard codes,
266
+ * GitHub's types, Linear's codes), by recovery class. Any other code is
267
+ * passed through as is, with no guessed next step. The MCP server's
268
+ * classification uses the same table. */
269
+ function graphqlErrorClass(code: string): "not_found" | "unauthenticated" | "forbidden" | "bad_input" | "rate_limited" | undefined {
270
+ const c = code.toUpperCase();
271
+ if (c === "NOT_FOUND") return "not_found";
272
+ if (c === "UNAUTHENTICATED" || c === "AUTHENTICATION_ERROR") return "unauthenticated";
273
+ if (c === "FORBIDDEN") return "forbidden";
274
+ if (c === "BAD_USER_INPUT" || c === "GRAPHQL_VALIDATION_FAILED" || c === "GRAPHQL_PARSE_FAILED" || c === "INPUT_ERROR") return "bad_input";
275
+ if (c === "RATE_LIMITED" || c === "RATELIMITED") return "rate_limited";
276
+ return undefined;
277
+ }
278
+
279
+ /** Error codes APIs report inside a 2xx body (Slack's `error`), mapped to
280
+ * the stable codes when their meaning is unambiguous. */
281
+ export function payloadFailureCode(code: unknown): "AUTH_INVALID" | "RATE_LIMITED" | undefined {
282
+ if (typeof code !== "string") return undefined;
283
+ if (/^(not_authed|invalid_auth|token_revoked|token_expired|account_inactive|unauthorized|unauthenticated|forbidden|access_denied|missing_scope)$/i.test(code)) return "AUTH_INVALID";
284
+ if (/^(ratelimited|rate_limited|rate_limit_exceeded|too_many_requests)$/i.test(code)) return "RATE_LIMITED";
285
+ return undefined;
183
286
  }
184
287
 
185
288
  // ---- agent mode -------------------------------------------------------------
@@ -243,6 +346,33 @@ export interface McpClient {
243
346
  write: (existing: string, name: string, entry: McpEntry) => string;
244
347
  /** True when the client cannot speak the current MCP protocol; skipped by --all. */
245
348
  incompatible?: string;
349
+ /**
350
+ * How the client expands an environment variable inside a header value.
351
+ * Entries carry `${VAR}`; each client gets its own spelling, and a client
352
+ * that expands nothing gets no Authorization header rather than a literal
353
+ * one it would send as-is. Defaults to "dollar".
354
+ */
355
+ headerEnv?: "dollar" | "env-colon" | "brace-env" | "none";
356
+ }
357
+
358
+ const ENV_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
359
+
360
+ /** Rewrite `${VAR}` header references into the client's syntax, or drop the headers that hold one. */
361
+ function entryForClient(client: McpClient, entry: McpEntry): { entry: McpEntry; note?: string } {
362
+ const style = client.headerEnv ?? "dollar";
363
+ if (!entry.headers || style === "dollar") return { entry };
364
+ const headers: Record<string, string> = {};
365
+ const dropped: string[] = [];
366
+ for (const [name, value] of Object.entries(entry.headers)) {
367
+ if (style === "none" && value.search(ENV_REF) !== -1) dropped.push(name);
368
+ else headers[name] = style === "env-colon" ? value.replace(ENV_REF, "${env:$1}") : style === "brace-env" ? value.replace(ENV_REF, "{env:$1}") : value;
369
+ }
370
+ const next: McpEntry = { ...entry };
371
+ delete next.headers;
372
+ if (Object.keys(headers).length) next.headers = headers;
373
+ return dropped.length
374
+ ? { entry: next, note: client.label + " does not expand environment variables in headers, so the entry omits " + dropped.join(", ") + "; the client signs in with MCP authorization when the server supports it." }
375
+ : { entry: next };
246
376
  }
247
377
 
248
378
  function readOr(file: string, fallback: string): string {
@@ -280,9 +410,14 @@ function tomlMerge(existing: string, name: string, entry: McpEntry): string {
280
410
  const lines: string[] = [header];
281
411
  if (entry.url) {
282
412
  lines.push("url = " + JSON.stringify(entry.url));
283
- const auth = entry.headers?.Authorization ?? entry.headers?.authorization;
284
- const envRef = auth ? /\$\{([A-Z0-9_]+)\}/.exec(auth)?.[1] : undefined;
285
- if (envRef) lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
413
+ const envHeaders: string[] = [];
414
+ for (const [header, value] of Object.entries(entry.headers ?? {})) {
415
+ const envRef = /\$\{([A-Z0-9_]+)\}/.exec(value)?.[1];
416
+ if (!envRef) continue;
417
+ if (header.toLowerCase() === "authorization" && /^Bearer /.test(value)) lines.push("bearer_token_env_var = " + JSON.stringify(envRef));
418
+ else envHeaders.push(JSON.stringify(header) + " = " + JSON.stringify(envRef));
419
+ }
420
+ if (envHeaders.length) lines.push("env_http_headers = { " + envHeaders.join(", ") + " }");
286
421
  } else {
287
422
  lines.push("command = " + JSON.stringify(entry.command ?? "node"));
288
423
  lines.push("args = " + JSON.stringify(entry.args ?? []));
@@ -316,6 +451,7 @@ export const MCP_CLIENTS: McpClient[] = [
316
451
  label: "VS Code",
317
452
  file: (cwd) => join(cwd, ".vscode", "mcp.json"),
318
453
  detect: (cwd) => existsSync(join(cwd, ".vscode")) || existsSync(join(home(), ".vscode")),
454
+ headerEnv: "env-colon",
319
455
  write: (existing, name, entry) => jsonMerge(existing, ["servers"], name, standardEntry(entry)),
320
456
  },
321
457
  {
@@ -323,6 +459,7 @@ export const MCP_CLIENTS: McpClient[] = [
323
459
  label: "Windsurf",
324
460
  file: () => join(home(), ".codeium", "windsurf", "mcp_config.json"),
325
461
  detect: () => existsSync(join(home(), ".codeium", "windsurf")),
462
+ headerEnv: "env-colon",
326
463
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { serverUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
327
464
  },
328
465
  {
@@ -330,6 +467,7 @@ export const MCP_CLIENTS: McpClient[] = [
330
467
  label: "Gemini CLI",
331
468
  file: () => join(home(), ".gemini", "settings.json"),
332
469
  detect: () => existsSync(join(home(), ".gemini")),
470
+ headerEnv: "none",
333
471
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, entry.url ? { httpUrl: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : stdioEntry(entry)),
334
472
  },
335
473
  {
@@ -337,6 +475,7 @@ export const MCP_CLIENTS: McpClient[] = [
337
475
  label: "OpenCode",
338
476
  file: () => join(xdg(), "opencode", "opencode.json"),
339
477
  detect: () => existsSync(join(xdg(), "opencode")),
478
+ headerEnv: "brace-env",
340
479
  write: (existing, name, entry) => jsonMerge(existing, ["mcp"], name, entry.url ? { type: "remote", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : { type: "local", command: [entry.command ?? "node", ...(entry.args ?? [])] }),
341
480
  },
342
481
  {
@@ -344,6 +483,7 @@ export const MCP_CLIENTS: McpClient[] = [
344
483
  label: "Zed",
345
484
  file: () => join(xdg(), "zed", "settings.json"),
346
485
  detect: () => existsSync(join(xdg(), "zed")),
486
+ headerEnv: "none",
347
487
  write: (existing, name, entry) => jsonMerge(existing, ["context_servers"], name, entry.url ? { source: "custom", url: entry.url, ...(entry.headers ? { headers: entry.headers } : {}) } : { source: "custom", command: entry.command ?? "node", args: entry.args ?? [] }),
348
488
  },
349
489
  {
@@ -362,10 +502,18 @@ export const MCP_CLIENTS: McpClient[] = [
362
502
  label: "Cursor",
363
503
  file: (cwd) => join(cwd, ".cursor", "mcp.json"),
364
504
  detect: () => existsSync(join(home(), ".cursor")),
505
+ headerEnv: "env-colon",
365
506
  write: (existing, name, entry) => jsonMerge(existing, ["mcpServers"], name, standardEntry(entry)),
366
507
  },
367
508
  ];
368
509
 
510
+ // Every client's write receives entries in its own env-reference syntax,
511
+ // whether it is called through writeMcpConfig or directly.
512
+ for (const client of MCP_CLIENTS) {
513
+ const write = client.write;
514
+ client.write = (existing, name, entry) => write(existing, name, entryForClient(client, entry).entry);
515
+ }
516
+
369
517
  export function findMcpClient(id: string): McpClient | undefined {
370
518
  return MCP_CLIENTS.find((c) => c.id === id);
371
519
  }
@@ -377,17 +525,19 @@ export interface McpWriteResult {
377
525
  note?: string;
378
526
  }
379
527
 
380
- /** Merge the entry into one client's config file. Never writes a literal secret: callers pass env references. */
528
+ /** Merge the entry into one client's config file. Never writes a literal secret: callers pass `${VAR}` references, which each client receives in its own syntax. */
381
529
  export function writeMcpConfig(client: McpClient, cwd: string, name: string, entry: McpEntry): McpWriteResult {
382
530
  const file = client.file(cwd);
383
531
  if (!entry.url && client.id === "claude-desktop" && !entry.command) {
384
532
  return { client: client.id, file, written: false, note: "Claude Desktop reads only stdio servers from its config file; add a remote server as a connector in the app." };
385
533
  }
386
534
  const existing = readOr(file, "");
535
+ const adapted = entryForClient(client, entry);
387
536
  const next = client.write(existing, name, entry);
388
537
  mkdirSync(dirname(file), { recursive: true });
389
538
  writeFileSync(file, next);
390
- return { client: client.id, file, written: true, ...(client.incompatible ? { note: client.incompatible } : {}) };
539
+ const note = [client.incompatible, adapted.note].filter(Boolean).join(" ");
540
+ return { client: client.id, file, written: true, ...(note ? { note } : {}) };
391
541
  }
392
542
 
393
543
  /** Does a client's config already mention this server? For doctor. */
@@ -469,10 +619,13 @@ export interface AgentContext {
469
619
  version: string;
470
620
  envPrefix: string;
471
621
  authEnvVars: string[];
622
+ /** The API Spec is silent on authentication: say so instead of "no auth". */
623
+ authNotDeclared?: boolean;
472
624
  docsUrl: string | null;
473
625
  docsIndexUrl?: string | null;
474
626
  generatedOperationCount?: number;
475
- omittedOperations?: { command: string; tool: string; method: string; path: string }[];
627
+ /** Operations in the API left out of a capped build; api.json lists them. */
628
+ omittedOperationCount?: number;
476
629
  /** The API's hosted MCP endpoint, when it has one. */
477
630
  mcpUrl: string | null;
478
631
  /** Skills repository (owner/name) an agent can install with npx skills add. */
@@ -481,7 +634,6 @@ export interface AgentContext {
481
634
  builtins: string[];
482
635
  }
483
636
 
484
- /** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
485
637
  /** string, number, usd|eur, string[], usd|eur[], object, json — the type as a reader expects it. */
486
638
  export function flagTypeLabel(f: CommandFlagSummary): string {
487
639
  const inline = (values: string[] | undefined) => values && values.join("|").length <= 24 ? values.join("|") : values ? "enum" : undefined;
@@ -489,6 +641,7 @@ export function flagTypeLabel(f: CommandFlagSummary): string {
489
641
  return inline(f.enum) ?? f.type;
490
642
  }
491
643
 
644
+ /** One line per command, pipe-delimited: the compact index that goes into AGENTS.md. */
492
645
  export function compactIndex(commands: CommandSummary[]): string {
493
646
  return commands
494
647
  .map((c) => {
@@ -498,27 +651,55 @@ export function compactIndex(commands: CommandSummary[]): string {
498
651
  .join("\n");
499
652
  }
500
653
 
654
+ /** Bytes of command index the guide and AGENTS.md carry before they switch to one line per resource. */
655
+ export const COMMAND_INDEX_BUDGET = 8_000;
656
+ const RESOURCE_INDEX_NAMES = 12;
657
+
658
+ /**
659
+ * One line per resource with its first command names, for an API whose
660
+ * per-command index would not fit COMMAND_INDEX_BUDGET. Resources past the
661
+ * budget are counted, not listed; help --json has them all.
662
+ */
663
+ export function resourceIndex(bin: string, commands: CommandSummary[]): string {
664
+ const byResource = new Map<string, string[]>();
665
+ for (const c of commands) byResource.set(c.resource, [...(byResource.get(c.resource) ?? []), c.command]);
666
+ const lines: string[] = [];
667
+ let bytes = 0;
668
+ let listed = 0;
669
+ for (const [resource, names] of byResource) {
670
+ const more = names.length - RESOURCE_INDEX_NAMES;
671
+ const line = resource + ": " + names.slice(0, RESOURCE_INDEX_NAMES).join(", ") + (more > 0 ? ", … +" + more + " more" : "");
672
+ if (bytes + line.length + 1 > COMMAND_INDEX_BUDGET) break;
673
+ lines.push(line);
674
+ bytes += line.length + 1;
675
+ listed++;
676
+ }
677
+ if (listed < byResource.size) lines.push("… " + (byResource.size - listed) + " more resources: " + bin + " help --json");
678
+ return lines.join("\n");
679
+ }
680
+
501
681
  /** The AGENTS.md block body. */
502
682
  export function agentBlock(ctx: AgentContext, commands: CommandSummary[]): string {
503
683
  const auth = ctx.authEnvVars.length ? ctx.authEnvVars.join(", ") : "(none)";
504
- const index = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
684
+ const docsIndex = ctx.docsIndexUrl ?? (ctx.docsUrl ? ctx.docsUrl.replace(/\/+$/, "") + "/llms.txt" : null);
685
+ const index = compactIndex(commands);
505
686
  return [
506
687
  "## " + ctx.bin + " CLI (" + ctx.apiTitle + ")",
507
688
  "",
508
689
  "API commands write JSON on stdout; discovery commands take --json. Errors are JSON on stderr ({status, issues[{code,message}], next_steps}), exit 0/1/2. Non-interactive under an agent: no prompts, no browsers.",
509
690
  "",
510
- ...((ctx.omittedOperations?.length ?? 0) > 0 ? ["- Plan limit: generated " + (ctx.generatedOperationCount ?? commands.length) + " of " + ((ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperations!.length) + " operations. Omitted: " + ctx.omittedOperations!.map((op) => "`" + op.tool + "` (" + op.method + " " + op.path + ")").join(", ") + ". These return `PLAN_LIMIT`; upgrade and regenerate before use."] : []),
511
- "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
512
- "- Discover: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`, `" + ctx.bin + " help --json` (machine-readable), `" + ctx.bin + " agent-guide --format json`.",
513
- "- Docs: " + (index ? "`" + ctx.bin + " docs search <term> --json`; " + index : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
691
+ ...((ctx.omittedOperationCount ?? 0) > 0 ? ["- Coverage: this build includes " + (ctx.generatedOperationCount ?? commands.length) + " of " + ((ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperationCount!) + " operations; api.json lists the rest, and calling one returns `PLAN_LIMIT`."] : []),
692
+ ctx.authNotDeclared
693
+ ? "- Auth: not declared by the API Spec. If the API needs a token, set " + auth + " or run `" + ctx.bin + " login`; other headers go in `--header \"Name: value\"` or " + ctx.envPrefix + "_HEADERS. Never write a key into a file in this repo."
694
+ : "- Auth: " + auth + " in the environment, or `" + ctx.bin + " login`. Never write a key into a file in this repo.",
695
+ "- Discover: `" + ctx.bin + " help --json` indexes resources and command names; `" + ctx.bin + " help <resource> --json` lists a resource's commands; `" + ctx.bin + " help <resource> <command> --json` gives one command's flags. `" + ctx.bin + " help --json --all` prints every command with every flag at once. Humans: `" + ctx.bin + " --help`, `" + ctx.bin + " <resource> <command> --help`.",
696
+ "- Docs: " + (docsIndex ? "`" + ctx.bin + " docs search <term> --json`; " + docsIndex : "`" + ctx.bin + " docs <resource> <command> --json` (a docs URL was not provided at generate time)") + ".",
514
697
  "- Lists: `--all` streams every page as NDJSON. Destructive commands need `--force`.",
515
698
  ...(ctx.hasMcp ? ["- MCP: `" + ctx.bin + " mcp install --all` registers this API's MCP server with the agent clients on this machine" + (ctx.mcpUrl ? " (hosted: " + ctx.mcpUrl + ")" : "") + "."] : []),
516
699
  "",
517
- "Commands (resource command | METHOD path | required flags | summary):",
518
- "",
519
- "```",
520
- compactIndex(commands),
521
- "```",
700
+ ...(Buffer.byteLength(index) <= COMMAND_INDEX_BUDGET
701
+ ? ["Commands (resource command | METHOD path | required flags | summary):", "", "```", index, "```"]
702
+ : ["Commands by resource (" + commands.length + " commands; `" + ctx.bin + " help <resource> --json` lists a resource's commands with summaries):", "", "```", resourceIndex(ctx.bin, commands), "```"]),
522
703
  ].join("\n");
523
704
  }
524
705
 
@@ -535,12 +716,12 @@ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Recor
535
716
  package: ctx.pkg,
536
717
  api: ctx.apiTitle,
537
718
  version: ctx.version,
538
- generated_by: "typeship",
719
+ generated_by: "Typeship",
539
720
  guide: agentBlock(ctx, commands),
540
721
  first_command: first ? ctx.bin + " " + first.resource + " " + first.command : ctx.bin + " --help",
541
722
  docs_index_url: docsIndexUrl,
542
723
  docs_full_url: docsFullUrl,
543
- ...((ctx.omittedOperations?.length ?? 0) > 0 ? { coverage: { generated_operations: ctx.generatedOperationCount ?? commands.length, total_operations: (ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperations!.length, omitted_operations: ctx.omittedOperations } } : {}),
724
+ ...((ctx.omittedOperationCount ?? 0) > 0 ? { coverage: { generated_operations: ctx.generatedOperationCount ?? commands.length, total_operations: (ctx.generatedOperationCount ?? commands.length) + ctx.omittedOperationCount! } } : {}),
544
725
  hosted_mcp_url: ctx.mcpUrl,
545
726
  local_mcp: ctx.hasMcp ? ctx.bin + "-mcp (stdio) or '" + ctx.bin + " mcp install --all'" : null,
546
727
  skills_install: ctx.skillsRepo ? "npx skills add " + ctx.skillsRepo : null,
@@ -553,12 +734,13 @@ export function agentGuide(ctx: AgentContext, commands: CommandSummary[]): Recor
553
734
  destructive: "Commands classified as destructive need --force (or --yes); without it they return CONFIRMATION_REQUIRED with the exact command to run.",
554
735
  flags: "Positional path arguments first, then --flags. Array flags take a comma list, the flag repeated, or a JSON array; object flags take JSON. --data '<json>' merges under field flags (@<file> reads a file, - reads stdin).",
555
736
  pagination: "Paginated commands return {items, hasMore, nextPage, nextCommand}: run nextCommand for the next page, or --all walks every page.",
737
+ discovery: "help --json is a bounded index of resources and command names. help <resource> --json pages through one resource's commands (next_command), help <resource> <command> --json is one command's flags, docs <resource> <command> --json its complete schemas, and docs search <term> --json finds commands by topic. help --json --all prints every command with every flag; on a large API it is large.",
556
738
  },
557
739
  builtins: ctx.builtins,
558
740
  next_steps: [
559
741
  ...(ctx.authEnvVars.length ? ["Set " + ctx.authEnvVars[0] + " in the environment or run '" + ctx.bin + " login'."] : []),
560
742
  "Run '" + ctx.bin + " auth check'.",
561
- "Run '" + ctx.bin + " help --json' for the command index, or read the AGENTS.md block '" + ctx.bin + " init' writes.",
743
+ "Run '" + ctx.bin + " help --json' for the command index, then '" + ctx.bin + " help <resource> <command> --json' for the command you need.",
562
744
  ...(ctx.hasMcp ? ["Run '" + ctx.bin + " mcp install --all' if this session has an MCP-capable client."] : []),
563
745
  ],
564
746
  };
@@ -704,3 +886,126 @@ export function summarizeDoctor(checks: DoctorCheck[]): { status: "ok" | "action
704
886
  next_steps: failing.map((c) => c.fix).filter((f): f is string => Boolean(f)),
705
887
  };
706
888
  }
889
+
890
+ /** Every OAuth scope an operation's security requirements name, in order. */
891
+ export function requiredScopes(security: Record<string, string[]>[] | undefined): string[] {
892
+ return [...new Set((security ?? []).flatMap((requirement) => Object.values(requirement).flat()))];
893
+ }
894
+
895
+ // ---- request preview (--dry-run) ----------------------------------------------
896
+
897
+ /** What --dry-run prints: the request an API command would send, with
898
+ * every credential replaced by "<redacted>". Nothing in it was sent. */
899
+ export interface RequestPreview {
900
+ dry_run: true;
901
+ sent: false;
902
+ message: string;
903
+ request: {
904
+ method: string;
905
+ url: string;
906
+ path: string;
907
+ query: Record<string, string | string[]>;
908
+ headers: Record<string, string>;
909
+ body?: unknown;
910
+ };
911
+ }
912
+
913
+ export const REDACTED = "<redacted>";
914
+
915
+ /** Header and query names that carry credentials, whatever the API calls
916
+ * them: Authorization, Cookie, X-Api-Key, api_key, access_token, a session
917
+ * or signature. */
918
+ const SENSITIVE_NAME = /^(authorization|proxy-authorization|cookie|cookie2|set-cookie)$|api[-_]?key|apikey|token|secret|password|passwd|session|signature|credential|^key$|^auth$/i;
919
+
920
+ function redactText(text: string, secrets: string[]): string {
921
+ let out = text;
922
+ for (const secret of secrets) if (secret.length >= 4) out = out.split(secret).join(REDACTED);
923
+ return out;
924
+ }
925
+
926
+ /**
927
+ * The request a dry run captured, as --dry-run shows it. `sensitiveNames`
928
+ * are the header and query names the API's security schemes bind; `secrets`
929
+ * are the resolved credential values, redacted wherever they appear (a
930
+ * token pasted into a body field, a key in a URL).
931
+ */
932
+ export async function requestPreview(
933
+ input: { method: string; url: string; headers: Record<string, string>; body?: unknown },
934
+ options: { sensitiveNames?: string[]; secrets?: string[] } = {},
935
+ ): Promise<RequestPreview> {
936
+ const secrets = [...new Set((options.secrets ?? []).filter((s) => typeof s === "string" && s.length >= 4))].sort((a, b) => b.length - a.length);
937
+ const named = new Set((options.sensitiveNames ?? []).map((n) => n.toLowerCase()));
938
+ const sensitive = (name: string) => named.has(name.toLowerCase()) || SENSITIVE_NAME.test(name);
939
+ const url = new URL(input.url);
940
+ const query: Record<string, string | string[]> = {};
941
+ for (const [name, raw] of url.searchParams) {
942
+ const value = sensitive(name) ? REDACTED : redactText(raw, secrets);
943
+ const previous = query[name];
944
+ query[name] = previous === undefined ? value : Array.isArray(previous) ? [...previous, value] : [previous, value];
945
+ }
946
+ const encodedSecrets = [...secrets, ...secrets.map(encodeURIComponent)];
947
+ const search = [...url.searchParams].map(([name, raw]) => encodeURIComponent(name) + "=" + (sensitive(name) ? REDACTED : redactText(encodeURIComponent(raw), encodedSecrets))).join("&");
948
+ const shownUrl = url.origin + redactText(url.pathname, encodedSecrets) + (search ? "?" + search : "");
949
+ const headers: Record<string, string> = {};
950
+ for (const [name, value] of Object.entries(input.headers)) {
951
+ headers[name] = sensitive(name) ? REDACTED : redactText(value, secrets);
952
+ }
953
+ const contentType = Object.entries(input.headers).find(([name]) => name.toLowerCase() === "content-type")?.[1] ?? "";
954
+ const body = await previewBody(input.body, contentType, secrets);
955
+ return {
956
+ dry_run: true,
957
+ sent: false,
958
+ message: "Dry run: nothing was sent to the API. This is the request the command would send, with credentials redacted.",
959
+ request: {
960
+ method: input.method,
961
+ url: shownUrl,
962
+ path: redactText(url.pathname, encodedSecrets),
963
+ query,
964
+ headers,
965
+ ...(body !== undefined ? { body } : {}),
966
+ },
967
+ };
968
+ }
969
+
970
+ async function previewBody(body: unknown, contentType: string, secrets: string[]): Promise<unknown> {
971
+ if (body === undefined || body === null) return undefined;
972
+ const redactValue = (value: unknown): unknown => {
973
+ if (typeof value === "string") return redactText(value, secrets);
974
+ if (Array.isArray(value)) return value.map(redactValue);
975
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, SENSITIVE_NAME.test(k) && typeof v === "string" && secrets.some((s) => v.includes(s)) ? REDACTED : redactValue(v)]));
976
+ return value;
977
+ };
978
+ if (typeof body === "string") {
979
+ if (/json/i.test(contentType)) {
980
+ try { return redactValue(JSON.parse(body)); } catch { /* shown as text */ }
981
+ }
982
+ return redactText(body, secrets);
983
+ }
984
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
985
+ const parts: Record<string, unknown>[] = [];
986
+ for (const [name, value] of body.entries()) {
987
+ parts.push(typeof value === "string"
988
+ ? { name, value: redactText(value, secrets) }
989
+ : { name, filename: (value as File).name, ...((value as Blob).type ? { type: (value as Blob).type } : {}), bytes: (value as Blob).size });
990
+ }
991
+ return { multipart: parts };
992
+ }
993
+ if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return redactText(body.toString(), secrets);
994
+ if (typeof Blob !== "undefined" && body instanceof Blob) return { binary: true, bytes: body.size, ...(body.type ? { type: body.type } : {}) };
995
+ if (body instanceof Uint8Array || body instanceof ArrayBuffer) return { binary: true, bytes: body.byteLength };
996
+ return { stream: true };
997
+ }
998
+
999
+ /** --dry-run for a person: the request line, then query, headers and body. */
1000
+ export function formatRequestPreview(preview: RequestPreview): string {
1001
+ const { request } = preview;
1002
+ const lines = ["Dry run: nothing was sent. Credentials are redacted.", "", request.method + " " + request.url];
1003
+ const headers = Object.entries(request.headers);
1004
+ if (headers.length) lines.push("", "Headers:", ...headers.map(([name, value]) => " " + name + ": " + value));
1005
+ if (request.body !== undefined) {
1006
+ lines.push("", "Body:");
1007
+ const text = typeof request.body === "string" ? request.body : JSON.stringify(request.body, null, 2);
1008
+ lines.push(...text.split("\n").map((line) => " " + line));
1009
+ }
1010
+ return lines.join("\n") + "\n";
1011
+ }