litai-spex 1.0.0
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 +254 -0
- package/package.json +46 -0
- package/src/commands/create-project.js +155 -0
- package/src/commands/deploy.js +155 -0
- package/src/commands/init.js +114 -0
- package/src/commands/ping.js +165 -0
- package/src/commands/scan.js +313 -0
- package/src/index.js +69 -0
- package/src/utils/config.js +115 -0
- package/src/utils/fileScanner.js +101 -0
- package/src/utils/sshDeployer.js +138 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CONFIG = {
|
|
5
|
+
connection: {
|
|
6
|
+
host: '',
|
|
7
|
+
username: '',
|
|
8
|
+
password: '',
|
|
9
|
+
privateKeyPath: '',
|
|
10
|
+
targetDirectory: '/var/www/app'
|
|
11
|
+
},
|
|
12
|
+
deploy: {
|
|
13
|
+
excludeDirectories: ['node_modules', 'logs', '.git', '.idea', '.vscode'],
|
|
14
|
+
excludeFiles: ['package-lock.json', '.env.local', '.DS_Store'],
|
|
15
|
+
excludePatterns: ['*.log', '*.tmp']
|
|
16
|
+
},
|
|
17
|
+
scripts: {
|
|
18
|
+
afterDeploy: 'deploy.sh'
|
|
19
|
+
},
|
|
20
|
+
project: {
|
|
21
|
+
repositoryUrl: '',
|
|
22
|
+
targetDirectory: '.'
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load configuration from file
|
|
28
|
+
* @param {string} configPath - Path to config file
|
|
29
|
+
* @returns {object} Configuration object
|
|
30
|
+
*/
|
|
31
|
+
function loadConfig(configPath) {
|
|
32
|
+
const fullPath = path.resolve(process.cwd(), configPath);
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(fullPath)) {
|
|
35
|
+
return { ...DEFAULT_CONFIG };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const configContent = fs.readFileSync(fullPath, 'utf8');
|
|
40
|
+
const userConfig = JSON.parse(configContent);
|
|
41
|
+
|
|
42
|
+
// Deep merge with defaults
|
|
43
|
+
return {
|
|
44
|
+
connection: { ...DEFAULT_CONFIG.connection, ...userConfig.connection },
|
|
45
|
+
deploy: {
|
|
46
|
+
excludeDirectories: userConfig.deploy?.excludeDirectories || DEFAULT_CONFIG.deploy.excludeDirectories,
|
|
47
|
+
excludeFiles: userConfig.deploy?.excludeFiles || DEFAULT_CONFIG.deploy.excludeFiles,
|
|
48
|
+
excludePatterns: userConfig.deploy?.excludePatterns || DEFAULT_CONFIG.deploy.excludePatterns
|
|
49
|
+
},
|
|
50
|
+
scripts: { ...DEFAULT_CONFIG.scripts, ...userConfig.scripts },
|
|
51
|
+
project: { ...DEFAULT_CONFIG.project, ...userConfig.project }
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(`Failed to parse config file: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Merge CLI options with config file settings (CLI takes precedence)
|
|
60
|
+
* @param {object} config - Config from file
|
|
61
|
+
* @param {object} options - CLI options
|
|
62
|
+
* @returns {object} Merged configuration
|
|
63
|
+
*/
|
|
64
|
+
function mergeWithCliOptions(config, options) {
|
|
65
|
+
return {
|
|
66
|
+
...config,
|
|
67
|
+
connection: {
|
|
68
|
+
...config.connection,
|
|
69
|
+
host: options.ip || config.connection.host,
|
|
70
|
+
username: options.user || config.connection.username,
|
|
71
|
+
password: options.password || config.connection.password,
|
|
72
|
+
privateKeyPath: options.key || config.connection.privateKeyPath,
|
|
73
|
+
targetDirectory: options.directory || config.connection.targetDirectory
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validate that required connection settings are present
|
|
80
|
+
* @param {object} config - Configuration object
|
|
81
|
+
* @returns {object} Object with isValid boolean and missing fields array
|
|
82
|
+
*/
|
|
83
|
+
function validateConfig(config) {
|
|
84
|
+
const missing = [];
|
|
85
|
+
|
|
86
|
+
if (!config.connection.host) missing.push('host (use -ip or set in config.json)');
|
|
87
|
+
if (!config.connection.username) missing.push('username (use -u or set in config.json)');
|
|
88
|
+
if (!config.connection.password && !config.connection.privateKeyPath) {
|
|
89
|
+
missing.push('password or privateKeyPath (use -p/-k or set in config.json)');
|
|
90
|
+
}
|
|
91
|
+
if (!config.connection.targetDirectory) missing.push('targetDirectory (use -dir or set in config.json)');
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
isValid: missing.length === 0,
|
|
95
|
+
missing
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create default config file
|
|
101
|
+
* @param {string} configPath - Path to create config file
|
|
102
|
+
*/
|
|
103
|
+
function createDefaultConfig(configPath) {
|
|
104
|
+
const fullPath = path.resolve(process.cwd(), configPath);
|
|
105
|
+
fs.writeFileSync(fullPath, JSON.stringify(DEFAULT_CONFIG, null, 2));
|
|
106
|
+
return fullPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
DEFAULT_CONFIG,
|
|
111
|
+
loadConfig,
|
|
112
|
+
mergeWithCliOptions,
|
|
113
|
+
validateConfig,
|
|
114
|
+
createDefaultConfig
|
|
115
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a filename matches any pattern (supports * wildcard)
|
|
6
|
+
* @param {string} filename - File name to check
|
|
7
|
+
* @param {string[]} patterns - Array of patterns
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
function matchesPattern(filename, patterns) {
|
|
11
|
+
return patterns.some(pattern => {
|
|
12
|
+
if (pattern.includes('*')) {
|
|
13
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
14
|
+
return regex.test(filename);
|
|
15
|
+
}
|
|
16
|
+
return filename === pattern;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Recursively scan directory and collect files to deploy
|
|
22
|
+
* @param {string} dir - Directory to scan
|
|
23
|
+
* @param {object} excludeConfig - Exclusion configuration
|
|
24
|
+
* @param {string} baseDir - Base directory for relative paths
|
|
25
|
+
* @returns {Array<{local: string, remote: string}>} Array of file mappings
|
|
26
|
+
*/
|
|
27
|
+
function scanDirectory(dir, excludeConfig, baseDir = dir) {
|
|
28
|
+
const { excludeDirectories, excludeFiles, excludePatterns } = excludeConfig;
|
|
29
|
+
const files = [];
|
|
30
|
+
|
|
31
|
+
const items = fs.readdirSync(dir);
|
|
32
|
+
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const fullPath = path.join(dir, item);
|
|
35
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
36
|
+
const stat = fs.statSync(fullPath);
|
|
37
|
+
|
|
38
|
+
if (stat.isDirectory()) {
|
|
39
|
+
// Check if directory should be excluded
|
|
40
|
+
if (excludeDirectories.includes(item)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Recursively scan subdirectory
|
|
45
|
+
const subFiles = scanDirectory(fullPath, excludeConfig, baseDir);
|
|
46
|
+
files.push(...subFiles);
|
|
47
|
+
} else {
|
|
48
|
+
// Check if file should be excluded
|
|
49
|
+
if (excludeFiles.includes(item)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check if file matches any exclude pattern
|
|
54
|
+
if (matchesPattern(item, excludePatterns)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
files.push({
|
|
59
|
+
local: fullPath,
|
|
60
|
+
remote: relativePath.replace(/\\/g, '/') // Normalize to forward slashes for remote
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get all directories that need to be created on remote
|
|
70
|
+
* @param {Array<{local: string, remote: string}>} files - File mappings
|
|
71
|
+
* @returns {string[]} Array of directory paths
|
|
72
|
+
*/
|
|
73
|
+
function getRemoteDirectories(files) {
|
|
74
|
+
const dirs = new Set();
|
|
75
|
+
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const dir = path.dirname(file.remote);
|
|
78
|
+
if (dir && dir !== '.') {
|
|
79
|
+
// Add all parent directories as well
|
|
80
|
+
const parts = dir.split('/');
|
|
81
|
+
let current = '';
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
current = current ? `${current}/${part}` : part;
|
|
84
|
+
dirs.add(current);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Sort by depth (shorter paths first) to ensure parent dirs are created first
|
|
90
|
+
return Array.from(dirs).sort((a, b) => {
|
|
91
|
+
const depthA = a.split('/').length;
|
|
92
|
+
const depthB = b.split('/').length;
|
|
93
|
+
return depthA - depthB;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
scanDirectory,
|
|
99
|
+
getRemoteDirectories,
|
|
100
|
+
matchesPattern
|
|
101
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const { NodeSSH } = require('node-ssh');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
class SSHDeployer {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.ssh = new NodeSSH();
|
|
8
|
+
this.connected = false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Connect to remote server
|
|
13
|
+
* @param {object} connectionConfig - Connection configuration
|
|
14
|
+
*/
|
|
15
|
+
async connect(connectionConfig) {
|
|
16
|
+
const config = {
|
|
17
|
+
host: connectionConfig.host,
|
|
18
|
+
username: connectionConfig.username
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (connectionConfig.privateKeyPath) {
|
|
22
|
+
const keyPath = path.resolve(process.cwd(), connectionConfig.privateKeyPath);
|
|
23
|
+
if (!fs.existsSync(keyPath)) {
|
|
24
|
+
throw new Error(`Private key file not found: ${keyPath}`);
|
|
25
|
+
}
|
|
26
|
+
config.privateKey = fs.readFileSync(keyPath, 'utf8');
|
|
27
|
+
} else if (connectionConfig.password) {
|
|
28
|
+
config.password = connectionConfig.password;
|
|
29
|
+
} else {
|
|
30
|
+
throw new Error('No authentication method provided (password or private key)');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await this.ssh.connect(config);
|
|
34
|
+
this.connected = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Disconnect from remote server
|
|
39
|
+
*/
|
|
40
|
+
disconnect() {
|
|
41
|
+
if (this.connected) {
|
|
42
|
+
this.ssh.dispose();
|
|
43
|
+
this.connected = false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create directories on remote server
|
|
49
|
+
* @param {string} baseDir - Base directory on remote
|
|
50
|
+
* @param {string[]} directories - Array of directory paths relative to baseDir
|
|
51
|
+
* @param {function} onProgress - Progress callback
|
|
52
|
+
*/
|
|
53
|
+
async createDirectories(baseDir, directories, onProgress) {
|
|
54
|
+
// First ensure base directory exists
|
|
55
|
+
await this.ssh.execCommand(`mkdir -p "${baseDir}"`);
|
|
56
|
+
|
|
57
|
+
for (const dir of directories) {
|
|
58
|
+
const fullPath = `${baseDir}/${dir}`;
|
|
59
|
+
await this.ssh.execCommand(`mkdir -p "${fullPath}"`);
|
|
60
|
+
if (onProgress) onProgress(dir);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Upload files to remote server
|
|
66
|
+
* @param {string} baseDir - Base directory on remote
|
|
67
|
+
* @param {Array<{local: string, remote: string}>} files - File mappings
|
|
68
|
+
* @param {function} onProgress - Progress callback (receives current file index and total)
|
|
69
|
+
*/
|
|
70
|
+
async uploadFiles(baseDir, files, onProgress) {
|
|
71
|
+
const total = files.length;
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < files.length; i++) {
|
|
74
|
+
const file = files[i];
|
|
75
|
+
const remotePath = `${baseDir}/${file.remote}`;
|
|
76
|
+
|
|
77
|
+
await this.ssh.putFile(file.local, remotePath);
|
|
78
|
+
|
|
79
|
+
if (onProgress) {
|
|
80
|
+
onProgress(i + 1, total, file.remote);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Execute a script on remote server
|
|
87
|
+
* @param {string} baseDir - Base directory on remote
|
|
88
|
+
* @param {string} scriptName - Script filename
|
|
89
|
+
* @returns {object} Execution result with stdout and stderr
|
|
90
|
+
*/
|
|
91
|
+
async executeScript(baseDir, scriptName) {
|
|
92
|
+
const scriptPath = `${baseDir}/${scriptName}`;
|
|
93
|
+
|
|
94
|
+
// First check if script exists
|
|
95
|
+
const checkResult = await this.ssh.execCommand(`test -f "${scriptPath}" && echo "exists"`);
|
|
96
|
+
if (!checkResult.stdout.includes('exists')) {
|
|
97
|
+
throw new Error(`Script not found: ${scriptPath}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Make script executable
|
|
101
|
+
await this.ssh.execCommand(`chmod +x "${scriptPath}"`);
|
|
102
|
+
|
|
103
|
+
// Execute script
|
|
104
|
+
const result = await this.ssh.execCommand(`cd "${baseDir}" && bash "${scriptPath}"`, {
|
|
105
|
+
cwd: baseDir
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check if remote directory exists
|
|
113
|
+
* @param {string} dir - Directory path
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
async directoryExists(dir) {
|
|
117
|
+
const result = await this.ssh.execCommand(`test -d "${dir}" && echo "exists"`);
|
|
118
|
+
return result.stdout.includes('exists');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get remote server info
|
|
123
|
+
* @returns {object} Server info
|
|
124
|
+
*/
|
|
125
|
+
async getServerInfo() {
|
|
126
|
+
const hostname = await this.ssh.execCommand('hostname');
|
|
127
|
+
const os = await this.ssh.execCommand('cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d= -f2 | tr -d \'"\'');
|
|
128
|
+
const uptime = await this.ssh.execCommand('uptime -p 2>/dev/null || uptime');
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
hostname: hostname.stdout.trim(),
|
|
132
|
+
os: os.stdout.trim() || 'Unknown',
|
|
133
|
+
uptime: uptime.stdout.trim()
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { SSHDeployer };
|