@tsctl/cli 0.2.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 (45) hide show
  1. package/README.md +735 -0
  2. package/bin/tsctl.js +2 -0
  3. package/package.json +65 -0
  4. package/src/__tests__/analyticsrules.test.ts +303 -0
  5. package/src/__tests__/apikeys.test.ts +223 -0
  6. package/src/__tests__/apply.test.ts +245 -0
  7. package/src/__tests__/client.test.ts +48 -0
  8. package/src/__tests__/collection-advanced.test.ts +274 -0
  9. package/src/__tests__/config-loader.test.ts +217 -0
  10. package/src/__tests__/curationsets.test.ts +190 -0
  11. package/src/__tests__/helpers.ts +17 -0
  12. package/src/__tests__/import-drift.test.ts +231 -0
  13. package/src/__tests__/migrate-advanced.test.ts +197 -0
  14. package/src/__tests__/migrate.test.ts +220 -0
  15. package/src/__tests__/plan-new-resources.test.ts +258 -0
  16. package/src/__tests__/plan.test.ts +337 -0
  17. package/src/__tests__/presets.test.ts +97 -0
  18. package/src/__tests__/resources.test.ts +592 -0
  19. package/src/__tests__/setup.ts +77 -0
  20. package/src/__tests__/state.test.ts +312 -0
  21. package/src/__tests__/stemmingdictionaries.test.ts +111 -0
  22. package/src/__tests__/stopwords.test.ts +109 -0
  23. package/src/__tests__/synonymsets.test.ts +170 -0
  24. package/src/__tests__/types.test.ts +416 -0
  25. package/src/apply/index.ts +336 -0
  26. package/src/cli/index.ts +1106 -0
  27. package/src/client/index.ts +55 -0
  28. package/src/config/loader.ts +158 -0
  29. package/src/index.ts +45 -0
  30. package/src/migrate/index.ts +220 -0
  31. package/src/plan/index.ts +1333 -0
  32. package/src/resources/alias.ts +59 -0
  33. package/src/resources/analyticsrule.ts +134 -0
  34. package/src/resources/apikey.ts +203 -0
  35. package/src/resources/collection.ts +424 -0
  36. package/src/resources/curationset.ts +155 -0
  37. package/src/resources/index.ts +11 -0
  38. package/src/resources/override.ts +174 -0
  39. package/src/resources/preset.ts +83 -0
  40. package/src/resources/stemmingdictionary.ts +103 -0
  41. package/src/resources/stopword.ts +100 -0
  42. package/src/resources/synonym.ts +152 -0
  43. package/src/resources/synonymset.ts +144 -0
  44. package/src/state/index.ts +206 -0
  45. package/src/types/index.ts +451 -0
