@launchframe/cli 1.0.0-beta.18 ā 1.0.0-beta.19
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.md +27 -0
- package/package.json +1 -1
- package/src/commands/deploy-build.js +20 -81
- package/src/commands/deploy-configure.js +1 -1
- package/src/commands/deploy-init.js +24 -57
- package/src/commands/deploy-set-env.js +11 -2
- package/src/utils/docker-helper.js +115 -2
- package/src/utils/env-generator.js +7 -4
- package/src/utils/env-validator.js +4 -2
- package/src/utils/ssh-helper.js +51 -1
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# LaunchFrame CLI
|
|
2
|
+
|
|
3
|
+
CLI tool for generating new projects from the LaunchFrame template.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
The CLI takes user input (project name, domain, GitHub org, etc.) and:
|
|
8
|
+
1. Copies the `services/` template
|
|
9
|
+
2. Replaces all `{{TEMPLATE_VARIABLES}}`
|
|
10
|
+
3. Generates secrets (DB password, auth secret)
|
|
11
|
+
4. Sets up the project structure
|
|
12
|
+
|
|
13
|
+
## Template Variables
|
|
14
|
+
|
|
15
|
+
The CLI replaces these placeholders in all files:
|
|
16
|
+
- `{{PROJECT_NAME}}` - lowercase project name
|
|
17
|
+
- `{{PROJECT_NAME_UPPER}}` - uppercase project name
|
|
18
|
+
- `{{GITHUB_ORG}}` - GitHub organization/username
|
|
19
|
+
- `{{PRIMARY_DOMAIN}}` - main domain (e.g., mysaas.com)
|
|
20
|
+
- `{{ADMIN_EMAIL}}` - admin email for Let's Encrypt
|
|
21
|
+
- `{{VPS_HOST}}` - VPS hostname/IP
|
|
22
|
+
- `{{BETTER_AUTH_SECRET}}` - auto-generated (32+ chars)
|
|
23
|
+
- `{{DB_PASSWORD}}` - auto-generated
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
|
|
27
|
+
TODO: CLI implementation details
|
package/package.json
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
|
-
const { exec } = require('child_process');
|
|
3
|
-
const { promisify } = require('util');
|
|
4
|
-
const ora = require('ora');
|
|
5
2
|
const path = require('path');
|
|
6
3
|
const { requireProject, getProjectConfig } = require('../utils/project-helpers');
|
|
7
|
-
const {
|
|
8
|
-
|
|
9
|
-
const execAsync = promisify(exec);
|
|
4
|
+
const { buildAndPushWorkflow } = require('../utils/docker-helper');
|
|
5
|
+
const { pullImagesOnVPS, restartServicesOnVPS } = require('../utils/ssh-helper');
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* Build, push, and deploy Docker images
|
|
@@ -29,102 +25,45 @@ async function deployBuild(serviceName) {
|
|
|
29
25
|
process.exit(1);
|
|
30
26
|
}
|
|
31
27
|
|
|
32
|
-
const { vpsHost, vpsUser, vpsAppFolder, githubOrg } = config.deployment;
|
|
28
|
+
const { vpsHost, vpsUser, vpsAppFolder, githubOrg, ghcrToken } = config.deployment;
|
|
33
29
|
const { projectName, installedServices } = config;
|
|
34
30
|
const envProdPath = path.join(projectRoot, 'infrastructure', '.env.prod');
|
|
35
31
|
|
|
36
|
-
// Step 1:
|
|
37
|
-
console.log(chalk.yellow('š³ Step 1:
|
|
38
|
-
|
|
39
|
-
const dockerSpinner = ora('Checking Docker...').start();
|
|
40
|
-
|
|
41
|
-
const dockerRunning = await checkDockerRunning();
|
|
42
|
-
if (!dockerRunning) {
|
|
43
|
-
dockerSpinner.fail('Docker is not running');
|
|
44
|
-
console.log(chalk.red('\nā Please start Docker and try again.\n'));
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
dockerSpinner.succeed('Docker is running');
|
|
49
|
-
|
|
50
|
-
// Step 2: Login to GHCR
|
|
51
|
-
console.log(chalk.yellow('\nš Step 2: Logging in to GitHub Container Registry...\n'));
|
|
52
|
-
|
|
53
|
-
const ghcrToken = config.deployment.ghcrToken;
|
|
54
|
-
if (!ghcrToken) {
|
|
55
|
-
console.log(chalk.red('ā Error: GHCR token not found in .launchframe config\n'));
|
|
56
|
-
console.log(chalk.gray('Run deploy:configure to set up your GitHub token.\n'));
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
32
|
+
// Step 1-3: Build and push images
|
|
33
|
+
console.log(chalk.yellow('š³ Step 1: Building and pushing images...\n'));
|
|
59
34
|
|
|
60
35
|
try {
|
|
61
|
-
await
|
|
36
|
+
await buildAndPushWorkflow({
|
|
37
|
+
projectRoot,
|
|
38
|
+
projectName,
|
|
39
|
+
githubOrg,
|
|
40
|
+
ghcrToken,
|
|
41
|
+
envProdPath,
|
|
42
|
+
installedServices,
|
|
43
|
+
serviceName
|
|
44
|
+
});
|
|
62
45
|
} catch (error) {
|
|
63
46
|
console.log(chalk.red(`\nā ${error.message}\n`));
|
|
64
47
|
process.exit(1);
|
|
65
48
|
}
|
|
66
49
|
|
|
67
|
-
// Step 3: Build and push images
|
|
68
|
-
console.log(chalk.yellow('\nš¦ Step 3: Building and pushing images...\n'));
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
if (serviceName) {
|
|
72
|
-
// Build specific service
|
|
73
|
-
if (!installedServices.includes(serviceName)) {
|
|
74
|
-
console.log(chalk.red(`ā Service "${serviceName}" not found in installed services.\n`));
|
|
75
|
-
console.log(chalk.gray(`Available services: ${installedServices.join(', ')}\n`));
|
|
76
|
-
process.exit(1);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const registry = `ghcr.io/${githubOrg}`;
|
|
80
|
-
await buildAndPushImage(
|
|
81
|
-
serviceName,
|
|
82
|
-
path.join(projectRoot, serviceName),
|
|
83
|
-
registry,
|
|
84
|
-
projectName
|
|
85
|
-
);
|
|
86
|
-
console.log(chalk.green.bold(`\nā
${serviceName} built and pushed to GHCR!\n`));
|
|
87
|
-
} else {
|
|
88
|
-
// Build all services
|
|
89
|
-
await buildFullAppImages(projectRoot, projectName, githubOrg, envProdPath, installedServices);
|
|
90
|
-
console.log(chalk.green.bold('\nā
All images built and pushed to GHCR!\n'));
|
|
91
|
-
}
|
|
92
|
-
} catch (error) {
|
|
93
|
-
console.log(chalk.red(`\nā Build failed: ${error.message}\n`));
|
|
94
|
-
process.exit(1);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
50
|
// Step 4: Pull images on VPS
|
|
98
|
-
console.log(chalk.yellow('
|
|
99
|
-
|
|
100
|
-
const pullSpinner = ora('Pulling images on VPS...').start();
|
|
51
|
+
console.log(chalk.yellow('š Step 2: Pulling images on VPS...\n'));
|
|
101
52
|
|
|
102
53
|
try {
|
|
103
|
-
await
|
|
104
|
-
`ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull"`,
|
|
105
|
-
{ timeout: 600000 } // 10 minutes
|
|
106
|
-
);
|
|
107
|
-
pullSpinner.succeed('Images pulled on VPS');
|
|
54
|
+
await pullImagesOnVPS(vpsUser, vpsHost, vpsAppFolder);
|
|
108
55
|
} catch (error) {
|
|
109
|
-
|
|
110
|
-
console.log(chalk.red(`\nā Error: ${error.message}\n`));
|
|
56
|
+
console.log(chalk.red(`\nā ${error.message}\n`));
|
|
111
57
|
process.exit(1);
|
|
112
58
|
}
|
|
113
59
|
|
|
114
60
|
// Step 5: Restart services
|
|
115
|
-
console.log(chalk.yellow('\nš Step
|
|
116
|
-
|
|
117
|
-
const restartSpinner = ora('Restarting services...').start();
|
|
61
|
+
console.log(chalk.yellow('\nš Step 3: Restarting services...\n'));
|
|
118
62
|
|
|
119
63
|
try {
|
|
120
|
-
await
|
|
121
|
-
`ssh ${vpsUser}@${vpsHost} "cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d"`,
|
|
122
|
-
{ timeout: 300000 } // 5 minutes
|
|
123
|
-
);
|
|
124
|
-
restartSpinner.succeed('Services restarted');
|
|
64
|
+
await restartServicesOnVPS(vpsUser, vpsHost, vpsAppFolder);
|
|
125
65
|
} catch (error) {
|
|
126
|
-
|
|
127
|
-
console.log(chalk.red(`\nā Error: ${error.message}\n`));
|
|
66
|
+
console.log(chalk.red(`\nā ${error.message}\n`));
|
|
128
67
|
process.exit(1);
|
|
129
68
|
}
|
|
130
69
|
|
|
@@ -59,8 +59,8 @@ async function deployConfigure() {
|
|
|
59
59
|
console.log(chalk.yellow('\nāļø Updating configuration files...\n'));
|
|
60
60
|
|
|
61
61
|
// Files that need template variable replacement
|
|
62
|
+
// Note: infrastructure/.env is NOT updated - it's for local development only
|
|
62
63
|
const filesToUpdate = [
|
|
63
|
-
'infrastructure/.env',
|
|
64
64
|
'infrastructure/.env.example',
|
|
65
65
|
'infrastructure/docker-compose.yml',
|
|
66
66
|
'infrastructure/docker-compose.dev.yml',
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const chalk = require('chalk');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const fs = require('fs-extra');
|
|
4
3
|
const ora = require('ora');
|
|
5
4
|
const { requireProject, getProjectConfig } = require('../utils/project-helpers');
|
|
6
5
|
const { validateEnvProd } = require('../utils/env-validator');
|
|
@@ -9,8 +8,10 @@ const {
|
|
|
9
8
|
checkSSHKeys,
|
|
10
9
|
executeSSH,
|
|
11
10
|
copyFileToVPS,
|
|
12
|
-
copyDirectoryToVPS
|
|
11
|
+
copyDirectoryToVPS,
|
|
12
|
+
pullImagesOnVPS
|
|
13
13
|
} = require('../utils/ssh-helper');
|
|
14
|
+
const { buildAndPushWorkflow } = require('../utils/docker-helper');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Initial VPS setup - copy infrastructure files and configure environment
|
|
@@ -30,8 +31,8 @@ async function deployInit() {
|
|
|
30
31
|
process.exit(1);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const { vpsHost, vpsUser, vpsAppFolder, githubOrg } = config.deployment;
|
|
34
|
-
const { projectName } = config;
|
|
34
|
+
const { vpsHost, vpsUser, vpsAppFolder, githubOrg, ghcrToken } = config.deployment;
|
|
35
|
+
const { projectName, installedServices } = config;
|
|
35
36
|
const projectRoot = process.cwd();
|
|
36
37
|
const envProdPath = path.join(projectRoot, 'infrastructure', '.env.prod');
|
|
37
38
|
|
|
@@ -86,42 +87,18 @@ async function deployInit() {
|
|
|
86
87
|
spinner.succeed('Connected to VPS successfully');
|
|
87
88
|
console.log();
|
|
88
89
|
|
|
89
|
-
// Step
|
|
90
|
-
console.log(chalk.yellow('š³ Step
|
|
91
|
-
|
|
92
|
-
// Check if Docker is running
|
|
93
|
-
const {
|
|
94
|
-
checkDockerRunning,
|
|
95
|
-
loginToGHCR,
|
|
96
|
-
buildFullAppImages
|
|
97
|
-
} = require('../utils/docker-helper');
|
|
98
|
-
|
|
99
|
-
const dockerRunning = await checkDockerRunning();
|
|
100
|
-
if (!dockerRunning) {
|
|
101
|
-
console.log(chalk.red('ā Docker is not running\n'));
|
|
102
|
-
console.log(chalk.gray('Please start Docker Desktop and try again.\n'));
|
|
103
|
-
console.log(chalk.gray('Docker is required to build production images for deployment.\n'));
|
|
104
|
-
process.exit(1);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Validate GHCR token is configured
|
|
108
|
-
const { ghcrToken } = config.deployment || {};
|
|
109
|
-
if (!ghcrToken) {
|
|
110
|
-
console.log(chalk.red('ā GHCR token not configured\n'));
|
|
111
|
-
console.log(chalk.gray('Run this command first:'));
|
|
112
|
-
console.log(chalk.white(' launchframe deploy:configure\n'));
|
|
113
|
-
process.exit(1);
|
|
114
|
-
}
|
|
90
|
+
// Step 4: Build and push Docker images
|
|
91
|
+
console.log(chalk.yellow('š³ Step 4: Building Docker images locally...\n'));
|
|
115
92
|
|
|
116
93
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
94
|
+
await buildAndPushWorkflow({
|
|
95
|
+
projectRoot,
|
|
96
|
+
projectName,
|
|
97
|
+
githubOrg,
|
|
98
|
+
ghcrToken,
|
|
99
|
+
envProdPath,
|
|
100
|
+
installedServices: installedServices || ['backend', 'admin-portal', 'website']
|
|
101
|
+
});
|
|
125
102
|
} catch (error) {
|
|
126
103
|
console.log(chalk.red('\nā Failed to build Docker images\n'));
|
|
127
104
|
console.log(chalk.gray('Error:'), error.message, '\n');
|
|
@@ -135,16 +112,15 @@ async function deployInit() {
|
|
|
135
112
|
process.exit(1);
|
|
136
113
|
}
|
|
137
114
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
console.log(chalk.yellow('š¦ Step 4: Setting up application on VPS...\n'));
|
|
115
|
+
// Step 5: Create app directory and copy infrastructure files
|
|
116
|
+
console.log(chalk.yellow('š¦ Step 5: Setting up application on VPS...\n'));
|
|
141
117
|
|
|
142
118
|
const setupSpinner = ora('Creating app directory...').start();
|
|
143
119
|
|
|
144
120
|
try {
|
|
145
121
|
// Create infrastructure directory on VPS
|
|
146
122
|
await executeSSH(vpsUser, vpsHost, `mkdir -p ${vpsAppFolder}/infrastructure`);
|
|
147
|
-
|
|
123
|
+
|
|
148
124
|
setupSpinner.text = 'Copying infrastructure files to VPS...';
|
|
149
125
|
|
|
150
126
|
// Copy entire infrastructure directory to VPS
|
|
@@ -205,8 +181,8 @@ async function deployInit() {
|
|
|
205
181
|
// If error, waitlist probably not running - continue
|
|
206
182
|
}
|
|
207
183
|
|
|
208
|
-
// Step
|
|
209
|
-
console.log(chalk.yellow('\nš Step
|
|
184
|
+
// Step 6: Copy .env.prod to VPS (overwrites .env copied from infrastructure/)
|
|
185
|
+
console.log(chalk.yellow('\nš Step 6: Configuring production environment...\n'));
|
|
210
186
|
|
|
211
187
|
const envSpinner = ora('Copying .env.prod to VPS...').start();
|
|
212
188
|
|
|
@@ -220,27 +196,18 @@ async function deployInit() {
|
|
|
220
196
|
process.exit(1);
|
|
221
197
|
}
|
|
222
198
|
|
|
223
|
-
// Step
|
|
224
|
-
console.log(chalk.yellow('\nš³ Step
|
|
199
|
+
// Step 7: Pull Docker images
|
|
200
|
+
console.log(chalk.yellow('\nš³ Step 7: Pulling Docker images on VPS...\n'));
|
|
225
201
|
console.log(chalk.gray('This may take several minutes...\n'));
|
|
226
202
|
|
|
227
|
-
const dockerSpinner = ora('Pulling Docker images...').start();
|
|
228
|
-
|
|
229
203
|
try {
|
|
230
|
-
await
|
|
231
|
-
vpsUser,
|
|
232
|
-
vpsHost,
|
|
233
|
-
`cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull`,
|
|
234
|
-
{ timeout: 600000 } // 10 minutes for image pull
|
|
235
|
-
);
|
|
236
|
-
dockerSpinner.succeed('Docker images pulled successfully');
|
|
204
|
+
await pullImagesOnVPS(vpsUser, vpsHost, vpsAppFolder);
|
|
237
205
|
} catch (error) {
|
|
238
|
-
dockerSpinner.fail('Failed to pull Docker images');
|
|
239
206
|
console.log(chalk.yellow(`\nā ļø Warning: ${error.message}\n`));
|
|
240
207
|
console.log(chalk.gray('This might mean Docker is not installed on the VPS.'));
|
|
241
208
|
console.log(chalk.gray('Please install Docker and Docker Compose:\n'));
|
|
242
209
|
console.log(chalk.white(' curl -fsSL https://get.docker.com | sh'));
|
|
243
|
-
console.log(chalk.white(
|
|
210
|
+
console.log(chalk.white(` sudo usermod -aG docker ${vpsUser}\n`));
|
|
244
211
|
process.exit(1);
|
|
245
212
|
}
|
|
246
213
|
|
|
@@ -163,12 +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 = {
|
|
167
174
|
'PRIMARY_DOMAIN': domain,
|
|
175
|
+
'NODE_ENV': 'production',
|
|
168
176
|
'API_BASE_URL': `https://api.${domain}`,
|
|
169
177
|
'ADMIN_BASE_URL': `https://admin.${domain}`,
|
|
170
|
-
'FRONTEND_BASE_URL': `https
|
|
171
|
-
'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`
|
|
172
181
|
};
|
|
173
182
|
|
|
174
183
|
for (const [key, value] of Object.entries(urlReplacements)) {
|
|
@@ -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,16 +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,
|
|
257
368
|
buildAndPushImage,
|
|
258
369
|
buildFullAppImages,
|
|
259
|
-
buildWaitlistImage
|
|
370
|
+
buildWaitlistImage,
|
|
371
|
+
buildAndPushWorkflow,
|
|
372
|
+
cleanupLocalImages
|
|
260
373
|
};
|
|
@@ -31,11 +31,12 @@ async function generateEnvFile(projectRoot, answers) {
|
|
|
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
|
-
'{{
|
|
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,
|
|
@@ -43,10 +44,12 @@ async function generateEnvFile(projectRoot, answers) {
|
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/utils/ssh-helper.js
CHANGED
|
@@ -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
|
};
|