zinkee 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/AGENTS.md +46 -0
  2. package/README.md +395 -0
  3. package/dist/index.d.ts +13 -0
  4. package/dist/index.js +7142 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +32 -0
  7. package/src/api/automations.ts +403 -0
  8. package/src/api/comments.ts +51 -0
  9. package/src/api/displays.ts +276 -0
  10. package/src/api/files.ts +50 -0
  11. package/src/api/navigation.ts +71 -0
  12. package/src/api/records.ts +167 -0
  13. package/src/api/schemas.ts +130 -0
  14. package/src/api/teamspace.ts +110 -0
  15. package/src/cli-examples.ts +175 -0
  16. package/src/cli-runner.ts +73 -0
  17. package/src/client.test.ts +128 -0
  18. package/src/client.ts +243 -0
  19. package/src/command-registry.ts +749 -0
  20. package/src/commands/automations.test.ts +728 -0
  21. package/src/commands/automations.ts +2013 -0
  22. package/src/commands/comments.test.ts +214 -0
  23. package/src/commands/comments.ts +303 -0
  24. package/src/commands/config.test.ts +81 -0
  25. package/src/commands/config.ts +150 -0
  26. package/src/commands/displays.test.ts +266 -0
  27. package/src/commands/displays.ts +755 -0
  28. package/src/commands/files.test.ts +284 -0
  29. package/src/commands/files.ts +280 -0
  30. package/src/commands/navigation.test.ts +214 -0
  31. package/src/commands/navigation.ts +348 -0
  32. package/src/commands/profiles.test.ts +82 -0
  33. package/src/commands/profiles.ts +281 -0
  34. package/src/commands/records.test.ts +367 -0
  35. package/src/commands/records.ts +726 -0
  36. package/src/commands/schemas.test.ts +748 -0
  37. package/src/commands/schemas.ts +746 -0
  38. package/src/commands/teamspace.test.ts +229 -0
  39. package/src/commands/teamspace.ts +540 -0
  40. package/src/config.test.ts +165 -0
  41. package/src/config.ts +248 -0
  42. package/src/index.test.ts +140 -0
  43. package/src/index.ts +8 -0
  44. package/src/parsers/expressions.test.ts +50 -0
  45. package/src/parsers/expressions.ts +111 -0
  46. package/src/parsers/kv.test.ts +35 -0
  47. package/src/parsers/kv.ts +49 -0
  48. package/src/parsers/selectors.test.ts +23 -0
  49. package/src/parsers/selectors.ts +29 -0
  50. package/src/program.ts +56 -0
  51. package/src/runtime-context.ts +18 -0
  52. package/src/types.ts +71 -0
  53. package/src/utils/errors.test.ts +48 -0
  54. package/src/utils/errors.ts +183 -0
  55. package/src/utils/examples.test.ts +67 -0
  56. package/src/utils/examples.ts +855 -0
  57. package/src/utils/output.test.ts +39 -0
  58. package/src/utils/output.ts +124 -0
  59. package/src/utils/schema-fields.ts +529 -0
  60. package/tsconfig.json +20 -0
  61. package/tsup.config.ts +13 -0
