@launchframe/cli 1.0.0-beta.3 โ†’ 1.0.0-beta.31

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.
@@ -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
  };
@@ -0,0 +1,238 @@
1
+ const https = require('https');
2
+ const crypto = require('crypto');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const chalk = require('chalk');
7
+
8
+ const MIXPANEL_TOKEN = '3e6214f33ba535dec14021547039427c';
9
+ const CONFIG_DIR = path.join(os.homedir(), '.launchframe');
10
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
11
+
12
+ let config = null;
13
+
14
+ /**
15
+ * Sanitize error messages by stripping file paths and capping length
16
+ * @param {string} message - Raw error message
17
+ * @returns {string} Sanitized message
18
+ */
19
+ function sanitize(message) {
20
+ if (!message) return 'unknown';
21
+ return message
22
+ .replace(/\/[\w\-\/.]+/g, '<path>')
23
+ .replace(/[A-Z]:\\[\w\-\\.\\]+/g, '<path>')
24
+ .substring(0, 200);
25
+ }
26
+
27
+ /**
28
+ * Read config from disk, or return defaults
29
+ * @returns {Object} Config object
30
+ */
31
+ function readConfig() {
32
+ try {
33
+ if (fs.existsSync(CONFIG_PATH)) {
34
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
35
+ return JSON.parse(raw);
36
+ }
37
+ } catch {
38
+ // Corrupted config โ€” start fresh
39
+ }
40
+ return {};
41
+ }
42
+
43
+ /**
44
+ * Write config to disk
45
+ * @param {Object} cfg - Config object to write
46
+ */
47
+ function writeConfig(cfg) {
48
+ try {
49
+ if (!fs.existsSync(CONFIG_DIR)) {
50
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
51
+ }
52
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
53
+ } catch {
54
+ // Silently ignore write failures
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Check if telemetry is disabled via environment variables
60
+ * @returns {boolean} True if disabled via env
61
+ */
62
+ function isDisabledByEnv() {
63
+ return process.env.DO_NOT_TRACK === '1' || process.env.LAUNCHFRAME_TELEMETRY_DISABLED === '1';
64
+ }
65
+
66
+ /**
67
+ * Check if running from a locally linked (dev) version
68
+ * @returns {boolean} True if running via npm link
69
+ */
70
+ function isDevMode() {
71
+ return !__dirname.includes('node_modules');
72
+ }
73
+
74
+ /**
75
+ * Check if telemetry is enabled
76
+ * @returns {boolean} True if telemetry is enabled
77
+ */
78
+ function isEnabled() {
79
+ if (isDevMode()) return false;
80
+ if (isDisabledByEnv()) return false;
81
+ if (!config || !config.telemetry) return false;
82
+ return config.telemetry.enabled !== false;
83
+ }
84
+
85
+ /**
86
+ * Initialize telemetry โ€” call once at CLI startup.
87
+ * Reads/creates config, shows first-run notice if needed.
88
+ * Synchronous and fast.
89
+ */
90
+ function initTelemetry() {
91
+ try {
92
+ config = readConfig();
93
+
94
+ if (!config.telemetry) {
95
+ config.telemetry = {
96
+ enabled: true,
97
+ noticeShown: false,
98
+ anonymousId: crypto.randomUUID()
99
+ };
100
+ writeConfig(config);
101
+ }
102
+
103
+ if (!config.telemetry.anonymousId) {
104
+ config.telemetry.anonymousId = crypto.randomUUID();
105
+ writeConfig(config);
106
+ }
107
+
108
+ if (!config.telemetry.noticeShown && !isDisabledByEnv() && !isDevMode()) {
109
+ console.log(
110
+ chalk.gray(
111
+ '\nLaunchFrame collects anonymous usage data to improve the CLI.\n' +
112
+ 'No personal information is collected. Run `launchframe telemetry --disable` to opt out.\n' +
113
+ 'Learn more: https://launchframe.dev/privacy\n'
114
+ )
115
+ );
116
+ config.telemetry.noticeShown = true;
117
+ writeConfig(config);
118
+ }
119
+ } catch {
120
+ // Telemetry init must never break the CLI
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Fire-and-forget event tracking.
126
+ * @param {string} name - Event name
127
+ * @param {Object} properties - Event properties
128
+ */
129
+ function trackEvent(name, properties = {}) {
130
+ try {
131
+ if (!isEnabled()) return;
132
+
133
+ const cliVersion = (() => {
134
+ try {
135
+ return require('../../package.json').version;
136
+ } catch {
137
+ return 'unknown';
138
+ }
139
+ })();
140
+
141
+ const payload = JSON.stringify([
142
+ {
143
+ event: name,
144
+ properties: {
145
+ token: MIXPANEL_TOKEN,
146
+ distinct_id: config.telemetry.anonymousId,
147
+ cli_version: cliVersion,
148
+ node_version: process.version,
149
+ os: process.platform,
150
+ os_arch: process.arch,
151
+ ...properties
152
+ }
153
+ }
154
+ ]);
155
+
156
+ const req = https.request(
157
+ {
158
+ hostname: 'api-eu.mixpanel.com',
159
+ path: '/track',
160
+ method: 'POST',
161
+ headers: {
162
+ 'Content-Type': 'application/json',
163
+ Accept: 'text/plain',
164
+ 'Content-Length': Buffer.byteLength(payload)
165
+ }
166
+ },
167
+ (res) => { res.resume(); }
168
+ );
169
+
170
+ req.on('error', () => {});
171
+ req.write(payload);
172
+ req.end();
173
+ req.unref();
174
+ } catch {
175
+ // Telemetry must never break the CLI
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Enable or disable telemetry
181
+ * @param {boolean} enabled - Whether to enable telemetry
182
+ */
183
+ function setTelemetryEnabled(enabled) {
184
+ config = readConfig();
185
+
186
+ if (!config.telemetry) {
187
+ config.telemetry = {
188
+ enabled,
189
+ noticeShown: true,
190
+ anonymousId: crypto.randomUUID()
191
+ };
192
+ } else {
193
+ config.telemetry.enabled = enabled;
194
+ config.telemetry.noticeShown = true;
195
+ }
196
+
197
+ writeConfig(config);
198
+
199
+ if (enabled) {
200
+ console.log(chalk.green('\nTelemetry enabled. Thank you for helping improve LaunchFrame!\n'));
201
+ } else {
202
+ console.log(chalk.yellow('\nTelemetry disabled. No data will be collected.\n'));
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Show current telemetry status
208
+ */
209
+ function showTelemetryStatus() {
210
+ config = readConfig();
211
+ const envDisabled = isDisabledByEnv();
212
+
213
+ console.log(chalk.blue.bold('\nTelemetry Status\n'));
214
+
215
+ if (envDisabled) {
216
+ console.log(chalk.yellow(' Disabled via environment variable'));
217
+ if (process.env.DO_NOT_TRACK === '1') {
218
+ console.log(chalk.gray(' DO_NOT_TRACK=1'));
219
+ }
220
+ if (process.env.LAUNCHFRAME_TELEMETRY_DISABLED === '1') {
221
+ console.log(chalk.gray(' LAUNCHFRAME_TELEMETRY_DISABLED=1'));
222
+ }
223
+ } else if (config.telemetry && config.telemetry.enabled !== false) {
224
+ console.log(chalk.green(' Enabled'));
225
+ } else {
226
+ console.log(chalk.yellow(' Disabled'));
227
+ }
228
+
229
+ if (config.telemetry && config.telemetry.anonymousId) {
230
+ console.log(chalk.gray(` Anonymous ID: ${config.telemetry.anonymousId}`));
231
+ }
232
+
233
+ console.log(chalk.gray('\n To disable: launchframe telemetry --disable'));
234
+ console.log(chalk.gray(' To enable: launchframe telemetry --enable'));
235
+ console.log(chalk.gray(' Env vars: DO_NOT_TRACK=1 or LAUNCHFRAME_TELEMETRY_DISABLED=1\n'));
236
+ }
237
+
238
+ module.exports = { initTelemetry, trackEvent, sanitize, setTelemetryEnabled, showTelemetryStatus };
@@ -1,6 +1,9 @@
1
1
  const fs = require('fs-extra');
2
+ const { glob } = require('node:fs');
2
3
  const path = require('path');
3
- const { glob } = require('glob');
4
+
5
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build']);
6
+ const BINARY_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.ico', '.pdf', '.woff', '.woff2', '.ttf', '.eot']);
4
7
 
5
8
  /**
6
9
  * Replace template variables in all files within a directory
@@ -9,31 +12,23 @@ const { glob } = require('glob');
9
12
  */
10
13
  async function replaceVariables(directory, variables) {
11
14
  // Find all files (excluding node_modules, .git, binary files)
12
- const files = await glob('**/*', {
13
- cwd: directory,
14
- nodir: true,
15
- dot: true, // Include hidden files/directories like .vitepress
16
- ignore: [
17
- '**/node_modules/**',
18
- '**/.git/**',
19
- '**/.next/**',
20
- '**/dist/**',
21
- '**/build/**',
22
- '**/*.png',
23
- '**/*.jpg',
24
- '**/*.jpeg',
25
- '**/*.gif',
26
- '**/*.ico',
27
- '**/*.pdf',
28
- '**/*.woff',
29
- '**/*.woff2',
30
- '**/*.ttf',
31
- '**/*.eot'
32
- ]
15
+ const files = await new Promise((resolve, reject) => {
16
+ glob('**/*', {
17
+ cwd: directory,
18
+ exclude: (name) => EXCLUDED_DIRS.has(name),
19
+ }, (err, matches) => err ? reject(err) : resolve(matches));
20
+ });
21
+
22
+ const filtered = files.filter(f => {
23
+ const ext = path.extname(f).toLowerCase();
24
+ return !BINARY_EXTENSIONS.has(ext);
33
25
  });
34
26
 
35
- for (const file of files) {
27
+ for (const file of filtered) {
36
28
  const filePath = path.join(directory, file);
29
+ // Skip directories (fs.glob includes them unlike third-party glob packages)
30
+ const stat = await fs.stat(filePath);
31
+ if (stat.isDirectory()) continue;
37
32
  await replaceVariablesInFile(filePath, variables);
38
33
  }
39
34
  }
@@ -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,35 @@ 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
+ // 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);
65
68
  continue;
66
69
  }
67
70
 
68
- console.log(`\n โœจ Applying ${variantName} variant:`);
71
+ logger.detail(`Applying ${variantName} variant`, 2);
69
72
 
70
- // Step 3a: Copy variant FILES
73
+ // Copy variant FILES
71
74
  await copyVariantFiles(
72
75
  variantName,
73
76
  variantConfig.files || [],
@@ -76,7 +79,7 @@ async function processServiceVariant(
76
79
  templateRoot
77
80
  );
78
81
 
79
- // Step 3b: Insert variant SECTIONS
82
+ // Insert variant SECTIONS
80
83
  await insertVariantSections(
81
84
  variantName,
82
85
  variantConfig.sections || {},
@@ -86,15 +89,15 @@ async function processServiceVariant(
86
89
  );
87
90
  }
88
91
 
89
- // Step 4: Clean up unused section markers
90
- console.log(`\n ๐Ÿงน Cleaning up unused section markers`);
92
+ // Clean up unused section markers
93
+ logger.detail('Cleaning up unused section markers', 2);
91
94
  await cleanupSectionMarkers(serviceName, serviceConfig, variantsToApply, destination);
92
95
 
93
- // Step 5: Replace template variables ({{PROJECT_NAME}}, etc.)
94
- console.log(`\n ๐Ÿ”ค Replacing template variables`);
96
+ // Replace template variables
97
+ logger.detail('Replacing template variables', 2);
95
98
  await replaceVariables(destination, replacements);
96
99
 
97
- console.log(` โœ… ${serviceName} processing complete\n`);
100
+ logger.detail(`${serviceName} complete`, 2);
98
101
  }
99
102
 
100
103
  /**
@@ -110,7 +113,7 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
110
113
  const unappliedVariants = allVariants.filter(v => !appliedVariants.includes(v));
111
114
 
112
115
  if (unappliedVariants.length === 0) {
113
- console.log(` โœ“ No unused section markers to clean`);
116
+ logger.detail('No unused section markers to clean', 3);
114
117
  return;
115
118
  }
116
119
 
@@ -146,8 +149,6 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
146
149
  // Remove each unused section marker (keep content, remove only marker comments)
147
150
  for (const sectionName of sectionNames) {
148
151
  // 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
152
  const slashPattern = new RegExp(
152
153
  `^[ \\t]*\\/\\/ ${sectionName}_START\\n([\\s\\S]*?)^[ \\t]*\\/\\/ ${sectionName}_END\\n?`,
153
154
  'gm'
@@ -185,14 +186,14 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
185
186
  totalCleaned++;
186
187
  }
187
188
  } catch (error) {
188
- console.warn(` โš ๏ธ Could not clean markers in ${filePath}:`, error.message);
189
+ logger.warn(`Could not clean markers in ${filePath}: ${error.message}`);
189
190
  }
190
191
  }
191
192
 
192
193
  if (totalCleaned > 0) {
193
- console.log(` โœ“ Cleaned up section markers in ${totalCleaned} file(s)`);
194
+ logger.detail(`Cleaned section markers in ${totalCleaned} file(s)`, 3);
194
195
  } else {
195
- console.log(` โœ“ No unused section markers found`);
196
+ logger.detail('No unused section markers found', 3);
196
197
  }
197
198
  }
198
199
 
@@ -206,11 +207,11 @@ async function cleanupSectionMarkers(serviceName, serviceConfig, appliedVariants
206
207
  */
207
208
  async function copyVariantFiles(variantName, files, filesDir, destination, templateRoot) {
208
209
  if (!files || files.length === 0) {
209
- console.log(` ๐Ÿ“‚ No files to copy for ${variantName}`);
210
+ logger.detail(`No files to copy for ${variantName}`, 3);
210
211
  return;
211
212
  }
212
213
 
213
- console.log(` ๐Ÿ“‚ Copying ${files.length} file(s)/folder(s):`);
214
+ logger.detail(`Copying ${files.length} file(s) for ${variantName}`, 3);
214
215
 
215
216
  const variantFilesPath = path.join(templateRoot, filesDir, variantName);
216
217
 
@@ -219,22 +220,18 @@ async function copyVariantFiles(variantName, files, filesDir, destination, templ
219
220
  const destPath = path.join(destination, filePath);
220
221
 
221
222
  try {
222
- // Check if source exists
223
223
  if (!await fs.pathExists(sourcePath)) {
224
- console.warn(` โš ๏ธ Source not found: ${filePath}, skipping`);
224
+ logger.warn(`Source not found: ${filePath}, skipping`);
225
225
  continue;
226
226
  }
227
227
 
228
- // Create parent directory if needed
229
228
  await fs.ensureDir(path.dirname(destPath));
230
-
231
- // Copy file or directory
232
229
  await fs.copy(sourcePath, destPath, { overwrite: true });
233
230
 
234
231
  const isDir = (await fs.stat(sourcePath)).isDirectory();
235
- console.log(` โœ“ Copied ${isDir ? 'folder' : 'file'}: ${filePath}`);
232
+ logger.detail(`Copied ${isDir ? 'folder' : 'file'}: ${filePath}`, 4);
236
233
  } catch (error) {
237
- console.warn(` โš ๏ธ Could not copy ${filePath}:`, error.message);
234
+ logger.warn(`Could not copy ${filePath}: ${error.message}`);
238
235
  }
239
236
  }
240
237
  }
@@ -249,45 +246,41 @@ async function copyVariantFiles(variantName, files, filesDir, destination, templ
249
246
  */
250
247
  async function insertVariantSections(variantName, sections, sectionsDir, destination, templateRoot) {
251
248
  if (!sections || Object.keys(sections).length === 0) {
252
- console.log(` โœ๏ธ No sections to insert for ${variantName}`);
249
+ logger.detail(`No sections to insert for ${variantName}`, 3);
253
250
  return;
254
251
  }
255
252
 
256
253
  const sectionFiles = Object.keys(sections);
257
- console.log(` โœ๏ธ Inserting sections into ${sectionFiles.length} file(s):`);
254
+ logger.detail(`Inserting sections into ${sectionFiles.length} file(s)`, 3);
258
255
 
259
256
  const variantSectionsPath = path.join(templateRoot, sectionsDir, variantName);
260
257
 
261
258
  for (const [filePath, sectionNames] of Object.entries(sections)) {
262
259
  const targetFilePath = path.join(destination, filePath);
263
260
 
264
- // Check if target file exists
265
261
  if (!await fs.pathExists(targetFilePath)) {
266
- console.warn(` โš ๏ธ Target file not found: ${filePath}, skipping sections`);
262
+ logger.warn(`Target file not found: ${filePath}, skipping sections`);
267
263
  continue;
268
264
  }
269
265
 
270
- console.log(` ๐Ÿ“ ${filePath}:`);
266
+ logger.detail(`Processing ${filePath}`, 4);
271
267
 
272
268
  for (const sectionName of sectionNames) {
273
269
  try {
274
- // Read section content from file
275
270
  const fileName = path.basename(filePath);
276
271
  const sectionFileName = `${fileName}.${sectionName}`;
277
272
  const sectionFilePath = path.join(variantSectionsPath, sectionFileName);
278
273
 
279
274
  if (!await fs.pathExists(sectionFilePath)) {
280
- console.warn(` โš ๏ธ Section file not found: ${sectionFileName}, skipping`);
275
+ logger.warn(`Section file not found: ${sectionFileName}, skipping`);
281
276
  continue;
282
277
  }
283
278
 
284
279
  const sectionContent = await fs.readFile(sectionFilePath, 'utf-8');
285
-
286
- // Insert section content into target file
287
280
  await replaceSection(targetFilePath, sectionName, sectionContent);
288
- console.log(` โœ“ Inserted [${sectionName}]`);
281
+ logger.detail(`Inserted [${sectionName}]`, 5);
289
282
  } catch (error) {
290
- console.warn(` โš ๏ธ Could not insert section ${sectionName}:`, error.message);
283
+ logger.warn(`Could not insert section ${sectionName}: ${error.message}`);
291
284
  }
292
285
  }
293
286
  }