@launchframe/cli 1.0.0-beta.33 → 1.0.0-beta.34
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 +1 -1
- package/src/commands/deploy-sync-features.js +233 -0
- package/src/commands/help.js +2 -1
- package/src/index.js +4 -0
package/package.json
CHANGED
|
@@ -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 };
|
package/src/commands/help.js
CHANGED
|
@@ -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]
|
|
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()) {
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,7 @@ 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 { deploySyncFeatures } = require('./commands/deploy-sync-features');
|
|
48
49
|
|
|
49
50
|
// Get command and arguments
|
|
50
51
|
const command = process.argv[2];
|
|
@@ -128,6 +129,9 @@ async function main() {
|
|
|
128
129
|
case 'deploy:build':
|
|
129
130
|
await deployBuild(args[1]); // Optional service name
|
|
130
131
|
break;
|
|
132
|
+
case 'deploy:sync-features':
|
|
133
|
+
await deploySyncFeatures();
|
|
134
|
+
break;
|
|
131
135
|
case 'waitlist:deploy':
|
|
132
136
|
await waitlistDeploy();
|
|
133
137
|
break;
|