zinkee 0.1.44 → 0.1.45
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 +4 -0
- package/dist/chunk-MHEVRMDX.js +1230 -0
- package/dist/chunk-MHEVRMDX.js.map +1 -0
- package/{src/utils/examples.ts → dist/examples-E6FFEVBE.js} +856 -1141
- package/dist/examples-E6FFEVBE.js.map +1 -0
- package/dist/index.js +1145 -8456
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
- package/.github/workflows/npm-publish.yml +0 -77
- package/.github/workflows/pr-checks.yml +0 -36
- package/AGENTS.md +0 -110
- package/docs/cli-contract.md +0 -41
- package/docs/npm-release.md +0 -64
- package/docs/superpowers/plans/2026-03-24-zinkee-cli-implementation.md +0 -837
- package/docs/superpowers/plans/2026-03-25-cli-backend-error-contract.md +0 -503
- package/docs/superpowers/plans/2026-03-26-display-freeform-create.md +0 -389
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-backend-blockers.md +0 -172
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-design.md +0 -1576
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-e2e-checklist.md +0 -215
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-e2e-design.md +0 -492
- package/docs/superpowers/specs/2026-03-24-zinkee-cli-e2e-status.md +0 -307
- package/docs/superpowers/specs/2026-07-30-cli-pr-checks-design.md +0 -37
- package/src/api/automations.ts +0 -404
- package/src/api/comments.ts +0 -51
- package/src/api/displays.ts +0 -337
- package/src/api/document-templates.ts +0 -110
- package/src/api/files.ts +0 -50
- package/src/api/formulas.test.ts +0 -65
- package/src/api/formulas.ts +0 -67
- package/src/api/logs.ts +0 -34
- package/src/api/navigation.ts +0 -70
- package/src/api/records.ts +0 -184
- package/src/api/schemas.ts +0 -148
- package/src/api/teamspace.ts +0 -110
- package/src/cli-examples.ts +0 -130
- package/src/cli-runner.ts +0 -95
- package/src/client.test.ts +0 -189
- package/src/client.ts +0 -269
- package/src/command-registry.ts +0 -882
- package/src/commands/automations.test.ts +0 -1030
- package/src/commands/automations.ts +0 -2102
- package/src/commands/comments.test.ts +0 -214
- package/src/commands/comments.ts +0 -303
- package/src/commands/config.test.ts +0 -81
- package/src/commands/config.ts +0 -150
- package/src/commands/displays.test.ts +0 -1105
- package/src/commands/displays.ts +0 -1442
- package/src/commands/document-templates.test.ts +0 -569
- package/src/commands/document-templates.ts +0 -563
- package/src/commands/files.test.ts +0 -284
- package/src/commands/files.ts +0 -280
- package/src/commands/formulas.test.ts +0 -194
- package/src/commands/formulas.ts +0 -243
- package/src/commands/logs.test.ts +0 -123
- package/src/commands/logs.ts +0 -159
- package/src/commands/navigation.test.ts +0 -211
- package/src/commands/navigation.ts +0 -348
- package/src/commands/profiles.test.ts +0 -191
- package/src/commands/profiles.ts +0 -303
- package/src/commands/records.test.ts +0 -860
- package/src/commands/records.ts +0 -883
- package/src/commands/schemas.test.ts +0 -1252
- package/src/commands/schemas.ts +0 -890
- package/src/commands/teamspace.test.ts +0 -229
- package/src/commands/teamspace.ts +0 -546
- package/src/completion/engine.test.ts +0 -138
- package/src/completion/engine.ts +0 -168
- package/src/completion/install.test.ts +0 -179
- package/src/completion/install.ts +0 -260
- package/src/completion/runtime.ts +0 -150
- package/src/completion/scripts.ts +0 -91
- package/src/config.test.ts +0 -362
- package/src/config.ts +0 -294
- package/src/index.test.ts +0 -217
- package/src/index.ts +0 -8
- package/src/parsers/expressions.test.ts +0 -95
- package/src/parsers/expressions.ts +0 -128
- package/src/parsers/kv.test.ts +0 -35
- package/src/parsers/kv.ts +0 -49
- package/src/parsers/selectors.test.ts +0 -23
- package/src/parsers/selectors.ts +0 -29
- package/src/program.ts +0 -64
- package/src/runtime-context.ts +0 -103
- package/src/types.ts +0 -76
- package/src/utils/argv-rewrite.test.ts +0 -149
- package/src/utils/argv-rewrite.ts +0 -269
- package/src/utils/errors.test.ts +0 -85
- package/src/utils/errors.ts +0 -191
- package/src/utils/examples.test.ts +0 -374
- package/src/utils/output.test.ts +0 -65
- package/src/utils/output.ts +0 -154
- package/src/utils/schema-fields.ts +0 -1016
- package/tsconfig.json +0 -20
- package/tsup.config.ts +0 -13
package/src/cli-runner.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { realpathSync } from "node:fs";
|
|
2
|
-
import { fileURLToPath } from "node:url";
|
|
3
|
-
|
|
4
|
-
import chalk from "chalk";
|
|
5
|
-
|
|
6
|
-
import { extractExampleRequest, showExamples } from "./cli-examples.js";
|
|
7
|
-
import { commandRegistry } from "./command-registry.js";
|
|
8
|
-
import {
|
|
9
|
-
extractCompletionFlag,
|
|
10
|
-
handleCompletionFlag,
|
|
11
|
-
isCompletionRequest,
|
|
12
|
-
runCompletionRequest,
|
|
13
|
-
} from "./completion/runtime.js";
|
|
14
|
-
import { buildProgram } from "./program.js";
|
|
15
|
-
import {
|
|
16
|
-
buildOptionMetadata,
|
|
17
|
-
hoistGlobalOptions,
|
|
18
|
-
} from "./utils/argv-rewrite.js";
|
|
19
|
-
import {
|
|
20
|
-
formatCliErrorJson,
|
|
21
|
-
formatCliErrorText,
|
|
22
|
-
getExitCode,
|
|
23
|
-
} from "./utils/errors.js";
|
|
24
|
-
|
|
25
|
-
class ExampleDisplayed extends Error {
|
|
26
|
-
override name = "ExampleDisplayed";
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function hasJsonOutput(argv: readonly string[]): boolean {
|
|
30
|
-
return argv.includes("--json");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function resolveExecutablePath(candidatePath: string): string {
|
|
34
|
-
try {
|
|
35
|
-
return realpathSync(candidatePath);
|
|
36
|
-
} catch {
|
|
37
|
-
return candidatePath;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function isExecutedAsEntry(argvEntry: string | undefined, moduleUrl: string): boolean {
|
|
42
|
-
if (!argvEntry) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return resolveExecutablePath(argvEntry) === resolveExecutablePath(fileURLToPath(moduleUrl));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function runCli(argv: string[]): Promise<void> {
|
|
50
|
-
try {
|
|
51
|
-
if (isCompletionRequest()) {
|
|
52
|
-
runCompletionRequest();
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const completionFlag = extractCompletionFlag(argv);
|
|
57
|
-
if (completionFlag) {
|
|
58
|
-
const exitCode = handleCompletionFlag(completionFlag);
|
|
59
|
-
process.exitCode = exitCode;
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const program = buildProgram();
|
|
64
|
-
const optionMeta = buildOptionMetadata(program);
|
|
65
|
-
const exampleRequest = extractExampleRequest(argv, optionMeta);
|
|
66
|
-
|
|
67
|
-
if (exampleRequest) {
|
|
68
|
-
showExamples(
|
|
69
|
-
exampleRequest.commandName,
|
|
70
|
-
exampleRequest.json,
|
|
71
|
-
undefined,
|
|
72
|
-
exampleRequest.filters,
|
|
73
|
-
);
|
|
74
|
-
throw new ExampleDisplayed();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const rewrittenArgv = hoistGlobalOptions(argv, optionMeta, commandRegistry);
|
|
78
|
-
await program.parseAsync(rewrittenArgv);
|
|
79
|
-
} catch (error) {
|
|
80
|
-
if (error instanceof ExampleDisplayed) {
|
|
81
|
-
process.exitCode = 0;
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const exitCode = getExitCode(error);
|
|
86
|
-
|
|
87
|
-
if (hasJsonOutput(argv)) {
|
|
88
|
-
process.stderr.write(`${formatCliErrorJson(error, { exitCode })}\n`);
|
|
89
|
-
} else {
|
|
90
|
-
process.stderr.write(`${chalk.red("Error:")} ${formatCliErrorText(error)}\n`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
process.exitCode = exitCode;
|
|
94
|
-
}
|
|
95
|
-
}
|
package/src/client.test.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { ApiClient, ApiClientError } from "./client.js";
|
|
4
|
-
|
|
5
|
-
type FetchInput = Parameters<typeof fetch>[0];
|
|
6
|
-
|
|
7
|
-
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
8
|
-
return new Response(JSON.stringify(body), {
|
|
9
|
-
...init,
|
|
10
|
-
headers: {
|
|
11
|
-
"content-type": "application/json",
|
|
12
|
-
...(init.headers ?? {}),
|
|
13
|
-
},
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function binaryResponse(bytes: number[], init: ResponseInit = {}): Response {
|
|
18
|
-
return new Response(new Uint8Array(bytes), {
|
|
19
|
-
...init,
|
|
20
|
-
headers: {
|
|
21
|
-
"content-type": "application/octet-stream",
|
|
22
|
-
...(init.headers ?? {}),
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("ApiClient", () => {
|
|
28
|
-
it("adds auth headers and parses JSON responses", async () => {
|
|
29
|
-
const fetchMock = vi.fn(async (_input: FetchInput, init?: RequestInit) => {
|
|
30
|
-
const headers = new Headers(init?.headers);
|
|
31
|
-
|
|
32
|
-
expect(headers.get("authorization")).toBe("Bearer secret");
|
|
33
|
-
expect(headers.get("accept")).toBe("application/json");
|
|
34
|
-
|
|
35
|
-
return jsonResponse({ data: { id: "1" } });
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const client = new ApiClient({
|
|
39
|
-
baseUrl: "https://api.example.com",
|
|
40
|
-
apiKey: "secret",
|
|
41
|
-
fetch: fetchMock,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
await expect(client.getJson<{ data: { id: string } }>("/api/v2/schemas")).resolves.toEqual({
|
|
45
|
-
data: { id: "1" },
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("preserves JSON error payloads from failed responses", async () => {
|
|
50
|
-
const fetchMock = vi.fn(async () =>
|
|
51
|
-
jsonResponse(
|
|
52
|
-
{
|
|
53
|
-
error: { code: "bad_request", message: "Bad request" },
|
|
54
|
-
meta: { requestId: "req_1" },
|
|
55
|
-
},
|
|
56
|
-
{ status: 400, statusText: "Bad Request" },
|
|
57
|
-
),
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
const client = new ApiClient({
|
|
61
|
-
baseUrl: "https://api.example.com",
|
|
62
|
-
fetch: fetchMock,
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
await expect(client.getJson("/api/v2/schemas")).rejects.toMatchObject({
|
|
66
|
-
name: "ApiClientError",
|
|
67
|
-
code: "http_error",
|
|
68
|
-
status: 400,
|
|
69
|
-
responseBody: {
|
|
70
|
-
error: { code: "bad_request", message: "Bad request" },
|
|
71
|
-
meta: { requestId: "req_1" },
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("times out slow requests", async () => {
|
|
77
|
-
vi.useFakeTimers();
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const fetchMock = vi.fn(
|
|
81
|
-
(_input: FetchInput, init?: RequestInit) =>
|
|
82
|
-
new Promise<Response>((_resolve, reject) => {
|
|
83
|
-
const signal = init?.signal;
|
|
84
|
-
|
|
85
|
-
signal?.addEventListener(
|
|
86
|
-
"abort",
|
|
87
|
-
() => {
|
|
88
|
-
reject(new Error("aborted"));
|
|
89
|
-
},
|
|
90
|
-
{ once: true },
|
|
91
|
-
);
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
const client = new ApiClient({
|
|
96
|
-
baseUrl: "https://api.example.com",
|
|
97
|
-
timeoutMs: 10,
|
|
98
|
-
fetch: fetchMock,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const promise = client.getJson("/api/v2/schemas");
|
|
102
|
-
const assertion = expect(promise).rejects.toMatchObject({
|
|
103
|
-
name: "ApiClientError",
|
|
104
|
-
code: "timeout",
|
|
105
|
-
message: "Request timed out after 10ms.",
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
await vi.advanceTimersByTimeAsync(10);
|
|
109
|
-
|
|
110
|
-
await assertion;
|
|
111
|
-
} finally {
|
|
112
|
-
vi.useRealTimers();
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("surfaces the underlying network cause in the error message", async () => {
|
|
117
|
-
const dnsCause = Object.assign(new Error("getaddrinfo ENOTFOUND api-z2.zinkee.invalid"), {
|
|
118
|
-
code: "ENOTFOUND",
|
|
119
|
-
});
|
|
120
|
-
const fetchError = Object.assign(new TypeError("fetch failed"), { cause: dnsCause });
|
|
121
|
-
const fetchMock = vi.fn(async () => {
|
|
122
|
-
throw fetchError;
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const client = new ApiClient({
|
|
126
|
-
baseUrl: "https://api-z2.zinkee.invalid",
|
|
127
|
-
fetch: fetchMock,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
await expect(client.getJson("/api/v2/schemas")).rejects.toMatchObject({
|
|
131
|
-
name: "ApiClientError",
|
|
132
|
-
code: "network_error",
|
|
133
|
-
message:
|
|
134
|
-
"Request failed for GET https://api-z2.zinkee.invalid/api/v2/schemas: getaddrinfo ENOTFOUND api-z2.zinkee.invalid.",
|
|
135
|
-
cause: fetchError,
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("appends the cause code when it is not already part of the message", async () => {
|
|
140
|
-
const tlsCause = Object.assign(new Error("self signed certificate"), {
|
|
141
|
-
code: "DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
142
|
-
});
|
|
143
|
-
const fetchError = Object.assign(new TypeError("fetch failed"), { cause: tlsCause });
|
|
144
|
-
const fetchMock = vi.fn(async () => {
|
|
145
|
-
throw fetchError;
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
const client = new ApiClient({
|
|
149
|
-
baseUrl: "https://self-signed.example.com",
|
|
150
|
-
fetch: fetchMock,
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
await expect(client.getJson("/api/v2/schemas")).rejects.toMatchObject({
|
|
154
|
-
code: "network_error",
|
|
155
|
-
message:
|
|
156
|
-
"Request failed for GET https://self-signed.example.com/api/v2/schemas: self signed certificate (DEPTH_ZERO_SELF_SIGNED_CERT).",
|
|
157
|
-
});
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("falls back to the bare message when the network cause has no useful detail", async () => {
|
|
161
|
-
const fetchMock = vi.fn(async () => {
|
|
162
|
-
throw new TypeError("fetch failed");
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
const client = new ApiClient({
|
|
166
|
-
baseUrl: "https://api.example.com",
|
|
167
|
-
fetch: fetchMock,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
await expect(client.getJson("/api/v2/schemas")).rejects.toMatchObject({
|
|
171
|
-
name: "ApiClientError",
|
|
172
|
-
code: "network_error",
|
|
173
|
-
message: "Request failed for GET https://api.example.com/api/v2/schemas.",
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("returns binary responses as ArrayBuffers", async () => {
|
|
178
|
-
const fetchMock = vi.fn(async () => binaryResponse([1, 2, 3]));
|
|
179
|
-
|
|
180
|
-
const client = new ApiClient({
|
|
181
|
-
baseUrl: "https://api.example.com",
|
|
182
|
-
fetch: fetchMock,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
const result = await client.requestBinary("/api/v2/files/abc");
|
|
186
|
-
|
|
187
|
-
expect(Array.from(new Uint8Array(result))).toEqual([1, 2, 3]);
|
|
188
|
-
});
|
|
189
|
-
});
|
package/src/client.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
export interface ApiClientOptions {
|
|
2
|
-
baseUrl: string;
|
|
3
|
-
apiKey?: string;
|
|
4
|
-
timeoutMs?: number;
|
|
5
|
-
fetch?: typeof fetch;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type ApiClientErrorCode =
|
|
9
|
-
| "network_error"
|
|
10
|
-
| "http_error"
|
|
11
|
-
| "timeout"
|
|
12
|
-
| "invalid_json";
|
|
13
|
-
|
|
14
|
-
export interface ApiClientErrorDetails {
|
|
15
|
-
url: string;
|
|
16
|
-
method: string;
|
|
17
|
-
status?: number;
|
|
18
|
-
responseBody?: unknown;
|
|
19
|
-
responseText?: string;
|
|
20
|
-
cause?: unknown;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class ApiClientError extends Error {
|
|
24
|
-
override name = "ApiClientError";
|
|
25
|
-
|
|
26
|
-
readonly code: ApiClientErrorCode;
|
|
27
|
-
|
|
28
|
-
readonly url: string;
|
|
29
|
-
|
|
30
|
-
readonly method: string;
|
|
31
|
-
|
|
32
|
-
readonly status?: number;
|
|
33
|
-
|
|
34
|
-
readonly responseBody?: unknown;
|
|
35
|
-
|
|
36
|
-
readonly responseText?: string;
|
|
37
|
-
|
|
38
|
-
override readonly cause?: unknown;
|
|
39
|
-
|
|
40
|
-
constructor(
|
|
41
|
-
code: ApiClientErrorCode,
|
|
42
|
-
message: string,
|
|
43
|
-
details: ApiClientErrorDetails,
|
|
44
|
-
) {
|
|
45
|
-
super(message);
|
|
46
|
-
this.code = code;
|
|
47
|
-
this.url = details.url;
|
|
48
|
-
this.method = details.method;
|
|
49
|
-
this.status = details.status;
|
|
50
|
-
this.responseBody = details.responseBody;
|
|
51
|
-
this.responseText = details.responseText;
|
|
52
|
-
this.cause = details.cause;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface ApiRequestOptions extends RequestInit {
|
|
57
|
-
timeoutMs?: number;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function isJsonContentType(contentType: string | null): boolean {
|
|
61
|
-
return contentType ? /(^|\/|\+)json\b/i.test(contentType) : false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function looksLikeJson(text: string): boolean {
|
|
65
|
-
const trimmed = text.trim();
|
|
66
|
-
|
|
67
|
-
return (
|
|
68
|
-
trimmed.startsWith("{") ||
|
|
69
|
-
trimmed.startsWith("[") ||
|
|
70
|
-
trimmed === "null" ||
|
|
71
|
-
trimmed === "true" ||
|
|
72
|
-
trimmed === "false" ||
|
|
73
|
-
/^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/.test(trimmed) ||
|
|
74
|
-
trimmed.startsWith('"')
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function parseJsonBody<T>(text: string, url: string, method: string): T {
|
|
79
|
-
const trimmed = text.trim();
|
|
80
|
-
|
|
81
|
-
if (trimmed.length === 0) {
|
|
82
|
-
return undefined as T;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
return JSON.parse(trimmed) as T;
|
|
87
|
-
} catch (cause) {
|
|
88
|
-
throw new ApiClientError("invalid_json", `Failed to parse JSON response from ${method} ${url}.`, {
|
|
89
|
-
url,
|
|
90
|
-
method,
|
|
91
|
-
responseText: text,
|
|
92
|
-
cause,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function parseErrorBody(text: string, contentType: string | null): unknown {
|
|
98
|
-
if (text.trim().length === 0) {
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (!isJsonContentType(contentType) && !looksLikeJson(text)) {
|
|
103
|
-
return text;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
return JSON.parse(text);
|
|
108
|
-
} catch {
|
|
109
|
-
return text;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function summarizeNetworkCause(cause: unknown): string | undefined {
|
|
114
|
-
const seen = new Set<unknown>();
|
|
115
|
-
let current: unknown = cause;
|
|
116
|
-
|
|
117
|
-
while (current && typeof current === "object" && !seen.has(current)) {
|
|
118
|
-
seen.add(current);
|
|
119
|
-
|
|
120
|
-
const candidate = current as { message?: unknown; code?: unknown; cause?: unknown };
|
|
121
|
-
const message = typeof candidate.message === "string" ? candidate.message.trim() : "";
|
|
122
|
-
const code = typeof candidate.code === "string" ? candidate.code : undefined;
|
|
123
|
-
|
|
124
|
-
if (message && message !== "fetch failed") {
|
|
125
|
-
return code && !message.includes(code) ? `${message} (${code})` : message;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
current = candidate.cause;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export class ApiClient {
|
|
135
|
-
private readonly fetchImpl: typeof fetch;
|
|
136
|
-
|
|
137
|
-
constructor(private readonly options: ApiClientOptions) {
|
|
138
|
-
this.fetchImpl = options.fetch ?? fetch;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async getJson<T>(path: string, init: ApiRequestOptions = {}): Promise<T> {
|
|
142
|
-
return this.requestJson<T>(path, { ...init, method: "GET" });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async requestJson<T>(path: string, init: ApiRequestOptions = {}): Promise<T> {
|
|
146
|
-
const response = await this.request(path, init, "application/json");
|
|
147
|
-
|
|
148
|
-
if (!response.ok) {
|
|
149
|
-
const text = await response.text();
|
|
150
|
-
throw this.buildHttpError(response, text, init, "requestJson");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const text = await response.text();
|
|
154
|
-
return parseJsonBody<T>(text, response.url, init.method ?? "GET");
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async requestBinary(path: string, init: ApiRequestOptions = {}): Promise<ArrayBuffer> {
|
|
158
|
-
const response = await this.request(path, init);
|
|
159
|
-
|
|
160
|
-
if (!response.ok) {
|
|
161
|
-
const text = await response.text();
|
|
162
|
-
throw this.buildHttpError(response, text, init, "requestBinary");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return response.arrayBuffer();
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
private buildUrl(path: string): string {
|
|
169
|
-
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
170
|
-
|
|
171
|
-
return new URL(normalizedPath, this.options.baseUrl).toString();
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
private async request(
|
|
175
|
-
path: string,
|
|
176
|
-
init: ApiRequestOptions,
|
|
177
|
-
accept?: string,
|
|
178
|
-
): Promise<Response> {
|
|
179
|
-
const url = this.buildUrl(path);
|
|
180
|
-
const method = init.method ?? "GET";
|
|
181
|
-
const headers = new Headers(init.headers);
|
|
182
|
-
|
|
183
|
-
if (accept && !headers.has("accept")) {
|
|
184
|
-
headers.set("accept", accept);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
if (this.options.apiKey && !headers.has("authorization")) {
|
|
188
|
-
headers.set("authorization", `Bearer ${this.options.apiKey}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const controller = new AbortController();
|
|
192
|
-
const timeoutMs = init.timeoutMs ?? this.options.timeoutMs;
|
|
193
|
-
let timedOut = false;
|
|
194
|
-
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
195
|
-
|
|
196
|
-
const abortListener = (): void => {
|
|
197
|
-
controller.abort();
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
if (init.signal) {
|
|
201
|
-
if (init.signal.aborted) {
|
|
202
|
-
controller.abort();
|
|
203
|
-
} else {
|
|
204
|
-
init.signal.addEventListener("abort", abortListener, { once: true });
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (timeoutMs !== undefined) {
|
|
209
|
-
timeoutHandle = setTimeout(() => {
|
|
210
|
-
timedOut = true;
|
|
211
|
-
controller.abort();
|
|
212
|
-
}, timeoutMs);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
try {
|
|
216
|
-
return await this.fetchImpl(url, {
|
|
217
|
-
...init,
|
|
218
|
-
headers,
|
|
219
|
-
signal: controller.signal,
|
|
220
|
-
});
|
|
221
|
-
} catch (cause) {
|
|
222
|
-
if (timedOut) {
|
|
223
|
-
throw new ApiClientError("timeout", `Request timed out after ${timeoutMs}ms.`, {
|
|
224
|
-
url,
|
|
225
|
-
method,
|
|
226
|
-
cause,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const causeSummary = summarizeNetworkCause(cause);
|
|
231
|
-
const message = causeSummary
|
|
232
|
-
? `Request failed for ${method} ${url}: ${causeSummary}.`
|
|
233
|
-
: `Request failed for ${method} ${url}.`;
|
|
234
|
-
|
|
235
|
-
throw new ApiClientError("network_error", message, {
|
|
236
|
-
url,
|
|
237
|
-
method,
|
|
238
|
-
cause,
|
|
239
|
-
});
|
|
240
|
-
} finally {
|
|
241
|
-
if (timeoutHandle !== undefined) {
|
|
242
|
-
clearTimeout(timeoutHandle);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (init.signal) {
|
|
246
|
-
init.signal.removeEventListener("abort", abortListener);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
private buildHttpError(
|
|
252
|
-
response: Response,
|
|
253
|
-
text: string,
|
|
254
|
-
init: ApiRequestOptions,
|
|
255
|
-
operation: string,
|
|
256
|
-
): ApiClientError {
|
|
257
|
-
const method = init.method ?? "GET";
|
|
258
|
-
const responseBody = parseErrorBody(text, response.headers.get("content-type"));
|
|
259
|
-
|
|
260
|
-
return new ApiClientError("http_error", `Request failed with status ${response.status} for ${method} ${response.url}.`, {
|
|
261
|
-
url: response.url,
|
|
262
|
-
method,
|
|
263
|
-
status: response.status,
|
|
264
|
-
responseBody,
|
|
265
|
-
responseText: responseBody === text ? text : undefined,
|
|
266
|
-
cause: new Error(operation),
|
|
267
|
-
});
|
|
268
|
-
}
|
|
269
|
-
}
|