zinkee 0.1.17 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zinkee",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -113,6 +113,67 @@ describe("ApiClient", () => {
113
113
  }
114
114
  });
115
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
+
116
177
  it("returns binary responses as ArrayBuffers", async () => {
117
178
  const fetchMock = vi.fn(async () => binaryResponse([1, 2, 3]));
118
179
 
package/src/client.ts CHANGED
@@ -110,6 +110,27 @@ function parseErrorBody(text: string, contentType: string | null): unknown {
110
110
  }
111
111
  }
112
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
+
113
134
  export class ApiClient {
114
135
  private readonly fetchImpl: typeof fetch;
115
136
 
@@ -206,7 +227,12 @@ export class ApiClient {
206
227
  });
207
228
  }
208
229
 
209
- throw new ApiClientError("network_error", `Request failed for ${method} ${url}.`, {
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, {
210
236
  url,
211
237
  method,
212
238
  cause,
@@ -79,4 +79,113 @@ base_url = "https://api.zinkee.test"
79
79
  expect(saved).toContain('base_url = "http://localhost:8088"');
80
80
  expect(saved).toContain('default_profile = "local"');
81
81
  });
82
+
83
+ it("rejects a malformed base URL when adding a profile", async () => {
84
+ const configPath = join(
85
+ mkdtempSync(join(tmpdir(), "zinkee-profiles-add-bad-url-")),
86
+ "config.toml",
87
+ );
88
+ const output = createStdoutBuffer();
89
+ const program = buildProgram({ configPath, stdout: output.stdout });
90
+
91
+ await expect(
92
+ program.parseAsync([
93
+ "node",
94
+ "zinkee",
95
+ "profiles",
96
+ "add",
97
+ "broken",
98
+ "--api-key",
99
+ "any",
100
+ "--base-url",
101
+ "api-z2.zinkee.com",
102
+ ]),
103
+ ).rejects.toMatchObject({
104
+ name: "CliError",
105
+ code: "configuration_error",
106
+ message: '"--base-url" must be an absolute http(s) URL.',
107
+ });
108
+ });
109
+
110
+ it("rejects a non-http(s) base URL when adding a profile", async () => {
111
+ const configPath = join(
112
+ mkdtempSync(join(tmpdir(), "zinkee-profiles-add-ftp-")),
113
+ "config.toml",
114
+ );
115
+ const output = createStdoutBuffer();
116
+ const program = buildProgram({ configPath, stdout: output.stdout });
117
+
118
+ await expect(
119
+ program.parseAsync([
120
+ "node",
121
+ "zinkee",
122
+ "profiles",
123
+ "add",
124
+ "broken",
125
+ "--api-key",
126
+ "any",
127
+ "--base-url",
128
+ "ftp://api-z2.zinkee.com",
129
+ ]),
130
+ ).rejects.toMatchObject({
131
+ name: "CliError",
132
+ code: "configuration_error",
133
+ message: '"--base-url" must be an absolute http(s) URL.',
134
+ });
135
+ });
136
+
137
+ it("normalizes trailing slashes when adding a profile", async () => {
138
+ const configPath = join(
139
+ mkdtempSync(join(tmpdir(), "zinkee-profiles-add-slash-")),
140
+ "config.toml",
141
+ );
142
+ const output = createStdoutBuffer();
143
+ const program = buildProgram({ configPath, stdout: output.stdout });
144
+
145
+ await program.parseAsync([
146
+ "node",
147
+ "zinkee",
148
+ "profiles",
149
+ "add",
150
+ "local",
151
+ "--api-key",
152
+ "any",
153
+ "--base-url",
154
+ "https://api-z2.zinkee.com///",
155
+ ]);
156
+
157
+ const saved = readFileSync(configPath, "utf8");
158
+
159
+ expect(saved).toContain('base_url = "https://api-z2.zinkee.com"');
160
+ });
161
+
162
+ it("rejects a malformed base URL when updating a profile", async () => {
163
+ const configPath = createTempConfig(`
164
+ [profiles.prod]
165
+ api_key = "secret"
166
+ base_url = "https://api-z2.zinkee.com"
167
+ `);
168
+ const output = createStdoutBuffer();
169
+ const program = buildProgram({ configPath, stdout: output.stdout });
170
+
171
+ await expect(
172
+ program.parseAsync([
173
+ "node",
174
+ "zinkee",
175
+ "profiles",
176
+ "update",
177
+ "prod",
178
+ "--base-url",
179
+ "not a url",
180
+ ]),
181
+ ).rejects.toMatchObject({
182
+ name: "CliError",
183
+ code: "configuration_error",
184
+ message: '"--base-url" must be an absolute http(s) URL.',
185
+ });
186
+
187
+ const saved = readFileSync(configPath, "utf8");
188
+
189
+ expect(saved).toContain('base_url = "https://api-z2.zinkee.com"');
190
+ });
82
191
  });
@@ -4,11 +4,13 @@ import { Command } from "commander";
4
4
 
