Repository navigation
Expand file tree
/
Copy pathclient.ts
More file actions
843 lines (764 loc) · 30.6 KB
/
Copy pathclient.ts
File metadata and controls
843 lines (764 loc) · 30.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
import { truncate } from '@sim/utils/string'
import { writeStderr } from '#sim-cli/output/io'
import { hasProgressTerminal, styles } from '#sim-cli/output/presentation'
import type { ResolvedProfile, StoredCredential, StoredOAuthCredential } from '../config/index'
import { identityHeaders } from '../telemetry/client-info'
import { warnIfCredentialOverCleartext, warnIfProxyIgnored } from './environment'
/**
* A failure the CLI can explain. Anything thrown as a `SimApiError` is printed
* as a clean message and a non-zero exit; anything else escapes as a stack
* trace, which is the signal that the CLI itself is broken rather than the
* request.
*/
export class SimApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string | null = null,
readonly details?: unknown,
readonly exitCode = 1
) {
super(message)
this.name = 'SimApiError'
}
}
/** `{ data, nextCursor }` — one page of a list. */
export interface V2Page<T> {
data: T[]
nextCursor: string | null
}
export interface RequestAllPagesOptions extends Omit<RequestOptions, 'query'> {
query?: Record<string, QueryValue>
/** Server page size; callers choose one accepted by the endpoint contract. */
pageSize: number
/** Maximum items to return. Omit to follow the cursor through the full list. */
limit?: number
}
export type QueryValue = string | number | boolean | null | undefined
export type AuthRequirement = 'required' | 'optional'
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
query?: Record<string, QueryValue>
body?: unknown
/** Contract-declared headers, e.g. the `upload-token` a transfer is bound to. */
headers?: Record<string, string>
/** Cancels both the initial request and any subsequent streaming body read. */
signal?: AbortSignal
/** Self-hosted, auth-disabled routes may deliberately omit a local API key. */
auth?: AuthRequirement
}
export interface WorkspaceOptions {
auth?: AuthRequirement
}
/**
* Joins an endpoint and a route into a request URL.
*
* Concatenation rather than `new URL(path, endpoint)`, which is the trap it
* exists to avoid: a leading-slash path is absolute, so `new URL()` resolves it
* against the endpoint's ORIGIN and silently drops any path the endpoint
* carries. A deployment served under a prefix — `https://host/sim` behind a
* proxy that fronts several apps — would have every request rewritten to
* `https://host/...`, losing the prefix that identifies it.
*
* Empty values are skipped rather than sent blank so an omitted optional
* parameter reads as absent, not as the empty string.
*/
export function buildUrl(
endpoint: string,
path: string,
query?: Record<string, QueryValue>
): string {
const url = new URL(`${endpoint}${path}`)
for (const [key, value] of Object.entries(query ?? {})) {
if (value === null || value === undefined || value === '') continue
url.searchParams.set(key, String(value))
}
return url.toString()
}
/**
* Statuses `fetch` would otherwise follow for us, and must not.
*
* A 301/302/303 is rewritten to a bodyless GET per the Fetch spec, so an
* endpoint that redirects (an apex host pointing at `www.`, say) keeps every
* read working through its query string while every write silently arrives with
* no body — the endpoint looks correct and only writes fail. Following a
* redirect would also hand the API key to whatever origin `Location` names.
*/
export const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
/** Markup a JSON endpoint would never answer with — a proxy or landing page. */
const MARKUP_PREFIX = /^\s*<(?:!doctype|html|\?xml)/i
/**
* The 403 causes the CLI can turn into an instruction.
*
* Two codes describe the same refusal because they are raised at different
* layers: the workspace-key policy answers `WORKSPACE_KEY_OPERATION_NOT_PERMITTED`,
* while the principal-kind check answers `PRINCIPAL_KIND_NOT_PERMITTED` with a
* message written for a server log ("Principal kind workspace_api_key cannot
* perform operation audit_logs.list"). Recognising only the first left the
* audit-log commands stating the refusal in vocabulary the reader has no way to
* act on, and without the one sentence that resolves it.
*/
const KEY_SCOPE_REFUSALS = new Set([
'WORKSPACE_KEY_OPERATION_NOT_PERMITTED',
'PRINCIPAL_KIND_NOT_PERMITTED',
])
/**
* Names the response the server actually sent, for a body that is not JSON.
*
* Dumping the body as the message turns a wrong endpoint into a wall of HTML
* that says nothing about what went wrong, so the URL and the shape lead; a
* short plain-text body (a proxy's `Bad Gateway`) is the diagnosis itself and is
* kept, while markup is not.
*/
function toNonJsonError(
url: string,
status: number,
contentType: string | null,
raw: string
): SimApiError {
const type = contentType?.split(';')[0]?.trim().toLowerCase()
const isMarkup =
type === 'text/html' || type === 'application/xhtml+xml' || MARKUP_PREFIX.test(raw)
const kind = isMarkup
? 'HTML'
: type && type !== 'application/json'
? type
: 'a non-JSON response'
const text = raw.trim()
const keepSnippet = !isMarkup && text.length > 0 && text.length <= 200
return new SimApiError(
`${url} returned ${kind}, not JSON (HTTP ${status}) — check your endpoint.${
keepSnippet ? ` Response: ${truncate(text, 200, '…')}` : ''
}`,
status
)
}
/**
* Pulls a human-readable message out of whatever the server returned.
*
* v2 answers with `{ error: { code, message } }`, but a request can also be
* turned away before it reaches a v2 route — by the v1 auth middleware
* (`{ error }`), or by a proxy that returns HTML. Each of those still has to
* produce a sentence rather than `[object Object]`.
*/
function toApiError(
url: string,
status: number,
contentType: string | null,
raw: string
): SimApiError {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
if (!raw.trim()) return new SimApiError(`Request failed with status ${status}`, status)
return toNonJsonError(url, status, contentType, raw)
}
const body = parsed as { error?: unknown; message?: unknown }
if (body.error && typeof body.error === 'object') {
const error = body.error as { code?: unknown; message?: unknown; details?: unknown }
return new SimApiError(
typeof error.message === 'string' ? error.message : `Request failed with status ${status}`,
status,
typeof error.code === 'string' ? error.code : null,
error.details
)
}
if (typeof body.error === 'string') return new SimApiError(body.error, status)
if (typeof body.message === 'string') return new SimApiError(body.message, status)
return new SimApiError(`Request failed with status ${status}`, status)
}
/**
* Keeps the useful nested reason from Node/Undici transport failures without
* serializing request options, headers, socket objects, or credentials.
*/
function transportErrorMessage(error: unknown): string {
const messages: string[] = []
const seen = new Set<object>()
let current: unknown = error
while (current && typeof current === 'object' && messages.length < 4 && !seen.has(current)) {
seen.add(current)
const candidate = current as { message?: unknown; code?: unknown; cause?: unknown }
const message =
typeof candidate.message === 'string'
? truncate(candidate.message.replace(/\s+/g, ' ').trim(), 300, '…')
: ''
const code = typeof candidate.code === 'string' ? candidate.code : ''
const detail = `${message}${code && !message.includes(code) ? ` (${code})` : ''}`
if (detail && messages.at(-1) !== detail) messages.push(detail)
current = candidate.cause
}
return messages.join(': ') || 'Unknown network error'
}
async function readResponseText(response: Response): Promise<string> {
try {
return await response.text()
} catch (error) {
throw new SimApiError(
`Unable to read the response: ${transportErrorMessage(error)}`,
response.status,
'RESPONSE_READ_FAILED'
)
}
}
/**
* Whether this is the refusal a workspace-scoped key gets from an operation only
* a personal key may perform, under either code that expresses it.
*
* `error.code` is the envelope's status class and is plain `FORBIDDEN` here; the
* actionable code rides in `error.details.code`, which is where the v2 error
* projection puts a refusal that names its cause. Reading only the top-level
* code meant the remedy was never appended against the real API. The top level
* is still accepted so a server that promotes the code stays covered.
*/
function namesKeyScopeRefusal(error: SimApiError): boolean {
if (typeof error.code === 'string' && KEY_SCOPE_REFUSALS.has(error.code)) return true
const details = error.details
if (!details || typeof details !== 'object') return false
const code = (details as { code?: unknown }).code
return typeof code === 'string' && KEY_SCOPE_REFUSALS.has(code)
}
interface DetailIssue {
path: string[]
message: string
/** The keys an `unrecognized_keys` issue names, `null` on every other issue. */
unrecognizedKeys: string[] | null
}
/** True when `path` addresses a strict ancestor of `other`. */
function isStrictPrefix(path: string[], other: string[]): boolean {
if (path.length >= other.length) return false
return path.every((segment, index) => segment === other[index])
}
/**
* True when `issue` rejects a key that another issue was found *underneath*.
*
* That combination only happens on a union: Zod reports every branch, so one bad
* operator arrives as both the real complaint (`predicate.all.0.op: invalid
* option`) and the branch with no `all` key at all (`predicate: unrecognized key
* "all"` — flatly untrue where the input landed). A genuinely unrecognized key
* is a key the schema knows nothing about, so no other issue can be reported
* inside it.
*/
function rejectsAKeyThatValidated(issue: DetailIssue, other: DetailIssue): boolean {
if (!issue.unrecognizedKeys || !isStrictPrefix(issue.path, other.path)) return false
return issue.unrecognizedKeys.includes(other.path[issue.path.length])
}
/**
* Drops the "unrecognized key" a union reports about the branch the input did
* not take.
*
* Scoped to that one shape on purpose. Suppressing every ancestor path instead
* swallowed complaints the caller has to act on and cannot see anywhere else: a
* container-level cap (`orderKeys: too big` alongside a bad element) and a
* cross-field refusal, whose path is empty and so is an ancestor of everything.
*/
function dropUnionBranchNoise(issues: DetailIssue[]): DetailIssue[] {
if (issues.length < 2) return issues
const kept = issues.filter(
(issue) => !issues.some((other) => rejectsAKeyThatValidated(issue, other))
)
return kept.length > 0 ? kept : issues
}
/**
* How long a single request may take before the CLI gives up, in seconds.
*
* The default is deliberately above every bound the server itself applies: a
* synchronous workflow run is allowed 3000s on a paid plan, so a tighter
* default would abort real work and report it as a transport failure. What it
* catches is the case the server cannot — a connection that is accepted and
* then never answers, which otherwise hangs the terminal indefinitely.
*
* `SIM_TIMEOUT_SECONDS=0` removes the bound, for a self-hosted deployment that
* runs executions without one of its own.
*/
const DEFAULT_TIMEOUT_SECONDS = 3600
/**
* The longest delay Node's timers accept.
*
* Past it a timeout does not fail — it silently becomes 1ms, so the request the
* caller asked to wait longest for would be the first one aborted.
*/
const MAX_TIMEOUT_MS = 2 ** 31 - 1
/** The one instruction that resolves an elapsed request bound, wherever it surfaces. */
export const RAISE_TIMEOUT_HINT = 'Raise SIM_TIMEOUT_SECONDS, or set it to 0 to wait indefinitely.'
/**
* Whether this is the CLI's own request bound elapsing.
*
* `AbortSignal.timeout` raises `TimeoutError`, while a caller's cancel raises
* `AbortError` — so this distinguishes a bound the user can raise from a stop
* the user asked for, which must keep reading as a cancellation.
*/
export function isRequestTimeout(error: unknown): boolean {
return error instanceof DOMException && error.name === 'TimeoutError'
}
function resolveTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.SIM_TIMEOUT_SECONDS
if (raw === undefined || raw.trim() === '') return DEFAULT_TIMEOUT_SECONDS * 1000
const seconds = Number(raw)
if (!Number.isFinite(seconds) || seconds < 0) {
throw new SimApiError(
`Invalid SIM_TIMEOUT_SECONDS "${raw}". Use a non-negative number of seconds, or 0 to disable.`,
0
)
}
// Rounded because a fractional millisecond is rejected outright by
// `AbortSignal.timeout`, and a sub-millisecond timeout is not a distinction
// anyone is drawing. Floored at 1ms for anything above zero: rounding alone
// sent a bound under 0.0005s to 0, which this function reserves for "no
// bound at all", so asking for the shortest possible timeout produced none.
const ms = seconds === 0 ? 0 : Math.max(1, Math.round(seconds * 1000))
if (ms > MAX_TIMEOUT_MS) {
throw new SimApiError(
`SIM_TIMEOUT_SECONDS "${raw}" is longer than Node can wait (${Math.floor(MAX_TIMEOUT_MS / 1000)}s). Use 0 to wait indefinitely.`,
0
)
}
return ms
}
/**
* Aborts when either signal does.
*
* `AbortSignal.any` arrived in Node 20.3 and this package supports Node 20, so
* on the earliest 20.x releases calling it would throw a bare `TypeError`
* before the request was ever made — turning a supported runtime into a crash.
*/
function combineSignals(
caller: AbortSignal | undefined,
timeout: AbortSignal | undefined
): AbortSignal | undefined {
if (!caller) return timeout
if (!timeout) return caller
if (typeof AbortSignal.any === 'function') return AbortSignal.any([caller, timeout])
const controller = new AbortController()
for (const signal of [caller, timeout]) {
if (signal.aborted) {
controller.abort(signal.reason)
break
}
signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true })
}
return controller.signal
}
/** Whether to trace requests to stderr. */
function debugEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = env.SIM_DEBUG
return raw !== undefined && raw !== '' && raw !== '0' && raw.toLowerCase() !== 'false'
}
/**
* Traces one request on stderr.
*
* Method, URL, status and duration only. Bodies and headers are deliberately
* absent: the request carries the API key, and `secrets set` carries the secret
* itself, so a trace that included either would write credentials into whatever
* log the user pasted it into.
*/
function traceRequest(method: string, url: string, status: number | string, startedAt: number) {
writeStderr(
`${styles().dim(`[sim] ${method} ${url} → ${status} ${Math.round(performance.now() - startedAt)}ms`)}\n`
)
}
/**
* Drops a label the message already opens with.
*
* Some routes name the field inside the message as well as in `path`, and the
* two are printed one after the other: `sortBy: only \"startedAt\" can order job
* runs` under `path: ['sortBy']` came out as `--sort-by: --sort-by: only …`
* once the wire name had been retyped as the flag.
*/
function withoutLeadingLabel(message: string, label: string): string {
const prefix = `${label}: `
return message.startsWith(prefix) ? message.slice(prefix.length) : message
}
/** Formats nested validation issues as readable, path-aware lines. */
export function formatApiErrorDetails(details: unknown): string[] {
const issues: DetailIssue[] = []
const seen = new Set<string>()
const visit = (value: unknown, parentPath: string[] = []): void => {
if (Array.isArray(value)) {
value.forEach((item) => visit(item, parentPath))
return
}
if (!value || typeof value !== 'object') return
const issue = value as Record<string, unknown>
const ownPath = Array.isArray(issue.path) ? issue.path.map(String) : []
const path = [...parentPath, ...ownPath]
const nested = Array.isArray(issue.errors) ? issue.errors : []
if (nested.length > 0) {
visit(nested, path)
return
}
if (typeof issue.message !== 'string' || issue.message === 'Invalid input') return
const line = `${path.join('.')}: ${issue.message}`
if (seen.has(line)) return
seen.add(line)
issues.push({
path,
message: issue.message,
unrecognizedKeys:
issue.code === 'unrecognized_keys' && Array.isArray(issue.keys)
? issue.keys.map(String)
: null,
})
}
visit(details)
if (issues.length === 0) return [` details: ${truncate(JSON.stringify(details), 1000, '…')}`]
const kept = dropUnionBranchNoise(issues)
const visible = kept.slice(0, 8)
const lines = [
' details:',
...visible.map((issue) => {
const label = issue.path.length > 0 ? issue.path.join('.') : 'request'
return ` ${label}: ${withoutLeadingLabel(issue.message, label)}`
}),
]
if (kept.length > visible.length) lines.push(` … ${kept.length - visible.length} more issues`)
return lines
}
/**
* Renews an OAuth login and returns the new pair; injected so the HTTP client
* does not import the OAuth flow, which imports the client.
*/
export type OAuthRefresher = (
profile: ResolvedProfile,
current: StoredOAuthCredential
) => Promise<StoredOAuthCredential>
export interface SimClientOptions {
refreshOAuth?: OAuthRefresher
}
/**
* Renew this long before the access token lapses. A request that starts with
* a few seconds left can still land after expiry; five minutes is the margin
* the AWS CLI and WorkOS's guidance settle on, and well inside the hour a Sim
* access token lives.
*/
const REFRESH_AHEAD_MS = 5 * 60 * 1000
/** Reads the OAuth error parameter from Sim's Bearer challenge. */
function bearerChallengeError(header: string | null): string | null {
if (!header?.trimStart().toLowerCase().startsWith('bearer')) return null
const match = /(?:^|,)\s*error\s*=\s*(?:"([^"]*)"|([^,\s]+))/i.exec(
header.replace(/^\s*Bearer\s*/i, '')
)
return match?.[1] ?? match?.[2] ?? null
}
export class SimClient {
private oauth: StoredOAuthCredential | null
private refreshing: Promise<StoredOAuthCredential> | null = null
constructor(
private readonly profile: ResolvedProfile,
private readonly options: SimClientOptions = {}
) {
this.oauth = profile.oauth ?? null
}
private resolveCredential(auth: AuthRequirement = 'required'): StoredCredential | undefined {
if (this.profile.apiKey) return { kind: 'api_key', apiKey: this.profile.apiKey }
if (this.oauth) return { kind: 'oauth', oauth: this.oauth }
if (auth === 'optional') return undefined
throw new SimApiError(
`Not logged in on profile "${this.profile.name}". Run: sim login --profile ${this.profile.authProfile}`,
0
)
}
/**
* One refresh at a time per process, shared by every request that finds the
* token expiring; the cross-process half lives behind the refresher.
*/
private async refreshOAuth(current: StoredOAuthCredential): Promise<StoredOAuthCredential> {
const refresh = this.options.refreshOAuth
if (!refresh) {
throw new SimApiError(
`Your Sim login has expired. Run sim logout --profile ${this.profile.name}, then sim login --profile ${this.profile.name}.`,
401
)
}
if (!this.refreshing) {
this.refreshing = refresh(this.profile, current).finally(() => {
this.refreshing = null
})
}
const next = await this.refreshing
this.oauth = next
return next
}
/**
* The workspace every workspace-scoped command defaults to.
*
* By default this checks the key first even though it does not need one:
* commands resolve the workspace while building their query, so without this
* a brand-new install is told to set a workspace when the actual first step
* is logging in. Auth-disabled self-hosted protocols opt out explicitly.
*/
requireWorkspace(explicit?: string, options: WorkspaceOptions = {}): string {
this.resolveCredential(options.auth)
const workspaceId = explicit ?? this.profile.workspaceId
if (!workspaceId) {
throw new SimApiError(
`No workspace set for profile "${this.profile.name}". Pass --workspace, or run: sim configure --profile ${this.profile.name} --set-workspace <id>`,
0
)
}
return workspaceId
}
/**
* Makes a request without consuming its body. Authentication is required
* unless a self-hosted protocol explicitly opts out.
*
* JSON commands use {@link request}; streaming and binary protocols keep the
* raw response so they can process bytes incrementally. HTTP failures still
* become the same structured `SimApiError` either way.
*/
async requestRaw(path: string, options: RequestOptions = {}): Promise<Response> {
return (await this.send(path, options)).response
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { response, url } = await this.send(path, options)
const raw = await readResponseText(response)
if (!raw) return undefined as T
try {
return JSON.parse(raw) as T
} catch {
throw toNonJsonError(url, response.status, response.headers.get('content-type'), raw)
}
}
private async send(
path: string,
options: RequestOptions,
retriedAfterRefresh = false
): Promise<{ response: Response; url: string }> {
let credential = this.resolveCredential(options.auth)
if (
credential?.kind === 'oauth' &&
credential.oauth.expiresAt - Date.now() < REFRESH_AHEAD_MS
) {
credential = { kind: 'oauth', oauth: await this.refreshOAuth(credential.oauth) }
}
const url = buildUrl(this.profile.endpoint, path, options.query)
const hasBody = options.body !== undefined
const method = options.method ?? 'GET'
warnIfProxyIgnored()
warnIfCredentialOverCleartext(this.profile.endpoint, Boolean(credential))
// The caller's signal still cancels; the timeout only adds a second reason
// to abort, so neither can mask the other.
const timeoutMs = resolveTimeoutMs()
const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
const caller = combineSignals(options.signal, this.profile.signal)
if (caller?.aborted) throw new SimApiError('Request cancelled.', 0)
const signal = combineSignals(caller, timeout)
const trace = debugEnabled()
const startedAt = performance.now()
let response: Response
try {
response = await (this.profile.transport ?? fetch)(url, {
method,
headers: {
...(credential?.kind === 'api_key' ? { 'x-api-key': credential.apiKey } : {}),
...(credential?.kind === 'oauth'
? { authorization: `Bearer ${credential.oauth.accessToken}` }
: {}),
accept: 'application/json',
...identityHeaders(),
...(hasBody ? { 'content-type': 'application/json' } : {}),
...options.headers,
},
body: hasBody ? JSON.stringify(options.body) : undefined,
signal,
redirect: 'manual',
})
} catch (cause) {
if (trace) traceRequest(method, url, 'failed', startedAt)
if (caller?.aborted) {
throw new SimApiError('Request cancelled.', 0)
}
if (timeout?.aborted) {
throw new SimApiError(
`${url} did not answer within ${timeoutMs / 1000}s. ${RAISE_TIMEOUT_HINT}`,
0
)
}
throw new SimApiError(
`Could not reach ${this.profile.endpoint}: ${transportErrorMessage(cause)}`,
0
)
}
if (trace) traceRequest(method, url, response.status, startedAt)
if (REDIRECT_STATUSES.has(response.status)) throw this.toRedirectError(url, path, response)
/**
* A token the server no longer accepts — revoked, or expired on a clock
* this process disagrees with — is renewed once and the request repeated.
* Only for `invalid_token` (RFC 6750 §3.1): any other 401 means the
* refresh would not change the answer.
*/
if (
response.status === 401 &&
credential?.kind === 'oauth' &&
!retriedAfterRefresh &&
bearerChallengeError(response.headers.get('www-authenticate')) === 'invalid_token'
) {
await response.body?.cancel()
await this.refreshOAuth(credential.oauth)
return this.send(path, options, true)
}
if (!response.ok) {
const raw = await readResponseText(response)
const error = toApiError(url, response.status, response.headers.get('content-type'), raw)
if (response.status === 401) {
error.message = `${error.message} — run: sim login --profile ${this.profile.authProfile}`
}
if (namesKeyScopeRefusal(error)) {
error.message = `${error.message} — this operation does not support workspace API keys; use an OAuth login or personal API key: sim login --profile ${this.profile.authProfile}`
}
throw error
}
return { response, url }
}
/**
* Explains a redirect instead of following it, naming the endpoint to switch to.
*
* The destination comes from `Location` resolved against the request URL, so a
* relative target works and no string surgery is done on the configured
* endpoint. A `Location` that is missing or unparseable still has to produce a
* sentence — the redirect is the finding either way.
*/
private toRedirectError(url: string, path: string, response: Response): SimApiError {
const location = response.headers.get('location')?.trim()
let target: URL | null = null
if (location) {
try {
target = new URL(location, url)
} catch {
target = null
}
}
if (!target) {
return new SimApiError(
`${url} answered HTTP ${response.status} with no usable redirect target. Check the endpoint for profile "${this.profile.name}".`,
response.status
)
}
const suggested = redirectEndpoint(this.profile.endpoint, path, target)
if (!suggested) {
return new SimApiError(
`${url} redirected to ${target.href}. The CLI does not follow redirects, because a redirect can drop the request body and turn a write into a silent no-op.`,
response.status
)
}
return new SimApiError(
`Endpoint redirected to ${suggested}. Run: sim configure --profile ${this.profile.name} --set-endpoint ${suggested}`,
response.status
)
}
}
/**
* The endpoint a redirect implies, or null when it implies no change.
*
* Strips the request's own path from the target rather than taking
* `target.origin`, so a self-hosted endpoint carrying a path prefix
* (`https://host/sim`) keeps it. Naming the bare origin would hand back a value
* that is not an API root, and following that advice would break a deployment
* that was only ever one hostname away from working.
*
* Null when the target resolves to the endpoint already configured — a
* trailing-slash or path-normalization redirect keeps the origin, and telling
* someone to set the value they already have explains nothing.
*/
export function redirectEndpoint(
endpoint: string,
requestPath: string,
target: URL
): string | null {
const prefix = target.pathname.endsWith(requestPath)
? target.pathname.slice(0, target.pathname.length - requestPath.length)
: ''
const suggested = `${target.origin}${prefix}`.replace(/\/+$/, '')
return suggested === endpoint.replace(/\/+$/, '') ? null : suggested
}
export interface PageProgress {
/** Call once a further page is known to be coming, with the count so far. */
advance: (fetched: number) => void
/** Erases the line, if anything was ever written to it. */
finish: () => void
}
/**
* Reports cursor progress on stderr while a list keeps paging.
*
* A long cursor is many sequential requests and reads as a hang, so say so — but
* only on a terminal, and only on stderr, because stdout is what gets piped to
* `jq`.
*
* Shared because the CLI pages in two places: {@link requestAllPages} for the
* `ls` commands, and the contract-driven loop in `runtime/execute`, which also
* has to carry a cursor in the body. Only one of them had the writer, and it was
* not the one nearly every `list --limit 0` goes through.
*/
export function pageProgress(): PageProgress {
let reported = false
return {
advance: (fetched) => {
if (!hasProgressTerminal()) return
reported = true
writeStderr(`\r${styles().dim(`fetched ${fetched}…`)}\u001b[K`)
},
finish: () => {
if (reported) writeStderr('\r\u001b[K')
},
}
}
/** Rejects cursor cycles before a pager repeats requests or returns an unusable continuation. */
export function assertCursorAdvances(cursor: string | null, seenCursors: Set<string>): void {
if (cursor === null) return
if (seenCursors.has(cursor)) {
throw new SimApiError('The API returned a repeated pagination cursor; cannot continue.', 0)
}
seenCursors.add(cursor)
}
/** Follows a standard v2 cursor envelope without duplicating pagination loops. */
export async function requestAllPages<T>(
client: Pick<SimClient, 'request'>,
path: string,
options: RequestAllPagesOptions
): Promise<T[]> {
const { query, pageSize, limit: requestedLimit, ...requestOptions } = options
const limit = requestedLimit ?? Number.POSITIVE_INFINITY
if (limit <= 0) return []
const items: T[] = []
const seenCursors = new Set<string>()
const progress = pageProgress()
let cursor: string | null = null
// `finally`, because a page that throws part-way through would otherwise skip
// the cleanup and leave `fetched 1200…` sitting on the line the error is then
// written onto.
try {
do {
const page: V2Page<T> = await client.request<V2Page<T>>(path, {
...requestOptions,
query: {
...query,
limit: Math.min(pageSize, limit - items.length),
cursor,
},
})
assertCursorAdvances(page.nextCursor, seenCursors)
items.push(...page.data)
cursor = page.nextCursor
if (cursor && items.length < limit) progress.advance(items.length)
} while (cursor && items.length < limit)
} finally {
progress.finish()
}
return items.slice(0, limit)
}
/**
* Substitutes `[id]`-style path segments.
*
* Values are percent-encoded: table and workspace ids are opaque, and a `/` or
* `?` inside one would otherwise silently retarget the request at a different
* endpoint.
*/
export function resolvePath(template: string, params: Record<string, string> = {}): string {
return template.replace(/\[([^\]]+)\]/g, (_match, key: string) => {
const value = params[key]
if (value === undefined) {
throw new SimApiError(`Missing path parameter "${key}" for ${template}`, 0)
}
return encodeURIComponent(value)
})
}