@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.
- package/.claude/settings.local.json +12 -0
- package/CLAUDE.md +27 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/LICENSE +21 -0
- package/README.md +7 -1
- package/package.json +9 -6
- package/src/commands/cache.js +14 -14
- package/src/commands/database-console.js +84 -0
- package/src/commands/deploy-build.js +76 -0
- package/src/commands/deploy-configure.js +10 -3
- package/src/commands/deploy-init.js +24 -57
- package/src/commands/deploy-set-env.js +17 -7
- package/src/commands/deploy-sync-features.js +233 -0
- package/src/commands/deploy-up.js +4 -3
- package/src/commands/dev-add-user.js +165 -0
- package/src/commands/dev-logo.js +160 -0
- package/src/commands/dev-npm-install.js +33 -0
- package/src/commands/dev-queue.js +85 -0
- package/src/commands/docker-build.js +9 -6
- package/src/commands/help.js +35 -11
- package/src/commands/init.js +48 -56
- package/src/commands/migration-create.js +40 -0
- package/src/commands/migration-revert.js +32 -0
- package/src/commands/migration-run.js +32 -0
- package/src/commands/module.js +146 -0
- package/src/commands/service.js +6 -6
- package/src/commands/waitlist-deploy.js +1 -0
- package/src/generator.js +43 -42
- package/src/index.js +109 -4
- package/src/services/module-config.js +25 -0
- package/src/services/module-registry.js +12 -0
- package/src/services/variant-config.js +24 -13
- package/src/utils/docker-helper.js +116 -2
- package/src/utils/env-generator.js +9 -6
- package/src/utils/env-validator.js +4 -2
- package/src/utils/github-access.js +19 -17
- package/src/utils/logger.js +93 -0
- package/src/utils/module-installer.js +58 -0
- package/src/utils/project-helpers.js +34 -1
- package/src/utils/{module-cache.js → service-cache.js} +67 -73
- package/src/utils/ssh-helper.js +51 -1
- package/src/utils/telemetry.js +238 -0
- package/src/utils/variable-replacer.js +18 -23
- package/src/utils/variant-processor.js +35 -42
|
@@ -71,7 +71,7 @@ async function deploySetEnv() {
|
|
|
71
71
|
// Generate secure defaults
|
|
72
72
|
const dbPassword = generateSecret(24);
|
|
73
73
|
const redisPassword = generateSecret(24);
|
|
74
|
-
const
|
|
74
|
+
const betterAuthSecret = generateSecret(32);
|
|
75
75
|
const bullAdminToken = generateSecret(32);
|
|
76
76
|
|
|
77
77
|
const answers = await inquirer.prompt([
|
|
@@ -91,9 +91,9 @@ async function deploySetEnv() {
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
type: 'password',
|
|
94
|
-
name: '
|
|
95
|
-
message: '
|
|
96
|
-
default:
|
|
94
|
+
name: 'betterAuthSecret',
|
|
95
|
+
message: 'Better Auth secret (min 32 chars):',
|
|
96
|
+
default: betterAuthSecret,
|
|
97
97
|
mask: '*'
|
|
98
98
|
},
|
|
99
99
|
{
|
|
@@ -143,7 +143,7 @@ async function deploySetEnv() {
|
|
|
143
143
|
|
|
144
144
|
replacements['DB_PASSWORD'] = answers.dbPassword;
|
|
145
145
|
replacements['REDIS_PASSWORD'] = answers.redisPassword;
|
|
146
|
-
replacements['
|
|
146
|
+
replacements['BETTER_AUTH_SECRET'] = answers.betterAuthSecret;
|
|
147
147
|
replacements['BULL_ADMIN_TOKEN'] = answers.bullAdminToken;
|
|
148
148
|
replacements['RESEND_API_KEY'] = answers.resendApiKey || 're_your_resend_api_key';
|
|
149
149
|
replacements['POLAR_ACCESS_TOKEN'] = answers.polarAccessToken || 'polar_oat_your_token';
|
|
@@ -163,11 +163,21 @@ async function deploySetEnv() {
|
|
|
163
163
|
// Update production URLs based on deployment config
|
|
164
164
|
if (config.deployment?.primaryDomain) {
|
|
165
165
|
const domain = config.deployment.primaryDomain;
|
|
166
|
+
const adminEmail = config.deployment.adminEmail || `admin@${domain}`;
|
|
167
|
+
|
|
168
|
+
// First, replace all {{PRIMARY_DOMAIN}} and {{ADMIN_EMAIL}} placeholders globally
|
|
169
|
+
envContent = envContent.split('{{PRIMARY_DOMAIN}}').join(domain);
|
|
170
|
+
envContent = envContent.split('{{ADMIN_EMAIL}}').join(adminEmail);
|
|
171
|
+
|
|
172
|
+
// Then update specific URL variables for production
|
|
166
173
|
const urlReplacements = {
|
|
174
|
+
'PRIMARY_DOMAIN': domain,
|
|
175
|
+
'NODE_ENV': 'production',
|
|
167
176
|
'API_BASE_URL': `https://api.${domain}`,
|
|
168
177
|
'ADMIN_BASE_URL': `https://admin.${domain}`,
|
|
169
|
-
'FRONTEND_BASE_URL': `https
|
|
170
|
-
'WEBSITE_BASE_URL': `https
|
|
178
|
+
'FRONTEND_BASE_URL': `https://${domain}`,
|
|
179
|
+
'WEBSITE_BASE_URL': `https://www.${domain}`,
|
|
180
|
+
'GOOGLE_REDIRECT_URI': `https://api.${domain}/auth/google/callback`
|
|
171
181
|
};
|
|
172
182
|
|
|
173
183
|
for (const [key, value] of Object.entries(urlReplacements)) {
|
|
@@ -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 };
|
|
@@ -105,8 +105,8 @@ async function deployUp() {
|
|
|
105
105
|
const verifySpinner = ora('Checking service status...').start();
|
|
106
106
|
|
|
107
107
|
try {
|
|
108
|
-
const { stdout: psOutput} = await execAsync(
|
|
109
|
-
`ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml ps"`,
|
|
108
|
+
const { stdout: psOutput } = await execAsync(
|
|
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
|
|
|
@@ -114,6 +114,7 @@ async function deployUp() {
|
|
|
114
114
|
|
|
115
115
|
console.log(chalk.gray('\n' + psOutput));
|
|
116
116
|
} catch (error) {
|
|
117
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
117
118
|
verifySpinner.warn('Could not verify services');
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -138,7 +139,7 @@ async function deployUp() {
|
|
|
138
139
|
console.log(chalk.gray(' Just push to GitHub - CI/CD will handle deployment automatically!\n'));
|
|
139
140
|
|
|
140
141
|
console.log(chalk.white('3. Monitor services:'));
|
|
141
|
-
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`));
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
module.exports = { deployUp };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const bcrypt = require('bcryptjs');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { requireProject, getProjectConfig } = require('../utils/project-helpers');
|
|
8
|
+
|
|
9
|
+
const FIRST_NAMES = [
|
|
10
|
+
'Alice', 'Bob', 'Carol', 'David', 'Eve', 'Frank', 'Grace', 'Henry',
|
|
11
|
+
'Iris', 'Jack', 'Karen', 'Leo', 'Mia', 'Nathan', 'Olivia'
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const LAST_NAMES = [
|
|
15
|
+
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller',
|
|
16
|
+
'Davis', 'Wilson', 'Moore', 'Taylor', 'Anderson', 'Thomas', 'Jackson', 'White'
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function randomElement(arr) {
|
|
20
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function generateDummyUser() {
|
|
24
|
+
const firstName = randomElement(FIRST_NAMES);
|
|
25
|
+
const lastName = randomElement(LAST_NAMES);
|
|
26
|
+
const suffix = String(Math.floor(1000 + Math.random() * 9000));
|
|
27
|
+
const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}${suffix}@gmail.com`;
|
|
28
|
+
return { firstName, lastName, name: `${firstName} ${lastName}`, email, suffix };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function checkEmailExists(infrastructurePath, email) {
|
|
32
|
+
const composeArgs = [
|
|
33
|
+
'compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.dev.yml',
|
|
34
|
+
'exec', '-T', 'database', 'sh', '-c', 'psql -U $POSTGRES_USER $POSTGRES_DB -t -c "SELECT COUNT(*) FROM users WHERE email = \'__EMAIL__\'"'
|
|
35
|
+
.replace('__EMAIL__', email.replace(/'/g, "''"))
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const result = spawnSync('docker', composeArgs, { cwd: infrastructurePath, encoding: 'utf8' });
|
|
39
|
+
if (result.status !== 0) return false;
|
|
40
|
+
const count = parseInt((result.stdout || '').trim(), 10);
|
|
41
|
+
return count > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function devAddUser() {
|
|
45
|
+
requireProject();
|
|
46
|
+
|
|
47
|
+
const infrastructurePath = path.join(process.cwd(), 'infrastructure');
|
|
48
|
+
|
|
49
|
+
if (!fs.existsSync(infrastructurePath)) {
|
|
50
|
+
console.error(chalk.red('\n❌ Error: infrastructure/ directory not found'));
|
|
51
|
+
console.log(chalk.gray('Make sure you are in the root of your LaunchFrame project.\n'));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check database container is running
|
|
56
|
+
const psResult = spawnSync(
|
|
57
|
+
'docker',
|
|
58
|
+
[
|
|
59
|
+
'compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.dev.yml',
|
|
60
|
+
'ps', '--status', 'running', '-q', 'database'
|
|
61
|
+
],
|
|
62
|
+
{ cwd: infrastructurePath, encoding: 'utf8' }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!psResult.stdout || psResult.stdout.trim() === '') {
|
|
66
|
+
console.error(chalk.red('\n❌ Database container is not running.'));
|
|
67
|
+
console.log(chalk.gray('Start local services first:'));
|
|
68
|
+
console.log(chalk.white(' launchframe docker:up\n'));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Generate user with unique email (up to 5 attempts)
|
|
73
|
+
let user = null;
|
|
74
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
75
|
+
const candidate = generateDummyUser();
|
|
76
|
+
if (!checkEmailExists(infrastructurePath, candidate.email)) {
|
|
77
|
+
user = candidate;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!user) {
|
|
83
|
+
console.error(chalk.red('\n❌ Could not generate a unique email after 5 attempts.'));
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const config = getProjectConfig();
|
|
88
|
+
const isMultiTenant = config.variants?.tenancy === 'multi-tenant';
|
|
89
|
+
|
|
90
|
+
const passwordHash = await bcrypt.hash('test123', 10);
|
|
91
|
+
const accountId = crypto.randomUUID();
|
|
92
|
+
|
|
93
|
+
const projectTitle = `Demo Project`;
|
|
94
|
+
const projectSlug = `demo-project-${user.suffix}`;
|
|
95
|
+
|
|
96
|
+
const projectInsert = isMultiTenant ? `
|
|
97
|
+
INSERT INTO projects (user_id, title, slug, description, created_at, updated_at)
|
|
98
|
+
VALUES (
|
|
99
|
+
new_user_id,
|
|
100
|
+
'${projectTitle}',
|
|
101
|
+
'${projectSlug}',
|
|
102
|
+
'Auto-generated demo project',
|
|
103
|
+
NOW(),
|
|
104
|
+
NOW()
|
|
105
|
+
);` : '';
|
|
106
|
+
|
|
107
|
+
const sqlScript = `
|
|
108
|
+
DO $$
|
|
109
|
+
DECLARE
|
|
110
|
+
new_user_id INT;
|
|
111
|
+
BEGIN
|
|
112
|
+
INSERT INTO users (email, name, role, email_verified, is_active, created_at, updated_at)
|
|
113
|
+
VALUES (
|
|
114
|
+
'${user.email.replace(/'/g, "''")}',
|
|
115
|
+
'${user.name.replace(/'/g, "''")}',
|
|
116
|
+
'business_user',
|
|
117
|
+
true,
|
|
118
|
+
true,
|
|
119
|
+
NOW(),
|
|
120
|
+
NOW()
|
|
121
|
+
)
|
|
122
|
+
RETURNING id INTO new_user_id;
|
|
123
|
+
|
|
124
|
+
INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at)
|
|
125
|
+
VALUES (
|
|
126
|
+
'${accountId}',
|
|
127
|
+
new_user_id,
|
|
128
|
+
new_user_id::text,
|
|
129
|
+
'credential',
|
|
130
|
+
'${passwordHash.replace(/'/g, "''")}',
|
|
131
|
+
NOW(),
|
|
132
|
+
NOW()
|
|
133
|
+
);
|
|
134
|
+
${projectInsert}
|
|
135
|
+
END $$;
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
const execResult = spawnSync(
|
|
139
|
+
'docker',
|
|
140
|
+
[
|
|
141
|
+
'compose', '-f', 'docker-compose.yml', '-f', 'docker-compose.dev.yml',
|
|
142
|
+
'exec', '-T', 'database', 'sh', '-c', 'psql -v ON_ERROR_STOP=1 -U $POSTGRES_USER $POSTGRES_DB'
|
|
143
|
+
],
|
|
144
|
+
{ cwd: infrastructurePath, input: sqlScript, encoding: 'utf8' }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (execResult.status !== 0) {
|
|
148
|
+
console.error(chalk.red('\n❌ Failed to insert user into database.'));
|
|
149
|
+
if (execResult.stderr) {
|
|
150
|
+
console.error(chalk.gray(execResult.stderr));
|
|
151
|
+
}
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(chalk.green('\n✅ User created!'));
|
|
156
|
+
console.log(chalk.gray(` Name: ${user.name}`));
|
|
157
|
+
console.log(chalk.gray(` Email: ${user.email}`));
|
|
158
|
+
console.log(chalk.gray(` Password: test123`));
|
|
159
|
+
if (isMultiTenant) {
|
|
160
|
+
console.log(chalk.gray(` Project: ${projectTitle} (${projectSlug})`));
|
|
161
|
+
}
|
|
162
|
+
console.log();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { devAddUser };
|
|
@@ -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 };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const { requireProject } = require('../utils/project-helpers');
|
|
6
|
+
|
|
7
|
+
async function devNpmInstall(serviceName, packages = []) {
|
|
8
|
+
requireProject();
|
|
9
|
+
|
|
10
|
+
const servicePath = path.join(process.cwd(), serviceName);
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(servicePath)) {
|
|
13
|
+
console.error(chalk.red(`\n❌ Error: directory "${serviceName}/" not found`));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pkgList = packages.length ? packages.join(' ') : '';
|
|
18
|
+
const label = pkgList ? `Installing ${pkgList} in ${serviceName}` : `Running npm install in ${serviceName}`;
|
|
19
|
+
console.log(chalk.blue.bold(`\n📦 ${label}\n`));
|
|
20
|
+
console.log(chalk.gray('Using node:20-alpine to match Docker build environment...\n'));
|
|
21
|
+
|
|
22
|
+
const npmArgs = pkgList ? `npm install ${pkgList}` : 'npm install';
|
|
23
|
+
const cmd = `docker run --rm -v "${servicePath}":/app -w /app node:20-alpine ${npmArgs}`;
|
|
24
|
+
console.log(chalk.gray(`Running: ${cmd}\n`));
|
|
25
|
+
|
|
26
|
+
execSync(cmd, { stdio: 'inherit' });
|
|
27
|
+
|
|
28
|
+
console.log(chalk.green.bold(`\n✅ Done! package-lock.json updated with node:20-alpine.\n`));
|
|
29
|
+
console.log(chalk.white('Next: rebuild the service:'));
|
|
30
|
+
console.log(chalk.gray(` launchframe docker:build ${serviceName}\n`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { devNpmInstall };
|