@simplens/onboard 1.0.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 (94) hide show
  1. package/README.md +214 -0
  2. package/dist/__tests__/errors.test.d.ts +2 -0
  3. package/dist/__tests__/errors.test.d.ts.map +1 -0
  4. package/dist/__tests__/errors.test.js +125 -0
  5. package/dist/__tests__/errors.test.js.map +1 -0
  6. package/dist/__tests__/utils.test.d.ts +2 -0
  7. package/dist/__tests__/utils.test.d.ts.map +1 -0
  8. package/dist/__tests__/utils.test.js +105 -0
  9. package/dist/__tests__/utils.test.js.map +1 -0
  10. package/dist/__tests__/validators.test.d.ts +2 -0
  11. package/dist/__tests__/validators.test.d.ts.map +1 -0
  12. package/dist/__tests__/validators.test.js +148 -0
  13. package/dist/__tests__/validators.test.js.map +1 -0
  14. package/dist/config/constants.d.ts +69 -0
  15. package/dist/config/constants.d.ts.map +1 -0
  16. package/dist/config/constants.js +79 -0
  17. package/dist/config/constants.js.map +1 -0
  18. package/dist/config/index.d.ts +2 -0
  19. package/dist/config/index.d.ts.map +1 -0
  20. package/dist/config/index.js +2 -0
  21. package/dist/config/index.js.map +1 -0
  22. package/dist/env-config.d.ts +33 -0
  23. package/dist/env-config.d.ts.map +1 -0
  24. package/dist/env-config.js +285 -0
  25. package/dist/env-config.js.map +1 -0
  26. package/dist/index.d.ts +3 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +153 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/infra.d.ts +31 -0
  31. package/dist/infra.d.ts.map +1 -0
  32. package/dist/infra.js +267 -0
  33. package/dist/infra.js.map +1 -0
  34. package/dist/plugins.d.ts +35 -0
  35. package/dist/plugins.d.ts.map +1 -0
  36. package/dist/plugins.js +164 -0
  37. package/dist/plugins.js.map +1 -0
  38. package/dist/services.d.ts +52 -0
  39. package/dist/services.d.ts.map +1 -0
  40. package/dist/services.js +158 -0
  41. package/dist/services.js.map +1 -0
  42. package/dist/templates.d.ts +3 -0
  43. package/dist/templates.d.ts.map +1 -0
  44. package/dist/templates.js +202 -0
  45. package/dist/templates.js.map +1 -0
  46. package/dist/types/domain.d.ts +119 -0
  47. package/dist/types/domain.d.ts.map +1 -0
  48. package/dist/types/domain.js +5 -0
  49. package/dist/types/domain.js.map +1 -0
  50. package/dist/types/errors.d.ts +69 -0
  51. package/dist/types/errors.d.ts.map +1 -0
  52. package/dist/types/errors.js +129 -0
  53. package/dist/types/errors.js.map +1 -0
  54. package/dist/types/index.d.ts +3 -0
  55. package/dist/types/index.d.ts.map +1 -0
  56. package/dist/types/index.js +3 -0
  57. package/dist/types/index.js.map +1 -0
  58. package/dist/utils/index.d.ts +2 -0
  59. package/dist/utils/index.d.ts.map +1 -0
  60. package/dist/utils/index.js +2 -0
  61. package/dist/utils/index.js.map +1 -0
  62. package/dist/utils/logger.d.ts +54 -0
  63. package/dist/utils/logger.d.ts.map +1 -0
  64. package/dist/utils/logger.js +92 -0
  65. package/dist/utils/logger.js.map +1 -0
  66. package/dist/utils.d.ts +32 -0
  67. package/dist/utils.d.ts.map +1 -0
  68. package/dist/utils.js +79 -0
  69. package/dist/utils.js.map +1 -0
  70. package/dist/validators.d.ts +93 -0
  71. package/dist/validators.d.ts.map +1 -0
  72. package/dist/validators.js +180 -0
  73. package/dist/validators.js.map +1 -0
  74. package/package.json +45 -0
  75. package/src/__tests__/errors.test.ts +187 -0
  76. package/src/__tests__/utils.test.ts +142 -0
  77. package/src/__tests__/validators.test.ts +195 -0
  78. package/src/config/constants.ts +86 -0
  79. package/src/config/index.ts +1 -0
  80. package/src/env-config.ts +320 -0
  81. package/src/index.ts +203 -0
  82. package/src/infra.ts +300 -0
  83. package/src/plugins.ts +190 -0
  84. package/src/services.ts +190 -0
  85. package/src/templates.ts +203 -0
  86. package/src/types/domain.ts +127 -0
  87. package/src/types/errors.ts +173 -0
  88. package/src/types/index.ts +2 -0
  89. package/src/utils/index.ts +1 -0
  90. package/src/utils/logger.ts +118 -0
  91. package/src/utils.ts +105 -0
  92. package/src/validators.ts +192 -0
  93. package/tsconfig.json +19 -0
  94. package/vitest.config.ts +20 -0
