@launchframe/cli 0.1.6
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/README.md +59 -0
- package/package.json +45 -0
- package/src/commands/deploy-configure.js +219 -0
- package/src/commands/deploy-init.js +277 -0
- package/src/commands/deploy-set-env.js +232 -0
- package/src/commands/deploy-up.js +144 -0
- package/src/commands/docker-build.js +44 -0
- package/src/commands/docker-destroy.js +93 -0
- package/src/commands/docker-down.js +44 -0
- package/src/commands/docker-logs.js +69 -0
- package/src/commands/docker-up.js +73 -0
- package/src/commands/doctor.js +20 -0
- package/src/commands/help.js +79 -0
- package/src/commands/init.js +126 -0
- package/src/commands/service.js +569 -0
- package/src/commands/waitlist-deploy.js +231 -0
- package/src/commands/waitlist-down.js +50 -0
- package/src/commands/waitlist-logs.js +55 -0
- package/src/commands/waitlist-up.js +95 -0
- package/src/generator.js +190 -0
- package/src/index.js +158 -0
- package/src/prompts.js +200 -0
- package/src/services/registry.js +48 -0
- package/src/services/variant-config.js +349 -0
- package/src/utils/docker-helper.js +237 -0
- package/src/utils/env-generator.js +88 -0
- package/src/utils/env-validator.js +75 -0
- package/src/utils/file-ops.js +87 -0
- package/src/utils/project-helpers.js +104 -0
- package/src/utils/section-replacer.js +71 -0
- package/src/utils/ssh-helper.js +220 -0
- package/src/utils/variable-replacer.js +95 -0
- package/src/utils/variant-processor.js +313 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# LaunchFrame CLI
|
|
2
|
+
|
|
3
|
+
> Ship your B2B SaaS to production in hours, not weeks.
|
|
4
|
+
|
|
5
|
+
LaunchFrame is a production-ready SaaS boilerplate that deploys to a single affordable VPS. Get subscriptions, credits, multi-tenancy, feature gating, and API management out of the box.
|
|
6
|
+
|
|
7
|
+
## What You Get
|
|
8
|
+
|
|
9
|
+
- **Single VPS deployment**: Everything runs on one $7-20/mo server (Docker + Traefik)
|
|
10
|
+
- **Variant selection on init**: Choose single/multi-tenant, B2B/B2B2C - optimized for your use case
|
|
11
|
+
- **Service registry**: Add new services (docs, waitlist, admin tools) with zero config
|
|
12
|
+
- **Full-stack TypeScript**: NestJS backend, React frontends, Next.js marketing site
|
|
13
|
+
- **Monetization built-in**: Subscriptions (Polar.sh MOR) + usage-based credits
|
|
14
|
+
- **Flexible tenancy models**: Single-tenant (simpler) or multi-tenant (workspaces + custom domains)
|
|
15
|
+
- **B2B + B2B2C support**: Admin-only or admin+end-user models
|
|
16
|
+
- **Feature guard system**: Tier-based access control across frontend and backend
|
|
17
|
+
- **Production-grade auth**: JWT + OAuth (Google), role-based access control
|
|
18
|
+
- **API-first**: Auto-generated OpenAPI docs, API key management
|
|
19
|
+
- **Resilient architecture**: Background jobs, webhook processing, health checks
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @launchframe/cli init
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Early Access
|
|
28
|
+
|
|
29
|
+
LaunchFrame is currently in **private beta**.
|
|
30
|
+
|
|
31
|
+
Join the waitlist at **[launchframe.dev](https://launchframe.dev)** to get early access, exclusive updates, and founding member perks.
|
|
32
|
+
|
|
33
|
+
## Why LaunchFrame?
|
|
34
|
+
|
|
35
|
+
Most SaaS boilerplates give you authentication and a database. LaunchFrame gives you a **complete business**:
|
|
36
|
+
|
|
37
|
+
- Subscriptions AND credits (hybrid monetization)
|
|
38
|
+
- Feature flags tied to billing tiers
|
|
39
|
+
- Multi-tenant architecture with project isolation
|
|
40
|
+
- Webhook processing that actually scales
|
|
41
|
+
- API key system with usage tracking
|
|
42
|
+
- Zero-downtime deployment patterns
|
|
43
|
+
- Comprehensive documentation
|
|
44
|
+
|
|
45
|
+
All tested in production. All ready to customize.
|
|
46
|
+
|
|
47
|
+
## Status
|
|
48
|
+
|
|
49
|
+
This CLI is **preview-only** - it demonstrates the architecture but doesn't yet generate fully functional projects.
|
|
50
|
+
|
|
51
|
+
For production-ready access and full documentation, **[join the waitlist](https://launchframe.dev)**.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
Built with ⚡ by developers who were tired of rebuilding the same SaaS infrastructure over and over.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@launchframe/cli",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "Production-ready B2B SaaS boilerplate with subscriptions, credits, and multi-tenancy",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"launchframe": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"saas",
|
|
14
|
+
"boilerplate",
|
|
15
|
+
"template",
|
|
16
|
+
"nestjs",
|
|
17
|
+
"react",
|
|
18
|
+
"nextjs",
|
|
19
|
+
"typescript",
|
|
20
|
+
"subscriptions",
|
|
21
|
+
"multi-tenant",
|
|
22
|
+
"b2b",
|
|
23
|
+
"starter-kit",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"author": "LaunchFrame",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"homepage": "https://launchframe.dev",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/launchframe/cli"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/launchframe/cli/issues"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=16.0.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"inquirer": "^8.2.5",
|
|
41
|
+
"chalk": "^4.1.2",
|
|
42
|
+
"fs-extra": "^11.1.1",
|
|
43
|
+
"glob": "^10.3.10"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const inquirer = require('inquirer');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const ora = require('ora');
|
|
5
|
+
const { runDeployPrompts } = require('../prompts');
|
|
6
|
+
const { requireProject, getProjectConfig, updateProjectConfig } = require('../utils/project-helpers');
|
|
7
|
+
const { replaceVariablesInFile } = require('../utils/variable-replacer');
|
|
8
|
+
const { testSSHConnection, executeSSH } = require('../utils/ssh-helper');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configure deployment settings
|
|
12
|
+
*/
|
|
13
|
+
async function deployConfigure() {
|
|
14
|
+
requireProject();
|
|
15
|
+
|
|
16
|
+
console.log(chalk.blue.bold('\n🚀 LaunchFrame Deployment Configuration\n'));
|
|
17
|
+
|
|
18
|
+
// Check if already configured
|
|
19
|
+
const config = getProjectConfig();
|
|
20
|
+
if (config.deployConfigured) {
|
|
21
|
+
console.log(chalk.yellow('⚠️ Deployment already configured. This will update existing settings.\n'));
|
|
22
|
+
console.log(chalk.gray('Current configuration:'));
|
|
23
|
+
console.log(chalk.gray(` Domain: ${config.deployment?.primaryDomain || 'Not set'}`));
|
|
24
|
+
console.log(chalk.gray(` Email: ${config.deployment?.adminEmail || 'Not set'}`));
|
|
25
|
+
console.log(chalk.gray(` GitHub Org: ${config.deployment?.githubOrg || config.githubOrg || 'Not set'}`));
|
|
26
|
+
console.log(chalk.gray(` VPS Host: ${config.deployment?.vpsHost || 'Not set'}`));
|
|
27
|
+
console.log(chalk.gray(` VPS User: ${config.deployment?.vpsUser || 'Not set'}`));
|
|
28
|
+
console.log(chalk.gray(` VPS App Folder: ${config.deployment?.vpsAppFolder || config.vpsAppFolder || 'Not set'}\n`));
|
|
29
|
+
|
|
30
|
+
const { proceed } = await inquirer.prompt([
|
|
31
|
+
{
|
|
32
|
+
type: 'confirm',
|
|
33
|
+
name: 'proceed',
|
|
34
|
+
message: 'Continue with reconfiguration?',
|
|
35
|
+
default: false
|
|
36
|
+
}
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
if (!proceed) {
|
|
40
|
+
console.log(chalk.yellow('\n❌ Configuration cancelled\n'));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log(chalk.yellow('Configure your production deployment settings.\n'));
|
|
46
|
+
console.log(chalk.gray('Note: VPS credentials (SSH keys, passwords) are managed via GitHub Secrets.'));
|
|
47
|
+
console.log(chalk.gray('This command configures domain and connection details.\n'));
|
|
48
|
+
|
|
49
|
+
const deployAnswers = await runDeployPrompts(config.projectName);
|
|
50
|
+
|
|
51
|
+
// Prepare variable mappings for file replacement
|
|
52
|
+
const variables = {
|
|
53
|
+
'{{PRIMARY_DOMAIN}}': deployAnswers.primaryDomain,
|
|
54
|
+
'{{ADMIN_EMAIL}}': deployAnswers.adminEmail,
|
|
55
|
+
'{{GITHUB_ORG}}': deployAnswers.githubOrg,
|
|
56
|
+
'{{VPS_APP_FOLDER}}': deployAnswers.vpsAppFolder
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
console.log(chalk.yellow('\n⚙️ Updating configuration files...\n'));
|
|
60
|
+
|
|
61
|
+
// Files that need template variable replacement
|
|
62
|
+
const filesToUpdate = [
|
|
63
|
+
'infrastructure/.env',
|
|
64
|
+
'infrastructure/.env.example'
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const projectRoot = process.cwd();
|
|
68
|
+
let filesUpdated = 0;
|
|
69
|
+
|
|
70
|
+
for (const relativePath of filesToUpdate) {
|
|
71
|
+
const filePath = path.join(projectRoot, relativePath);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const fs = require('fs-extra');
|
|
75
|
+
if (!await fs.pathExists(filePath)) {
|
|
76
|
+
console.log(chalk.gray(` ⊘ Skipping ${relativePath} (not found)`));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const modified = await replaceVariablesInFile(filePath, variables);
|
|
81
|
+
if (modified) {
|
|
82
|
+
console.log(chalk.green(` ✓ Updated ${relativePath}`));
|
|
83
|
+
filesUpdated++;
|
|
84
|
+
} else {
|
|
85
|
+
console.log(chalk.gray(` − No changes needed in ${relativePath}`));
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.log(chalk.red(` ✗ Failed to update ${relativePath}: ${error.message}`));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Update all installed component .env.prod files with new domain
|
|
93
|
+
const installedComponents = config.installedComponents || [];
|
|
94
|
+
if (installedComponents.length > 0) {
|
|
95
|
+
console.log(chalk.yellow('\n⚙️ Updating component environment files...\n'));
|
|
96
|
+
|
|
97
|
+
for (const componentName of installedComponents) {
|
|
98
|
+
const componentEnvProdPath = path.join(projectRoot, componentName, '.env.prod');
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const fs = require('fs-extra');
|
|
102
|
+
if (!await fs.pathExists(componentEnvProdPath)) {
|
|
103
|
+
console.log(chalk.gray(` ⊘ Skipping ${componentName}/.env.prod (not found)`));
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read current .env.prod content
|
|
108
|
+
let envContent = await fs.readFile(componentEnvProdPath, 'utf8');
|
|
109
|
+
|
|
110
|
+
// Replace PRIMARY_DOMAIN with new value
|
|
111
|
+
const oldDomainMatch = envContent.match(/PRIMARY_DOMAIN=.*/);
|
|
112
|
+
if (oldDomainMatch) {
|
|
113
|
+
const newEnvContent = envContent.replace(
|
|
114
|
+
/PRIMARY_DOMAIN=.*/,
|
|
115
|
+
`PRIMARY_DOMAIN=${deployAnswers.primaryDomain}`
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (newEnvContent !== envContent) {
|
|
119
|
+
await fs.writeFile(componentEnvProdPath, newEnvContent, 'utf8');
|
|
120
|
+
console.log(chalk.green(` ✓ Updated ${componentName}/.env.prod`));
|
|
121
|
+
filesUpdated++;
|
|
122
|
+
} else {
|
|
123
|
+
console.log(chalk.gray(` − No changes needed in ${componentName}/.env.prod`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.log(chalk.red(` ✗ Failed to update ${componentName}/.env.prod: ${error.message}`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Update .launchframe config
|
|
133
|
+
console.log(chalk.yellow('\n📝 Saving configuration...\n'));
|
|
134
|
+
|
|
135
|
+
const updatedConfig = {
|
|
136
|
+
...config,
|
|
137
|
+
primaryDomain: deployAnswers.primaryDomain,
|
|
138
|
+
githubOrg: deployAnswers.githubOrg,
|
|
139
|
+
vpsAppFolder: deployAnswers.vpsAppFolder,
|
|
140
|
+
deployConfigured: true,
|
|
141
|
+
deployment: {
|
|
142
|
+
adminEmail: deployAnswers.adminEmail,
|
|
143
|
+
vpsHost: deployAnswers.vpsHost,
|
|
144
|
+
vpsUser: deployAnswers.vpsUser,
|
|
145
|
+
ghcrToken: deployAnswers.ghcrToken,
|
|
146
|
+
configuredAt: new Date().toISOString()
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
updateProjectConfig(updatedConfig);
|
|
151
|
+
|
|
152
|
+
// Authenticate to GHCR on VPS
|
|
153
|
+
console.log(chalk.yellow('\n🔐 Authenticating to GitHub Container Registry on VPS...\n'));
|
|
154
|
+
|
|
155
|
+
const authSpinner = ora('Testing VPS connection...').start();
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Test SSH connection
|
|
159
|
+
const connectionTest = await testSSHConnection(deployAnswers.vpsUser, deployAnswers.vpsHost);
|
|
160
|
+
|
|
161
|
+
if (!connectionTest.success) {
|
|
162
|
+
authSpinner.warn('Could not connect to VPS');
|
|
163
|
+
console.log(chalk.yellow('\n⚠️ Warning: Unable to authenticate to GHCR on VPS\n'));
|
|
164
|
+
console.log(chalk.gray(`Error: ${connectionTest.error}`));
|
|
165
|
+
console.log(chalk.gray('\nYou can authenticate manually later by running:'));
|
|
166
|
+
console.log(chalk.white(` ssh ${deployAnswers.vpsUser}@${deployAnswers.vpsHost} "echo 'YOUR_GHCR_TOKEN' | docker login ghcr.io -u ${deployAnswers.githubOrg} --password-stdin"\n`));
|
|
167
|
+
} else {
|
|
168
|
+
authSpinner.text = 'Logging into GitHub Container Registry...';
|
|
169
|
+
|
|
170
|
+
// Login to GHCR on VPS
|
|
171
|
+
await executeSSH(
|
|
172
|
+
deployAnswers.vpsUser,
|
|
173
|
+
deployAnswers.vpsHost,
|
|
174
|
+
`echo '${deployAnswers.ghcrToken}' | docker login ghcr.io -u ${deployAnswers.githubOrg} --password-stdin`
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
authSpinner.succeed('VPS authenticated to GitHub Container Registry');
|
|
178
|
+
console.log(chalk.gray(' Docker can now pull images from ghcr.io\n'));
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
authSpinner.warn('Could not authenticate to GHCR');
|
|
182
|
+
console.log(chalk.yellow('\n⚠️ Warning: Failed to authenticate to GHCR on VPS\n'));
|
|
183
|
+
console.log(chalk.gray(`Error: ${error.message}`));
|
|
184
|
+
console.log(chalk.gray('\nYou can authenticate manually later by running:'));
|
|
185
|
+
console.log(chalk.white(` ssh ${deployAnswers.vpsUser}@${deployAnswers.vpsHost} "echo 'YOUR_GHCR_TOKEN' | docker login ghcr.io -u ${deployAnswers.githubOrg} --password-stdin"\n`));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(chalk.green.bold('\n✅ Deployment configuration complete!\n'));
|
|
189
|
+
console.log(chalk.white('Configuration saved to:'));
|
|
190
|
+
console.log(chalk.gray(` - .launchframe`));
|
|
191
|
+
if (filesUpdated > 0) {
|
|
192
|
+
console.log(chalk.gray(` - ${filesUpdated} file(s) updated\n`));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log(chalk.white('Prerequisites:\n'));
|
|
196
|
+
console.log(chalk.white('• Docker must be installed and running locally'));
|
|
197
|
+
console.log(chalk.gray(' (Required for building production images during deploy:init)\n'));
|
|
198
|
+
|
|
199
|
+
console.log(chalk.white('Next steps:\n'));
|
|
200
|
+
console.log(chalk.white('1. Configure GitHub Secrets (required for CI/CD):'));
|
|
201
|
+
console.log(chalk.gray(` Repository: https://github.com/${deployAnswers.githubOrg}/${config.projectName}/settings/secrets/actions`));
|
|
202
|
+
console.log(chalk.gray(` Required secrets:`));
|
|
203
|
+
console.log(chalk.gray(` - VPS_HOST: ${deployAnswers.vpsHost}`));
|
|
204
|
+
console.log(chalk.gray(` - VPS_USER: ${deployAnswers.vpsUser}`));
|
|
205
|
+
console.log(chalk.gray(` - VPS_SSH_KEY: (your private SSH key)`));
|
|
206
|
+
console.log(chalk.gray(` - GHCR_TOKEN: (use the same token you just provided)`));
|
|
207
|
+
|
|
208
|
+
console.log(chalk.white('\n2. Point DNS records to your VPS:'));
|
|
209
|
+
console.log(chalk.gray(` - A record: ${deployAnswers.primaryDomain} → VPS IP`));
|
|
210
|
+
console.log(chalk.gray(` - A record: *.${deployAnswers.primaryDomain} → VPS IP`));
|
|
211
|
+
|
|
212
|
+
console.log(chalk.white('\n3. Configure production environment variables:'));
|
|
213
|
+
console.log(chalk.gray(` Run: launchframe deploy:set-env`));
|
|
214
|
+
|
|
215
|
+
console.log(chalk.white('\n4. Initial VPS deployment:'));
|
|
216
|
+
console.log(chalk.gray(` Run: launchframe deploy:init\n`));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { deployConfigure };
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const ora = require('ora');
|
|
5
|
+
const { requireProject, getProjectConfig } = require('../utils/project-helpers');
|
|
6
|
+
const { validateEnvProd } = require('../utils/env-validator');
|
|
7
|
+
const {
|
|
8
|
+
testSSHConnection,
|
|
9
|
+
checkSSHKeys,
|
|
10
|
+
executeSSH,
|
|
11
|
+
copyFileToVPS,
|
|
12
|
+
copyDirectoryToVPS,
|
|
13
|
+
checkRepoPrivacy,
|
|
14
|
+
showDeployKeyInstructions
|
|
15
|
+
} = require('../utils/ssh-helper');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initial VPS setup - clone repo and configure environment
|
|
19
|
+
*/
|
|
20
|
+
async function deployInit() {
|
|
21
|
+
requireProject();
|
|
22
|
+
|
|
23
|
+
console.log(chalk.blue.bold('\n🚀 LaunchFrame Initial VPS Setup\n'));
|
|
24
|
+
|
|
25
|
+
const config = getProjectConfig();
|
|
26
|
+
|
|
27
|
+
// Validate deployment is configured
|
|
28
|
+
if (!config.deployConfigured || !config.deployment) {
|
|
29
|
+
console.log(chalk.red('❌ Error: Deployment not configured yet\n'));
|
|
30
|
+
console.log(chalk.gray('Run this command first:'));
|
|
31
|
+
console.log(chalk.white(' launchframe deploy:configure\n'));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { vpsHost, vpsUser, vpsAppFolder, githubOrg } = config.deployment;
|
|
36
|
+
const { projectName } = config;
|
|
37
|
+
const projectRoot = process.cwd();
|
|
38
|
+
const envProdPath = path.join(projectRoot, 'infrastructure', '.env.prod');
|
|
39
|
+
|
|
40
|
+
// Step 1: Validate .env.prod exists and has no placeholders
|
|
41
|
+
console.log(chalk.yellow('📋 Step 1: Validating production environment...\n'));
|
|
42
|
+
|
|
43
|
+
const validation = await validateEnvProd(envProdPath);
|
|
44
|
+
if (!validation.valid) {
|
|
45
|
+
console.log(chalk.red(`❌ Error: ${validation.error}\n`));
|
|
46
|
+
|
|
47
|
+
if (validation.placeholders) {
|
|
48
|
+
console.log(chalk.yellow('Placeholder variables found:'));
|
|
49
|
+
validation.placeholders.forEach(p => console.log(chalk.gray(` - ${p}`)));
|
|
50
|
+
console.log();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(chalk.gray('Run this command first:'));
|
|
54
|
+
console.log(chalk.white(' launchframe deploy:set-env\n'));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(chalk.green('✓ Production environment validated\n'));
|
|
59
|
+
|
|
60
|
+
// Step 2: Check SSH keys
|
|
61
|
+
console.log(chalk.yellow('🔑 Step 2: Checking SSH configuration...\n'));
|
|
62
|
+
|
|
63
|
+
const { hasKeys, keyPaths } = await checkSSHKeys();
|
|
64
|
+
if (!hasKeys) {
|
|
65
|
+
console.log(chalk.yellow('⚠️ Warning: No SSH keys found in ~/.ssh/\n'));
|
|
66
|
+
console.log(chalk.gray('You may need to generate SSH keys:'));
|
|
67
|
+
console.log(chalk.white(' ssh-keygen -t ed25519\n'));
|
|
68
|
+
} else {
|
|
69
|
+
console.log(chalk.green(`✓ Found SSH keys: ${keyPaths.length} key(s)\n`));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Step 3: Test SSH connection
|
|
73
|
+
console.log(chalk.yellow('🔌 Step 3: Testing VPS connection...\n'));
|
|
74
|
+
|
|
75
|
+
const spinner = ora('Connecting to VPS...').start();
|
|
76
|
+
const connectionTest = await testSSHConnection(vpsUser, vpsHost);
|
|
77
|
+
|
|
78
|
+
if (!connectionTest.success) {
|
|
79
|
+
spinner.fail('Failed to connect to VPS');
|
|
80
|
+
console.log(chalk.red(`\n❌ SSH connection failed: ${connectionTest.error}\n`));
|
|
81
|
+
console.log(chalk.gray('Troubleshooting:'));
|
|
82
|
+
console.log(chalk.gray(` - Check VPS is online: ping ${vpsHost}`));
|
|
83
|
+
console.log(chalk.gray(` - Test SSH manually: ssh ${vpsUser}@${vpsHost}`));
|
|
84
|
+
console.log(chalk.gray(' - Verify SSH keys are configured\n'));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
spinner.succeed('Connected to VPS successfully');
|
|
89
|
+
console.log();
|
|
90
|
+
|
|
91
|
+
// Step 3.5: Build and push Docker images
|
|
92
|
+
console.log(chalk.yellow('🐳 Step 3.5: Building Docker images locally...\n'));
|
|
93
|
+
|
|
94
|
+
// Check if Docker is running
|
|
95
|
+
const {
|
|
96
|
+
checkDockerRunning,
|
|
97
|
+
loginToGHCR,
|
|
98
|
+
buildFullAppImages
|
|
99
|
+
} = require('../utils/docker-helper');
|
|
100
|
+
|
|
101
|
+
const dockerRunning = await checkDockerRunning();
|
|
102
|
+
if (!dockerRunning) {
|
|
103
|
+
console.log(chalk.red('❌ Docker is not running\n'));
|
|
104
|
+
console.log(chalk.gray('Please start Docker Desktop and try again.\n'));
|
|
105
|
+
console.log(chalk.gray('Docker is required to build production images for deployment.\n'));
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Validate GHCR token is configured
|
|
110
|
+
const { ghcrToken } = config.deployment || {};
|
|
111
|
+
if (!ghcrToken) {
|
|
112
|
+
console.log(chalk.red('❌ GHCR token not configured\n'));
|
|
113
|
+
console.log(chalk.gray('Run this command first:'));
|
|
114
|
+
console.log(chalk.white(' launchframe deploy:configure\n'));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Login to GHCR
|
|
120
|
+
await loginToGHCR(githubOrg, ghcrToken);
|
|
121
|
+
|
|
122
|
+
// Build full-app images
|
|
123
|
+
await buildFullAppImages(projectRoot, projectName, githubOrg, envProdPath);
|
|
124
|
+
|
|
125
|
+
console.log(chalk.green.bold('\n✅ All images built and pushed to GHCR!\n'));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.log(chalk.red('\n❌ Failed to build Docker images\n'));
|
|
128
|
+
console.log(chalk.gray('Error:'), error.message, '\n');
|
|
129
|
+
console.log(chalk.gray('Common issues:'));
|
|
130
|
+
console.log(chalk.gray(' - Invalid GHCR token (check write:packages permission)'));
|
|
131
|
+
console.log(chalk.gray(' - Dockerfile syntax errors'));
|
|
132
|
+
console.log(chalk.gray(' - Insufficient disk space (~10GB required)'));
|
|
133
|
+
console.log(chalk.gray(' - Network connection issues\n'));
|
|
134
|
+
console.log(chalk.white('To create a valid token:'));
|
|
135
|
+
console.log(chalk.gray(' https://github.com/settings/tokens/new\n'));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
// Step 4: Check repository privacy
|
|
141
|
+
console.log(chalk.yellow('🔍 Step 4: Checking repository access...\n'));
|
|
142
|
+
|
|
143
|
+
const repoCheck = await checkRepoPrivacy(githubOrg, projectName);
|
|
144
|
+
if (repoCheck.isPrivate) {
|
|
145
|
+
console.log(chalk.yellow('⚠️ Repository appears to be private or inaccessible\n'));
|
|
146
|
+
showDeployKeyInstructions(vpsUser, vpsHost, githubOrg, projectName);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
console.log(chalk.green('✓ Repository is accessible (public)\n'));
|
|
151
|
+
|
|
152
|
+
// Step 5: Create app directory and clone repository
|
|
153
|
+
console.log(chalk.yellow('📦 Step 5: Setting up application on VPS...\n'));
|
|
154
|
+
|
|
155
|
+
const cloneSpinner = ora('Creating app directory...').start();
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
// Create directory
|
|
159
|
+
await executeSSH(vpsUser, vpsHost, `mkdir -p ${vpsAppFolder}`);
|
|
160
|
+
cloneSpinner.text = 'Cloning repository...';
|
|
161
|
+
|
|
162
|
+
// Check if directory is empty or has .git
|
|
163
|
+
const { stdout: lsOutput } = await executeSSH(vpsUser, vpsHost, `ls -A ${vpsAppFolder}`);
|
|
164
|
+
|
|
165
|
+
if (lsOutput.trim()) {
|
|
166
|
+
// Directory not empty
|
|
167
|
+
const { stdout: gitCheck } = await executeSSH(vpsUser, vpsHost, `test -d ${vpsAppFolder}/.git && echo "exists" || echo "missing"`);
|
|
168
|
+
|
|
169
|
+
if (gitCheck.trim() === 'exists') {
|
|
170
|
+
cloneSpinner.text = 'Repository already cloned, pulling latest changes...';
|
|
171
|
+
await executeSSH(vpsUser, vpsHost, `cd ${vpsAppFolder} && git pull origin main || git pull origin master`);
|
|
172
|
+
} else {
|
|
173
|
+
cloneSpinner.warn('Directory not empty and not a git repository');
|
|
174
|
+
console.log(chalk.yellow(`\n⚠️ Warning: ${vpsAppFolder} exists but is not a git repository\n`));
|
|
175
|
+
console.log(chalk.gray('Please clean up the directory or choose a different path.\n'));
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// Clone fresh
|
|
180
|
+
await executeSSH(
|
|
181
|
+
vpsUser,
|
|
182
|
+
vpsHost,
|
|
183
|
+
`git clone https://github.com/${githubOrg}/${projectName}.git ${vpsAppFolder}`,
|
|
184
|
+
{ timeout: 180000 } // 3 minutes for clone
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
cloneSpinner.succeed('Repository setup complete');
|
|
189
|
+
} catch (error) {
|
|
190
|
+
cloneSpinner.fail('Failed to setup repository');
|
|
191
|
+
console.log(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Check if waitlist is running and stop it (full-app deployment)
|
|
196
|
+
try {
|
|
197
|
+
const { stdout: psOutput } = await executeSSH(
|
|
198
|
+
vpsUser,
|
|
199
|
+
vpsHost,
|
|
200
|
+
`docker ps --filter "name=${projectName}-waitlist" --format "{{.Names}}"`,
|
|
201
|
+
{ timeout: 10000 }
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (psOutput.trim()) {
|
|
205
|
+
console.log(chalk.yellow('\n⚠️ Waitlist is currently running. Stopping it...\n'));
|
|
206
|
+
|
|
207
|
+
const stopSpinner = ora('Stopping waitlist containers...').start();
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await executeSSH(
|
|
211
|
+
vpsUser,
|
|
212
|
+
vpsHost,
|
|
213
|
+
`cd ${vpsAppFolder}/waitlist && docker-compose -f docker-compose.waitlist.yml down`,
|
|
214
|
+
{ timeout: 30000 }
|
|
215
|
+
);
|
|
216
|
+
stopSpinner.succeed('Waitlist stopped');
|
|
217
|
+
} catch (stopError) {
|
|
218
|
+
stopSpinner.warn('Could not stop waitlist automatically');
|
|
219
|
+
console.log(chalk.gray('You may need to stop it manually after deployment.\n'));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
// If error, waitlist probably not running - continue
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Step 6: Copy .env.prod to VPS
|
|
227
|
+
console.log(chalk.yellow('\n📄 Step 6: Copying production environment...\n'));
|
|
228
|
+
|
|
229
|
+
const envSpinner = ora('Copying .env.prod to VPS...').start();
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const remoteEnvPath = `${vpsAppFolder}/infrastructure/.env`;
|
|
233
|
+
await copyFileToVPS(envProdPath, vpsUser, vpsHost, remoteEnvPath);
|
|
234
|
+
envSpinner.succeed('.env.prod copied successfully');
|
|
235
|
+
} catch (error) {
|
|
236
|
+
envSpinner.fail('Failed to copy .env.prod');
|
|
237
|
+
console.log(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Step 7: Pull Docker images
|
|
242
|
+
console.log(chalk.yellow('\n🐳 Step 7: Pulling Docker images...\n'));
|
|
243
|
+
console.log(chalk.gray('This may take several minutes...\n'));
|
|
244
|
+
|
|
245
|
+
const dockerSpinner = ora('Pulling Docker images...').start();
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await executeSSH(
|
|
249
|
+
vpsUser,
|
|
250
|
+
vpsHost,
|
|
251
|
+
`cd ${vpsAppFolder}/infrastructure && docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull`,
|
|
252
|
+
{ timeout: 600000 } // 10 minutes for image pull
|
|
253
|
+
);
|
|
254
|
+
dockerSpinner.succeed('Docker images pulled successfully');
|
|
255
|
+
} catch (error) {
|
|
256
|
+
dockerSpinner.fail('Failed to pull Docker images');
|
|
257
|
+
console.log(chalk.yellow(`\n⚠️ Warning: ${error.message}\n`));
|
|
258
|
+
console.log(chalk.gray('This might mean Docker is not installed on the VPS.'));
|
|
259
|
+
console.log(chalk.gray('Please install Docker and Docker Compose:\n'));
|
|
260
|
+
console.log(chalk.white(' curl -fsSL https://get.docker.com | sh'));
|
|
261
|
+
console.log(chalk.white(' sudo usermod -aG docker ${vpsUser}\n'));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Success!
|
|
266
|
+
console.log(chalk.green.bold('\n✅ Initial VPS setup complete!\n'));
|
|
267
|
+
|
|
268
|
+
console.log(chalk.white('Summary:'));
|
|
269
|
+
console.log(chalk.gray(` - Repository cloned to: ${vpsAppFolder}`));
|
|
270
|
+
console.log(chalk.gray(' - Production .env configured'));
|
|
271
|
+
console.log(chalk.gray(' - Docker images pulled\n'));
|
|
272
|
+
|
|
273
|
+
console.log(chalk.white('Next step:'));
|
|
274
|
+
console.log(chalk.gray(' Run: launchframe deploy:up\n'));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = { deployInit };
|