@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
@@ -0,0 +1,55 @@
1
+ import Typesense, { Client } from "typesense";
2
+ import type { ConnectionConfig } from "../types/index.js";
3
+
4
+ let client: Client | null = null;
5
+
6
+ export function initClient(config: ConnectionConfig): Client {
7
+ client = new Typesense.Client({
8
+ nodes: config.nodes,
9
+ apiKey: config.apiKey,
10
+ connectionTimeoutSeconds: config.connectionTimeoutSeconds ?? 10,
11
+ retryIntervalSeconds: config.retryIntervalSeconds ?? 0.1,
12
+ numRetries: config.numRetries ?? 3,
13
+ });
14
+ return client;
15
+ }
16
+
17
+ export function getClient(): Client {
18
+ if (!client) {
19
+ throw new Error(
20
+ "Typesense client not initialized. Call initClient() first or run 'tsctl init'"
21
+ );
22
+ }
23
+ return client;
24
+ }
25
+
26
+ export async function testConnection(): Promise<boolean> {
27
+ try {
28
+ const c = getClient();
29
+ await c.health.retrieve();
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export function getClientFromEnv(): Client {
37
+ const host = process.env["TYPESENSE_HOST"] || "localhost";
38
+ const port = parseInt(process.env["TYPESENSE_PORT"] || "8108", 10);
39
+ const protocol = (process.env["TYPESENSE_PROTOCOL"] || "http") as
40
+ | "http"
41
+ | "https";
42
+ const apiKey = process.env["TYPESENSE_API_KEY"];
43
+
44
+ if (!apiKey) {
45
+ throw new Error(
46
+ "TYPESENSE_API_KEY environment variable is required.\n" +
47
+ "Set it in your environment or create a .env file."
48
+ );
49
+ }
50
+
51
+ return initClient({
52
+ nodes: [{ host, port, protocol }],
53
+ apiKey,
54
+ });
55
+ }
@@ -0,0 +1,158 @@
1
+ import { cosmiconfig } from "cosmiconfig";
2
+ import { resolve, dirname } from "path";
3
+ import { pathToFileURL } from "url";
4
+ import { TypesenseConfigSchema, type TypesenseConfig } from "../types/index.js";
5
+
6
+ const MODULE_NAME = "tsctl";
7
+
8
+ /**
9
+ * TypeScript/ESM loader for cosmiconfig
10
+ */
11
+ async function typeScriptLoader(filepath: string): Promise<unknown> {
12
+ const fileUrl = pathToFileURL(filepath).href;
13
+ // Add cache busting to ensure we get fresh config
14
+ const cacheBustedUrl = `${fileUrl}?t=${Date.now()}`;
15
+ const module = await import(cacheBustedUrl);
16
+ return module.default || module;
17
+ }
18
+
19
+ /**
20
+ * Create the cosmiconfig explorer with all supported file formats
21
+ *
22
+ * Supported config files (in search order):
23
+ * - package.json ("tsctl" property)
24
+ * - .tsctlrc (JSON or YAML)
25
+ * - .tsctlrc.json
26
+ * - .tsctlrc.yaml / .tsctlrc.yml
27
+ * - .tsctlrc.js / .tsctlrc.cjs / .tsctlrc.mjs
28
+ * - .tsctlrc.ts / .tsctlrc.cts / .tsctlrc.mts
29
+ * - tsctl.config.js / tsctl.config.cjs / tsctl.config.mjs
30
+ * - tsctl.config.ts / tsctl.config.cts / tsctl.config.mts
31
+ * - tsctl.config.json
32
+ * - tsctl.config.yaml / tsctl.config.yml
33
+ * - typesense.config.js / typesense.config.cjs / typesense.config.mjs (legacy)
34
+ * - typesense.config.ts / typesense.config.cts / typesense.config.mts (legacy)
35
+ */
36
+ function createExplorer() {
37
+ return cosmiconfig(MODULE_NAME, {
38
+ searchPlaces: [
39
+ // package.json
40
+ "package.json",
41
+
42
+ // RC files (JSON/YAML)
43
+ `.${MODULE_NAME}rc`,
44
+ `.${MODULE_NAME}rc.json`,
45
+ `.${MODULE_NAME}rc.yaml`,
46
+ `.${MODULE_NAME}rc.yml`,
47
+
48
+ // RC files (JS)
49
+ `.${MODULE_NAME}rc.js`,
50
+ `.${MODULE_NAME}rc.cjs`,
51
+ `.${MODULE_NAME}rc.mjs`,
52
+
53
+ // RC files (TS)
54
+ `.${MODULE_NAME}rc.ts`,
55
+ `.${MODULE_NAME}rc.cts`,
56
+ `.${MODULE_NAME}rc.mts`,
57
+
58
+ // Config files (JS)
59
+ `${MODULE_NAME}.config.js`,
60
+ `${MODULE_NAME}.config.cjs`,
61
+ `${MODULE_NAME}.config.mjs`,
62
+
63
+ // Config files (TS)
64
+ `${MODULE_NAME}.config.ts`,
65
+ `${MODULE_NAME}.config.cts`,
66
+ `${MODULE_NAME}.config.mts`,
67
+
68
+ // Config files (JSON/YAML)
69
+ `${MODULE_NAME}.config.json`,
70
+ `${MODULE_NAME}.config.yaml`,
71
+ `${MODULE_NAME}.config.yml`,
72
+
73
+ // Legacy config files (for backwards compatibility)
74
+ "typesense.config.js",
75
+ "typesense.config.cjs",
76
+ "typesense.config.mjs",
77
+ "typesense.config.ts",
78
+ "typesense.config.cts",
79
+ "typesense.config.mts",
80
+ "typesense.config.json",
81
+ "typesense.config.yaml",
82
+ "typesense.config.yml",
83
+ ],
84
+ loaders: {
85
+ ".ts": typeScriptLoader,
86
+ ".cts": typeScriptLoader,
87
+ ".mts": typeScriptLoader,
88
+ ".mjs": typeScriptLoader,
89
+ },
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Find the config file in the given directory or parent directories
95
+ */
96
+ export async function findConfigFile(startDir: string = process.cwd()): Promise<string | null> {
97
+ const explorer = createExplorer();
98
+ const result = await explorer.search(startDir);
99
+ return result?.filepath || null;
100
+ }
101
+
102
+ /**
103
+ * Load and validate a config file
104
+ *
105
+ * @param configPath - Optional path to config file. If not provided, searches for config.
106
+ * @returns Validated TypesenseConfig
107
+ */
108
+ export async function loadConfig(configPath?: string): Promise<TypesenseConfig> {
109
+ const explorer = createExplorer();
110
+
111
+ let result;
112
+
113
+ if (configPath) {
114
+ // Load specific file
115
+ const resolvedPath = resolve(configPath);
116
+ result = await explorer.load(resolvedPath);
117
+ } else {
118
+ // Search for config
119
+ result = await explorer.search();
120
+ }
121
+
122
+ if (!result || result.isEmpty) {
123
+ throw new Error(
124
+ "No config file found. Create one of the following:\n" +
125
+ " - tsctl.config.ts (recommended)\n" +
126
+ " - tsctl.config.js\n" +
127
+ " - tsctl.config.json\n" +
128
+ " - tsctl.config.yaml\n" +
129
+ " - .tsctlrc\n" +
130
+ " - \"tsctl\" property in package.json\n" +
131
+ "\nOr specify a config file with --config"
132
+ );
133
+ }
134
+
135
+ try {
136
+ // Validate with Zod
137
+ const validated = TypesenseConfigSchema.parse(result.config);
138
+ return validated;
139
+ } catch (error) {
140
+ if (error instanceof Error && error.name === "ZodError") {
141
+ throw new Error(`Invalid config in ${result.filepath}:\n${error.message}`);
142
+ }
143
+ throw new Error(
144
+ `Failed to parse config file: ${result.filepath}\n${error instanceof Error ? error.message : String(error)}`
145
+ );
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Get the directory containing the config file
151
+ */
152
+ export async function getConfigDir(configPath?: string): Promise<string> {
153
+ const configFile = configPath ? resolve(configPath) : await findConfigFile();
154
+ if (!configFile) {
155
+ return process.cwd();
156
+ }
157
+ return dirname(configFile);
158
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ // Main entry point for library usage
2
+ // Users can import { defineConfig } from "@tsctl/cli"
3
+
4
+ export {
5
+ defineConfig,
6
+ // Types
7
+ type TypesenseConfig,
8
+ type CollectionConfig,
9
+ type AliasConfig,
10
+ type SynonymConfig,
11
+ type SynonymSetConfig,
12
+ type SynonymSetItem,
13
+ type OverrideConfig,
14
+ type CurationSetConfig,
15
+ type CurationItem,
16
+ type StopwordSetConfig,
17
+ type PresetConfig,
18
+ type StemmingDictionaryConfig,
19
+ type StemmingWord,
20
+ type AnalyticsRuleConfig,
21
+ type ApiKeyConfig,
22
+ type Field,
23
+ type FieldType,
24
+ type ConnectionConfig,
25
+ // Schemas (for validation)
26
+ TypesenseConfigSchema,
27
+ CollectionConfigSchema,
28
+ AliasConfigSchema,
29
+ SynonymConfigSchema,
30
+ SynonymSetConfigSchema,
31
+ SynonymSetItemSchema,
32
+ OverrideConfigSchema,
33
+ CurationSetConfigSchema,
34
+ CurationItemSchema,
35
+ CurationRuleSchema,
36
+ StopwordSetConfigSchema,
37
+ PresetConfigSchema,
38
+ StemmingDictionaryConfigSchema,
39
+ StemmingWordSchema,
40
+ AnalyticsRuleConfigSchema,
41
+ ApiKeyConfigSchema,
42
+ FieldSchema,
43
+ FieldTypeSchema,
44
+ ConnectionConfigSchema,
45
+ } from "./types/index.js";
@@ -0,0 +1,220 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import {
4
+ getCollection,
5
+ createCollection,
6
+ deleteCollection,
7
+ listCollections,
8
+ } from "../resources/collection.js";
9
+ import { upsertAlias, getAlias } from "../resources/alias.js";
10
+ import type { CollectionConfig, AliasConfig } from "../types/index.js";
11
+
12
+ export interface MigrationPlan {
13
+ alias: string;
14
+ currentCollection: string | null;
15
+ newCollection: string;
16
+ newCollectionConfig: CollectionConfig;
17
+ steps: MigrationStep[];
18
+ }
19
+
20
+ export interface MigrationStep {
21
+ action: "create_collection" | "switch_alias" | "delete_old_collection";
22
+ description: string;
23
+ target: string;
24
+ }
25
+
26
+ export interface MigrationResult {
27
+ success: boolean;
28
+ newCollectionName: string;
29
+ oldCollectionName: string | null;
30
+ aliasName: string;
31
+ errors: string[];
32
+ }
33
+
34
+ /**
35
+ * Generate a versioned collection name
36
+ */
37
+ export function generateVersionedName(baseName: string): string {
38
+ const timestamp = Date.now();
39
+ return `${baseName}_${timestamp}`;
40
+ }
41
+
42
+ /**
43
+ * Extract the base name from a versioned collection name
44
+ */
45
+ export function extractBaseName(name: string): string {
46
+ // Try to extract base name by removing _<timestamp> suffix
47
+ const match = name.match(/^(.+)_\d{13}$/);
48
+ return match ? match[1]! : name;
49
+ }
50
+
51
+ /**
52
+ * Find all versions of a collection by base name
53
+ */
54
+ export async function findCollectionVersions(baseName: string): Promise<CollectionConfig[]> {
55
+ const allCollections = await listCollections();
56
+ return allCollections.filter(
57
+ (c) => c.name === baseName || c.name.startsWith(`${baseName}_`)
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Plan a blue/green migration
63
+ */
64
+ export async function planMigration(
65
+ aliasName: string,
66
+ newCollectionConfig: CollectionConfig
67
+ ): Promise<MigrationPlan> {
68
+ const steps: MigrationStep[] = [];
69
+
70
+ // Get current alias target
71
+ const currentAlias = await getAlias(aliasName);
72
+ const currentCollection = currentAlias?.collection || null;
73
+
74
+ // Generate new collection name with timestamp
75
+ const baseName = extractBaseName(newCollectionConfig.name);
76
+ const newCollectionName = generateVersionedName(baseName);
77
+
78
+ // Update config with versioned name
79
+ const versionedConfig: CollectionConfig = {
80
+ ...newCollectionConfig,
81
+ name: newCollectionName,
82
+ };
83
+
84
+ // Step 1: Create new collection
85
+ steps.push({
86
+ action: "create_collection",
87
+ description: `Create new collection '${newCollectionName}'`,
88
+ target: newCollectionName,
89
+ });
90
+
91
+ // Step 2: Switch alias (user should index data between step 1 and 2)
92
+ steps.push({
93
+ action: "switch_alias",
94
+ description: `Switch alias '${aliasName}' to '${newCollectionName}'`,
95
+ target: aliasName,
96
+ });
97
+
98
+ // Step 3: Delete old collection (optional)
99
+ if (currentCollection) {
100
+ steps.push({
101
+ action: "delete_old_collection",
102
+ description: `Delete old collection '${currentCollection}'`,
103
+ target: currentCollection,
104
+ });
105
+ }
106
+
107
+ return {
108
+ alias: aliasName,
109
+ currentCollection,
110
+ newCollection: newCollectionName,
111
+ newCollectionConfig: versionedConfig,
112
+ steps,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Execute a migration step
118
+ */
119
+ export async function executeMigrationStep(
120
+ plan: MigrationPlan,
121
+ step: MigrationStep
122
+ ): Promise<void> {
123
+ switch (step.action) {
124
+ case "create_collection":
125
+ await createCollection(plan.newCollectionConfig);
126
+ break;
127
+
128
+ case "switch_alias":
129
+ await upsertAlias({
130
+ name: plan.alias,
131
+ collection: plan.newCollection,
132
+ });
133
+ break;
134
+
135
+ case "delete_old_collection":
136
+ if (plan.currentCollection) {
137
+ await deleteCollection(plan.currentCollection);
138
+ }
139
+ break;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Execute a full migration
145
+ */
146
+ export async function executeMigration(
147
+ plan: MigrationPlan,
148
+ options: {
149
+ skipDelete?: boolean;
150
+ onStep?: (step: MigrationStep, index: number) => void;
151
+ } = {}
152
+ ): Promise<MigrationResult> {
153
+ const result: MigrationResult = {
154
+ success: true,
155
+ newCollectionName: plan.newCollection,
156
+ oldCollectionName: plan.currentCollection,
157
+ aliasName: plan.alias,
158
+ errors: [],
159
+ };
160
+
161
+ for (let i = 0; i < plan.steps.length; i++) {
162
+ const step = plan.steps[i]!;
163
+
164
+ // Skip delete step if requested
165
+ if (step.action === "delete_old_collection" && options.skipDelete) {
166
+ continue;
167
+ }
168
+
169
+ if (options.onStep) {
170
+ options.onStep(step, i);
171
+ }
172
+
173
+ try {
174
+ await executeMigrationStep(plan, step);
175
+ } catch (error) {
176
+ result.success = false;
177
+ result.errors.push(
178
+ `Step ${i + 1} failed: ${error instanceof Error ? error.message : String(error)}`
179
+ );
180
+ break; // Stop on first error
181
+ }
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Format migration plan for display
189
+ */
190
+ export function formatMigrationPlan(plan: MigrationPlan): string {
191
+ const lines: string[] = [];
192
+
193
+ lines.push(chalk.bold("\nMigration Plan:\n"));
194
+
195
+ lines.push(` Alias: ${chalk.cyan(plan.alias)}`);
196
+ lines.push(
197
+ ` Current collection: ${plan.currentCollection ? chalk.yellow(plan.currentCollection) : chalk.gray("(none)")}`
198
+ );
199
+ lines.push(` New collection: ${chalk.green(plan.newCollection)}`);
200
+
201
+ lines.push(chalk.bold("\nSteps:\n"));
202
+
203
+ for (let i = 0; i < plan.steps.length; i++) {
204
+ const step = plan.steps[i]!;
205
+ const icon =
206
+ step.action === "create_collection"
207
+ ? chalk.green("+")
208
+ : step.action === "switch_alias"
209
+ ? chalk.blue("→")
210
+ : chalk.red("-");
211
+
212
+ lines.push(` ${i + 1}. ${icon} ${step.description}`);
213
+ }
214
+
215
+ lines.push(chalk.yellow("\n⚠️ Important:"));
216
+ lines.push(chalk.yellow(" Index your data to the new collection before switching the alias."));
217
+ lines.push(chalk.yellow(" Use --skip-delete to keep the old collection for rollback."));
218
+
219
+ return lines.join("\n");
220
+ }