@launchframe/cli 1.0.0-beta.14 โ†’ 1.0.0-beta.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@launchframe/cli",
3
- "version": "1.0.0-beta.14",
3
+ "version": "1.0.0-beta.17",
4
4
  "description": "Production-ready B2B SaaS boilerplate with subscriptions, credits, and multi-tenancy",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,137 @@
1
+ const chalk = require('chalk');
2
+ const { exec } = require('child_process');
3
+ const { promisify } = require('util');
4
+ const ora = require('ora');
5
+ const path = require('path');
6
+ const { requireProject, getProjectConfig } = require('../utils/project-helpers');
7
+ const { checkDockerRunning, loginToGHCR, buildFullAppImages, buildAndPushImage } = require('../utils/docker-helper');
8
+
9
+ const execAsync = promisify(exec);
10
+
11
+ /**
12
+ * Build, push, and deploy Docker images
13
+ * @param {string} [serviceName] - Optional specific service to build (e.g., 'backend', 'admin-portal')
14
+ */
15
+ async function deployBuild(serviceName) {
16
+ requireProject();
17
+
18
+ const serviceLabel = serviceName ? `(${serviceName})` : '(all services)';
19
+ console.log(chalk.blue.bold(`\n๐Ÿ”จ LaunchFrame Build & Deploy ${serviceLabel}\n`));
20
+
21
+ const config = getProjectConfig();
22
+ const projectRoot = process.cwd();
23
+
24
+ // Validate deployment is configured
25
+ if (!config.deployConfigured || !config.deployment) {
26
+ console.log(chalk.red('โŒ Error: Deployment not configured yet\n'));
27
+ console.log(chalk.gray('Run this command first:'));
28
+ console.log(chalk.white(' launchframe deploy:configure\n'));
29
+ process.exit(1);
30
+ }
31
+
32
+ const { vpsHost, vpsUser, vpsAppFolder, githubOrg } = config.deployment;
33
+ const { projectName, installedServices } = config;
34
+ const envProdPath = path.join(projectRoot, 'infrastructure', '.env.prod');
35
+
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
+ }
59
+
60
+ try {
61
+ await loginToGHCR(githubOrg, ghcrToken);
62
+ } catch (error) {
63
+ console.log(chalk.red(`\nโŒ ${error.message}\n`));
64
+ process.exit(1);
65
+ }
66
+
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
+ // 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();
101
+
102
+ 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');
108
+ } catch (error) {
109
+ pullSpinner.fail('Failed to pull images');
110
+ console.log(chalk.red(`\nโŒ Error: ${error.message}\n`));
111
+ process.exit(1);
112
+ }
113
+
114
+ // Step 5: Restart services
115
+ console.log(chalk.yellow('\n๐Ÿ”„ Step 5: Restarting services...\n'));
116
+
117
+ const restartSpinner = ora('Restarting services...').start();
118
+
119
+ 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');
125
+ } catch (error) {
126
+ restartSpinner.fail('Failed to restart services');
127
+ console.log(chalk.red(`\nโŒ Error: ${error.message}\n`));
128
+ process.exit(1);
129
+ }
130
+
131
+ // Success!
132
+ console.log(chalk.green.bold('\nโœ… Build and deploy complete!\n'));
133
+
134
+ console.log(chalk.gray('Your updated application is now running.\n'));
135
+ }
136
+
137
+ module.exports = { deployBuild };
@@ -164,6 +164,7 @@ async function deploySetEnv() {
164
164
  if (config.deployment?.primaryDomain) {
165
165
  const domain = config.deployment.primaryDomain;
166
166
  const urlReplacements = {
167
+ 'PRIMARY_DOMAIN': domain,
167
168
  'API_BASE_URL': `https://api.${domain}`,
168
169
  'ADMIN_BASE_URL': `https://admin.${domain}`,
169
170
  'FRONTEND_BASE_URL': `https://app.${domain}`,
@@ -106,7 +106,7 @@ async function deployUp() {
106
106
 
107
107
  try {
108
108
  const { stdout: psOutput } = await execAsync(
109
- `ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml ps"`,
109
+ `ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml ps"`,
110
110
  { timeout: 30000 }
111
111
  );
112
112
 
@@ -139,7 +139,7 @@ async function deployUp() {
139
139
  console.log(chalk.gray(' Just push to GitHub - CI/CD will handle deployment automatically!\n'));
140
140
 
141
141
  console.log(chalk.white('3. Monitor services:'));
142
- console.log(chalk.gray(` Run: ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose logs -f"\n`));
142
+ console.log(chalk.gray(` Run: ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml logs -f"\n`));
143
143
  }
144
144
 
145
145
  module.exports = { deployUp };
@@ -7,16 +7,19 @@ const { isLaunchFrameProject, isWaitlistInstalled } = require('../utils/project-
7
7
  function help() {
8
8
  const inProject = isLaunchFrameProject();
9
9
 
10
- console.log(chalk.blue.bold('\n๐Ÿš€ LaunchFrame CLI\n'));
10
+ console.log(chalk.blue.bold('\nLaunchFrame CLI\n'));
11
11
  console.log(chalk.white('Usage:'));
12
- console.log(chalk.gray(' launchframe [command]\n'));
12
+ console.log(chalk.gray(' launchframe [command] [options]\n'));
13
+ console.log(chalk.white('Global options:'));
14
+ console.log(chalk.gray(' --verbose, -v Show detailed output\n'));
13
15
 
14
16
  if (inProject) {
15
17
  console.log(chalk.white('Deployment commands:'));
16
18
  console.log(chalk.gray(' deploy:configure Configure production deployment settings'));
17
19
  console.log(chalk.gray(' deploy:set-env Configure production environment variables'));
18
20
  console.log(chalk.gray(' deploy:init Initialize VPS and build Docker images'));
19
- console.log(chalk.gray(' deploy:up Start services on VPS\n'));
21
+ console.log(chalk.gray(' deploy:up Start services on VPS'));
22
+ console.log(chalk.gray(' deploy:build [service] Build, push, and deploy (all or specific service)\n'));
20
23
 
21
24
  // Conditionally show waitlist commands
22
25
  if (isWaitlistInstalled()) {
@@ -83,6 +86,8 @@ function help() {
83
86
  console.log(chalk.gray(' launchframe init\n'));
84
87
  console.log(chalk.gray(' # Non-interactive mode'));
85
88
  console.log(chalk.gray(' launchframe init --project-name my-saas --tenancy single --user-model b2b\n'));
89
+ console.log(chalk.gray(' # With verbose output'));
90
+ console.log(chalk.gray(' launchframe init --verbose\n'));
86
91
  }
87
92
  }
88
93
 
@@ -6,6 +6,7 @@ const { generateProject } = require('../generator');
6
6
  const { checkGitHubAccess, showAccessDeniedMessage } = require('../utils/github-access');
7
7
  const { ensureCacheReady } = require('../utils/service-cache');
8
8
  const { isLaunchFrameProject } = require('../utils/project-helpers');
9
+ const logger = require('../utils/logger');
9
10
 
10
11
  /**
11
12
  * Check if running in development mode (local) vs production (npm install)
@@ -23,28 +24,28 @@ function isDevMode() {
23
24
  * @param {string} options.userModel - User model: 'b2b' or 'b2b2c' (skips prompt if provided)
24
25
  */
25
26
  async function init(options = {}) {
26
- console.log(chalk.blue.bold('\n๐Ÿš€ Welcome to LaunchFrame!\n'));
27
+ console.log(chalk.blue.bold('\nLaunchFrame\n'));
27
28
 
28
29
  // Check if in development mode
29
30
  const devMode = isDevMode();
30
-
31
+
31
32
  if (!devMode) {
32
33
  // Production mode: Check GitHub access
33
- console.log(chalk.blue('๐Ÿ” Checking repository access...\n'));
34
-
34
+ console.log(chalk.gray('Checking repository access...'));
35
+
35
36
  const accessCheck = await checkGitHubAccess();
36
-
37
+
37
38
  if (!accessCheck.hasAccess) {
38
- // No access - show purchase/setup message
39
39
  showAccessDeniedMessage();
40
- process.exit(1); // Exit with error code
40
+ process.exit(1);
41
41
  }
42
-
43
- console.log(chalk.green('โœ“ Repository access confirmed\n'));
42
+
43
+ console.log(chalk.green('Repository access confirmed\n'));
44
44
  }
45
+
45
46
  // Check if already in a LaunchFrame project
46
47
  if (isLaunchFrameProject()) {
47
- console.error(chalk.red('\nโŒ Error: Already in a LaunchFrame project'));
48
+ console.error(chalk.red('Error: Already in a LaunchFrame project'));
48
49
  console.log(chalk.gray('Use other commands to manage your project, or run init from outside the project.\n'));
49
50
  process.exit(1);
50
51
  }
@@ -54,12 +55,10 @@ async function init(options = {}) {
54
55
 
55
56
  // If project name provided via flag, skip prompts
56
57
  if (options.projectName) {
57
- // Validate project name format
58
58
  if (!/^[a-z0-9-]+$/.test(options.projectName)) {
59
59
  throw new Error('Project name must contain only lowercase letters, numbers, and hyphens');
60
60
  }
61
61
 
62
- // Auto-generate display name from slug
63
62
  const projectDisplayName = options.projectName
64
63
  .split('-')
65
64
  .map(word => word.charAt(0).toUpperCase() + word.slice(1))
@@ -75,91 +74,74 @@ async function init(options = {}) {
75
74
  projectNameCamel: options.projectName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
76
75
  };
77
76
 
78
- console.log(chalk.gray(`Using project name: ${options.projectName}`));
79
- console.log(chalk.gray(`Using display name: ${projectDisplayName}`));
80
- console.log(chalk.gray(`Using description: ${projectDescription}\n`));
77
+ logger.detail(`Project name: ${options.projectName}`);
78
+ logger.detail(`Display name: ${projectDisplayName}`);
79
+ logger.detail(`Description: ${projectDescription}`);
81
80
  } else {
82
- // Get user inputs via interactive prompts
83
81
  answers = await runInitPrompts();
84
82
  }
85
83
 
86
84
  // Get variant selections (multi-tenancy, B2B vs B2B2C)
87
85
  let variantChoices;
88
-
89
- // If both flags provided, skip variant prompts
86
+
90
87
  if (options.tenancy && options.userModel) {
91
- // Validate tenancy value
92
88
  if (!['single', 'multi'].includes(options.tenancy)) {
93
89
  throw new Error('Invalid --tenancy value. Must be "single" or "multi"');
94
90
  }
95
-
96
- // Validate userModel value
91
+
97
92
  if (!['b2b', 'b2b2c'].includes(options.userModel)) {
98
93
  throw new Error('Invalid --user-model value. Must be "b2b" or "b2b2c"');
99
94
  }
100
-
101
- // Convert short flag values to full variant names
95
+
102
96
  const tenancyMap = {
103
97
  'single': 'single-tenant',
104
98
  'multi': 'multi-tenant'
105
99
  };
106
-
100
+
107
101
  variantChoices = {
108
102
  tenancy: tenancyMap[options.tenancy],
109
103
  userModel: options.userModel
110
104
  };
111
-
112
- console.log(chalk.gray(`Using tenancy: ${options.tenancy}`));
113
- console.log(chalk.gray(`Using user model: ${options.userModel}\n`));
105
+
106
+ logger.detail(`Tenancy: ${options.tenancy}`);
107
+ logger.detail(`User model: ${options.userModel}`);
114
108
  } else {
115
- // Run interactive variant prompts
116
109
  variantChoices = await runVariantPrompts();
117
110
  }
118
111
 
119
- // Determine which services are needed based on variant choices
120
- const requiredServices = [
121
- 'backend',
122
- 'admin-portal',
123
- 'infrastructure',
124
- 'website'
125
- ];
126
-
127
- // Add customers-portal only if B2B2C mode
112
+ // Determine which services are needed
113
+ const requiredServices = ['backend', 'admin-portal', 'infrastructure', 'website'];
114
+
128
115
  if (variantChoices.userModel === 'b2b2c') {
129
116
  requiredServices.push('customers-portal');
130
117
  }
131
118
 
132
- // Determine template source (dev mode = local, production = cache)
119
+ // Determine template source
133
120
  let templateRoot;
134
-
121
+
135
122
  if (devMode) {
136
- // Dev mode: Use local services directory
137
123
  templateRoot = path.resolve(__dirname, '../../../services');
138
- console.log(chalk.gray(`[DEV MODE] Using local services: ${templateRoot}\n`));
124
+ logger.detail(`[DEV MODE] Using local services: ${templateRoot}`);
139
125
  } else {
140
- // Production mode: Use cache
141
126
  try {
142
127
  templateRoot = await ensureCacheReady(requiredServices);
143
128
  } catch (error) {
144
- console.error(chalk.red(`\nโŒ Error: ${error.message}\n`));
129
+ console.error(chalk.red(`Error: ${error.message}\n`));
145
130
  process.exit(1);
146
131
  }
147
132
  }
148
133
 
149
- // Generate project with variant selections
150
- console.log(chalk.yellow('\nโš™๏ธ Generating project...\n'));
134
+ // Generate project
135
+ console.log(chalk.white('\nGenerating project...\n'));
151
136
  await generateProject(answers, variantChoices, templateRoot);
152
137
 
153
- console.log(chalk.green.bold('\nโœ… Project generated successfully!\n'));
138
+ console.log(chalk.green.bold('\nProject created successfully!\n'));
154
139
  console.log(chalk.white('Next steps:'));
155
- console.log(chalk.white(` cd ${answers.projectName}`));
156
- console.log(chalk.white(' launchframe docker:up # Start all services\n'));
157
- console.log(chalk.gray('Optional:'));
158
- console.log(chalk.gray(' # Review and customize infrastructure/.env if needed'));
159
- console.log(chalk.gray(' launchframe docker:build # Rebuild images after changes\n'));
140
+ console.log(chalk.gray(` cd ${answers.projectName}`));
141
+ console.log(chalk.gray(' launchframe docker:up\n'));
160
142
 
161
143
  } catch (error) {
162
- console.error(chalk.red('\nโŒ Error:'), error.message);
144
+ console.error(chalk.red('Error:'), error.message);
163
145
  process.exit(1);
164
146
  }
165
147
  }
package/src/generator.js CHANGED
@@ -1,11 +1,13 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs-extra');
3
3
  const { execSync } = require('child_process');
4
+ const chalk = require('chalk');
4
5
  const { replaceVariables } = require('./utils/variable-replacer');
5
6
  const { copyDirectory } = require('./utils/file-ops');
6
7
  const { generateEnvFile } = require('./utils/env-generator');
7
8
  const { processServiceVariant } = require('./utils/variant-processor');
8
9
  const { resolveVariantChoices } = require('./services/variant-config');
10
+ const logger = require('./utils/logger');
9
11
 
10
12
  /**
11
13
  * Initialize git repository in a service directory
@@ -14,13 +16,13 @@ const { resolveVariantChoices } = require('./services/variant-config');
14
16
  */
15
17
  function initGitRepo(servicePath, serviceName) {
16
18
  try {
17
- console.log(`๐Ÿ”ง Initializing git repository for ${serviceName}...`);
19
+ logger.detail(`Initializing git repository for ${serviceName}`);
18
20
  execSync('git init', { cwd: servicePath, stdio: 'ignore' });
19
21
  execSync('git add .', { cwd: servicePath, stdio: 'ignore' });
20
22
  execSync('git commit -m "Initial commit"', { cwd: servicePath, stdio: 'ignore' });
21
- console.log(`โœ… Git repository initialized for ${serviceName}`);
23
+ logger.detail(`Git initialized: ${serviceName}`);
22
24
  } catch (error) {
23
- console.warn(`โš ๏ธ Could not initialize git repository for ${serviceName}: ${error.message}`);
25
+ logger.warn(`Could not initialize git for ${serviceName}: ${error.message}`);
24
26
  }
25
27
  }
26
28
 
@@ -34,12 +36,11 @@ async function generateProject(answers, variantChoices, templateRoot) {
34
36
  const { projectName } = answers;
35
37
 
36
38
  // Define source (template) and destination paths
37
- // templateRoot is now passed as parameter (cache or local dev path)
38
- const projectRoot = path.resolve(__dirname, '../..'); // For root-level files like .github, README.md
39
+ const projectRoot = path.resolve(__dirname, '../..'); // For root-level files
39
40
  const destinationRoot = path.resolve(process.cwd(), projectName);
40
41
 
41
- console.log(`๐Ÿ“ Template source: ${templateRoot}`);
42
- console.log(`๐Ÿ“ Destination: ${destinationRoot}\n`);
42
+ logger.detail(`Template source: ${templateRoot}`);
43
+ logger.detail(`Destination: ${destinationRoot}`);
43
44
 
44
45
  // Ensure destination directory exists
45
46
  await fs.ensureDir(destinationRoot);
@@ -60,8 +61,8 @@ async function generateProject(answers, variantChoices, templateRoot) {
60
61
  // Resolve variant choices for all services
61
62
  const allServiceVariants = resolveVariantChoices(variantChoices);
62
63
 
63
- // Step 1: Process backend service with variants
64
- console.log('๐Ÿ”ง Processing backend service...');
64
+ // Process backend
65
+ console.log(chalk.gray(' Processing backend...'));
65
66
  await processServiceVariant(
66
67
  'backend',
67
68
  allServiceVariants.backend,
@@ -71,11 +72,10 @@ async function generateProject(answers, variantChoices, templateRoot) {
71
72
  );
72
73
  initGitRepo(path.join(destinationRoot, 'backend'), 'backend');
73
74
 
74
- // Step 2: Process admin-portal service with variants
75
- // Note: admin-portal folder might not exist yet in templates, skip if missing
75
+ // Process admin-portal
76
76
  const adminPortalTemplatePath = path.join(templateRoot, 'admin-portal/base');
77
77
  if (await fs.pathExists(adminPortalTemplatePath)) {
78
- console.log('๐Ÿ”ง Processing admin-portal service...');
78
+ console.log(chalk.gray(' Processing admin-portal...'));
79
79
  await processServiceVariant(
80
80
  'admin-portal',
81
81
  allServiceVariants['admin-portal'],
@@ -85,8 +85,8 @@ async function generateProject(answers, variantChoices, templateRoot) {
85
85
  );
86
86
  initGitRepo(path.join(destinationRoot, 'admin-portal'), 'admin-portal');
87
87
  } else {
88
- // Fallback: Copy admin-portal directly without variants (for now)
89
- console.log('๐Ÿ“‹ Copying admin-portal service (no variants yet)...');
88
+ // Fallback: Copy admin-portal directly without variants
89
+ console.log(chalk.gray(' Copying admin-portal...'));
90
90
  const adminPortalSource = path.join(templateRoot, 'admin-portal');
91
91
  if (await fs.pathExists(adminPortalSource)) {
92
92
  await copyDirectory(adminPortalSource, path.join(destinationRoot, 'admin-portal'));
@@ -95,12 +95,11 @@ async function generateProject(answers, variantChoices, templateRoot) {
95
95
  }
96
96
  }
97
97
 
98
- // Step 3: Process customers-portal service (ONLY if B2B2C selected)
98
+ // Process customers-portal (only if B2B2C)
99
99
  if (variantChoices.userModel === 'b2b2c') {
100
- // Note: customers-portal folder might not exist yet in templates, skip if missing
101
100
  const customersPortalTemplatePath = path.join(templateRoot, 'customers-portal/base');
102
101
  if (await fs.pathExists(customersPortalTemplatePath)) {
103
- console.log('๐Ÿ”ง Processing customers-portal service...');
102
+ console.log(chalk.gray(' Processing customers-portal...'));
104
103
  await processServiceVariant(
105
104
  'customers-portal',
106
105
  allServiceVariants['customers-portal'],
@@ -110,8 +109,7 @@ async function generateProject(answers, variantChoices, templateRoot) {
110
109
  );
111
110
  initGitRepo(path.join(destinationRoot, 'customers-portal'), 'customers-portal');
112
111
  } else {
113
- // Fallback: Copy customers-portal directly without variants (for now)
114
- console.log('๐Ÿ“‹ Copying customers-portal service (B2B2C mode)...');
112
+ console.log(chalk.gray(' Copying customers-portal...'));
115
113
  const customersPortalSource = path.join(templateRoot, 'customers-portal');
116
114
  if (await fs.pathExists(customersPortalSource)) {
117
115
  await copyDirectory(customersPortalSource, path.join(destinationRoot, 'customers-portal'));
@@ -120,11 +118,11 @@ async function generateProject(answers, variantChoices, templateRoot) {
120
118
  }
121
119
  }
122
120
  } else {
123
- console.log('๐Ÿ“‹ Skipping customers-portal (B2B mode - admin users only)');
121
+ logger.detail('Skipping customers-portal (B2B mode)');
124
122
  }
125
123
 
126
- // Step 4: Process infrastructure with variants (docker-compose files conditionally include customers-portal)
127
- console.log('๐Ÿ”ง Processing infrastructure...');
124
+ // Process infrastructure
125
+ console.log(chalk.gray(' Processing infrastructure...'));
128
126
  await processServiceVariant(
129
127
  'infrastructure',
130
128
  allServiceVariants.infrastructure,
@@ -134,7 +132,8 @@ async function generateProject(answers, variantChoices, templateRoot) {
134
132
  );
135
133
  initGitRepo(path.join(destinationRoot, 'infrastructure'), 'infrastructure');
136
134
 
137
- console.log('๐Ÿ“‹ Copying website...');
135
+ // Process website
136
+ console.log(chalk.gray(' Processing website...'));
138
137
  await copyDirectory(
139
138
  path.join(templateRoot, 'website'),
140
139
  path.join(destinationRoot, 'website')
@@ -142,14 +141,9 @@ async function generateProject(answers, variantChoices, templateRoot) {
142
141
  await replaceVariables(path.join(destinationRoot, 'website'), variables);
143
142
  initGitRepo(path.join(destinationRoot, 'website'), 'website');
144
143
 
145
- // Step 5: Copy additional files (from project root, not services/)
146
- console.log('๐Ÿ“‹ Copying additional files...');
147
- const additionalFiles = [
148
- '.github',
149
- 'README.md',
150
- '.gitignore',
151
- 'LICENSE'
152
- ];
144
+ // Copy additional files
145
+ logger.detail('Copying additional files...');
146
+ const additionalFiles = ['.github', 'README.md', '.gitignore', 'LICENSE'];
153
147
 
154
148
  for (const file of additionalFiles) {
155
149
  const sourcePath = path.join(projectRoot, file);
@@ -166,16 +160,15 @@ async function generateProject(answers, variantChoices, templateRoot) {
166
160
  }
167
161
  }
168
162
 
169
- // Step 6: Generate .env file with localhost defaults
170
- console.log('\n๐Ÿ” Generating .env file with secure secrets...');
163
+ // Generate .env file
164
+ console.log(chalk.gray(' Generating environment file...'));
171
165
  const { envPath } = await generateEnvFile(destinationRoot, answers);
172
- console.log(`โœ… Environment file created: ${envPath}`);
166
+ logger.detail(`Environment file: ${envPath}`);
173
167
 
174
- // Step 7: Create .launchframe marker file with variant metadata
175
- console.log('๐Ÿ“ Creating LaunchFrame marker file...');
168
+ // Create .launchframe marker file
169
+ logger.detail('Creating project marker file...');
176
170
  const markerPath = path.join(destinationRoot, '.launchframe');
177
171
 
178
- // Determine which services were installed
179
172
  const installedServices = ['backend', 'admin-portal', 'infrastructure', 'website'];
180
173
  if (variantChoices.userModel === 'b2b2c') {
181
174
  installedServices.push('customers-portal');
@@ -188,12 +181,9 @@ async function generateProject(answers, variantChoices, templateRoot) {
188
181
  projectDisplayName: answers.projectDisplayName,
189
182
  deployConfigured: false,
190
183
  installedServices: installedServices,
191
- // Store variant choices for future reference
192
184
  variants: variantChoices
193
185
  };
194
186
  await fs.writeJson(markerPath, markerContent, { spaces: 2 });
195
-
196
- console.log('โœ… Base project generated with variants applied');
197
187
  }
198
188
 
199
189
  module.exports = { generateProject };
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const chalk = require('chalk');
4
4
  const { isLaunchFrameProject } = require('./utils/project-helpers');
5
+ const logger = require('./utils/logger');
5
6
 
6
7
  // Import commands
7
8
  const { init } = require('./commands/init');
@@ -9,6 +10,7 @@ const { deployConfigure } = require('./commands/deploy-configure');
9
10
  const { deploySetEnv } = require('./commands/deploy-set-env');
10
11
  const { deployInit } = require('./commands/deploy-init');
11
12
  const { deployUp } = require('./commands/deploy-up');
13
+ const { deployBuild } = require('./commands/deploy-build');
12
14
  const { waitlistDeploy } = require('./commands/waitlist-deploy');
13
15
  const { waitlistUp } = require('./commands/waitlist-up');
14
16
  const { waitlistDown } = require('./commands/waitlist-down');
@@ -65,6 +67,11 @@ async function main() {
65
67
  const inProject = isLaunchFrameProject();
66
68
  const flags = parseFlags(args);
67
69
 
70
+ // Set verbose mode globally
71
+ if (flags.verbose || flags.v) {
72
+ logger.setVerbose(true);
73
+ }
74
+
68
75
  // No command provided
69
76
  if (!command) {
70
77
  help();
@@ -92,6 +99,9 @@ async function main() {
92
99
  case 'deploy:up':
93
100
  await deployUp();
94
101
  break;
102
+ case 'deploy:build':
103
+ await deployBuild(args[1]); // Optional service name
104
+ break;
95
105
  case 'waitlist:deploy':
96
106
  await waitlistDeploy();
97
107
  break;
@@ -156,7 +166,7 @@ async function main() {
156
166
  help();
157
167
  break;
158
168
  default:
159
- console.error(chalk.red(`\nโŒ Unknown command: ${command}\n`));
169
+ console.error(chalk.red(`\nUnknown command: ${command}\n`));
160
170
  help();
161
171
  process.exit(1);
162
172
  }
@@ -254,6 +254,7 @@ async function buildWaitlistImage(projectRoot, projectName, githubOrg) {
254
254
  module.exports = {
255
255
  checkDockerRunning,
256
256
  loginToGHCR,
257
+ buildAndPushImage,
257
258
  buildFullAppImages,
258
259
  buildWaitlistImage
259
260
  };
@@ -0,0 +1,93 @@
1
+ const chalk = require('chalk');
2
+
3
+ /**
4
+ * Simple logger with verbose mode support
5
+ *
6
+ * Usage:
7
+ * logger.setVerbose(true);
8
+ * logger.info('Main message'); // Always shown
9
+ * logger.detail('Nested detail'); // Only shown in verbose mode
10
+ */
11
+
12
+ let verboseMode = false;
13
+
14
+ /**
15
+ * Enable or disable verbose mode
16
+ * @param {boolean} enabled
17
+ */
18
+ function setVerbose(enabled) {
19
+ verboseMode = enabled;
20
+ }
21
+
22
+ /**
23
+ * Check if verbose mode is enabled
24
+ * @returns {boolean}
25
+ */
26
+ function isVerbose() {
27
+ return verboseMode;
28
+ }
29
+
30
+ /**
31
+ * Log a main info message (always shown)
32
+ * @param {string} message
33
+ */
34
+ function info(message) {
35
+ console.log(message);
36
+ }
37
+
38
+ /**
39
+ * Log a success message (always shown)
40
+ * @param {string} message
41
+ */
42
+ function success(message) {
43
+ console.log(chalk.green(message));
44
+ }
45
+
46
+ /**
47
+ * Log an error message (always shown)
48
+ * @param {string} message
49
+ */
50
+ function error(message) {
51
+ console.error(chalk.red(message));
52
+ }
53
+
54
+ /**
55
+ * Log a warning message (always shown)
56
+ * @param {string} message
57
+ */
58
+ function warn(message) {
59
+ console.warn(chalk.yellow(message));
60
+ }
61
+
62
+ /**
63
+ * Log a detail/nested message (only in verbose mode)
64
+ * @param {string} message
65
+ * @param {number} indent - Indentation level (default 1)
66
+ */
67
+ function detail(message, indent = 1) {
68
+ if (verboseMode) {
69
+ const prefix = ' '.repeat(indent);
70
+ console.log(chalk.gray(`${prefix}${message}`));
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Log a step within an operation (only in verbose mode)
76
+ * @param {string} message
77
+ */
78
+ function step(message) {
79
+ if (verboseMode) {
80
+ console.log(chalk.gray(` - ${message}`));
81
+ }
82
+ }
83
+
84
+ module.exports = {
85
+ setVerbose,
86
+ isVerbose,
87
+ info,
88
+ success,
89
+ error,
90
+ warn,
91
+ detail,
92
+ step
93
+ };
@@ -3,6 +3,7 @@ const fs = require('fs-extra');
3
3
  const os = require('os');
4
4
  const { execSync } = require('child_process');
5
5
  const chalk = require('chalk');
6
+ const logger = require('./logger');
6
7
 
7
8
  const SERVICES_REPO = 'git@github.com:launchframe-dev/services.git';
8
9
  const BRANCH = 'main';
@@ -38,30 +39,26 @@ async function cacheExists() {
38
39
  async function initializeCache() {
39
40
  const cacheDir = getCacheDir();
40
41
 
41
- console.log(chalk.blue('๐Ÿ”„ Initializing services cache...'));
42
+ logger.detail('Initializing services cache...');
42
43
 
43
44
  try {
44
- // Ensure parent directory exists
45
45
  await fs.ensureDir(path.dirname(cacheDir));
46
46
 
47
- // Sparse clone (only root files, no services)
48
47
  execSync(
49
48
  `git clone --sparse --depth 1 --branch ${BRANCH} ${SERVICES_REPO} "${cacheDir}"`,
50
49
  {
51
- stdio: 'pipe', // Hide output
52
- timeout: 60000 // 1 minute timeout
50
+ stdio: 'pipe',
51
+ timeout: 60000
53
52
  }
54
53
  );
55
54
 
56
- // Configure sparse checkout (starts with empty set)
57
55
  execSync('git sparse-checkout init --cone', {
58
56
  cwd: cacheDir,
59
57
  stdio: 'pipe'
60
58
  });
61
59
 
62
- console.log(chalk.green('โœ“ Cache initialized'));
60
+ logger.detail('Cache initialized');
63
61
  } catch (error) {
64
- // Clean up partial clone on failure
65
62
  await fs.remove(cacheDir);
66
63
  throw new Error(`Failed to initialize cache: ${error.message}`);
67
64
  }
@@ -75,16 +72,16 @@ async function initializeCache() {
75
72
  async function updateCache() {
76
73
  const cacheDir = getCacheDir();
77
74
 
78
- console.log(chalk.blue('๐Ÿ”„ Updating service cache...'));
75
+ logger.detail('Updating service cache...');
79
76
 
80
77
  try {
81
78
  execSync('git pull origin main', {
82
79
  cwd: cacheDir,
83
80
  stdio: 'pipe',
84
- timeout: 30000 // 30 seconds
81
+ timeout: 30000
85
82
  });
86
83
 
87
- console.log(chalk.green('โœ“ Cache updated'));
84
+ logger.detail('Cache updated');
88
85
  } catch (error) {
89
86
  throw new Error(`Failed to update cache: ${error.message}`);
90
87
  }
@@ -98,10 +95,9 @@ async function updateCache() {
98
95
  async function expandServices(serviceNames) {
99
96
  const cacheDir = getCacheDir();
100
97
 
101
- console.log(chalk.blue(`๐Ÿ“ฆ Loading services: ${serviceNames.join(', ')}...`));
98
+ logger.detail(`Loading services: ${serviceNames.join(', ')}...`);
102
99
 
103
100
  try {
104
- // Get current sparse checkout list
105
101
  let currentServices = [];
106
102
  try {
107
103
  const output = execSync('git sparse-checkout list', {
@@ -114,17 +110,15 @@ async function expandServices(serviceNames) {
114
110
  // No services yet, that's fine
115
111
  }
116
112
 
117
- // Add new services to the list
118
113
  const allServices = [...new Set([...currentServices, ...serviceNames])];
119
114
 
120
- // Set sparse checkout to include all services
121
115
  execSync(`git sparse-checkout set ${allServices.join(' ')}`, {
122
116
  cwd: cacheDir,
123
117
  stdio: 'pipe',
124
- timeout: 60000 // 1 minute (may need to download files)
118
+ timeout: 60000
125
119
  });
126
120
 
127
- console.log(chalk.green('โœ“ Services loaded'));
121
+ logger.detail('Services loaded');
128
122
  } catch (error) {
129
123
  throw new Error(`Failed to expand services: ${error.message}`);
130
124
  }
@@ -158,7 +152,7 @@ async function clearCache() {
158
152
 
159
153
  if (await fs.pathExists(cacheDir)) {
160
154
  await fs.remove(cacheDir);
161
- console.log(chalk.green('โœ“ Cache cleared'));
155
+ console.log(chalk.green('Cache cleared'));
162
156
  } else {
163
157
  console.log(chalk.gray('Cache is already empty'));
164
158
  }
@@ -15,6 +15,7 @@ const { replaceVariables } = require('./variable-replacer');
15
15
  const { getVariantConfig, getVariantsToApply } = require('../services/variant-config');
16
16
  const fs = require('fs-extra');
17
17
  const path = require('path');
18
+ const logger = require('./logger');
18
19
 
19
20
  /**
20
21
  * Process service with variant modifications
@@ -32,7 +33,7 @@ async function processServiceVariant(
32
33
  replacements,
33
34
  templateRoot
34
35
  ) {
35
- console.log(`\n๐Ÿ“ฆ Processing ${serviceName} with choices:`, variantChoices);
36
+ logger.detail(`Processing ${serviceName} with choices: ${JSON.stringify(variantChoices)}`, 2);
36
37
 
37
38
  const serviceConfig = getVariantConfig(serviceName);
38
39
  if (!serviceConfig) {
@@ -41,33 +42,33 @@ async function processServiceVariant(
41
42
 
42
43
  const basePath = path.join(templateRoot, serviceConfig.base);
43
44
 
44
- // Step 1: Copy base template (minimal - B2B + single-tenant)
45
- console.log(` ๐Ÿ“ Copying base template from ${serviceConfig.base}`);
45
+ // Copy base template
46
+ logger.detail(`Copying base template from ${serviceConfig.base}`, 2);
46
47
  await copyDirectory(basePath, destination, {
47
48
  exclude: ['node_modules', '.git', 'dist', '.env']
48
49
  });
49
50
 
50
- // Step 2: Determine which variants to apply
51
+ // Determine which variants to apply
51
52
  const variantsToApply = getVariantsToApply(variantChoices);
52
53
 
53
54
  if (variantsToApply.length === 0) {
54
- console.log(` โœ… Using base template (no variants to apply)`);
55
+ logger.detail('Using base template (no variants)', 2);
55
56
  } else {
56
- console.log(` ๐Ÿ”ง Applying variants: ${variantsToApply.join(', ')}`);
57
+ logger.detail(`Applying variants: ${variantsToApply.join(', ')}`, 2);
57
58
  }
58
59
 
59
- // Step 3: Apply each variant
60
+ // Apply each variant
60
61
  for (const variantName of variantsToApply) {
61
62
  const variantConfig = serviceConfig.variants[variantName];
62
63
 
63
64
  if (!variantConfig) {
64
- console.warn(` โš ๏ธ No configuration found for variant: ${variantName}, skipping`);
65
+ logger.warn(`No configuration found for variant: ${variantName}, skipping`);
65
66
  continue;
66
67
  }
67
68
 
68
- console.log(`\n โœจ Applying ${variantName} variant:`);
69
+ logger.detail(`Applying ${variantName} variant`, 2);
69
70
 
70
- // Step 3a: Copy variant FILES
71
+ // Copy variant FILES
71
72
  await copyVariantFiles(
72
73
  variantName,
73
74
  variantConfig.files || [],
@@ -76,7 +77,7 @@ async function processServiceVariant(
76
77
  templateRoot
77
78
  );
78
79
 
79
- // Step 3b: Insert variant SECTIONS
80
+ // Insert variant SECTIONS
80
81
  await insertVariantSections(
81
82
  variantName,
82
83
  variantConfig.sections || {},
@@ -86,15 +87,15 @@ async function processServiceVariant(
86
87
  );
87
88
  }
88
89
 
89
- // Step 4: Clean up unused section markers
90
- console.log(`\n ๐Ÿงน Cleaning up unused section markers`);
90
+ // Clean up unused section markers
91
+ logger.detail('Cleaning up unused section markers', 2);
91
92
  await cleanupSectionMarkers(serviceName, serviceConfig, variantsToApply, destination);
92
93
 
93
- // Step 5: Replace template variables ({{PROJECT_NAME}}, etc.)
94
- console.log(`\n ๐Ÿ”ค Replacing template variables`);
94
+ // Replace template variables
95
+ logger.detail('Replacing template variables', 2);
95
96
  await replaceVariables(destination, replacements);
96
97
 
97
- console.log(` โœ… ${serviceName} processing complete\n`);
98
+ logger.detail(`${serviceName} complete`, 2);
98
99
  }
99
100
 
100
101
  /**
@@ -110,7 +111,7 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
110
111
  const unappliedVariants = allVariants.filter(v => !appliedVariants.includes(v));
111
112
 
112
113
  if (unappliedVariants.length === 0) {
113
- console.log(` โœ“ No unused section markers to clean`);
114
+ logger.detail('No unused section markers to clean', 3);
114
115
  return;
115
116
  }
116
117
 
@@ -146,8 +147,6 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
146
147
  // Remove each unused section marker (keep content, remove only marker comments)
147
148
  for (const sectionName of sectionNames) {
148
149
  // Try all comment formats (// for JS/TS, {/* */} for JSX, # for YAML/Shell)
149
- // Capture: START marker, content, END marker - replace with just content
150
- // Include leading whitespace before markers to prevent indentation issues
151
150
  const slashPattern = new RegExp(
152
151
  `^[ \\t]*\\/\\/ ${sectionName}_START\\n([\\s\\S]*?)^[ \\t]*\\/\\/ ${sectionName}_END\\n?`,
153
152
  'gm'
@@ -185,14 +184,14 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
185
184
  totalCleaned++;
186
185
  }
187
186
  } catch (error) {
188
- console.warn(` โš ๏ธ Could not clean markers in ${filePath}:`, error.message);
187
+ logger.warn(`Could not clean markers in ${filePath}: ${error.message}`);
189
188
  }
190
189
  }
191
190
 
192
191
  if (totalCleaned > 0) {
193
- console.log(` โœ“ Cleaned up section markers in ${totalCleaned} file(s)`);
192
+ logger.detail(`Cleaned section markers in ${totalCleaned} file(s)`, 3);
194
193
  } else {
195
- console.log(` โœ“ No unused section markers found`);
194
+ logger.detail('No unused section markers found', 3);
196
195
  }
197
196
  }
198
197
 
@@ -206,11 +205,11 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
206
205
  */
207
206
  async function copyVariantFiles(variantName, files, filesDir, destination, templateRoot) {
208
207
  if (!files || files.length === 0) {
209
- console.log(` ๐Ÿ“‚ No files to copy for ${variantName}`);
208
+ logger.detail(`No files to copy for ${variantName}`, 3);
210
209
  return;
211
210
  }
212
211
 
213
- console.log(` ๐Ÿ“‚ Copying ${files.length} file(s)/folder(s):`);
212
+ logger.detail(`Copying ${files.length} file(s) for ${variantName}`, 3);
214
213
 
215
214
  const variantFilesPath = path.join(templateRoot, filesDir, variantName);
216
215
 
@@ -219,22 +218,18 @@ async function copyVariantFiles(variantName, files, filesDir, destination, templ
219
218
  const destPath = path.join(destination, filePath);
220
219
 
221
220
  try {
222
- // Check if source exists
223
221
  if (!await fs.pathExists(sourcePath)) {
224
- console.warn(` โš ๏ธ Source not found: ${filePath}, skipping`);
222
+ logger.warn(`Source not found: ${filePath}, skipping`);
225
223
  continue;
226
224
  }
227
225
 
228
- // Create parent directory if needed
229
226
  await fs.ensureDir(path.dirname(destPath));
230
-
231
- // Copy file or directory
232
227
  await fs.copy(sourcePath, destPath, { overwrite: true });
233
228
 
234
229
  const isDir = (await fs.stat(sourcePath)).isDirectory();
235
- console.log(` โœ“ Copied ${isDir ? 'folder' : 'file'}: ${filePath}`);
230
+ logger.detail(`Copied ${isDir ? 'folder' : 'file'}: ${filePath}`, 4);
236
231
  } catch (error) {
237
- console.warn(` โš ๏ธ Could not copy ${filePath}:`, error.message);
232
+ logger.warn(`Could not copy ${filePath}: ${error.message}`);
238
233
  }
239
234
  }
240
235
  }
@@ -249,45 +244,41 @@ async function copyVariantFiles(variantName, files, filesDir, destination, templ
249
244
  */
250
245
  async function insertVariantSections(variantName, sections, sectionsDir, destination, templateRoot) {
251
246
  if (!sections || Object.keys(sections).length === 0) {
252
- console.log(` โœ๏ธ No sections to insert for ${variantName}`);
247
+ logger.detail(`No sections to insert for ${variantName}`, 3);
253
248
  return;
254
249
  }
255
250
 
256
251
  const sectionFiles = Object.keys(sections);
257
- console.log(` โœ๏ธ Inserting sections into ${sectionFiles.length} file(s):`);
252
+ logger.detail(`Inserting sections into ${sectionFiles.length} file(s)`, 3);
258
253
 
259
254
  const variantSectionsPath = path.join(templateRoot, sectionsDir, variantName);
260
255
 
261
256
  for (const [filePath, sectionNames] of Object.entries(sections)) {
262
257
  const targetFilePath = path.join(destination, filePath);
263
258
 
264
- // Check if target file exists
265
259
  if (!await fs.pathExists(targetFilePath)) {
266
- console.warn(` โš ๏ธ Target file not found: ${filePath}, skipping sections`);
260
+ logger.warn(`Target file not found: ${filePath}, skipping sections`);
267
261
  continue;
268
262
  }
269
263
 
270
- console.log(` ๐Ÿ“ ${filePath}:`);
264
+ logger.detail(`Processing ${filePath}`, 4);
271
265
 
272
266
  for (const sectionName of sectionNames) {
273
267
  try {
274
- // Read section content from file
275
268
  const fileName = path.basename(filePath);
276
269
  const sectionFileName = `${fileName}.${sectionName}`;
277
270
  const sectionFilePath = path.join(variantSectionsPath, sectionFileName);
278
271
 
279
272
  if (!await fs.pathExists(sectionFilePath)) {
280
- console.warn(` โš ๏ธ Section file not found: ${sectionFileName}, skipping`);
273
+ logger.warn(`Section file not found: ${sectionFileName}, skipping`);
281
274
  continue;
282
275
  }
283
276
 
284
277
  const sectionContent = await fs.readFile(sectionFilePath, 'utf-8');
285
-
286
- // Insert section content into target file
287
278
  await replaceSection(targetFilePath, sectionName, sectionContent);
288
- console.log(` โœ“ Inserted [${sectionName}]`);
279
+ logger.detail(`Inserted [${sectionName}]`, 5);
289
280
  } catch (error) {
290
- console.warn(` โš ๏ธ Could not insert section ${sectionName}:`, error.message);
281
+ logger.warn(`Could not insert section ${sectionName}: ${error.message}`);
291
282
  }
292
283
  }
293
284
  }