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,540 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { Writable } from "node:stream";
3
+
4
+ import { Command, InvalidArgumentError } from "commander";
5
+
6
+ import {
7
+ TeamspaceApi,
8
+ type ApiTeamspaceFolder,
9
+ type CreateTeamspaceFolderInput,
10
+ type UpdateTeamspaceFolderInput,
11
+ } from "../api/teamspace.js";
12
+ import { ApiClient } from "../client.js";
13
+ import { defaultConfigPath, readConfig } from "../config.js";
14
+ import {
15
+ normalizeUuidOrSlug,
16
+ type NormalizedUuidOrSlug,
17
+ } from "../parsers/selectors.js";
18
+ import { resolveRuntimeContext } from "../runtime-context.js";
19
+ import type { JsonObject, RuntimeOverrides } from "../types.js";
20
+ import { CliError } from "../utils/errors.js";
21
+ import { buildJsonSuccess, printTable } from "../utils/output.js";
22
+
23
+ interface CommandIo {
24
+ write(chunk: string): unknown;
25
+ }
26
+
27
+ export interface RegisterTeamspaceCommandsOptions {
28
+ configPath?: string;
29
+ stdout?: CommandIo;
30
+ }
31
+
32
+ interface GlobalOptions extends RuntimeOverrides {}
33
+
34
+ interface FolderListCommandOptions {
35
+ includeResources?: boolean;
36
+ }
37
+
38
+ interface FolderCreateCommandOptions {
39
+ name?: string;
40
+ order?: number;
41
+ props?: string;
42
+ propsFile?: string;
43
+ }
44
+
45
+ interface FolderUpdateCommandOptions extends FolderCreateCommandOptions {}
46
+
47
+ interface ResourcePublishCommandOptions {
48
+ to: NormalizedUuidOrSlug;
49
+ }
50
+
51
+ function getCommandIo(stdout: CommandIo | undefined): CommandIo {
52
+ return stdout ?? (process.stdout as Writable);
53
+ }
54
+
55
+ function writeLine(stdout: CommandIo, value: string): void {
56
+ stdout.write(`${value}\n`);
57
+ }
58
+
59
+ function getGlobalOptions(command: Command): GlobalOptions {
60
+ return command.optsWithGlobals<GlobalOptions>();
61
+ }
62
+
63
+ function emitJson(
64
+ stdout: CommandIo,
65
+ commandName: string,
66
+ data: unknown,
67
+ configPath: string,
68
+ ): void {
69
+ writeLine(
70
+ stdout,
71
+ JSON.stringify(buildJsonSuccess(data, { command: commandName, configPath })),
72
+ );
73
+ }
74
+
75
+ function toConfigurationError(filePath: string, error: unknown): never {
76
+ if (error instanceof CliError) {
77
+ throw error;
78
+ }
79
+
80
+ if (
81
+ typeof error === "object" &&
82
+ error !== null &&
83
+ "code" in error &&
84
+ error.code === "ENOENT"
85
+ ) {
86
+ throw new CliError("configuration_error", `Config file not found at ${filePath}.`);
87
+ }
88
+
89
+ if (error instanceof Error) {
90
+ throw new CliError("configuration_error", error.message);
91
+ }
92
+
93
+ throw error;
94
+ }
95
+
96
+ function createTeamspaceApi(
97
+ command: Command,
98
+ filePath: string,
99
+ ): { api: TeamspaceApi; json: boolean; readOnly: boolean } {
100
+ try {
101
+ const config = readConfig(filePath);
102
+ const runtime = resolveRuntimeContext(config, getGlobalOptions(command));
103
+
104
+ if (!runtime.baseUrl) {
105
+ throw new CliError(
106
+ "configuration_error",
107
+ `Resolved profile "${runtime.profile}" is missing a base URL.`,
108
+ );
109
+ }
110
+
111
+ return {
112
+ api: new TeamspaceApi(
113
+ new ApiClient({
114
+ baseUrl: runtime.baseUrl,
115
+ apiKey: runtime.apiKey,
116
+ }),
117
+ ),
118
+ json: runtime.json,
119
+ readOnly: runtime.readOnly,
120
+ };
121
+ } catch (error) {
122
+ toConfigurationError(filePath, error);
123
+ }
124
+ }
125
+
126
+ function ensureWritable(
127
+ commandName: string,
128
+ readOnly: boolean,
129
+ commandMode: "write" | "destructive",
130
+ ): void {
131
+ if (readOnly) {
132
+ throw new CliError(
133
+ "command_not_allowed_in_read_only_mode",
134
+ `Command "${commandName}" is not allowed in read-only mode.`,
135
+ {
136
+ commandMode,
137
+ executionMode: "read-only",
138
+ },
139
+ );
140
+ }
141
+ }
142
+
143
+ function parseUuidOrSlugSelector(input: string): NormalizedUuidOrSlug {
144
+ try {
145
+ return normalizeUuidOrSlug(input);
146
+ } catch (error) {
147
+ const message = error instanceof Error ? error.message : "Invalid selector.";
148
+ throw new InvalidArgumentError(message);
149
+ }
150
+ }
151
+
152
+ function parseNonNegativeInteger(input: string): number {
153
+ if (!/^\d+$/.test(input.trim())) {
154
+ throw new InvalidArgumentError(`Expected a non-negative integer, received "${input}".`);
155
+ }
156
+
157
+ return Number(input);
158
+ }
159
+
160
+ function isJsonObject(value: unknown): value is JsonObject {
161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
162
+ }
163
+
164
+ function parseRawJsonObject(input: string, sourceLabel: string): JsonObject {
165
+ let parsed: unknown;
166
+
167
+ try {
168
+ parsed = JSON.parse(input);
169
+ } catch (error) {
170
+ const message = error instanceof Error ? error.message : "Invalid JSON.";
171
+ throw new CliError(
172
+ "invalid_cli_usage",
173
+ `Failed to parse JSON from ${sourceLabel}: ${message}`,
174
+ );
175
+ }
176
+
177
+ if (!isJsonObject(parsed)) {
178
+ throw new CliError(
179
+ "invalid_cli_usage",
180
+ `Expected ${sourceLabel} to contain a JSON object.`,
181
+ );
182
+ }
183
+
184
+ return parsed;
185
+ }
186
+
187
+ function readRawObject(
188
+ raw: string | undefined,
189
+ rawFile: string | undefined,
190
+ ): JsonObject | undefined {
191
+ if (raw && rawFile) {
192
+ throw new CliError(
193
+ "invalid_cli_usage",
194
+ 'Provide either "--props" or "--props-file", not both.',
195
+ );
196
+ }
197
+
198
+ if (raw) {
199
+ return parseRawJsonObject(raw, "--props");
200
+ }
201
+
202
+ if (rawFile) {
203
+ let contents: string;
204
+
205
+ try {
206
+ contents = readFileSync(rawFile, "utf8");
207
+ } catch (error) {
208
+ const message = error instanceof Error ? error.message : "Unable to read file.";
209
+ throw new CliError(
210
+ "invalid_cli_usage",
211
+ `Failed to read props payload from ${rawFile}: ${message}`,
212
+ );
213
+ }
214
+
215
+ return parseRawJsonObject(contents, `--props-file ${rawFile}`);
216
+ }
217
+
218
+ return undefined;
219
+ }
220
+
221
+ function isEmptyObject(value: object): boolean {
222
+ return Object.keys(value).length === 0;
223
+ }
224
+
225
+ function buildFolderCreateInput(
226
+ options: FolderCreateCommandOptions,
227
+ ): CreateTeamspaceFolderInput {
228
+ if (options.name === undefined) {
229
+ throw new CliError("invalid_cli_usage", 'Provide folder name via "--name".');
230
+ }
231
+
232
+ const input: CreateTeamspaceFolderInput = {
233
+ name: options.name,
234
+ };
235
+
236
+ if (options.order !== undefined) {
237
+ input.order = options.order;
238
+ }
239
+
240
+ const props = readRawObject(options.props, options.propsFile);
241
+
242
+ if (props !== undefined) {
243
+ input.additionalProperties = props;
244
+ }
245
+
246
+ return input;
247
+ }
248
+
249
+ function buildFolderUpdateInput(
250
+ options: FolderUpdateCommandOptions,
251
+ ): UpdateTeamspaceFolderInput {
252
+ const input: UpdateTeamspaceFolderInput = {};
253
+
254
+ if (options.name !== undefined) {
255
+ input.name = options.name;
256
+ }
257
+
258
+ if (options.order !== undefined) {
259
+ input.order = options.order;
260
+ }
261
+
262
+ const props = readRawObject(options.props, options.propsFile);
263
+
264
+ if (props !== undefined) {
265
+ input.additionalProperties = props;
266
+ }
267
+
268
+ if (isEmptyObject(input)) {
269
+ throw new CliError(
270
+ "invalid_cli_usage",
271
+ 'Provide folder updates via "--name", "--order", "--props", or "--props-file".',
272
+ );
273
+ }
274
+
275
+ return input;
276
+ }
277
+
278
+ function toTableRows(data: unknown): Record<string, unknown>[] {
279
+ if (Array.isArray(data)) {
280
+ return data.map((entry) => (isJsonObject(entry) ? entry : { value: entry }));
281
+ }
282
+
283
+ if (isJsonObject(data)) {
284
+ return [data];
285
+ }
286
+
287
+ return [{ value: data }];
288
+ }
289
+
290
+ function emitTable(stdout: CommandIo, data: unknown): void {
291
+ printTable(toTableRows(data), undefined, stdout);
292
+ }
293
+
294
+ function getFolderIdentifier(folder: ApiTeamspaceFolder): string {
295
+ return typeof folder.id === "string"
296
+ ? folder.id
297
+ : typeof folder.name === "string"
298
+ ? folder.name
299
+ : "unknown";
300
+ }
301
+
302
+ function buildFolderLookup(
303
+ folders: readonly ApiTeamspaceFolder[],
304
+ selector: NormalizedUuidOrSlug,
305
+ ): NormalizedUuidOrSlug {
306
+ if (selector.kind === "uuid") {
307
+ return selector;
308
+ }
309
+
310
+ const matches = folders.filter((folder) => folder.name === selector.value);
311
+
312
+ if (matches.length === 1) {
313
+ const folderId = matches[0]?.id;
314
+
315
+ if (typeof folderId === "string" && folderId.length > 0) {
316
+ return {
317
+ kind: "uuid",
318
+ value: folderId,
319
+ };
320
+ }
321
+ }
322
+
323
+ if (matches.length > 1) {
324
+ throw new CliError(
325
+ "invalid_cli_usage",
326
+ `Folder "${selector.value}" is ambiguous. Found ${matches.length} matches.`,
327
+ );
328
+ }
329
+
330
+ throw new CliError("invalid_cli_usage", `Folder "${selector.value}" was not found.`);
331
+ }
332
+
333
+ function findFolderEntity(
334
+ folders: readonly ApiTeamspaceFolder[],
335
+ selector: NormalizedUuidOrSlug,
336
+ ): ApiTeamspaceFolder {
337
+ if (selector.kind === "uuid") {
338
+ const match = folders.find((folder) => folder.id === selector.value);
339
+
340
+ if (match) {
341
+ return match;
342
+ }
343
+
344
+ throw new CliError("invalid_cli_usage", `Folder "${selector.value}" was not found.`);
345
+ }
346
+
347
+ const matches = folders.filter((folder) => folder.name === selector.value);
348
+
349
+ if (matches.length === 1) {
350
+ return matches[0] as ApiTeamspaceFolder;
351
+ }
352
+
353
+ if (matches.length > 1) {
354
+ throw new CliError(
355
+ "invalid_cli_usage",
356
+ `Folder "${selector.value}" is ambiguous. Found ${matches.length} matches.`,
357
+ );
358
+ }
359
+
360
+ throw new CliError("invalid_cli_usage", `Folder "${selector.value}" was not found.`);
361
+ }
362
+
363
+ async function resolveFolderSelector(
364
+ api: TeamspaceApi,
365
+ selector: NormalizedUuidOrSlug,
366
+ ): Promise<NormalizedUuidOrSlug> {
367
+ if (selector.kind === "uuid") {
368
+ return selector;
369
+ }
370
+
371
+ const folders = await api.listFolders(false);
372
+ return buildFolderLookup(folders, selector);
373
+ }
374
+
375
+ export function registerTeamspaceCommands(
376
+ program: Command,
377
+ options: RegisterTeamspaceCommandsOptions = {},
378
+ ): void {
379
+ const filePath = options.configPath ?? defaultConfigPath;
380
+ const stdout = getCommandIo(options.stdout);
381
+ const teamspace = program.command("teamspace").description("Manage teamspace folders and resources");
382
+
383
+ const folders = teamspace.command("folders").description("Manage teamspace folders");
384
+
385
+ folders
386
+ .command("list")
387
+ .description("List teamspace folders")
388
+ .option("--include-resources", "include nested resources")
389
+ .action(async function action(this: Command, commandOptions: FolderListCommandOptions) {
390
+ const { api, json } = createTeamspaceApi(this, filePath);
391
+ const data = await api.listFolders(Boolean(commandOptions.includeResources));
392
+
393
+ if (json) {
394
+ emitJson(stdout, "teamspace folders list", data, filePath);
395
+ return;
396
+ }
397
+
398
+ emitTable(stdout, data);
399
+ });
400
+
401
+ folders
402
+ .command("get")
403
+ .description("Get a teamspace folder by exact name or UUID")
404
+ .argument("<folder>", "folder UUID or exact name", parseUuidOrSlugSelector)
405
+ .option("--include-resources", "include nested resources")
406
+ .action(async function action(
407
+ this: Command,
408
+ folder: NormalizedUuidOrSlug,
409
+ commandOptions: FolderListCommandOptions,
410
+ ) {
411
+ const { api, json } = createTeamspaceApi(this, filePath);
412
+ const resolvedFolder = await resolveFolderSelector(api, folder);
413
+ const allFolders = await api.listFolders(Boolean(commandOptions.includeResources));
414
+ const data = findFolderEntity(allFolders, resolvedFolder);
415
+
416
+ if (json) {
417
+ emitJson(stdout, "teamspace folders get", data, filePath);
418
+ return;
419
+ }
420
+
421
+ emitTable(stdout, data);
422
+ });
423
+
424
+ folders
425
+ .command("create")
426
+ .description("Create a teamspace folder")
427
+ .requiredOption("--name <name>", "folder name")
428
+ .option("--order <n>", "folder order", parseNonNegativeInteger)
429
+ .option("--props <json>", "folder properties payload")
430
+ .option("--props-file <path>", "path to a folder properties JSON payload")
431
+ .action(async function action(
432
+ this: Command,
433
+ commandOptions: FolderCreateCommandOptions,
434
+ ) {
435
+ const { api, json, readOnly } = createTeamspaceApi(this, filePath);
436
+ ensureWritable("teamspace folders create", readOnly, "write");
437
+
438
+ const data = await api.createFolder(buildFolderCreateInput(commandOptions));
439
+
440
+ if (json) {
441
+ emitJson(stdout, "teamspace folders create", data, filePath);
442
+ return;
443
+ }
444
+
445
+ writeLine(stdout, `Created folder "${getFolderIdentifier(data)}".`);
446
+ });
447
+
448
+ folders
449
+ .command("update")
450
+ .description("Update a teamspace folder")
451
+ .argument("<folder>", "folder UUID or exact name", parseUuidOrSlugSelector)
452
+ .option("--name <name>", "folder name")
453
+ .option("--order <n>", "folder order", parseNonNegativeInteger)
454
+ .option("--props <json>", "folder properties payload")
455
+ .option("--props-file <path>", "path to a folder properties JSON payload")
456
+ .action(async function action(
457
+ this: Command,
458
+ folder: NormalizedUuidOrSlug,
459
+ commandOptions: FolderUpdateCommandOptions,
460
+ ) {
461
+ const { api, json, readOnly } = createTeamspaceApi(this, filePath);
462
+ ensureWritable("teamspace folders update", readOnly, "write");
463
+
464
+ const resolvedFolder = await resolveFolderSelector(api, folder);
465
+ const data = await api.updateFolder(resolvedFolder, buildFolderUpdateInput(commandOptions));
466
+
467
+ if (json) {
468
+ emitJson(stdout, "teamspace folders update", data, filePath);
469
+ return;
470
+ }
471
+
472
+ writeLine(stdout, `Updated folder "${getFolderIdentifier(data)}".`);
473
+ });
474
+
475
+ folders
476
+ .command("delete")
477
+ .description("Delete a teamspace folder")
478
+ .argument("<folder>", "folder UUID or exact name", parseUuidOrSlugSelector)
479
+ .action(async function action(this: Command, folder: NormalizedUuidOrSlug) {
480
+ const { api, json, readOnly } = createTeamspaceApi(this, filePath);
481
+ ensureWritable("teamspace folders delete", readOnly, "destructive");
482
+
483
+ const resolvedFolder = await resolveFolderSelector(api, folder);
484
+ const data = await api.deleteFolder(resolvedFolder);
485
+
486
+ if (json) {
487
+ emitJson(stdout, "teamspace folders delete", data, filePath);
488
+ return;
489
+ }
490
+
491
+ writeLine(stdout, `Deleted folder "${resolvedFolder.value}".`);
492
+ });
493
+
494
+ const resources = teamspace.command("resources").description("Manage teamspace resources");
495
+
496
+ resources
497
+ .command("publish")
498
+ .description("Publish a resource to a teamspace folder")
499
+ .argument("<resource>", "resource UUID or exact name", parseUuidOrSlugSelector)
500
+ .requiredOption("--to <folder>", "target folder", parseUuidOrSlugSelector)
501
+ .action(async function action(
502
+ this: Command,
503
+ resource: NormalizedUuidOrSlug,
504
+ commandOptions: ResourcePublishCommandOptions,
505
+ ) {
506
+ const { api, json, readOnly } = createTeamspaceApi(this, filePath);
507
+ ensureWritable("teamspace resources publish", readOnly, "write");
508
+
509
+ const folder = await resolveFolderSelector(api, commandOptions.to);
510
+ const data = await api.publishResource({
511
+ resourceId: resource.value,
512
+ targetFolderId: folder.value,
513
+ });
514
+
515
+ if (json) {
516
+ emitJson(stdout, "teamspace resources publish", data, filePath);
517
+ return;
518
+ }
519
+
520
+ writeLine(stdout, `Published resource "${resource.value}" to folder "${folder.value}".`);
521
+ });
522
+
523
+ resources
524
+ .command("unpublish")
525
+ .description("Unpublish a resource from teamspace")
526
+ .argument("<resource>", "resource UUID or exact name", parseUuidOrSlugSelector)
527
+ .action(async function action(this: Command, resource: NormalizedUuidOrSlug) {
528
+ const { api, json, readOnly } = createTeamspaceApi(this, filePath);
529
+ ensureWritable("teamspace resources unpublish", readOnly, "write");
530
+
531
+ const data = await api.unpublishResource(resource.value);
532
+
533
+ if (json) {
534
+ emitJson(stdout, "teamspace resources unpublish", data, filePath);
535
+ return;
536
+ }
537
+
538
+ writeLine(stdout, `Unpublished resource "${resource.value}".`);
539
+ });
540
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ parseConfig,
5
+ redactConfig,
6
+ redactSecret,
7
+ removeProfileConfig,
8
+ resolveProfile,
9
+ serializeConfig,
10
+ setDefaultProfile,
11
+ setProfileConfig,
12
+ } from "./config.js";
13
+ import { resolveRuntimeContext } from "./runtime-context.js";
14
+
15
+ describe("parseConfig", () => {
16
+ it("reads the legacy config shape without migration", () => {
17
+ const config = parseConfig(`
18
+ default_profile = "prod"
19
+
20
+ [profiles.prod]
21
+ api_key = "token"
22
+ base_url = "https://api.zinkee.com"
23
+ retry_count = 3
24
+ `);
25
+
26
+ expect(config.defaultProfile).toBe("prod");
27
+ expect(config.profiles.prod?.apiKey).toBe("token");
28
+ expect(config.profiles.prod?.baseUrl).toBe("https://api.zinkee.com");
29
+ expect(config.profiles.prod?.retry_count).toBe(3);
30
+ });
31
+ });
32
+
33
+ describe("resolveProfile", () => {
34
+ it("prefers explicit profile over default_profile", () => {
35
+ const config = {
36
+ defaultProfile: "prod",
37
+ profiles: {
38
+ prod: { apiKey: "a" },
39
+ local: { apiKey: "b", baseUrl: "http://localhost:8088" },
40
+ },
41
+ };
42
+
43
+ expect(resolveProfile(config, { profile: "local" }).name).toBe("local");
44
+ });
45
+
46
+ it("throws a clear error when the explicit profile is missing", () => {
47
+ const config = {
48
+ defaultProfile: "prod",
49
+ profiles: {
50
+ prod: { apiKey: "a" },
51
+ },
52
+ };
53
+
54
+ expect(() => resolveProfile(config, { profile: "local" })).toThrowError(
55
+ 'Profile "local" not found in config.',
56
+ );
57
+ });
58
+
59
+ it("throws a clear error when no effective profile exists", () => {
60
+ const config = {
61
+ profiles: {},
62
+ };
63
+
64
+ expect(() => resolveProfile(config)).toThrowError("No profile could be resolved from config.");
65
+ });
66
+ });
67
+
68
+ describe("resolveRuntimeContext", () => {
69
+ it("applies profile, baseUrl, apiKey, readOnly, and json overrides cleanly", () => {
70
+ const config = {
71
+ defaultProfile: "prod",
72
+ profiles: {
73
+ prod: { apiKey: "token", baseUrl: "https://api.zinkee.com" },
74
+ local: { apiKey: "local-token", baseUrl: "http://localhost:8088" },
75
+ },
76
+ };
77
+
78
+ expect(
79
+ resolveRuntimeContext(config, {
80
+ profile: "local",
81
+ baseUrl: "http://127.0.0.1:8088",
82
+ apiKey: "override-token",
83
+ readOnly: true,
84
+ json: false,
85
+ }),
86
+ ).toEqual({
87
+ profile: "local",
88
+ baseUrl: "http://127.0.0.1:8088",
89
+ apiKey: "override-token",
90
+ readOnly: true,
91
+ json: false,
92
+ });
93
+ });
94
+ });
95
+
96
+ describe("config mutation helpers", () => {
97
+ it("serializes updates back to the legacy TOML shape", () => {
98
+ const nextConfig = setDefaultProfile(
99
+ setProfileConfig(
100
+ {
101
+ profiles: {},
102
+ },
103
+ "local",
104
+ {
105
+ apiKey: "token",
106
+ baseUrl: "http://localhost:8088",
107
+ },
108
+ ),
109
+ "local",
110
+ );
111
+
112
+ expect(serializeConfig(nextConfig)).toContain('default_profile = "local"');
113
+ expect(serializeConfig(nextConfig)).toContain('[profiles.local]');
114
+ expect(serializeConfig(nextConfig)).toContain('api_key = "token"');
115
+ expect(serializeConfig(nextConfig)).toContain('base_url = "http://localhost:8088"');
116
+ });
117
+
118
+ it("removes a deleted default profile cleanly", () => {
119
+ const config = {
120
+ defaultProfile: "prod",
121
+ profiles: {
122
+ prod: { apiKey: "token" },
123
+ local: { apiKey: "other" },
124
+ },
125
+ };
126
+
127
+ expect(removeProfileConfig(config, "prod")).toEqual({
128
+ defaultProfile: undefined,
129
+ profiles: {
130
+ local: { apiKey: "other" },
131
+ },
132
+ });
133
+ });
134
+ });
135
+
136
+ describe("redaction helpers", () => {
137
+ it("redacts secrets while keeping the last four characters", () => {
138
+ expect(redactSecret("secret-token-1234")).toBe("********1234");
139
+ expect(redactSecret("abc")).toBe("********");
140
+ });
141
+
142
+ it("redacts config output without mutating other fields", () => {
143
+ expect(
144
+ redactConfig({
145
+ defaultProfile: "prod",
146
+ profiles: {
147
+ prod: {
148
+ apiKey: "secret-token-1234",
149
+ baseUrl: "https://api.zinkee.test",
150
+ retry_count: 3,
151
+ },
152
+ },
153
+ }),
154
+ ).toEqual({
155
+ defaultProfile: "prod",
156
+ profiles: {
157
+ prod: {
158
+ apiKey: "********1234",
159
+ baseUrl: "https://api.zinkee.test",
160
+ retry_count: 3,
161
+ },
162
+ },
163
+ });
164
+ });
165
+ });