@launchframe/cli 0.1.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.
@@ -0,0 +1,569 @@
1
+ const chalk = require('chalk');
2
+ const fs = require('fs-extra');
3
+ const path = require('path');
4
+ const { execSync } = require('child_process');
5
+ const inquirer = require('inquirer');
6
+ const { SERVICE_REGISTRY } = require('../services/registry');
7
+ const { isLaunchFrameProject, getProjectConfig, updateProjectConfig, getPrimaryDomain } = require('../utils/project-helpers');
8
+ const { replaceVariables } = require('../utils/variable-replacer');
9
+ const { updateEnvFile } = require('../utils/env-generator');
10
+
11
+ async function serviceAdd(serviceName) {
12
+ // STEP 1: Validation
13
+ console.log(chalk.blue(`Installing ${serviceName} service...`));
14
+
15
+ // Check if inside LaunchFrame project
16
+ if (!isLaunchFrameProject()) {
17
+ console.error(chalk.red('Error: Not in a LaunchFrame project directory'));
18
+ console.log('Run this command from your LaunchFrame project root');
19
+ process.exit(1);
20
+ }
21
+
22
+ // Get project config
23
+ const projectConfig = getProjectConfig();
24
+ const projectName = projectConfig.projectName;
25
+
26
+ // Check if service exists
27
+ const service = SERVICE_REGISTRY[serviceName];
28
+ if (!service) {
29
+ console.error(chalk.red(`Error: Service "${serviceName}" not found`));
30
+ console.log('\nAvailable services:');
31
+ Object.keys(SERVICE_REGISTRY).forEach(key => {
32
+ console.log(` - ${key}`);
33
+ });
34
+ process.exit(1);
35
+ }
36
+
37
+ // Check if already installed
38
+ const installedServices = projectConfig.installedServices || [];
39
+ if (installedServices.includes(serviceName)) {
40
+ console.error(chalk.red(`Error: Service "${serviceName}" is already installed`));
41
+ process.exit(1);
42
+ }
43
+
44
+ // STEP 2: Display service info and confirm
45
+ console.log(chalk.green(`\n${service.displayName}`));
46
+ console.log(service.description);
47
+ console.log(`Tech stack: ${service.techStack}`);
48
+ console.log(`Dependencies: ${service.dependencies.join(', ')}`);
49
+
50
+ const { confirmed } = await inquirer.prompt([{
51
+ type: 'confirm',
52
+ name: 'confirmed',
53
+ message: 'Continue with installation?',
54
+ default: true
55
+ }]);
56
+
57
+ if (!confirmed) {
58
+ console.log('Installation cancelled');
59
+ process.exit(0);
60
+ }
61
+
62
+ // STEP 3: Clone repository or copy from local (dev mode)
63
+ const installPath = path.resolve(process.cwd(), serviceName);
64
+
65
+ if (fs.existsSync(installPath)) {
66
+ console.error(chalk.red(`Error: Directory ${installPath} already exists`));
67
+ process.exit(1);
68
+ }
69
+
70
+ // Check if in development mode (for LaunchFrame development itself)
71
+ const isDevMode = process.env.LAUNCHFRAME_DEV_MODE === 'true';
72
+
73
+ if (isDevMode) {
74
+ // Local development: copy from launchframe-dev/modules directory
75
+ console.log(chalk.blue('\n[DEV MODE] Copying service from local directory...'));
76
+ const sourceDir = path.resolve('/home/matfish/Work/launchframe-dev/modules', serviceName);
77
+
78
+ if (!fs.existsSync(sourceDir)) {
79
+ console.error(chalk.red(`Error: Local service directory not found: ${sourceDir}`));
80
+ console.log('Make sure the service exists in the launchframe-dev/modules directory');
81
+ process.exit(1);
82
+ }
83
+
84
+ try {
85
+ // Copy directory recursively, excluding .git, node_modules, .next, etc.
86
+ await fs.copy(sourceDir, installPath, {
87
+ filter: (src) => {
88
+ const basename = path.basename(src);
89
+ return !['node_modules', '.git', '.next', 'dist', 'build', '.env'].includes(basename);
90
+ }
91
+ });
92
+ console.log(chalk.green('✓ Service copied successfully'));
93
+ } catch (error) {
94
+ console.error(chalk.red('Failed to copy service directory'));
95
+ console.error(error.message);
96
+ process.exit(1);
97
+ }
98
+ } else {
99
+ // Production mode: clone from git repository
100
+ console.log(chalk.blue('\nCloning service repository...'));
101
+
102
+ // Replace {{GITHUB_ORG}} in repo URL with actual org from project config
103
+ const repoUrl = service.repoUrl.replace('{{GITHUB_ORG}}', projectConfig.githubOrg || 'launchframe');
104
+
105
+ try {
106
+ execSync(`git clone ${repoUrl} ${installPath}`, { stdio: 'inherit' });
107
+ } catch (error) {
108
+ console.error(chalk.red('Failed to clone repository'));
109
+ console.log('Make sure you have access to the repository');
110
+ process.exit(1);
111
+ }
112
+ }
113
+
114
+ // STEP 4: Service-specific prompts (e.g., Airtable credentials)
115
+ console.log(chalk.blue('\nConfiguring service...'));
116
+ const envValues = await runServicePrompts(service);
117
+
118
+ // STEP 5: Replace template variables
119
+ console.log(chalk.blue('\nCustomizing service for your project...'));
120
+
121
+ // Build replacement map for ALL {{TEMPLATE_VARIABLES}}
122
+ const replacements = {
123
+ '{{PROJECT_NAME}}': projectName,
124
+ '{{PROJECT_NAME_UPPER}}': projectName.toUpperCase().replace(/-/g, '_'),
125
+ '{{PROJECT_DISPLAY_NAME}}': projectConfig.projectDisplayName || projectName,
126
+ '{{PRIMARY_DOMAIN}}': getPrimaryDomain(projectConfig) || 'example.com',
127
+ '{{GITHUB_ORG}}': projectConfig.githubOrg || 'launchframe'
128
+ };
129
+
130
+ // Add service-specific env var values as {{VAR_NAME}} (with double curly braces)
131
+ for (const [key, value] of Object.entries(envValues)) {
132
+ replacements[`{{${key}}}`] = value;
133
+ }
134
+
135
+ // Replace all template variables (but preserve Docker Compose ${VAR} syntax)
136
+ await replaceVariables(installPath, replacements);
137
+
138
+ // STEP 6: Create service .env file
139
+ console.log(chalk.blue('\nCreating service environment file...'));
140
+ const serviceEnvPath = path.resolve(installPath, '.env');
141
+ let serviceEnvContent = '';
142
+ for (const [key, description] of Object.entries(service.envVars)) {
143
+ serviceEnvContent += `${key}=${envValues[key]}\n`;
144
+ }
145
+ // Add project-specific vars
146
+ serviceEnvContent += `\n# Project Configuration\n`;
147
+ serviceEnvContent += `PROJECT_NAME=${projectConfig.projectDisplayName || projectName}\n`;
148
+ serviceEnvContent += `PRIMARY_DOMAIN=${getPrimaryDomain(projectConfig) || 'localhost'}\n`;
149
+ serviceEnvContent += `NEXT_PUBLIC_PROJECT_NAME=${projectConfig.projectDisplayName || projectName}\n`;
150
+ serviceEnvContent += `NEXT_PUBLIC_SITE_URL=http://localhost:${service.devPort || 3000}\n`;
151
+ await fs.writeFile(serviceEnvPath, serviceEnvContent, 'utf8');
152
+
153
+ // Create .env.prod for production deployment
154
+ const serviceEnvProdPath = path.resolve(installPath, '.env.prod');
155
+ let serviceEnvProdContent = serviceEnvContent;
156
+ const productionDomain = getPrimaryDomain(projectConfig);
157
+ if (productionDomain && productionDomain !== 'localhost') {
158
+ serviceEnvProdContent = serviceEnvProdContent.replace(
159
+ /PRIMARY_DOMAIN=.*/g,
160
+ `PRIMARY_DOMAIN=${productionDomain}`
161
+ );
162
+ serviceEnvProdContent = serviceEnvProdContent.replace(
163
+ /NEXT_PUBLIC_SITE_URL=.*/g,
164
+ `NEXT_PUBLIC_SITE_URL=https://${productionDomain}`
165
+ );
166
+ }
167
+ await fs.writeFile(serviceEnvProdPath, serviceEnvProdContent, 'utf8');
168
+ console.log(chalk.green('✓ Created .env (development)'));
169
+ console.log(chalk.green('✓ Created .env.prod (production)'));
170
+
171
+ // STEP 7: Update main project .env file (in infrastructure/)
172
+ console.log(chalk.blue('\nUpdating main project environment configuration...'));
173
+ const mainEnvPath = path.resolve(process.cwd(), 'infrastructure', '.env');
174
+ if (fs.existsSync(mainEnvPath)) {
175
+ await updateEnvFile(mainEnvPath, serviceName, service.envVars, envValues);
176
+ }
177
+
178
+ // STEP 8: Update .launchframe marker
179
+ projectConfig.installedServices = installedServices;
180
+ projectConfig.installedServices.push(serviceName);
181
+ updateProjectConfig(projectConfig);
182
+
183
+ // STEP 8b: For integrated services, append to docker-compose files
184
+ if (!service.standalone) {
185
+ console.log(chalk.blue('\nAdding service to infrastructure docker-compose files...'));
186
+ await appendServiceToDockerCompose(serviceName, service, projectName, projectConfig);
187
+ }
188
+
189
+ // STEP 8c: Initialize git repository for the service
190
+ console.log(chalk.blue('\nInitializing git repository for service...'));
191
+ try {
192
+ execSync('git init', { cwd: installPath, stdio: 'ignore' });
193
+ execSync('git add .', { cwd: installPath, stdio: 'ignore' });
194
+ execSync('git commit -m "Initial commit"', { cwd: installPath, stdio: 'ignore' });
195
+ console.log(chalk.green('✓ Git repository initialized'));
196
+ } catch (error) {
197
+ console.warn(chalk.yellow(`⚠️ Could not initialize git repository: ${error.message}`));
198
+ }
199
+
200
+ // STEP 9: Success message
201
+ console.log(chalk.green(`\n✓ Service "${serviceName}" installed successfully!`));
202
+ console.log(`\nLocation: ${installPath}`);
203
+
204
+ // Service-specific customization reminder
205
+ if (serviceName === 'waitlist') {
206
+ console.log(chalk.yellow('\n📝 Customize the landing page:'));
207
+ console.log(chalk.gray(` ${serviceName}/src/components/HeroSection.tsx`));
208
+ console.log(chalk.gray(` ${serviceName}/src/components/BenefitsSection.tsx`));
209
+ console.log(chalk.gray(' (LaunchFrame example content included - update for your product)'));
210
+ }
211
+
212
+ // Get service-specific port info
213
+ const devPort = service.devPort || 3000;
214
+
215
+ console.log('\nNext steps:');
216
+
217
+ // Service-specific instructions
218
+ if (service.standalone) {
219
+ // Standalone services like waitlist
220
+ if (serviceName === 'waitlist') {
221
+ console.log(chalk.cyan('\n Run locally with Docker (recommended):'));
222
+ console.log(chalk.white(` launchframe waitlist:up`));
223
+ console.log(chalk.gray(` → Starts development server at http://localhost:${devPort}`));
224
+ console.log(chalk.gray(` → Hot reloading enabled`));
225
+
226
+ console.log(chalk.cyan('\n Deploy to production VPS:'));
227
+ console.log(chalk.white(` launchframe waitlist:deploy`));
228
+ console.log(chalk.gray(` → Builds and deploys with SSL via Traefik`));
229
+
230
+ console.log(chalk.cyan('\n Other commands:'));
231
+ console.log(chalk.gray(` launchframe waitlist:down - Stop running service`));
232
+ console.log(chalk.gray(` launchframe waitlist:logs - View service logs`));
233
+ }
234
+ } else {
235
+ // Integrated services like docs
236
+ console.log(chalk.cyan('\n Run locally with Docker:'));
237
+ console.log(chalk.white(` launchframe docker:up ${serviceName}`));
238
+ console.log(chalk.gray(` → Starts ${serviceName} at http://localhost:${devPort}`));
239
+ console.log(chalk.gray(` → Part of main infrastructure`));
240
+
241
+ console.log(chalk.cyan('\n Or start all services:'));
242
+ console.log(chalk.white(` launchframe docker:up`));
243
+ console.log(chalk.gray(` → Includes ${serviceName} + all other services`));
244
+
245
+ console.log(chalk.cyan('\n View logs:'));
246
+ console.log(chalk.white(` launchframe docker:logs ${serviceName}`));
247
+
248
+ console.log(chalk.cyan('\n Deploy to production:'));
249
+ console.log(chalk.white(` launchframe deploy:up`));
250
+ console.log(chalk.gray(` → Deploys entire stack including ${serviceName}`));
251
+ console.log(chalk.gray(` → Available at https://${serviceName}.your-domain.com`));
252
+ }
253
+
254
+ console.log(`\n📖 See README.md in ${serviceName}/ for more details.`);
255
+ }
256
+
257
+ async function runServicePrompts(service) {
258
+ const envValues = {};
259
+
260
+ // Prompt for each required env var
261
+ for (const [key, description] of Object.entries(service.envVars)) {
262
+ const { value } = await inquirer.prompt([{
263
+ type: 'input',
264
+ name: 'value',
265
+ message: `${description}:`,
266
+ validate: input => input.length > 0 || 'This field is required'
267
+ }]);
268
+ envValues[key] = value;
269
+ }
270
+
271
+ return envValues;
272
+ }
273
+
274
+ async function serviceList() {
275
+ console.log(chalk.blue('\nAvailable Services:\n'));
276
+
277
+ Object.values(SERVICE_REGISTRY).forEach(service => {
278
+ console.log(chalk.green(` ${service.name}`));
279
+ console.log(` ${service.description}`);
280
+ console.log(` Tech: ${service.techStack}`);
281
+ console.log(` Dependencies: ${service.dependencies.join(', ')}`);
282
+ console.log('');
283
+ });
284
+
285
+ console.log('To install a service:');
286
+ console.log(' launchframe service:add <service-name>');
287
+ }
288
+
289
+ async function serviceRemove(serviceName) {
290
+ console.log(chalk.yellow('Service removal is not yet implemented.'));
291
+ console.log('To manually remove:');
292
+ console.log(` 1. rm -rf ${serviceName}/`);
293
+ console.log(` 2. Edit .launchframe to remove "${serviceName}" from installedServices array`);
294
+ }
295
+
296
+ /**
297
+ * Append service definitions to infrastructure docker-compose files
298
+ * Used for integrated services (not standalone like waitlist)
299
+ */
300
+ async function appendServiceToDockerCompose(serviceName, service, projectName, projectConfig) {
301
+ const infrastructurePath = path.resolve(process.cwd(), 'infrastructure');
302
+
303
+ // Define service configurations for each docker-compose file
304
+ const serviceDefinitions = getDockerComposeDefinitions(serviceName, service, projectName, projectConfig);
305
+
306
+ // Append to base docker-compose.yml
307
+ await appendToComposeFile(
308
+ path.join(infrastructurePath, 'docker-compose.yml'),
309
+ serviceDefinitions.base.service,
310
+ serviceDefinitions.base.volumes
311
+ );
312
+ console.log(chalk.green('✓ Added to docker-compose.yml'));
313
+
314
+ // Append to docker-compose.dev.yml
315
+ await appendToComposeFile(
316
+ path.join(infrastructurePath, 'docker-compose.dev.yml'),
317
+ serviceDefinitions.dev.service,
318
+ serviceDefinitions.dev.volumes
319
+ );
320
+ console.log(chalk.green('✓ Added to docker-compose.dev.yml'));
321
+
322
+ // Append to docker-compose.prod.yml
323
+ await appendToComposeFile(
324
+ path.join(infrastructurePath, 'docker-compose.prod.yml'),
325
+ serviceDefinitions.prod.service,
326
+ serviceDefinitions.prod.volumes
327
+ );
328
+ console.log(chalk.green('✓ Added to docker-compose.prod.yml'));
329
+ }
330
+
331
+ /**
332
+ * Get docker-compose service definitions for a service
333
+ */
334
+ function getDockerComposeDefinitions(serviceName, service, projectName, projectConfig) {
335
+ const definitions = {};
336
+
337
+ // Base configuration (docker-compose.yml)
338
+ if (serviceName === 'customers-frontend') {
339
+ definitions.base = {
340
+ service: `
341
+ # ---------------------------------------------------------------------------
342
+ # ${service.displayName} - ${service.techStack}
343
+ # ---------------------------------------------------------------------------
344
+ ${serviceName}:
345
+ networks:
346
+ - ${projectName}-network
347
+ depends_on:
348
+ - backend
349
+ environment:
350
+ - API_BASE_URL=\${API_BASE_URL}
351
+ - WEBSITE_BASE_URL=\${WEBSITE_BASE_URL}
352
+ - FRONTEND_BASE_URL=\${FRONTEND_BASE_URL}
353
+ - USER_FRONTEND_SENTRY_DSN=\${USER_FRONTEND_SENTRY_DSN}
354
+ `,
355
+ volumes: null
356
+ };
357
+ } else {
358
+ definitions.base = {
359
+ service: `
360
+ # ---------------------------------------------------------------------------
361
+ # ${service.displayName} - ${service.techStack}
362
+ # ---------------------------------------------------------------------------
363
+ ${serviceName}:
364
+ networks:
365
+ - ${projectName}-network
366
+ `,
367
+ volumes: null
368
+ };
369
+ }
370
+
371
+ // Development configuration (docker-compose.dev.yml)
372
+ if (serviceName === 'docs') {
373
+ definitions.dev = {
374
+ service: `
375
+ ${serviceName}:
376
+ container_name: ${projectName}-${serviceName}
377
+ build:
378
+ context: ../${serviceName}
379
+ dockerfile: Dockerfile
380
+ target: development
381
+ image: ${projectName}-${serviceName}:dev
382
+ restart: "no"
383
+ volumes:
384
+ - ../${serviceName}/.vitepress:/app/.vitepress
385
+ - ../${serviceName}/index.md:/app/index.md
386
+ - ../${serviceName}/guide:/app/guide
387
+ - ../${serviceName}/public:/app/public
388
+ - ${serviceName}_node_modules:/app/node_modules
389
+ environment:
390
+ - NODE_ENV=development
391
+ ports:
392
+ - "${service.devPort}:${service.devPort}"
393
+ `,
394
+ volumes: ` ${serviceName}_node_modules:
395
+ name: ${projectName}-${serviceName}-node-modules
396
+ `
397
+ };
398
+ } else if (serviceName === 'customers-frontend') {
399
+ definitions.dev = {
400
+ service: `
401
+ ${serviceName}:
402
+ container_name: ${projectName}-${serviceName}
403
+ build:
404
+ context: ../${serviceName}
405
+ dockerfile: Dockerfile
406
+ target: development
407
+ image: ${projectName}-${serviceName}:dev
408
+ volumes:
409
+ - ../${serviceName}:/app
410
+ - ${serviceName}_node_modules:/app/node_modules
411
+ - ${serviceName}_dist:/app/dist
412
+ command: npm run dev -- --host 0.0.0.0 --port ${service.devPort}
413
+ environment:
414
+ - NODE_ENV=development
415
+ - PORT=${service.devPort}
416
+ - API_BASE_URL=\${API_BASE_URL}
417
+ - WEBSITE_BASE_URL=\${WEBSITE_BASE_URL}
418
+ - FRONTEND_BASE_URL=\${FRONTEND_BASE_URL}
419
+ - USER_FRONTEND_SENTRY_DSN=\${USER_FRONTEND_SENTRY_DSN}
420
+ ports:
421
+ - "${service.devPort}:${service.devPort}"
422
+ stdin_open: true
423
+ tty: true
424
+ `,
425
+ volumes: ` ${serviceName}_node_modules:
426
+ name: ${projectName}-${serviceName}-node-modules
427
+ ${serviceName}_dist:
428
+ name: ${projectName}-${serviceName}-dist
429
+ `
430
+ };
431
+ }
432
+
433
+ // Production configuration (docker-compose.prod.yml)
434
+ const githubOrg = projectConfig.githubOrg || 'launchframe';
435
+ const primaryDomain = projectConfig.primaryDomain || 'example.com';
436
+
437
+ if (serviceName === 'docs') {
438
+ definitions.prod = {
439
+ service: `
440
+ ${serviceName}:
441
+ image: ghcr.io/${githubOrg}/${projectName}-${serviceName}:latest
442
+ restart: unless-stopped
443
+ labels:
444
+ - "traefik.enable=true"
445
+ - "traefik.http.routers.${serviceName}.rule=Host(\`${serviceName}.\${PRIMARY_DOMAIN}\`)"
446
+ - "traefik.http.routers.${serviceName}.entrypoints=websecure"
447
+ - "traefik.http.routers.${serviceName}.tls.certresolver=letsencrypt"
448
+ - "traefik.http.services.${serviceName}.loadbalancer.server.port=3000"
449
+ `,
450
+ volumes: null // No volumes needed in prod config
451
+ };
452
+ } else if (serviceName === 'customers-frontend') {
453
+ definitions.prod = {
454
+ service: `
455
+ ${serviceName}:
456
+ image: ghcr.io/${githubOrg}/${projectName}-${serviceName}:latest
457
+ restart: unless-stopped
458
+ healthcheck:
459
+ test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"]
460
+ interval: 30s
461
+ timeout: 10s
462
+ start_period: 40s
463
+ retries: 3
464
+ labels:
465
+ - "traefik.enable=true"
466
+ # Main app router (app.\${PRIMARY_DOMAIN}) - Higher priority
467
+ - "traefik.http.routers.app.rule=Host(\`app.\${PRIMARY_DOMAIN}\`)"
468
+ - "traefik.http.routers.app.entrypoints=websecure"
469
+ - "traefik.http.routers.app.tls.certresolver=letsencrypt"
470
+ - "traefik.http.routers.app.priority=100"
471
+ - "traefik.http.routers.app.service=app"
472
+ # Wildcard router (*.\${PRIMARY_DOMAIN}) - Lower priority, catches undefined subdomains
473
+ - "traefik.http.routers.app-wildcard.rule=HostRegexp(\`{subdomain:[a-z0-9-]+}.\${PRIMARY_DOMAIN}\`)"
474
+ - "traefik.http.routers.app-wildcard.entrypoints=websecure"
475
+ - "traefik.http.routers.app-wildcard.tls.certresolver=letsencrypt"
476
+ - "traefik.http.routers.app-wildcard.tls.domains[0].main=*.\${PRIMARY_DOMAIN}"
477
+ - "traefik.http.routers.app-wildcard.priority=50"
478
+ - "traefik.http.routers.app-wildcard.service=app"
479
+ # Service definition (shared by both routers)
480
+ - "traefik.http.services.app.loadbalancer.server.port=80"
481
+ `,
482
+ volumes: null // No volumes needed in prod config
483
+ };
484
+ }
485
+
486
+ return definitions;
487
+ }
488
+
489
+ /**
490
+ * Append content to a docker-compose file
491
+ * Properly handles separate services and volumes sections
492
+ */
493
+ async function appendToComposeFile(filePath, serviceContent, volumesContent) {
494
+ if (!await fs.pathExists(filePath)) {
495
+ throw new Error(`Docker Compose file not found: ${filePath}`);
496
+ }
497
+
498
+ // Read existing content
499
+ let existingContent = await fs.readFile(filePath, 'utf8');
500
+
501
+ // Extract service name from serviceContent
502
+ const serviceName = serviceContent.match(/^\s+(\w+):/m)?.[1];
503
+ if (!serviceName) {
504
+ throw new Error('Could not extract service name from service definition');
505
+ }
506
+
507
+ // Check if service already exists
508
+ if (existingContent.includes(` ${serviceName}:`)) {
509
+ console.log(chalk.yellow(`⚠️ Service "${serviceName}" already exists in ${path.basename(filePath)}, skipping...`));
510
+ return;
511
+ }
512
+
513
+ // Find where to insert the service (before networks/volumes sections)
514
+ // Look for the section markers to insert services in the right place
515
+ const networksSectionMatch = existingContent.match(/\n# ={5,}\n# Networks\n# ={5,}\nnetworks:/);
516
+ const volumesSectionMatch = existingContent.match(/\n# ={5,}\n# Volumes/);
517
+
518
+ if (networksSectionMatch) {
519
+ // Insert service before the Networks section header
520
+ const insertPosition = networksSectionMatch.index;
521
+ existingContent =
522
+ existingContent.slice(0, insertPosition) +
523
+ '\n' + serviceContent +
524
+ existingContent.slice(insertPosition);
525
+ } else if (volumesSectionMatch) {
526
+ // Insert service before the Volumes section header
527
+ const insertPosition = volumesSectionMatch.index;
528
+ existingContent =
529
+ existingContent.slice(0, insertPosition) +
530
+ '\n' + serviceContent +
531
+ existingContent.slice(insertPosition);
532
+ } else {
533
+ // No sections found, append at end
534
+ existingContent += '\n' + serviceContent;
535
+ }
536
+
537
+ // Handle volumes section if provided
538
+ if (volumesContent) {
539
+ // Find the existing "volumes:" section
540
+ const volumesKeyMatch = existingContent.match(/\nvolumes:\n/);
541
+
542
+ if (volumesKeyMatch) {
543
+ // Find the position after "volumes:\n"
544
+ const volumesPosition = volumesKeyMatch.index + volumesKeyMatch[0].length;
545
+
546
+ // Insert the new volume definition right after "volumes:"
547
+ existingContent =
548
+ existingContent.slice(0, volumesPosition) +
549
+ volumesContent +
550
+ existingContent.slice(volumesPosition);
551
+ } else {
552
+ // No volumes section exists yet, create one
553
+ existingContent += '\n# =============================================================================\n';
554
+ existingContent += '# Volumes\n';
555
+ existingContent += '# =============================================================================\n';
556
+ existingContent += 'volumes:\n';
557
+ existingContent += volumesContent;
558
+ }
559
+ }
560
+
561
+ // Write back to file
562
+ await fs.writeFile(filePath, existingContent, 'utf8');
563
+ }
564
+
565
+ module.exports = {
566
+ serviceAdd,
567
+ serviceList,
568
+ serviceRemove
569
+ };