@simplens/onboard 1.0.4 → 1.0.6

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 (46) hide show
  1. package/README.md +43 -9
  2. package/dist/__tests__/infra-prompts.test.js +3 -1
  3. package/dist/__tests__/infra-prompts.test.js.map +1 -1
  4. package/dist/__tests__/infra.test.js +13 -0
  5. package/dist/__tests__/infra.test.js.map +1 -1
  6. package/dist/__tests__/validators.test.js +21 -1
  7. package/dist/__tests__/validators.test.js.map +1 -1
  8. package/dist/config/constants.d.ts.map +1 -1
  9. package/dist/config/constants.js +0 -1
  10. package/dist/config/constants.js.map +1 -1
  11. package/dist/env-config.d.ts +1 -1
  12. package/dist/env-config.d.ts.map +1 -1
  13. package/dist/env-config.js +46 -6
  14. package/dist/env-config.js.map +1 -1
  15. package/dist/index.js +135 -19
  16. package/dist/index.js.map +1 -1
  17. package/dist/infra.d.ts +12 -3
  18. package/dist/infra.d.ts.map +1 -1
  19. package/dist/infra.js +95 -23
  20. package/dist/infra.js.map +1 -1
  21. package/dist/services.d.ts +12 -0
  22. package/dist/services.d.ts.map +1 -1
  23. package/dist/services.js +50 -0
  24. package/dist/services.js.map +1 -1
  25. package/dist/templates.d.ts +5 -1
  26. package/dist/templates.d.ts.map +1 -1
  27. package/dist/templates.js +66 -1
  28. package/dist/templates.js.map +1 -1
  29. package/dist/types/domain.d.ts +6 -0
  30. package/dist/types/domain.d.ts.map +1 -1
  31. package/dist/validators.d.ts +9 -0
  32. package/dist/validators.d.ts.map +1 -1
  33. package/dist/validators.js +38 -0
  34. package/dist/validators.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/__tests__/infra-prompts.test.ts +3 -1
  37. package/src/__tests__/infra.test.ts +15 -0
  38. package/src/__tests__/validators.test.ts +27 -1
  39. package/src/config/constants.ts +0 -1
  40. package/src/env-config.ts +51 -6
  41. package/src/index.ts +145 -18
  42. package/src/infra.ts +120 -24
  43. package/src/services.ts +72 -0
  44. package/src/templates.ts +70 -1
  45. package/src/types/domain.ts +6 -0
  46. package/src/validators.ts +51 -0
package/src/index.ts CHANGED
@@ -15,7 +15,11 @@ import {
15
15
  } from './utils.js';
16
16
  import { text, confirm, select } from '@clack/prompts';
17
17
  import { intro, outro, handleCancel, log, note } from './ui.js';
