@launchframe/cli 1.0.0-beta.17 → 1.0.0-beta.19

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/CLAUDE.md ADDED
@@ -0,0 +1,27 @@
1
+ # LaunchFrame CLI
2
+
3
+ CLI tool for generating new projects from the LaunchFrame template.
4
+
5
+ ## Purpose
6
+
7
+ The CLI takes user input (project name, domain, GitHub org, etc.) and:
8
+ 1. Copies the `services/` template
9
+ 2. Replaces all `{{TEMPLATE_VARIABLES}}`
10
+ 3. Generates secrets (DB password, auth secret)
11
+ 4. Sets up the project structure
12
+
13
+ ## Template Variables
14
+
15
+ The CLI replaces these placeholders in all files:
16
+ - `{{PROJECT_NAME}}` - lowercase project name
17
+ - `{{PROJECT_NAME_UPPER}}` - uppercase project name
18
+ - `{{GITHUB_ORG}}` - GitHub organization/username
19
+ - `{{PRIMARY_DOMAIN}}` - main domain (e.g., mysaas.com)
20
+ - `{{ADMIN_EMAIL}}` - admin email for Let's Encrypt
21
+ - `{{VPS_HOST}}` - VPS hostname/IP
22
+ - `{{BETTER_AUTH_SECRET}}` - auto-generated (32+ chars)
23
+ - `{{DB_PASSWORD}}` - auto-generated
24
+
25
+ ## Development
26
+
27
+ TODO: CLI implementation details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchframe/cli",
3
- "version": "1.0.0-beta.17",
3
+ "version": "1.0.0-beta.19",
4
4
  "description": "Production-ready B2B SaaS boilerplate with subscriptions, credits, and multi-tenancy",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1,12 +1,8 @@
1
1
  const chalk = require('chalk');
2
- const { exec } = require('child_process');
3
- const { promisify } = require('util');
4
- const ora = require('ora');
5
2
  const path = require('path');
6
3
  const { requireProject, getProjectConfig } = require('../utils/project-helpers');
7
- const { checkDockerRunning, loginToGHCR, buildFullAppImages, buildAndPushImage } = require('../utils/docker-helper');
8
-
9
- const execAsync = promisify(exec);
4
+ const { buildAndPushWorkflow } = require('../utils/docker-helper');
5
+ const { pullImagesOnVPS, restartServicesOnVPS } = require('../utils/ssh-helper');
10
6
 
