@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,1106 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import ora from "ora";
6
+ import { config as dotenvConfig } from "dotenv";
7
+ import { writeFileSync, existsSync } from "fs";
8
+ import { resolve } from "path";
9
+ import { createInterface } from "readline";
10
+
11
+ import { getClientFromEnv, testConnection } from "../client/index.js";
12
+ import { loadConfig, findConfigFile } from "../config/loader.js";
13
+ import { loadState, saveState, formatResourceId } from "../state/index.js";
14
+ import { buildPlan, formatPlan, importResources, buildNewState, detectDrift, formatDriftReport } from "../plan/index.js";
15
+ import { planMigration, executeMigration, formatMigrationPlan } from "../migrate/index.js";
16
+ import { applyPlan } from "../apply/index.js";
17
+ import type { TypesenseConfig } from "../types/index.js";
18
+
19
+ /**
20
+ * Load environment configuration based on the specified environment
21
+ * Loads .env file first, then .env.<environment> to override
22
+ */
23
+ function loadEnvironment(env?: string): void {
24
+ // Always load base .env first
25
+ dotenvConfig();
26
+
27
+ if (env) {
28
+ // Load environment-specific .env file
29
+ const envFile = resolve(process.cwd(), `.env.${env}`);
30
+ if (existsSync(envFile)) {
31
+ dotenvConfig({ path: envFile, override: true });
32
+ console.log(chalk.gray(`Using environment: ${env}`));
33
+ } else {
34
+ console.log(chalk.yellow(`Warning: Environment file .env.${env} not found, using defaults`));
35
+ }
36
+ }
37
+ }
38
+
39
+ const program = new Command();
40
+
41
+ program
42
+ .name("tsctl")
43
+ .description("Terraform-like CLI for managing Typesense infrastructure")
44
+ .version("0.1.0")
45
+ .option("-e, --env <environment>", "Environment to use (loads .env.<environment>)")
46
+ .hook("preAction", (thisCommand) => {
47
+ const opts = thisCommand.opts();
48
+ loadEnvironment(opts.env);
49
+ });
50
+
51
+ // ============================================================================
52
+ // init command
53
+ // ============================================================================
54
+
55
+ program
56
+ .command("init")
57
+ .description("Initialize a new tsctl project")
58
+ .option("-f, --force", "Overwrite existing config file")
59
+ .option("--with-environments", "Create example environment files (development, staging, production)")
60
+ .action(async (options) => {
61
+ console.log(chalk.bold("\nInitializing tsctl project...\n"));
62
+
63
+ // Check for existing config
64
+ const existingConfig = await findConfigFile();
65
+ if (existingConfig && !options.force) {
66
+ console.log(chalk.yellow(`Config file already exists: ${existingConfig}`));
67
+ console.log(chalk.gray("Use --force to overwrite"));
68
+ return;
69
+ }
70
+
71
+ // Create sample config file
72
+ const configPath = resolve(process.cwd(), "tsctl.config.ts");
73
+ const sampleConfig = `import { defineConfig } from "@tsctl/cli";
74
+
75
+ export default defineConfig({
76
+ collections: [
77
+ {
78
+ name: "products",
79
+ fields: [
80
+ { name: "name", type: "string" },
81
+ { name: "description", type: "string", optional: true },
82
+ { name: "price", type: "float" },
83
+ { name: "category", type: "string", facet: true },
84
+ { name: "tags", type: "string[]", facet: true, optional: true },
85
+ { name: "in_stock", type: "bool", facet: true },
86
+ { name: "created_at", type: "int64" },
87
+ ],
88
+ default_sorting_field: "created_at",
89
+ },
90
+ ],
91
+ aliases: [
92
+ {
93
+ name: "products_live",
94
+ collection: "products",
95
+ },
96
+ ],
97
+ });
98
+ `;
99
+
100
+ writeFileSync(configPath, sampleConfig);
101
+ console.log(chalk.green(`✓ Created ${configPath}`));
102
+
103
+ // Create .env file if it doesn't exist
104
+ const envPath = resolve(process.cwd(), ".env");
105
+ if (!existsSync(envPath)) {
106
+ const envContent = `# Typesense Connection (default/development)
107
+ TYPESENSE_HOST=localhost
108
+ TYPESENSE_PORT=8108
109
+ TYPESENSE_PROTOCOL=http
110
+ TYPESENSE_API_KEY=your-api-key-here
111
+ `;
112
+ writeFileSync(envPath, envContent);
113
+ console.log(chalk.green(`✓ Created ${envPath}`));
114
+ console.log(chalk.yellow(" Update TYPESENSE_API_KEY with your actual API key"));
115
+ }
116
+
117
+ // Create environment-specific files if requested
118
+ if (options.withEnvironments) {
119
+ const environments = [
120
+ {
121
+ name: "development",
122
+ host: "localhost",
123
+ port: "8108",
124
+ protocol: "http",
125
+ },
126
+ {
127
+ name: "staging",
128
+ host: "staging.typesense.example.com",
129
+ port: "443",
130
+ protocol: "https",
131
+ },
132
+ {
133
+ name: "production",
134
+ host: "production.typesense.example.com",
135
+ port: "443",
136
+ protocol: "https",
137
+ },
138
+ ];
139
+
140
+ for (const env of environments) {
141
+ const envFilePath = resolve(process.cwd(), `.env.${env.name}`);
142
+ if (!existsSync(envFilePath)) {
143
+ const envFileContent = `# Typesense Connection (${env.name})
144
+ TYPESENSE_HOST=${env.host}
145
+ TYPESENSE_PORT=${env.port}
146
+ TYPESENSE_PROTOCOL=${env.protocol}
147
+ TYPESENSE_API_KEY=your-${env.name}-api-key-here
148
+ `;
149
+ writeFileSync(envFilePath, envFileContent);
150
+ console.log(chalk.green(`✓ Created .env.${env.name}`));
151
+ }
152
+ }
153
+
154
+ console.log(chalk.gray("\n Use --env flag to switch environments:"));
155
+ console.log(chalk.gray(" tsctl plan --env production"));
156
+ console.log(chalk.gray(" tsctl apply --env staging"));
157
+ }
158
+
159
+ // Test connection
160
+ const spinner = ora("Testing connection...").start();
161
+ try {
162
+ getClientFromEnv();
163
+ const connected = await testConnection();
164
+ if (connected) {
165
+ spinner.succeed("Connected to Typesense");
166
+ } else {
167
+ spinner.warn("Could not connect to Typesense (check your .env settings)");
168
+ }
169
+ } catch (error) {
170
+ spinner.warn(`Connection test skipped: ${error instanceof Error ? error.message : String(error)}`);
171
+ }
172
+
173
+ console.log(chalk.bold("\n✓ Project initialized"));
174
+ console.log(chalk.gray("\nNext steps:"));
175
+ console.log(chalk.gray(" 1. Update .env with your Typesense credentials"));
176
+ console.log(chalk.gray(" 2. Edit tsctl.config.ts to define your schema"));
177
+ console.log(chalk.gray(" 3. Run 'tsctl plan' to see what will be created"));
178
+ console.log(chalk.gray(" 4. Run 'tsctl apply' to apply changes"));
179
+ });
180
+
181
+ // ============================================================================
182
+ // validate command
183
+ // ============================================================================
184
+
185
+ program
186
+ .command("validate")
187
+ .description("Validate the configuration file")
188
+ .option("-c, --config <path>", "Path to config file")
189
+ .action(async (options) => {
190
+ const spinner = ora("Validating config...").start();
191
+
192
+ try {
193
+ const config = await loadConfig(options.config);
194
+ spinner.succeed("Config is valid");
195
+
196
+ // Show summary
197
+ console.log(chalk.gray("\nResources defined:"));
198
+ if (config.collections?.length) {
199
+ console.log(chalk.gray(` Collections: ${config.collections.length}`));
200
+ for (const c of config.collections) {
201
+ console.log(chalk.gray(` - ${c.name} (${c.fields.length} fields)`));
202
+ }
203
+ }
204
+ if (config.aliases?.length) {
205
+ console.log(chalk.gray(` Aliases: ${config.aliases.length}`));
206
+ for (const a of config.aliases) {
207
+ console.log(chalk.gray(` - ${a.name} → ${a.collection}`));
208
+ }
209
+ }
210
+ if (config.synonyms?.length) {
211
+ console.log(chalk.gray(` Synonyms: ${config.synonyms.length}`));
212
+ for (const s of config.synonyms) {
213
+ const type = s.root ? `root: ${s.root}` : `${s.synonyms?.length || 0} terms`;
214
+ console.log(chalk.gray(` - ${s.id} (${s.collection}) [${type}]`));
215
+ }
216
+ }
217
+ if (config.overrides?.length) {
218
+ console.log(chalk.gray(` Overrides: ${config.overrides.length}`));
219
+ for (const o of config.overrides) {
220
+ const ruleDesc = o.rule.query ? `query: "${o.rule.query}"` : o.rule.filter_by ? `filter: ${o.rule.filter_by}` : "custom";
221
+ console.log(chalk.gray(` - ${o.id} (${o.collection}) [${ruleDesc}]`));
222
+ }
223
+ }
224
+ if (config.apiKeys?.length) {
225
+ console.log(chalk.gray(` API Keys: ${config.apiKeys.length}`));
226
+ for (const k of config.apiKeys) {
227
+ const collections = k.collections.join(", ");
228
+ console.log(chalk.gray(` - ${k.description} [${collections}]`));
229
+ }
230
+ }
231
+ if (config.synonymSets?.length) {
232
+ console.log(chalk.gray(` Synonym Sets: ${config.synonymSets.length}`));
233
+ for (const s of config.synonymSets) {
234
+ console.log(chalk.gray(` - ${s.name} (${s.items.length} items)`));
235
+ }
236
+ }
237
+ if (config.curationSets?.length) {
238
+ console.log(chalk.gray(` Curation Sets: ${config.curationSets.length}`));
239
+ for (const c of config.curationSets) {
240
+ console.log(chalk.gray(` - ${c.name} (${c.items.length} items)`));
241
+ }
242
+ }
243
+ if (config.stopwords?.length) {
244
+ console.log(chalk.gray(` Stopwords: ${config.stopwords.length}`));
245
+ for (const s of config.stopwords) {
246
+ console.log(chalk.gray(` - ${s.id} (${s.stopwords.length} words)`));
247
+ }
248
+ }
249
+ if (config.presets?.length) {
250
+ console.log(chalk.gray(` Presets: ${config.presets.length}`));
251
+ for (const p of config.presets) {
252
+ console.log(chalk.gray(` - ${p.name}`));
253
+ }
254
+ }
255
+ if (config.analyticsRules?.length) {
256
+ console.log(chalk.gray(` Analytics Rules: ${config.analyticsRules.length}`));
257
+ for (const r of config.analyticsRules) {
258
+ console.log(chalk.gray(` - ${r.name} (${r.type})`));
259
+ }
260
+ }
261
+ if (config.stemmingDictionaries?.length) {
262
+ console.log(chalk.gray(` Stemming Dictionaries: ${config.stemmingDictionaries.length}`));
263
+ for (const d of config.stemmingDictionaries) {
264
+ console.log(chalk.gray(` - ${d.id} (${d.words.length} words)`));
265
+ }
266
+ }
267
+ } catch (error) {
268
+ spinner.fail("Config validation failed");
269
+ console.error(chalk.red(`\n${error instanceof Error ? error.message : String(error)}`));
270
+ process.exit(1);
271
+ }
272
+ });
273
+
274
+ // ============================================================================
275
+ // plan command
276
+ // ============================================================================
277
+
278
+ program
279
+ .command("plan")
280
+ .description("Show what changes will be made")
281
+ .option("-c, --config <path>", "Path to config file")
282
+ .option("-o, --out <path>", "Save plan to file")
283
+ .option("--json", "Output plan as JSON")
284
+ .action(async (options) => {
285
+ try {
286
+ // Initialize client
287
+ getClientFromEnv();
288
+
289
+ // Load config
290
+ if (!options.json) {
291
+ var spinner = ora("Loading config...").start();
292
+ }
293
+ const config = await loadConfig(options.config);
294
+ if (!options.json) spinner!.succeed("Config loaded");
295
+
296
+ // Build plan
297
+ if (!options.json) {
298
+ var planSpinner = ora("Building plan...").start();
299
+ }
300
+ const plan = await buildPlan(config);
301
+ if (!options.json) planSpinner!.succeed("Plan built");
302
+
303
+ if (options.json) {
304
+ // JSON output: strip diff (contains ANSI codes) and output clean JSON
305
+ const cleanPlan = {
306
+ ...plan,
307
+ changes: plan.changes.map((c) => ({
308
+ action: c.action,
309
+ identifier: c.identifier,
310
+ before: c.before,
311
+ after: c.after,
312
+ })),
313
+ };
314
+ console.log(JSON.stringify(cleanPlan, null, 2));
315
+ } else {
316
+ // Display plan
317
+ console.log(formatPlan(plan));
318
+ }
319
+
320
+ // Save plan if requested
321
+ if (options.out) {
322
+ const planPath = resolve(options.out);
323
+ writeFileSync(planPath, JSON.stringify(plan, null, 2));
324
+ if (!options.json) {
325
+ console.log(chalk.gray(`\nPlan saved to ${planPath}`));
326
+ }
327
+ }
328
+ } catch (error) {
329
+ if (options.json) {
330
+ console.error(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
331
+ } else {
332
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
333
+ }
334
+ process.exit(1);
335
+ }
336
+ });
337
+
338
+ // ============================================================================
339
+ // apply command
340
+ // ============================================================================
341
+
342
+ program
343
+ .command("apply")
344
+ .description("Apply changes to Typesense")
345
+ .option("-c, --config <path>", "Path to config file")
346
+ .option("-y, --yes", "Auto-approve changes")
347
+ .option("--force-recreate", "Force recreation of collections with incompatible changes")
348
+ .option("-t, --target <resources...>", "Only apply specific resources (e.g., collection.products alias.products_live)")
349
+ .action(async (options) => {
350
+ try {
351
+ // Initialize client
352
+ getClientFromEnv();
353
+
354
+ // Load config
355
+ const spinner = ora("Loading config...").start();
356
+ const config = await loadConfig(options.config);
357
+ spinner.succeed("Config loaded");
358
+
359
+ // Build plan
360
+ const planSpinner = ora("Building plan...").start();
361
+ const plan = await buildPlan(config);
362
+ planSpinner.succeed("Plan built");
363
+
364
+ // Filter plan by target if specified
365
+ if (options.target) {
366
+ const targets = new Set(options.target as string[]);
367
+ plan.changes = plan.changes.filter((c) => {
368
+ const resourceId = formatResourceId(c.identifier);
369
+ return targets.has(resourceId);
370
+ });
371
+ plan.hasChanges = plan.changes.some((c) => c.action !== "no-change");
372
+ plan.summary = {
373
+ create: plan.changes.filter((c) => c.action === "create").length,
374
+ update: plan.changes.filter((c) => c.action === "update").length,
375
+ delete: plan.changes.filter((c) => c.action === "delete").length,
376
+ noChange: plan.changes.filter((c) => c.action === "no-change").length,
377
+ };
378
+ }
379
+
380
+ // Display plan
381
+ console.log(formatPlan(plan));
382
+
383
+ if (!plan.hasChanges) {
384
+ return;
385
+ }
386
+
387
+ // Confirm if not auto-approved
388
+ if (!options.yes) {
389
+ const rl = createInterface({
390
+ input: process.stdin,
391
+ output: process.stdout,
392
+ });
393
+
394
+ const answer = await new Promise<string>((resolve) => {
395
+ rl.question(
396
+ chalk.yellow("\nDo you want to apply these changes? (yes/no): "),
397
+ resolve
398
+ );
399
+ });
400
+ rl.close();
401
+
402
+ if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
403
+ console.log(chalk.gray("Apply cancelled."));
404
+ return;
405
+ }
406
+ }
407
+
408
+ // Apply plan
409
+ const result = await applyPlan(plan, config, {
410
+ autoApprove: options.yes,
411
+ forceRecreate: options.forceRecreate,
412
+ });
413
+
414
+ if (!result.success) {
415
+ process.exit(1);
416
+ }
417
+ } catch (error) {
418
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
419
+ process.exit(1);
420
+ }
421
+ });
422
+
423
+ // ============================================================================
424
+ // destroy command
425
+ // ============================================================================
426
+
427
+ program
428
+ .command("destroy")
429
+ .description("Destroy all managed resources")
430
+ .option("-y, --yes", "Auto-approve destruction")
431
+ .action(async (options) => {
432
+ try {
433
+ // Initialize client
434
+ getClientFromEnv();
435
+
436
+ // Load state
437
+ const state = await loadState();
438
+
439
+ if (state.resources.length === 0) {
440
+ console.log(chalk.yellow("\nNo resources to destroy."));
441
+ return;
442
+ }
443
+
444
+ console.log(chalk.bold.red("\n⚠️ DESTRUCTION PLAN\n"));
445
+ console.log(chalk.red("The following resources will be destroyed:\n"));
446
+
447
+ for (const resource of state.resources) {
448
+ console.log(chalk.red(` - ${formatResourceId(resource.identifier)}`));
449
+ }
450
+
451
+ // Confirm
452
+ if (!options.yes) {
453
+ const rl = createInterface({
454
+ input: process.stdin,
455
+ output: process.stdout,
456
+ });
457
+
458
+ const answer = await new Promise<string>((resolve) => {
459
+ rl.question(
460
+ chalk.red("\nType 'destroy' to confirm: "),
461
+ resolve
462
+ );
463
+ });
464
+ rl.close();
465
+
466
+ if (answer !== "destroy") {
467
+ console.log(chalk.gray("Destroy cancelled."));
468
+ return;
469
+ }
470
+ }
471
+
472
+ // Build a destroy plan (empty config)
473
+ const emptyConfig: TypesenseConfig = {};
474
+ const plan = await buildPlan(emptyConfig);
475
+
476
+ // Apply destruction
477
+ await applyPlan(plan, emptyConfig, { autoApprove: true });
478
+
479
+ console.log(chalk.green("\n✓ All managed resources destroyed."));
480
+ } catch (error) {
481
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
482
+ process.exit(1);
483
+ }
484
+ });
485
+
486
+ // ============================================================================
487
+ // import command
488
+ // ============================================================================
489
+
490
+ program
491
+ .command("import")
492
+ .description("Import existing Typesense resources into state")
493
+ .option("-o, --out <path>", "Output config file path", "tsctl.imported.config.ts")
494
+ .action(async (options) => {
495
+ try {
496
+ // Initialize client
497
+ getClientFromEnv();
498
+
499
+ const spinner = ora("Importing resources...").start();
500
+ const { collections, aliases, synonyms, synonymSets, overrides, curationSets, analyticsRules, apiKeys, stopwords, presets, stemmingDictionaries } = await importResources();
501
+ spinner.succeed("Resources imported");
502
+
503
+ console.log(chalk.gray("\nFound:"));
504
+ console.log(chalk.gray(` Collections: ${collections.length}`));
505
+ console.log(chalk.gray(` Aliases: ${aliases.length}`));
506
+ console.log(chalk.gray(` Synonyms: ${synonyms.length}`));
507
+ console.log(chalk.gray(` Synonym Sets: ${synonymSets.length}`));
508
+ console.log(chalk.gray(` Overrides: ${overrides.length}`));
509
+ console.log(chalk.gray(` Curation Sets: ${curationSets.length}`));
510
+ console.log(chalk.gray(` Analytics Rules: ${analyticsRules.length}`));
511
+ console.log(chalk.gray(` API Keys: ${apiKeys.length}`));
512
+ console.log(chalk.gray(` Stopwords: ${stopwords.length}`));
513
+ console.log(chalk.gray(` Presets: ${presets.length}`));
514
+ console.log(chalk.gray(` Stemming Dictionaries: ${stemmingDictionaries.length}`));
515
+
516
+ if (apiKeys.length > 0) {
517
+ console.log(chalk.yellow("\n Note: API key values cannot be retrieved after creation."));
518
+ console.log(chalk.yellow(" Imported API keys will track existing keys but cannot recreate them."));
519
+ }
520
+
521
+ // Generate config file
522
+ const config: TypesenseConfig = {
523
+ collections: collections.length > 0 ? collections : undefined,
524
+ aliases: aliases.length > 0 ? aliases : undefined,
525
+ synonyms: synonyms.length > 0 ? synonyms : undefined,
526
+ synonymSets: synonymSets.length > 0 ? synonymSets : undefined,
527
+ overrides: overrides.length > 0 ? overrides : undefined,
528
+ curationSets: curationSets.length > 0 ? curationSets : undefined,
529
+ analyticsRules: analyticsRules.length > 0 ? analyticsRules : undefined,
530
+ apiKeys: apiKeys.length > 0 ? apiKeys : undefined,
531
+ stopwords: stopwords.length > 0 ? stopwords : undefined,
532
+ presets: presets.length > 0 ? presets : undefined,
533
+ stemmingDictionaries: stemmingDictionaries.length > 0 ? stemmingDictionaries : undefined,
534
+ };
535
+
536
+ const configContent = `import { defineConfig } from "@tsctl/cli";
537
+
538
+ export default defineConfig(${JSON.stringify(config, null, 2)});
539
+ `;
540
+
541
+ const outPath = resolve(options.out);
542
+ writeFileSync(outPath, configContent);
543
+ console.log(chalk.green(`\n✓ Config written to ${outPath}`));
544
+
545
+ // Save state
546
+ const newState = buildNewState({ version: "1.0", resources: [] }, config);
547
+ await saveState(newState);
548
+ console.log(chalk.green("✓ State saved to Typesense"));
549
+
550
+ console.log(chalk.gray("\nReview the generated config and rename to tsctl.config.ts"));
551
+ } catch (error) {
552
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
553
+ process.exit(1);
554
+ }
555
+ });
556
+
557
+ // ============================================================================
558
+ // drift command
559
+ // ============================================================================
560
+
561
+ program
562
+ .command("drift")
563
+ .description("Detect changes made outside of tsctl (drift detection)")
564
+ .option("--json", "Output as JSON")
565
+ .action(async (options) => {
566
+ try {
567
+ // Initialize client
568
+ getClientFromEnv();
569
+
570
+ const spinner = ora("Detecting drift...").start();
571
+ const report = await detectDrift();
572
+ spinner.succeed("Drift detection complete");
573
+
574
+ if (options.json) {
575
+ console.log(JSON.stringify(report, null, 2));
576
+ } else {
577
+ console.log(formatDriftReport(report));
578
+ }
579
+
580
+ // Exit with code 1 if drift is detected (useful for CI/CD)
581
+ if (report.hasDrift) {
582
+ process.exit(1);
583
+ }
584
+ } catch (error) {
585
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
586
+ process.exit(1);
587
+ }
588
+ });
589
+
590
+ // ============================================================================
591
+ // migrate command
592
+ // ============================================================================
593
+
594
+ program
595
+ .command("migrate")
596
+ .description("Blue/green migration for collections (zero-downtime updates)")
597
+ .requiredOption("-a, --alias <name>", "Alias to migrate")
598
+ .requiredOption("-c, --config <path>", "Path to config file")
599
+ .option("--collection <name>", "Collection name from config (if multiple)")
600
+ .option("--skip-delete", "Keep old collection after migration (for rollback)")
601
+ .option("-y, --yes", "Auto-approve migration")
602
+ .option("--create-only", "Only create the new collection (don't switch alias)")
603
+ .option("--switch-only", "Only switch alias (collection must exist)")
604
+ .option("--cleanup <collection>", "Delete an old collection after successful migration")
605
+ .action(async (options) => {
606
+ try {
607
+ // Initialize client
608
+ getClientFromEnv();
609
+
610
+ // Load config
611
+ const spinner = ora("Loading config...").start();
612
+ const config = await loadConfig(options.config);
613
+ spinner.succeed("Config loaded");
614
+
615
+ // Find the collection in config
616
+ if (!config.collections || config.collections.length === 0) {
617
+ console.error(chalk.red("No collections defined in config"));
618
+ process.exit(1);
619
+ }
620
+
621
+ let collectionConfig = config.collections[0];
622
+ if (options.collection) {
623
+ const found = config.collections.find((c) => c.name === options.collection);
624
+ if (!found) {
625
+ console.error(chalk.red(`Collection '${options.collection}' not found in config`));
626
+ process.exit(1);
627
+ }
628
+ collectionConfig = found;
629
+ } else if (config.collections.length > 1) {
630
+ console.error(chalk.red("Multiple collections in config. Use --collection to specify which one."));
631
+ console.log(chalk.gray("Available collections:"));
632
+ for (const c of config.collections) {
633
+ console.log(chalk.gray(` - ${c.name}`));
634
+ }
635
+ process.exit(1);
636
+ }
637
+
638
+ // Handle cleanup mode
639
+ if (options.cleanup) {
640
+ const cleanupSpinner = ora(`Deleting collection '${options.cleanup}'...`).start();
641
+ try {
642
+ const { deleteCollection } = await import("../resources/collection.js");
643
+ await deleteCollection(options.cleanup);
644
+ cleanupSpinner.succeed(`Deleted collection '${options.cleanup}'`);
645
+ } catch (error) {
646
+ cleanupSpinner.fail(`Failed to delete collection: ${error instanceof Error ? error.message : String(error)}`);
647
+ process.exit(1);
648
+ }
649
+ return;
650
+ }
651
+
652
+ // Plan migration
653
+ const planSpinner = ora("Planning migration...").start();
654
+ const plan = await planMigration(options.alias, collectionConfig!);
655
+ planSpinner.succeed("Migration planned");
656
+
657
+ // Display plan
658
+ console.log(formatMigrationPlan(plan));
659
+
660
+ // Handle create-only mode
661
+ if (options.createOnly) {
662
+ if (!options.yes) {
663
+ const rl = createInterface({
664
+ input: process.stdin,
665
+ output: process.stdout,
666
+ });
667
+
668
+ const answer = await new Promise<string>((resolve) => {
669
+ rl.question(
670
+ chalk.yellow("\nCreate the new collection? (yes/no): "),
671
+ resolve
672
+ );
673
+ });
674
+ rl.close();
675
+
676
+ if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
677
+ console.log(chalk.gray("Migration cancelled."));
678
+ return;
679
+ }
680
+ }
681
+
682
+ const createSpinner = ora(`Creating collection '${plan.newCollection}'...`).start();
683
+ try {
684
+ const { createCollection } = await import("../resources/collection.js");
685
+ await createCollection(plan.newCollectionConfig);
686
+ createSpinner.succeed(`Created collection '${plan.newCollection}'`);
687
+ console.log(chalk.green("\n✓ Collection created. Index your data, then run:"));
688
+ console.log(chalk.cyan(` tsctl migrate -a ${options.alias} -c ${options.config} --switch-only`));
689
+ } catch (error) {
690
+ createSpinner.fail(`Failed: ${error instanceof Error ? error.message : String(error)}`);
691
+ process.exit(1);
692
+ }
693
+ return;
694
+ }
695
+
696
+ // Handle switch-only mode
697
+ if (options.switchOnly) {
698
+ if (!options.yes) {
699
+ const rl = createInterface({
700
+ input: process.stdin,
701
+ output: process.stdout,
702
+ });
703
+
704
+ const answer = await new Promise<string>((resolve) => {
705
+ rl.question(
706
+ chalk.yellow(`\nSwitch alias '${options.alias}' to the latest collection? (yes/no): `),
707
+ resolve
708
+ );
709
+ });
710
+ rl.close();
711
+
712
+ if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
713
+ console.log(chalk.gray("Migration cancelled."));
714
+ return;
715
+ }
716
+ }
717
+
718
+ // Find the latest versioned collection
719
+ const { findCollectionVersions, extractBaseName } = await import("../migrate/index.js");
720
+ const baseName = extractBaseName(collectionConfig!.name);
721
+ const versions = await findCollectionVersions(baseName);
722
+
723
+ if (versions.length === 0) {
724
+ console.error(chalk.red(`No collections found matching '${baseName}'`));
725
+ process.exit(1);
726
+ }
727
+
728
+ // Sort by name (timestamp) and get latest
729
+ const latestCollection = versions.sort((a, b) => b.name.localeCompare(a.name))[0]!;
730
+
731
+ const switchSpinner = ora(`Switching alias '${options.alias}' to '${latestCollection.name}'...`).start();
732
+ try {
733
+ const { upsertAlias } = await import("../resources/alias.js");
734
+ await upsertAlias({ name: options.alias, collection: latestCollection.name });
735
+ switchSpinner.succeed(`Switched alias '${options.alias}' to '${latestCollection.name}'`);
736
+
737
+ if (plan.currentCollection) {
738
+ console.log(chalk.green("\n✓ Migration complete. Old collection still exists for rollback."));
739
+ console.log(chalk.gray(` To delete the old collection:`));
740
+ console.log(chalk.cyan(` tsctl migrate -a ${options.alias} -c ${options.config} --cleanup ${plan.currentCollection}`));
741
+ }
742
+ } catch (error) {
743
+ switchSpinner.fail(`Failed: ${error instanceof Error ? error.message : String(error)}`);
744
+ process.exit(1);
745
+ }
746
+ return;
747
+ }
748
+
749
+ // Full migration
750
+ if (!options.yes) {
751
+ const rl = createInterface({
752
+ input: process.stdin,
753
+ output: process.stdout,
754
+ });
755
+
756
+ const answer = await new Promise<string>((resolve) => {
757
+ rl.question(
758
+ chalk.yellow("\nProceed with migration? (yes/no): "),
759
+ resolve
760
+ );
761
+ });
762
+ rl.close();
763
+
764
+ if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
765
+ console.log(chalk.gray("Migration cancelled."));
766
+ return;
767
+ }
768
+ }
769
+
770
+ // Execute migration
771
+ console.log(chalk.bold("\nExecuting migration...\n"));
772
+
773
+ const result = await executeMigration(plan, {
774
+ skipDelete: options.skipDelete,
775
+ onStep: (step, index) => {
776
+ console.log(chalk.gray(` Step ${index + 1}: ${step.description}...`));
777
+ },
778
+ });
779
+
780
+ if (result.success) {
781
+ console.log(chalk.green("\n✓ Migration completed successfully!"));
782
+ console.log(chalk.gray(` New collection: ${result.newCollectionName}`));
783
+ console.log(chalk.gray(` Alias: ${result.aliasName}`));
784
+
785
+ if (options.skipDelete && result.oldCollectionName) {
786
+ console.log(chalk.yellow(`\n Old collection '${result.oldCollectionName}' was kept for rollback.`));
787
+ console.log(chalk.gray(` To delete it later:`));
788
+ console.log(chalk.cyan(` tsctl migrate -a ${options.alias} -c ${options.config} --cleanup ${result.oldCollectionName}`));
789
+ }
790
+ } else {
791
+ console.error(chalk.red("\n✗ Migration failed:"));
792
+ for (const error of result.errors) {
793
+ console.error(chalk.red(` ${error}`));
794
+ }
795
+ process.exit(1);
796
+ }
797
+ } catch (error) {
798
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
799
+ process.exit(1);
800
+ }
801
+ });
802
+
803
+ // ============================================================================
804
+ // env command
805
+ // ============================================================================
806
+
807
+ const envCmd = program
808
+ .command("env")
809
+ .description("Manage environments");
810
+
811
+ envCmd
812
+ .command("list")
813
+ .description("List available environments")
814
+ .action(async () => {
815
+ console.log(chalk.bold("\nAvailable environments:\n"));
816
+
817
+ // Find all .env files
818
+ const cwd = process.cwd();
819
+ const envFiles = [".env"];
820
+ const envNames = ["(default)"];
821
+
822
+ // Check for environment-specific files
823
+ const commonEnvs = ["development", "dev", "staging", "stage", "production", "prod", "local", "test"];
824
+ for (const env of commonEnvs) {
825
+ const envFile = `.env.${env}`;
826
+ if (existsSync(resolve(cwd, envFile))) {
827
+ envFiles.push(envFile);
828
+ envNames.push(env);
829
+ }
830
+ }
831
+
832
+ // Also scan for any other .env.* files
833
+ const fs = await import("fs");
834
+ const files = fs.readdirSync(cwd);
835
+ for (const file of files) {
836
+ if (file.startsWith(".env.") && !envFiles.includes(file)) {
837
+ envFiles.push(file);
838
+ envNames.push(file.replace(".env.", ""));
839
+ }
840
+ }
841
+
842
+ if (envFiles.length === 0) {
843
+ console.log(chalk.yellow(" No environment files found."));
844
+ console.log(chalk.gray("\n Create .env files with: tsctl init --with-environments"));
845
+ return;
846
+ }
847
+
848
+ for (let i = 0; i < envFiles.length; i++) {
849
+ const isDefault = i === 0;
850
+ const name = envNames[i];
851
+ const file = envFiles[i];
852
+
853
+ if (isDefault) {
854
+ console.log(` ${chalk.green("●")} ${chalk.bold(name!)} ${chalk.gray(`(${file})`)}`);
855
+ } else {
856
+ console.log(` ${chalk.gray("○")} ${name} ${chalk.gray(`(${file})`)}`);
857
+ }
858
+ }
859
+
860
+ console.log(chalk.gray("\n Use --env flag to switch:"));
861
+ console.log(chalk.gray(" tsctl plan --env production"));
862
+ console.log(chalk.gray(" tsctl apply --env staging -y"));
863
+ });
864
+
865
+ envCmd
866
+ .command("show")
867
+ .description("Show current environment configuration")
868
+ .action(async () => {
869
+ const globalOpts = program.opts();
870
+ const envName = globalOpts.env || "(default)";
871
+
872
+ console.log(chalk.bold(`\nEnvironment: ${chalk.cyan(envName)}\n`));
873
+
874
+ const host = process.env.TYPESENSE_HOST || "localhost";
875
+ const port = process.env.TYPESENSE_PORT || "8108";
876
+ const protocol = process.env.TYPESENSE_PROTOCOL || "http";
877
+ const apiKey = process.env.TYPESENSE_API_KEY;
878
+
879
+ console.log(` Host: ${chalk.cyan(host)}`);
880
+ console.log(` Port: ${chalk.cyan(port)}`);
881
+ console.log(` Protocol: ${chalk.cyan(protocol)}`);
882
+ console.log(` API Key: ${apiKey ? chalk.green("●●●●●●●● (set)") : chalk.red("(not set)")}`);
883
+ console.log(` URL: ${chalk.cyan(`${protocol}://${host}:${port}`)}`);
884
+
885
+ // Test connection
886
+ const spinner = ora("Testing connection...").start();
887
+ try {
888
+ getClientFromEnv();
889
+ const connected = await testConnection();
890
+ if (connected) {
891
+ spinner.succeed(chalk.green("Connected to Typesense"));
892
+ } else {
893
+ spinner.fail(chalk.red("Could not connect to Typesense"));
894
+ }
895
+ } catch (error) {
896
+ spinner.fail(`Connection failed: ${error instanceof Error ? error.message : String(error)}`);
897
+ }
898
+ });
899
+
900
+ // ============================================================================
901
+ // state command
902
+ // ============================================================================
903
+
904
+ const stateCmd = program
905
+ .command("state")
906
+ .description("Manage state");
907
+
908
+ stateCmd
909
+ .command("list")
910
+ .description("List all managed resources")
911
+ .action(async () => {
912
+ try {
913
+ getClientFromEnv();
914
+ const state = await loadState();
915
+
916
+ if (state.resources.length === 0) {
917
+ console.log(chalk.yellow("\nNo resources in state."));
918
+ return;
919
+ }
920
+
921
+ console.log(chalk.bold("\nManaged resources:\n"));
922
+ for (const resource of state.resources) {
923
+ console.log(` ${formatResourceId(resource.identifier)}`);
924
+ console.log(chalk.gray(` Checksum: ${resource.checksum}`));
925
+ console.log(chalk.gray(` Updated: ${resource.lastUpdated}`));
926
+ console.log();
927
+ }
928
+ } catch (error) {
929
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
930
+ process.exit(1);
931
+ }
932
+ });
933
+
934
+ stateCmd
935
+ .command("show")
936
+ .description("Show full state")
937
+ .action(async () => {
938
+ try {
939
+ getClientFromEnv();
940
+ const state = await loadState();
941
+ console.log(JSON.stringify(state, null, 2));
942
+ } catch (error) {
943
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
944
+ process.exit(1);
945
+ }
946
+ });
947
+
948
+ stateCmd
949
+ .command("clear")
950
+ .description("Clear state (does not delete resources)")
951
+ .option("-y, --yes", "Auto-approve")
952
+ .action(async (options) => {
953
+ try {
954
+ getClientFromEnv();
955
+
956
+ if (!options.yes) {
957
+ const rl = createInterface({
958
+ input: process.stdin,
959
+ output: process.stdout,
960
+ });
961
+
962
+ const answer = await new Promise<string>((resolve) => {
963
+ rl.question(
964
+ chalk.yellow("Clear state? Resources will NOT be deleted. (yes/no): "),
965
+ resolve
966
+ );
967
+ });
968
+ rl.close();
969
+
970
+ if (answer.toLowerCase() !== "yes" && answer.toLowerCase() !== "y") {
971
+ console.log(chalk.gray("Cancelled."));
972
+ return;
973
+ }
974
+ }
975
+
976
+ await saveState({ version: "1.0", resources: [] });
977
+ console.log(chalk.green("✓ State cleared."));
978
+ } catch (error) {
979
+ console.error(chalk.red(`\nError: ${error instanceof Error ? error.message : String(error)}`));
980
+ process.exit(1);
981
+ }
982
+ });
983
+
984
+ // ============================================================================
985
+ // completion command
986
+ // ============================================================================
987
+
988
+ program
989
+ .command("completion")
990
+ .description("Generate shell completion script")
991
+ .argument("<shell>", "Shell type: bash, zsh, or fish")
992
+ .action((shell: string) => {
993
+ const commands = "init validate plan apply destroy import drift migrate env state completion";
994
+ const globalFlags = "--env --help --version";
995
+
996
+ switch (shell) {
997
+ case "bash":
998
+ console.log(`# tsctl bash completion
999
+ # Add to ~/.bashrc: eval "$(tsctl completion bash)"
1000
+ _tsctl_completions() {
1001
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
1002
+ local commands="${commands}"
1003
+ local global_flags="${globalFlags}"
1004
+
1005
+ if [ "\${COMP_CWORD}" -eq 1 ]; then
1006
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
1007
+ elif [ "\${COMP_CWORD}" -eq 2 ]; then
1008
+ case "\${COMP_WORDS[1]}" in
1009
+ state) COMPREPLY=( $(compgen -W "list show clear" -- "\${cur}") ) ;;
1010
+ env) COMPREPLY=( $(compgen -W "list show" -- "\${cur}") ) ;;
1011
+ plan) COMPREPLY=( $(compgen -W "--config --out --json" -- "\${cur}") ) ;;
1012
+ apply) COMPREPLY=( $(compgen -W "--config --yes --force-recreate --target" -- "\${cur}") ) ;;
1013
+ migrate) COMPREPLY=( $(compgen -W "--alias --config --collection --skip-delete --yes --create-only --switch-only --cleanup" -- "\${cur}") ) ;;
1014
+ *) COMPREPLY=( $(compgen -W "\${global_flags}" -- "\${cur}") ) ;;
1015
+ esac
1016
+ fi
1017
+ }
1018
+ complete -F _tsctl_completions tsctl`);
1019
+ break;
1020
+
1021
+ case "zsh":
1022
+ console.log(`# tsctl zsh completion
1023
+ # Add to ~/.zshrc: eval "$(tsctl completion zsh)"
1024
+ _tsctl() {
1025
+ local -a commands=(
1026
+ 'init:Initialize a new project'
1027
+ 'validate:Validate config file'
1028
+ 'plan:Show planned changes'
1029
+ 'apply:Apply changes to Typesense'
1030
+ 'destroy:Destroy all managed resources'
1031
+ 'import:Import existing resources'
1032
+ 'drift:Detect drift'
1033
+ 'migrate:Blue/green migration'
1034
+ 'env:Manage environments'
1035
+ 'state:Manage state'
1036
+ 'completion:Generate shell completions'
1037
+ )
1038
+
1039
+ _arguments -C \\
1040
+ '--env[Environment]:environment' \\
1041
+ '--help[Show help]' \\
1042
+ '--version[Show version]' \\
1043
+ '1:command:->command' \\
1044
+ '*::arg:->args'
1045
+
1046
+ case "\$state" in
1047
+ command)
1048
+ _describe 'command' commands
1049
+ ;;
1050
+ args)
1051
+ case "\$words[1]" in
1052
+ state)
1053
+ _values 'subcommand' 'list[List managed resources]' 'show[Show full state]' 'clear[Clear state]'
1054
+ ;;
1055
+ env)
1056
+ _values 'subcommand' 'list[List environments]' 'show[Show current environment]'
1057
+ ;;
1058
+ plan)
1059
+ _arguments '--config[Config file]:file:_files' '--out[Output file]:file:_files' '--json[JSON output]'
1060
+ ;;
1061
+ apply)
1062
+ _arguments '--config[Config file]:file:_files' '-y[Auto-approve]' '--target[Target resources]:resource'
1063
+ ;;
1064
+ esac
1065
+ ;;
1066
+ esac
1067
+ }
1068
+ compdef _tsctl tsctl`);
1069
+ break;
1070
+
1071
+ case "fish":
1072
+ console.log(`# tsctl fish completion
1073
+ # Save to ~/.config/fish/completions/tsctl.fish
1074
+ complete -c tsctl -n '__fish_use_subcommand' -a 'init' -d 'Initialize a new project'
1075
+ complete -c tsctl -n '__fish_use_subcommand' -a 'validate' -d 'Validate config file'
1076
+ complete -c tsctl -n '__fish_use_subcommand' -a 'plan' -d 'Show planned changes'
1077
+ complete -c tsctl -n '__fish_use_subcommand' -a 'apply' -d 'Apply changes to Typesense'
1078
+ complete -c tsctl -n '__fish_use_subcommand' -a 'destroy' -d 'Destroy all managed resources'
1079
+ complete -c tsctl -n '__fish_use_subcommand' -a 'import' -d 'Import existing resources'
1080
+ complete -c tsctl -n '__fish_use_subcommand' -a 'drift' -d 'Detect drift'
1081
+ complete -c tsctl -n '__fish_use_subcommand' -a 'migrate' -d 'Blue/green migration'
1082
+ complete -c tsctl -n '__fish_use_subcommand' -a 'env' -d 'Manage environments'
1083
+ complete -c tsctl -n '__fish_use_subcommand' -a 'state' -d 'Manage state'
1084
+ complete -c tsctl -n '__fish_use_subcommand' -a 'completion' -d 'Generate completions'
1085
+ complete -c tsctl -l env -d 'Environment to use'
1086
+ complete -c tsctl -n '__fish_seen_subcommand_from plan' -l config -d 'Config file'
1087
+ complete -c tsctl -n '__fish_seen_subcommand_from plan' -l json -d 'JSON output'
1088
+ complete -c tsctl -n '__fish_seen_subcommand_from plan' -l out -d 'Output file'
1089
+ complete -c tsctl -n '__fish_seen_subcommand_from apply' -l config -d 'Config file'
1090
+ complete -c tsctl -n '__fish_seen_subcommand_from apply' -s y -l yes -d 'Auto-approve'
1091
+ complete -c tsctl -n '__fish_seen_subcommand_from apply' -s t -l target -d 'Target resources'
1092
+ complete -c tsctl -n '__fish_seen_subcommand_from state' -a 'list show clear'
1093
+ complete -c tsctl -n '__fish_seen_subcommand_from env' -a 'list show'`);
1094
+ break;
1095
+
1096
+ default:
1097
+ console.error(`Unknown shell: ${shell}. Supported: bash, zsh, fish`);
1098
+ process.exit(1);
1099
+ }
1100
+ });
1101
+
1102
+ // ============================================================================
1103
+ // Run CLI
1104
+ // ============================================================================
1105
+
1106
+ program.parse();