@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.
- package/README.md +735 -0
- package/bin/tsctl.js +2 -0
- package/package.json +65 -0
- package/src/__tests__/analyticsrules.test.ts +303 -0
- package/src/__tests__/apikeys.test.ts +223 -0
- package/src/__tests__/apply.test.ts +245 -0
- package/src/__tests__/client.test.ts +48 -0
- package/src/__tests__/collection-advanced.test.ts +274 -0
- package/src/__tests__/config-loader.test.ts +217 -0
- package/src/__tests__/curationsets.test.ts +190 -0
- package/src/__tests__/helpers.ts +17 -0
- package/src/__tests__/import-drift.test.ts +231 -0
- package/src/__tests__/migrate-advanced.test.ts +197 -0
- package/src/__tests__/migrate.test.ts +220 -0
- package/src/__tests__/plan-new-resources.test.ts +258 -0
- package/src/__tests__/plan.test.ts +337 -0
- package/src/__tests__/presets.test.ts +97 -0
- package/src/__tests__/resources.test.ts +592 -0
- package/src/__tests__/setup.ts +77 -0
- package/src/__tests__/state.test.ts +312 -0
- package/src/__tests__/stemmingdictionaries.test.ts +111 -0
- package/src/__tests__/stopwords.test.ts +109 -0
- package/src/__tests__/synonymsets.test.ts +170 -0
- package/src/__tests__/types.test.ts +416 -0
- package/src/apply/index.ts +336 -0
- package/src/cli/index.ts +1106 -0
- package/src/client/index.ts +55 -0
- package/src/config/loader.ts +158 -0
- package/src/index.ts +45 -0
- package/src/migrate/index.ts +220 -0
- package/src/plan/index.ts +1333 -0
- package/src/resources/alias.ts +59 -0
- package/src/resources/analyticsrule.ts +134 -0
- package/src/resources/apikey.ts +203 -0
- package/src/resources/collection.ts +424 -0
- package/src/resources/curationset.ts +155 -0
- package/src/resources/index.ts +11 -0
- package/src/resources/override.ts +174 -0
- package/src/resources/preset.ts +83 -0
- package/src/resources/stemmingdictionary.ts +103 -0
- package/src/resources/stopword.ts +100 -0
- package/src/resources/synonym.ts +152 -0
- package/src/resources/synonymset.ts +144 -0
- package/src/state/index.ts +206 -0
- package/src/types/index.ts +451 -0
package/src/cli/index.ts
ADDED
|
@@ -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();
|