@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,75 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a file contains any template placeholders ({{VAR}})
|
|
5
|
+
* @param {string} filePath - Path to file to check
|
|
6
|
+
* @returns {Promise<{hasPlaceholders: boolean, placeholders: string[]}>}
|
|
7
|
+
*/
|
|
8
|
+
async function checkForPlaceholders(filePath) {
|
|
9
|
+
try {
|
|
10
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
11
|
+
|
|
12
|
+
// Regex to find {{VAR}} patterns (but not ${{VAR}} which is GitHub Actions)
|
|
13
|
+
const placeholderRegex = /(?<!\$)\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
|
|
14
|
+
const placeholders = [];
|
|
15
|
+
let match;
|
|
16
|
+
|
|
17
|
+
while ((match = placeholderRegex.exec(content)) !== null) {
|
|
18
|
+
const placeholder = match[0]; // Full match including {{ }}
|
|
19
|
+
if (!placeholders.includes(placeholder)) {
|
|
20
|
+
placeholders.push(placeholder);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
hasPlaceholders: placeholders.length > 0,
|
|
26
|
+
placeholders
|
|
27
|
+
};
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`Failed to read file ${filePath}: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate that .env.prod file exists and has no placeholders
|
|
35
|
+
* @param {string} envProdPath - Path to .env.prod file
|
|
36
|
+
* @returns {Promise<{valid: boolean, error?: string, placeholders?: string[]}>}
|
|
37
|
+
*/
|
|
38
|
+
async function validateEnvProd(envProdPath) {
|
|
39
|
+
// Check if file exists
|
|
40
|
+
if (!await fs.pathExists(envProdPath)) {
|
|
41
|
+
return {
|
|
42
|
+
valid: false,
|
|
43
|
+
error: 'File does not exist'
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check for placeholders
|
|
48
|
+
const { hasPlaceholders, placeholders } = await checkForPlaceholders(envProdPath);
|
|
49
|
+
|
|
50
|
+
if (hasPlaceholders) {
|
|
51
|
+
return {
|
|
52
|
+
valid: false,
|
|
53
|
+
error: 'File contains placeholder variables',
|
|
54
|
+
placeholders
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { valid: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate a secure random string for secrets
|
|
63
|
+
* @param {number} length - Length of string to generate
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function generateSecret(length = 32) {
|
|
67
|
+
const crypto = require('crypto');
|
|
68
|
+
return crypto.randomBytes(length).toString('base64').slice(0, length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
checkForPlaceholders,
|
|
73
|
+
validateEnvProd,
|
|
74
|
+
generateSecret
|
|
75
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Copy a directory recursively with whitelist or blacklist filtering
|
|
6
|
+
* @param {string} source - Source directory path
|
|
7
|
+
* @param {string} destination - Destination directory path
|
|
8
|
+
* @param {Object} options - Options object
|
|
9
|
+
* @param {string[]} options.exclude - Array of directory names to exclude (blacklist approach)
|
|
10
|
+
* @param {string[]} options.include - Array of directory names to include (whitelist approach, takes precedence)
|
|
11
|
+
*/
|
|
12
|
+
async function copyDirectory(source, destination, options = {}) {
|
|
13
|
+
const { exclude = ['node_modules', '.git', '.next', 'dist', 'build', '.env'], include = null } = options;
|
|
14
|
+
|
|
15
|
+
// Ensure destination exists
|
|
16
|
+
await fs.ensureDir(destination);
|
|
17
|
+
|
|
18
|
+
// Read directory contents
|
|
19
|
+
const entries = await fs.readdir(source, { withFileTypes: true });
|
|
20
|
+
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const sourcePath = path.join(source, entry.name);
|
|
23
|
+
const destPath = path.join(destination, entry.name);
|
|
24
|
+
|
|
25
|
+
// Always check exclude list first (applies at all levels)
|
|
26
|
+
if (exclude.includes(entry.name)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Whitelist approach: if include array is provided, only copy items in the list (only at top level)
|
|
31
|
+
if (include !== null && include.length > 0) {
|
|
32
|
+
if (!include.includes(entry.name)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
// Recursively copy directory (pass exclude, remove include for nested levels)
|
|
39
|
+
await copyDirectory(sourcePath, destPath, { exclude });
|
|
40
|
+
} else {
|
|
41
|
+
// Copy file
|
|
42
|
+
await fs.copy(sourcePath, destPath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Delete a file
|
|
49
|
+
* @param {string} filePath - Path to file
|
|
50
|
+
*/
|
|
51
|
+
async function deleteFile(filePath) {
|
|
52
|
+
await fs.remove(filePath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Delete a directory
|
|
57
|
+
* @param {string} dirPath - Path to directory
|
|
58
|
+
*/
|
|
59
|
+
async function deleteDirectory(dirPath) {
|
|
60
|
+
await fs.remove(dirPath);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Copy a single file
|
|
65
|
+
* @param {string} source - Source file path
|
|
66
|
+
* @param {string} destination - Destination file path
|
|
67
|
+
*/
|
|
68
|
+
async function copyFile(source, destination) {
|
|
69
|
+
await fs.copy(source, destination);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Move a file
|
|
74
|
+
* @param {string} source - Source file path
|
|
75
|
+
* @param {string} destination - Destination file path
|
|
76
|
+
*/
|
|
77
|
+
async function moveFile(source, destination) {
|
|
78
|
+
await fs.move(source, destination);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
copyDirectory,
|
|
83
|
+
deleteFile,
|
|
84
|
+
deleteDirectory,
|
|
85
|
+
copyFile,
|
|
86
|
+
moveFile
|
|
87
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if current directory is a LaunchFrame project
|
|
7
|
+
*/
|
|
8
|
+
function isLaunchFrameProject() {
|
|
9
|
+
const markerPath = path.join(process.cwd(), '.launchframe');
|
|
10
|
+
return fs.existsSync(markerPath);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Require that the current directory is a LaunchFrame project
|
|
15
|
+
* Exits with error if not
|
|
16
|
+
*/
|
|
17
|
+
function requireProject() {
|
|
18
|
+
if (!isLaunchFrameProject()) {
|
|
19
|
+
console.error(chalk.red('\n❌ Error: Not in a LaunchFrame project'));
|
|
20
|
+
console.log(chalk.gray('Run this command from the root of your LaunchFrame project.\n'));
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get project configuration from .launchframe file
|
|
27
|
+
*/
|
|
28
|
+
function getProjectConfig() {
|
|
29
|
+
requireProject();
|
|
30
|
+
const markerPath = path.join(process.cwd(), '.launchframe');
|
|
31
|
+
const content = fs.readFileSync(markerPath, 'utf8');
|
|
32
|
+
return JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Update project configuration in .launchframe file
|
|
37
|
+
*/
|
|
38
|
+
function updateProjectConfig(config) {
|
|
39
|
+
const markerPath = path.join(process.cwd(), '.launchframe');
|
|
40
|
+
fs.writeFileSync(markerPath, JSON.stringify(config, null, 2), 'utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get list of installed services
|
|
45
|
+
*/
|
|
46
|
+
function getInstalledComponents() {
|
|
47
|
+
const config = getProjectConfig();
|
|
48
|
+
return config.installedServices || [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a service is installed
|
|
53
|
+
*/
|
|
54
|
+
function isComponentInstalled(componentName) {
|
|
55
|
+
const installedComponents = getInstalledComponents();
|
|
56
|
+
return installedComponents.includes(componentName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Add a service to the installed services list
|
|
61
|
+
*/
|
|
62
|
+
function addInstalledComponent(componentName) {
|
|
63
|
+
const config = getProjectConfig();
|
|
64
|
+
if (!config.installedServices) {
|
|
65
|
+
config.installedServices = [];
|
|
66
|
+
}
|
|
67
|
+
if (!config.installedServices.includes(componentName)) {
|
|
68
|
+
config.installedServices.push(componentName);
|
|
69
|
+
updateProjectConfig(config);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Get primary domain from config
|
|
75
|
+
* @param {Object} config - Project config object
|
|
76
|
+
* @returns {string|null} Primary domain
|
|
77
|
+
*/
|
|
78
|
+
function getPrimaryDomain(config) {
|
|
79
|
+
return config.primaryDomain || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if waitlist service is installed
|
|
84
|
+
* @param {Object} config - Project config object (optional, will fetch if not provided)
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
function isWaitlistInstalled(config = null) {
|
|
88
|
+
if (!config) {
|
|
89
|
+
config = getProjectConfig();
|
|
90
|
+
}
|
|
91
|
+
return (config.installedServices || []).includes('waitlist');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
isLaunchFrameProject,
|
|
96
|
+
requireProject,
|
|
97
|
+
getProjectConfig,
|
|
98
|
+
updateProjectConfig,
|
|
99
|
+
getInstalledComponents,
|
|
100
|
+
isComponentInstalled,
|
|
101
|
+
addInstalledComponent,
|
|
102
|
+
getPrimaryDomain,
|
|
103
|
+
isWaitlistInstalled
|
|
104
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replace a marked section in a file
|
|
5
|
+
* @param {string} filePath - Path to file
|
|
6
|
+
* @param {string} sectionName - Name of section (e.g., 'CORS_CONFIG')
|
|
7
|
+
* @param {string} newContent - New content to insert
|
|
8
|
+
*/
|
|
9
|
+
async function replaceSection(filePath, sectionName, newContent) {
|
|
10
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
11
|
+
|
|
12
|
+
// Try both comment formats (// for regular comments, {/* */} for JSX)
|
|
13
|
+
const startMarkerRegular = `// ${sectionName}_START`;
|
|
14
|
+
const endMarkerRegular = `// ${sectionName}_END`;
|
|
15
|
+
const startMarkerJSX = `{/* ${sectionName}_START */}`;
|
|
16
|
+
const endMarkerJSX = `{/* ${sectionName}_END */}`;
|
|
17
|
+
|
|
18
|
+
let startIndex = content.indexOf(startMarkerRegular);
|
|
19
|
+
let endIndex = content.indexOf(endMarkerRegular);
|
|
20
|
+
let isJSX = false;
|
|
21
|
+
|
|
22
|
+
// If not found with regular comments, try JSX comments
|
|
23
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
24
|
+
startIndex = content.indexOf(startMarkerJSX);
|
|
25
|
+
endIndex = content.indexOf(endMarkerJSX);
|
|
26
|
+
isJSX = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (startIndex === -1 || endIndex === -1) {
|
|
30
|
+
throw new Error(`Section markers not found: ${sectionName} in ${filePath}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Find the end of the end marker line
|
|
34
|
+
const endLineEnd = content.indexOf('\n', endIndex);
|
|
35
|
+
|
|
36
|
+
// Construct new content - exclude both marker lines
|
|
37
|
+
const before = content.substring(0, startIndex);
|
|
38
|
+
const after = content.substring(endLineEnd + 1);
|
|
39
|
+
const replaced = before + newContent + after;
|
|
40
|
+
|
|
41
|
+
await fs.writeFile(filePath, replaced, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if a file contains a specific section marker
|
|
46
|
+
* @param {string} filePath - Path to file
|
|
47
|
+
* @param {string} sectionName - Name of section
|
|
48
|
+
* @returns {Promise<boolean>} - True if section exists
|
|
49
|
+
*/
|
|
50
|
+
async function hasSection(filePath, sectionName) {
|
|
51
|
+
try {
|
|
52
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
53
|
+
const startMarkerRegular = `// ${sectionName}_START`;
|
|
54
|
+
const endMarkerRegular = `// ${sectionName}_END`;
|
|
55
|
+
const startMarkerJSX = `{/* ${sectionName}_START */}`;
|
|
56
|
+
const endMarkerJSX = `{/* ${sectionName}_END */}`;
|
|
57
|
+
|
|
58
|
+
// Check both comment formats
|
|
59
|
+
const hasRegular = content.includes(startMarkerRegular) && content.includes(endMarkerRegular);
|
|
60
|
+
const hasJSX = content.includes(startMarkerJSX) && content.includes(endMarkerJSX);
|
|
61
|
+
|
|
62
|
+
return hasRegular || hasJSX;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
replaceSection,
|
|
70
|
+
hasSection
|
|
71
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const { exec } = require('child_process');
|
|
2
|
+
const { promisify } = require('util');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const fs = require('fs-extra');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Test SSH connection to VPS
|
|
12
|
+
* @param {string} vpsUser - SSH username
|
|
13
|
+
* @param {string} vpsHost - VPS hostname or IP
|
|
14
|
+
* @returns {Promise<{success: boolean, error?: string}>}
|
|
15
|
+
*/
|
|
16
|
+
async function testSSHConnection(vpsUser, vpsHost) {
|
|
17
|
+
try {
|
|
18
|
+
// Try a simple SSH command (echo test)
|
|
19
|
+
await execAsync(`ssh -o ConnectTimeout=10 -o BatchMode=yes ${vpsUser}@${vpsHost} "echo 'Connection successful'"`, {
|
|
20
|
+
timeout: 15000
|
|
21
|
+
});
|
|
22
|
+
return { success: true };
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return {
|
|
25
|
+
success: false,
|
|
26
|
+
error: error.message
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if SSH keys are configured
|
|
33
|
+
* @returns {Promise<{hasKeys: boolean, keyPaths: string[]}>}
|
|
34
|
+
*/
|
|
35
|
+
async function checkSSHKeys() {
|
|
36
|
+
const homeDir = os.homedir();
|
|
37
|
+
const sshDir = path.join(homeDir, '.ssh');
|
|
38
|
+
|
|
39
|
+
const commonKeyNames = [
|
|
40
|
+
'id_rsa',
|
|
41
|
+
'id_ed25519',
|
|
42
|
+
'id_ecdsa',
|
|
43
|
+
'id_dsa'
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const keyPaths = [];
|
|
47
|
+
|
|
48
|
+
for (const keyName of commonKeyNames) {
|
|
49
|
+
const keyPath = path.join(sshDir, keyName);
|
|
50
|
+
if (await fs.pathExists(keyPath)) {
|
|
51
|
+
keyPaths.push(keyPath);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
hasKeys: keyPaths.length > 0,
|
|
57
|
+
keyPaths
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Execute SSH command on VPS
|
|
63
|
+
* @param {string} vpsUser - SSH username
|
|
64
|
+
* @param {string} vpsHost - VPS hostname or IP
|
|
65
|
+
* @param {string} command - Command to execute
|
|
66
|
+
* @param {Object} options - Execution options
|
|
67
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
68
|
+
*/
|
|
69
|
+
async function executeSSH(vpsUser, vpsHost, command, options = {}) {
|
|
70
|
+
const { timeout = 120000 } = options;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const { stdout, stderr } = await execAsync(
|
|
74
|
+
`ssh -o ConnectTimeout=10 ${vpsUser}@${vpsHost} "${command.replace(/"/g, '\\"')}"`,
|
|
75
|
+
{ timeout }
|
|
76
|
+
);
|
|
77
|
+
return { stdout, stderr };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`SSH command failed: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Copy file to VPS via SCP
|
|
85
|
+
* @param {string} localPath - Local file path
|
|
86
|
+
* @param {string} vpsUser - SSH username
|
|
87
|
+
* @param {string} vpsHost - VPS hostname or IP
|
|
88
|
+
* @param {string} remotePath - Remote file path
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async function copyFileToVPS(localPath, vpsUser, vpsHost, remotePath) {
|
|
92
|
+
try {
|
|
93
|
+
await execAsync(`scp "${localPath}" ${vpsUser}@${vpsHost}:"${remotePath}"`, {
|
|
94
|
+
timeout: 60000
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(`Failed to copy file: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Copy entire directory to VPS using tar + scp
|
|
103
|
+
* @param {string} localDir - Local directory path
|
|
104
|
+
* @param {string} vpsUser - SSH username
|
|
105
|
+
* @param {string} vpsHost - VPS hostname or IP
|
|
106
|
+
* @param {string} remoteDir - Remote directory path
|
|
107
|
+
* @returns {Promise<void>}
|
|
108
|
+
*/
|
|
109
|
+
async function copyDirectoryToVPS(localDir, vpsUser, vpsHost, remoteDir) {
|
|
110
|
+
const path = require('path');
|
|
111
|
+
const fs = require('fs-extra');
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Create a temporary tarball excluding unnecessary files
|
|
115
|
+
const tarballName = `deploy-${Date.now()}.tar.gz`;
|
|
116
|
+
const tarballPath = path.join('/tmp', tarballName);
|
|
117
|
+
|
|
118
|
+
// Create tarball with exclusions
|
|
119
|
+
await execAsync(
|
|
120
|
+
`tar -czf "${tarballPath}" -C "${localDir}" --exclude='node_modules' --exclude='.next' --exclude='dist' --exclude='build' --exclude='.git' --exclude='*.log' .`,
|
|
121
|
+
{ timeout: 60000 }
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Ensure remote directory exists
|
|
125
|
+
await execAsync(
|
|
126
|
+
`ssh ${vpsUser}@${vpsHost} "mkdir -p ${remoteDir}"`,
|
|
127
|
+
{ timeout: 30000 }
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Copy tarball to VPS
|
|
131
|
+
await execAsync(
|
|
132
|
+
`scp "${tarballPath}" ${vpsUser}@${vpsHost}:/tmp/${tarballName}`,
|
|
133
|
+
{ timeout: 180000 } // 3 minutes
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Extract tarball on VPS
|
|
137
|
+
await execAsync(
|
|
138
|
+
`ssh ${vpsUser}@${vpsHost} "tar -xzf /tmp/${tarballName} -C ${remoteDir} && rm /tmp/${tarballName}"`,
|
|
139
|
+
{ timeout: 60000 }
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Clean up local tarball
|
|
143
|
+
await fs.remove(tarballPath);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new Error(`Failed to copy directory: ${error.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if repository is private by attempting to clone
|
|
151
|
+
* @param {string} githubOrg - GitHub organization/username
|
|
152
|
+
* @param {string} projectName - Project name
|
|
153
|
+
* @returns {Promise<{isPrivate: boolean, error?: string}>}
|
|
154
|
+
*/
|
|
155
|
+
async function checkRepoPrivacy(githubOrg, projectName) {
|
|
156
|
+
try {
|
|
157
|
+
// Try to get repository info via HTTPS (no auth)
|
|
158
|
+
await execAsync(`git ls-remote https://github.com/${githubOrg}/${projectName}.git HEAD`, {
|
|
159
|
+
timeout: 10000
|
|
160
|
+
});
|
|
161
|
+
return { isPrivate: false };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// If error contains "authentication" or "not found", likely private or doesn't exist
|
|
164
|
+
if (error.message.includes('Authentication') || error.message.includes('not found')) {
|
|
165
|
+
return {
|
|
166
|
+
isPrivate: true,
|
|
167
|
+
error: 'Repository appears to be private or does not exist'
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Other errors
|
|
171
|
+
return {
|
|
172
|
+
isPrivate: true,
|
|
173
|
+
error: error.message
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Display instructions for setting up deploy keys for private repository
|
|
180
|
+
* @param {string} vpsUser - SSH username
|
|
181
|
+
* @param {string} vpsHost - VPS hostname or IP
|
|
182
|
+
* @param {string} githubOrg - GitHub organization/username
|
|
183
|
+
* @param {string} projectName - Project name
|
|
184
|
+
*/
|
|
185
|
+
function showDeployKeyInstructions(vpsUser, vpsHost, githubOrg, projectName) {
|
|
186
|
+
console.log(chalk.yellow('\n📝 Private Repository Detected\n'));
|
|
187
|
+
console.log(chalk.white('Your repository appears to be private. Follow these steps:\n'));
|
|
188
|
+
|
|
189
|
+
console.log(chalk.white('1. SSH to your VPS:'));
|
|
190
|
+
console.log(chalk.gray(` ssh ${vpsUser}@${vpsHost}\n`));
|
|
191
|
+
|
|
192
|
+
console.log(chalk.white('2. Generate SSH key (if not already exists):'));
|
|
193
|
+
console.log(chalk.gray(` ssh-keygen -t ed25519 -C "deploy-key-${projectName}"\n`));
|
|
194
|
+
|
|
195
|
+
console.log(chalk.white('3. Display the public key:'));
|
|
196
|
+
console.log(chalk.gray(' cat ~/.ssh/id_ed25519.pub\n'));
|
|
197
|
+
|
|
198
|
+
console.log(chalk.white('4. Add deploy key to GitHub:'));
|
|
199
|
+
console.log(chalk.gray(` - Go to: https://github.com/${githubOrg}/${projectName}/settings/keys`));
|
|
200
|
+
console.log(chalk.gray(' - Click "Add deploy key"'));
|
|
201
|
+
console.log(chalk.gray(' - Paste the public key'));
|
|
202
|
+
console.log(chalk.gray(' - Title: "VPS Deploy Key"'));
|
|
203
|
+
console.log(chalk.gray(' - Check "Allow write access" if needed\n'));
|
|
204
|
+
|
|
205
|
+
console.log(chalk.white('5. Test the connection:'));
|
|
206
|
+
console.log(chalk.gray(' ssh -T git@github.com\n'));
|
|
207
|
+
|
|
208
|
+
console.log(chalk.white('6. Then retry:'));
|
|
209
|
+
console.log(chalk.gray(' launchframe deploy:init\n'));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = {
|
|
213
|
+
testSSHConnection,
|
|
214
|
+
checkSSHKeys,
|
|
215
|
+
executeSSH,
|
|
216
|
+
copyFileToVPS,
|
|
217
|
+
copyDirectoryToVPS,
|
|
218
|
+
checkRepoPrivacy,
|
|
219
|
+
showDeployKeyInstructions
|
|
220
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { glob } = require('glob');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Replace template variables in all files within a directory
|
|
7
|
+
* @param {string} directory - Root directory to search
|
|
8
|
+
* @param {Object} variables - Object with variable mappings (e.g., {'{{VAR}}': 'value'})
|
|
9
|
+
*/
|
|
10
|
+
async function replaceVariables(directory, variables) {
|
|
11
|
+
// Find all files (excluding node_modules, .git, binary files)
|
|
12
|
+
const files = await glob('**/*', {
|
|
13
|
+
cwd: directory,
|
|
14
|
+
nodir: true,
|
|
15
|
+
dot: true, // Include hidden files/directories like .vitepress
|
|
16
|
+
ignore: [
|
|
17
|
+
'**/node_modules/**',
|
|
18
|
+
'**/.git/**',
|
|
19
|
+
'**/.next/**',
|
|
20
|
+
'**/dist/**',
|
|
21
|
+
'**/build/**',
|
|
22
|
+
'**/*.png',
|
|
23
|
+
'**/*.jpg',
|
|
24
|
+
'**/*.jpeg',
|
|
25
|
+
'**/*.gif',
|
|
26
|
+
'**/*.ico',
|
|
27
|
+
'**/*.pdf',
|
|
28
|
+
'**/*.woff',
|
|
29
|
+
'**/*.woff2',
|
|
30
|
+
'**/*.ttf',
|
|
31
|
+
'**/*.eot'
|
|
32
|
+
]
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
const filePath = path.join(directory, file);
|
|
37
|
+
await replaceVariablesInFile(filePath, variables);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Escape special regex characters in a string
|
|
43
|
+
* @param {string} string - String to escape
|
|
44
|
+
* @returns {string} Escaped string safe for use in regex
|
|
45
|
+
*/
|
|
46
|
+
function escapeRegex(string) {
|
|
47
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Replace template variables in a single file
|
|
52
|
+
* Uses negative lookbehind to avoid replacing GitHub Actions syntax (${{ }})
|
|
53
|
+
* @param {string} filePath - Path to file
|
|
54
|
+
* @param {Object} variables - Object with variable mappings
|
|
55
|
+
*/
|
|
56
|
+
async function replaceVariablesInFile(filePath, variables) {
|
|
57
|
+
try {
|
|
58
|
+
let content = await fs.readFile(filePath, 'utf8');
|
|
59
|
+
let modified = false;
|
|
60
|
+
|
|
61
|
+
// Replace each variable using regex with negative lookbehind
|
|
62
|
+
for (const [placeholder, value] of Object.entries(variables)) {
|
|
63
|
+
// Create regex that matches {{VAR}} but NOT ${{VAR}}
|
|
64
|
+
// Negative lookbehind: (?<!\$) means "not preceded by $"
|
|
65
|
+
const escapedPlaceholder = escapeRegex(placeholder);
|
|
66
|
+
const regex = new RegExp(`(?<!\\$)${escapedPlaceholder}`, 'g');
|
|
67
|
+
|
|
68
|
+
if (regex.test(content)) {
|
|
69
|
+
// Reset regex lastIndex after test
|
|
70
|
+
regex.lastIndex = 0;
|
|
71
|
+
content = content.replace(regex, value);
|
|
72
|
+
modified = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only write if changes were made
|
|
77
|
+
if (modified) {
|
|
78
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return modified;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// Skip binary files or files that can't be read as text
|
|
84
|
+
if (error.code !== 'EISDIR') {
|
|
85
|
+
// Log warning but continue
|
|
86
|
+
console.warn(`Warning: Could not process ${filePath}`);
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
replaceVariables,
|
|
94
|
+
replaceVariablesInFile
|
|
95
|
+
};
|