18
- import { validatePrerequisites } from './validators.js';
18
+ import {
19
+ validatePrerequisites,
20
+ validatePublicDomain,
21
+ validateEmailAddress,
22
+ } from './validators.js';
19
23
  import {
20
24
  promptInfraServicesWithBasePath,
21
25
  generateInfraCompose,
@@ -45,6 +49,8 @@ import {
45
49
  waitForInfraHealth,
46
50
  startAppServices,
47
51
  displayServiceStatus,
52
+ setupSslCertificates,
53
+ getSslManualCommands,
48
54
  } from './services.js';
49
55
 
50
56
  const program = new Command();
@@ -58,7 +64,12 @@ program
58
64
  .option('--env <mode>', 'Environment setup mode: "default" or "interactive"')
59
65
  .option('--dir <path>', 'Target directory for setup')
60
66
  .option('--base-path <path>', 'Dashboard BASE_PATH (example: /dashboard, default: root)')
67
+ .option('--core-version <version>', 'Override CORE_VERSION in generated .env (primarily for --full mode)')
68
+ .option('--dashboard-version <version>', 'Override DASHBOARD_VERSION in generated .env (primarily for --full mode)')
61
69
  .option('--plugin [plugins...]', 'Plugins to install (e.g., @simplens/mock @simplens/nodemailer-gmail)')
70
+ .option('--ssl', 'Enable optional SSL certificate setup using Dockerized Certbot')
71
+ .option('--ssl-domain <domain>', 'Public domain for SSL certificate (required with --ssl in --full mode)')
72
+ .option('--ssl-email <email>', 'Email for Let\'s Encrypt registration (required with --ssl in --full mode)')
62
73
  .option('--no-output', 'Suppress all console output (silent mode)');
63
74
 
64
75
  interface OnboardSetupOptions {
@@ -68,6 +79,9 @@ interface OnboardSetupOptions {
68
79
  targetDir: string;
69
80
  basePath: string;
70
81
  plugins: string[];
82
+ enableSsl: boolean;
83
+ sslDomain?: string;
84
+ sslEmail?: string;
71
85
  }
72
86
 
73
87
  function printStep(step: number, total: number, title: string): void {
@@ -110,6 +124,9 @@ function showSetupSummary(setupOptions: OnboardSetupOptions, targetDir: string,
110
124
  const pluginsLabel = setupOptions.plugins.length > 0
111
125
  ? setupOptions.plugins.join(', ')
112
126
  : 'none';
127
+ const sslLabel = setupOptions.enableSsl
128
+ ? `enabled (${setupOptions.sslDomain})`
129
+ : 'disabled';
113
130
 
114
131
  const summaryLines = [
115
132
  `Target directory : ${targetDir}`,
@@ -117,6 +134,7 @@ function showSetupSummary(setupOptions: OnboardSetupOptions, targetDir: string,
117
134
  `Environment mode : ${setupOptions.envMode}`,
118
135
  `BASE_PATH : ${basePathLabel}`,
119
136
  `Plugins : ${pluginsLabel}`,
137
+ `SSL (Certbot) : ${sslLabel}`,
120
138
  `Nginx auto-include : ${autoNginx ? 'enabled (BASE_PATH is non-default)' : 'disabled'}`,
121
139
  ].join('\n');
122
140
 
@@ -168,6 +186,26 @@ async function promptSetupOptions(options: any): Promise<OnboardSetupOptions> {
168
186
  }
169
187
  }
170
188
 
189
+ if (options.ssl === true) {
190
+ if (!options.sslDomain) {
191
+ errors.push('--ssl-domain <domain> is required in --full mode when --ssl is enabled');
192
+ } else {
193
+ const domainValidation = validatePublicDomain(options.sslDomain);
194
+ if (domainValidation !== true) {
195
+ errors.push(`Invalid --ssl-domain: ${domainValidation}`);
196
+ }
197
+ }
198
+
199
+ if (!options.sslEmail) {
200
+ errors.push('--ssl-email <email> is required in --full mode when --ssl is enabled');
201
+ } else {
202
+ const emailValidation = validateEmailAddress(options.sslEmail);
203
+ if (emailValidation !== true) {
204
+ errors.push(`Invalid --ssl-email: ${emailValidation}`);
205
+ }
206
+ }
207
+ }
208
+
171
209
  if (errors.length > 0) {
172
210
  console.error('\\n❌ Validation errors in --full mode:\\n');
173
211
  errors.forEach(err => console.error(` • ${err}`));
@@ -272,6 +310,55 @@ async function promptSetupOptions(options: any): Promise<OnboardSetupOptions> {
272
310
  }
273
311
  // If not provided and not in full mode, will prompt later in the main workflow
274
312
 
313
+ // --- SSL ---
314
+ let enableSslValue = false;
315
+ let sslDomainValue: string | undefined;
316
+ let sslEmailValue: string | undefined;
317
+
318
+ if (options.ssl === true) {
319
+ enableSslValue = true;
320
+ } else if (!isFullMode) {
321
+ const sslConfirm = await confirm({
322
+ message: 'Do you want to automatically setup SSL certificate using Certbot?',
323
+ initialValue: false,
324
+ withGuide: true,
325
+ });
326
+ handleCancel(sslConfirm);
327
+ enableSslValue = sslConfirm as boolean;
328
+ }
329
+
330
+ if (enableSslValue) {
331
+ if (typeof options.sslDomain === 'string') {
332
+ sslDomainValue = options.sslDomain.trim().toLowerCase();
333
+ } else if (!isFullMode) {
334
+ const domainAnswer = await text({
335
+ message: 'Public domain to secure (example: app.example.com):',
336
+ validate: (value: string | undefined) => {
337
+ const validation = validatePublicDomain(value ?? '');
338
+ return validation === true ? undefined : validation;
339
+ },
340
+ withGuide: true,
341
+ });
342
+ handleCancel(domainAnswer);
343
+ sslDomainValue = (domainAnswer as string).trim().toLowerCase();
344
+ }
345
+
346
+ if (typeof options.sslEmail === 'string') {
347
+ sslEmailValue = options.sslEmail.trim();
348
+ } else if (!isFullMode) {
349
+ const emailAnswer = await text({
350
+ message: 'Email for Let\'s Encrypt registration:',
351
+ validate: (value: string | undefined) => {
352
+ const validation = validateEmailAddress(value ?? '');
353
+ return validation === true ? undefined : validation;
354
+ },
355
+ withGuide: true,
356
+ });
357
+ handleCancel(emailAnswer);
358
+ sslEmailValue = (emailAnswer as string).trim();
359
+ }
360
+ }
361
+
275
362
  return {
276
363
  infra: infraValue,
277
364
  infraServices: infraServices,
@@ -279,6 +366,9 @@ async function promptSetupOptions(options: any): Promise<OnboardSetupOptions> {
279
366
  targetDir: targetDirValue || process.cwd(),
280
367
  basePath: basePathValue,
281
368
  plugins: pluginsValue,
369
+ enableSsl: enableSslValue,
370
+ sslDomain: sslDomainValue,
371
+ sslEmail: sslEmailValue,
282
372
  };
283
373
  }
284
374
 
@@ -316,6 +406,7 @@ async function main() {
316
406
  // Get target directory
317
407
  const targetDir = path.resolve(setupOptions.targetDir);
318
408
  const autoEnableNginx = shouldAutoEnableNginx(setupOptions.basePath);
409
+ const nginxRequired = autoEnableNginx || setupOptions.enableSsl;
319
410
 
320
411
  logDebug(`Resolved target directory: ${targetDir}`);
321
412
  showSetupSummary(setupOptions, targetDir, autoEnableNginx);
@@ -327,48 +418,66 @@ async function main() {
327
418
  // Step 2: Infrastructure setup (if --infra flag is provided)
328
419
  log.step('Step 2/6 — Infrastructure Setup');
329
420
  let selectedInfraServices: string[] = [];
421
+ const shouldSetupInfra = setupOptions.infra || nginxRequired;
330
422
 
331
- if (setupOptions.infra) {
423
+ if (shouldSetupInfra) {
332
424
  // Use pre-provided services from CLI, or prompt for them
333
- if (setupOptions.infraServices.length > 0) {
425
+ if (setupOptions.infra && setupOptions.infraServices.length > 0) {
334
426
  selectedInfraServices = setupOptions.infraServices;
335
427
  log.info(`Using infrastructure services: ${selectedInfraServices.join(', ')}`);
428
+ } else if (!setupOptions.infra && nginxRequired) {
429
+ selectedInfraServices = ['nginx'];
430
+ log.info('Nginx is required (BASE_PATH/SSL), so infrastructure compose will be generated with nginx.');
336
431
  } else {
337
432
  // Prompt for services (interactive mode)
338
433
  if (!autoEnableNginx) {
339
434
  log.info('BASE_PATH is empty, nginx reverse proxy is disabled.');
340
- selectedInfraServices = await promptInfraServicesWithBasePath({ allowNginx: false });
435
+ selectedInfraServices = await promptInfraServicesWithBasePath({
436
+ allowNginx: false,
437
+ });
341
438
  } else {
342
- selectedInfraServices = await promptInfraServicesWithBasePath({ allowNginx: true });
439
+ selectedInfraServices = await promptInfraServicesWithBasePath({
440
+ allowNginx: true,
441
+ defaultNginx: true,
442
+ });
343
443
  }
344
444
  }
345
445
 
346
- if (autoEnableNginx && !selectedInfraServices.includes('nginx')) {
446
+ if (setupOptions.enableSsl && !selectedInfraServices.includes('nginx')) {
347
447
  selectedInfraServices.push('nginx');
348
- log.info('BASE_PATH is non-default, so nginx was added automatically.');
448
+ log.info('SSL is enabled, so nginx was added automatically.');
349
449
  }
350
450
 
351
- await generateInfraCompose(targetDir, selectedInfraServices);
451
+ const infraHasNginx = selectedInfraServices.includes('nginx');
452
+ await generateInfraCompose(targetDir, selectedInfraServices, {
453
+ includeSsl: setupOptions.enableSsl && infraHasNginx,
454
+ });
352
455
  } else {
353
456
  log.info('Skipping infrastructure setup (use --infra to enable).');
354
457
  }
355
458
 
356
459
  // Step 3: Always write app docker-compose
357
460
  log.step('Step 3/6 — Application Compose Setup');
358
- const includeNginxInAppCompose = autoEnableNginx && !selectedInfraServices.includes('nginx');
359
- if (includeNginxInAppCompose) {
360
- log.info('Including nginx in docker-compose.yaml because BASE_PATH is non-default.');
361
- }
362
- await writeAppCompose(targetDir, { includeNginx: includeNginxInAppCompose });
461
+ await writeAppCompose(targetDir, {
462
+ includeNginx: false,
463
+ includeSsl: false,
464
+ });
363
465
 
364
466
  // Step 4: Environment configuration
365
467
  log.step('Step 4/6 — Environment Configuration');
366
468
  const envMode = setupOptions.envMode;
469
+ const envOverrides = options.full
470
+ ? {
471
+ CORE_VERSION: options.coreVersion,
472
+ DASHBOARD_VERSION: options.dashboardVersion,
473
+ }
474
+ : undefined;
367
475
  const envVars = await promptEnvVariables(
368
476
  envMode,
369
477
  selectedInfraServices,
370
478
  setupOptions.basePath,
371
- options.full || false
479
+ options.full || false,
480
+ envOverrides
372
481
  );
373
482
  await generateEnvFile(targetDir, envVars);
374
483
 
@@ -381,9 +490,12 @@ async function main() {
381
490
  }
382
491
 
383
492
  // Generate nginx.conf whenever nginx is active in either compose file
384
- const nginxEnabled = selectedInfraServices.includes('nginx') || includeNginxInAppCompose;
493
+ const nginxEnabled = selectedInfraServices.includes('nginx');
385
494
  if (nginxEnabled) {
386
- await generateNginxConfig(targetDir, setupOptions.basePath);
495
+ await generateNginxConfig(targetDir, setupOptions.basePath, {
496
+ enableSsl: setupOptions.enableSsl,
497
+ domain: setupOptions.sslDomain,
498
+ });
387
499
  }
388
500
 
389
501
  // Step 5: Plugin installation
@@ -438,7 +550,7 @@ async function main() {
438
550
 
439
551
  if (shouldStart) {
440
552
  // Start infra services first (if --infra was used)
441
- if (setupOptions.infra && selectedInfraServices.length > 0) {
553
+ if (selectedInfraServices.length > 0) {
442
554
  await startInfraServices(targetDir);
443
555
  await waitForInfraHealth(targetDir);
444
556
  }
@@ -446,15 +558,30 @@ async function main() {
446
558
  // Start app services
447
559
  await startAppServices(targetDir);
448
560
 
561
+ if (setupOptions.enableSsl && setupOptions.sslDomain && setupOptions.sslEmail) {
562
+ await setupSslCertificates(targetDir, {
563
+ composeFile: 'docker-compose.infra.yaml',
564
+ domain: setupOptions.sslDomain,
565
+ email: setupOptions.sslEmail,
566
+ });
567
+ }
568
+
449
569
  // Display service status
450
570
  await displayServiceStatus();
451
571
  } else {
452
572
  log.info('Services not started. You can start them later with:');
453
573
  const commands: string[] = [];
454
- if (setupOptions.infra) {
574
+ if (selectedInfraServices.length > 0) {
455
575
  commands.push('docker-compose -f docker-compose.infra.yaml up -d');
456
576
  }
457
577
  commands.push('docker-compose up -d');
578
+ if (setupOptions.enableSsl && setupOptions.sslDomain && setupOptions.sslEmail) {
579
+ commands.push(...getSslManualCommands({
580
+ composeFile: 'docker-compose.infra.yaml',
581
+ domain: setupOptions.sslDomain,
582
+ email: setupOptions.sslEmail,
583
+ }));
584
+ }
458
585
  printCommandHints('Manual startup commands', commands);
459
586
  }
460
587
 
package/src/infra.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { APP_COMPOSE_TEMPLATE, APP_NGINX_SERVICE_TEMPLATE } from './templates.js';
1
+ import {
2
+ APP_COMPOSE_TEMPLATE,
3
+ APP_NGINX_SERVICE_TEMPLATE,
4
+ APP_NGINX_SSL_SERVICE_TEMPLATE,
5
+ APP_CERTBOT_SERVICES_TEMPLATE,
6
+ INFRA_CERTBOT_SERVICES_TEMPLATE,
7
+ INFRA_CERTBOT_VOLUMES,
8
+ } from './templates.js';
2
9
  import { writeFile, logInfo, logSuccess } from './utils.js';
3
10
  import { multiselect } from '@clack/prompts';
4
11
  import { handleCancel, spinner } from './ui.js';
@@ -8,7 +15,7 @@ import type { InfraService } from './types/domain.js';
8
15
  const INFRA_SERVICES: InfraService[] = [
9
16
  { name: 'MongoDB (Database)', value: 'mongo', checked: true },
10
17
  { name: 'Kafka (Message Queue)', value: 'kafka', checked: true },
11
- { name: 'Kafka UI (Dashboard)', value: 'kafka-ui', checked: true },
18
+ { name: 'Kafka UI (Dashboard)', value: 'kafka-ui', checked: false },
12
19
  { name: 'Redis (Cache)', value: 'redis', checked: true },
13
20
  { name: 'Nginx (Reverse Proxy)', value: 'nginx', checked: false },
14
21
  { name: 'Loki (Log Aggregation)', value: 'loki', checked: false },
@@ -31,9 +38,15 @@ export async function promptInfraServices(): Promise<string[]> {
31
38
  */
32
39
  export async function promptInfraServicesWithBasePath(options: {
33
40
  allowNginx: boolean;
41
+ defaultNginx?: boolean;
34
42
  }): Promise<string[]> {
35
43
  const choices = options.allowNginx
36
- ? INFRA_SERVICES
44
+ ? INFRA_SERVICES.map(service => {
45
+ if (service.value === 'nginx') {
46
+ return { ...service, checked: options.defaultNginx === true };
47
+ }
48
+ return service;
49
+ })
37
50
  : INFRA_SERVICES.filter(service => service.value !== 'nginx');
38
51
 
39
52
  const message = options.allowNginx
@@ -179,6 +192,18 @@ const SERVICE_CHUNKS: Record<string, string> = {
179
192
  restart: unless-stopped`,
180
193
  };
181
194
 
195
+ const NGINX_INFRA_SSL_SERVICE_CHUNK = ` nginx:
196
+ image: nginx:alpine
197
+ container_name: nginx
198
+ ports:
199
+ - "80:80"
200
+ - "443:443"
201
+ volumes:
202
+ - "./nginx.conf:/etc/nginx/conf.d/default.conf:ro"
203
+ - certbot-etc:/etc/letsencrypt
204
+ - certbot-www:/var/www/certbot
205
+ restart: unless-stopped`;
206
+
182
207
  /**
183
208
  * Service-to-volumes mapping
184
209
  */
@@ -195,7 +220,10 @@ const SERVICE_VOLUMES: Record<string, string[]> = {
195
220
  /**
196
221
  * Build docker-compose content from selected services
197
222
  */
198
- function buildInfraCompose(selectedServices: string[]): string {
223
+ function buildInfraCompose(
224
+ selectedServices: string[],
225
+ options: { includeSsl?: boolean } = {}
226
+ ): string {
199
227
  // Header
200
228
  const header = `# ============================================
201
229
  # SimpleNS Infrastructure Services
@@ -212,9 +240,17 @@ services:
212
240
  const serviceBlocks: string[] = [];
213
241
  for (const service of selectedServices) {
214
242
  if (SERVICE_CHUNKS[service]) {
215
- serviceBlocks.push(SERVICE_CHUNKS[service]);
243
+ if (service === 'nginx' && options.includeSsl === true) {
244
+ serviceBlocks.push(NGINX_INFRA_SSL_SERVICE_CHUNK);
245
+ } else {
246
+ serviceBlocks.push(SERVICE_CHUNKS[service]);
247
+ }
216
248
  }
217
249
  }
250
+
251
+ if (options.includeSsl === true) {
252
+ serviceBlocks.push(INFRA_CERTBOT_SERVICES_TEMPLATE);
253
+ }
218
254
 
219
255
  // Collect volumes for selected services
220
256
  const volumeSet = new Set<string>();
@@ -222,6 +258,12 @@ services:
222
258
  const volumes = SERVICE_VOLUMES[service] || [];
223
259
  volumes.forEach(v => volumeSet.add(v));
224
260
  }
261
+
262
+ if (options.includeSsl === true) {
263
+ for (const volumeName of INFRA_CERTBOT_VOLUMES) {
264
+ volumeSet.add(volumeName);
265
+ }
266
+ }
225
267
 
226
268
  // Build volumes section
227
269
  const volumeLines: string[] = ['', 'volumes:'];
@@ -248,13 +290,14 @@ services:
248
290
  */
249
291
  export async function generateInfraCompose(
250
292
  targetDir: string,
251
- selectedServices: string[]
293
+ selectedServices: string[],
294
+ options: { includeSsl?: boolean } = {}
252
295
  ): Promise<void> {
253
296
  const s = spinner();
254
297
  s.start('Generating docker-compose.infra.yaml...');
255
298
 
256
299
  // Build compose content from service chunks
257
- const infraContent = buildInfraCompose(selectedServices);
300
+ const infraContent = buildInfraCompose(selectedServices, options);
258
301
 
259
302
  // Write infrastructure compose file
260
303
  const infraPath = path.join(targetDir, 'docker-compose.infra.yaml');
@@ -266,17 +309,35 @@ export async function generateInfraCompose(
266
309
  * Build app docker-compose content.
267
310
  * Optionally inject nginx reverse-proxy service before the volumes section.
268
311
  */
269
- export function buildAppComposeContent(includeNginx: boolean): string {
270
- if (!includeNginx) {
271
- return APP_COMPOSE_TEMPLATE;
312
+ export function buildAppComposeContent(
313
+ includeNginx: boolean,
314
+ options: { includeSsl?: boolean } = {}
315
+ ): string {
316
+ let content = APP_COMPOSE_TEMPLATE;
317
+ const includeSsl = options.includeSsl === true;
318
+ const shouldIncludeNginx = includeNginx || includeSsl;
319
+ const marker = '\nvolumes:';
320
+
321
+ if (!content.includes(marker)) {
322
+ return content;
272
323
  }
273
324
 
274
- const marker = '\nvolumes:';
275
- if (!APP_COMPOSE_TEMPLATE.includes(marker)) {
276
- return APP_COMPOSE_TEMPLATE;
325
+ if (shouldIncludeNginx) {
326
+ const nginxBlock = includeSsl
327
+ ? APP_NGINX_SSL_SERVICE_TEMPLATE
328
+ : APP_NGINX_SERVICE_TEMPLATE;
329
+ content = content.replace(marker, `\n${nginxBlock}\n${marker}`);
330
+ }
331
+
332
+ if (includeSsl) {
333
+ content = content.replace(marker, `\n${APP_CERTBOT_SERVICES_TEMPLATE}\n${marker}`);
334
+ content = content.replace(
335
+ '\nvolumes:\n plugin-data:',
336
+ '\nvolumes:\n certbot-etc:\n certbot-www:\n plugin-data:'
337
+ );
277
338
  }
278
339
 
279
- return APP_COMPOSE_TEMPLATE.replace(marker, `\n${APP_NGINX_SERVICE_TEMPLATE}\n${marker}`);
340
+ return content;
280
341
  }
281
342
 
282
343
  /**
@@ -284,12 +345,14 @@ export function buildAppComposeContent(includeNginx: boolean): string {
284
345
  */
285
346
  export async function writeAppCompose(
286
347
  targetDir: string,
287
- options: { includeNginx?: boolean } = {}
348
+ options: { includeNginx?: boolean; includeSsl?: boolean } = {}
288
349
  ): Promise<void> {
289
350
  const s = spinner();
290
351
  s.start('Generating docker-compose.yaml...');
291
352
  const appPath = path.join(targetDir, 'docker-compose.yaml');
292
- const appContent = buildAppComposeContent(options.includeNginx === true);
353
+ const appContent = buildAppComposeContent(options.includeNginx === true, {
354
+ includeSsl: options.includeSsl === true,
355
+ });
293
356
  await writeFile(appPath, appContent);
294
357
  s.stop('Generated docker-compose.yaml');
295
358
  }
@@ -299,7 +362,8 @@ export async function writeAppCompose(
299
362
  */
300
363
  export async function generateNginxConfig(
301
364
  targetDir: string,
302
- basePath: string
365
+ basePath: string,
366
+ options: { enableSsl?: boolean; domain?: string } = {}
303
367
  ): Promise<void> {
304
368
  const s = spinner();
305
369
  s.start('Generating nginx.conf...');
@@ -307,13 +371,11 @@ export async function generateNginxConfig(
307
371
  // Normalize basePath (remove leading/trailing slashes for template)
308
372
  const normalizedPath = basePath.trim().replace(/^\/|\/$/g, '');
309
373
  const hasBasePath = normalizedPath.length > 0;
374
+ const enableSsl = options.enableSsl === true;
375
+ const domain = options.domain?.trim() || 'localhost';
310
376
 
311
- // Template for nginx.conf
312
- const nginxTemplate = `server {
313
- listen 80;
314
- server_name localhost;
315
-
316
- location /api/notification/ {
377
+ const proxyRoutes = `
378
+ location /api {
317
379
  proxy_pass http://api:3000;
318
380
  proxy_http_version 1.1;
319
381
 
@@ -395,10 +457,44 @@ ${hasBasePath ? `
395
457
  proxy_set_header X-Forwarded-Proto $scheme;
396
458
  }
397
459
  `}
460
+ `;
461
+
462
+ const nginxTemplate = enableSsl
463
+ ? `server {
464
+ listen 80;
465
+ server_name ${domain};
466
+
467
+ location /.well-known/acme-challenge/ {
468
+ root /var/www/certbot;
469
+ }
470
+
471
+ location / {
472
+ return 301 https://$host$request_uri;
473
+ }
474
+ }
475
+
476
+ server {
477
+ listen 443 ssl;
478
+ server_name ${domain};
479
+
480
+ ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
481
+ ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
482
+ ssl_protocols TLSv1.2 TLSv1.3;
483
+ ssl_prefer_server_ciphers on;
484
+ location = / {
485
+ return 302 /dashboard;
486
+ }
487
+ ${proxyRoutes}
488
+ }
489
+ `
490
+ : `server {
491
+ listen 80;
492
+ server_name localhost;${proxyRoutes}
398
493
  }
399
494
  `;
400
495
 
401
496
  const nginxPath = path.join(targetDir, 'nginx.conf');
402
497
  await writeFile(nginxPath, nginxTemplate);
403
- s.stop(`Generated nginx.conf${hasBasePath ? ` with base path: /${normalizedPath}` : ' (root path)'}`);
498
+ const sslLabel = enableSsl ? `, SSL domain: ${domain}` : '';
499
+ s.stop(`Generated nginx.conf${hasBasePath ? ` with base path: /${normalizedPath}` : ' (root path)'}${sslLabel}`);
404
500
  }
package/src/services.ts CHANGED
@@ -19,6 +19,15 @@ async function execDockerCompose(args: string[], cwd: string): Promise<void> {
19
19
  }
20
20
  }
21
21
 
22
+ type ComposeFile = 'docker-compose.yaml' | 'docker-compose.infra.yaml';
23
+
24
+ function withComposeFile(args: string[], composeFile?: ComposeFile): string[] {
25
+ if (!composeFile) {
26
+ return args;
27
+ }
28
+ return ['-f', composeFile, ...args];
29
+ }
30
+
22
31
  /**
23
32
  * Prompts user whether to start the services immediately after setup.
24
33
  *
@@ -125,6 +134,69 @@ export async function startAppServices(targetDir: string): Promise<void> {
125
134
  }
126
135
  }
127
136
 
137
+ export function getSslManualCommands(options: {
138
+ composeFile: ComposeFile;
139
+ domain: string;
140
+ email: string;
141
+ }): string[] {
142
+ const composeFlag = options.composeFile === 'docker-compose.infra.yaml'
143
+ ? '-f docker-compose.infra.yaml '
144
+ : '';
145
+
146
+ return [
147
+ `docker compose ${composeFlag}up -d nginx certbot certbot-renew`,
148
+ `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`,
149
+ `docker compose ${composeFlag}exec -T nginx nginx -s reload`,
150
+ `docker compose ${composeFlag}up -d certbot-renew`,
151
+ ];
152
+ }
153
+
154
+ export async function setupSslCertificates(targetDir: string, options: {
155
+ composeFile: ComposeFile;
156
+ domain: string;
157
+ email: string;
158
+ }): Promise<void> {
159
+ logInfo(`Setting up SSL certificate for ${options.domain}...`);
160
+
161
+ const s = spinner();
162
+ const composeArgs = (args: string[]) => withComposeFile(args, options.composeFile);
163
+
164
+ s.start('Ensuring nginx/certbot services are running...');
165
+ await execDockerCompose(composeArgs(['up', '-d', 'nginx', 'certbot']), targetDir);
166
+ s.stop('Nginx and certbot services are running');
167
+
168
+ s.start('Requesting initial certificate from Let\'s Encrypt...');
169
+ await execDockerCompose(
170
+ composeArgs([
171
+ 'exec',
172
+ '-T',
173
+ 'certbot',
174
+ 'certbot',
175
+ 'certonly',
176
+ '--webroot',
177
+ '-w',
178
+ '/var/www/certbot',
179
+ '--email',
180
+ options.email,
181
+ '--agree-tos',
182
+ '--no-eff-email',
183
+ '-d',
184
+ options.domain,
185
+ '--non-interactive',
186
+ ]),
187
+ targetDir
188
+ );
189
+ s.stop('Initial certificate issued');
190
+
191
+ s.start('Reloading nginx to apply certificates...');
192
+ await execDockerCompose(composeArgs(['exec', '-T', 'nginx', 'nginx', '-s', 'reload']), targetDir);
193
+ s.stop('Nginx reloaded');
194
+
195
+ s.start('Starting automatic certificate renewal service...');
196
+ await execDockerCompose(composeArgs(['up', '-d', 'certbot-renew']), targetDir);
197
+ s.stop('Certificate auto-renewal service started');
198
+ }
199
+
128
200
  /**
129
201
  * Display service status and URLs
130
202
  */