package/bin/tsctl.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli/index.ts";
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@tsctl/cli",
3
+ "version": "0.2.0",
4
+ "description": "Terraform-like CLI for managing Typesense collections, aliases, synonyms, and curations",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/index.ts",
11
+ "types": "./src/index.ts"
12
+ }
13
+ },
14
+ "bin": {
15
+ "tsctl": "bin/tsctl.js"
16
+ },
17
+ "files": [
18
+ "bin/tsctl.js",
19
+ "src",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "bun build src/cli/index.ts --compile --outfile bin/tsctl",
24
+ "build:all": "bun run build:linux && bun run build:macos && bun run build:windows",
25
+ "build:linux": "bun build src/cli/index.ts --compile --target=bun-linux-x64 --outfile bin/tsctl-linux-x64",
26
+ "build:macos": "bun build src/cli/index.ts --compile --target=bun-darwin-arm64 --outfile bin/tsctl-darwin-arm64",
27
+ "build:windows": "bun build src/cli/index.ts --compile --target=bun-windows-x64 --outfile bin/tsctl-windows-x64.exe",
28
+ "dev": "bun run src/cli/index.ts",
29
+ "start": "bun run src/cli/index.ts",
30
+ "typecheck": "bun x tsc --noEmit",
31
+ "test": "bun test"
32
+ },
33
+ "keywords": [
34
+ "typesense",
35
+ "cli",
36
+ "infrastructure",
37
+ "terraform"
38
+ ],
39
+ "author": "akshitkrnagpal",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/akshitkrnagpal/tsctl.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/akshitkrnagpal/tsctl/issues"
47
+ },
48
+ "homepage": "https://github.com/akshitkrnagpal/tsctl#readme",
49
+ "dependencies": {
50
+ "chalk": "^5.3.0",
51
+ "commander": "^12.1.0",
52
+ "cosmiconfig": "^9.0.0",
53
+ "diff": "^5.2.0",
54
+ "dotenv": "^16.4.5",
55
+ "ora": "^8.0.1",
56
+ "typesense": "^3.0.0-2",
57
+ "yaml": "^2.4.5",
58
+ "zod": "^3.23.8"
59
+ },
60
+ "devDependencies": {
61
+ "@types/diff": "^5.2.1",
62
+ "@types/node": "^20.14.10",
63
+ "typescript": "^5.5.3"
64
+ }
65
+ }
@@ -0,0 +1,303 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import { getTypesenseVersion } from "./helpers.js";
4
+ import {
5
+ createAnalyticsRule,
6
+ getAnalyticsRule,
7
+ listAnalyticsRules,
8
+ updateAnalyticsRule,
9
+ deleteAnalyticsRule,
10
+ analyticsRuleConfigsEqual,
11
+ } from "../resources/analyticsrule.js";
12
+ import { createCollection } from "../resources/collection.js";
13
+ import type { AnalyticsRuleConfig } from "../types/index.js";
14
+
15
+ /**
16
+ * Check if analytics is enabled on the Typesense server
17
+ */
18
+ async function isAnalyticsEnabled(): Promise<boolean> {
19
+ try {
20
+ const { getClient } = await import("../client/index.js");
21
+ const client = getClient();
22
+ await client.analytics.rules().retrieve();
23
+ return true;
24
+ } catch (error: any) {
25
+ if (error?.httpStatus === 400 && error?.message?.includes("source")) {
26
+ return false;
27
+ }
28
+ // 404 means analytics endpoint doesn't exist
29
+ if (error?.httpStatus === 404) {
30
+ return false;
31
+ }
32
+ return true; // Other errors mean analytics exists but had issue
33
+ }
34
+ }
35
+
36
+ describe("analytics rules (integration)", () => {
37
+ let version: number;
38
+ let analyticsAvailable: boolean;
39
+
40
+ beforeEach(async () => {
41
+ setupClient();
42
+ await cleanupTypesense();
43
+ version = await getTypesenseVersion();
44
+ // Check if analytics is actually enabled by trying to create a test rule
45
+ analyticsAvailable = false;
46
+ try {
47
+ const { getClient } = await import("../client/index.js");
48
+ // Create temp collections
49
+ try {
50
+ await getClient().collections().create({ name: "_analytics_test", fields: [{ name: "q", type: "string" }, { name: "count", type: "int32" }] });
51
+ await getClient().collections().create({ name: "_analytics_test_src", fields: [{ name: "x", type: "string" }] });
52
+ } catch { /* may already exist */ }
53
+ await getClient().analytics.rules().upsert("_analytics_test_rule", {
54
+ type: "popular_queries",
55
+ collection: "_analytics_test_src",
56
+ event_type: "search",
57
+ params: { destination_collection: "_analytics_test", limit: 10 },
58
+ } as any);
59
+ await getClient().analytics.rules("_analytics_test_rule").delete();
60
+ analyticsAvailable = true;
61
+ } catch {
62
+ analyticsAvailable = false;
63
+ }
64
+ // Cleanup test collections
65
+ try {
66
+ const { getClient } = await import("../client/index.js");
67
+ await getClient().collections("_analytics_test").delete();
68
+ await getClient().collections("_analytics_test_src").delete();
69
+ } catch { /* ignore */ }
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await cleanupTypesense();
74
+ });
75
+
76
+ test("creates popular_queries analytics rule", async () => {
77
+ if (version >= 30 || !analyticsAvailable) return;
78
+ // Create source and destination collections
79
+ await createCollection({
80
+ name: "products",
81
+ fields: [{ name: "title", type: "string" }],
82
+ });
83
+ await createCollection({
84
+ name: "popular_queries",
85
+ fields: [
86
+ { name: "q", type: "string" },
87
+ { name: "count", type: "int32" },
88
+ ],
89
+ });
90
+
91
+ const rule: AnalyticsRuleConfig = {
92
+ name: "product-popular-queries",
93
+ type: "popular_queries",
94
+ collection: "products",
95
+ event_type: "search",
96
+ params: {
97
+ destination_collection: "popular_queries",
98
+ limit: 1000,
99
+ },
100
+ };
101
+
102
+ await createAnalyticsRule(rule);
103
+ const result = await getAnalyticsRule("product-popular-queries");
104
+ expect(result).not.toBeNull();
105
+ expect(result!.name).toBe("product-popular-queries");
106
+ expect(result!.type).toBe("popular_queries");
107
+ expect(result!.collection).toBe("products");
108
+ });
109
+
110
+ test("creates nohits_queries analytics rule", async () => {
111
+ if (version >= 30 || !analyticsAvailable) return;
112
+ await createCollection({
113
+ name: "products",
114
+ fields: [{ name: "title", type: "string" }],
115
+ });
116
+ await createCollection({
117
+ name: "nohits",
118
+ fields: [
119
+ { name: "q", type: "string" },
120
+ { name: "count", type: "int32" },
121
+ ],
122
+ });
123
+
124
+ const rule: AnalyticsRuleConfig = {
125
+ name: "product-nohits",
126
+ type: "nohits_queries",
127
+ collection: "products",
128
+ event_type: "search",
129
+ params: {
130
+ destination_collection: "nohits",
131
+ limit: 500,
132
+ },
133
+ };
134
+
135
+ await createAnalyticsRule(rule);
136
+ const result = await getAnalyticsRule("product-nohits");
137
+ expect(result).not.toBeNull();
138
+ expect(result!.type).toBe("nohits_queries");
139
+ });
140
+
141
+ test("creates counter analytics rule", async () => {
142
+ if (version >= 30 || !analyticsAvailable) return;
143
+ await createCollection({
144
+ name: "products",
145
+ fields: [
146
+ { name: "title", type: "string" },
147
+ { name: "click_count", type: "int32" },
148
+ ],
149
+ });
150
+
151
+ const rule: AnalyticsRuleConfig = {
152
+ name: "product-clicks",
153
+ type: "counter",
154
+ collection: "products",
155
+ event_type: "click",
156
+ params: {
157
+ counter_field: "click_count",
158
+ weight: 1,
159
+ },
160
+ };
161
+
162
+ await createAnalyticsRule(rule);
163
+ const result = await getAnalyticsRule("product-clicks");
164
+ expect(result).not.toBeNull();
165
+ expect(result!.type).toBe("counter");
166
+ expect(result!.params?.counter_field).toBe("click_count");
167
+ });
168
+
169
+ test("getAnalyticsRule returns null for non-existent", async () => {
170
+ const result = await getAnalyticsRule("nonexistent");
171
+ expect(result).toBeNull();
172
+ });
173
+
174
+ test("listAnalyticsRules returns all rules", async () => {
175
+ if (version >= 30 || !analyticsAvailable) return;
176
+ await createCollection({
177
+ name: "products",
178
+ fields: [{ name: "title", type: "string" }],
179
+ });
180
+ await createCollection({
181
+ name: "popular_queries",
182
+ fields: [
183
+ { name: "q", type: "string" },
184
+ { name: "count", type: "int32" },
185
+ ],
186
+ });
187
+ await createCollection({
188
+ name: "nohits",
189
+ fields: [
190
+ { name: "q", type: "string" },
191
+ { name: "count", type: "int32" },
192
+ ],
193
+ });
194
+
195
+ await createAnalyticsRule({
196
+ name: "rule1",
197
+ type: "popular_queries",
198
+ collection: "products",
199
+ event_type: "search",
200
+ params: { destination_collection: "popular_queries" },
201
+ });
202
+ await createAnalyticsRule({
203
+ name: "rule2",
204
+ type: "nohits_queries",
205
+ collection: "products",
206
+ event_type: "search",
207
+ params: { destination_collection: "nohits" },
208
+ });
209
+
210
+ const rules = await listAnalyticsRules();
211
+ const names = rules.map((r) => r.name);
212
+ expect(names).toContain("rule1");
213
+ expect(names).toContain("rule2");
214
+ });
215
+
216
+ test("deleteAnalyticsRule removes rule", async () => {
217
+ if (version >= 30 || !analyticsAvailable) return;
218
+ await createCollection({
219
+ name: "products",
220
+ fields: [{ name: "title", type: "string" }],
221
+ });
222
+ await createCollection({
223
+ name: "popular",
224
+ fields: [
225
+ { name: "q", type: "string" },
226
+ { name: "count", type: "int32" },
227
+ ],
228
+ });
229
+
230
+ await createAnalyticsRule({
231
+ name: "to-delete",
232
+ type: "popular_queries",
233
+ collection: "products",
234
+ event_type: "search",
235
+ params: { destination_collection: "popular" },
236
+ });
237
+
238
+ await deleteAnalyticsRule("to-delete");
239
+ const result = await getAnalyticsRule("to-delete");
240
+ expect(result).toBeNull();
241
+ });
242
+
243
+ test("updateAnalyticsRule modifies rule", async () => {
244
+ if (version >= 30 || !analyticsAvailable) return;
245
+ await createCollection({
246
+ name: "products",
247
+ fields: [{ name: "title", type: "string" }],
248
+ });
249
+ await createCollection({
250
+ name: "popular",
251
+ fields: [
252
+ { name: "q", type: "string" },
253
+ { name: "count", type: "int32" },
254
+ ],
255
+ });
256
+
257
+ await createAnalyticsRule({
258
+ name: "my-rule",
259
+ type: "popular_queries",
260
+ collection: "products",
261
+ event_type: "search",
262
+ params: { destination_collection: "popular", limit: 100 },
263
+ });
264
+
265
+ await updateAnalyticsRule({
266
+ name: "my-rule",
267
+ type: "popular_queries",
268
+ collection: "products",
269
+ event_type: "search",
270
+ params: { destination_collection: "popular", limit: 500 },
271
+ });
272
+
273
+ const result = await getAnalyticsRule("my-rule");
274
+ expect(result!.params?.limit).toBe(500);
275
+ });
276
+
277
+ test("analyticsRuleConfigsEqual with matching params", () => {
278
+ const a: AnalyticsRuleConfig = {
279
+ name: "rule",
280
+ type: "popular_queries",
281
+ collection: "products",
282
+ event_type: "search",
283
+ params: { limit: 100 },
284
+ };
285
+ const b = { ...a };
286
+ expect(analyticsRuleConfigsEqual(a, b)).toBe(true);
287
+ });
288
+
289
+ test("analyticsRuleConfigsEqual with different params", () => {
290
+ const a: AnalyticsRuleConfig = {
291
+ name: "rule",
292
+ type: "popular_queries",
293
+ collection: "products",
294
+ event_type: "search",
295
+ params: { limit: 100 },
296
+ };
297
+ const b: AnalyticsRuleConfig = {
298
+ ...a,
299
+ params: { limit: 200 },
300
+ };
301
+ expect(analyticsRuleConfigsEqual(a, b)).toBe(false);
302
+ });
303
+ });
@@ -0,0 +1,223 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { setupClient, cleanupTypesense } from "./setup.js";
3
+ import {
4
+ createApiKey,
5
+ getApiKey,
6
+ getApiKeyById,
7
+ listApiKeys,
8
+ deleteApiKey,
9
+ deleteApiKeyByDescription,
10
+ apiKeyConfigsEqual,
11
+ } from "../resources/apikey.js";
12
+
13
+ describe("API keys (integration)", () => {
14
+ beforeEach(async () => {
15
+ setupClient();
16
+ await cleanupTypesense();
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await cleanupTypesense();
21
+ });
22
+
23
+ test("creates search-only key", async () => {
24
+ const result = await createApiKey({
25
+ description: "Search-only key for frontend",
26
+ actions: ["documents:search"],
27
+ collections: ["products"],
28
+ });
29
+
30
+ expect(result.id).toBeGreaterThanOrEqual(0);
31
+ expect(result.value).toBeTruthy();
32
+ expect(result.value.length).toBeGreaterThan(10);
33
+ });
34
+
35
+ test("creates admin key", async () => {
36
+ const result = await createApiKey({
37
+ description: "Admin key",
38
+ actions: ["*"],
39
+ collections: ["*"],
40
+ });
41
+
42
+ expect(result.id).toBeGreaterThanOrEqual(0);
43
+ });
44
+
45
+ test("creates key with custom value", async () => {
46
+ const result = await createApiKey({
47
+ description: "Custom value key",
48
+ actions: ["documents:search"],
49
+ collections: ["*"],
50
+ value: "my-custom-api-key-value-12345678",
51
+ });
52
+
53
+ expect(result.value).toBe("my-custom-api-key-value-12345678");
54
+ });
55
+
56
+ test("creates key with expiration", async () => {
57
+ const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24h from now
58
+ const result = await createApiKey({
59
+ description: "Expiring key",
60
+ actions: ["documents:search"],
61
+ collections: ["*"],
62
+ expires_at: expiresAt,
63
+ });
64
+
65
+ const retrieved = await getApiKey("Expiring key");
66
+ expect(retrieved).not.toBeNull();
67
+ expect(retrieved!.expires_at).toBe(expiresAt);
68
+ });
69
+
70
+ test("getApiKeyById retrieves key", async () => {
71
+ const created = await createApiKey({
72
+ description: "Test key by ID",
73
+ actions: ["documents:search"],
74
+ collections: ["*"],
75
+ });
76
+
77
+ const retrieved = await getApiKeyById(created.id);
78
+ expect(retrieved).not.toBeNull();
79
+ expect(retrieved!.id).toBe(created.id);
80
+ expect(retrieved!.description).toBe("Test key by ID");
81
+ });
82
+
83
+ test("getApiKeyById returns null for non-existent", async () => {
84
+ const result = await getApiKeyById(99999);
85
+ expect(result).toBeNull();
86
+ });
87
+
88
+ test("listApiKeys returns keys with descriptions", async () => {
89
+ await createApiKey({
90
+ description: "Key A",
91
+ actions: ["documents:search"],
92
+ collections: ["*"],
93
+ });
94
+ await createApiKey({
95
+ description: "Key B",
96
+ actions: ["*"],
97
+ collections: ["*"],
98
+ });
99
+
100
+ const keys = await listApiKeys();
101
+ const descriptions = keys.map((k) => k.description);
102
+ expect(descriptions).toContain("Key A");
103
+ expect(descriptions).toContain("Key B");
104
+ });
105
+
106
+ test("deleteApiKey removes by ID", async () => {
107
+ const created = await createApiKey({
108
+ description: "To delete by ID",
109
+ actions: ["*"],
110
+ collections: ["*"],
111
+ });
112
+
113
+ await deleteApiKey(created.id);
114
+ const result = await getApiKeyById(created.id);
115
+ expect(result).toBeNull();
116
+ });
117
+
118
+ test("deleteApiKeyByDescription removes by description", async () => {
119
+ await createApiKey({
120
+ description: "To delete by desc",
121
+ actions: ["*"],
122
+ collections: ["*"],
123
+ });
124
+
125
+ await deleteApiKeyByDescription("To delete by desc");
126
+ const result = await getApiKey("To delete by desc");
127
+ expect(result).toBeNull();
128
+ });
129
+
130
+ test("deleteApiKeyByDescription does nothing for non-existent", async () => {
131
+ // Should not throw
132
+ await deleteApiKeyByDescription("nonexistent");
133
+ });
134
+
135
+ test("apiKeyConfigsEqual handles set-like comparison", () => {
136
+ expect(
137
+ apiKeyConfigsEqual(
138
+ {
139
+ description: "key",
140
+ actions: ["documents:search", "documents:get"],
141
+ collections: ["products", "users"],
142
+ },
143
+ {
144
+ description: "key",
145
+ actions: ["documents:get", "documents:search"],
146
+ collections: ["users", "products"],
147
+ }
148
+ )
149
+ ).toBe(true);
150
+ });
151
+
152
+ test("apiKeyConfigsEqual detects action differences", () => {
153
+ expect(
154
+ apiKeyConfigsEqual(
155
+ {
156
+ description: "key",
157
+ actions: ["documents:search"],
158
+ collections: ["*"],
159
+ },
160
+ {
161
+ description: "key",
162
+ actions: ["documents:search", "documents:get"],
163
+ collections: ["*"],
164
+ }
165
+ )
166
+ ).toBe(false);
167
+ });
168
+
169
+ test("apiKeyConfigsEqual detects collection differences", () => {
170
+ expect(
171
+ apiKeyConfigsEqual(
172
+ {
173
+ description: "key",
174
+ actions: ["*"],
175
+ collections: ["products"],
176
+ },
177
+ {
178
+ description: "key",
179
+ actions: ["*"],
180
+ collections: ["products", "users"],
181
+ }
182
+ )
183
+ ).toBe(false);
184
+ });
185
+
186
+ test("apiKeyConfigsEqual considers autodelete", () => {
187
+ expect(
188
+ apiKeyConfigsEqual(
189
+ {
190
+ description: "key",
191
+ actions: ["*"],
192
+ collections: ["*"],
193
+ autodelete: true,
194
+ },
195
+ {
196
+ description: "key",
197
+ actions: ["*"],
198
+ collections: ["*"],
199
+ autodelete: false,
200
+ }
201
+ )
202
+ ).toBe(false);
203
+ });
204
+
205
+ test("multiple keys with same actions but different descriptions", async () => {
206
+ await createApiKey({
207
+ description: "Frontend search",
208
+ actions: ["documents:search"],
209
+ collections: ["products"],
210
+ });
211
+ await createApiKey({
212
+ description: "Backend search",
213
+ actions: ["documents:search"],
214
+ collections: ["products"],
215
+ });
216
+
217
+ const frontend = await getApiKey("Frontend search");
218
+ const backend = await getApiKey("Backend search");
219
+ expect(frontend).not.toBeNull();
220
+ expect(backend).not.toBeNull();
221
+ expect(frontend!.id).not.toBe(backend!.id);
222
+ });
223
+ });