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,214 @@
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { Command } from "commander";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { registerNavigationCommands } from "./navigation.js";
9
+
10
+ function createTempConfig(contents: string): string {
11
+ const dir = mkdtempSync(join(tmpdir(), "zinkee-navigation-"));
12
+ const filePath = join(dir, "config.toml");
13
+ writeFileSync(filePath, contents, "utf8");
14
+ return filePath;
15
+ }
16
+
17
+ function createApiConfig(): string {
18
+ return createTempConfig(`
19
+ default_profile = "prod"
20
+
21
+ [profiles.prod]
22
+ api_key = "navigation-token-1234"
23
+ base_url = "https://api.zinkee.test"
24
+ `);
25
+ }
26
+
27
+ function createStdoutBuffer() {
28
+ let output = "";
29
+
30
+ return {
31
+ stdout: {
32
+ write(chunk: string) {
33
+ output += chunk;
34
+ return true;
35
+ },
36
+ },
37
+ read() {
38
+ return output;
39
+ },
40
+ };
41
+ }
42
+
43
+ function jsonResponse(body: unknown, status: number = 200): Response {
44
+ return new Response(JSON.stringify(body), {
45
+ status,
46
+ headers: {
47
+ "content-type": "application/json",
48
+ },
49
+ });
50
+ }
51
+
52
+ function createFetchMock() {
53
+ const fetchMock = vi.fn<typeof fetch>();
54
+ vi.stubGlobal("fetch", fetchMock);
55
+ return fetchMock;
56
+ }
57
+
58
+ function buildProgram(configPath: string, stdout: { write(chunk: string): unknown }) {
59
+ const program = new Command();
60
+
61
+ program
62
+ .name("zinkee")
63
+ .enablePositionalOptions()
64
+ .showHelpAfterError()
65
+ .option("--profile <name>", "select a profile from config")
66
+ .option("--base-url <url>", "override the configured base URL")
67
+ .option("--api-key <token>", "override the configured API key")
68
+ .option("--read-only", "run in read-only mode")
69
+ .option("--json", "emit JSON output");
70
+
71
+ registerNavigationCommands(program, { configPath, stdout });
72
+
73
+ return program;
74
+ }
75
+
76
+ afterEach(() => {
77
+ vi.unstubAllGlobals();
78
+ vi.restoreAllMocks();
79
+ });
80
+
81
+ describe("navigation commands", () => {
82
+ it("resolves exact folder names for get and rejects duplicate matches", async () => {
83
+ const configPath = createApiConfig();
84
+ const output = createStdoutBuffer();
85
+ const fetchMock = createFetchMock();
86
+ fetchMock.mockResolvedValueOnce(
87
+ jsonResponse([
88
+ {
89
+ id: "11111111-1111-1111-1111-111111111111",
90
+ name: "Finance",
91
+ order: 10,
92
+ resources: [],
93
+ },
94
+ {
95
+ id: "22222222-2222-2222-2222-222222222222",
96
+ name: "Marketing",
97
+ order: 20,
98
+ resources: [],
99
+ },
100
+ ]),
101
+ );
102
+ const program = buildProgram(configPath, output.stdout);
103
+
104
+ await program.parseAsync([
105
+ "node",
106
+ "zinkee",
107
+ "--json",
108
+ "navigation",
109
+ "folders",
110
+ "get",
111
+ "Finance",
112
+ ]);
113
+
114
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
115
+ "https://api.zinkee.test/api/v2/navigation/folders",
116
+ );
117
+ expect(JSON.parse(output.read())).toEqual({
118
+ data: {
119
+ id: "11111111-1111-1111-1111-111111111111",
120
+ name: "Finance",
121
+ order: 10,
122
+ resources: [],
123
+ },
124
+ meta: {
125
+ command: "navigation folders get",
126
+ configPath,
127
+ },
128
+ });
129
+
130
+ fetchMock.mockReset();
131
+ fetchMock.mockResolvedValueOnce(
132
+ jsonResponse([
133
+ {
134
+ id: "11111111-1111-1111-1111-111111111111",
135
+ name: "Finance",
136
+ order: 10,
137
+ resources: [],
138
+ },
139
+ {
140
+ id: "22222222-2222-2222-2222-222222222222",
141
+ name: "Finance",
142
+ order: 20,
143
+ resources: [],
144
+ },
145
+ ]),
146
+ );
147
+
148
+ await expect(
149
+ buildProgram(configPath, output.stdout).parseAsync([
150
+ "node",
151
+ "zinkee",
152
+ "navigation",
153
+ "folders",
154
+ "get",
155
+ "Finance",
156
+ ]),
157
+ ).rejects.toThrowError('Folder "Finance" is ambiguous. Found 2 matches.');
158
+ });
159
+
160
+ it("shapes move payloads using the resource type and resolved folder id", async () => {
161
+ const configPath = createApiConfig();
162
+ const output = createStdoutBuffer();
163
+ const fetchMock = createFetchMock();
164
+ fetchMock.mockResolvedValueOnce(
165
+ jsonResponse([
166
+ {
167
+ id: "aaaabbbb-cccc-dddd-eeee-ffffffffffff",
168
+ name: "Finance",
169
+ order: 10,
170
+ resources: [
171
+ {
172
+ id: "resource-1",
173
+ name: "Invoices",
174
+ type: "DISPLAY",
175
+ isInSpace: false,
176
+ },
177
+ ],
178
+ },
179
+ ]),
180
+ );
181
+ fetchMock.mockResolvedValueOnce(jsonResponse({ resourceId: "resource-1", targetFolderId: "aaaabbbb-cccc-dddd-eeee-ffffffffffff" }));
182
+ const program = buildProgram(configPath, output.stdout);
183
+
184
+ await program.parseAsync([
185
+ "node",
186
+ "zinkee",
187
+ "--json",
188
+ "navigation",
189
+ "resources",
190
+ "move",
191
+ "resource-1",
192
+ "--type",
193
+ "display",
194
+ "--to",
195
+ "Finance",
196
+ ]);
197
+
198
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
199
+ "https://api.zinkee.test/api/v2/navigation/folders",
200
+ );
201
+
202
+ const moveInit = fetchMock.mock.calls[1]?.[1];
203
+
204
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
205
+ "https://api.zinkee.test/api/v2/navigation/resources:move",
206
+ );
207
+ expect(moveInit?.method).toBe("POST");
208
+ expect(JSON.parse(String(moveInit?.body))).toEqual({
209
+ resourceId: "resource-1",
210
+ resourceType: "DISPLAY",
211
+ targetFolderId: "aaaabbbb-cccc-dddd-eeee-ffffffffffff",
212
+ });
213
+ });
214
+ });
@@ -0,0 +1,348 @@
1
+ import { readFileSync } from "node:fs";
2
+ import type { Writable } from "node:stream";
3
+
4
+ import { Command, InvalidArgumentError } from "commander";
5
+
6
+ import { NavigationApi, type NavigationFolder } from "../api/navigation.js";
7
+ import { ApiClient } from "../client.js";
8
+ import { defaultConfigPath, readConfig } from "../config.js";
9
+ import { normalizeUuidOrSlug, type NormalizedUuidOrSlug } from "../parsers/selectors.js";
10
+ import { resolveRuntimeContext } from "../runtime-context.js";
11
+ import type { RuntimeOverrides } from "../types.js";
12
+ import { CliError } from "../utils/errors.js";
13
+ import { buildJsonSuccess, printTable } from "../utils/output.js";
14
+
15
+ interface CommandIo {
16
+ write(chunk: string): unknown;
17
+ }
18
+
19
+ export interface RegisterNavigationCommandsOptions {
20
+ configPath?: string;
21
+ stdout?: CommandIo;
22
+ }
23
+
24
+ interface GlobalOptions extends RuntimeOverrides {}
25
+
26
+ interface FolderMutationOptions {
27
+ name?: string;
28
+ order?: string;
29
+ props?: string;
30
+ propsFile?: string;
31
+ }
32
+
33
+ function getCommandIo(stdout: CommandIo | undefined): CommandIo {
34
+ return stdout ?? (process.stdout as Writable);
35
+ }
36
+
37
+ function writeLine(stdout: CommandIo, value: string): void {
38
+ stdout.write(`${value}\n`);
39
+ }
40
+
41
+ function getGlobalOptions(command: Command): GlobalOptions {
42
+ return command.optsWithGlobals<GlobalOptions>();
43
+ }
44
+
45
+ function emitJson(
46
+ stdout: CommandIo,
47
+ commandName: string,
48
+ data: unknown,
49
+ configPath: string,
50
+ ): void {
51
+ writeLine(stdout, JSON.stringify(buildJsonSuccess(data, { command: commandName, configPath })));
52
+ }
53
+
54
+ function toConfigurationError(filePath: string, error: unknown): never {
55
+ if (error instanceof CliError) {
56
+ throw error;
57
+ }
58
+
59
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
60
+ throw new CliError("configuration_error", `Config file not found at ${filePath}.`);
61
+ }
62
+
63
+ if (error instanceof Error) {
64
+ throw new CliError("configuration_error", error.message);
65
+ }
66
+
67
+ throw error;
68
+ }
69
+
70
+ function createNavigationApi(
71
+ command: Command,
72
+ filePath: string,
73
+ ): { api: NavigationApi; json: boolean; readOnly: boolean } {
74
+ try {
75
+ const config = readConfig(filePath);
76
+ const runtime = resolveRuntimeContext(config, getGlobalOptions(command));
77
+
78
+ if (!runtime.baseUrl) {
79
+ throw new CliError("configuration_error", `Resolved profile "${runtime.profile}" is missing a base URL.`);
80
+ }
81
+
82
+ return {
83
+ api: new NavigationApi(
84
+ new ApiClient({
85
+ baseUrl: runtime.baseUrl,
86
+ apiKey: runtime.apiKey,
87
+ }),
88
+ ),
89
+ json: runtime.json,
90
+ readOnly: runtime.readOnly,
91
+ };
92
+ } catch (error) {
93
+ toConfigurationError(filePath, error);
94
+ }
95
+ }
96
+
97
+ function ensureWritable(commandName: string, readOnly: boolean, mode: "write" | "destructive"): void {
98
+ if (readOnly) {
99
+ throw new CliError(
100
+ "command_not_allowed_in_read_only_mode",
101
+ `Command "${commandName}" is not allowed in read-only mode.`,
102
+ { commandMode: mode, executionMode: "read-only" },
103
+ );
104
+ }
105
+ }
106
+
107
+ function parseUuidOrSlugSelector(input: string): NormalizedUuidOrSlug {
108
+ try {
109
+ return normalizeUuidOrSlug(input);
110
+ } catch (error) {
111
+ const message = error instanceof Error ? error.message : "Invalid selector.";
112
+ throw new InvalidArgumentError(message);
113
+ }
114
+ }
115
+
116
+ function parseInteger(value: string | undefined, flagName: string): number | undefined {
117
+ if (value === undefined) {
118
+ return undefined;
119
+ }
120
+
121
+ const parsed = Number.parseInt(value, 10);
122
+ if (Number.isNaN(parsed)) {
123
+ throw new CliError("invalid_cli_usage", `${flagName} must be an integer.`);
124
+ }
125
+
126
+ return parsed;
127
+ }
128
+
129
+ function readJsonObject(raw?: string, rawFile?: string): Record<string, unknown> {
130
+ if (raw && rawFile) {
131
+ throw new CliError("invalid_cli_usage", 'Use either "--props" or "--props-file", not both.');
132
+ }
133
+
134
+ const source = rawFile ? readFileSync(rawFile, "utf8") : raw;
135
+ if (!source) {
136
+ return {};
137
+ }
138
+
139
+ try {
140
+ const parsed = JSON.parse(source) as unknown;
141
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
142
+ throw new CliError("invalid_cli_usage", "Props payload must be a JSON object.");
143
+ }
144
+ return parsed as Record<string, unknown>;
145
+ } catch (error) {
146
+ if (error instanceof CliError) {
147
+ throw error;
148
+ }
149
+ throw new CliError("invalid_cli_usage", "Props payload is invalid JSON.");
150
+ }
151
+ }
152
+
153
+ function buildFolderBody(options: FolderMutationOptions): Record<string, unknown> {
154
+ const body: Record<string, unknown> = {};
155
+
156
+ if (options.name !== undefined) {
157
+ body.name = options.name;
158
+ }
159
+
160
+ const order = parseInteger(options.order, "--order");
161
+ if (order !== undefined) {
162
+ body.order = order;
163
+ }
164
+
165
+ const props = readJsonObject(options.props, options.propsFile);
166
+ if (Object.keys(props).length > 0) {
167
+ body.additionalProperties = props;
168
+ }
169
+
170
+ return body;
171
+ }
172
+
173
+ function resolveFolder(
174
+ folders: readonly NavigationFolder[],
175
+ selector: NormalizedUuidOrSlug,
176
+ ): NavigationFolder {
177
+ if (selector.kind === "uuid") {
178
+ const match = folders.find((folder) => folder.id === selector.value);
179
+ if (!match) {
180
+ throw new CliError("invalid_cli_usage", `Folder "${selector.value}" was not found.`);
181
+ }
182
+ return match;
183
+ }
184
+
185
+ const matches = folders.filter((folder) => folder.name === selector.value);
186
+ if (matches.length === 1) {
187
+ return matches[0] as NavigationFolder;
188
+ }
189
+
190
+ if (matches.length > 1) {
191
+ throw new CliError(
192
+ "invalid_cli_usage",
193
+ `Folder "${selector.value}" is ambiguous. Found ${matches.length} matches.`,
194
+ );
195
+ }
196
+
197
+ throw new CliError("invalid_cli_usage", `Folder "${selector.value}" was not found.`);
198
+ }
199
+
200
+ function folderRows(folders: readonly NavigationFolder[]): Record<string, unknown>[] {
201
+ return folders.map((folder) => ({
202
+ id: folder.id,
203
+ order: folder.order ?? "",
204
+ name: folder.name ?? "",
205
+ resourceCount: Array.isArray(folder.resources) ? folder.resources.length : "",
206
+ }));
207
+ }
208
+
209
+ export function registerNavigationCommands(
210
+ program: Command,
211
+ options: RegisterNavigationCommandsOptions = {},
212
+ ): void {
213
+ const filePath = options.configPath ?? defaultConfigPath;
214
+ const stdout = getCommandIo(options.stdout);
215
+ const navigation = program.command("navigation").description("Manage workspace navigation");
216
+ const folders = navigation.command("folders").description("Manage navigation folders");
217
+
218
+ folders
219
+ .command("list")
220
+ .description("List navigation folders")
221
+ .action(async function action(this: Command) {
222
+ const { api, json } = createNavigationApi(this, filePath);
223
+ const data = await api.listFolders();
224
+
225
+ if (json) {
226
+ emitJson(stdout, "navigation folders list", data, filePath);
227
+ return;
228
+ }
229
+
230
+ printTable(folderRows(data), ["id", "order", "name", "resourceCount"], stdout);
231
+ });
232
+
233
+ folders
234
+ .command("get")
235
+ .description("Get a navigation folder by UUID or exact name")
236
+ .argument("<folder>", "folder UUID or exact name", parseUuidOrSlugSelector)
237
+ .action(async function action(this: Command, folder: NormalizedUuidOrSlug) {
238
+ const { api, json } = createNavigationApi(this, filePath);
239
+ const data = await api.listFolders();
240
+ const resolved = resolveFolder(data, folder);
241
+
242
+ if (json) {
243
+ emitJson(stdout, "navigation folders get", resolved, filePath);
244
+ return;
245
+ }
246
+
247
+ printTable(folderRows([resolved]), ["id", "order", "name", "resourceCount"], stdout);
248
+ });
249
+
250
+ folders
251
+ .command("create")
252
+ .description("Create a navigation folder")
253
+ .requiredOption("--name <name>", "folder name")
254
+ .option("--order <n>", "folder order")
255
+ .option("--props <json>", "folder additionalProperties JSON")
256
+ .option("--props-file <path>", "folder additionalProperties JSON file")
257
+ .action(async function action(this: Command, commandOptions: FolderMutationOptions) {
258
+ const { api, json, readOnly } = createNavigationApi(this, filePath);
259
+ ensureWritable("navigation folders create", readOnly, "write");
260
+ const data = await api.createFolder(buildFolderBody(commandOptions));
261
+
262
+ if (json) {
263
+ emitJson(stdout, "navigation folders create", data, filePath);
264
+ return;
265
+ }
266
+
267
+ writeLine(stdout, `Created navigation folder "${String(data.name ?? commandOptions.name)}".`);
268
+ });
269
+
270
+ folders
271
+ .command("update")
272
+ .description("Update a navigation folder")
273
+ .argument("<folder>", "folder UUID or exact name", parseUuidOrSlugSelector)
274
+ .option("--name <name>", "folder name")
275
+ .option("--order <n>", "folder order")
276
+ .option("--props <json>", "folder additionalProperties JSON")
277
+ .option("--props-file <path>", "folder additionalProperties JSON file")
278
+ .action(async function action(this: Command, folder: NormalizedUuidOrSlug, commandOptions: FolderMutationOptions) {
279
+ const { api, json, readOnly } = createNavigationApi(this, filePath);
280
+ ensureWritable("navigation folders update", readOnly, "write");
281
+ const allFolders = await api.listFolders();
282
+ const resolved = resolveFolder(allFolders, folder);
283
+ const body = buildFolderBody(commandOptions);
284
+
285
+ if (Object.keys(body).length === 0) {
286
+ throw new CliError("invalid_cli_usage", "Nothing to update.");
287
+ }
288
+
289
+ const data = await api.updateFolder({ kind: "uuid", value: resolved.id }, body);
290
+
291
+ if (json) {
292
+ emitJson(stdout, "navigation folders update", data, filePath);
293
+ return;
294
+ }
295
+
296
+ writeLine(stdout, `Updated navigation folder "${String(data.name ?? resolved.name ?? resolved.id)}".`);
297
+ });
298
+
299
+ folders
300
+ .command("delete")
301
+ .description("Delete a navigation folder")
302
+ .argument("<folder>", "folder UUID or exact name", parseUuidOrSlugSelector)
303
+ .action(async function action(this: Command, folder: NormalizedUuidOrSlug) {
304
+ const { api, json, readOnly } = createNavigationApi(this, filePath);
305
+ ensureWritable("navigation folders delete", readOnly, "destructive");
306
+ const allFolders = await api.listFolders();
307
+ const resolved = resolveFolder(allFolders, folder);
308
+ const data = await api.deleteFolder({ kind: "uuid", value: resolved.id });
309
+
310
+ if (json) {
311
+ emitJson(stdout, "navigation folders delete", data, filePath);
312
+ return;
313
+ }
314
+
315
+ writeLine(stdout, `Deleted navigation folder "${resolved.name ?? resolved.id}".`);
316
+ });
317
+
318
+ navigation
319
+ .command("resources")
320
+ .description("Manage navigation resources")
321
+ .command("move")
322
+ .description("Move a resource into a folder")
323
+ .argument("<resource>", "resource UUID")
324
+ .requiredOption("--type <type>", "resource type")
325
+ .requiredOption("--to <folder>", "target folder UUID or exact name")
326
+ .action(async function action(
327
+ this: Command,
328
+ resourceId: string,
329
+ commandOptions: { type: string; to: string },
330
+ ) {
331
+ const { api, json, readOnly } = createNavigationApi(this, filePath);
332
+ ensureWritable("navigation resources move", readOnly, "write");
333
+ const allFolders = await api.listFolders();
334
+ const resolved = resolveFolder(allFolders, parseUuidOrSlugSelector(commandOptions.to));
335
+ const data = await api.moveResource({
336
+ resourceId,
337
+ resourceType: commandOptions.type.toUpperCase(),
338
+ targetFolderId: resolved.id,
339
+ });
340
+
341
+ if (json) {
342
+ emitJson(stdout, "navigation resources move", data, filePath);
343
+ return;
344
+ }
345
+
346
+ writeLine(stdout, `Moved resource "${resourceId}" to "${resolved.name ?? resolved.id}".`);
347
+ });
348
+ }
@@ -0,0 +1,82 @@
1
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { buildProgram } from "../index.js";
8
+
9
+ function createTempConfig(contents: string): string {
10
+ const dir = mkdtempSync(join(tmpdir(), "zinkee-profiles-"));
11
+ const filePath = join(dir, "config.toml");
12
+ writeFileSync(filePath, contents, "utf8");
13
+ return filePath;
14
+ }
15
+
16
+ function createStdoutBuffer() {
17
+ let output = "";
18
+
19
+ return {
20
+ stdout: {
21
+ write(chunk: string) {
22
+ output += chunk;
23
+ return true;
24
+ },
25
+ },
26
+ read() {
27
+ return output;
28
+ },
29
+ };
30
+ }
31
+
32
+ describe("profiles commands", () => {
33
+ it("registers the profiles command group", () => {
34
+ const program = buildProgram();
35
+
36
+ expect(program.commands.some((command) => command.name() === "profiles")).toBe(true);
37
+ });
38
+
39
+ it("redacts secrets when showing a profile", async () => {
40
+ const configPath = createTempConfig(`
41
+ default_profile = "prod"
42
+
43
+ [profiles.prod]
44
+ api_key = "secret-token-1234"
45
+ base_url = "https://api.zinkee.test"
46
+ `);
47
+ const output = createStdoutBuffer();
48
+ const program = buildProgram({ configPath, stdout: output.stdout });
49
+
50
+ await program.parseAsync(["node", "zinkee", "profiles", "get", "prod"]);
51
+
52
+ expect(output.read()).toContain("apiKey");
53
+ expect(output.read()).toContain("********1234");
54
+ expect(output.read()).not.toContain("secret-token-1234");
55
+ });
56
+
57
+ it("adds a profile and writes a TOML config file", async () => {
58
+ const configPath = join(mkdtempSync(join(tmpdir(), "zinkee-profiles-add-")), "config.toml");
59
+ const output = createStdoutBuffer();
60
+ const program = buildProgram({ configPath, stdout: output.stdout });
61
+
62
+ await program.parseAsync([
63
+ "node",
64
+ "zinkee",
65
+ "profiles",
66
+ "add",
67
+ "local",
68
+ "--api-key",
69
+ "new-token-5678",
70
+ "--base-url",
71
+ "http://localhost:8088",
72
+ ]);
73
+
74
+ const saved = readFileSync(configPath, "utf8");
75
+
76
+ expect(output.read()).toContain('Added profile "local".');
77
+ expect(saved).toContain('[profiles.local]');
78
+ expect(saved).toContain('api_key = "new-token-5678"');
79
+ expect(saved).toContain('base_url = "http://localhost:8088"');
80
+ expect(saved).toContain('default_profile = "local"');
81
+ });
82
+ });