5
5
  import {
6
6
  defaultConfigPath,
7
+ ProfileResolutionError,
7
8
  readConfigIfExists,
8
9
  redactProfile,
9
10
  removeProfileConfig,
10
11
  setDefaultProfile,
11
12
  setProfileConfig,
13
+ validateBaseUrl,
12
14
  writeConfig,
13
15
  } from "../config.js";
14
16
  import type { AppConfig, ProfileConfig } from "../types.js";
@@ -93,6 +95,22 @@ function loadConfigForAdd(filePath: string): AppConfig {
93
95
  return readConfigIfExists(filePath) ?? { profiles: {} };
94
96
  }
95
97
 
98
+ function normalizeProfileBaseUrl(baseUrl: string | undefined): string | undefined {
99
+ if (baseUrl === undefined) {
100
+ return undefined;
101
+ }
102
+
103
+ try {
104
+ return validateBaseUrl(baseUrl, '"--base-url"');
105
+ } catch (error) {
106
+ if (error instanceof ProfileResolutionError) {
107
+ throw new CliError("configuration_error", error.message);
108
+ }
109
+
110
+ throw error;
111
+ }
112
+ }
113
+
96
114
  function emitProfilesTable(stdout: CommandIo, data: readonly DisplayProfile[]): void {
97
115
  printTable(
98
116
  data.map((profile) => ({
@@ -191,9 +209,11 @@ export function registerProfilesCommands(
191
209
  );
192
210
  }
193
211
 
212
+ const baseUrl = normalizeProfileBaseUrl(commandOptions.baseUrl);
213
+
194
214
  let nextConfig = setProfileConfig(config, profileName, {
195
215
  apiKey: commandOptions.apiKey,
196
- baseUrl: commandOptions.baseUrl,
216
+ baseUrl,
197
217
  });
198
218
 
199
219
  if (!nextConfig.defaultProfile) {
@@ -236,9 +256,11 @@ export function registerProfilesCommands(
236
256
  const config = readRequiredConfig(filePath);
237
257
  getRequiredProfile(config, profileName);
238
258
 
259
+ const baseUrl = normalizeProfileBaseUrl(commandOptions.baseUrl);
260
+
239
261
  const nextConfig = setProfileConfig(config, profileName, {
240
262
  apiKey: commandOptions.apiKey,
241
- baseUrl: commandOptions.baseUrl,
263
+ baseUrl,
242
264
  });
243
265
 
244
266
  writeConfig(nextConfig, filePath);
@@ -256,6 +256,28 @@ describe("resolveRuntimeContext", () => {
256
256
  else process.env[ENV_BASE_URL] = previousBaseUrl;
257
257
  }
258
258
  });
259
+
260
+ it("rejects a --base-url override that is not an absolute http(s) URL", () => {
261
+ expect(() =>
262
+ resolveRuntimeContext({ profiles: {} }, { baseUrl: "api-z2.zinkee.com", apiKey: "k" }),
263
+ ).toThrowError('"--base-url" must be an absolute http(s) URL.');
264
+
265
+ expect(() =>
266
+ resolveRuntimeContext(
267
+ { profiles: {} },
268
+ { baseUrl: "ftp://api-z2.zinkee.com", apiKey: "k" },
269
+ ),
270
+ ).toThrowError('"--base-url" must be an absolute http(s) URL.');
271
+ });
272
+
273
+ it("normalizes trailing slashes on a --base-url override", () => {
274
+ expect(
275
+ resolveRuntimeContext(
276
+ { profiles: {} },
277
+ { baseUrl: "https://api-z2.zinkee.com///", apiKey: "k" },
278
+ ).baseUrl,
279
+ ).toBe("https://api-z2.zinkee.com");
280
+ });
259
281
  });
260
282
 
261
283
  describe("env readers", () => {
package/src/config.ts CHANGED
@@ -260,21 +260,17 @@ export function resolveProfile(
260
260
  throw new ProfileResolutionError("No profile could be resolved from config.");
261
261
  }
262
262
 
263
- export function validateBaseUrl(value: string): string {
263
+ export function validateBaseUrl(value: string, source: string = "Base URL"): string {
264
264
  let parsed: URL;
265
265
 
266
266
  try {
267
267
  parsed = new URL(value);
268
268
  } catch {
269
- throw new ProfileResolutionError(
270
- `Environment variable ${ENV_BASE_URL} must be an absolute http(s) URL.`,
271
- );
269
+ throw new ProfileResolutionError(`${source} must be an absolute http(s) URL.`);
272
270
  }
273
271
 
274
272
  if (!["http:", "https:"].includes(parsed.protocol) || !parsed.host) {
275
- throw new ProfileResolutionError(
276
- `Environment variable ${ENV_BASE_URL} must be an absolute http(s) URL.`,
277
- );
273
+ throw new ProfileResolutionError(`${source} must be an absolute http(s) URL.`);
278
274
  }
279
275
 
280
276
  return value.replace(/\/+$/, "");
@@ -290,7 +286,7 @@ export function readEnvBaseUrl(env: NodeJS.ProcessEnv = process.env): string | u
290
286
  return undefined;
291
287
  }
292
288
 
293
- return validateBaseUrl(baseUrl);
289
+ return validateBaseUrl(baseUrl, `Environment variable ${ENV_BASE_URL}`);
294
290
  }
295
291
 
296
292
  export function readEnvProfile(env: NodeJS.ProcessEnv = process.env): string | undefined {
@@ -9,6 +9,7 @@ import {
9
9
  readEnvProfile,
10
10
  resolveProfile,
11
11
  ProfileResolutionError,
12
+ validateBaseUrl,
12
13
  } from "./config.js";
13
14
 
14
15
  export function resolveRuntimeContext(
@@ -19,9 +20,14 @@ export function resolveRuntimeContext(
19
20
  const envApiKey = readEnvApiKey();
20
21
  const envBaseUrl = readEnvBaseUrl();
21
22
 
23
+ const flagBaseUrl =
24
+ overrides.baseUrl !== undefined
25
+ ? validateBaseUrl(overrides.baseUrl, '"--base-url"')
26
+ : undefined;
27
+
22
28
  const profileOverride = overrides.profile ?? envProfile;
23
29
  const apiKeyOverride = overrides.apiKey ?? envApiKey;
24
- const baseUrlOverride = overrides.baseUrl ?? envBaseUrl;
30
+ const baseUrlOverride = flagBaseUrl ?? envBaseUrl;
25
31
 
26
32
  if (apiKeyOverride && !baseUrlOverride) {
27
33
  throw new ProfileResolutionError(
@@ -28,7 +28,7 @@ describe("formatCliErrorJson", () => {
28
28
 
29
29
  expect(JSON.parse(formatCliErrorJson(error, { exitCode: 1 }))).toEqual({
30
30
  error: {
31
- code: "unknown_error",
31
+ code: "http_error",
32
32
  message: "Schema is locked.",
33
33
  exitCode: 1,
34
34
  details: {
@@ -45,4 +45,41 @@ describe("formatCliErrorJson", () => {
45
45
  },
46
46
  });
47
47
  });
48
+
49
+ it("preserves network_error code for transport-layer failures", () => {
50
+ const error = new ApiClientError(
51
+ "network_error",
52
+ "Request failed for GET https://api.example.com/api/v2/schemas: getaddrinfo ENOTFOUND api.example.com.",
53
+ {
54
+ url: "https://api.example.com/api/v2/schemas",
55
+ method: "GET",
56
+ cause: new Error("getaddrinfo ENOTFOUND api.example.com"),
57
+ },
58
+ );
59
+
60
+ expect(JSON.parse(formatCliErrorJson(error)).error.code).toBe("network_error");
61
+ });
62
+
63
+ it("preserves timeout code for aborted requests", () => {
64
+ const error = new ApiClientError("timeout", "Request timed out after 10ms.", {
65
+ url: "https://api.example.com/api/v2/schemas",
66
+ method: "GET",
67
+ });
68
+
69
+ expect(JSON.parse(formatCliErrorJson(error)).error.code).toBe("timeout");
70
+ });
71
+
72
+ it("preserves invalid_json code for unparseable responses", () => {
73
+ const error = new ApiClientError(
74
+ "invalid_json",
75
+ "Failed to parse JSON response from GET https://api.example.com/api/v2/schemas.",
76
+ {
77
+ url: "https://api.example.com/api/v2/schemas",
78
+ method: "GET",
79
+ responseText: "<html>oops</html>",
80
+ },
81
+ );
82
+
83
+ expect(JSON.parse(formatCliErrorJson(error)).error.code).toBe("invalid_json");
84
+ });
48
85
  });
@@ -7,13 +7,21 @@ export type CliErrorCode =
7
7
  | "unknown_error"
8
8
  | "invalid_cli_usage"
9
9
  | "configuration_error"
10
- | "command_not_allowed_in_read_only_mode";
10
+ | "command_not_allowed_in_read_only_mode"
11
+ | "network_error"
12
+ | "http_error"
13
+ | "timeout"
14
+ | "invalid_json";
11
15
 
12
16
  export const cliExitCodeByErrorCode = {
13
17
  unknown_error: 1,
14
18
  invalid_cli_usage: 2,
15
19
  configuration_error: 2,
16
20
  command_not_allowed_in_read_only_mode: 7,
21
+ network_error: 1,
22
+ http_error: 1,
23
+ timeout: 1,
24
+ invalid_json: 1,
17
25
  } as const satisfies Record<CliErrorCode, number>;
18
26
 
19
27
  export interface CliErrorDetails {
@@ -144,7 +152,7 @@ export function toCliError(error: unknown): CliError {
144
152
  if (error instanceof ApiClientError) {
145
153
  const apiError = extractApiErrorDetails(error);
146
154
 
147
- return new CliError("unknown_error", apiError.message, apiError.details);
155
+ return new CliError(error.code, apiError.message, apiError.details);
148
156
  }
149
157
 
150
158
  if (error instanceof Error) {