@launchframe/cli 1.0.0-beta.8 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CLAUDE.md +27 -0
  3. package/CODE_OF_CONDUCT.md +128 -0
  4. package/LICENSE +21 -0
  5. package/README.md +7 -1
  6. package/package.json +9 -6
  7. package/src/commands/cache.js +14 -14
  8. package/src/commands/database-console.js +84 -0
  9. package/src/commands/deploy-build.js +76 -0
  10. package/src/commands/deploy-configure.js +10 -3
  11. package/src/commands/deploy-init.js +24 -57
  12. package/src/commands/deploy-set-env.js +17 -7
  13. package/src/commands/deploy-sync-features.js +233 -0
  14. package/src/commands/deploy-up.js +4 -3
  15. package/src/commands/dev-add-user.js +165 -0
  16. package/src/commands/dev-logo.js +160 -0
  17. package/src/commands/dev-npm-install.js +33 -0
  18. package/src/commands/dev-queue.js +85 -0
  19. package/src/commands/docker-build.js +9 -6
  20. package/src/commands/help.js +35 -11
  21. package/src/commands/init.js +48 -56
  22. package/src/commands/migration-create.js +40 -0
  23. package/src/commands/migration-revert.js +32 -0
  24. package/src/commands/migration-run.js +32 -0
  25. package/src/commands/module.js +146 -0
  26. package/src/commands/service.js +6 -6
  27. package/src/commands/waitlist-deploy.js +1 -0
  28. package/src/generator.js +43 -42
  29. package/src/index.js +109 -4
  30. package/src/services/module-config.js +25 -0
  31. package/src/services/module-registry.js +12 -0
  32. package/src/services/variant-config.js +24 -13
  33. package/src/utils/docker-helper.js +116 -2
  34. package/src/utils/env-generator.js +9 -6
  35. package/src/utils/env-validator.js +4 -2
  36. package/src/utils/github-access.js +19 -17
  37. package/src/utils/logger.js +93 -0
  38. package/src/utils/module-installer.js +58 -0
  39. package/src/utils/project-helpers.js +34 -1
  40. package/src/utils/{module-cache.js → service-cache.js} +67 -73
  41. package/src/utils/ssh-helper.js +51 -1
  42. package/src/utils/telemetry.js +238 -0
  43. package/src/utils/variable-replacer.js +18 -23
  44. package/src/utils/variant-processor.js +35 -42
@@ -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,15 +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,
368
+ buildAndPushImage,
257
369
  buildFullAppImages,
258
- buildWaitlistImage
370
+ buildWaitlistImage,
371
+ buildAndPushWorkflow,
372
+ cleanupLocalImages
259
373
  };
