@launchframe/cli 1.0.0-beta.33 → 1.0.0-beta.35

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.33",
3
+ "version": "1.0.0-beta.35",
4
4
  "description": "Production-ready B2B SaaS boilerplate with subscriptions, credits, and multi-tenancy",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -40,10 +40,12 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
+ "@resvg/resvg-js": "^2.6.2",
43
44
  "bcryptjs": "^2.4.3",
44
45
  "chalk": "^4.1.2",
45
46
  "dotenv": "^17.3.1",
46
47
  "fs-extra": "^11.1.1",
47
- "inquirer": "^8.2.5"
48
+ "inquirer": "^8.2.5",
49
+ "to-ico": "^1.1.5"
48
50
  }
49
51
  }
@@ -0,0 +1,233 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const inquirer = require('inquirer');
5
+ const ora = require('ora');
6
+ const { spawnSync } = require('child_process');
7
+ const { requireProject, getProjectConfig } = require('../utils/project-helpers');
8
+
9
+ function localQuery(infrastructurePath, sql) {
10
+ return spawnSync('docker', [
11
+ 'compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.dev.yml',
12
+ 'exec', '-T', 'database', 'sh', '-c', 'psql -U $POSTGRES_USER $POSTGRES_DB -t -A'
13
+ ], { cwd: infrastructurePath, input: sql, encoding: 'utf8' });
14
+ }
15
+
16
+ function remoteQuery(vpsUser, vpsHost, vpsAppFolder, sql) {
17
+ return spawnSync('ssh', [
18
+ `${vpsUser}@${vpsHost}`,
19
+ `cd ${vpsAppFolder}/infrastructure && docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T database sh -c 'psql -U $POSTGRES_USER $POSTGRES_DB -t -A'`
20
+ ], { input: sql, encoding: 'utf8' });
21
+ }
22
+
23
+ function sqlStr(val) {
24
+ if (val === null || val === undefined) return 'NULL';
25
+ return `'${String(val).replace(/'/g, "''")}'`;
26
+ }
27
+
28
+ function sqlJsonb(val) {
29
+ if (val === null || val === undefined) return 'NULL';
30
+ return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb`;
31
+ }
32
+
33
+ function sqlBool(val) {
34
+ return val ? 'true' : 'false';
35
+ }
36
+
37
+ async function deploySyncFeatures() {
38
+ requireProject();
39
+
40
+ // Step 1 — Project + infrastructure check
41
+ const infrastructurePath = path.join(process.cwd(), 'infrastructure');
42
+
43
+ if (!fs.existsSync(infrastructurePath)) {
44
+ console.error(chalk.red('\n❌ Error: infrastructure/ directory not found'));
45
+ console.log(chalk.gray('Make sure you are in the root of your LaunchFrame project.\n'));
46
+ process.exit(1);
47
+ }
48
+
49
+ // Step 2 — Deployment configured
50
+ const config = getProjectConfig();
51
+
52
+ if (!config.deployConfigured || !config.deployment) {
53
+ console.error(chalk.red('\n❌ Deployment is not configured.'));
54
+ console.log(chalk.gray('Run deploy:configure first.\n'));
55
+ process.exit(1);
56
+ }
57
+
58
+ const { vpsUser, vpsHost, vpsAppFolder } = config.deployment;
59
+
60
+ // Step 3 — Local database container up
61
+ const localPs = spawnSync(
62
+ 'docker',
63
+ [
64
+ 'compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.dev.yml',
65
+ 'ps', '--status', 'running', '-q', 'database'
66
+ ],
67
+ { cwd: infrastructurePath, encoding: 'utf8' }
68
+ );
69
+
70
+ if (!localPs.stdout || localPs.stdout.trim() === '') {
71
+ console.error(chalk.red('\n❌ Local database container is not running.'));
72
+ console.log(chalk.gray('Run: launchframe docker:up\n'));
73
+ process.exit(1);
74
+ }
75
+
76
+ // Step 4 — Remote database container up
77
+ const remoteDockerPsCmd = `cd ${vpsAppFolder}/infrastructure && docker compose -f docker-compose.yml -f docker-compose.prod.yml ps --status running -q database`;
78
+ const remotePs = spawnSync('ssh', [`${vpsUser}@${vpsHost}`, remoteDockerPsCmd], { encoding: 'utf8' });
79
+
80
+ if (remotePs.status !== 0 || !remotePs.stdout || remotePs.stdout.trim() === '') {
81
+ console.error(chalk.red('\n❌ Remote database container is not running.'));
82
+ console.log(chalk.gray('Make sure services are running: launchframe deploy:up\n'));
83
+ process.exit(1);
84
+ }
85
+
86
+ // Step 5 — Remote Polar sync check
87
+ const polarCountResult = remoteQuery(
88
+ vpsUser, vpsHost, vpsAppFolder,
89
+ 'SELECT COUNT(*) FROM subscription_plans WHERE polar_product_id IS NOT NULL;'
90
+ );
91
+
92
+ const polarCount = parseInt((polarCountResult.stdout || '').trim(), 10);
93
+ if (!polarCount || polarCount === 0) {
94
+ console.error(chalk.red('\n❌ Remote plans have not been synced from Polar yet.'));
95
+ console.log(chalk.gray('Import plans from the admin portal first.\n'));
96
+ process.exit(1);
97
+ }
98
+
99
+ // Step 6 — Compare plans local vs remote
100
+ const planCodesSQL = 'SELECT json_agg(code ORDER BY sort_order) FROM subscription_plans;';
101
+
102
+ const localPlansResult = localQuery(infrastructurePath, planCodesSQL);
103
+ const remotePlansResult = remoteQuery(vpsUser, vpsHost, vpsAppFolder, planCodesSQL);
104
+
105
+ let localCodes = [];
106
+ let remoteCodes = [];
107
+
108
+ try {
109
+ localCodes = JSON.parse((localPlansResult.stdout || '').trim()) || [];
110
+ } catch {
111
+ localCodes = [];
112
+ }
113
+
114
+ try {
115
+ remoteCodes = JSON.parse((remotePlansResult.stdout || '').trim()) || [];
116
+ } catch {
117
+ remoteCodes = [];
118
+ }
119
+
120
+ const missingOnRemote = localCodes.filter(code => !remoteCodes.includes(code));
121
+ if (missingOnRemote.length > 0) {
122
+ console.error(chalk.red(`\n❌ Some local plans are missing on remote: ${missingOnRemote.join(', ')}`));
123
+ console.log(chalk.gray('Ensure plans are imported from Polar on the remote admin portal.\n'));
124
+ process.exit(1);
125
+ }
126
+
127
+ // Step 7 — Query local data (for summary counts)
128
+ const featuresSQL = `SELECT json_agg(row_to_json(t)) FROM (
129
+ SELECT name, code, description, feature_type, default_value, template, is_active, sort_order
130
+ FROM subscription_plan_features ORDER BY sort_order
131
+ ) t;`;
132
+
133
+ const featureValuesSQL = `SELECT json_agg(row_to_json(t)) FROM (
134
+ SELECT sp.code AS plan_code, spf.code AS feature_code, spfv.value
135
+ FROM subscription_plan_feature_values spfv
136
+ JOIN subscription_plans sp ON sp.id = spfv.subscription_plan_id
137
+ JOIN subscription_plan_features spf ON spf.id = spfv.feature_id
138
+ ) t;`;
139
+
140
+ const featuresResult = localQuery(infrastructurePath, featuresSQL);
141
+ const featureValuesResult = localQuery(infrastructurePath, featureValuesSQL);
142
+
143
+ let features = [];
144
+ let featureValues = [];
145
+
146
+ try {
147
+ features = JSON.parse((featuresResult.stdout || '').trim()) || [];
148
+ } catch {
149
+ features = [];
150
+ }
151
+
152
+ try {
153
+ featureValues = JSON.parse((featureValuesResult.stdout || '').trim()) || [];
154
+ } catch {
155
+ featureValues = [];
156
+ }
157
+
158
+ // Step 7 cont — Show summary + confirm
159
+ console.log(chalk.yellow.bold('\n⚠️ You are about to overwrite ALL features on the PRODUCTION database.\n'));
160
+ console.log(chalk.gray(` Local features: ${features.length}`));
161
+ console.log(chalk.gray(` Local feature-plan values: ${featureValues.length}`));
162
+ console.log(chalk.gray(` Remote host: ${vpsHost}\n`));
163
+ console.log(chalk.red('This will TRUNCATE subscription_plan_features (cascades to feature values).\n'));
164
+
165
+ const { confirmed } = await inquirer.prompt([
166
+ {
167
+ type: 'confirm',
168
+ name: 'confirmed',
169
+ message: 'Are you sure you want to sync features to production?',
170
+ default: false
171
+ }
172
+ ]);
173
+
174
+ if (!confirmed) {
175
+ console.log(chalk.gray('\nAborted.\n'));
176
+ process.exit(0);
177
+ }
178
+
179
+ // Step 9 — Build sync SQL transaction
180
+ const featureRows = features.map(f =>
181
+ `(${sqlStr(f.name)}, ${sqlStr(f.code)}, ${sqlStr(f.description)}, ${sqlStr(f.feature_type)}, ${sqlJsonb(f.default_value)}, ${sqlStr(f.template)}, ${sqlBool(f.is_active)}, ${f.sort_order !== null && f.sort_order !== undefined ? f.sort_order : 'NULL'}, NOW(), NOW())`
182
+ ).join(',\n ');
183
+
184
+ const featureValueRows = featureValues.map(v =>
185
+ `(${sqlStr(v.plan_code)}, ${sqlStr(v.feature_code)}, ${sqlJsonb(v.value)})`
186
+ ).join(',\n ');
187
+
188
+ let syncSql = `BEGIN;\n\nTRUNCATE subscription_plan_features CASCADE;\n`;
189
+
190
+ if (features.length > 0) {
191
+ syncSql += `
192
+ INSERT INTO subscription_plan_features
193
+ (name, code, description, feature_type, default_value, template, is_active, sort_order, created_at, updated_at)
194
+ VALUES
195
+ ${featureRows};
196
+ `;
197
+ }
198
+
199
+ if (featureValues.length > 0) {
200
+ syncSql += `
201
+ INSERT INTO subscription_plan_feature_values (subscription_plan_id, feature_id, value, created_at, updated_at)
202
+ SELECT p.id, f.id, v.value, NOW(), NOW()
203
+ FROM (VALUES
204
+ ${featureValueRows}
205
+ ) AS v(plan_code, feature_code, value)
206
+ JOIN subscription_plans p ON p.code = v.plan_code
207
+ JOIN subscription_plan_features f ON f.code = v.feature_code;
208
+ `;
209
+ }
210
+
211
+ syncSql += `\nCOMMIT;\n`;
212
+
213
+ // Step 10 — Execute on remote with spinner
214
+ const spinner = ora('Syncing features to production...').start();
215
+
216
+ const execResult = spawnSync('ssh', [
217
+ `${vpsUser}@${vpsHost}`,
218
+ `cd ${vpsAppFolder}/infrastructure && docker compose -f docker-compose.yml -f docker-compose.prod.yml exec -T database sh -c 'psql -v ON_ERROR_STOP=1 -U $POSTGRES_USER $POSTGRES_DB'`
219
+ ], { input: syncSql, encoding: 'utf8' });
220
+
221
+ if (execResult.status !== 0) {
222
+ spinner.fail('Failed to sync features to production.');
223
+ if (execResult.stderr) {
224
+ console.error(chalk.gray(execResult.stderr));
225
+ }
226
+ process.exit(1);
227
+ }
228
+
229
+ spinner.succeed(chalk.green(`Synced ${features.length} features and ${featureValues.length} feature-plan values to production.`));
230
+ console.log();
231
+ }
232
+
233
+ module.exports = { deploySyncFeatures };
@@ -0,0 +1,160 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const { requireProject, getProjectConfig } = require('../utils/project-helpers');
5
+
6
+ /**
7
+ * Render SVG to PNG buffer at a given size using @resvg/resvg-js
8
+ * @param {Buffer} svgBuffer - SVG file buffer
9
+ * @param {number} size - Width/height in pixels
10
+ * @returns {Buffer} PNG buffer
11
+ */
12
+ function renderPng(Resvg, svgBuffer, size) {
13
+ const resvg = new Resvg(svgBuffer, {
14
+ fitTo: { mode: 'width', value: size },
15
+ });
16
+ const pngData = resvg.render();
17
+ return pngData.asPng();
18
+ }
19
+
20
+ /**
21
+ * Write a file, creating directories as needed
22
+ * @param {string} filePath - Absolute path to write
23
+ * @param {Buffer|string} data - File content
24
+ */
25
+ function writeFile(filePath, data) {
26
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
27
+ fs.writeFileSync(filePath, data);
28
+ console.log(chalk.gray(` ✓ ${filePath.replace(process.cwd() + '/', '')}`));
29
+ }
30
+
31
+ /**
32
+ * Generate and inject logo/favicon assets across all relevant frontend services
33
+ */
34
+ async function devLogo() {
35
+ requireProject();
36
+
37
+ const cwd = process.cwd();
38
+ const logoPath = path.join(cwd, 'logo.svg');
39
+
40
+ if (!fs.existsSync(logoPath)) {
41
+ console.error(chalk.red('\n❌ Error: logo.svg not found'));
42
+ console.log(chalk.gray('Place your logo.svg file in the project root:'));
43
+ console.log(chalk.white(` ${cwd}/logo.svg`));
44
+ console.log();
45
+ console.log(chalk.gray('Then run:'));
46
+ console.log(chalk.white(' launchframe dev:logo\n'));
47
+ process.exit(1);
48
+ }
49
+
50
+ const config = getProjectConfig();
51
+ const hasCustomersPortal = (config.installedServices || []).includes('customers-portal');
52
+
53
+ // Lazy load heavy dependencies
54
+ const { Resvg } = require('@resvg/resvg-js');
55
+ const toIco = require('to-ico');
56
+
57
+ const svgBuffer = fs.readFileSync(logoPath);
58
+ const svgContent = svgBuffer.toString('utf8');
59
+
60
+ console.log(chalk.blue.bold('\nGenerating logo assets...\n'));
61
+
62
+ // ─── Website ─────────────────────────────────────────────────────────────
63
+ const websitePath = path.join(cwd, 'website');
64
+ if (!fs.existsSync(websitePath)) {
65
+ console.log(chalk.yellow('⚠ website not found — skipping'));
66
+ } else {
67
+ console.log(chalk.white('website/public/'));
68
+ const pub = path.join(websitePath, 'public');
69
+ const images = path.join(pub, 'images');
70
+
71
+ const png16 = renderPng(Resvg, svgBuffer, 16);
72
+ const png32 = renderPng(Resvg, svgBuffer, 32);
73
+ const png96 = renderPng(Resvg, svgBuffer, 96);
74
+ const png180 = renderPng(Resvg, svgBuffer, 180);
75
+ const png512 = renderPng(Resvg, svgBuffer, 512);
76
+ const icoBuffer = await toIco([png16, png32]);
77
+
78
+ writeFile(path.join(pub, 'favicon.ico'), icoBuffer);
79
+ writeFile(path.join(pub, 'favicon.svg'), svgContent);
80
+ writeFile(path.join(pub, 'favicon.png'), png32);
81
+ writeFile(path.join(pub, 'favicon-96x96.png'), png96);
82
+ writeFile(path.join(pub, 'apple-touch-icon.png'), png180);
83
+ writeFile(path.join(images, 'logo.svg'), svgContent);
84
+ writeFile(path.join(images, 'logo.png'), png512);
85
+ }
86
+
87
+ // ─── Admin Portal ─────────────────────────────────────────────────────────
88
+ const adminPath = path.join(cwd, 'admin-portal');
89
+ if (!fs.existsSync(adminPath)) {
90
+ console.log(chalk.yellow('⚠ admin-portal not found — skipping'));
91
+ } else {
92
+ console.log(chalk.white('\nadmin-portal/public/'));
93
+ const pub = path.join(adminPath, 'public');
94
+ const favicons = path.join(pub, 'favicons');
95
+ const srcAssets = path.join(adminPath, 'src', 'assets');
96
+
97
+ const png16 = renderPng(Resvg, svgBuffer, 16);
98
+ const png24 = renderPng(Resvg, svgBuffer, 24);
99
+ const png32 = renderPng(Resvg, svgBuffer, 32);
100
+ const png64 = renderPng(Resvg, svgBuffer, 64);
101
+ const png96 = renderPng(Resvg, svgBuffer, 96);
102
+ const png180 = renderPng(Resvg, svgBuffer, 180);
103
+ const png192 = renderPng(Resvg, svgBuffer, 192);
104
+ const png512 = renderPng(Resvg, svgBuffer, 512);
105
+ const icoBuffer = await toIco([png16, png32]);
106
+ const icoFavicons = await toIco([png16, png24, png32, png64]);
107
+
108
+ writeFile(path.join(pub, 'favicon.ico'), icoBuffer);
109
+ writeFile(path.join(pub, 'favicon.svg'), svgContent);
110
+ writeFile(path.join(pub, 'favicon.png'), png32);
111
+ writeFile(path.join(favicons, 'favicon.svg'), svgContent);
112
+ writeFile(path.join(favicons, 'favicon.ico'), icoFavicons);
113
+ writeFile(path.join(favicons, 'favicon-16x16.png'), png16);
114
+ writeFile(path.join(favicons, 'favicon-32x32.png'), png32);
115
+ writeFile(path.join(favicons, 'favicon-96x96.png'), png96);
116
+ writeFile(path.join(favicons, 'web-app-manifest-192x192.png'), png192);
117
+ writeFile(path.join(favicons, 'web-app-manifest-96x96.png'), png96);
118
+ writeFile(path.join(favicons, 'web-app-manifest-512x512.png'), png512);
119
+ writeFile(path.join(favicons, 'apple-touch-icon.png'), png180);
120
+ writeFile(path.join(pub, 'logo.svg'), svgContent);
121
+ writeFile(path.join(pub, 'logo.png'), png512);
122
+ writeFile(path.join(pub, 'logo192.png'), png192);
123
+ writeFile(path.join(pub, 'logo512.png'), png512);
124
+ writeFile(path.join(srcAssets, 'logo.svg'), svgContent);
125
+ }
126
+
127
+ // ─── Customers Portal (B2B2C only) ────────────────────────────────────────
128
+ if (hasCustomersPortal) {
129
+ const customersPath = path.join(cwd, 'customers-portal');
130
+ if (!fs.existsSync(customersPath)) {
131
+ console.log(chalk.yellow('\n⚠ customers-portal not found — skipping'));
132
+ } else {
133
+ console.log(chalk.white('\ncustomers-portal/public/'));
134
+ const pub = path.join(customersPath, 'public');
135
+ const favicons = path.join(pub, 'favicons');
136
+
137
+ const png16 = renderPng(Resvg, svgBuffer, 16);
138
+ const png32 = renderPng(Resvg, svgBuffer, 32);
139
+ const png96 = renderPng(Resvg, svgBuffer, 96);
140
+ const png180 = renderPng(Resvg, svgBuffer, 180);
141
+ const png512 = renderPng(Resvg, svgBuffer, 512);
142
+ const icoBuffer = await toIco([png16, png32]);
143
+
144
+ writeFile(path.join(pub, 'favicon.ico'), icoBuffer);
145
+ writeFile(path.join(pub, 'favicon.svg'), svgContent);
146
+ writeFile(path.join(pub, 'favicon.png'), png32);
147
+ writeFile(path.join(favicons, 'favicon.svg'), svgContent);
148
+ writeFile(path.join(favicons, 'favicon-16x16.png'), png16);
149
+ writeFile(path.join(favicons, 'favicon-32x32.png'), png32);
150
+ writeFile(path.join(favicons, 'favicon-96x96.png'), png96);
151
+ writeFile(path.join(favicons, 'apple-touch-icon.png'), png180);
152
+ writeFile(path.join(pub, 'logo.svg'), svgContent);
153
+ writeFile(path.join(pub, 'logo.png'), png512);
154
+ }
155
+ }
156
+
157
+ console.log(chalk.green('\n✅ Logo assets generated successfully!\n'));
158
+ }
159
+
160
+ module.exports = { devLogo };
@@ -20,7 +20,8 @@ function help() {
20
20
  console.log(chalk.gray(' deploy:set-env Configure production environment variables'));
21
21
  console.log(chalk.gray(' deploy:init Initialize VPS and build Docker images'));
22
22
  console.log(chalk.gray(' deploy:up Start services on VPS'));
23
- console.log(chalk.gray(' deploy:build [service] Build, push, and deploy (all or specific service)\n'));
23
+ console.log(chalk.gray(' deploy:build [service] Build, push, and deploy (all or specific service)'));
24
+ console.log(chalk.gray(' deploy:sync-features Sync local features and plan assignments to production\n'));
24
25
 
25
26
  // Conditionally show waitlist commands
26
27
  if (isWaitlistInstalled()) {
@@ -60,7 +61,8 @@ function help() {
60
61
  console.log(chalk.gray(' cache:clear Delete cache (re-download on next use)\n'));
61
62
  console.log(chalk.white('Dev Helpers:'));
62
63
  console.log(chalk.gray(' dev:add-user Generate and insert a random test user into the local database'));
63
- console.log(chalk.gray(' dev:queue Open the Bull queue dashboard in the browser\n'));
64
+ console.log(chalk.gray(' dev:queue Open the Bull queue dashboard in the browser'));
65
+ console.log(chalk.gray(' dev:logo Generate and inject favicon/logo assets from logo.svg\n'));
64
66
  console.log(chalk.white('Other commands:'));
65
67
  console.log(chalk.gray(' doctor Check project health and configuration'));
66
68
  console.log(chalk.gray(' telemetry Show telemetry status'));
package/src/index.js CHANGED
@@ -45,6 +45,8 @@ const { moduleAdd, moduleList } = require('./commands/module');
45
45
  const { cacheClear, cacheInfo, cacheUpdate } = require('./commands/cache');
46
46
  const { devAddUser } = require('./commands/dev-add-user');
47
47
  const { devQueue } = require('./commands/dev-queue');
48
+ const { devLogo } = require('./commands/dev-logo');
49
+ const { deploySyncFeatures } = require('./commands/deploy-sync-features');
48
50
 
49
51
  // Get command and arguments
50
52
  const command = process.argv[2];
@@ -128,6 +130,9 @@ async function main() {
128
130
  case 'deploy:build':
129
131
  await deployBuild(args[1]); // Optional service name
130
132
  break;
133
+ case 'deploy:sync-features':
134
+ await deploySyncFeatures();
135
+ break;
131
136
  case 'waitlist:deploy':
132
137
  await waitlistDeploy();
133
138
  break;
@@ -215,6 +220,9 @@ async function main() {
215
220
  case 'dev:queue':
216
221
  await devQueue();
217
222
  break;
223
+ case 'dev:logo':
224
+ await devLogo();
225
+ break;
218
226
  case 'telemetry':
219
227
  if (flags.disable) {
220
228
  setTelemetryEnabled(false);