11
7
  /**
12
8
  * Build, push, and deploy Docker images
@@ -29,102 +25,45 @@ async function deployBuild(serviceName) {
29
25
  process.exit(1);
30
26
  }
31
27
 
32
- const { vpsHost, vpsUser, vpsAppFolder, githubOrg } = config.deployment;
28
+ const { vpsHost, vpsUser, vpsAppFolder, githubOrg, ghcrToken } = config.deployment;
33
29
  const { projectName, installedServices } = config;
34
30
  const envProdPath = path.join(projectRoot, 'infrastructure', '.env.prod');
35
31
 
36
- // Step 1: Check Docker is running
37
- console.log(chalk.yellow('🐳 Step 1: Checking Docker...\n'));
38
-
39
- const dockerSpinner = ora('Checking Docker...').start();
40
-
41
- const dockerRunning = await checkDockerRunning();
42
- if (!dockerRunning) {
43
- dockerSpinner.fail('Docker is not running');
44
- console.log(chalk.red('\nāŒ Please start Docker and try again.\n'));
45
- process.exit(1);
46
- }
47
-
48
- dockerSpinner.succeed('Docker is running');
49
-
50
- // Step 2: Login to GHCR
51
- console.log(chalk.yellow('\nšŸ” Step 2: Logging in to GitHub Container Registry...\n'));
52
-
53
- const ghcrToken = config.deployment.ghcrToken;
54
- if (!ghcrToken) {
55
- console.log(chalk.red('āŒ Error: GHCR token not found in .launchframe config\n'));
56
- console.log(chalk.gray('Run deploy:configure to set up your GitHub token.\n'));
57
- process.exit(1);
58
- }
32
+ // Step 1-3: Build and push images
33
+ console.log(chalk.yellow('🐳 Step 1: Building and pushing images...\n'));
59
34
 
60
35
  try {
61
- await loginToGHCR(githubOrg, ghcrToken);
36
+ await buildAndPushWorkflow({
37
+ projectRoot,
38
+ projectName,
39
+ githubOrg,
40
+ ghcrToken,
41
+ envProdPath,
42
+ installedServices,
43
+ serviceName
44
+ });
62
45
  } catch (error) {
63
46
  console.log(chalk.red(`\nāŒ ${error.message}\n`));
64
47
  process.exit(1);
65
48
  }
66
49
 
67
- // Step 3: Build and push images
68
- console.log(chalk.yellow('\nšŸ“¦ Step 3: Building and pushing images...\n'));
69
-
70
- try {
71
- if (serviceName) {
72
- // Build specific service
73
- if (!installedServices.includes(serviceName)) {
74
- console.log(chalk.red(`āŒ Service "${serviceName}" not found in installed services.\n`));
75
- console.log(chalk.gray(`Available services: ${installedServices.join(', ')}\n`));
76
- process.exit(1);
77
- }
78
-
79
- const registry = `ghcr.io/${githubOrg}`;
80
- await buildAndPushImage(
81
- serviceName,
82
- path.join(projectRoot, serviceName),
83
- registry,
84
- projectName
85
- );
86
- console.log(chalk.green.bold(`\nāœ… ${serviceName} built and pushed to GHCR!\n`));
87
- } else {
88
- // Build all services
89
- await buildFullAppImages(projectRoot, projectName, githubOrg, envProdPath, installedServices);
90
- console.log(chalk.green.bold('\nāœ… All images built and pushed to GHCR!\n'));
91
- }
92
- } catch (error) {
93
- console.log(chalk.red(`\nāŒ Build failed: ${error.message}\n`));
94
- process.exit(1);
95
- }
96
-
97
50
  // Step 4: Pull images on VPS
98
- console.log(chalk.yellow('\nšŸš€ Step 4: Pulling images on VPS...\n'));
99
-
100
- const pullSpinner = ora('Pulling images on VPS...').start();
51
+ console.log(chalk.yellow('šŸš€ Step 2: Pulling images on VPS...\n'));
101
52
 
102
53
  try {
103
- await execAsync(
104
- `ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull"`,
105
- { timeout: 600000 } // 10 minutes
106
- );
107
- pullSpinner.succeed('Images pulled on VPS');
54
+ await pullImagesOnVPS(vpsUser, vpsHost, vpsAppFolder);
108
55
  } catch (error) {
109
- pullSpinner.fail('Failed to pull images');
110
- console.log(chalk.red(`\nāŒ Error: ${error.message}\n`));
56
+ console.log(chalk.red(`\nāŒ ${error.message}\n`));
111
57
  process.exit(1);
112
58
  }
113
59
 
114
60
  // Step 5: Restart services
115
- console.log(chalk.yellow('\nšŸ”„ Step 5: Restarting services...\n'));
116
-
117
- const restartSpinner = ora('Restarting services...').start();
61
+ console.log(chalk.yellow('\nšŸ”„ Step 3: Restarting services...\n'));
118
62
 
119
63
  try {
120
- await execAsync(
121
- `ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d"`,
122
- { timeout: 300000 } // 5 minutes
123
- );
124
- restartSpinner.succeed('Services restarted');
64
+ await restartServicesOnVPS(vpsUser, vpsHost, vpsAppFolder);
125
65
  } catch (error) {
126
- restartSpinner.fail('Failed to restart services');
127
- console.log(chalk.red(`\nāŒ Error: ${error.message}\n`));
66
+ console.log(chalk.red(`\nāŒ ${error.message}\n`));
128
67
  process.exit(1);
129
68
  }
130
69
 
@@ -59,8 +59,8 @@ async function deployConfigure() {
59
59
  console.log(chalk.yellow('\nāš™ļø Updating configuration files...\n'));
60
60
 
61
61
  // Files that need template variable replacement
62
+ // Note: infrastructure/.env is NOT updated - it's for local development only
62
63
  const filesToUpdate = [
63
- 'infrastructure/.env',
64
64
  'infrastructure/.env.example',
65
65
  'infrastructure/docker-compose.yml',
66
66
  'infrastructure/docker-compose.dev.yml',
@@ -1,6 +1,5 @@
1
1
  const chalk = require('chalk');
2
2
  const path = require('path');
3
- const fs = require('fs-extra');
4
3
  const ora = require('ora');
5
4
  const { requireProject, getProjectConfig } = require('../utils/project-helpers');
6
5
  const { validateEnvProd } = require('../utils/env-validator');
@@ -9,8 +8,10 @@ const {
9
8
  checkSSHKeys,
10
9
  executeSSH,
11
10
  copyFileToVPS,
12
- copyDirectoryToVPS
11
+ copyDirectoryToVPS,
12
+ pullImagesOnVPS
13
13
  } = require('../utils/ssh-helper');
14
+ const { buildAndPushWorkflow } = require('../utils/docker-helper');
14
15
 
15
16
  /**
16
17
  * Initial VPS setup - copy infrastructure files and configure environment
@@ -30,8 +31,8 @@ async function deployInit() {
30
31
  process.exit(1);
31
32
  }
32
33
 
33
- const { vpsHost, vpsUser, vpsAppFolder, githubOrg } = config.deployment;
34
- const { projectName } = config;
34
+ const { vpsHost, vpsUser, vpsAppFolder, githubOrg, ghcrToken } = config.deployment;
35
+ const { projectName, installedServices } = config;
35
36
  const projectRoot = process.cwd();
36
37
  const envProdPath = path.join(projectRoot, 'infrastructure', '.env.prod');
37
38
 
@@ -86,42 +87,18 @@ async function deployInit() {
86
87
  spinner.succeed('Connected to VPS successfully');
87
88
  console.log();
88
89
 
89
- // Step 3.5: Build and push Docker images
90
- console.log(chalk.yellow('🐳 Step 3.5: Building Docker images locally...\n'));
91
-
92
- // Check if Docker is running
93
- const {
94
- checkDockerRunning,
95
- loginToGHCR,
96
- buildFullAppImages
97
- } = require('../utils/docker-helper');
98
-
99
- const dockerRunning = await checkDockerRunning();
100
- if (!dockerRunning) {
101
- console.log(chalk.red('āŒ Docker is not running\n'));
102
- console.log(chalk.gray('Please start Docker Desktop and try again.\n'));
103
- console.log(chalk.gray('Docker is required to build production images for deployment.\n'));
104
- process.exit(1);
105
- }
106
-
107
- // Validate GHCR token is configured
108
- const { ghcrToken } = config.deployment || {};
109
- if (!ghcrToken) {
110
- console.log(chalk.red('āŒ GHCR token not configured\n'));
111
- console.log(chalk.gray('Run this command first:'));
112
- console.log(chalk.white(' launchframe deploy:configure\n'));
113
- process.exit(1);
114
- }
90
+ // Step 4: Build and push Docker images
91
+ console.log(chalk.yellow('🐳 Step 4: Building Docker images locally...\n'));
115
92
 
116
93
  try {
117
- // Login to GHCR
118
- await loginToGHCR(githubOrg, ghcrToken);
119
-
120
- // Build full-app images (only for installed services)
121
- const installedServices = config.installedServices || ['backend', 'admin-portal', 'website'];
122
- await buildFullAppImages(projectRoot, projectName, githubOrg, envProdPath, installedServices);
123
-
124
- console.log(chalk.green.bold('\nāœ… All images built and pushed to GHCR!\n'));
94
+ await buildAndPushWorkflow({
95
+ projectRoot,
96
+ projectName,
97
+ githubOrg,
98
+ ghcrToken,
99
+ envProdPath,
100
+ installedServices: installedServices || ['backend', 'admin-portal', 'website']
101
+ });
125
102
  } catch (error) {
126
103
  console.log(chalk.red('\nāŒ Failed to build Docker images\n'));
127
104
  console.log(chalk.gray('Error:'), error.message, '\n');
@@ -135,16 +112,15 @@ async function deployInit() {
135
112
  process.exit(1);
136
113
  }
137
114
 
138
-
139
- // Step 4: Create app directory and copy infrastructure files
140
- console.log(chalk.yellow('šŸ“¦ Step 4: Setting up application on VPS...\n'));
115
+ // Step 5: Create app directory and copy infrastructure files
116
+ console.log(chalk.yellow('šŸ“¦ Step 5: Setting up application on VPS...\n'));
141
117
 
142
118
  const setupSpinner = ora('Creating app directory...').start();
143
119
 
144
120
  try {
145
121
  // Create infrastructure directory on VPS
146
122
  await executeSSH(vpsUser, vpsHost, `mkdir -p ${vpsAppFolder}/infrastructure`);
147
-
123
+
148
124
  setupSpinner.text = 'Copying infrastructure files to VPS...';
149
125
 
150
126
  // Copy entire infrastructure directory to VPS
@@ -205,8 +181,8 @@ async function deployInit() {
205
181
  // If error, waitlist probably not running - continue
206
182
  }
207
183
 
208
- // Step 5: Copy .env.prod to VPS (overwrites .env copied from infrastructure/)
209
- console.log(chalk.yellow('\nšŸ“„ Step 5: Configuring production environment...\n'));
184
+ // Step 6: Copy .env.prod to VPS (overwrites .env copied from infrastructure/)
185
+ console.log(chalk.yellow('\nšŸ“„ Step 6: Configuring production environment...\n'));
210
186
 
211
187
  const envSpinner = ora('Copying .env.prod to VPS...').start();
212
188
 
@@ -220,27 +196,18 @@ async function deployInit() {
220
196
  process.exit(1);
221
197
  }
222
198
 
223
- // Step 6: Pull Docker images
224
- console.log(chalk.yellow('\n🐳 Step 6: Pulling Docker images...\n'));
199
+ // Step 7: Pull Docker images
200
+ console.log(chalk.yellow('\n🐳 Step 7: Pulling Docker images on VPS...\n'));
225
201
  console.log(chalk.gray('This may take several minutes...\n'));
226
202
 
227
- const dockerSpinner = ora('Pulling Docker images...').start();
228
-
229
203
  try {
230
- await executeSSH(
231
- vpsUser,
232
- vpsHost,
233
- `cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull`,
234
- { timeout: 600000 } // 10 minutes for image pull
235
- );
236
- dockerSpinner.succeed('Docker images pulled successfully');
204
+ await pullImagesOnVPS(vpsUser, vpsHost, vpsAppFolder);
237
205
  } catch (error) {
238
- dockerSpinner.fail('Failed to pull Docker images');
239
206
  console.log(chalk.yellow(`\nāš ļø Warning: ${error.message}\n`));
240
207
  console.log(chalk.gray('This might mean Docker is not installed on the VPS.'));
241
208
  console.log(chalk.gray('Please install Docker and Docker Compose:\n'));
242
209
  console.log(chalk.white(' curl -fsSL https://get.docker.com | sh'));
243
- console.log(chalk.white(' sudo usermod -aG docker ${vpsUser}\n'));
210
+ console.log(chalk.white(` sudo usermod -aG docker ${vpsUser}\n`));
244
211
  process.exit(1);
245
212
  }
246
213
 
@@ -163,12 +163,21 @@ async function deploySetEnv() {
163
163
  // Update production URLs based on deployment config
164
164
  if (config.deployment?.primaryDomain) {
165
165
  const domain = config.deployment.primaryDomain;
166
+ const adminEmail = config.deployment.adminEmail || `admin@${domain}`;
167
+
168
+ // First, replace all {{PRIMARY_DOMAIN}} and {{ADMIN_EMAIL}} placeholders globally
169
+ envContent = envContent.split('{{PRIMARY_DOMAIN}}').join(domain);
170
+ envContent = envContent.split('{{ADMIN_EMAIL}}').join(adminEmail);
171
+
172
+ // Then update specific URL variables for production
166
173
  const urlReplacements = {
167
174
  'PRIMARY_DOMAIN': domain,
175
+ 'NODE_ENV': 'production',
168
176
  'API_BASE_URL': `https://api.${domain}`,
169
177
  'ADMIN_BASE_URL': `https://admin.${domain}`,
170
- 'FRONTEND_BASE_URL': `https://app.${domain}`,
171
- 'WEBSITE_BASE_URL': `https://${domain}`
178
+ 'FRONTEND_BASE_URL': `https://${domain}`,
179
+ 'WEBSITE_BASE_URL': `https://www.${domain}`,
180
+ 'GOOGLE_REDIRECT_URI': `https://api.${domain}/auth/google/callback`
172
181
  };
173
182
 
174
183
  for (const [key, value] of Object.entries(urlReplacements)) {
@@ -21,7 +21,6 @@ const VARIANT_CONFIG = {
21
21
  // Complete files/folders to copy
22
22
  files: [
23
23
  'src/modules/domain/projects', // Entire projects module
24
- 'src/modules/domain/ai/services/project-config.service.ts', // Project config service
25
24
  'src/guards/project-ownership.guard.ts', // Project ownership guard (header-based)
26
25
  'src/guards/project-param.guard.ts', // Project param guard (route-based)
27
26
  'src/modules/users/users.service.ts', // Users service with multi-tenant support
@@ -30,13 +29,8 @@ const VARIANT_CONFIG = {
30
29
  ],
31
30
 
32
31
  // Code sections to insert into base template files
32
+ // Note: main.ts uses PRIMARY_DOMAIN env var for dynamic CORS - no sections needed
33
33
  sections: {
34
- 'src/main.ts': [
35
- 'PROJECT_IMPORTS', // Add project-related imports
36
- 'PROJECT_CUSTOM_DOMAINS', // Add custom domains query
37
- 'PROJECT_CUSTOM_DOMAINS_CORS', // Add custom domains to CORS
38
- 'PROJECT_GUARD' // Add ProjectOwnershipGuard registration
39
- ],
40
34
  'src/modules/app/app.module.ts': [
41
35
  'PROJECTS_MODULE_IMPORT', // Add ProjectsModule import
42
36
  'PROJECTS_MODULE' // Add ProjectsModule to imports array
@@ -159,7 +159,6 @@ async function buildFullAppImages(projectRoot, projectName, githubOrg, envFilePa
159
159
  `DOCS_URL=${envVars.DOCS_URL || ''}`,
160
160
  `CONTACT_EMAIL=${envVars.CONTACT_EMAIL || ''}`,
161
161
  `CTA_LINK=${envVars.CTA_LINK || ''}`,
162
- `LIVE_DEMO_URL=${envVars.LIVE_DEMO_URL || ''}`,
163
162
  `MIXPANEL_PROJECT_TOKEN=${envVars.MIXPANEL_PROJECT_TOKEN || ''}`,
164
163
  `GOOGLE_ANALYTICS_ID=${envVars.GOOGLE_ANALYTICS_ID || ''}`
165
164
  ];
@@ -245,16 +244,130 @@ async function buildWaitlistImage(projectRoot, projectName, githubOrg) {
245
244
  });
246
245
 
247
246
  spinner.succeed(`waitlist built and pushed successfully`);
247
+
248
+ // Clean up local image after push
249
+ const cleanupSpinner = ora('Cleaning up local waitlist image...').start();
250
+ try {
251
+ await execAsync(`docker rmi ${imageName}`, { timeout: 30000 });
252
+ cleanupSpinner.succeed('Cleaned up local waitlist image');
253
+ } catch (error) {
254
+ cleanupSpinner.info('Could not remove local waitlist image (may be in use)');
255
+ }
248
256
  } catch (error) {
249
257
  spinner.fail(`Failed to build waitlist`);
250
258
  throw new Error(`Build failed for waitlist: ${error.message}`);
251
259
  }
252
260
  }
253
261
 
262
+ /**
263
+ * Clean up local Docker images after push
264
+ * @param {string} registry - Registry URL (e.g., 'ghcr.io/myorg')
265
+ * @param {string} projectName - Project name
266
+ * @param {string[]} services - List of services to clean up
267
+ * @returns {Promise<void>}
268
+ */
269
+ async function cleanupLocalImages(registry, projectName, services) {
270
+ const spinner = ora('Cleaning up local Docker images...').start();
271
+
272
+ const imagesToRemove = services.map(service => `${registry}/${projectName}-${service}:latest`);
273
+ let removedCount = 0;
274
+
275
+ for (const imageName of imagesToRemove) {
276
+ try {
277
+ await execAsync(`docker rmi ${imageName}`, { timeout: 30000 });
278
+ removedCount++;
279
+ } catch (error) {
280
+ // Image might not exist or be in use, continue
281
+ }
282
+ }
283
+
284
+ if (removedCount > 0) {
285
+ spinner.succeed(`Cleaned up ${removedCount} local Docker image(s)`);
286
+ } else {
287
+ spinner.info('No local images to clean up');
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Complete build and push workflow - checks Docker, logs in to GHCR, builds and pushes images
293
+ * @param {Object} options - Workflow options
294
+ * @param {string} options.projectRoot - Project root directory
295
+ * @param {string} options.projectName - Project name
296
+ * @param {string} options.githubOrg - GitHub organization/username
297
+ * @param {string} options.ghcrToken - GitHub Container Registry token
298
+ * @param {string} options.envProdPath - Path to .env.prod file
299
+ * @param {string[]} options.installedServices - List of installed services
300
+ * @param {string} [options.serviceName] - Optional specific service to build (if not provided, builds all)
301
+ * @returns {Promise<void>}
302
+ */
303
+ async function buildAndPushWorkflow(options) {
304
+ const {
305
+ projectRoot,
306
+ projectName,
307
+ githubOrg,
308
+ ghcrToken,
309
+ envProdPath,
310
+ installedServices,
311
+ serviceName
312
+ } = options;
313
+
314
+ // Step 1: Check Docker is running
315
+ const dockerSpinner = ora('Checking Docker...').start();
316
+
317
+ const dockerRunning = await checkDockerRunning();
318
+ if (!dockerRunning) {
319
+ dockerSpinner.fail('Docker is not running');
320
+ throw new Error('Docker is not running. Please start Docker and try again.');
321
+ }
322
+
323
+ dockerSpinner.succeed('Docker is running');
324
+
325
+ // Step 2: Login to GHCR
326
+ if (!ghcrToken) {
327
+ throw new Error('GHCR token not found. Run deploy:configure to set up your GitHub token.');
328
+ }
329
+
330
+ await loginToGHCR(githubOrg, ghcrToken);
331
+
332
+ // Step 3: Build and push images
333
+ console.log(chalk.yellow('\nšŸ“¦ Building and pushing images...\n'));
334
+
335
+ const registry = `ghcr.io/${githubOrg}`;
336
+
337
+ if (serviceName) {
338
+ // Build specific service
339
+ if (!installedServices.includes(serviceName)) {
340
+ throw new Error(`Service "${serviceName}" not found in installed services. Available: ${installedServices.join(', ')}`);
341
+ }
342
+
343
+ await buildAndPushImage(
344
+ serviceName,
345
+ path.join(projectRoot, serviceName),
346
+ registry,
347
+ projectName
348
+ );
349
+
350
+ // Clean up local image after push
351
+ await cleanupLocalImages(registry, projectName, [serviceName]);
352
+
353
+ console.log(chalk.green.bold(`\nāœ… ${serviceName} built and pushed to GHCR!\n`));
354
+ } else {
355
+ // Build all services
356
+ await buildFullAppImages(projectRoot, projectName, githubOrg, envProdPath, installedServices);
357
+
358
+ // Clean up local images after push
359
+ await cleanupLocalImages(registry, projectName, installedServices);
360
+
361
+ console.log(chalk.green.bold('\nāœ… All images built and pushed to GHCR!\n'));
362
+ }
363
+ }
364
+
254
365
  module.exports = {
255
366
  checkDockerRunning,
256
367
  loginToGHCR,
257
368
  buildAndPushImage,
258
369
  buildFullAppImages,
259
- buildWaitlistImage
370
+ buildWaitlistImage,
371
+ buildAndPushWorkflow,
372
+ cleanupLocalImages
260
373
  };
@@ -31,11 +31,12 @@ async function generateEnvFile(projectRoot, answers) {
31
31
  };
32
32
 
33
33
  // Create variable mappings
34
+ // Note: PRIMARY_DOMAIN and ADMIN_EMAIL are NOT replaced here - they stay as placeholders
35
+ // until deploy:configure and deploy:set-env are run
34
36
  const variables = {
35
37
  '{{PROJECT_NAME}}': answers.projectName,
36
38
  '{{PROJECT_NAME_UPPER}}': answers.projectNameUpper,
37
- '{{PRIMARY_DOMAIN}}': answers.primaryDomain,
38
- '{{ADMIN_EMAIL}}': answers.adminEmail,
39
+ '{{PROJECT_DISPLAY_NAME}}': answers.projectDisplayName,
39
40
 
40
41
  // Replace placeholder passwords with generated secrets
41
42
  'your_secure_postgres_password': secrets.DB_PASSWORD,
@@ -43,10 +44,12 @@ async function generateEnvFile(projectRoot, answers) {
43
44
  'your_bull_admin_token': secrets.BULL_ADMIN_TOKEN
44
45
  };
45
46
 
46
- // Replace variables in template
47
+ // Replace variables in template (only those with defined values)
47
48
  let envContent = envTemplate;
48
49
  for (const [placeholder, value] of Object.entries(variables)) {
49
- envContent = envContent.split(placeholder).join(value);
50
+ if (value !== undefined && value !== null) {
51
+ envContent = envContent.split(placeholder).join(value);
52
+ }
50
53
  }
51
54
 
52
55
  // Write .env file
@@ -59,13 +59,15 @@ async function validateEnvProd(envProdPath) {
59
59
  }
60
60
 
61
61
  /**
62
- * Generate a secure random string for secrets
62
+ * Generate a secure random string for secrets (URL-safe)
63
63
  * @param {number} length - Length of string to generate
64
64
  * @returns {string}
65
65
  */
66
66
  function generateSecret(length = 32) {
67
67
  const crypto = require('crypto');
68
- return crypto.randomBytes(length).toString('base64').slice(0, length);
68
+ // Use hex encoding to avoid URL-unsafe characters (+, /, =)
69
+ // Hex produces 2 chars per byte, so divide by 2
70
+ return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length);
69
71
  }
70
72
 
71
73
  module.exports = {
@@ -209,6 +209,54 @@ function showDeployKeyInstructions(vpsUser, vpsHost, githubOrg, projectName) {
209
209
  console.log(chalk.gray(' launchframe deploy:init\n'));
210
210
  }
211
211
 
212
+ /**
213
+ * Pull Docker images on VPS
214
+ * @param {string} vpsUser - SSH username
215
+ * @param {string} vpsHost - VPS hostname or IP
216
+ * @param {string} vpsAppFolder - App folder path on VPS
217
+ * @returns {Promise<void>}
218
+ */
219
+ async function pullImagesOnVPS(vpsUser, vpsHost, vpsAppFolder) {
220
+ const ora = require('ora');
221
+
222
+ const spinner = ora('Pulling images on VPS...').start();
223
+
224
+ try {
225
+ await execAsync(
226
+ `ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull"`,
227
+ { timeout: 600000 } // 10 minutes
228
+ );
229
+ spinner.succeed('Images pulled on VPS');
230
+ } catch (error) {
231
+ spinner.fail('Failed to pull images on VPS');
232
+ throw new Error(`Failed to pull images: ${error.message}`);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Restart services on VPS
238
+ * @param {string} vpsUser - SSH username
239
+ * @param {string} vpsHost - VPS hostname or IP
240
+ * @param {string} vpsAppFolder - App folder path on VPS
241
+ * @returns {Promise<void>}
242
+ */
243
+ async function restartServicesOnVPS(vpsUser, vpsHost, vpsAppFolder) {
244
+ const ora = require('ora');
245
+
246
+ const spinner = ora('Restarting services...').start();
247
+
248
+ try {
249
+ await execAsync(
250
+ `ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d"`,
251
+ { timeout: 300000 } // 5 minutes
252
+ );
253
+ spinner.succeed('Services restarted');
254
+ } catch (error) {
255
+ spinner.fail('Failed to restart services');
256
+ throw new Error(`Failed to restart services: ${error.message}`);
257
+ }
258
+ }
259
+
212
260
  module.exports = {
213
261
  testSSHConnection,
214
262
  checkSSHKeys,
@@ -216,5 +264,7 @@ module.exports = {
216
264
  copyFileToVPS,
217
265
  copyDirectoryToVPS,
218
266
  checkRepoPrivacy,
219
- showDeployKeyInstructions
267
+ showDeployKeyInstructions,
268
+ pullImagesOnVPS,
269
+ restartServicesOnVPS
220
270
  };
@@ -62,7 +62,9 @@ async function processServiceVariant(
62
62
  const variantConfig = serviceConfig.variants[variantName];
63
63
 
64
64
  if (!variantConfig) {
65
- logger.warn(`No configuration found for variant: ${variantName}, skipping`);
65
+ // Silently skip - not every service needs every variant combination
66
+ // (e.g., b2b2c_multi-tenant may only apply to backend, not admin-portal)
67
+ logger.detail(`Skipping ${variantName} (not applicable to this service)`, 3);
66
68
  continue;
67
69
  }
68
70