adapty 0.8.5 → 0.8.7
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.
- package/README.md +7 -1
- package/dist/cli/base/adapty/adapty-command.d.ts +21 -0
- package/dist/cli/base/adapty/adapty-command.js +47 -0
- package/dist/cli/base/adapty/build.d.ts +11 -0
- package/dist/cli/base/adapty/build.js +13 -0
- package/dist/cli/base/adapty/index.d.ts +5 -0
- package/dist/cli/base/adapty/index.js +3 -0
- package/dist/cli/base/adapty/openSession.d.ts +22 -0
- package/dist/cli/base/adapty/openSession.js +25 -0
- package/dist/cli/base/base-command.d.ts +30 -0
- package/dist/cli/base/base-command.js +64 -0
- package/dist/cli/commands/apps/create.d.ts +19 -0
- package/dist/cli/commands/apps/create.js +68 -0
- package/dist/cli/commands/apps/get.d.ts +10 -0
- package/dist/cli/commands/apps/get.js +14 -0
- package/dist/cli/commands/apps/list.d.ts +11 -0
- package/dist/cli/commands/apps/list.js +14 -0
- package/dist/cli/commands/apps/update.d.ts +15 -0
- package/dist/cli/commands/apps/update.js +30 -0
- package/dist/cli/commands/auth/login.d.ts +11 -0
- package/dist/cli/commands/auth/login.js +68 -0
- package/dist/cli/commands/auth/logout.d.ts +12 -0
- package/dist/cli/commands/auth/logout.js +28 -0
- package/dist/cli/commands/auth/revoke.d.ts +13 -0
- package/dist/cli/commands/auth/revoke.js +32 -0
- package/dist/cli/commands/auth/status/index.d.ts +7 -0
- package/dist/cli/commands/auth/status/index.js +22 -0
- package/dist/cli/commands/auth/status/lib/render.d.ts +3 -0
- package/dist/cli/commands/auth/status/lib/render.js +14 -0
- package/dist/cli/commands/auth/status/lib/result.d.ts +15 -0
- package/dist/cli/commands/auth/status/lib/result.js +1 -0
- package/dist/cli/commands/auth/whoami.d.ts +10 -0
- package/dist/cli/commands/auth/whoami.js +16 -0
- package/dist/cli/errors.d.ts +39 -0
- package/dist/cli/errors.js +92 -0
- package/dist/cli/flags.d.ts +16 -0
- package/dist/cli/flags.js +27 -0
- package/dist/cli/views/list.d.ts +6 -0
- package/dist/cli/views/list.js +10 -0
- package/dist/cli/views/record.d.ts +9 -0
- package/dist/cli/views/record.js +14 -0
- package/dist/commands/apps/create.d.ts +1 -14
- package/dist/commands/apps/create.js +2 -57
- package/dist/commands/apps/get.d.ts +1 -11
- package/dist/commands/apps/get.js +2 -22
- package/dist/commands/apps/list.d.ts +1 -13
- package/dist/commands/apps/list.js +2 -19
- package/dist/commands/apps/update.d.ts +1 -16
- package/dist/commands/apps/update.js +2 -41
- package/dist/commands/asa/keywords/recommend.d.ts +20 -0
- package/dist/commands/asa/keywords/recommend.js +102 -0
- package/dist/commands/asa/whoami.js +6 -2
- package/dist/commands/auth/login.d.ts +1 -8
- package/dist/commands/auth/login.js +2 -122
- package/dist/commands/auth/logout.d.ts +1 -9
- package/dist/commands/auth/logout.js +2 -18
- package/dist/commands/auth/revoke.d.ts +1 -9
- package/dist/commands/auth/revoke.js +2 -21
- package/dist/commands/auth/status.d.ts +1 -7
- package/dist/commands/auth/status.js +2 -26
- package/dist/commands/auth/whoami.d.ts +1 -7
- package/dist/commands/auth/whoami.js +2 -15
- package/dist/lib/api-schemas.d.ts +7 -3
- package/dist/lib/asa-schemas.d.ts +41 -1
- package/dist/sdk/adapty/access-levels.d.ts +15 -0
- package/dist/sdk/adapty/access-levels.js +4 -0
- package/dist/sdk/adapty/apps/create.d.ts +26 -0
- package/dist/sdk/adapty/apps/create.js +36 -0
- package/dist/sdk/adapty/apps/index.d.ts +13 -0
- package/dist/sdk/adapty/apps/index.js +9 -0
- package/dist/sdk/adapty/apps/model.d.ts +24 -0
- package/dist/sdk/adapty/apps/model.js +10 -0
- package/dist/sdk/adapty/apps/resource.d.ts +19 -0
- package/dist/sdk/adapty/apps/resource.js +23 -0
- package/dist/sdk/adapty/apps/update.d.ts +16 -0
- package/dist/sdk/adapty/apps/update.js +25 -0
- package/dist/sdk/adapty/auth/device-code.d.ts +8 -0
- package/dist/sdk/adapty/auth/device-code.js +19 -0
- package/dist/sdk/adapty/auth/index.d.ts +7 -0
- package/dist/sdk/adapty/auth/index.js +5 -0
- package/dist/sdk/adapty/auth/model.d.ts +15 -0
- package/dist/sdk/adapty/auth/model.js +6 -0
- package/dist/sdk/adapty/auth/poll-token.d.ts +8 -0
- package/dist/sdk/adapty/auth/poll-token.js +58 -0
- package/dist/sdk/adapty/auth/resource.d.ts +17 -0
- package/dist/sdk/adapty/auth/resource.js +21 -0
- package/dist/sdk/adapty/errors.d.ts +10 -0
- package/dist/sdk/adapty/errors.js +38 -0
- package/dist/sdk/adapty/index.d.ts +36 -0
- package/dist/sdk/adapty/index.js +27 -0
- package/dist/sdk/adapty/pagination.d.ts +23 -0
- package/dist/sdk/adapty/pagination.js +1 -0
- package/dist/sdk/core/auth/device-flow.d.ts +46 -0
- package/dist/sdk/core/auth/device-flow.js +62 -0
- package/dist/sdk/core/clock.d.ts +7 -0
- package/dist/sdk/core/clock.js +21 -0
- package/dist/sdk/core/errors.d.ts +81 -0
- package/dist/sdk/core/errors.js +84 -0
- package/dist/sdk/core/http/client.d.ts +46 -0
- package/dist/sdk/core/http/client.js +127 -0
- package/dist/sdk/core/http/index.d.ts +11 -0
- package/dist/sdk/core/http/index.js +7 -0
- package/dist/sdk/core/http/policies.d.ts +14 -0
- package/dist/sdk/core/http/policies.js +29 -0
- package/dist/sdk/core/http/retry.d.ts +27 -0
- package/dist/sdk/core/http/retry.js +22 -0
- package/dist/sdk/core/http/url.d.ts +3 -0
- package/dist/sdk/core/http/url.js +19 -0
- package/dist/sdk/core/session.d.ts +20 -0
- package/dist/sdk/core/session.js +69 -0
- package/dist/sdk/core/testing.d.ts +25 -0
- package/dist/sdk/core/testing.js +46 -0
- package/dist/sdk/core/validation.d.ts +10 -0
- package/dist/sdk/core/validation.js +14 -0
- package/oclif.manifest.json +211 -148
- package/package.json +10 -2
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sdk errors carry no user-facing text and no exit code: the adapter assigns both
|
|
3
|
+
* (src/cli/errors.ts, and a future MCP server).
|
|
4
|
+
*
|
|
5
|
+
* Every error has a stable `kind`, and the adapter switches over it. An instanceof chain would
|
|
6
|
+
* let a new error class fall through to the fallback and become exit 1 with no readable text.
|
|
7
|
+
*/
|
|
8
|
+
/** Abstract on purpose: `new SdkError(...)` would be an error without a kind, unrecognisable. */
|
|
9
|
+
export class SdkError extends Error {
|
|
10
|
+
constructor(message, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** No response at all: DNS, refused connection, a dropped socket. */
|
|
16
|
+
export class NetworkError extends SdkError {
|
|
17
|
+
kind = 'network';
|
|
18
|
+
url;
|
|
19
|
+
constructor(url, cause) {
|
|
20
|
+
super(`Request to ${url} failed`, { cause });
|
|
21
|
+
this.url = url;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** The server answered with an error. `code` and `details` follow the service's own format. */
|
|
25
|
+
export class ApiError extends SdkError {
|
|
26
|
+
kind = 'api';
|
|
27
|
+
code;
|
|
28
|
+
details;
|
|
29
|
+
retryAfterMs;
|
|
30
|
+
status;
|
|
31
|
+
constructor(init) {
|
|
32
|
+
super(init.message);
|
|
33
|
+
this.status = init.status;
|
|
34
|
+
this.code = init.code;
|
|
35
|
+
this.details = init.details;
|
|
36
|
+
this.retryAfterMs = init.retryAfterMs;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class AuthRequiredError extends SdkError {
|
|
40
|
+
kind = 'auth_required';
|
|
41
|
+
reason;
|
|
42
|
+
constructor(reason) {
|
|
43
|
+
super(reason === 'missing' ? 'Not authenticated' : 'Token expired or invalid');
|
|
44
|
+
this.reason = reason;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class CancelledError extends SdkError {
|
|
48
|
+
kind = 'cancelled';
|
|
49
|
+
constructor() {
|
|
50
|
+
super('Operation cancelled');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** A business rule broken before any network call. */
|
|
54
|
+
export class ValidationError extends SdkError {
|
|
55
|
+
kind = 'validation';
|
|
56
|
+
issues;
|
|
57
|
+
constructor(issues) {
|
|
58
|
+
super(issues.map(issue => (issue.path === undefined ? issue.message : `${issue.path}: ${issue.message}`)).join('; '));
|
|
59
|
+
this.issues = issues;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class DeviceFlowExpiredError extends SdkError {
|
|
63
|
+
kind = 'device_flow_expired';
|
|
64
|
+
constructor() {
|
|
65
|
+
super('Device code expired before authorization');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class DeviceFlowDeniedError extends SdkError {
|
|
69
|
+
kind = 'device_flow_denied';
|
|
70
|
+
constructor() {
|
|
71
|
+
super('Authorization denied');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** The session file is there but unusable: corrupted JSON, or something else in its place. */
|
|
75
|
+
export class StorageError extends SdkError {
|
|
76
|
+
kind = 'storage';
|
|
77
|
+
path;
|
|
78
|
+
constructor(message, path, options) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.path = path;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export const isSdkError = (error) => error instanceof SdkError;
|
|
84
|
+
export const isAbortError = (error) => error instanceof Error && error.name === 'AbortError';
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
import type { ErrorParser, ShouldRetry } from './policies.js';
|
|
3
|
+
import type { RetryAttempt, RetryPolicy } from './retry.js';
|
|
4
|
+
import type { QueryParams } from './url.js';
|
|
5
|
+
export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
|
|
6
|
+
export type RequestOptions = {
|
|
7
|
+
/** A JSON-serializable value, or FormData for file uploads. */
|
|
8
|
+
body?: unknown;
|
|
9
|
+
headers?: Record<string, string> | undefined;
|
|
10
|
+
/** Retry on 429, 5xx and network errors. On by default for GET only. */
|
|
11
|
+
idempotent?: boolean | undefined;
|
|
12
|
+
/** Response headers, e.g. an ETag for optimistic locking. */
|
|
13
|
+
onResponse?: ((headers: Headers) => void) | undefined;
|
|
14
|
+
query?: QueryParams | undefined;
|
|
15
|
+
signal?: AbortSignal | undefined;
|
|
16
|
+
};
|
|
17
|
+
type BodylessOptions = Omit<RequestOptions, 'body'>;
|
|
18
|
+
export type Http = {
|
|
19
|
+
delete<T>(path: string, options?: BodylessOptions): Promise<T>;
|
|
20
|
+
get<T>(path: string, options?: BodylessOptions): Promise<T>;
|
|
21
|
+
patch<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
22
|
+
post<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
23
|
+
put<T>(path: string, body?: unknown, options?: BodylessOptions): Promise<T>;
|
|
24
|
+
request<T>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
|
|
25
|
+
};
|
|
26
|
+
export type HttpOptions = {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
clock?: Clock | undefined;
|
|
29
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
30
|
+
headers?: Record<string, string> | undefined;
|
|
31
|
+
onRetry?: ((info: RetryAttempt) => void) | undefined;
|
|
32
|
+
parseError?: ErrorParser | undefined;
|
|
33
|
+
retry?: RetryPolicy | undefined;
|
|
34
|
+
/** When to retry an idempotent request. */
|
|
35
|
+
shouldRetry?: ShouldRetry | undefined;
|
|
36
|
+
signal?: AbortSignal | undefined;
|
|
37
|
+
token?: string | undefined;
|
|
38
|
+
/** Trailing slash in paths (Django style). On by default. */
|
|
39
|
+
trailingSlash?: boolean | undefined;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors,
|
|
43
|
+
* retry for idempotent requests. It knows nothing about resources or about the CLI.
|
|
44
|
+
*/
|
|
45
|
+
export declare const createHttp: (options: HttpOptions) => Http;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { systemClock } from '../clock.js';
|
|
2
|
+
import { ApiError, AuthRequiredError, CancelledError, isAbortError, NetworkError } from '../errors.js';
|
|
3
|
+
import { defaultErrorParser, defaultShouldRetry } from './policies.js';
|
|
4
|
+
import { defaultRetryPolicy, retry } from './retry.js';
|
|
5
|
+
import { buildUrl } from './url.js';
|
|
6
|
+
/**
|
|
7
|
+
* The transport: base URL, bearer token, JSON both ways, responses mapped to sdk errors,
|
|
8
|
+
* retry for idempotent requests. It knows nothing about resources or about the CLI.
|
|
9
|
+
*/
|
|
10
|
+
export const createHttp = (options) => {
|
|
11
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
12
|
+
const clock = options.clock ?? systemClock;
|
|
13
|
+
const policy = options.retry ?? defaultRetryPolicy;
|
|
14
|
+
const shouldRetry = options.shouldRetry ?? defaultShouldRetry;
|
|
15
|
+
const parseError = options.parseError ?? defaultErrorParser;
|
|
16
|
+
const trailingSlash = options.trailingSlash ?? true;
|
|
17
|
+
const send = async (method, path, req) => {
|
|
18
|
+
const url = buildUrl(options.baseUrl, path, req.query, trailingSlash);
|
|
19
|
+
const signal = combineSignals(options.signal, req.signal);
|
|
20
|
+
const headers = new Headers({ accept: 'application/json', ...options.headers, ...req.headers });
|
|
21
|
+
if (options.token !== undefined) {
|
|
22
|
+
headers.set('authorization', `Bearer ${options.token}`);
|
|
23
|
+
}
|
|
24
|
+
const init = { headers, method };
|
|
25
|
+
if (req.body instanceof FormData) {
|
|
26
|
+
init.body = req.body;
|
|
27
|
+
}
|
|
28
|
+
else if (req.body !== undefined) {
|
|
29
|
+
headers.set('content-type', 'application/json');
|
|
30
|
+
init.body = JSON.stringify(req.body);
|
|
31
|
+
}
|
|
32
|
+
if (signal) {
|
|
33
|
+
init.signal = signal;
|
|
34
|
+
}
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetchImpl(url, init);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (signal?.aborted === true || isAbortError(error)) {
|
|
41
|
+
throw new CancelledError();
|
|
42
|
+
}
|
|
43
|
+
throw new NetworkError(url, error);
|
|
44
|
+
}
|
|
45
|
+
req.onResponse?.(response.headers);
|
|
46
|
+
// fetch resolves at the headers; reading the body can still fail or be cancelled.
|
|
47
|
+
// Keep the caller's onResponse callback outside this network-error conversion.
|
|
48
|
+
let payload;
|
|
49
|
+
try {
|
|
50
|
+
payload = await readBody(response);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (signal?.aborted === true || isAbortError(error)) {
|
|
54
|
+
throw new CancelledError();
|
|
55
|
+
}
|
|
56
|
+
throw new NetworkError(url, error);
|
|
57
|
+
}
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
return payload;
|
|
60
|
+
}
|
|
61
|
+
if (response.status === 401) {
|
|
62
|
+
throw new AuthRequiredError('rejected');
|
|
63
|
+
}
|
|
64
|
+
const parsed = parseError(response.status, payload);
|
|
65
|
+
throw new ApiError({
|
|
66
|
+
code: parsed.code ?? `http_${response.status}`,
|
|
67
|
+
details: payload,
|
|
68
|
+
message: parsed.message ?? `${method} ${path} failed with HTTP ${response.status}`,
|
|
69
|
+
retryAfterMs: parseRetryAfter(response.headers.get('retry-after'), clock),
|
|
70
|
+
status: response.status,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const request = (method, path, req = {}) => {
|
|
74
|
+
const idempotent = req.idempotent ?? method === 'GET';
|
|
75
|
+
if (!idempotent) {
|
|
76
|
+
return send(method, path, req);
|
|
77
|
+
}
|
|
78
|
+
return retry(() => send(method, path, req), {
|
|
79
|
+
clock,
|
|
80
|
+
onRetry: options.onRetry,
|
|
81
|
+
policy,
|
|
82
|
+
shouldRetry,
|
|
83
|
+
signal: combineSignals(options.signal, req.signal),
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
request,
|
|
88
|
+
delete: (path, req) => request('DELETE', path, req),
|
|
89
|
+
get: (path, req) => request('GET', path, req),
|
|
90
|
+
patch: (path, body, req) => request('PATCH', path, { ...req, body }),
|
|
91
|
+
post: (path, body, req) => request('POST', path, { ...req, body }),
|
|
92
|
+
put: (path, body, req) => request('PUT', path, { ...req, body }),
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
const combineSignals = (...signals) => {
|
|
96
|
+
const present = signals.filter((signal) => signal !== undefined);
|
|
97
|
+
if (present.length <= 1) {
|
|
98
|
+
return present[0];
|
|
99
|
+
}
|
|
100
|
+
return AbortSignal.any(present);
|
|
101
|
+
};
|
|
102
|
+
const readBody = async (response) => {
|
|
103
|
+
if (response.status === 204) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
if (text.length === 0) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return JSON.parse(text);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return text;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const parseRetryAfter = (header, clock) => {
|
|
118
|
+
if (header === null) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const seconds = Number(header);
|
|
122
|
+
if (Number.isFinite(seconds)) {
|
|
123
|
+
return Math.max(0, seconds * 1000);
|
|
124
|
+
}
|
|
125
|
+
const date = Date.parse(header);
|
|
126
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - clock.now());
|
|
127
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport as a module: one door out. An eslint zone fences off the internals (client,
|
|
3
|
+
* policies, retry, url), so they can be rearranged without touching products.
|
|
4
|
+
*/
|
|
5
|
+
export { createHttp } from './client.js';
|
|
6
|
+
export type { Http, HttpMethod, HttpOptions, RequestOptions } from './client.js';
|
|
7
|
+
export { defaultErrorParser, defaultShouldRetry } from './policies.js';
|
|
8
|
+
export type { ErrorParser, ShouldRetry } from './policies.js';
|
|
9
|
+
export { defaultRetryPolicy, retry } from './retry.js';
|
|
10
|
+
export type { RetryAttempt, RetryDecision, RetryOptions, RetryPolicy } from './retry.js';
|
|
11
|
+
export type { QueryParams, QueryValue } from './url.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport as a module: one door out. An eslint zone fences off the internals (client,
|
|
3
|
+
* policies, retry, url), so they can be rearranged without touching products.
|
|
4
|
+
*/
|
|
5
|
+
export { createHttp } from './client.js';
|
|
6
|
+
export { defaultErrorParser, defaultShouldRetry } from './policies.js';
|
|
7
|
+
export { defaultRetryPolicy, retry } from './retry.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RetryDecision } from './retry.js';
|
|
2
|
+
/** How to pull code and message out of an error body. Developer API and ASA differ, so it is a parameter. */
|
|
3
|
+
export type ErrorParser = (status: number, body: unknown) => {
|
|
4
|
+
code?: string | undefined;
|
|
5
|
+
message?: string | undefined;
|
|
6
|
+
};
|
|
7
|
+
export type ShouldRetry = (error: unknown, attempt: number) => RetryDecision;
|
|
8
|
+
/** Default: network errors with backoff, 429 and 5xx after the delay the server asked for, if any. */
|
|
9
|
+
export declare const defaultShouldRetry: ShouldRetry;
|
|
10
|
+
/**
|
|
11
|
+
* The default error format, tolerant of the three common body shapes:
|
|
12
|
+
* { error: 'code', error_description }, { error: { code, message } }, { code, message | detail }.
|
|
13
|
+
*/
|
|
14
|
+
export declare const defaultErrorParser: ErrorParser;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ApiError, NetworkError } from '../errors.js';
|
|
2
|
+
/** Default: network errors with backoff, 429 and 5xx after the delay the server asked for, if any. */
|
|
3
|
+
export const defaultShouldRetry = (error) => {
|
|
4
|
+
if (error instanceof NetworkError) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
if (error instanceof ApiError && (error.status === 429 || error.status >= 500)) {
|
|
8
|
+
return { delayMs: error.retryAfterMs };
|
|
9
|
+
}
|
|
10
|
+
return false;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The default error format, tolerant of the three common body shapes:
|
|
14
|
+
* { error: 'code', error_description }, { error: { code, message } }, { code, message | detail }.
|
|
15
|
+
*/
|
|
16
|
+
export const defaultErrorParser = (_status, body) => {
|
|
17
|
+
if (!isRecord(body)) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
if (typeof body.error === 'string') {
|
|
21
|
+
return { code: body.error, message: asString(body.error_description ?? body.message) };
|
|
22
|
+
}
|
|
23
|
+
if (isRecord(body.error)) {
|
|
24
|
+
return { code: asString(body.error.code), message: asString(body.error.message) };
|
|
25
|
+
}
|
|
26
|
+
return { code: asString(body.code), message: asString(body.message ?? body.detail) };
|
|
27
|
+
};
|
|
28
|
+
const isRecord = (value) => typeof value === 'object' && value !== null;
|
|
29
|
+
const asString = (value) => (typeof value === 'string' ? value : undefined);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Clock } from '../clock.js';
|
|
2
|
+
export type RetryPolicy = {
|
|
3
|
+
/** Total attempts, the first one included. */
|
|
4
|
+
attempts: number;
|
|
5
|
+
baseDelayMs: number;
|
|
6
|
+
maxDelayMs: number;
|
|
7
|
+
};
|
|
8
|
+
export declare const defaultRetryPolicy: RetryPolicy;
|
|
9
|
+
/** false: do not retry. An object: retry, optionally after the delay the server suggested. */
|
|
10
|
+
export type RetryDecision = false | {
|
|
11
|
+
delayMs?: number | undefined;
|
|
12
|
+
};
|
|
13
|
+
export type RetryAttempt = {
|
|
14
|
+
/** Number of the failed attempt, starting at 1. */
|
|
15
|
+
attempt: number;
|
|
16
|
+
delayMs: number;
|
|
17
|
+
error: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type RetryOptions = {
|
|
20
|
+
clock: Clock;
|
|
21
|
+
/** Called before the pause. The sdk never prints: cli warns, MCP stays quiet. */
|
|
22
|
+
onRetry?: ((info: RetryAttempt) => void) | undefined;
|
|
23
|
+
policy: RetryPolicy;
|
|
24
|
+
shouldRetry: (error: unknown, attempt: number) => RetryDecision;
|
|
25
|
+
signal?: AbortSignal | undefined;
|
|
26
|
+
};
|
|
27
|
+
export declare const retry: <T>(fn: () => Promise<T>, options: RetryOptions) => Promise<T>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { throwIfAborted } from '../clock.js';
|
|
2
|
+
export const defaultRetryPolicy = { attempts: 3, baseDelayMs: 500, maxDelayMs: 8000 };
|
|
3
|
+
export const retry = async (fn, options) => {
|
|
4
|
+
const { clock, policy, signal } = options;
|
|
5
|
+
for (let attempt = 1;; attempt += 1) {
|
|
6
|
+
throwIfAborted(signal);
|
|
7
|
+
try {
|
|
8
|
+
return await fn();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const decision = attempt < policy.attempts ? options.shouldRetry(error, attempt) : false;
|
|
12
|
+
if (decision === false) {
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
const delayMs = decision.delayMs ?? backoffDelay(policy, attempt);
|
|
16
|
+
options.onRetry?.({ attempt, delayMs, error });
|
|
17
|
+
await clock.sleep(delayMs, signal);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** Exponential backoff without jitter: deterministic tests are worth more than spread here. */
|
|
22
|
+
const backoffDelay = (policy, attempt) => Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const buildUrl = (baseUrl, path, query, trailingSlash) => {
|
|
2
|
+
// A relative path against a base ending in a slash keeps a prefix like /api/v1/developer
|
|
3
|
+
const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
|
4
|
+
let relative = path.replace(/^\/+/, '');
|
|
5
|
+
if (trailingSlash && relative !== '' && !relative.endsWith('/')) {
|
|
6
|
+
relative += '/';
|
|
7
|
+
}
|
|
8
|
+
const url = new URL(relative, base);
|
|
9
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
const items = typeof value === 'object' ? value : [value];
|
|
14
|
+
for (const item of items) {
|
|
15
|
+
url.searchParams.append(key, String(item));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return url.toString();
|
|
19
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type SessionUser = {
|
|
2
|
+
email: string;
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
export type Session = {
|
|
6
|
+
token: string;
|
|
7
|
+
user?: SessionUser | undefined;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Where the session lives is a port, not a path: a keychain, a CI secret store or an in-memory
|
|
11
|
+
* store for an MCP server plug into this same type without touching a command.
|
|
12
|
+
*/
|
|
13
|
+
export type SessionStore = {
|
|
14
|
+
clear(): Promise<void>;
|
|
15
|
+
load(): Promise<Session | undefined>;
|
|
16
|
+
readonly path: string;
|
|
17
|
+
save(session: Session): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/** A file under a directory the adapter picks: the sdk touches neither HOME nor process.env. */
|
|
20
|
+
export declare const createFileSessionStore: (dir: string) => SessionStore;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { StorageError } from './errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* The file and field names the published CLI already writes. A rename would log every existing
|
|
6
|
+
* user out, so the on-disk contract stays `{ "access_token", "user": { "email", "name" } }`.
|
|
7
|
+
*/
|
|
8
|
+
const FILE_NAME = 'config.json';
|
|
9
|
+
/** A file under a directory the adapter picks: the sdk touches neither HOME nor process.env. */
|
|
10
|
+
export const createFileSessionStore = (dir) => {
|
|
11
|
+
const path = join(dir, FILE_NAME);
|
|
12
|
+
return {
|
|
13
|
+
path,
|
|
14
|
+
clear: async () => {
|
|
15
|
+
await rm(path, { force: true });
|
|
16
|
+
},
|
|
17
|
+
load: async () => {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = await readFile(path, 'utf8');
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (isErrno(error, 'ENOENT')) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// No permission, a broken disk: not ours to paper over as "not logged in"
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(raw);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new StorageError(`Session file is corrupted: ${path}`, path, { cause: error });
|
|
35
|
+
}
|
|
36
|
+
// An unknown shape is most likely a file from another version: asking for a fresh
|
|
37
|
+
// login beats failing every command.
|
|
38
|
+
return toSession(parsed);
|
|
39
|
+
},
|
|
40
|
+
save: async (session) => {
|
|
41
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
await writeFile(path, `${JSON.stringify(toStored(session), null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
43
|
+
// `mode` above applies only when the file is created; an existing 0644 one (a hand
|
|
44
|
+
// edit, a restored backup) would leave the token readable for everyone on the machine.
|
|
45
|
+
await chmod(path, 0o600);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
const toStored = (session) => (session.user === undefined
|
|
50
|
+
? { access_token: session.token }
|
|
51
|
+
: { access_token: session.token, user: session.user });
|
|
52
|
+
/** Not a full schema check: only the minimum the rest of the code leans on. */
|
|
53
|
+
const toSession = (parsed) => {
|
|
54
|
+
const record = asRecord(parsed);
|
|
55
|
+
if (record === undefined || typeof record.access_token !== 'string' || record.access_token === '') {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const user = toUser(record.user);
|
|
59
|
+
return user === undefined ? { token: record.access_token } : { token: record.access_token, user };
|
|
60
|
+
};
|
|
61
|
+
const toUser = (value) => {
|
|
62
|
+
const record = asRecord(value);
|
|
63
|
+
if (record === undefined || typeof record.email !== 'string' || typeof record.name !== 'string') {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
return { email: record.email, name: record.name };
|
|
67
|
+
};
|
|
68
|
+
const asRecord = (value) => (typeof value === 'object' && value !== null ? value : undefined);
|
|
69
|
+
const isErrno = (error, code) => error instanceof Error && 'code' in error && error.code === code;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Clock } from './clock.js';
|
|
2
|
+
export type FakeClock = {
|
|
3
|
+
advance(ms: number): void;
|
|
4
|
+
/** Durations of every sleep, in order. */
|
|
5
|
+
readonly sleeps: number[];
|
|
6
|
+
} & Clock;
|
|
7
|
+
/** A clock whose sleep moves time forward instantly. */
|
|
8
|
+
export declare const createFakeClock: (start?: number) => FakeClock;
|
|
9
|
+
export type ScriptedResponse = {
|
|
10
|
+
body?: unknown;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
status?: number;
|
|
13
|
+
};
|
|
14
|
+
export type RecordedCall = {
|
|
15
|
+
body: string | undefined;
|
|
16
|
+
headers: Headers;
|
|
17
|
+
method: string;
|
|
18
|
+
url: string;
|
|
19
|
+
};
|
|
20
|
+
export type ScriptedFetch = {
|
|
21
|
+
calls: RecordedCall[];
|
|
22
|
+
fetch: typeof globalThis.fetch;
|
|
23
|
+
};
|
|
24
|
+
/** Answers with the scripted responses in order; an Error in the script is thrown, as fetch would. */
|
|
25
|
+
export declare const createScriptedFetch: (script: readonly (Error | ScriptedResponse)[]) => ScriptedFetch;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Fakes for the sdk and its consumers: cli, MCP and any other adapter test with the same tools. */
|
|
2
|
+
import { throwIfAborted } from './clock.js';
|
|
3
|
+
/** A clock whose sleep moves time forward instantly. */
|
|
4
|
+
export const createFakeClock = (start = 0) => {
|
|
5
|
+
let time = start;
|
|
6
|
+
const sleeps = [];
|
|
7
|
+
return {
|
|
8
|
+
sleeps,
|
|
9
|
+
now: () => time,
|
|
10
|
+
advance: (ms) => {
|
|
11
|
+
time += ms;
|
|
12
|
+
},
|
|
13
|
+
sleep: (ms, signal) => {
|
|
14
|
+
throwIfAborted(signal);
|
|
15
|
+
sleeps.push(ms);
|
|
16
|
+
time += ms;
|
|
17
|
+
return Promise.resolve();
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/** Answers with the scripted responses in order; an Error in the script is thrown, as fetch would. */
|
|
22
|
+
export const createScriptedFetch = (script) => {
|
|
23
|
+
const queue = [...script];
|
|
24
|
+
const calls = [];
|
|
25
|
+
const fetch = (input, init) => {
|
|
26
|
+
calls.push({
|
|
27
|
+
body: typeof init?.body === 'string' ? init.body : undefined,
|
|
28
|
+
headers: new Headers(init?.headers),
|
|
29
|
+
method: init?.method ?? 'GET',
|
|
30
|
+
url: String(input instanceof Request ? input.url : input),
|
|
31
|
+
});
|
|
32
|
+
const next = queue.shift();
|
|
33
|
+
if (next === undefined) {
|
|
34
|
+
return Promise.reject(new Error(`scripted fetch: no response for call #${calls.length}`));
|
|
35
|
+
}
|
|
36
|
+
if (next instanceof Error) {
|
|
37
|
+
return Promise.reject(next);
|
|
38
|
+
}
|
|
39
|
+
// ResponseInit.headers is readonly in @types/node, so the init is built in one go
|
|
40
|
+
const status = next.status ?? 200;
|
|
41
|
+
const responseInit = next.headers ? { headers: next.headers, status } : { status };
|
|
42
|
+
const body = next.body === undefined ? null : JSON.stringify(next.body);
|
|
43
|
+
return Promise.resolve(new Response(body, responseInit));
|
|
44
|
+
};
|
|
45
|
+
return { calls, fetch };
|
|
46
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Issue } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns a rule's problems into a throw — the only place that does. Rules return lists instead of
|
|
4
|
+
* throwing, so a table test walks them without an environment and the user sees every problem at
|
|
5
|
+
* once.
|
|
6
|
+
*
|
|
7
|
+
* Call it from an `async` method: a broken rule then arrives as a rejected promise, like a network
|
|
8
|
+
* failure, instead of a throw the caller has to handle separately.
|
|
9
|
+
*/
|
|
10
|
+
export declare const assertValid: (issues: readonly Issue[]) => void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ValidationError } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turns a rule's problems into a throw — the only place that does. Rules return lists instead of
|
|
4
|
+
* throwing, so a table test walks them without an environment and the user sees every problem at
|
|
5
|
+
* once.
|
|
6
|
+
*
|
|
7
|
+
* Call it from an `async` method: a broken rule then arrives as a rejected promise, like a network
|
|
8
|
+
* failure, instead of a throw the caller has to handle separately.
|
|
9
|
+
*/
|
|
10
|
+
export const assertValid = (issues) => {
|
|
11
|
+
if (issues.length > 0) {
|
|
12
|
+
throw new ValidationError(issues);
|
|
13
|
+
}
|
|
14
|
+
};
|