@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
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variant Configuration
|
|
3
|
+
*
|
|
4
|
+
* Defines how to ADD functionality to the base template via variants.
|
|
5
|
+
* Follows open-closed principle: base is minimal, variants ADD features.
|
|
6
|
+
*
|
|
7
|
+
* Two types of additions:
|
|
8
|
+
* - SECTIONS: Code snippets inserted into base template files (via {{MARKERS}})
|
|
9
|
+
* - FILES: Complete files/folders copied to the project
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const VARIANT_CONFIG = {
|
|
13
|
+
backend: {
|
|
14
|
+
base: 'backend/templates/base',
|
|
15
|
+
sectionsDir: 'backend/templates/sections',
|
|
16
|
+
filesDir: 'backend/templates/files',
|
|
17
|
+
|
|
18
|
+
variants: {
|
|
19
|
+
// Multi-tenant variant: Adds project/workspace support
|
|
20
|
+
'multi-tenant': {
|
|
21
|
+
// Complete files/folders to copy
|
|
22
|
+
files: [
|
|
23
|
+
'src/modules/domain/projects', // Entire projects module
|
|
24
|
+
'src/modules/domain/jwt-auth', // JWT auth module (project-specific JWT issuance)
|
|
25
|
+
'src/modules/domain/ai/services/project-config.service.ts', // Project config service
|
|
26
|
+
'src/guards/project-ownership.guard.ts', // Project ownership guard
|
|
27
|
+
'src/modules/auth/auth.service.ts', // Auth service with multi-tenant support
|
|
28
|
+
'src/modules/auth/auth.controller.ts', // Auth controller with multi-tenant support
|
|
29
|
+
'src/modules/users/users.service.ts', // Users service with multi-tenant support
|
|
30
|
+
'src/modules/users/users.controller.ts', // Users controller with multi-tenant support
|
|
31
|
+
'src/modules/users/create-user.dto.ts' // CreateUserDto with businessId and recoveryCode
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
// Code sections to insert into base template files
|
|
35
|
+
sections: {
|
|
36
|
+
'src/main.ts': [
|
|
37
|
+
'PROJECT_IMPORTS', // Add project-related imports
|
|
38
|
+
'PROJECT_CUSTOM_DOMAINS', // Add custom domains query
|
|
39
|
+
'PROJECT_CUSTOM_DOMAINS_CORS', // Add custom domains to CORS
|
|
40
|
+
'PROJECT_GUARD' // Add ProjectOwnershipGuard registration
|
|
41
|
+
],
|
|
42
|
+
'src/modules/app/app.module.ts': [
|
|
43
|
+
'PROJECTS_MODULE_IMPORT', // Add ProjectsModule import
|
|
44
|
+
'PROJECTS_MODULE', // Add ProjectsModule to imports array
|
|
45
|
+
'JWT_AUTH_MODULE_IMPORT', // Add JwtAuthModule import
|
|
46
|
+
'JWT_AUTH_MODULE' // Add JwtAuthModule to imports array
|
|
47
|
+
],
|
|
48
|
+
'src/modules/auth/auth.module.ts': [
|
|
49
|
+
'MULTI_TENANT_IMPORTS', // Add Project entity import
|
|
50
|
+
'MULTI_TENANT_TYPEORM' // Add Project to TypeOrmModule
|
|
51
|
+
],
|
|
52
|
+
'src/modules/users/user.entity.ts': [
|
|
53
|
+
'PROJECTS_RELATIONSHIP_IMPORT', // Add Project entity import
|
|
54
|
+
'PROJECTS_RELATIONSHIP' // Add projects relationship
|
|
55
|
+
],
|
|
56
|
+
'src/modules/users/users.module.ts': [
|
|
57
|
+
'MULTI_TENANT_IMPORTS', // Add Projects-related imports
|
|
58
|
+
'MULTI_TENANT_SERVICE_IMPORTS', // Add JWT auth service imports
|
|
59
|
+
'MULTI_TENANT_ENTITIES', // Add Project entities to TypeORM
|
|
60
|
+
'MULTI_TENANT_MODULE_IMPORTS', // Add ProjectsModule to imports
|
|
61
|
+
'MULTI_TENANT_PROVIDERS' // Add multi-tenant providers
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// B2B2C variant: Adds regular_user support
|
|
67
|
+
'b2b2c': {
|
|
68
|
+
// Complete files to copy
|
|
69
|
+
files: [
|
|
70
|
+
'src/modules/users/user-business.entity.ts' // Business-to-user linking entity
|
|
71
|
+
],
|
|
72
|
+
|
|
73
|
+
// Code sections to insert
|
|
74
|
+
sections: {
|
|
75
|
+
'src/modules/users/user.entity.ts': [
|
|
76
|
+
'B2B2C_IMPORTS', // Add UserBusiness import
|
|
77
|
+
'B2B2C_USER_ROLE', // Add REGULAR_USER enum value
|
|
78
|
+
'B2B2C_RELATIONSHIPS' // Add userBusinesses relationship
|
|
79
|
+
],
|
|
80
|
+
'src/modules/users/users.module.ts': [
|
|
81
|
+
'B2B2C_IMPORTS', // Add UserBusiness import
|
|
82
|
+
'B2B2C_ENTITIES' // Add UserBusiness to TypeORM
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
// Variant selection prompts
|
|
89
|
+
prompts: {
|
|
90
|
+
tenancy: {
|
|
91
|
+
message: 'Will your application have multiple workspaces/projects per user?',
|
|
92
|
+
choices: [
|
|
93
|
+
{
|
|
94
|
+
name: 'Single-tenant (one instance per user) - Simpler data model',
|
|
95
|
+
value: 'single-tenant',
|
|
96
|
+
description: 'Each user has one instance. Simpler architecture without project isolation.',
|
|
97
|
+
isDefault: true // Base template is single-tenant
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'Multi-tenant (workspaces/projects) - Users can create multiple projects',
|
|
101
|
+
value: 'multi-tenant',
|
|
102
|
+
description: 'Each user can create multiple isolated workspaces. Data is scoped by project context.'
|
|
103
|
+
}
|
|
104
|
+
],
|
|
105
|
+
default: 'single-tenant'
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
userModel: {
|
|
109
|
+
message: 'Who will use your application?',
|
|
110
|
+
choices: [
|
|
111
|
+
{
|
|
112
|
+
name: 'B2B (Business users only) - Organization employees manage the system',
|
|
113
|
+
value: 'b2b',
|
|
114
|
+
description: 'Only business users (your customers) access the system. No end-user portal.',
|
|
115
|
+
isDefault: true // Base template is B2B
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'B2B2C (Business + End customers) - Business users manage their customers',
|
|
119
|
+
value: 'b2b2c',
|
|
120
|
+
description: 'Business users manage their end customers (regular_user type). Adds customer relationships.'
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
default: 'b2b'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// Admin portal inherits tenancy choice from backend
|
|
129
|
+
'admin-portal': {
|
|
130
|
+
base: 'admin-portal/templates/base',
|
|
131
|
+
sectionsDir: 'admin-portal/templates/sections',
|
|
132
|
+
filesDir: 'admin-portal/templates/files',
|
|
133
|
+
|
|
134
|
+
variants: {
|
|
135
|
+
'multi-tenant': {
|
|
136
|
+
// Complete files to copy (multi-tenant only components)
|
|
137
|
+
files: [
|
|
138
|
+
'src/components/projects/ProjectsSelect.tsx', // Project selector dropdown
|
|
139
|
+
'src/components/projects/NewProject.tsx', // Create project dialog
|
|
140
|
+
'src/pages/FirstProject.tsx', // Onboarding page
|
|
141
|
+
'src/api/projectRequests.ts', // Project API calls
|
|
142
|
+
'src/components/ProjectDetailsOnAuthPage.tsx', // Recovery code flow
|
|
143
|
+
'src/components/ProjectFrontendLink.tsx', // Project navigation link
|
|
144
|
+
'src/components/settings/CustomDomain.tsx', // Custom domain settings (multi-tenant only)
|
|
145
|
+
'src/components/settings/ProjectSettings.tsx' // Project settings (multi-tenant only)
|
|
146
|
+
],
|
|
147
|
+
|
|
148
|
+
// Code sections to insert into base template files
|
|
149
|
+
sections: {
|
|
150
|
+
'src/redux/user/userSlice.ts': [
|
|
151
|
+
'TYPES_IMPORT', // Add Project type to imports
|
|
152
|
+
'PROJECTS_STATE', // Add projects state to interface
|
|
153
|
+
'PROJECTS_STATE_INITIAL', // Initialize projects in initial state
|
|
154
|
+
'PROJECTS_INITIALIZATION', // Initialize projects on profile fetch
|
|
155
|
+
'PROJECTS_REDUCERS', // Add setSelectedProject reducer
|
|
156
|
+
'PROJECTS_EXPORTS', // Export setSelectedProject action
|
|
157
|
+
'PROJECTS_SELECTOR' // Export selectSelectedProject selector
|
|
158
|
+
],
|
|
159
|
+
'src/api/client.ts': [
|
|
160
|
+
'STORE_IMPORT', // Import Redux store
|
|
161
|
+
'PROJECT_HEADER' // Add X-Project-Id header to requests
|
|
162
|
+
],
|
|
163
|
+
'src/components/Layout.tsx': [
|
|
164
|
+
'PROJECT_STATE', // Extract project state from Redux
|
|
165
|
+
'FIRST_PROJECT_CHECKS' // Add loading state for missing project
|
|
166
|
+
],
|
|
167
|
+
'src/components/ui/SaMenu.tsx': [
|
|
168
|
+
'PROJECTS_SELECT_IMPORT', // Import ProjectsSelect component
|
|
169
|
+
'PROJECTS_SELECT_COMPONENT' // Render ProjectsSelect in sidebar
|
|
170
|
+
],
|
|
171
|
+
'src/pages/auth/Login.tsx': [
|
|
172
|
+
'ROUTER_IMPORTS', // Add useNavigate, useSearchParams to imports
|
|
173
|
+
'PROJECT_DETAILS_IMPORT', // Import ProjectDetailsOnAuthPage
|
|
174
|
+
'PROJECT_DETAILS_HANDLER', // Handler for project retrieval
|
|
175
|
+
'PROJECT_DETAILS_COMPONENT' // Render ProjectDetailsOnAuthPage
|
|
176
|
+
],
|
|
177
|
+
'src/pages/auth/SignUp.tsx': [
|
|
178
|
+
'REACT_IMPORTS', // Add useState to React imports
|
|
179
|
+
'PROJECT_DETAILS_IMPORT', // Import ProjectDetailsOnAuthPage
|
|
180
|
+
'PROJECT_DETAILS_STATE', // State for recovery code
|
|
181
|
+
'PROJECT_DETAILS_COMPONENT', // Render ProjectDetailsOnAuthPage
|
|
182
|
+
'RECOVERY_CODE_PROP_GOOGLE', // Pass recovery code to GoogleLogin
|
|
183
|
+
'RECOVERY_CODE_PROP_FORM' // Pass recovery code to SignUpForm
|
|
184
|
+
],
|
|
185
|
+
'src/pages/Settings.tsx': [
|
|
186
|
+
'MULTI_TENANT_SETTINGS_IMPORTS', // Import ProjectSettings and CustomDomain
|
|
187
|
+
'MULTI_TENANT_SETTINGS_STATE', // Set default activeTab to 'project'
|
|
188
|
+
'MULTI_TENANT_SETTINGS_TABS', // Add Project and Custom Domain tabs
|
|
189
|
+
'MULTI_TENANT_SETTINGS_CONTENT' // Render ProjectSettings and CustomDomain
|
|
190
|
+
],
|
|
191
|
+
'src/App.tsx': [
|
|
192
|
+
'SET_SELECTED_PROJECT_IMPORT', // Import setSelectedProject action
|
|
193
|
+
'FIRST_PROJECT_IMPORT', // Import FirstProject component
|
|
194
|
+
'FIRST_PROJECT_ROUTE_GUARD', // Add FirstProjectRoute guard
|
|
195
|
+
'USER_ROUTE_VARIABLES', // Add user and location variables for onboarding
|
|
196
|
+
'USER_ROUTE_ONBOARDING_CHECK', // Redirect to /first-project
|
|
197
|
+
'PROJECT_QUERY_PARAM_HANDLER', // Handle ?project= query param
|
|
198
|
+
'FIRST_PROJECT_ROUTE' // Add /first-project route
|
|
199
|
+
],
|
|
200
|
+
'src/types/index.ts': [
|
|
201
|
+
'PROJECTS_FIELD' // Add projects field to User interface
|
|
202
|
+
]
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
'b2b2c': {
|
|
207
|
+
files: [
|
|
208
|
+
'src/pages/Users.tsx' // Users page for managing end customers
|
|
209
|
+
],
|
|
210
|
+
|
|
211
|
+
sections: {
|
|
212
|
+
'src/components/Layout.tsx': [
|
|
213
|
+
'B2B2C_USER_ICON_IMPORT', // Import UserIcon for Users menu
|
|
214
|
+
'B2B2C_USERS_MENU' // Add Users menu item for customer management
|
|
215
|
+
],
|
|
216
|
+
'src/App.tsx': [
|
|
217
|
+
'B2B2C_USERS_IMPORT', // Import Users page component
|
|
218
|
+
'B2B2C_USERS_ROUTE' // Add /users route
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
linkedTo: 'backend.tenancy'
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// Customers portal (for B2B2C regular_user access)
|
|
228
|
+
'customers-portal': {
|
|
229
|
+
base: 'customers-portal/templates/base',
|
|
230
|
+
sectionsDir: 'customers-portal/templates/sections',
|
|
231
|
+
filesDir: 'customers-portal/templates/files',
|
|
232
|
+
|
|
233
|
+
variants: {
|
|
234
|
+
'multi-tenant': {
|
|
235
|
+
files: [],
|
|
236
|
+
sections: {}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
linkedTo: 'backend.tenancy'
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// Infrastructure (Docker Compose orchestration)
|
|
244
|
+
infrastructure: {
|
|
245
|
+
base: 'infrastructure',
|
|
246
|
+
sectionsDir: 'infrastructure/templates/sections',
|
|
247
|
+
filesDir: 'infrastructure/templates/files',
|
|
248
|
+
|
|
249
|
+
variants: {
|
|
250
|
+
// B2B2C variant: Adds customers-portal service to docker-compose files
|
|
251
|
+
'b2b2c': {
|
|
252
|
+
files: [],
|
|
253
|
+
|
|
254
|
+
sections: {
|
|
255
|
+
'docker-compose.yml': [
|
|
256
|
+
'B2B2C_CUSTOMERS_PORTAL_SERVICE'
|
|
257
|
+
],
|
|
258
|
+
'docker-compose.dev.yml': [
|
|
259
|
+
'B2B2C_CUSTOMERS_PORTAL_SERVICE',
|
|
260
|
+
'B2B2C_CUSTOMERS_PORTAL_VOLUMES'
|
|
261
|
+
],
|
|
262
|
+
'docker-compose.prod.yml': [
|
|
263
|
+
'B2B2C_CUSTOMERS_PORTAL_SERVICE'
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
linkedTo: 'backend.userModel'
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get variant configuration for a service
|
|
275
|
+
* @param {string} serviceName - Service name (backend, admin-portal, etc.)
|
|
276
|
+
* @returns {object|null} Service variant configuration
|
|
277
|
+
*/
|
|
278
|
+
function getVariantConfig(serviceName) {
|
|
279
|
+
return VARIANT_CONFIG[serviceName] || null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Get variant prompts for user selection
|
|
284
|
+
* @returns {object} Prompts configuration
|
|
285
|
+
*/
|
|
286
|
+
function getVariantPrompts() {
|
|
287
|
+
return VARIANT_CONFIG.backend.prompts;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve variant choices for all services
|
|
292
|
+
* @param {object} backendChoices - User's choices for backend variants
|
|
293
|
+
* @returns {object} Variant choices for all services
|
|
294
|
+
*/
|
|
295
|
+
function resolveVariantChoices(backendChoices) {
|
|
296
|
+
const choices = {
|
|
297
|
+
backend: backendChoices
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Resolve linked services
|
|
301
|
+
Object.keys(VARIANT_CONFIG).forEach(serviceName => {
|
|
302
|
+
if (serviceName === 'backend') return;
|
|
303
|
+
|
|
304
|
+
const serviceConfig = VARIANT_CONFIG[serviceName];
|
|
305
|
+
if (serviceConfig.linkedTo) {
|
|
306
|
+
// Parse linkedTo (e.g., "backend.tenancy")
|
|
307
|
+
const [linkedService, linkedDimension] = serviceConfig.linkedTo.split('.');
|
|
308
|
+
choices[serviceName] = {
|
|
309
|
+
[linkedDimension]: backendChoices[linkedDimension]
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Special case: admin-portal inherits BOTH tenancy and userModel
|
|
315
|
+
if (choices['admin-portal']) {
|
|
316
|
+
choices['admin-portal'].userModel = backendChoices.userModel;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return choices;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get variants to apply (exclude defaults)
|
|
324
|
+
* @param {object} choices - User's variant selections
|
|
325
|
+
* @returns {string[]} List of variant names to apply
|
|
326
|
+
*/
|
|
327
|
+
function getVariantsToApply(choices) {
|
|
328
|
+
const variantsToApply = [];
|
|
329
|
+
|
|
330
|
+
// Add multi-tenant if selected
|
|
331
|
+
if (choices.tenancy === 'multi-tenant') {
|
|
332
|
+
variantsToApply.push('multi-tenant');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Add b2b2c if selected
|
|
336
|
+
if (choices.userModel === 'b2b2c') {
|
|
337
|
+
variantsToApply.push('b2b2c');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return variantsToApply;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
VARIANT_CONFIG,
|
|
345
|
+
getVariantConfig,
|
|
346
|
+
getVariantPrompts,
|
|
347
|
+
resolveVariantChoices,
|
|
348
|
+
getVariantsToApply
|
|
349
|
+
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
const { exec } = require('child_process');
|
|
2
|
+
const { promisify } = require('util');
|
|
3
|
+
const ora = require('ora');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const fs = require('fs-extra');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if Docker is running
|
|
12
|
+
* @returns {Promise<boolean>}
|
|
13
|
+
*/
|
|
14
|
+
async function checkDockerRunning() {
|
|
15
|
+
try {
|
|
16
|
+
await execAsync('docker info', { timeout: 5000 });
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Login to GitHub Container Registry
|
|
25
|
+
* @param {string} githubOrg - GitHub organization/username
|
|
26
|
+
* @param {string} ghcrToken - GitHub Personal Access Token
|
|
27
|
+
* @returns {Promise<void>}
|
|
28
|
+
*/
|
|
29
|
+
async function loginToGHCR(githubOrg, ghcrToken) {
|
|
30
|
+
const spinner = ora('Logging in to GitHub Container Registry...').start();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
await execAsync(
|
|
34
|
+
`echo "${ghcrToken}" | docker login ghcr.io -u ${githubOrg} --password-stdin`,
|
|
35
|
+
{ timeout: 30000 }
|
|
36
|
+
);
|
|
37
|
+
spinner.succeed('Logged in to GHCR');
|
|
38
|
+
} catch (error) {
|
|
39
|
+
spinner.fail('Failed to login to GHCR');
|
|
40
|
+
throw new Error(`GHCR login failed: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build and push a Docker image
|
|
46
|
+
* @param {string} serviceName - Service name (e.g., 'backend', 'admin-frontend')
|
|
47
|
+
* @param {string} contextDir - Docker build context directory
|
|
48
|
+
* @param {string} registry - Registry URL (e.g., 'ghcr.io/myorg')
|
|
49
|
+
* @param {string} projectName - Project name
|
|
50
|
+
* @param {string[]} buildArgs - Array of build arguments (e.g., ['KEY=value'])
|
|
51
|
+
* @returns {Promise<void>}
|
|
52
|
+
*/
|
|
53
|
+
async function buildAndPushImage(serviceName, contextDir, registry, projectName, buildArgs = []) {
|
|
54
|
+
const imageName = `${registry}/${projectName}-${serviceName}:latest`;
|
|
55
|
+
|
|
56
|
+
console.log(chalk.blue(`\n🐳 Building ${serviceName}...\n`));
|
|
57
|
+
|
|
58
|
+
const spinner = ora(`Building ${serviceName} image...`).start();
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Build image
|
|
62
|
+
const buildArgsStr = buildArgs.map(arg => `--build-arg "${arg}"`).join(' ');
|
|
63
|
+
const buildCmd = `docker build --target production --tag ${imageName} ${buildArgsStr} ${contextDir}`;
|
|
64
|
+
|
|
65
|
+
await execAsync(buildCmd, {
|
|
66
|
+
timeout: 600000, // 10 minutes per service
|
|
67
|
+
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for build output
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
spinner.text = `Pushing ${serviceName} to registry...`;
|
|
71
|
+
|
|
72
|
+
// Push image
|
|
73
|
+
await execAsync(`docker push ${imageName}`, {
|
|
74
|
+
timeout: 600000 // 10 minutes for push
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
spinner.succeed(`${serviceName} built and pushed successfully`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
spinner.fail(`Failed to build ${serviceName}`);
|
|
80
|
+
throw new Error(`Build failed for ${serviceName}: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load environment variables from .env.prod
|
|
86
|
+
* @param {string} envFilePath - Path to .env.prod file
|
|
87
|
+
* @returns {Object} Environment variables as key-value pairs
|
|
88
|
+
*/
|
|
89
|
+
function loadEnvFile(envFilePath) {
|
|
90
|
+
const envContent = fs.readFileSync(envFilePath, 'utf8');
|
|
91
|
+
const envVars = {};
|
|
92
|
+
|
|
93
|
+
envContent.split('\n').forEach(line => {
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (!trimmed || trimmed.startsWith('#')) return;
|
|
96
|
+
|
|
97
|
+
const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
98
|
+
if (match) {
|
|
99
|
+
const [, key, value] = match;
|
|
100
|
+
// Remove surrounding quotes if present
|
|
101
|
+
envVars[key] = value.replace(/^["']|["']$/g, '');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return envVars;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build all production images for full app
|
|
110
|
+
* @param {string} projectRoot - Project root directory
|
|
111
|
+
* @param {string} projectName - Project name
|
|
112
|
+
* @param {string} githubOrg - GitHub organization/username
|
|
113
|
+
* @param {string} envFilePath - Path to .env.prod file
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
async function buildFullAppImages(projectRoot, projectName, githubOrg, envFilePath) {
|
|
117
|
+
const registry = `ghcr.io/${githubOrg}`;
|
|
118
|
+
const envVars = loadEnvFile(envFilePath);
|
|
119
|
+
|
|
120
|
+
console.log(chalk.yellow('\n📦 Building production Docker images...\n'));
|
|
121
|
+
console.log(chalk.gray('This may take 10-20 minutes depending on your system.\n'));
|
|
122
|
+
|
|
123
|
+
// Build backend
|
|
124
|
+
await buildAndPushImage(
|
|
125
|
+
'backend',
|
|
126
|
+
path.join(projectRoot, 'backend'),
|
|
127
|
+
registry,
|
|
128
|
+
projectName
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Build admin-frontend
|
|
132
|
+
await buildAndPushImage(
|
|
133
|
+
'admin-frontend',
|
|
134
|
+
path.join(projectRoot, 'admin-frontend'),
|
|
135
|
+
registry,
|
|
136
|
+
projectName
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Build customers-frontend
|
|
140
|
+
await buildAndPushImage(
|
|
141
|
+
'customers-frontend',
|
|
142
|
+
path.join(projectRoot, 'frontend'),
|
|
143
|
+
registry,
|
|
144
|
+
projectName
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Build website (requires build args)
|
|
148
|
+
const websiteBuildArgs = [
|
|
149
|
+
`APP_NAME=${envVars.APP_NAME || ''}`,
|
|
150
|
+
`DOCS_URL=${envVars.DOCS_URL || ''}`,
|
|
151
|
+
`CONTACT_EMAIL=${envVars.CONTACT_EMAIL || ''}`,
|
|
152
|
+
`CTA_LINK=${envVars.CTA_LINK || ''}`,
|
|
153
|
+
`LIVE_DEMO_URL=${envVars.LIVE_DEMO_URL || ''}`,
|
|
154
|
+
`MIXPANEL_PROJECT_TOKEN=${envVars.MIXPANEL_PROJECT_TOKEN || ''}`,
|
|
155
|
+
`GOOGLE_ANALYTICS_ID=${envVars.GOOGLE_ANALYTICS_ID || ''}`
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
await buildAndPushImage(
|
|
159
|
+
'website',
|
|
160
|
+
path.join(projectRoot, 'website'),
|
|
161
|
+
registry,
|
|
162
|
+
projectName,
|
|
163
|
+
websiteBuildArgs
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build waitlist image
|
|
169
|
+
* @param {string} projectRoot - Project root directory
|
|
170
|
+
* @param {string} projectName - Project name (unused for waitlist, kept for API compatibility)
|
|
171
|
+
* @param {string} githubOrg - GitHub organization/username
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
async function buildWaitlistImage(projectRoot, projectName, githubOrg) {
|
|
175
|
+
const registry = `ghcr.io/${githubOrg}`;
|
|
176
|
+
const waitlistPath = path.join(projectRoot, 'waitlist');
|
|
177
|
+
const imageName = `${registry}/waitlist:latest`;
|
|
178
|
+
|
|
179
|
+
console.log(chalk.yellow('\n📦 Building waitlist Docker image...\n'));
|
|
180
|
+
console.log(chalk.gray(`Project root: ${projectRoot}`));
|
|
181
|
+
console.log(chalk.gray(`Waitlist path: ${waitlistPath}\n`));
|
|
182
|
+
|
|
183
|
+
// Load environment variables from waitlist .env.prod file (for production build)
|
|
184
|
+
const waitlistEnvProdPath = path.join(waitlistPath, '.env.prod');
|
|
185
|
+
const waitlistEnvPath = path.join(waitlistPath, '.env');
|
|
186
|
+
|
|
187
|
+
// Prefer .env.prod for deployment, fallback to .env
|
|
188
|
+
const envFilePath = fs.existsSync(waitlistEnvProdPath) ? waitlistEnvProdPath : waitlistEnvPath;
|
|
189
|
+
let buildArgs = [];
|
|
190
|
+
|
|
191
|
+
if (fs.existsSync(envFilePath)) {
|
|
192
|
+
const envVars = loadEnvFile(envFilePath);
|
|
193
|
+
buildArgs = [
|
|
194
|
+
`AIRTABLE_PERSONAL_ACCESS_TOKEN=${envVars.AIRTABLE_PERSONAL_ACCESS_TOKEN || ''}`,
|
|
195
|
+
`AIRTABLE_BASE_ID=${envVars.AIRTABLE_BASE_ID || ''}`,
|
|
196
|
+
`AIRTABLE_TABLE_NAME=${envVars.AIRTABLE_TABLE_NAME || 'Waitlist'}`,
|
|
197
|
+
`NEXT_PUBLIC_PROJECT_NAME=${envVars.NEXT_PUBLIC_PROJECT_NAME || envVars.PROJECT_NAME || ''}`,
|
|
198
|
+
`NEXT_PUBLIC_SITE_URL=${envVars.NEXT_PUBLIC_SITE_URL || ''}`,
|
|
199
|
+
`PROJECT_NAME=${envVars.PROJECT_NAME || ''}`,
|
|
200
|
+
`PRIMARY_DOMAIN=${envVars.PRIMARY_DOMAIN || ''}`
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(chalk.blue(`\n🐳 Building waitlist...\n`));
|
|
205
|
+
|
|
206
|
+
const spinner = ora(`Building waitlist image...`).start();
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
// Build image
|
|
210
|
+
const buildArgsStr = buildArgs.map(arg => `--build-arg "${arg}"`).join(' ');
|
|
211
|
+
const buildCmd = `docker build --tag ${imageName} ${buildArgsStr} ${waitlistPath}`;
|
|
212
|
+
|
|
213
|
+
await execAsync(buildCmd, {
|
|
214
|
+
timeout: 600000, // 10 minutes
|
|
215
|
+
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for build output
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
spinner.text = `Pushing waitlist to registry...`;
|
|
219
|
+
|
|
220
|
+
// Push image
|
|
221
|
+
await execAsync(`docker push ${imageName}`, {
|
|
222
|
+
timeout: 600000 // 10 minutes for push
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
spinner.succeed(`waitlist built and pushed successfully`);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
spinner.fail(`Failed to build waitlist`);
|
|
228
|
+
throw new Error(`Build failed for waitlist: ${error.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
checkDockerRunning,
|
|
234
|
+
loginToGHCR,
|
|
235
|
+
buildFullAppImages,
|
|
236
|
+
buildWaitlistImage
|
|
237
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a secure random string
|
|
7
|
+
* @param {number} length - Length of the string
|
|
8
|
+
* @returns {string} Random hex string
|
|
9
|
+
*/
|
|
10
|
+
function generateSecret(length = 32) {
|
|
11
|
+
return crypto.randomBytes(length).toString('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate environment file from .env.example
|
|
16
|
+
* @param {string} projectRoot - Root directory of the generated project
|
|
17
|
+
* @param {Object} answers - User answers from prompts
|
|
18
|
+
*/
|
|
19
|
+
async function generateEnvFile(projectRoot, answers) {
|
|
20
|
+
const envExamplePath = path.join(projectRoot, 'infrastructure', '.env.example');
|
|
21
|
+
const envPath = path.join(projectRoot, 'infrastructure', '.env');
|
|
22
|
+
|
|
23
|
+
// Read .env.example
|
|
24
|
+
const envTemplate = await fs.readFile(envExamplePath, 'utf8');
|
|
25
|
+
|
|
26
|
+
// Generate secure secrets
|
|
27
|
+
const secrets = {
|
|
28
|
+
JWT_SECRET: generateSecret(32),
|
|
29
|
+
DB_PASSWORD: generateSecret(24),
|
|
30
|
+
BULL_ADMIN_TOKEN: generateSecret(24)
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Create variable mappings
|
|
34
|
+
const variables = {
|
|
35
|
+
'{{PROJECT_NAME}}': answers.projectName,
|
|
36
|
+
'{{PROJECT_NAME_UPPER}}': answers.projectNameUpper,
|
|
37
|
+
'{{PRIMARY_DOMAIN}}': answers.primaryDomain,
|
|
38
|
+
'{{ADMIN_EMAIL}}': answers.adminEmail,
|
|
39
|
+
|
|
40
|
+
// Replace placeholder passwords with generated secrets
|
|
41
|
+
'your_secure_postgres_password': secrets.DB_PASSWORD,
|
|
42
|
+
'your_jwt_secret_key_change_this_in_production': secrets.JWT_SECRET,
|
|
43
|
+
'your_bull_admin_token': secrets.BULL_ADMIN_TOKEN
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// Replace variables in template
|
|
47
|
+
let envContent = envTemplate;
|
|
48
|
+
for (const [placeholder, value] of Object.entries(variables)) {
|
|
49
|
+
envContent = envContent.split(placeholder).join(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Write .env file
|
|
53
|
+
await fs.writeFile(envPath, envContent, 'utf8');
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
envPath,
|
|
57
|
+
secrets
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Update environment file with component-specific variables
|
|
63
|
+
* @param {string} envPath - Path to .env file
|
|
64
|
+
* @param {string} componentName - Name of the component
|
|
65
|
+
* @param {Object} envVarSchema - Schema of env vars (key: description)
|
|
66
|
+
* @param {Object} values - Actual values for the env vars
|
|
67
|
+
*/
|
|
68
|
+
async function updateEnvFile(envPath, componentName, envVarSchema, values) {
|
|
69
|
+
// Read existing .env file
|
|
70
|
+
let envContent = await fs.readFile(envPath, 'utf8');
|
|
71
|
+
|
|
72
|
+
// Add section header for component
|
|
73
|
+
envContent += `\n\n# ${componentName.charAt(0).toUpperCase() + componentName.slice(1)} Component\n`;
|
|
74
|
+
|
|
75
|
+
// Add each env var
|
|
76
|
+
for (const [key, description] of Object.entries(envVarSchema)) {
|
|
77
|
+
envContent += `${key}=${values[key]}\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Write back to file
|
|
81
|
+
await fs.writeFile(envPath, envContent, 'utf8');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
generateEnvFile,
|
|
86
|
+
generateSecret,
|
|
87
|
+
updateEnvFile
|
|
88
|
+
};
|