@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.
- package/README.md +59 -0
- package/package.json +45 -0
- package/src/commands/deploy-configure.js +219 -0
- package/src/commands/deploy-init.js +277 -0
- package/src/commands/deploy-set-env.js +232 -0
- package/src/commands/deploy-up.js +144 -0
- package/src/commands/docker-build.js +44 -0
- package/src/commands/docker-destroy.js +93 -0
- package/src/commands/docker-down.js +44 -0
- package/src/commands/docker-logs.js +69 -0
- package/src/commands/docker-up.js +73 -0
- package/src/commands/doctor.js +20 -0
- package/src/commands/help.js +79 -0
- package/src/commands/init.js +126 -0
- package/src/commands/service.js +569 -0
- package/src/commands/waitlist-deploy.js +231 -0
- package/src/commands/waitlist-down.js +50 -0
- package/src/commands/waitlist-logs.js +55 -0
- package/src/commands/waitlist-up.js +95 -0
- package/src/generator.js +190 -0
- package/src/index.js +158 -0
- package/src/prompts.js +200 -0
- package/src/services/registry.js +48 -0
- package/src/services/variant-config.js +349 -0
- package/src/utils/docker-helper.js +237 -0
- package/src/utils/env-generator.js +88 -0
- package/src/utils/env-validator.js +75 -0
- package/src/utils/file-ops.js +87 -0
- package/src/utils/project-helpers.js +104 -0
- package/src/utils/section-replacer.js +71 -0
- package/src/utils/ssh-helper.js +220 -0
- package/src/utils/variable-replacer.js +95 -0
- package/src/utils/variant-processor.js +313 -0
|
@@ -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
|
+
};
|