package/src/index.ts ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import path from 'path';
5
+ import inquirer from 'inquirer';
6
+ import { displayBanner, logSuccess, logError, logInfo, initLogger, logDebug } from './utils.js';
7
+ import { validatePrerequisites } from './validators.js';
8
+ import {
9
+ promptInfraServices,
10
+ generateInfraCompose,
11
+ writeAppCompose,
12
+ } from './infra.js';
13
+ import {
14
+ promptEnvVariables,
15
+ generateEnvFile,
16
+ appendPluginEnv,
17
+ } from './env-config.js';
18
+ import {
19
+ fetchAvailablePlugins,
20
+ promptPluginSelection,
21
+ generatePluginConfig,
22
+ parseConfigCredentials,
23
+ promptPluginCredentials,
24
+ } from './plugins.js';
25
+ import {
26
+ promptStartServices,
27
+ startInfraServices,
28
+ waitForInfraHealth,
29
+ startAppServices,
30
+ displayServiceStatus,
31
+ } from './services.js';
32
+
33
+ const program = new Command();
34
+
35
+ program
36
+ .name('@simplens/onboard')
37
+ .description('A CLI tool to setup a SimpleNS instance on your machine/server')
38
+ .version('1.0.0')
39
+ .option('--infra', 'Setup infrastructure services (MongoDB, Kafka, Redis, etc.)')
40
+ .option('--env <mode>', 'Environment setup mode: "default" or "interactive"')
41
+ .option('--dir <path>', 'Target directory for setup')
42
+ .parse(process.argv);
43
+
44
+ const options = program.opts();
45
+
46
+ /**
47
+ * Prompt for setup options if not provided via CLI args
48
+ */
49
+ async function promptSetupOptions(): Promise<{
50
+ infra: boolean;
51
+ envMode: 'default' | 'interactive';
52
+ targetDir: string;
53
+ }> {
54
+ const answers = await inquirer.prompt([
55
+ {
56
+ type: 'confirm',
57
+ name: 'infra',
58
+ message: 'Do you want to setup infrastructure services (MongoDB, Kafka, Redis, etc.)?',
59
+ default: true,
60
+ when: () => options.infra === undefined,
61
+ },
62
+ {
63
+ type: 'list',
64
+ name: 'envMode',
65
+ message: 'Select environment configuration mode:',
66
+ choices: [
67
+ { name: 'Default (use preset values, prompt only for critical)', value: 'default' },
68
+ { name: 'Interactive (prompt for all variables)', value: 'interactive' },
69
+ ],
70
+ default: 'default',
71
+ when: () => !options.env,
72
+ },
73
+ {
74
+ type: 'input',
75
+ name: 'targetDir',
76
+ message: 'Target directory for setup:',
77
+ default: process.cwd(),
78
+ when: () => !options.dir,
79
+ },
80
+ ]);
81
+
82
+ return {
83
+ infra: options.infra !== undefined ? options.infra : answers.infra,
84
+ envMode: options.env || answers.envMode || 'default',
85
+ targetDir: options.dir || answers.targetDir || process.cwd(),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Main onboarding workflow
91
+ */
92
+ async function main() {
93
+ try {
94
+ // Display banner
95
+ displayBanner();
96
+
97
+ // Initialize logger based on CLI flags
98
+ const opts = program.opts();
99
+ initLogger({
100
+ verbose: opts.verbose || false,
101
+ debug: opts.debug || false,
102
+ logFile: opts.debug ? path.join(process.cwd(), 'onboard-debug.log') : undefined,
103
+ });
104
+
105
+ logDebug('Logger initialized');
106
+ logDebug(`CLI options: ${JSON.stringify(opts)}`);
107
+
108
+ // Prompt for setup options if not provided
109
+ const setupOptions = await promptSetupOptions();
110
+
111
+ // Get target directory
112
+ const targetDir = path.resolve(setupOptions.targetDir);
113
+ logInfo(`Target directory: ${targetDir}`);
114
+ logDebug(`Resolved target directory: ${targetDir}`);
115
+
116
+ // Step 1: Validate prerequisites
117
+ await validatePrerequisites();
118
+
119
+ // Step 2: Infrastructure setup (if --infra flag is provided)
120
+ let selectedInfraServices: string[] = [];
121
+ let infraHost = 'host.docker.internal';
122
+
123
+ if (setupOptions.infra) {
124
+ logInfo('\n🏗️ Infrastructure Setup\n');
125
+ selectedInfraServices = await promptInfraServices();
126
+ infraHost = await generateInfraCompose(targetDir, selectedInfraServices);
127
+ } else {
128
+ logInfo('\n⏭️ Skipping infrastructure setup (use --infra to enable)');
129
+ }
130
+
131
+ // Always write app docker-compose
132
+ logInfo('\n📦 Application Services Setup\n');
133
+ await writeAppCompose(targetDir);
134
+
135
+ // Step 3: Environment configuration
136
+ logInfo('\n⚙️ Environment Configuration\n');
137
+ const envMode = setupOptions.envMode;
138
+ const envVars = await promptEnvVariables(envMode, selectedInfraServices, infraHost);
139
+ await generateEnvFile(targetDir, envVars);
140
+
141
+ // Step 4: Plugin installation
142
+ logInfo('\n🔌 Plugin Installation\n');
143
+ const availablePlugins = await fetchAvailablePlugins();
144
+ const selectedPlugins = await promptPluginSelection(availablePlugins);
145
+
146
+ if (selectedPlugins.length > 0) {
147
+ await generatePluginConfig(targetDir, selectedPlugins);
148
+
149
+ // Extract and prompt for plugin credentials
150
+ const configPath = path.join(targetDir, 'simplens.config.yaml');
151
+ const credentialKeys = await parseConfigCredentials(configPath);
152
+
153
+ if (credentialKeys.length > 0) {
154
+ const pluginCreds = await promptPluginCredentials(credentialKeys);
155
+ await appendPluginEnv(targetDir, pluginCreds);
156
+ }
157
+ }
158
+
159
+ // Step 5: Service orchestration
160
+ logInfo('\n🚀 Service Orchestration\n');
161
+ const shouldStart = await promptStartServices();
162
+
163
+ if (shouldStart) {
164
+ // Start infra services first (if --infra was used)
165
+ if (setupOptions.infra && selectedInfraServices.length > 0) {
166
+ await startInfraServices(targetDir);
167
+ await waitForInfraHealth(targetDir);
168
+ }
169
+
170
+ // Start app services
171
+ await startAppServices(targetDir);
172
+
173
+ // Display service status
174
+ await displayServiceStatus();
175
+ } else {
176
+ logInfo('Services not started. You can start them later with:');
177
+ if (setupOptions.infra) {
178
+ console.log(' docker-compose -f docker-compose.infra.yaml up -d');
179
+ }
180
+ console.log(' docker-compose up -d\n');
181
+ }
182
+
183
+ // Final success message
184
+ logSuccess('\n🎉 SimpleNS onboarding completed successfully!\n');
185
+
186
+ } catch (error: any) {
187
+ // Import at top of file
188
+ const { formatErrorForUser } = await import('./types/errors.js');
189
+
190
+ console.log('\n' + formatErrorForUser(error));
191
+
192
+ // Log full error to stderr for debugging
193
+ if (process.env.DEBUG) {
194
+ console.error('\nFull error details:');
195
+ console.error(error);
196
+ }
197
+
198
+ process.exit(1);
199
+ }
200
+ }
201
+
202
+ // Run main function
203
+ main();
package/src/infra.ts ADDED
@@ -0,0 +1,300 @@
1
+ import inquirer from 'inquirer';
2
+ import { detectOS } from './validators.js';
3
+ import { INFRA_COMPOSE_TEMPLATE, APP_COMPOSE_TEMPLATE } from './templates.js';
4
+ import { writeFile, logInfo, logSuccess, logWarning } from './utils.js';
5
+ import path from 'path';
6
+ import type { InfraService } from './types/domain.js';
7
+
8
+ const INFRA_SERVICES: InfraService[] = [
9
+ { name: 'MongoDB (Database)', value: 'mongo', checked: true },
10
+ { name: 'Kafka (Message Queue)', value: 'kafka', checked: true },
11
+ { name: 'Kafka UI (Dashboard)', value: 'kafka-ui', checked: true },
12
+ { name: 'Redis (Cache)', value: 'redis', checked: true },
13
+ { name: 'Loki (Log Aggregation)', value: 'loki', checked: false },
14
+ { name: 'Grafana (Observability Dashboard)', value: 'grafana', checked: false },
15
+ ];
16
+
17
+ /**
18
+ * Prompts user to select which infrastructure services to deploy.
19
+ * Services include MongoDB, Kafka, Redis, Loki, and Grafana.
20
+ *
21
+ * @returns Array of selected service IDs (e.g., ['mongo', 'kafka', 'redis'])
22
+ * @throws Error if no services are selected
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const services = await promptInfraServices();
27
+ * // Returns: ['mongo', 'kafka', 'kafka-ui', 'redis']
28
+ * ```
29
+ */
30
+ export async function promptInfraServices(): Promise<string[]> {
31
+ const answer = await inquirer.prompt<{ services: string[] }>([
32
+ {
33
+ type: 'checkbox',
34
+ name: 'services',
35
+ message: 'Select infrastructure services to run:',
36
+ choices: INFRA_SERVICES,
37
+ validate: (input: string[]) => {
38
+ if (input.length === 0) {
39
+ return 'Please select at least one service';
40
+ }
41
+ return true;
42
+ },
43
+ },
44
+ ]);
45
+
46
+ return answer.services;
47
+ }
48
+
49
+ /**
50
+ * Get infrastructure host based on OS
51
+ */
52
+ export function getInfraHost(): string {
53
+ // const os = detectOS();
54
+
55
+ // if (os === 'linux') {
56
+ // logWarning('Linux detected: host.docker.internal does not work by default.');
57
+
58
+ // const answer = await inquirer.prompt<{ hostChoice: string; customHost?: string }>([
59
+ // {
60
+ // type: 'list',
61
+ // name: 'hostChoice',
62
+ // message: 'Select host configuration:',
63
+ // choices: [
64
+ // { name: 'Use Docker bridge IP (172.17.0.1)', value: '172.17.0.1' },
65
+ // { name: 'Enter custom IP/hostname', value: 'custom' },
66
+ // ],
67
+ // },
68
+ // {
69
+ // type: 'input',
70
+ // name: 'customHost',
71
+ // message: 'Enter your machine IP or hostname:',
72
+ // when: (answers) => answers.hostChoice === 'custom',
73
+ // validate: (input: string) => {
74
+ // if (!input || input.trim().length === 0) {
75
+ // return 'Please enter a valid IP or hostname';
76
+ // }
77
+ // return true;
78
+ // },
79
+ // },
80
+ // ]);
81
+
82
+ // return answer.hostChoice === 'custom' ? answer.customHost! : answer.hostChoice;
83
+ // }
84
+
85
+ // For Windows, linux and macOS, use host.docker.internal
86
+ return 'host.docker.internal';
87
+ }
88
+
89
+ /**
90
+ * Service chunk definitions - each service as a complete block
91
+ */
92
+ const SERVICE_CHUNKS: Record<string, string> = {
93
+ 'mongo': ` mongo:
94
+ image: mongo:7.0
95
+ container_name: mongo
96
+ command: [ "--replSet", "rs0", "--bind_ip_all", "--port", "27017" ]
97
+ ports:
98
+ - 27017:27017
99
+ extra_hosts:
100
+ - "host.docker.internal:host-gateway"
101
+ healthcheck:
102
+ test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'{{INFRA_HOST}}:27017'}]}) }" | mongosh --port 27017 --quiet
103
+ interval: 5s
104
+ timeout: 30s
105
+ start_period: 0s
106
+ start_interval: 1s
107
+ retries: 30
108
+ volumes:
109
+ - "mongo_data:/data/db"
110
+ - "mongo_config:/data/configdb"`,
111
+
112
+ 'kafka': ` kafka:
113
+ image: apache/kafka-native
114
+ container_name: kafka
115
+ ports:
116
+ - "9092:9092"
117
+ environment:
118
+ # Configure listeners for both docker and host communication
119
+ KAFKA_LISTENERS: CONTROLLER://localhost:9091,HOST://0.0.0.0:9092,DOCKER://0.0.0.0:9093
120
+ KAFKA_ADVERTISED_LISTENERS: HOST://{{INFRA_HOST}}:9092,DOCKER://kafka:9093
121
+ KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,DOCKER:PLAINTEXT,HOST:PLAINTEXT
122
+
123
+ # Settings required for KRaft mode
124
+ KAFKA_NODE_ID: 1
125
+ KAFKA_PROCESS_ROLES: broker,controller
126
+ KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
127
+ KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9091
128
+
129
+ # Listener to use for broker-to-broker communication
130
+ KAFKA_INTER_BROKER_LISTENER_NAME: DOCKER
131
+
132
+ # Required for a single node cluster
133
+ KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
134
+
135
+ # Disable auto-topic creation - API server will create topics with correct partitions
136
+ KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
137
+ volumes:
138
+ - "kafka_data:/var/lib/kafka/data"`,
139
+
140
+ 'kafka-ui': ` kafka-ui:
141
+ image: kafbat/kafka-ui:main
142
+ container_name: kafka-ui
143
+ ports:
144
+ - 8080:8080
145
+ environment:
146
+ DYNAMIC_CONFIG_ENABLED: "true"
147
+ KAFKA_CLUSTERS_0_NAME: local
148
+ KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9093
149
+ depends_on:
150
+ - kafka`,
151
+
152
+ 'redis': ` redis:
153
+ image: redis:7-alpine
154
+ container_name: redis
155
+ ports:
156
+ - "6379:6379"
157
+ command: redis-server --appendonly yes
158
+ volumes:
159
+ - "redis_data:/data"
160
+ healthcheck:
161
+ test: [ "CMD", "redis-cli", "ping" ]
162
+ interval: 5s
163
+ timeout: 3s
164
+ retries: 5`,
165
+
166
+ 'loki': ` loki:
167
+ image: grafana/loki:2.9.0
168
+ container_name: loki
169
+ ports:
170
+ - "3100:3100"
171
+ command: -config.file=/etc/loki/local-config.yaml
172
+ volumes:
173
+ - "loki_data:/loki"
174
+ healthcheck:
175
+ test: [ "CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:3100/ready || exit 1" ]
176
+ interval: 10s
177
+ timeout: 5s
178
+ retries: 5`,
179
+
180
+ 'grafana': ` grafana:
181
+ image: grafana/grafana:10.2.0
182
+ container_name: grafana
183
+ ports:
184
+ - "3001:3000"
185
+ environment:
186
+ - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
187
+ - GF_AUTH_ANONYMOUS_ENABLED=true
188
+ - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
189
+ - GF_SECURITY_ADMIN_PASSWORD=admin
190
+ volumes:
191
+ - "grafana_data:/var/lib/grafana"
192
+ depends_on:
193
+ loki:
194
+ condition: service_healthy`,
195
+ };
196
+
197
+ /**
198
+ * Service-to-volumes mapping
199
+ */
200
+ const SERVICE_VOLUMES: Record<string, string[]> = {
201
+ 'mongo': ['mongo_data', 'mongo_config'],
202
+ 'kafka': ['kafka_data'],
203
+ 'kafka-ui': [],
204
+ 'redis': ['redis_data'],
205
+ 'loki': ['loki_data'],
206
+ 'grafana': ['grafana_data'],
207
+ };
208
+
209
+ /**
210
+ * Build docker-compose content from selected services
211
+ */
212
+ function buildInfraCompose(selectedServices: string[]): string {
213
+ // Header
214
+ const header = `# ============================================
215
+ # INFRA_HOST: Set this in .env to your machine's IP/hostname when running
216
+ # infrastructure on a separate system from application services.
217
+ # Default: host.docker.internal (for same-system deployment)
218
+ # ============================================
219
+
220
+ services:
221
+ # ============================================
222
+ # Infrastructure Services
223
+ # ============================================`;
224
+
225
+ // Assemble selected service chunks
226
+ const serviceBlocks: string[] = [];
227
+ for (const service of selectedServices) {
228
+ if (SERVICE_CHUNKS[service]) {
229
+ serviceBlocks.push(SERVICE_CHUNKS[service]);
230
+ }
231
+ }
232
+
233
+ // Collect volumes for selected services
234
+ const volumeSet = new Set<string>();
235
+ for (const service of selectedServices) {
236
+ const volumes = SERVICE_VOLUMES[service] || [];
237
+ volumes.forEach(v => volumeSet.add(v));
238
+ }
239
+
240
+ // Build volumes section
241
+ const volumeLines: string[] = ['', 'volumes:'];
242
+ for (const volume of Array.from(volumeSet).sort()) {
243
+ volumeLines.push(` ${volume}:`);
244
+ }
245
+
246
+ // Build networks section with custom default network name
247
+ const networkLines: string[] = ['', 'networks:', ' default:', ' name: simplens'];
248
+
249
+ // Combine all parts
250
+ return [
251
+ header,
252
+ serviceBlocks.join('\n\n'),
253
+ volumeLines.join('\n'),
254
+ networkLines.join('\n'),
255
+ ].join('\n');
256
+ }
257
+
258
+ /**
259
+ * Replace INFRA_HOST placeholder in template
260
+ */
261
+ export function replaceInfraHost(template: string, infraHost: string): string {
262
+ return template.replace(/\{\{INFRA_HOST\}\}/g, infraHost);
263
+ }
264
+
265
+ /**
266
+ * Generate and write docker-compose.infra.yaml
267
+ */
268
+ export async function generateInfraCompose(
269
+ targetDir: string,
270
+ selectedServices: string[]
271
+ ): Promise<string> {
272
+ logInfo('Generating docker-compose.infra.yaml...');
273
+
274
+ // Get infrastructure host
275
+ const infraHost = getInfraHost();
276
+ logSuccess(`Using infrastructure host: ${infraHost}`);
277
+
278
+ // Build compose content from service chunks
279
+ let infraContent = buildInfraCompose(selectedServices);
280
+
281
+ // Replace host placeholder
282
+ infraContent = replaceInfraHost(infraContent, infraHost);
283
+
284
+ // Write infrastructure compose file
285
+ const infraPath = path.join(targetDir, 'docker-compose.infra.yaml');
286
+ await writeFile(infraPath, infraContent);
287
+ logSuccess('Generated docker-compose.infra.yaml');
288
+
289
+ // Return infraHost for env configuration
290
+ return infraHost;
291
+ }
292
+
293
+ /**
294
+ * Write app docker-compose.yaml
295
+ */
296
+ export async function writeAppCompose(targetDir: string): Promise<void> {
297
+ const appPath = path.join(targetDir, 'docker-compose.yaml');
298
+ await writeFile(appPath, APP_COMPOSE_TEMPLATE);
299
+ logSuccess('Generated docker-compose.yaml');
300
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,190 @@
1
+ import inquirer from 'inquirer';
2
+ import { execa } from 'execa';
3
+ import yaml from 'js-yaml';
4
+ import { readFile, logInfo, logSuccess, logError, logWarning } from './utils.js';
5
+ import path from 'path';
6
+ import type { PluginInfo, SimplensConfig } from './types/domain.js';
7
+
8
+ /**
9
+ * Fetches available SimpleNS plugins using the config-gen CLI tool.
10
+ * Falls back to default plugins if fetching fails.
11
+ *
12
+ * @returns Array of available plugin information
13
+ *
14
+ * @remarks
15
+ * Uses `npx @simplens/config-gen list --official` to fetch plugins.
16
+ * Default fallback plugins: mock, nodemailer-gmail, resend
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const plugins = await fetchAvailablePlugins();
21
+ * // Returns: [{ package: '@simplens/mock', name: 'Mock Provider', ... }, ...]
22
+ * ```
23
+ */
24
+ export async function fetchAvailablePlugins(): Promise<PluginInfo[]> {
25
+ logInfo('Fetching available plugins...');
26
+
27
+ try {
28
+ // Execute config-gen list command
29
+ const { stdout } = await execa('npx', ['@simplens/config-gen', 'list', '--official'], {
30
+ stdio: 'pipe',
31
+ });
32
+
33
+ // Parse output to extract plugins
34
+ // Expected format: " @simplens/package-name Plugin Name"
35
+ const plugins: PluginInfo[] = [];
36
+ const lines = stdout.split('\n');
37
+
38
+ for (const line of lines) {
39
+ // Match plugin lines (starts with @simplens/)
40
+ const match = line.match(/^\s+(@simplens\/[\w-]+)\s+(.+)$/);
41
+ if (match) {
42
+ const [, packageName, rest] = match;
43
+ // Extract name and description
44
+ const parts = rest.split(/\s{2,}/); // Split by multiple spaces
45
+ plugins.push({
46
+ package: packageName.trim(),
47
+ name: parts[0]?.trim() || packageName,
48
+ description: parts[1]?.trim() || '',
49
+ });
50
+ }
51
+ }
52
+
53
+ logSuccess(`Found ${plugins.length} available plugins`);
54
+ return plugins;
55
+ } catch (error: any) {
56
+ logWarning('Could not fetch plugins list. Using defaults.');
57
+ // Return default plugins as fallback
58
+ return [
59
+ { package: '@simplens/mock', name: 'Mock Provider', description: 'Mock notification provider for testing' },
60
+ { package: '@simplens/nodemailer-gmail', name: 'Gmail', description: 'Send emails via Gmail' },
61
+ { package: '@simplens/resend', name: 'Resend', description: 'Send emails via Resend' },
62
+ ];
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Prompt user to select plugins
68
+ */
69
+ export async function promptPluginSelection(availablePlugins: PluginInfo[]): Promise<string[]> {
70
+ if (availablePlugins.length === 0) {
71
+ logWarning('No plugins available to select.');
72
+ return [];
73
+ }
74
+
75
+ const answer = await inquirer.prompt<{ plugins: string[] }>([
76
+ {
77
+ type: 'checkbox',
78
+ name: 'plugins',
79
+ message: 'Select plugins to install:',
80
+ choices: availablePlugins.map(p => ({
81
+ name: `${p.name} (${p.package}) - ${p.description}`,
82
+ value: p.package,
83
+ checked: p.package === '@simplens/mock', // Mock checked by default
84
+ })),
85
+ },
86
+ ]);
87
+
88
+ return answer.plugins;
89
+ }
90
+
91
+ /**
92
+ * Generate plugin configuration using config-gen
93
+ */
94
+ export async function generatePluginConfig(
95
+ targetDir: string,
96
+ selectedPlugins: string[]
97
+ ): Promise<void> {
98
+ if (selectedPlugins.length === 0) {
99
+ logInfo('No plugins selected, skipping config generation.');
100
+ return;
101
+ }
102
+
103
+ logInfo(`Generating configuration for ${selectedPlugins.length} plugin(s)...`);
104
+
105
+ try {
106
+ const configPath = path.join(targetDir, 'simplens.config.yaml');
107
+
108
+ // Execute config-gen for all selected plugins
109
+ // Use relative path to avoid WSL path issues when npx runs Windows binaries
110
+ await execa(
111
+ 'npx',
112
+ ['@simplens/config-gen', 'gen', ...selectedPlugins, '-o', 'simplens.config.yaml'],
113
+ { cwd: targetDir, stdio: 'inherit' }
114
+ );
115
+
116
+ logSuccess('Generated simplens.config.yaml');
117
+ } catch (error: any) {
118
+ logError('Failed to generate plugin configuration');
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Parse simplens.config.yaml to extract credential keys
125
+ */
126
+ export async function parseConfigCredentials(configPath: string): Promise<string[]> {
127
+ try {
128
+ const content = await readFile(configPath);
129
+ const config: any = yaml.load(content);
130
+
131
+ const credentialKeys = new Set<string>();
132
+
133
+ // Extract credential keys from providers (ONLY from credentials, not optionalConfig)
134
+ if (config.providers && Array.isArray(config.providers)) {
135
+ for (const provider of config.providers) {
136
+ if (provider.credentials && typeof provider.credentials === 'object') {
137
+ for (const [key, value] of Object.entries(provider.credentials)) {
138
+ // Extract env var name from ${ENV_VAR} format
139
+ if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
140
+ const envVar = value.slice(2, -1);
141
+ credentialKeys.add(envVar);
142
+ }
143
+ }
144
+ }
145
+ // NOTE: We intentionally skip optionalConfig - those are optional!
146
+ }
147
+ }
148
+
149
+ return Array.from(credentialKeys);
150
+ } catch (error) {
151
+ logWarning('Could not parse config file for credentials');
152
+ return [];
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Prompt for plugin-specific credentials
158
+ */
159
+ export async function promptPluginCredentials(credentialKeys: string[]): Promise<Map<string, string>> {
160
+ if (credentialKeys.length === 0) {
161
+ logInfo('No plugin credentials required.');
162
+ return new Map();
163
+ }
164
+
165
+ logInfo('Configuring plugin credentials...');
166
+
167
+ const result = new Map<string, string>();
168
+
169
+ for (const key of credentialKeys) {
170
+ const answer = await inquirer.prompt<{ value: string }>([
171
+ {
172
+ type: key.toLowerCase().includes('password') || key.toLowerCase().includes('key')
173
+ ? 'password'
174
+ : 'input',
175
+ name: 'value',
176
+ message: `${key}:`,
177
+ validate: (input: string) => {
178
+ if (!input || input.trim().length === 0) {
179
+ return `${key} is required`;
180
+ }
181
+ return true;
182
+ },
183
+ },
184
+ ]);
185
+ result.set(key, answer.value);
186
+ }
187
+
188
+ logSuccess('Plugin credentials configured');
189
+ return result;
190
+ }