@@ -25,28 +25,31 @@ async function generateEnvFile(projectRoot, answers) {
25
25
 
26
26
  // Generate secure secrets
27
27
  const secrets = {
28
- JWT_SECRET: generateSecret(32),
28
+ BETTER_AUTH_SECRET: generateSecret(32),
29
29
  DB_PASSWORD: generateSecret(24),
30
30
  BULL_ADMIN_TOKEN: generateSecret(24)
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,
42
- 'your_jwt_secret_key_change_this_in_production': secrets.JWT_SECRET,
43
+ 'your_better_auth_secret_minimum_32_chars': secrets.BETTER_AUTH_SECRET,
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 = {
@@ -14,14 +14,14 @@ function makeClickable(text, url) {
14
14
  }
15
15
 
16
16
  /**
17
- * Check if user has SSH access to LaunchFrame modules repository
17
+ * Check if user has SSH access to LaunchFrame services repository
18
18
  * @returns {Promise<{hasAccess: boolean, error?: string}>}
19
19
  */
20
20
  async function checkGitHubAccess() {
21
21
  try {
22
22
  // Test SSH access by checking if we can list remote refs
23
23
  execSync(
24
- 'git ls-remote git@github.com:launchframe-dev/modules.git HEAD',
24
+ 'git ls-remote git@github.com:launchframe-dev/services.git HEAD',
25
25
  {
26
26
  timeout: 15000,
27
27
  stdio: 'pipe' // Don't show output
@@ -37,27 +37,29 @@ async function checkGitHubAccess() {
37
37
  }
38
38
 
39
39
  /**
40
- * Display message when user doesn't have access to modules repository
41
- * Guides them to either purchase or setup SSH keys
40
+ * Display message when user doesn't have access to services repository
41
+ * Guides them to either get beta access or setup SSH keys
42
42
  */
43
43
  function showAccessDeniedMessage() {
44
- const purchaseUrl = 'https://buy.polar.sh/polar_cl_Zy4YqEwhoIEdUrAH8vHaWuZtwZuv306sYMnq118MbKi';
44
+ const betaUrl = 'https://launchframe.dev/';
45
45
  const docsUrl = 'https://docs.launchframe.dev/guide/quick-start#add-ssh-key-to-repo';
46
-
47
- console.log(chalk.red('\n❌ Cannot access LaunchFrame modules repository\n'));
48
-
46
+
47
+ console.log(chalk.red('\n❌ Cannot access LaunchFrame services repository\n'));
48
+
49
49
  console.log(chalk.white('This could mean:\n'));
50
- console.log(chalk.gray(' 1. You haven\'t purchased LaunchFrame yet'));
51
- console.log(chalk.gray(' 2. You purchased but haven\'t added your SSH key to the repo\n'));
52
-
53
- console.log(chalk.cyan('→ New customers:'));
54
- console.log(' ' + chalk.blue.bold.underline(makeClickable('Purchase LaunchFrame', purchaseUrl)));
55
- console.log(' ' + chalk.cyan(purchaseUrl + '\n'));
56
-
57
- console.log(chalk.cyan('→ Existing customers:'));
50
+ console.log(chalk.gray(' 1. You don\'t have beta access yet'));
51
+ console.log(chalk.gray(' 2. You have access but haven\'t added your SSH key to the repo\n'));
52
+
53
+ console.log(chalk.cyan('→ Get beta access:'));
54
+ console.log(chalk.white(' LaunchFrame is in open beta for 100 users.'));
55
+ console.log(chalk.white(' Get free lifetime access at:'));
56
+ console.log(' ' + chalk.blue.bold.underline(makeClickable('launchframe.dev', betaUrl)));
57
+ console.log(' ' + chalk.cyan(betaUrl + '\n'));
58
+
59
+ console.log(chalk.cyan('→ Already have access?'));
58
60
  console.log(' ' + chalk.blue.bold.underline(makeClickable('Setup SSH key (docs)', docsUrl)));
59
61
  console.log(' ' + chalk.cyan(docsUrl + '\n'));
60
-
62
+
61
63
  console.log(chalk.gray('After setup, run: launchframe init\n'));
62
64
  }
63
65
 
@@ -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
+ };
@@ -0,0 +1,58 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const { execSync } = require('child_process');
4
+ const { replaceSection } = require('./section-replacer');
5
+
6
+ /**
7
+ * Install a module into a project
8
+ * @param {string} moduleName - Name of the module to install
9
+ * @param {Object} moduleConfig - Config object from MODULE_CONFIG[moduleName]
10
+ */
11
+ async function installModule(moduleName, moduleConfig) {
12
+ const templateRoot = path.resolve(__dirname, '../../../services');
13
+ const cwd = process.cwd();
14
+
15
+ for (const [serviceName, config] of Object.entries(moduleConfig)) {
16
+ const moduleFilesDir = path.join(templateRoot, 'modules', moduleName, serviceName, 'files');
17
+ const moduleSectionsDir = path.join(templateRoot, 'modules', moduleName, serviceName, 'sections');
18
+ const serviceDir = path.join(cwd, serviceName);
19
+
20
+ // Copy files
21
+ for (const filePath of config.files) {
22
+ const src = path.join(moduleFilesDir, filePath);
23
+ const dest = path.join(serviceDir, filePath);
24
+ console.log(` Adding ${filePath}`);
25
+ await fs.copy(src, dest, { overwrite: true });
26
+ }
27
+
28
+ // Inject sections
29
+ for (const [targetFile, markerNames] of Object.entries(config.sections)) {
30
+ const targetFilePath = path.join(serviceDir, targetFile);
31
+ const targetBasename = path.basename(targetFile);
32
+
33
+ for (const markerName of markerNames) {
34
+ const sectionFile = path.join(moduleSectionsDir, `${targetBasename}.${markerName}`);
35
+ console.log(` Injecting ${markerName} into ${targetFile}`);
36
+ const sectionContent = await fs.readFile(sectionFile, 'utf8');
37
+ await replaceSection(targetFilePath, markerName, sectionContent);
38
+ }
39
+ }
40
+
41
+ // Merge dependencies into package.json and sync lockfile
42
+ if (config.dependencies && Object.keys(config.dependencies).length > 0) {
43
+ const packageJsonPath = path.join(serviceDir, 'package.json');
44
+ const packageJson = await fs.readJson(packageJsonPath);
45
+ packageJson.dependencies = {
46
+ ...packageJson.dependencies,
47
+ ...config.dependencies,
48
+ };
49
+ await fs.writeJson(packageJsonPath, packageJson, { spaces: 2 });
50
+
51
+ // Run npm install to sync package-lock.json, otherwise `npm ci` will fail on rebuild
52
+ console.log(`\nRunning npm install in ${serviceName}...`);
53
+ execSync('npm install', { cwd: serviceDir, stdio: 'inherit' });
54
+ }
55
+ }
56
+ }
57
+
58
+ module.exports = { installModule };
@@ -95,6 +95,36 @@ function isWaitlistInstalled(config = null) {
95
95
  return (config.installedServices || []).includes('waitlist');
96
96
  }
97
97
 
98
+ /**
99
+ * Get list of installed modules
100
+ */
101
+ function getInstalledModules() {
102
+ const config = getProjectConfig();
103
+ return config.installedModules || [];
104
+ }
105
+
106
+ /**
107
+ * Check if a module is installed
108
+ */
109
+ function isModuleInstalled(moduleName) {
110
+ const installedModules = getInstalledModules();
111
+ return installedModules.includes(moduleName);
112
+ }
113
+
114
+ /**
115
+ * Add a module to the installed modules list
116
+ */
117
+ function addInstalledModule(moduleName) {
118
+ const config = getProjectConfig();
119
+ if (!config.installedModules) {
120
+ config.installedModules = [];
121
+ }
122
+ if (!config.installedModules.includes(moduleName)) {
123
+ config.installedModules.push(moduleName);
124
+ updateProjectConfig(config);
125
+ }
126
+ }
127
+
98
128
  module.exports = {
99
129
  isLaunchFrameProject,
100
130
  requireProject,
@@ -104,5 +134,8 @@ module.exports = {
104
134
  isComponentInstalled,
105
135
  addInstalledComponent,
106
136
  getPrimaryDomain,
107
- isWaitlistInstalled
137
+ isWaitlistInstalled,
138
+ getInstalledModules,
139
+ isModuleInstalled,
140
+ addInstalledModule
108
141
  };