@simplens/onboard 1.0.8 → 1.0.10

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/src/plugins.ts DELETED
@@ -1,221 +0,0 @@
1
- import { execa } from 'execa';
2
- import yaml from 'js-yaml';
3
- import crypto from 'crypto';
4
- import { readFile, logInfo, logSuccess, logError, logWarning } from './utils.js';
5
- import { multiselect, text, password } from '@clack/prompts';
6
- import { handleCancel, spinner } from './ui.js';
7
- import path from 'path';
8
- import type { PluginInfo, SimplensConfig } from './types/domain.js';
9
-
10
- /**
11
- * Fetches available SimpleNS plugins using the config-gen CLI tool.
12
- * Falls back to default plugins if fetching fails.
13
- *
14
- * @returns Array of available plugin information
15
- */
16
- export async function fetchAvailablePlugins(): Promise<PluginInfo[]> {
17
- const s = spinner();
18
- s.start('Fetching available plugins...');
19
-
20
- try {
21
- // Execute config-gen list command
22
- const { stdout } = await execa('npx', ['@simplens/config-gen', 'list', '--official'], {
23
- stdio: 'pipe',
24
- });
25
-
26
- // Parse output to extract plugins
27
- // Expected format: " @simplens/package-name Plugin Name"
28
- const plugins: PluginInfo[] = [];
29
- const lines = stdout.split('\n');
30
-
31
- for (const line of lines) {
32
- // Match plugin lines (starts with @simplens/)
33
- const match = line.match(/^\s+(@simplens\/[\w-]+)\s+(.+)$/);
34
- if (match) {
35
- const [, packageName, rest] = match;
36
- // Extract name and description
37
- const parts = rest.split(/\s{2,}/); // Split by multiple spaces
38
- plugins.push({
39
- package: packageName.trim(),
40
- name: parts[0]?.trim() || packageName,
41
- description: parts[1]?.trim() || '',
42
- });
43
- }
44
- }
45
-
46
- s.stop(`Found ${plugins.length} available plugins`);
47
- return plugins;
48
- } catch (error: unknown) {
49
- s.stop('Could not fetch plugins list. Using defaults.');
50
- logWarning('Falling back to default plugin list.');
51
- // Return default plugins as fallback
52
- return [
53
- { package: '@simplens/mock', name: 'Mock Provider', description: 'Mock notification provider for testing' },
54
- { package: '@simplens/nodemailer-gmail', name: 'Gmail', description: 'Send emails via Gmail' },
55
- { package: '@simplens/resend', name: 'Resend', description: 'Send emails via Resend' },
56
- ];
57
- }
58
- }
59
-
60
- /**
61
- * Prompt user to select plugins
62
- */
63
- export async function promptPluginSelection(availablePlugins: PluginInfo[]): Promise<string[]> {
64
- if (availablePlugins.length === 0) {
65
- logWarning('No plugins available to select.');
66
- return [];
67
- }
68
-
69
- const selected = await multiselect({
70
- message: 'Select plugins to install (Space to select, Enter to confirm):',
71
- options: availablePlugins.map(p => ({
72
- value: p.package,
73
- label: `${p.name} (${p.package})`,
74
- hint: p.description,
75
- })),
76
- initialValues: availablePlugins
77
- .filter(p => p.package === '@simplens/mock')
78
- .map(p => p.package),
79
- withGuide: true,
80
- });
81
-
82
- handleCancel(selected);
83
- return selected as string[];
84
- }
85
-
86
- /**
87
- * Generate plugin configuration using config-gen
88
- */
89
- export async function generatePluginConfig(
90
- targetDir: string,
91
- selectedPlugins: string[]
92
- ): Promise<void> {
93
- if (selectedPlugins.length === 0) {
94
- logInfo('No plugins selected, skipping config generation.');
95
- return;
96
- }
97
-
98
- const s = spinner();
99
- s.start(`Generating configuration for ${selectedPlugins.length} plugin(s)...`);
100
-
101
- try {
102
- // Execute config-gen for all selected plugins
103
- // Use relative path to avoid WSL path issues when npx runs Windows binaries
104
- await execa(
105
- 'npx',
106
- ['@simplens/config-gen', 'gen', ...selectedPlugins, '-o', 'simplens.config.yaml'],
107
- { cwd: targetDir, stdio: 'pipe' }
108
- );
109
-
110
- s.stop('Generated simplens.config.yaml');
111
- } catch (error: unknown) {
112
- s.error('Failed to generate plugin configuration');
113
- throw error;
114
- }
115
- }
116
-
117
- /**
118
- * Parse simplens.config.yaml to extract credential keys
119
- */
120
- export async function parseConfigCredentials(configPath: string): Promise<string[]> {
121
- try {
122
- const content = await readFile(configPath);
123
- const config: any = yaml.load(content);
124
-
125
- const credentialKeys = new Set<string>();
126
-
127
- // Extract credential keys from providers (ONLY from credentials, not optionalConfig)
128
- if (config.providers && Array.isArray(config.providers)) {
129
- for (const provider of config.providers) {
130
- if (provider.credentials && typeof provider.credentials === 'object') {
131
- for (const [key, value] of Object.entries(provider.credentials)) {
132
- // Extract env var name from ${ENV_VAR} format
133
- if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
134
- const envVar = value.slice(2, -1);
135
- credentialKeys.add(envVar);
136
- }
137
- }
138
- }
139
- // NOTE: We intentionally skip optionalConfig - those are optional!
140
- }
141
- }
142
-
143
- return Array.from(credentialKeys);
144
- } catch (error) {
145
- logWarning('Could not parse config file for credentials');
146
- return [];
147
- }
148
- }
149
-
150
- /**
151
- * Generate default placeholder values for plugin credentials
152
- * Used in --full mode for non-interactive setup
153
- */
154
- export function generateDefaultPluginCredentials(credentialKeys: string[]): Map<string, string> {
155
- const result = new Map<string, string>();
156
-
157
- for (const key of credentialKeys) {
158
- // Generate placeholder values based on key name patterns
159
- if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
160
- result.set(key, crypto.randomBytes(16).toString('base64'));
161
- } else if (key.toLowerCase().includes('apikey') || key.toLowerCase().includes('api_key')) {
162
- result.set(key, `sk_${crypto.randomBytes(24).toString('base64').slice(0, 32)}`);
163
- } else if (key.toLowerCase().includes('token')) {
164
- result.set(key, crypto.randomBytes(32).toString('hex'));
165
- } else if (key.toLowerCase().includes('email') || key.toLowerCase().includes('user')) {
166
- result.set(key, 'CHANGE_ME@example.com');
167
- } else {
168
- // Generic placeholder
169
- result.set(key, 'CHANGE_ME');
170
- }
171
- }
172
-
173
- return result;
174
- }
175
-
176
- /**
177
- * Prompt for plugin-specific credentials
178
- */
179
- export async function promptPluginCredentials(credentialKeys: string[]): Promise<Map<string, string>> {
180
- if (credentialKeys.length === 0) {
181
- logInfo('No plugin credentials required.');
182
- return new Map();
183
- }
184
-
185
- logInfo('Configuring plugin credentials...');
186
-
187
- const result = new Map<string, string>();
188
-
189
- for (const key of credentialKeys) {
190
- const isSecret = key.toLowerCase().includes('password') || key.toLowerCase().includes('key');
191
-
192
- let answer: string | symbol;
193
- if (isSecret) {
194
- answer = await password({
195
- message: `${key}:`,
196
- validate: (input: string | undefined) => {
197
- if (!input || input.trim().length === 0) {
198
- return `${key} is required`;
199
- }
200
- return undefined;
201
- },
202
- });
203
- } else {
204
- answer = await text({
205
- message: `${key}:`,
206
- validate: (input: string | undefined) => {
207
- if (!input || input.trim().length === 0) {
208
- return `${key} is required`;
209
- }
210
- return undefined;
211
- },
212
- });
213
- }
214
-
215
- handleCancel(answer);
216
- result.set(key, answer as string);
217
- }
218
-
219
- logSuccess('Plugin credentials configured');
220
- return result;
221
- }
package/src/services.ts DELETED
@@ -1,308 +0,0 @@
1
- import { execa } from 'execa';
2
- import chalk from 'chalk';
3
- import { logInfo, logWarning, divider, printSummaryCard, printCommandHints } from './utils.js';
4
- import { confirm } from '@clack/prompts';
5
- import { handleCancel, spinner } from './ui.js';
6
- import { HEALTH_CHECK, getServiceURL } from './config/constants.js';
7
-
8
- /**
9
- * Execute docker compose command with fallback to docker-compose.
10
- * Tries 'docker compose' first (newer), then falls back to 'docker-compose' (legacy).
11
- */
12
- async function execDockerCompose(args: string[], cwd: string): Promise<void> {
13
- try {
14
- // Try newer 'docker compose' first
15
- await execa('docker', ['compose', ...args], { cwd });
16
- } catch (error) {
17
- // Fallback to legacy 'docker-compose'
18
- await execa('docker-compose', args, { cwd });
19
- }
20
- }
21
-
22
- async function waitForContainerRunning(
23
- containerName: string,
24
- timeoutMs: number = 60_000,
25
- intervalMs: number = 1_500
26
- ): Promise<void> {
27
- const maxRetries = Math.ceil(timeoutMs / intervalMs);
28
-
29
- for (let i = 0; i < maxRetries; i++) {
30
- try {
31
- const { stdout } = await execa('docker', [
32
- 'ps',
33
- '--filter',
34
- `name=^${containerName}$`,
35
- '--filter',
36
- 'status=running',
37
- '--format',
38
- '{{.Names}}',
39
- ]);
40
-
41
- const running = stdout
42
- .split('\n')
43
- .map(line => line.trim())
44
- .filter(Boolean)
45
- .includes(containerName);
46
-
47
- if (running) {
48
- return;
49
- }
50
- } catch {
51
- // continue polling
52
- }
53
-
54
- await sleep(intervalMs);
55
- }
56
-
57
- throw new Error(`Container '${containerName}' did not reach running state within ${timeoutMs}ms`);
58
- }
59
-
60
- type ComposeFile = 'docker-compose.yaml' | 'docker-compose.infra.yaml';
61
-
62
- function withComposeFile(args: string[], composeFile?: ComposeFile): string[] {
63
- if (!composeFile) {
64
- return args;
65
- }
66
- return ['-f', composeFile, ...args];
67
- }
68
-
69
- /**
70
- * Prompts user whether to start the services immediately after setup.
71
- *
72
- * @returns `true` if user wants to start services, `false` otherwise
73
- */
74
- export async function promptStartServices(): Promise<boolean> {
75
- const shouldStart = await confirm({
76
- message: 'Start services now after setup?',
77
- initialValue: true,
78
- withGuide: true,
79
- });
80
-
81
- handleCancel(shouldStart);
82
- return shouldStart as boolean;
83
- }
84
-
85
- /**
86
- * Starts infrastructure services using docker compose.
87
- * Runs `docker compose -f docker-compose.infra.yaml up -d` first,
88
- * then falls back to `docker-compose -f docker-compose.infra.yaml up -d`.
89
- *
90
- * @param targetDir - Directory containing docker-compose.infra.yaml
91
- * @throws Error if both docker compose and docker-compose commands fail
92
- */
93
- export async function startInfraServices(targetDir: string): Promise<void> {
94
- logInfo('Starting infrastructure services...');
95
-
96
- const s = spinner();
97
- s.start('Starting docker-compose.infra.yaml...');
98
-
99
- try {
100
- await execDockerCompose(
101
- ['-f', 'docker-compose.infra.yaml', 'up', '-d'],
102
- targetDir
103
- );
104
- s.stop('Infrastructure services started');
105
- } catch (error: unknown) {
106
- s.error('Failed to start infrastructure services');
107
- throw error;
108
- }
109
- }
110
-
111
- /**
112
- * Waits for infrastructure services to become healthy.
113
- * Polls Docker health checks for up to 60 seconds (30 retries x 2s).
114
- *
115
- * @param targetDir - Directory where services are running
116
- */
117
- export async function waitForInfraHealth(targetDir: string): Promise<void> {
118
- logInfo('Waiting for infrastructure services to be healthy...');
119
-
120
- const s = spinner();
121
- s.start('Checking service health...');
122
-
123
- // Wait for mongo, redis health checks
124
- const maxRetries = HEALTH_CHECK.MAX_RETRIES;
125
- const retryDelay = HEALTH_CHECK.RETRY_DELAY_MS;
126
-
127
- for (let i = 0; i < maxRetries; i++) {
128
- try {
129
- // Check if containers are healthy
130
- const { stdout } = await execa('docker', ['ps', '--filter', 'health=healthy', '--format', '{{.Names}}']);
131
- const healthyContainers = stdout.split('\n').filter(Boolean);
132
-
133
- // Check for critical services
134
- const hasMongoOrRedis = healthyContainers.some(name =>
135
- name.includes('mongo') || name.includes('redis')
136
- );
137
-
138
- if (hasMongoOrRedis) {
139
- s.stop('Infrastructure services are healthy');
140
- return;
141
- }
142
-
143
- s.message(`Waiting for services... (${i + 1}/${maxRetries})`);
144
- await sleep(retryDelay);
145
- } catch (error) {
146
- s.message(`Checking health... (${i + 1}/${maxRetries})`);
147
- await sleep(retryDelay);
148
- }
149
- }
150
-
151
- s.stop('Health check timed out, but services may still be starting');
152
- logWarning('You may need to wait a bit longer for all services to be ready.');
153
- }
154
-
155
- /**
156
- * Start application services
157
- */
158
- export async function startAppServices(targetDir: string): Promise<void> {
159
- logInfo('Starting application services...');
160
-
161
- const s = spinner();
162
- s.start('Starting docker-compose.yaml...');
163
-
164
- try {
165
- await execDockerCompose(
166
- ['up', '-d'],
167
- targetDir
168
- );
169
- s.stop('Application services started');
170
- } catch (error: unknown) {
171
- s.error('Failed to start application services');
172
- throw error;
173
- }
174
- }
175
-
176
- export function getSslManualCommands(options: {
177
- composeFile: ComposeFile;
178
- domain: string;
179
- email: string;
180
- }): string[] {
181
- const composeFlag = options.composeFile === 'docker-compose.infra.yaml'
182
- ? '-f docker-compose.infra.yaml '
183
- : '';
184
-
185
- return [
186
- `docker compose ${composeFlag}up -d nginx certbot certbot-renew`,
187
- `docker compose ${composeFlag}exec -T certbot certbot certonly --webroot -w /var/www/certbot --email ${options.email} --agree-tos --no-eff-email -d ${options.domain} --non-interactive`,
188
- `docker compose ${composeFlag}exec -T nginx nginx -s reload`,
189
- `docker compose ${composeFlag}up -d certbot-renew`,
190
- ];
191
- }
192
-
193
- export async function setupSslCertificates(targetDir: string, options: {
194
- composeFile: ComposeFile;
195
- domain: string;
196
- email: string;
197
- }): Promise<void> {
198
- logInfo(`Setting up SSL certificate for ${options.domain}...`);
199
-
200
- const s = spinner();
201
- const composeArgs = (args: string[]) => withComposeFile(args, options.composeFile);
202
-
203
- s.start('Ensuring nginx/certbot services are running...');
204
- await execDockerCompose(composeArgs(['up', '-d', 'nginx', 'certbot']), targetDir);
205
- await waitForContainerRunning('nginx');
206
- await waitForContainerRunning('certbot');
207
- s.stop('Nginx and certbot services are running');
208
-
209
- s.start('Requesting initial certificate from Let\'s Encrypt...');
210
- await execDockerCompose(
211
- composeArgs([
212
- 'exec',
213
- '-T',
214
- 'certbot',
215
- 'certbot',
216
- 'certonly',
217
- '--webroot',
218
- '-w',
219
- '/var/www/certbot',
220
- '--email',
221
- options.email,
222
- '--agree-tos',
223
- '--no-eff-email',
224
- '-d',
225
- options.domain,
226
- '--non-interactive',
227
- ]),
228
- targetDir
229
- );
230
- s.stop('Initial certificate issued');
231
-
232
- s.start('Reloading nginx to apply certificates...');
233
- await execDockerCompose(composeArgs(['exec', '-T', 'nginx', 'nginx', '-s', 'reload']), targetDir);
234
- s.stop('Nginx reloaded');
235
-
236
- s.start('Starting automatic certificate renewal service...');
237
- await execDockerCompose(composeArgs(['up', '-d', 'certbot-renew']), targetDir);
238
- s.stop('Certificate auto-renewal service started');
239
- }
240
-
241
- export async function reloadNginxConfiguration(targetDir: string, options: {
242
- composeFile: ComposeFile;
243
- }): Promise<void> {
244
- const composeArgs = (args: string[]) => withComposeFile(args, options.composeFile);
245
- await execDockerCompose(composeArgs(['exec', '-T', 'nginx', 'nginx', '-t']), targetDir);
246
- await execDockerCompose(composeArgs(['exec', '-T', 'nginx', 'nginx', '-s', 'reload']), targetDir);
247
- }
248
-
249
- /**
250
- * Display service status and URLs
251
- */
252
- export async function displayServiceStatus(): Promise<void> {
253
- console.log(`\n${divider('green', '═')}`);
254
- console.log(chalk.greenBright(chalk.bold('Services Started')));
255
- console.log(divider('green', '═'));
256
-
257
- try {
258
- // Get running containers
259
- const { stdout } = await execa('docker', ['ps', '--format', '{{.Names}}']);
260
- const containers = stdout.split('\n').filter(Boolean).sort();
261
-
262
- const accessRows: Array<{ label: string; value: string }> = [];
263
-
264
- // Display URLs for known services
265
- if (containers.some(c => c.includes('api'))) {
266
- accessRows.push({ label: 'API Server', value: getServiceURL('API') });
267
- accessRows.push({ label: 'API Health', value: `${getServiceURL('API')}/health` });
268
- }
269
-
270
- if (containers.some(c => c.includes('dashboard'))) {
271
- accessRows.push({ label: 'Dashboard', value: getServiceURL('DASHBOARD') });
272
- }
273
-
274
- if (containers.some(c => c.includes('kafka-ui'))) {
275
- accessRows.push({ label: 'Kafka UI', value: getServiceURL('KAFKA_UI') });
276
- }
277
-
278
- if (containers.some(c => c.includes('grafana'))) {
279
- accessRows.push({ label: 'Grafana', value: `${getServiceURL('GRAFANA')} (admin/admin)` });
280
- }
281
-
282
- if (accessRows.length > 0) {
283
- printSummaryCard('Access URLs', accessRows);
284
- }
285
-
286
- console.log(chalk.cyanBright('Running Containers'));
287
- console.log(divider());
288
- for (const container of containers) {
289
- console.log(` ${chalk.greenBright('•')} ${container}`);
290
- }
291
- console.log('');
292
-
293
- printCommandHints('Helpful commands', [
294
- 'docker compose logs -f',
295
- 'docker compose down',
296
- ]);
297
- console.log(`${divider('green', '═')}\n`);
298
- } catch (error) {
299
- logWarning('Could not fetch container status');
300
- }
301
- }
302
-
303
- /**
304
- * Helper: Sleep for specified milliseconds
305
- */
306
- function sleep(ms: number): Promise<void> {
307
- return new Promise(resolve => setTimeout(resolve, ms));
308
- }