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
package/src/config.ts ADDED
@@ -0,0 +1,248 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { parseTOML, stringifyTOML } from "confbox/toml";
6
+
7
+ import type { AppConfig, ProfileConfig, RuntimeOverrides, ResolvedProfile } from "./types.js";
8
+
9
+ export const defaultConfigPath = join(homedir(), ".config", "zinkee", "config.toml");
10
+ export const redactedSecretMask = "********";
11
+
12
+ export class ProfileResolutionError extends Error {
13
+ override name = "ProfileResolutionError";
14
+
15
+ constructor(message: string) {
16
+ super(message);
17
+ }
18
+ }
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return typeof value === "object" && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ function toStringValue(value: unknown): string | undefined {
25
+ return typeof value === "string" ? value : undefined;
26
+ }
27
+
28
+ function parseProfileConfig(value: unknown): ProfileConfig {
29
+ const profile: ProfileConfig = {};
30
+
31
+ if (!isRecord(value)) {
32
+ return profile;
33
+ }
34
+
35
+ for (const [key, rawValue] of Object.entries(value)) {
36
+ if (key === "api_key") {
37
+ profile.apiKey = toStringValue(rawValue);
38
+ continue;
39
+ }
40
+
41
+ if (key === "base_url") {
42
+ profile.baseUrl = toStringValue(rawValue);
43
+ continue;
44
+ }
45
+
46
+ profile[key] = rawValue;
47
+ }
48
+
49
+ return profile;
50
+ }
51
+
52
+ export function parseConfig(toml: string): AppConfig {
53
+ const parsed = parseTOML<unknown>(toml);
54
+ const config: AppConfig = { profiles: {} };
55
+
56
+ if (!isRecord(parsed)) {
57
+ return config;
58
+ }
59
+
60
+ for (const [key, value] of Object.entries(parsed)) {
61
+ if (key === "default_profile") {
62
+ config.defaultProfile = toStringValue(value);
63
+ continue;
64
+ }
65
+
66
+ if (key === "profiles") {
67
+ if (!isRecord(value)) {
68
+ continue;
69
+ }
70
+
71
+ for (const [profileName, profileValue] of Object.entries(value)) {
72
+ config.profiles[profileName] = parseProfileConfig(profileValue);
73
+ }
74
+
75
+ continue;
76
+ }
77
+
78
+ config[key] = value;
79
+ }
80
+
81
+ return config;
82
+ }
83
+
84
+ export function readConfig(filePath: string = defaultConfigPath): AppConfig {
85
+ return parseConfig(readFileSync(filePath, "utf8"));
86
+ }
87
+
88
+ export function readConfigIfExists(filePath: string = defaultConfigPath): AppConfig | undefined {
89
+ try {
90
+ return readConfig(filePath);
91
+ } catch (error) {
92
+ if (
93
+ typeof error === "object" &&
94
+ error !== null &&
95
+ "code" in error &&
96
+ error.code === "ENOENT"
97
+ ) {
98
+ return undefined;
99
+ }
100
+
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ function serializeProfileConfig(profile: ProfileConfig): Record<string, unknown> {
106
+ const serialized: Record<string, unknown> = {};
107
+
108
+ for (const [key, value] of Object.entries(profile)) {
109
+ if (value === undefined) {
110
+ continue;
111
+ }
112
+
113
+ if (key === "apiKey") {
114
+ serialized.api_key = value;
115
+ continue;
116
+ }
117
+
118
+ if (key === "baseUrl") {
119
+ serialized.base_url = value;
120
+ continue;
121
+ }
122
+
123
+ serialized[key] = value;
124
+ }
125
+
126
+ return serialized;
127
+ }
128
+
129
+ export function serializeConfig(config: AppConfig): string {
130
+ const serialized: Record<string, unknown> = {};
131
+
132
+ if (config.defaultProfile !== undefined) {
133
+ serialized.default_profile = config.defaultProfile;
134
+ }
135
+
136
+ for (const [key, value] of Object.entries(config)) {
137
+ if (key === "defaultProfile" || key === "profiles" || value === undefined) {
138
+ continue;
139
+ }
140
+
141
+ serialized[key] = value;
142
+ }
143
+
144
+ const serializedProfiles = Object.fromEntries(
145
+ Object.entries(config.profiles).map(([name, profile]) => [name, serializeProfileConfig(profile)]),
146
+ );
147
+
148
+ if (Object.keys(serializedProfiles).length > 0) {
149
+ serialized.profiles = serializedProfiles;
150
+ }
151
+
152
+ return stringifyTOML(serialized).trimEnd().concat("\n");
153
+ }
154
+
155
+ export function writeConfig(config: AppConfig, filePath: string = defaultConfigPath): void {
156
+ mkdirSync(dirname(filePath), { recursive: true });
157
+ writeFileSync(filePath, serializeConfig(config), "utf8");
158
+ }
159
+
160
+ export function setProfileConfig(
161
+ config: AppConfig,
162
+ profileName: string,
163
+ updates: Partial<ProfileConfig>,
164
+ ): AppConfig {
165
+ return {
166
+ ...config,
167
+ profiles: {
168
+ ...config.profiles,
169
+ [profileName]: {
170
+ ...config.profiles[profileName],
171
+ ...updates,
172
+ },
173
+ },
174
+ };
175
+ }
176
+
177
+ export function removeProfileConfig(config: AppConfig, profileName: string): AppConfig {
178
+ const nextProfiles = { ...config.profiles };
179
+ delete nextProfiles[profileName];
180
+
181
+ return {
182
+ ...config,
183
+ defaultProfile:
184
+ config.defaultProfile === profileName
185
+ ? undefined
186
+ : config.defaultProfile,
187
+ profiles: nextProfiles,
188
+ };
189
+ }
190
+
191
+ export function setDefaultProfile(config: AppConfig, profileName: string): AppConfig {
192
+ return {
193
+ ...config,
194
+ defaultProfile: profileName,
195
+ };
196
+ }
197
+
198
+ export function redactSecret(value: string | undefined): string | undefined {
199
+ if (value === undefined) {
200
+ return undefined;
201
+ }
202
+
203
+ if (value.length <= 4) {
204
+ return redactedSecretMask;
205
+ }
206
+
207
+ return `${redactedSecretMask}${value.slice(-4)}`;
208
+ }
209
+
210
+ export function redactProfile(profile: ProfileConfig): ProfileConfig {
211
+ return {
212
+ ...profile,
213
+ apiKey: redactSecret(profile.apiKey),
214
+ };
215
+ }
216
+
217
+ export function redactConfig(config: AppConfig): AppConfig {
218
+ return {
219
+ ...config,
220
+ profiles: Object.fromEntries(
221
+ Object.entries(config.profiles).map(([name, profile]) => [name, redactProfile(profile)]),
222
+ ),
223
+ };
224
+ }
225
+
226
+ export function resolveProfile(
227
+ config: AppConfig,
228
+ overrides: Pick<RuntimeOverrides, "profile" | "baseUrl" | "apiKey"> = {},
229
+ ): ResolvedProfile {
230
+ const profileName = overrides.profile ?? config.defaultProfile;
231
+ if (profileName) {
232
+ const profile = config.profiles[profileName];
233
+
234
+ if (!profile) {
235
+ const label = overrides.profile ? "Profile" : "Default profile";
236
+ throw new ProfileResolutionError(`${label} "${profileName}" not found in config.`);
237
+ }
238
+
239
+ return {
240
+ ...profile,
241
+ name: profileName,
242
+ baseUrl: overrides.baseUrl ?? profile.baseUrl,
243
+ apiKey: overrides.apiKey ?? profile.apiKey,
244
+ };
245
+ }
246
+
247
+ throw new ProfileResolutionError("No profile could be resolved from config.");
248
+ }
@@ -0,0 +1,140 @@
1
+ import { mkdtempSync, rmSync, symlinkSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+
8
+ import { extractExampleRequest, showExamples } from "./cli-examples.js";
9
+ import { isExecutedAsEntry } from "./cli-runner.js";
10
+ import { buildProgram } from "./program.js";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ describe("project bootstrap", () => {
15
+ afterEach(() => {
16
+ while (tempDirs.length > 0) {
17
+ rmSync(tempDirs.pop()!, { force: true, recursive: true });
18
+ }
19
+ });
20
+
21
+ it("loads the CLI entry module", async () => {
22
+ const mod = await import("./index.js");
23
+
24
+ expect(mod).toBeTruthy();
25
+ expect(mod.buildProgram).toBeTypeOf("function");
26
+ });
27
+
28
+ it("registers the global --example option", () => {
29
+ const program = buildProgram();
30
+
31
+ expect(program.options.some((option) => option.long === "--example")).toBe(true);
32
+ });
33
+
34
+ it("extracts example requests before commander parsing", () => {
35
+ expect(
36
+ extractExampleRequest(["node", "zinkee", "--json", "--example", "records", "query"]),
37
+ ).toEqual({
38
+ commandName: "records query",
39
+ filters: {},
40
+ overrides: {},
41
+ json: true,
42
+ });
43
+ });
44
+
45
+ it("extracts typed schema field examples without treating schema args as part of the command", () => {
46
+ expect(
47
+ extractExampleRequest([
48
+ "node",
49
+ "zinkee",
50
+ "--profile",
51
+ "local",
52
+ "--example",
53
+ "schemas",
54
+ "fields",
55
+ "create",
56
+ "contacts",
57
+ "--type",
58
+ "option",
59
+ ]),
60
+ ).toEqual({
61
+ commandName: "schemas fields create",
62
+ filters: {
63
+ type: "option",
64
+ },
65
+ overrides: {
66
+ profile: "local",
67
+ },
68
+ json: false,
69
+ });
70
+ });
71
+
72
+ it("shows field examples filtered by type", async () => {
73
+ let output = "";
74
+
75
+ await showExamples(
76
+ "schemas fields create",
77
+ false,
78
+ {
79
+ write(chunk: string) {
80
+ output += chunk;
81
+ return true;
82
+ },
83
+ },
84
+ { type: "option" },
85
+ );
86
+
87
+ expect(output).toContain('zinkee --json schemas fields create contacts --raw');
88
+ expect(output).toContain('"type":"option"');
89
+ expect(output).not.toContain('"type":"text"');
90
+ expect(output).toContain("Create a option field");
91
+ });
92
+
93
+ it("shows both number creation examples, including Moneda formatting", async () => {
94
+ let output = "";
95
+
96
+ await showExamples(
97
+ "schemas fields create",
98
+ false,
99
+ {
100
+ write(chunk: string) {
101
+ output += chunk;
102
+ return true;
103
+ },
104
+ },
105
+ { type: "number" },
106
+ );
107
+
108
+ expect(output).toContain('"slug":"amount"');
109
+ expect(output).toContain('"symbolEnabled":true');
110
+ expect(output).toContain('"symbolPosition":"right"');
111
+ });
112
+
113
+ it("shows automation examples with descriptions and equivalent payloads", async () => {
114
+ let output = "";
115
+
116
+ await showExamples("automations create", false, {
117
+ write(chunk: string) {
118
+ output += chunk;
119
+ return true;
120
+ },
121
+ });
122
+
123
+ expect(output).toContain("Create a scheduled automation with a six-field cron expression");
124
+ expect(output).toContain("Equivalent JSON payload:");
125
+ expect(output).toContain('"type": "scheduled"');
126
+ expect(output).toContain('"cronExpression": "0 15 10 * * 1"');
127
+ });
128
+
129
+ it("treats symlinked executables as the CLI entrypoint", () => {
130
+ const tempDir = mkdtempSync(join(tmpdir(), "zinkee-entry-"));
131
+ const realEntry = join(tempDir, "real-entry.js");
132
+ const linkedEntry = join(tempDir, "zinkee");
133
+ tempDirs.push(tempDir);
134
+
135
+ symlinkSync(fileURLToPath(import.meta.url), realEntry);
136
+ symlinkSync(realEntry, linkedEntry);
137
+
138
+ expect(isExecutedAsEntry(linkedEntry, pathToFileURL(realEntry).href)).toBe(true);
139
+ });
140
+ });
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli, isExecutedAsEntry } from "./cli-runner.js";
4
+ export { buildProgram, type BuildProgramOptions } from "./program.js";
5
+
6
+ if (isExecutedAsEntry(process.argv[1], import.meta.url)) {
7
+ await runCli(process.argv);
8
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseExpression } from "./expressions.js";
4
+
5
+ describe("parseExpression", () => {
6
+ it("parses space-separated equality filters", () => {
7
+ expect(parseExpression("status eq active")).toEqual({
8
+ field: "status",
9
+ comparator: "eq",
10
+ value: "active",
11
+ raw: "status eq active",
12
+ });
13
+ });
14
+
15
+ it("parses contains filters with spaces in the value", () => {
16
+ expect(parseExpression("email contains @acme.com")).toEqual({
17
+ field: "email",
18
+ comparator: "contains",
19
+ value: "@acme.com",
20
+ raw: "email contains @acme.com",
21
+ });
22
+ });
23
+
24
+ it("parses unary null checks", () => {
25
+ expect(parseExpression("closedAt is-null")).toEqual({
26
+ field: "closedAt",
27
+ comparator: "is-null",
28
+ value: null,
29
+ raw: "closedAt is-null",
30
+ });
31
+ });
32
+
33
+ it("parses repeated value lists", () => {
34
+ expect(parseExpression("ownerId in a,b,c")).toEqual({
35
+ field: "ownerId",
36
+ comparator: "in",
37
+ value: ["a", "b", "c"],
38
+ raw: "ownerId in a,b,c",
39
+ });
40
+ });
41
+
42
+ it("parses colon-delimited condition expressions", () => {
43
+ expect(parseExpression("amount:gt:1000")).toEqual({
44
+ field: "amount",
45
+ comparator: "gt",
46
+ value: "1000",
47
+ raw: "amount:gt:1000",
48
+ });
49
+ });
50
+ });
@@ -0,0 +1,111 @@
1
+ export type ExpressionComparator = "eq" | "contains" | "gt" | "is-null" | "in";
2
+
3
+ export type ExpressionValue = string | readonly string[] | null;
4
+
5
+ export interface ParsedExpression {
6
+ field: string;
7
+ comparator: ExpressionComparator;
8
+ value: ExpressionValue;
9
+ raw: string;
10
+ }
11
+
12
+ const supportedComparators = new Set<ExpressionComparator>([
13
+ "eq",
14
+ "contains",
15
+ "gt",
16
+ "is-null",
17
+ "in",
18
+ ]);
19
+
20
+ function parseColonExpression(input: string): [string, string, string] {
21
+ const parts = input.split(":");
22
+
23
+ if (parts.length < 2) {
24
+ throw new Error(`Expected a field:comparator[:value] expression, got "${input}".`);
25
+ }
26
+
27
+ const field = parts[0]?.trim();
28
+ const comparator = parts[1]?.trim();
29
+
30
+ if (!field) {
31
+ throw new Error(`Expected a field before ":", got "${input}".`);
32
+ }
33
+
34
+ if (!comparator) {
35
+ throw new Error(`Expected a comparator after "${field}:", got "${input}".`);
36
+ }
37
+
38
+ return [field, comparator, parts.slice(2).join(":").trim()];
39
+ }
40
+
41
+ function parseSpaceExpression(input: string): [string, string, string] {
42
+ const parts = input.trim().split(/\s+/);
43
+
44
+ if (parts.length < 2) {
45
+ throw new Error(`Expected a field comparator expression, got "${input}".`);
46
+ }
47
+
48
+ const field = parts[0];
49
+ const comparator = parts[1];
50
+
51
+ if (!field) {
52
+ throw new Error(`Expected a field comparator expression, got "${input}".`);
53
+ }
54
+
55
+ if (!comparator) {
56
+ throw new Error(`Expected a comparator after "${field}", got "${input}".`);
57
+ }
58
+
59
+ return [field.trim(), comparator.trim(), parts.slice(2).join(" ").trim()];
60
+ }
61
+
62
+ function normalizeValue(comparator: ExpressionComparator, rawValue: string): ExpressionValue {
63
+ if (comparator === "is-null") {
64
+ if (rawValue.length > 0) {
65
+ throw new Error(`Comparator "${comparator}" does not accept a value.`);
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ if (comparator === "in") {
72
+ const values = rawValue
73
+ .split(",")
74
+ .map((value) => value.trim())
75
+ .filter(Boolean);
76
+
77
+ if (values.length === 0) {
78
+ throw new Error(`Comparator "${comparator}" requires at least one value.`);
79
+ }
80
+
81
+ return values;
82
+ }
83
+
84
+ if (rawValue.length === 0) {
85
+ throw new Error(`Comparator "${comparator}" requires a value.`);
86
+ }
87
+
88
+ return rawValue;
89
+ }
90
+
91
+ export function parseExpression(input: string): ParsedExpression {
92
+ const trimmed = input.trim();
93
+ const [field, comparatorCandidate, rawValue] = trimmed.includes(":") && !trimmed.includes(" ")
94
+ ? parseColonExpression(trimmed)
95
+ : parseSpaceExpression(trimmed);
96
+
97
+ if (!supportedComparators.has(comparatorCandidate as ExpressionComparator)) {
98
+ throw new Error(
99
+ `Unsupported comparator "${comparatorCandidate}" in expression "${input}".`,
100
+ );
101
+ }
102
+
103
+ const comparator = comparatorCandidate as ExpressionComparator;
104
+
105
+ return {
106
+ field,
107
+ comparator,
108
+ value: normalizeValue(comparator, rawValue),
109
+ raw: trimmed,
110
+ };
111
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseJsonAssignment, parseKeyValue } from "./kv.js";
4
+
5
+ describe("parseKeyValue", () => {
6
+ it("splits field=value pairs", () => {
7
+ expect(parseKeyValue("status=active")).toEqual({
8
+ key: "status",
9
+ value: "active",
10
+ });
11
+ });
12
+
13
+ it("trims surrounding whitespace without changing the assigned value", () => {
14
+ expect(parseKeyValue(" title = hello world ")).toEqual({
15
+ key: "title",
16
+ value: "hello world",
17
+ });
18
+ });
19
+ });
20
+
21
+ describe("parseJsonAssignment", () => {
22
+ it("parses field=json pairs", () => {
23
+ expect(parseJsonAssignment('payload={"a":1}')).toEqual({
24
+ key: "payload",
25
+ value: { a: 1 },
26
+ });
27
+ });
28
+
29
+ it("supports null assignments", () => {
30
+ expect(parseJsonAssignment("payload=null")).toEqual({
31
+ key: "payload",
32
+ value: null,
33
+ });
34
+ });
35
+ });
@@ -0,0 +1,49 @@
1
+ import type { JsonValue } from "../types.js";
2
+
3
+ export interface KeyValueAssignment {
4
+ key: string;
5
+ value: string;
6
+ }
7
+
8
+ export interface JsonKeyValueAssignment<TValue = JsonValue> {
9
+ key: string;
10
+ value: TValue;
11
+ }
12
+
13
+ function splitAssignment(input: string): [string, string] {
14
+ const separatorIndex = input.indexOf("=");
15
+
16
+ if (separatorIndex === -1) {
17
+ throw new Error(`Expected key=value assignment, got "${input}".`);
18
+ }
19
+
20
+ const key = input.slice(0, separatorIndex).trim();
21
+ const value = input.slice(separatorIndex + 1).trim();
22
+
23
+ if (!key) {
24
+ throw new Error(`Expected a key before "=", got "${input}".`);
25
+ }
26
+
27
+ return [key, value];
28
+ }
29
+
30
+ export function parseKeyValue(input: string): KeyValueAssignment {
31
+ const [key, value] = splitAssignment(input);
32
+
33
+ return { key, value };
34
+ }
35
+
36
+ export function parseJsonAssignment<TValue = JsonValue>(
37
+ input: string,
38
+ ): JsonKeyValueAssignment<TValue> {
39
+ const [key, value] = splitAssignment(input);
40
+
41
+ try {
42
+ return {
43
+ key,
44
+ value: JSON.parse(value) as TValue,
45
+ };
46
+ } catch (cause) {
47
+ throw new Error(`Expected ${key}=<json> assignment, got invalid JSON.`, { cause });
48
+ }
49
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { normalizeUuidOrSlug } from "./selectors.js";
4
+
5
+ describe("normalizeUuidOrSlug", () => {
6
+ it("recognizes UUID selectors and lowercases them", () => {
7
+ expect(normalizeUuidOrSlug("550E8400-E29B-41D4-A716-446655440000")).toEqual({
8
+ kind: "uuid",
9
+ value: "550e8400-e29b-41d4-a716-446655440000",
10
+ });
11
+ });
12
+
13
+ it("preserves slug selectors", () => {
14
+ expect(normalizeUuidOrSlug("customer-records")).toEqual({
15
+ kind: "slug",
16
+ value: "customer-records",
17
+ });
18
+ });
19
+
20
+ it("rejects empty selectors", () => {
21
+ expect(() => normalizeUuidOrSlug(" ")).toThrowError("Selector cannot be empty.");
22
+ });
23
+ });
@@ -0,0 +1,29 @@
1
+ export type SelectorKind = "uuid" | "slug";
2
+
3
+ export interface NormalizedUuidOrSlug {
4
+ kind: SelectorKind;
5
+ value: string;
6
+ }
7
+
8
+ const uuidPattern =
9
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+
11
+ export function normalizeUuidOrSlug(input: string): NormalizedUuidOrSlug {
12
+ const value = input.trim();
13
+
14
+ if (!value) {
15
+ throw new Error("Selector cannot be empty.");
16
+ }
17
+
18
+ if (uuidPattern.test(value)) {
19
+ return {
20
+ kind: "uuid",
21
+ value: value.toLowerCase(),
22
+ };
23
+ }
24
+
25
+ return {
26
+ kind: "slug",
27
+ value,
28
+ };
29
+ }