@wxuns/zp-cli 0.0.1

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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * upload.js - 文件/目录上传命令
3
+ * 核心流程:检测 Git 仓库 → 匹配映射 → 确定服务器和路径 → 执行部署
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const chalk = require('chalk');
8
+ const logger = require('../utils/logger');
9
+ const configManager = require('../core/configManager');
10
+ const gitHelper = require('../core/gitHelper');
11
+ const sshDeployer = require('../core/sshDeployer');
12
+
13
+ /**
14
+ * 执行 upload 命令
15
+ * @param {string} localPath - 本地文件/目录路径
16
+ * @param {Object} options - 命令行选项
17
+ * @param {string} [options.remotePath] - 指定远程目标路径
18
+ * @param {string} [options.server] - 指定服务器别名
19
+ */
20
+ async function run(localPath, options) {
21
+ try {
22
+ // 1. 检查本地路径是否存在
23
+ const absPath = path.resolve(localPath);
24
+ if (!fs.existsSync(absPath)) {
25
+ logger.error(`路径不存在: ${absPath}`);
26
+ process.exit(1);
27
+ }
28
+
29
+ const isDir = fs.statSync(absPath).isDirectory();
30
+ logger.log(chalk.bold(`\n🚀 zp-cli 部署工具\n`));
31
+ logger.info(`本地路径: ${absPath}`);
32
+ logger.info(`类型: ${isDir ? '目录' : '文件'}`);
33
+ logger.newline();
34
+
35
+ // 2. 加载配置文件
36
+ const config = configManager.loadConfig();
37
+ if (!config) {
38
+ logger.error('配置文件不存在,请先执行 ' + chalk.cyan('zp-cli init') + ' 初始化配置');
39
+ process.exit(1);
40
+ }
41
+
42
+ // 3. 确定服务器和远程路径
43
+ let server = null;
44
+ let remoteBase = null;
45
+ let relativePath = null;
46
+
47
+ // 模式 A:命令行直接指定了服务器别名和远程路径
48
+ if (options.server && options.remotePath) {
49
+ server = configManager.findServerByAlias(config, options.server);
50
+ if (!server) {
51
+ logger.error(`未找到别名为 "${options.server}" 的服务器`);
52
+ process.exit(1);
53
+ }
54
+ remoteBase = options.remotePath;
55
+ relativePath = path.basename(absPath);
56
+ logger.info(`使用命令行指定的服务器: ${server.alias}`);
57
+ }
58
+ // 模式 B:命令行指定了服务器别名
59
+ // 优先从 Git 映射中取 remotePath,否则用服务器的 defaultRemotePath
60
+ else if (options.server) {
61
+ server = configManager.findServerByAlias(config, options.server);
62
+ if (!server) {
63
+ logger.error(`未找到别名为 "${options.server}" 的服务器`);
64
+ process.exit(1);
65
+ }
66
+
67
+ // 尝试从 Git 映射中获取远程路径
68
+ let mapped = false;
69
+ if (gitHelper.isGitRepo()) {
70
+ const gitUrl = gitHelper.getGitRemoteUrl();
71
+ if (gitUrl) {
72
+ const mapping = configManager.findMappingByGitUrl(config, gitUrl);
73
+ if (mapping) {
74
+ const repoRoot = gitHelper.getGitRoot();
75
+ const resolved = gitHelper.resolveRemotePath(absPath, mapping, repoRoot);
76
+ if (resolved.remoteBase) {
77
+ remoteBase = resolved.remoteBase;
78
+ relativePath = resolved.relativePath;
79
+ mapped = true;
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ // 没有映射则用服务器的 defaultRemotePath
86
+ if (!mapped) {
87
+ if (!server.defaultRemotePath) {
88
+ logger.error(`服务器 "${server.alias}" 未配置 defaultRemotePath,请通过 --remote-path 指定`);
89
+ process.exit(1);
90
+ }
91
+ remoteBase = server.defaultRemotePath;
92
+ relativePath = path.basename(absPath);
93
+ }
94
+
95
+ logger.info(`使用命令行指定的服务器: ${server.alias}`);
96
+ }
97
+ // 模式 C:命令行指定了远程路径(需要从映射或默认服务器推断)
98
+ else if (options.remotePath) {
99
+ // 尝试通过 Git 映射找到服务器
100
+ if (gitHelper.isGitRepo()) {
101
+ const gitUrl = gitHelper.getGitRemoteUrl();
102
+ if (gitUrl) {
103
+ const mapping = configManager.findMappingByGitUrl(config, gitUrl);
104
+ if (mapping) {
105
+ server = configManager.findServerByAlias(config, mapping.serverAlias);
106
+ }
107
+ }
108
+ }
109
+ if (!server && config.servers.length > 0) {
110
+ server = config.servers[0]; // 使用第一个服务器
111
+ }
112
+ if (!server) {
113
+ logger.error('无法确定目标服务器,请通过 --server 指定');
114
+ process.exit(1);
115
+ }
116
+ remoteBase = options.remotePath;
117
+ relativePath = path.basename(absPath);
118
+ }
119
+ // 模式 D:智能模式 - 通过 Git 仓库自动匹配
120
+ else {
121
+ const result = resolveFromGit(config, absPath);
122
+ if (!result) {
123
+ logger.error('无法自动确定部署目标。请检查以下可能的原因:');
124
+ logger.log(' 1. 当前目录不在 Git 仓库内');
125
+ logger.log(' 2. 配置文件中没有匹配的映射');
126
+ logger.log(' 3. 请使用 --server 和 --remote-path 手动指定');
127
+ process.exit(1);
128
+ }
129
+ server = result.server;
130
+ remoteBase = result.remoteBase;
131
+ relativePath = result.relativePath;
132
+ logger.info(`Git 仓库匹配成功: ${result.gitUrl}`);
133
+ logger.info(`服务器: ${server.alias} (${server.host})`);
134
+ }
135
+
136
+ logger.newline();
137
+
138
+ // 4. 验证服务器配置
139
+ const validation = configManager.validateServer(server);
140
+ if (!validation.valid) {
141
+ logger.error('服务器配置不完整:');
142
+ validation.errors.forEach(e => logger.log(` - ${e}`));
143
+ process.exit(1);
144
+ }
145
+
146
+ // 5. 执行部署
147
+ const permissions = (server.chown || server.chgrp)
148
+ ? { user: server.chown, group: server.chgrp }
149
+ : null;
150
+
151
+ await sshDeployer.deploy({
152
+ server,
153
+ localPath: absPath,
154
+ remoteBase,
155
+ relativePath,
156
+ isDir,
157
+ permissions
158
+ });
159
+
160
+ } catch (err) {
161
+ logger.error(`部署失败: ${err.message}`);
162
+ process.exit(1);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 通过 Git 仓库信息智能解析部署目标
168
+ * @param {Object} config - 配置对象
169
+ * @param {string} absPath - 本地文件的绝对路径
170
+ * @returns {Object|null} { server, remoteBase, relativePath, gitUrl }
171
+ */
172
+ function resolveFromGit(config, absPath) {
173
+ if (!gitHelper.isGitRepo()) {
174
+ logger.warn('当前目录不在 Git 仓库内');
175
+ return null;
176
+ }
177
+
178
+ const gitUrl = gitHelper.getGitRemoteUrl();
179
+ if (!gitUrl) {
180
+ logger.warn('无法获取 Git 远程仓库地址');
181
+ return null;
182
+ }
183
+
184
+ logger.info(`检测到 Git 远程地址: ${gitUrl}`);
185
+
186
+ // 在映射中查找匹配项(支持 SSH / HTTPS 两种格式互相匹配)
187
+ const mapping = configManager.findMappingByGitUrl(config, gitUrl);
188
+ if (!mapping) {
189
+ logger.warn('在配置文件中未找到匹配的映射');
190
+ logger.log(chalk.gray('提示: 您可以在 ~/.zp-cli.json 的 mappings 中添加该仓库的映射'));
191
+ logger.log(chalk.gray(' 配置中的 gitRemoteUrl 支持 SSH 和 HTTPS 两种格式,会自动匹配'));
192
+ return null;
193
+ }
194
+
195
+ // 显示匹配到的配置项(如果格式不同则同时显示)
196
+ if (mapping.gitRemoteUrl !== gitUrl) {
197
+ logger.info(`匹配到映射配置: ${mapping.gitRemoteUrl}`);
198
+ }
199
+
200
+ // 解析远程路径(优先子目录映射,回退到顶层配置)
201
+ const repoRoot = gitHelper.getGitRoot();
202
+ const resolved = gitHelper.resolveRemotePath(absPath, mapping, repoRoot);
203
+
204
+ // 确定服务器:子目录映射 > 映射顶层
205
+ const serverAlias = resolved.serverAlias || mapping.serverAlias;
206
+ if (!serverAlias) {
207
+ logger.error('无法确定目标服务器:映射中未配置 serverAlias,且未匹配到子目录映射');
208
+ return null;
209
+ }
210
+
211
+ const server = configManager.findServerByAlias(config, serverAlias);
212
+ if (!server) {
213
+ logger.error(`映射中指定的服务器 "${serverAlias}" 不存在`);
214
+ return null;
215
+ }
216
+
217
+ // 确定远程路径
218
+ if (!resolved.remoteBase) {
219
+ logger.error('无法确定远程路径:映射中未配置 remotePath,且未匹配到子目录映射');
220
+ return null;
221
+ }
222
+
223
+ return {
224
+ server,
225
+ remoteBase: resolved.remoteBase,
226
+ relativePath: resolved.relativePath,
227
+ gitUrl
228
+ };
229
+ }
230
+
231
+ module.exports = { run };
@@ -0,0 +1,166 @@
1
+ /**
2
+ * configManager.js - 配置文件管理模块
3
+ * 负责读取、写入和验证 ~/.zp-cli.json 配置文件
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const logger = require('../utils/logger');
9
+
10
+ // 配置文件路径:~/.zp-cli.json
11
+ const CONFIG_FILE = path.join(os.homedir(), '.zp-cli.json');
12
+
13
+ /**
14
+ * 获取配置文件路径
15
+ * @returns {string} 配置文件的绝对路径
16
+ */
17
+ function getConfigPath() {
18
+ return CONFIG_FILE;
19
+ }
20
+
21
+ /**
22
+ * 检查配置文件是否存在
23
+ * @returns {boolean}
24
+ */
25
+ function configExists() {
26
+ return fs.existsSync(CONFIG_FILE);
27
+ }
28
+
29
+ /**
30
+ * 读取配置文件
31
+ * @returns {Object|null} 解析后的配置对象,不存在时返回 null
32
+ */
33
+ function loadConfig() {
34
+ if (!configExists()) {
35
+ return null;
36
+ }
37
+ try {
38
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
39
+ return JSON.parse(raw);
40
+ } catch (err) {
41
+ logger.error(`读取配置文件失败: ${err.message}`);
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 将配置对象写入文件
48
+ * @param {Object} config - 配置对象
49
+ */
50
+ function saveConfig(config) {
51
+ try {
52
+ const json = JSON.stringify(config, null, 2);
53
+ fs.writeFileSync(CONFIG_FILE, json, 'utf-8');
54
+ } catch (err) {
55
+ logger.error(`写入配置文件失败: ${err.message}`);
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * 生成默认的空配置模板
62
+ * @returns {Object} 默认配置对象
63
+ */
64
+ function getDefaultConfig() {
65
+ return {
66
+ servers: [],
67
+ mappings: []
68
+ };
69
+ }
70
+
71
+ /**
72
+ * 根据别名查找服务器配置
73
+ * @param {Object} config - 完整配置对象
74
+ * @param {string} alias - 服务器别名
75
+ * @returns {Object|null} 服务器配置,未找到时返回 null
76
+ */
77
+ function findServerByAlias(config, alias) {
78
+ if (!config || !config.servers) return null;
79
+ return config.servers.find(s => s.alias === alias) || null;
80
+ }
81
+
82
+ /**
83
+ * 将 Git URL 归一化为统一的 host/path 格式
84
+ * 支持 SSH、HTTPS、ssh:// 等多种格式
85
+ *
86
+ * 示例:
87
+ * git@github.com:org/repo.git → github.com/org/repo
88
+ * https://github.com/org/repo.git → github.com/org/repo
89
+ * ssh://git@github.com:22/org/repo.git → github.com/org/repo
90
+ * git@gitlab.com:group/sub/repo.git → gitlab.com/group/sub/repo
91
+ *
92
+ * @param {string} url - Git 远程地址
93
+ * @returns {string} 归一化后的 host/path(小写,去除 .git 后缀)
94
+ */
95
+ function normalizeGitUrl(url) {
96
+ if (!url) return '';
97
+ let s = url.trim();
98
+
99
+ // 1. ssh://git@host:port/path.git → host/path
100
+ const sshSchemeMatch = s.match(/^ssh:\/\/(?:[^@]+@)?([^:/]+)(?::\d+)?[:/](.+?)(?:\.git)?$/i);
101
+ if (sshSchemeMatch) {
102
+ return `${sshSchemeMatch[1]}/${sshSchemeMatch[2]}`.toLowerCase().replace(/\.git$/, '');
103
+ }
104
+
105
+ // 2. git@host:path.git → host/path
106
+ const sshShortMatch = s.match(/^git@([^:]+):(.+?)(?:\.git)?$/i);
107
+ if (sshShortMatch) {
108
+ return `${sshShortMatch[1]}/${sshShortMatch[2]}`.toLowerCase().replace(/\.git$/, '');
109
+ }
110
+
111
+ // 3. https://host/path.git → host/path
112
+ const httpsMatch = s.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?(?:\/)?$/i);
113
+ if (httpsMatch) {
114
+ return `${httpsMatch[1]}/${httpsMatch[2]}`.toLowerCase().replace(/\.git$/, '');
115
+ }
116
+
117
+ // 4. 无法识别的格式,原样返回(小写、去尾部斜杠和 .git)
118
+ return s.toLowerCase().replace(/\.git$/, '').replace(/\/+$/, '');
119
+ }
120
+
121
+ /**
122
+ * 根据 Git 远程地址查找匹配的映射配置
123
+ * 支持 SSH(git@host:path)和 HTTPS(https://host/path)两种格式互相匹配
124
+ *
125
+ * @param {Object} config - 完整配置对象
126
+ * @param {string} gitRemoteUrl - Git 远程仓库地址
127
+ * @returns {Object|null} 匹配的映射配置,未找到时返回 null
128
+ */
129
+ function findMappingByGitUrl(config, gitRemoteUrl) {
130
+ if (!config || !config.mappings) return null;
131
+ const normalizedInput = normalizeGitUrl(gitRemoteUrl);
132
+ if (!normalizedInput) return null;
133
+
134
+ return config.mappings.find(m => {
135
+ const normalizedMapping = normalizeGitUrl(m.gitRemoteUrl);
136
+ return normalizedInput === normalizedMapping;
137
+ }) || null;
138
+ }
139
+
140
+ /**
141
+ * 验证服务器配置是否完整
142
+ * @param {Object} server - 服务器配置对象
143
+ * @returns {Object} { valid: boolean, errors: string[] }
144
+ */
145
+ function validateServer(server) {
146
+ const errors = [];
147
+ if (!server.alias) errors.push('缺少服务器别名 (alias)');
148
+ if (!server.host) errors.push('缺少服务器地址 (host)');
149
+ if (!server.username) errors.push('缺少登录用户名 (username)');
150
+ if (!server.password && !server.privateKeyPath) {
151
+ errors.push('至少需要配置密码 (password) 或私钥路径 (privateKeyPath)');
152
+ }
153
+ return { valid: errors.length === 0, errors };
154
+ }
155
+
156
+ module.exports = {
157
+ getConfigPath,
158
+ configExists,
159
+ loadConfig,
160
+ saveConfig,
161
+ getDefaultConfig,
162
+ findServerByAlias,
163
+ findMappingByGitUrl,
164
+ normalizeGitUrl,
165
+ validateServer
166
+ };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * gitHelper.js - Git 仓库辅助模块
3
+ * 负责读取当前目录的 Git 远程仓库地址,用于自动匹配部署映射
4
+ */
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { execSync } = require('child_process');
8
+ const logger = require('../utils/logger');
9
+
10
+ /**
11
+ * 从 .git/config 文件中解析 origin 远程地址
12
+ * @param {string} repoRoot - Git 仓库根目录
13
+ * @returns {string|null} 远程仓库地址,解析失败时返回 null
14
+ */
15
+ function parseGitRemoteFromConfig(repoRoot) {
16
+ const gitConfigPath = path.join(repoRoot, '.git', 'config');
17
+ if (!fs.existsSync(gitConfigPath)) return null;
18
+
19
+ try {
20
+ const content = fs.readFileSync(gitConfigPath, 'utf-8');
21
+ const lines = content.split('\n');
22
+ let inOriginSection = false;
23
+
24
+ for (const line of lines) {
25
+ const trimmed = line.trim();
26
+ // 检测 [remote "origin"] 段落
27
+ if (trimmed === '[remote "origin"]') {
28
+ inOriginSection = true;
29
+ continue;
30
+ }
31
+ // 遇到新的 section 则退出
32
+ if (trimmed.startsWith('[') && inOriginSection) {
33
+ break;
34
+ }
35
+ // 提取 url 字段
36
+ if (inOriginSection && trimmed.startsWith('url =')) {
37
+ return trimmed.replace('url =', '').trim();
38
+ }
39
+ }
40
+ } catch (err) {
41
+ // 读取失败时静默处理,回退到 git 命令
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * 使用 git 命令获取 origin 远程地址
48
+ * @returns {string|null} 远程仓库地址
49
+ */
50
+ function getGitRemoteFromCommand() {
51
+ try {
52
+ const url = execSync('git remote get-url origin', {
53
+ encoding: 'utf-8',
54
+ stdio: ['pipe', 'pipe', 'pipe']
55
+ }).trim();
56
+ return url || null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 判断当前目录是否在 Git 仓库内
64
+ * @returns {boolean}
65
+ */
66
+ function isGitRepo() {
67
+ try {
68
+ execSync('git rev-parse --is-inside-work-tree', {
69
+ stdio: ['pipe', 'pipe', 'pipe']
70
+ });
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 获取 Git 仓库根目录
79
+ * @returns {string|null} 仓库根目录的绝对路径
80
+ */
81
+ function getGitRoot() {
82
+ try {
83
+ return execSync('git rev-parse --show-toplevel', {
84
+ encoding: 'utf-8',
85
+ stdio: ['pipe', 'pipe', 'pipe']
86
+ }).trim();
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * 获取当前目录对应的 Git origin 远程地址
94
+ * 优先从 .git/config 解析,失败则使用 git 命令
95
+ * @returns {string|null} 远程仓库地址
96
+ */
97
+ function getGitRemoteUrl() {
98
+ const repoRoot = getGitRoot();
99
+ if (!repoRoot) return null;
100
+
101
+ // 优先从配置文件解析(速度快,无需子进程)
102
+ const fromConfig = parseGitRemoteFromConfig(repoRoot);
103
+ if (fromConfig) return fromConfig;
104
+
105
+ // 回退到 git 命令
106
+ return getGitRemoteFromCommand();
107
+ }
108
+
109
+ /**
110
+ * 根据本地上传路径和映射配置,计算远程目标路径
111
+ *
112
+ * 优先级:子目录映射 > 映射顶层 serverAlias/remotePath
113
+ * - 有子目录映射且匹配 → 使用子映射的 serverAlias 和 remotePath
114
+ * 无子目录映射或未匹配 → 回退到顶层 serverAlias 和 remotePath
115
+ *
116
+ * subdirectoryMappings 的值支持两种格式:
117
+ * - 字符串: "/var/www/frontend" → 同服务器不同路径
118
+ * - 对象: { serverAlias, remotePath } → 部署到不同服务器
119
+ *
120
+ * @param {string} localPath - 本地上传路径(相对于仓库根目录)
121
+ * @param {Object} mapping - 映射配置对象
122
+ * @param {string} repoRoot - Git 仓库根目录
123
+ * @returns {{ remoteBase?: string, relativePath: string, serverAlias?: string } | null}
124
+ */
125
+ function resolveRemotePath(localPath, mapping, repoRoot) {
126
+ // 获取相对于仓库根目录的路径
127
+ const absLocal = path.resolve(localPath);
128
+ let relativePath = path.relative(repoRoot, absLocal).replace(/\\/g, '/');
129
+
130
+ // 有子目录映射时,优先尝试匹配
131
+ if (mapping.subdirectoryMappings && Object.keys(mapping.subdirectoryMappings).length > 0) {
132
+ const parts = relativePath.split('/');
133
+ const topDir = parts[0];
134
+
135
+ if (mapping.subdirectoryMappings[topDir]) {
136
+ const sub = mapping.subdirectoryMappings[topDir];
137
+
138
+ // 对象格式:{ serverAlias, remotePath }
139
+ if (typeof sub === 'object' && sub !== null) {
140
+ return {
141
+ remoteBase: sub.remotePath,
142
+ relativePath: parts.slice(1).join('/'),
143
+ serverAlias: sub.serverAlias
144
+ };
145
+ }
146
+
147
+ // 字符串格式:同服务器不同路径
148
+ return {
149
+ remoteBase: sub,
150
+ relativePath: parts.slice(1).join('/')
151
+ };
152
+ }
153
+ }
154
+
155
+ // 未匹配子目录映射,回退到顶层配置
156
+ return {
157
+ remoteBase: mapping.remotePath,
158
+ relativePath: relativePath
159
+ };
160
+ }
161
+
162
+ module.exports = {
163
+ isGitRepo,
164
+ getGitRoot,
165
+ getGitRemoteUrl,
166
+ resolveRemotePath
167
+ };