@@ -0,0 +1,281 @@
1
+ import type { Writable } from "node:stream";
2
+
3
+ import { Command } from "commander";
4
+
5
+ import {
6
+ defaultConfigPath,
7
+ readConfigIfExists,
8
+ redactProfile,
9
+ removeProfileConfig,
10
+ setDefaultProfile,
11
+ setProfileConfig,
12
+ writeConfig,
13
+ } from "../config.js";
14
+ import type { AppConfig, ProfileConfig } from "../types.js";
15
+ import { CliError } from "../utils/errors.js";
16
+ import { buildJsonSuccess, printTable } from "../utils/output.js";
17
+
18
+ interface CommandIo {
19
+ write(chunk: string): unknown;
20
+ }
21
+
22
+ export interface RegisterProfilesCommandsOptions {
23
+ configPath?: string;
24
+ stdout?: CommandIo;
25
+ }
26
+
27
+ interface GlobalOptions {
28
+ json?: boolean;
29
+ }
30
+
31
+ export interface DisplayProfile extends ProfileConfig {
32
+ name: string;
33
+ isDefault: boolean;
34
+ }
35
+
36
+ function getCommandIo(stdout: CommandIo | undefined): CommandIo {
37
+ return stdout ?? (process.stdout as Writable);
38
+ }
39
+
40
+ function writeLine(stdout: CommandIo, value: string): void {
41
+ stdout.write(`${value}\n`);
42
+ }
43
+
44
+ function getGlobalOptions(command: Command): GlobalOptions {
45
+ return command.optsWithGlobals<GlobalOptions>();
46
+ }
47
+
48
+ function emitJson(
49
+ stdout: CommandIo,
50
+ commandName: string,
51
+ data: unknown,
52
+ configPath: string,
53
+ ): void {
54
+ writeLine(
55
+ stdout,
56
+ JSON.stringify(buildJsonSuccess(data, { command: commandName, configPath })),
57
+ );
58
+ }
59
+
60
+ function readRequiredConfig(filePath: string): AppConfig {
61
+ const config = readConfigIfExists(filePath);
62
+
63
+ if (config) {
64
+ return config;
65
+ }
66
+
67
+ throw new CliError("configuration_error", `Config file not found at ${filePath}.`);
68
+ }
69
+
70
+ function getRequiredProfile(config: AppConfig, profileName: string): ProfileConfig {
71
+ const profile = config.profiles[profileName];
72
+
73
+ if (profile) {
74
+ return profile;
75
+ }
76
+
77
+ throw new CliError("configuration_error", `Profile "${profileName}" not found in config.`);
78
+ }
79
+
80
+ function toDisplayProfile(
81
+ profileName: string,
82
+ profile: ProfileConfig,
83
+ defaultProfile: string | undefined,
84
+ ): DisplayProfile {
85
+ return {
86
+ name: profileName,
87
+ isDefault: defaultProfile === profileName,
88
+ ...redactProfile(profile),
89
+ };
90
+ }
91
+
92
+ function loadConfigForAdd(filePath: string): AppConfig {
93
+ return readConfigIfExists(filePath) ?? { profiles: {} };
94
+ }
95
+
96
+ function emitProfilesTable(stdout: CommandIo, data: readonly DisplayProfile[]): void {
97
+ printTable(
98
+ data.map((profile) => ({
99
+ name: profile.name,
100
+ default: profile.isDefault ? "yes" : "",
101
+ baseUrl: profile.baseUrl ?? "",
102
+ apiKey: profile.apiKey ?? "",
103
+ })),
104
+ ["name", "default", "baseUrl", "apiKey"],
105
+ stdout,
106
+ );
107
+ }
108
+
109
+ export function registerProfilesCommands(
110
+ program: Command,
111
+ options: RegisterProfilesCommandsOptions = {},
112
+ ): void {
113
+ const filePath = options.configPath ?? defaultConfigPath;
114
+ const stdout = getCommandIo(options.stdout);
115
+ const profiles = program.command("profiles").description("Manage local CLI profiles");
116
+
117
+ profiles
118
+ .command("list")
119
+ .description("List configured profiles")
120
+ .action(function action(this: Command) {
121
+ const config = readRequiredConfig(filePath);
122
+ const data = Object.entries(config.profiles).map(([name, profile]) =>
123
+ toDisplayProfile(name, profile, config.defaultProfile),
124
+ );
125
+
126
+ if (getGlobalOptions(this).json) {
127
+ emitJson(stdout, "profiles list", data, filePath);
128
+ return;
129
+ }
130
+
131
+ emitProfilesTable(stdout, data);
132
+ });
133
+
134
+ profiles
135
+ .command("get")
136
+ .description("Show a single configured profile")
137
+ .argument("<profile>", "profile name")
138
+ .action(function action(this: Command, profileName: string) {
139
+ const config = readRequiredConfig(filePath);
140
+ const profile = getRequiredProfile(config, profileName);
141
+ const data = toDisplayProfile(profileName, profile, config.defaultProfile);
142
+
143
+ if (getGlobalOptions(this).json) {
144
+ emitJson(stdout, "profiles get", data, filePath);
145
+ return;
146
+ }
147
+
148
+ emitProfilesTable(stdout, [data]);
149
+ });
150
+
151
+ profiles
152
+ .command("use")
153
+ .description("Set the default profile")
154
+ .argument("<profile>", "profile name")
155
+ .action(function action(this: Command, profileName: string) {
156
+ const config = readRequiredConfig(filePath);
157
+ getRequiredProfile(config, profileName);
158
+
159
+ const nextConfig = setDefaultProfile(config, profileName);
160
+ writeConfig(nextConfig, filePath);
161
+ const data = {
162
+ profile: profileName,
163
+ defaultProfile: nextConfig.defaultProfile,
164
+ };
165
+
166
+ if (getGlobalOptions(this).json) {
167
+ emitJson(stdout, "profiles use", data, filePath);
168
+ return;
169
+ }
170
+
171
+ writeLine(stdout, `Using profile "${profileName}".`);
172
+ });
173
+
174
+ profiles
175
+ .command("add")
176
+ .description("Add a profile to the local config")
177
+ .argument("<profile>", "profile name")
178
+ .requiredOption("--api-key <token>", "API key")
179
+ .option("--base-url <url>", "base URL")
180
+ .action(function action(
181
+ this: Command,
182
+ profileName: string,
183
+ commandOptions: { apiKey: string; baseUrl?: string },
184
+ ) {
185
+ const config = loadConfigForAdd(filePath);
186
+
187
+ if (config.profiles[profileName]) {
188
+ throw new CliError(
189
+ "configuration_error",
190
+ `Profile "${profileName}" already exists in config.`,
191
+ );
192
+ }
193
+
194
+ let nextConfig = setProfileConfig(config, profileName, {
195
+ apiKey: commandOptions.apiKey,
196
+ baseUrl: commandOptions.baseUrl,
197
+ });
198
+
199
+ if (!nextConfig.defaultProfile) {
200
+ nextConfig = setDefaultProfile(nextConfig, profileName);
201
+ }
202
+
203
+ writeConfig(nextConfig, filePath);
204
+ const data = toDisplayProfile(
205
+ profileName,
206
+ nextConfig.profiles[profileName] ?? {},
207
+ nextConfig.defaultProfile,
208
+ );
209
+
210
+ if (getGlobalOptions(this).json) {
211
+ emitJson(stdout, "profiles add", data, filePath);
212
+ return;
213
+ }
214
+
215
+ writeLine(stdout, `Added profile "${profileName}".`);
216
+ });
217
+
218
+ profiles
219
+ .command("update")
220
+ .description("Update an existing profile")
221
+ .argument("<profile>", "profile name")
222
+ .option("--api-key <token>", "API key")
223
+ .option("--base-url <url>", "base URL")
224
+ .action(function action(
225
+ this: Command,
226
+ profileName: string,
227
+ commandOptions: { apiKey?: string; baseUrl?: string },
228
+ ) {
229
+ if (commandOptions.apiKey === undefined && commandOptions.baseUrl === undefined) {
230
+ throw new CliError(
231
+ "configuration_error",
232
+ 'Provide at least one of "--api-key" or "--base-url".',
233
+ );
234
+ }
235
+
236
+ const config = readRequiredConfig(filePath);
237
+ getRequiredProfile(config, profileName);
238
+
239
+ const nextConfig = setProfileConfig(config, profileName, {
240
+ apiKey: commandOptions.apiKey,
241
+ baseUrl: commandOptions.baseUrl,
242
+ });
243
+
244
+ writeConfig(nextConfig, filePath);
245
+ const data = toDisplayProfile(
246
+ profileName,
247
+ nextConfig.profiles[profileName] ?? {},
248
+ nextConfig.defaultProfile,
249
+ );
250
+
251
+ if (getGlobalOptions(this).json) {
252
+ emitJson(stdout, "profiles update", data, filePath);
253
+ return;
254
+ }
255
+
256
+ writeLine(stdout, `Updated profile "${profileName}".`);
257
+ });
258
+
259
+ profiles
260
+ .command("remove")
261
+ .description("Remove a profile from the local config")
262
+ .argument("<profile>", "profile name")
263
+ .action(function action(this: Command, profileName: string) {
264
+ const config = readRequiredConfig(filePath);
265
+ getRequiredProfile(config, profileName);
266
+
267
+ const nextConfig = removeProfileConfig(config, profileName);
268
+ writeConfig(nextConfig, filePath);
269
+ const data = {
270
+ profile: profileName,
271
+ defaultProfile: nextConfig.defaultProfile,
272
+ };
273
+
274
+ if (getGlobalOptions(this).json) {
275
+ emitJson(stdout, "profiles remove", data, filePath);
276
+ return;
277
+ }
278
+
279
+ writeLine(stdout, `Removed profile "${profileName}".`);
280
+ });
281
+ }
@@ -0,0 +1,367 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { buildProgram } from "../index.js";
8
+
9
+ function createTempConfig(contents: string): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "zinkee-records-"));
11
+ const filePath = join(dir, "config.toml");
12
+ writeFileSync(filePath, contents, "utf8");
13
+ return filePath;
14
+ }
15
+
16
+ function createApiConfig(): string {
17
+ return createTempConfig(`
18
+ default_profile = "prod"
19
+
20
+ [profiles.prod]
21
+ api_key = "records-token-1234"
22
+ base_url = "https://api.zinkee.test"
23
+ `);
24
+ }
25
+
26
+ function createStdoutBuffer() {
27
+ let output = "";
28
+
29
+ return {
30
+ stdout: {
31
+ write(chunk: string) {
32
+ output += chunk;
33
+ return true;
34
+ },
35
+ },
36
+ read() {
37
+ return output;
38
+ },
39
+ };
40
+ }
41
+
42
+ function jsonResponse(body: unknown, status: number = 200): Response {
43
+ return new Response(JSON.stringify(body), {
44
+ status,
45
+ headers: {
46
+ "content-type": "application/json",
47
+ },
48
+ });
49
+ }
50
+
51
+ function createFetchMock() {
52
+ const fetchMock = vi.fn<typeof fetch>();
53
+ vi.stubGlobal("fetch", fetchMock);
54
+ return fetchMock;
55
+ }
56
+
57
+ function mockSchemaLookup(fetchMock: ReturnType<typeof createFetchMock>, id: string = "schema-1") {
58
+ fetchMock.mockResolvedValueOnce(
59
+ jsonResponse([
60
+ {
61
+ id,
62
+ slug: "contacts",
63
+ name: "Contacts",
64
+ },
65
+ ]),
66
+ );
67
+ }
68
+
69
+ afterEach(() => {
70
+ vi.unstubAllGlobals();
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ describe("records commands", () => {
75
+ it("registers the records command group", () => {
76
+ const program = buildProgram();
77
+
78
+ expect(program.commands.some((command) => command.name() === "records")).toBe(true);
79
+ });
80
+
81
+ it("lists records with simple pagination and emits page metadata", async () => {
82
+ const configPath = createApiConfig();
83
+ const output = createStdoutBuffer();
84
+ const fetchMock = createFetchMock();
85
+ mockSchemaLookup(fetchMock);
86
+ fetchMock.mockResolvedValueOnce(
87
+ jsonResponse({
88
+ records: [
89
+ {
90
+ recordId: "record-1",
91
+ values: { name: "Ana" },
92
+ },
93
+ ],
94
+ page: { offset: 20, limit: 10, total: 1, count: 1, hasMore: false },
95
+ }),
96
+ );
97
+ const program = buildProgram({ configPath, stdout: output.stdout });
98
+
99
+ await program.parseAsync([
100
+ "node",
101
+ "zinkee",
102
+ "--json",
103
+ "records",
104
+ "list",
105
+ "contacts",
106
+ "--limit",
107
+ "10",
108
+ "--offset",
109
+ "20",
110
+ ]);
111
+
112
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.zinkee.test/api/v2/schemas");
113
+
114
+ const url = new URL(String(fetchMock.mock.calls[1]?.[0]));
115
+ const init = fetchMock.mock.calls[1]?.[1];
116
+
117
+ expect(url.origin + url.pathname).toBe("https://api.zinkee.test/api/v2/schemas/schema-1/records");
118
+ expect(init?.method).toBe("GET");
119
+ expect(url.searchParams.get("limit")).toBe("10");
120
+ expect(url.searchParams.get("offset")).toBe("20");
121
+ expect(JSON.parse(output.read())).toEqual({
122
+ data: [{ recordId: "record-1", values: { name: "Ana" } }],
123
+ meta: {
124
+ command: "records list",
125
+ configPath,
126
+ page: { offset: 20, limit: 10, total: 1, count: 1, hasMore: false },
127
+ },
128
+ });
129
+ });
130
+
131
+ it("queries records with the same normalized query model used by list", async () => {
132
+ const configPath = createApiConfig();
133
+ const output = createStdoutBuffer();
134
+ const fetchMock = createFetchMock();
135
+ mockSchemaLookup(fetchMock);
136
+ fetchMock.mockResolvedValueOnce(
137
+ jsonResponse({
138
+ records: [{ recordId: "record-1", values: { name: "Ana" } }],
139
+ page: { offset: 10, limit: 5, total: 1, count: 1, hasMore: false },
140
+ }),
141
+ );
142
+ const program = buildProgram({ configPath, stdout: output.stdout });
143
+
144
+ await program.parseAsync([
145
+ "node",
146
+ "zinkee",
147
+ "--json",
148
+ "records",
149
+ "query",
150
+ "contacts",
151
+ "--select",
152
+ "id,name",
153
+ "--where",
154
+ "status eq active",
155
+ "--sort",
156
+ "name:asc,createdAt:desc",
157
+ "--limit",
158
+ "5",
159
+ "--offset",
160
+ "10",
161
+ "--raw",
162
+ '{"select":["wrong"],"extra":"keep"}',
163
+ ]);
164
+
165
+ const init = fetchMock.mock.calls[1]?.[1];
166
+ const headers = new Headers(init?.headers);
167
+
168
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
169
+ "https://api.zinkee.test/api/v2/schemas/schema-1/records:query",
170
+ );
171
+ expect(init?.method).toBe("POST");
172
+ expect(headers.get("content-type")).toBe("application/json");
173
+ expect(JSON.parse(String(init?.body))).toEqual({
174
+ extra: "keep",
175
+ select: ["id", "name"],
176
+ where: [
177
+ {
178
+ field: "status",
179
+ op: "eq",
180
+ value: "active",
181
+ },
182
+ ],
183
+ sort: [
184
+ { field: "name", direction: "ASC" },
185
+ { field: "createdAt", direction: "DESC" },
186
+ ],
187
+ page: { limit: 5, offset: 10 },
188
+ });
189
+ expect(JSON.parse(output.read())).toEqual({
190
+ data: [{ recordId: "record-1", values: { name: "Ana" } }],
191
+ meta: {
192
+ command: "records query",
193
+ configPath,
194
+ page: { offset: 10, limit: 5, total: 1, count: 1, hasMore: false },
195
+ },
196
+ });
197
+ });
198
+
199
+ it("normalizes UUID selectors for records get", async () => {
200
+ const configPath = createApiConfig();
201
+ const output = createStdoutBuffer();
202
+ const fetchMock = createFetchMock();
203
+ fetchMock.mockResolvedValueOnce(
204
+ jsonResponse({
205
+ recordId: "550e8400-e29b-41d4-a716-446655440001",
206
+ values: { name: "Ana" },
207
+ }),
208
+ );
209
+ const program = buildProgram({ configPath, stdout: output.stdout });
210
+
211
+ await program.parseAsync([
212
+ "node",
213
+ "zinkee",
214
+ "--json",
215
+ "records",
216
+ "get",
217
+ "550E8400-E29B-41D4-A716-446655440000",
218
+ "550E8400-E29B-41D4-A716-446655440001",
219
+ ]);
220
+
221
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
222
+ "https://api.zinkee.test/api/v2/schemas/550e8400-e29b-41d4-a716-446655440000/records/550e8400-e29b-41d4-a716-446655440001",
223
+ );
224
+ });
225
+
226
+ it("creates records by merging raw JSON with set and set-json assignments", async () => {
227
+ const configPath = createApiConfig();
228
+ const output = createStdoutBuffer();
229
+ const fetchMock = createFetchMock();
230
+ mockSchemaLookup(fetchMock);
231
+ fetchMock.mockResolvedValueOnce(
232
+ jsonResponse({
233
+ id: "record-1",
234
+ name: "Ana",
235
+ }),
236
+ );
237
+ const program = buildProgram({ configPath, stdout: output.stdout });
238
+
239
+ await program.parseAsync([
240
+ "node",
241
+ "zinkee",
242
+ "--json",
243
+ "records",
244
+ "create",
245
+ "contacts",
246
+ "--set",
247
+ "name=Ana",
248
+ "--set-json",
249
+ 'profile={"tier":"gold"}',
250
+ "--raw",
251
+ '{"name":"Wrong","status":"active"}',
252
+ ]);
253
+
254
+ const init = fetchMock.mock.calls[1]?.[1];
255
+
256
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
257
+ "https://api.zinkee.test/api/v2/schemas/schema-1/records",
258
+ );
259
+ expect(init?.method).toBe("POST");
260
+ expect(JSON.parse(String(init?.body))).toEqual({
261
+ values: {
262
+ name: "Ana",
263
+ status: "active",
264
+ profile: {
265
+ tier: "gold",
266
+ },
267
+ },
268
+ });
269
+ });
270
+
271
+ it("updates records with the patch-style set and unset contract", async () => {
272
+ const configPath = createApiConfig();
273
+ const output = createStdoutBuffer();
274
+ const fetchMock = createFetchMock();
275
+ const rawFile = join(
276
+ mkdtempSync(join(tmpdir(), "zinkee-record-update-")),
277
+ "record.json",
278
+ );
279
+ writeFileSync(
280
+ rawFile,
281
+ JSON.stringify({
282
+ set: {
283
+ status: "inactive",
284
+ metadata: {
285
+ imported: true,
286
+ },
287
+ },
288
+ unset: ["legacyCode"],
289
+ audit: {
290
+ reason: "sync",
291
+ },
292
+ }),
293
+ "utf8",
294
+ );
295
+ mockSchemaLookup(fetchMock);
296
+ fetchMock.mockResolvedValueOnce(
297
+ jsonResponse({
298
+ id: "550e8400-e29b-41d4-a716-446655440001",
299
+ status: "active",
300
+ }),
301
+ );
302
+ const program = buildProgram({ configPath, stdout: output.stdout });
303
+
304
+ await program.parseAsync([
305
+ "node",
306
+ "zinkee",
307
+ "--json",
308
+ "records",
309
+ "update",
310
+ "contacts",
311
+ "550E8400-E29B-41D4-A716-446655440001",
312
+ "--set",
313
+ "status=active",
314
+ "--set-json",
315
+ 'profile={"tier":"gold"}',
316
+ "--unset",
317
+ "tempField",
318
+ "--unset",
319
+ "status",
320
+ "--raw-file",
321
+ rawFile,
322
+ ]);
323
+
324
+ const init = fetchMock.mock.calls[1]?.[1];
325
+
326
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
327
+ "https://api.zinkee.test/api/v2/schemas/schema-1/records/550e8400-e29b-41d4-a716-446655440001",
328
+ );
329
+ expect(init?.method).toBe("PATCH");
330
+ expect(JSON.parse(String(init?.body))).toEqual({
331
+ audit: {
332
+ reason: "sync",
333
+ },
334
+ set: {
335
+ status: "active",
336
+ metadata: {
337
+ imported: true,
338
+ },
339
+ profile: {
340
+ tier: "gold",
341
+ },
342
+ },
343
+ unset: ["legacyCode", "tempField"],
344
+ });
345
+ });
346
+
347
+ it("blocks destructive record commands in read-only mode", async () => {
348
+ const configPath = createApiConfig();
349
+ const output = createStdoutBuffer();
350
+ const fetchMock = createFetchMock();
351
+ const program = buildProgram({ configPath, stdout: output.stdout });
352
+
353
+ await expect(
354
+ program.parseAsync([
355
+ "node",
356
+ "zinkee",
357
+ "--read-only",
358
+ "records",
359
+ "delete",
360
+ "contacts",
361
+ "record-1",
362
+ ]),
363
+ ).rejects.toThrowError('Command "records delete" is not allowed in read-only mode.');
364
+
365
+ expect(fetchMock).not.toHaveBeenCalled();
366
+ });
367
+ });