@zhengyizhao/deploy-helper 0.1.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 +120 -0
- package/package.json +24 -0
- package/src/commands/backup.js +313 -0
- package/src/commands/env.js +264 -0
- package/src/commands/init.js +261 -0
- package/src/commands/rollback.js +188 -0
- package/src/commands/status.js +105 -0
- package/src/commands/update.js +284 -0
- package/src/index.js +66 -0
- package/src/utils/config.js +23 -0
- package/src/utils/detect.js +48 -0
- package/src/utils/setup.js +126 -0
- package/src/utils/ssh.js +63 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { connectSSH, runRemoteSilent } from '../utils/ssh.js';
|
|
5
|
+
import { loadConfig, saveConfig } from '../utils/config.js';
|
|
6
|
+
|
|
7
|
+
const SNAPSHOTS_DIR = '/var/deploy-helper/snapshots';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 部署时调用:把当前版本打快照存起来
|
|
11
|
+
* 保留最近 5 个版本,多余的自动删除
|
|
12
|
+
*/
|
|
13
|
+
export async function createSnapshot(ssh, config) {
|
|
14
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
15
|
+
const snapshotName = `${config.appName}_${timestamp}`;
|
|
16
|
+
const snapshotPath = `${SNAPSHOTS_DIR}/${snapshotName}`;
|
|
17
|
+
|
|
18
|
+
await runRemoteSilent(ssh, `mkdir -p ${SNAPSHOTS_DIR}`);
|
|
19
|
+
|
|
20
|
+
// 如果当前部署目录存在,就复制一份作为快照
|
|
21
|
+
const exists = await runRemoteSilent(ssh, `test -d ${config.remotePath} && echo yes || echo no`);
|
|
22
|
+
if (exists.stdout.trim() === 'yes') {
|
|
23
|
+
await runRemoteSilent(ssh, `cp -r ${config.remotePath} ${snapshotPath}`);
|
|
24
|
+
|
|
25
|
+
// 记录快照元信息
|
|
26
|
+
const meta = JSON.stringify({
|
|
27
|
+
name: snapshotName,
|
|
28
|
+
timestamp,
|
|
29
|
+
appName: config.appName,
|
|
30
|
+
remotePath: config.remotePath,
|
|
31
|
+
});
|
|
32
|
+
await runRemoteSilent(ssh, `echo '${meta}' > ${snapshotPath}/.snapshot-meta.json`);
|
|
33
|
+
|
|
34
|
+
// 只保留最近 5 个快照
|
|
35
|
+
await runRemoteSilent(
|
|
36
|
+
ssh,
|
|
37
|
+
`ls -t ${SNAPSHOTS_DIR} | grep "^${config.appName}_" | tail -n +6 | xargs -I{} rm -rf ${SNAPSHOTS_DIR}/{}`
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return snapshotName;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 列出服务器上的所有快照
|
|
47
|
+
*/
|
|
48
|
+
async function listSnapshots(ssh, config) {
|
|
49
|
+
const result = await runRemoteSilent(
|
|
50
|
+
ssh,
|
|
51
|
+
`ls -t ${SNAPSHOTS_DIR} 2>/dev/null | grep "^${config.appName}_" || echo ""`
|
|
52
|
+
);
|
|
53
|
+
const names = result.stdout.trim().split('\n').filter(Boolean);
|
|
54
|
+
|
|
55
|
+
const snapshots = [];
|
|
56
|
+
for (const name of names) {
|
|
57
|
+
const metaResult = await runRemoteSilent(
|
|
58
|
+
ssh,
|
|
59
|
+
`cat ${SNAPSHOTS_DIR}/${name}/.snapshot-meta.json 2>/dev/null || echo "{}"`
|
|
60
|
+
);
|
|
61
|
+
try {
|
|
62
|
+
const meta = JSON.parse(metaResult.stdout);
|
|
63
|
+
snapshots.push({ name, ...meta });
|
|
64
|
+
} catch {
|
|
65
|
+
snapshots.push({ name, timestamp: name.replace(`${config.appName}_`, '') });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return snapshots;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* rollback 命令主体
|
|
73
|
+
*/
|
|
74
|
+
export async function deployRollback() {
|
|
75
|
+
const config = loadConfig();
|
|
76
|
+
if (!config) {
|
|
77
|
+
console.log(chalk.red('\n没有找到部署配置,请先运行:') + chalk.cyan(' deploy-helper init\n'));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let ssh;
|
|
82
|
+
const spinner = ora('连接服务器...').start();
|
|
83
|
+
try {
|
|
84
|
+
ssh = await connectSSH(config);
|
|
85
|
+
spinner.succeed('连接成功');
|
|
86
|
+
} catch (err) {
|
|
87
|
+
spinner.fail('连接失败:' + err.message);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 获取快照列表
|
|
92
|
+
const listSpinner = ora('获取历史版本列表...').start();
|
|
93
|
+
const snapshots = await listSnapshots(ssh, config);
|
|
94
|
+
listSpinner.stop();
|
|
95
|
+
|
|
96
|
+
if (snapshots.length === 0) {
|
|
97
|
+
console.log(chalk.yellow('\n没有找到任何历史快照。'));
|
|
98
|
+
console.log(chalk.gray('提示:从下次部署开始会自动创建快照。\n'));
|
|
99
|
+
ssh.dispose();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 展示快照列表供用户选择
|
|
104
|
+
console.log(chalk.bold(`\n找到 ${snapshots.length} 个历史版本:\n`));
|
|
105
|
+
|
|
106
|
+
const choices = snapshots.map((s, i) => {
|
|
107
|
+
const date = s.timestamp
|
|
108
|
+
? new Date(s.timestamp.replace(/-(\d{2})-(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3')).toLocaleString('zh-CN')
|
|
109
|
+
: s.timestamp;
|
|
110
|
+
const label = i === 0 ? chalk.gray(' ← 上一个版本') : '';
|
|
111
|
+
return {
|
|
112
|
+
name: `${chalk.cyan(s.name)} ${chalk.gray(date)}${label}`,
|
|
113
|
+
value: s.name,
|
|
114
|
+
short: s.name,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { selectedSnapshot } = await inquirer.prompt([{
|
|
119
|
+
type: 'list',
|
|
120
|
+
name: 'selectedSnapshot',
|
|
121
|
+
message: '选择要回滚到的版本:',
|
|
122
|
+
choices,
|
|
123
|
+
}]);
|
|
124
|
+
|
|
125
|
+
const { confirm } = await inquirer.prompt([{
|
|
126
|
+
type: 'confirm',
|
|
127
|
+
name: 'confirm',
|
|
128
|
+
message: chalk.yellow(`确认回滚?当前版本将被替换,此操作不可撤销。`),
|
|
129
|
+
default: false,
|
|
130
|
+
}]);
|
|
131
|
+
|
|
132
|
+
if (!confirm) {
|
|
133
|
+
console.log(chalk.gray('\n已取消回滚。\n'));
|
|
134
|
+
ssh.dispose();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 执行回滚
|
|
139
|
+
console.log('');
|
|
140
|
+
try {
|
|
141
|
+
// 把当前版本也存一个"回滚前"的快照
|
|
142
|
+
const preRollbackSpinner = ora('备份当前版本...').start();
|
|
143
|
+
await createSnapshot(ssh, config);
|
|
144
|
+
preRollbackSpinner.succeed('当前版本已备份');
|
|
145
|
+
|
|
146
|
+
// 停止服务
|
|
147
|
+
const stopSpinner = ora('停止当前服务...').start();
|
|
148
|
+
if (config.projectType === 'nodejs') {
|
|
149
|
+
await runRemoteSilent(ssh, `pm2 stop ${config.appName} 2>/dev/null || true`);
|
|
150
|
+
} else if (config.projectType === 'python') {
|
|
151
|
+
await runRemoteSilent(ssh, `supervisorctl stop ${config.appName} 2>/dev/null || true`);
|
|
152
|
+
} else if (config.projectType === 'docker') {
|
|
153
|
+
await runRemoteSilent(ssh, `cd ${config.remotePath} && docker compose down 2>/dev/null || true`);
|
|
154
|
+
}
|
|
155
|
+
stopSpinner.succeed('服务已停止');
|
|
156
|
+
|
|
157
|
+
// 替换代码目录
|
|
158
|
+
const restoreSpinner = ora(`还原版本 ${selectedSnapshot}...`).start();
|
|
159
|
+
await runRemoteSilent(ssh, `rm -rf ${config.remotePath}`);
|
|
160
|
+
await runRemoteSilent(ssh, `cp -r ${SNAPSHOTS_DIR}/${selectedSnapshot} ${config.remotePath}`);
|
|
161
|
+
await runRemoteSilent(ssh, `rm -f ${config.remotePath}/.snapshot-meta.json`);
|
|
162
|
+
restoreSpinner.succeed('版本已还原');
|
|
163
|
+
|
|
164
|
+
// 重启服务
|
|
165
|
+
const startSpinner = ora('重启服务...').start();
|
|
166
|
+
if (config.projectType === 'nodejs') {
|
|
167
|
+
await runRemoteSilent(ssh, `cd ${config.remotePath} && pm2 restart ${config.appName} || pm2 start ${config.startCmd} --name ${config.appName}`);
|
|
168
|
+
} else if (config.projectType === 'python') {
|
|
169
|
+
await runRemoteSilent(ssh, `supervisorctl start ${config.appName}`);
|
|
170
|
+
} else if (config.projectType === 'docker') {
|
|
171
|
+
await runRemoteSilent(ssh, `cd ${config.remotePath} && docker compose up -d`);
|
|
172
|
+
} else if (config.projectType === 'static') {
|
|
173
|
+
await runRemoteSilent(ssh, `chown -R www-data:www-data ${config.remotePath}`);
|
|
174
|
+
}
|
|
175
|
+
startSpinner.succeed('服务已重启');
|
|
176
|
+
|
|
177
|
+
ssh.dispose();
|
|
178
|
+
console.log(chalk.green.bold('\n✅ 回滚成功!'));
|
|
179
|
+
console.log(chalk.gray(` 已恢复到版本:${selectedSnapshot}\n`));
|
|
180
|
+
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.log(chalk.red('\n回滚失败:' + err.message));
|
|
183
|
+
ssh.dispose();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 导出给 update.js 调用
|
|
188
|
+
export { connectSSH } from '../utils/ssh.js';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { connectSSH, runRemoteSilent } from '../utils/ssh.js';
|
|
5
|
+
import { loadConfig } from '../utils/config.js';
|
|
6
|
+
|
|
7
|
+
export async function deployStatus() {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
|
|
10
|
+
if (!config) {
|
|
11
|
+
console.log(chalk.red('\n没有找到部署配置,请先运行:') + chalk.cyan(' deploy-helper init\n'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let ssh;
|
|
16
|
+
const spinner = ora('连接服务器获取状态...').start();
|
|
17
|
+
try {
|
|
18
|
+
ssh = await connectSSH(config);
|
|
19
|
+
spinner.succeed('已连接');
|
|
20
|
+
} catch (err) {
|
|
21
|
+
spinner.fail('连接失败:' + err.message);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log(chalk.bold('\n📊 服务器状态报告\n'));
|
|
26
|
+
|
|
27
|
+
// 系统基本信息
|
|
28
|
+
const [uptime, memInfo, diskInfo] = await Promise.all([
|
|
29
|
+
runRemoteSilent(ssh, "uptime -p"),
|
|
30
|
+
runRemoteSilent(ssh, "free -m | awk 'NR==2{printf \"%s MB / %s MB\", $3, $2}'"),
|
|
31
|
+
runRemoteSilent(ssh, "df -h / | awk 'NR==2{printf \"%s / %s (%s)\", $3, $2, $5}'"),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
console.log(chalk.gray(' 系统运行时间:') + uptime.stdout);
|
|
35
|
+
console.log(chalk.gray(' 内存占用: ') + memInfo.stdout);
|
|
36
|
+
console.log(chalk.gray(' 磁盘占用: ') + diskInfo.stdout);
|
|
37
|
+
|
|
38
|
+
// 应用状态
|
|
39
|
+
console.log(chalk.bold('\n 应用状态:'));
|
|
40
|
+
|
|
41
|
+
if (config.projectType === 'nodejs') {
|
|
42
|
+
const pm2Status = await runRemoteSilent(ssh, `pm2 jlist`);
|
|
43
|
+
try {
|
|
44
|
+
const list = JSON.parse(pm2Status.stdout);
|
|
45
|
+
const app = list.find(p => p.name === config.appName);
|
|
46
|
+
if (app) {
|
|
47
|
+
const statusColor = app.pm2_env.status === 'online' ? chalk.green : chalk.red;
|
|
48
|
+
console.log(` ${statusColor('●')} ${config.appName}`);
|
|
49
|
+
console.log(chalk.gray(` 状态:${statusColor(app.pm2_env.status)}`));
|
|
50
|
+
console.log(chalk.gray(` PID:${app.pid}`));
|
|
51
|
+
console.log(chalk.gray(` 重启次数:${app.pm2_env.restart_time}`));
|
|
52
|
+
console.log(chalk.gray(` 运行时间:${formatUptime(app.pm2_env.pm_uptime)}`));
|
|
53
|
+
console.log(chalk.gray(` CPU:${app.monit?.cpu ?? 0}% 内存:${Math.round((app.monit?.memory ?? 0) / 1024 / 1024)}MB`));
|
|
54
|
+
} else {
|
|
55
|
+
console.log(chalk.red(` ✗ 未找到 ${config.appName},服务可能没有运行`));
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
console.log(chalk.yellow(' 无法解析 PM2 状态'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 最近日志
|
|
62
|
+
console.log(chalk.bold('\n 最近日志(最后 20 行):'));
|
|
63
|
+
const logs = await runRemoteSilent(ssh, `pm2 logs ${config.appName} --lines 20 --nostream 2>&1 | tail -20`);
|
|
64
|
+
if (logs.stdout) {
|
|
65
|
+
logs.stdout.split('\n').forEach(line => {
|
|
66
|
+
if (line.includes('error') || line.includes('Error')) {
|
|
67
|
+
console.log(chalk.red(' ' + line));
|
|
68
|
+
} else {
|
|
69
|
+
console.log(chalk.gray(' ' + line));
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
} else if (config.projectType === 'python') {
|
|
75
|
+
const supStatus = await runRemoteSilent(ssh, `supervisorctl status ${config.appName}`);
|
|
76
|
+
console.log(chalk.gray(' ') + supStatus.stdout);
|
|
77
|
+
|
|
78
|
+
} else if (config.projectType === 'docker') {
|
|
79
|
+
const dockerStatus = await runRemoteSilent(ssh, `cd ${config.remotePath} && docker compose ps`);
|
|
80
|
+
console.log(chalk.gray(dockerStatus.stdout));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Nginx 状态
|
|
84
|
+
console.log(chalk.bold('\n Nginx 状态:'));
|
|
85
|
+
const nginxStatus = await runRemoteSilent(ssh, `systemctl is-active nginx`);
|
|
86
|
+
const isActive = nginxStatus.stdout.trim() === 'active';
|
|
87
|
+
console.log(` ${isActive ? chalk.green('● 运行中') : chalk.red('✗ 未运行')}`);
|
|
88
|
+
|
|
89
|
+
// 访问地址
|
|
90
|
+
const accessUrl = config.useHttps && config.domain
|
|
91
|
+
? `https://${config.domain}`
|
|
92
|
+
: config.domain ? `http://${config.domain}` : `http://${config.host}`;
|
|
93
|
+
console.log(chalk.bold('\n 访问地址:') + chalk.cyan.underline(accessUrl));
|
|
94
|
+
|
|
95
|
+
console.log(chalk.gray(`\n 上次部署:${new Date(config.deployedAt).toLocaleString('zh-CN')}\n`));
|
|
96
|
+
|
|
97
|
+
ssh.dispose();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatUptime(timestamp) {
|
|
101
|
+
const ms = Date.now() - timestamp;
|
|
102
|
+
const h = Math.floor(ms / 3600000);
|
|
103
|
+
const m = Math.floor((ms % 3600000) / 60000);
|
|
104
|
+
return `${h}小时 ${m}分钟`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { connectSSH, runRemoteSilent, uploadDirectory } from '../utils/ssh.js';
|
|
5
|
+
import { loadConfig, saveConfig } from '../utils/config.js';
|
|
6
|
+
import { createSnapshot } from './rollback.js';
|
|
7
|
+
import { doBackup } from './backup.js';
|
|
8
|
+
|
|
9
|
+
const info = (msg) => console.log(chalk.gray(' ℹ ') + msg);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 对单台服务器执行部署流程
|
|
13
|
+
*/
|
|
14
|
+
async function deployToServer(serverConfig) {
|
|
15
|
+
const label = serverConfig.label ? chalk.cyan(`[${serverConfig.label}] `) : '';
|
|
16
|
+
|
|
17
|
+
let ssh;
|
|
18
|
+
const connectSpinner = ora(`${label}连接服务器 ${serverConfig.host}...`).start();
|
|
19
|
+
try {
|
|
20
|
+
ssh = await connectSSH(serverConfig);
|
|
21
|
+
connectSpinner.succeed(`${label}连接成功`);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
connectSpinner.fail(`${label}连接失败:${err.message}`);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// 1. 创建快照(部署前备份当前版本)
|
|
29
|
+
const snapSpinner = ora(`${label}创建版本快照...`).start();
|
|
30
|
+
const snapName = await createSnapshot(ssh, serverConfig);
|
|
31
|
+
if (snapName) {
|
|
32
|
+
snapSpinner.succeed(`${label}快照已创建:${chalk.gray(snapName)}`);
|
|
33
|
+
} else {
|
|
34
|
+
snapSpinner.info(`${label}跳过快照(首次部署)`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. 上传代码
|
|
38
|
+
const uploadSpinner = ora(`${label}上传代码...`).start();
|
|
39
|
+
await uploadDirectory(ssh, process.cwd(), serverConfig.remotePath);
|
|
40
|
+
uploadSpinner.succeed(`${label}代码上传完成`);
|
|
41
|
+
|
|
42
|
+
// 3. 安装依赖 & 重启
|
|
43
|
+
if (serverConfig.projectType === 'nodejs') {
|
|
44
|
+
const sp = ora(`${label}安装依赖 & 重启...`).start();
|
|
45
|
+
await runRemoteSilent(ssh, `cd ${serverConfig.remotePath} && npm install --production --silent`);
|
|
46
|
+
await runRemoteSilent(ssh, `pm2 restart ${serverConfig.appName} || pm2 start ${serverConfig.startCmd} --name ${serverConfig.appName}`);
|
|
47
|
+
sp.succeed(`${label}Node.js 服务已重启`);
|
|
48
|
+
|
|
49
|
+
} else if (serverConfig.projectType === 'python') {
|
|
50
|
+
const sp = ora(`${label}更新依赖 & 重启...`).start();
|
|
51
|
+
await runRemoteSilent(ssh, `cd ${serverConfig.remotePath} && pip3 install -r requirements.txt -q`);
|
|
52
|
+
await runRemoteSilent(ssh, `supervisorctl restart ${serverConfig.appName}`);
|
|
53
|
+
sp.succeed(`${label}Python 服务已重启`);
|
|
54
|
+
|
|
55
|
+
} else if (serverConfig.projectType === 'docker') {
|
|
56
|
+
const sp = ora(`${label}重新构建容器...`).start();
|
|
57
|
+
await runRemoteSilent(ssh, `cd ${serverConfig.remotePath} && docker compose up -d --build`);
|
|
58
|
+
sp.succeed(`${label}容器已更新`);
|
|
59
|
+
|
|
60
|
+
} else if (serverConfig.projectType === 'static') {
|
|
61
|
+
const sp = ora(`${label}刷新文件权限...`).start();
|
|
62
|
+
await runRemoteSilent(ssh, `chown -R www-data:www-data ${serverConfig.remotePath}`);
|
|
63
|
+
sp.succeed(`${label}静态文件已更新`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ssh.dispose();
|
|
67
|
+
return true;
|
|
68
|
+
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.log(chalk.red(`\n${label}部署失败:${err.message}`));
|
|
71
|
+
console.log(chalk.gray(` 运行 deploy-helper rollback 可恢复上一个版本`));
|
|
72
|
+
ssh.dispose();
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deployUpdate() {
|
|
78
|
+
const config = loadConfig();
|
|
79
|
+
if (!config) {
|
|
80
|
+
console.log(chalk.red('\n没有找到部署配置,请先运行:') + chalk.cyan(' deploy-helper init\n'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 兼容单台和多台服务器配置
|
|
85
|
+
const servers = config.servers
|
|
86
|
+
? config.servers
|
|
87
|
+
: [{ ...config, label: null }];
|
|
88
|
+
|
|
89
|
+
// 显示目标信息
|
|
90
|
+
console.log('');
|
|
91
|
+
if (servers.length === 1) {
|
|
92
|
+
info(`服务器:${servers[0].host} 应用:${config.appName}`);
|
|
93
|
+
} else {
|
|
94
|
+
info(`将部署到 ${chalk.bold(servers.length)} 台服务器:`);
|
|
95
|
+
servers.forEach(s => {
|
|
96
|
+
console.log(chalk.gray(` • ${s.label || s.host} (${s.host})`));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 询问部署选项
|
|
101
|
+
const promptList = [
|
|
102
|
+
{
|
|
103
|
+
type: 'confirm',
|
|
104
|
+
name: 'confirm',
|
|
105
|
+
message: '确认推送当前代码到服务器?',
|
|
106
|
+
default: true,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
type: 'confirm',
|
|
110
|
+
name: 'backupDb',
|
|
111
|
+
message: '部署前备份数据库?',
|
|
112
|
+
default: true,
|
|
113
|
+
when: (a) => a.confirm && !!config.database,
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
if (servers.length > 1) {
|
|
118
|
+
promptList.push({
|
|
119
|
+
type: 'list',
|
|
120
|
+
name: 'strategy',
|
|
121
|
+
message: '多服务器部署策略:',
|
|
122
|
+
choices: [
|
|
123
|
+
{ name: '并行(同时部署所有服务器,速度最快)', value: 'parallel' },
|
|
124
|
+
{ name: '串行(逐台部署,出错可停止)', value: 'serial' },
|
|
125
|
+
{ name: '滚动(每台完成后确认再继续下一台)', value: 'rolling' },
|
|
126
|
+
],
|
|
127
|
+
when: (a) => a.confirm,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const options = await inquirer.prompt(promptList);
|
|
132
|
+
if (!options.confirm) return;
|
|
133
|
+
|
|
134
|
+
console.log('');
|
|
135
|
+
|
|
136
|
+
// 部署前数据库备份
|
|
137
|
+
if (options.backupDb && config.database) {
|
|
138
|
+
console.log(chalk.bold('📦 部署前数据库备份\n'));
|
|
139
|
+
await doBackup(config, true);
|
|
140
|
+
console.log('');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 执行部署
|
|
144
|
+
console.log(chalk.bold('🚀 开始部署\n'));
|
|
145
|
+
const strategy = options.strategy || 'serial';
|
|
146
|
+
const results = [];
|
|
147
|
+
|
|
148
|
+
if (servers.length === 1 || strategy === 'parallel') {
|
|
149
|
+
const outcomes = await Promise.all(servers.map(s => deployToServer(s)));
|
|
150
|
+
results.push(...outcomes);
|
|
151
|
+
|
|
152
|
+
} else if (strategy === 'serial') {
|
|
153
|
+
for (const server of servers) {
|
|
154
|
+
const ok = await deployToServer(server);
|
|
155
|
+
results.push(ok);
|
|
156
|
+
if (!ok) {
|
|
157
|
+
const { continueAnyway } = await inquirer.prompt([{
|
|
158
|
+
type: 'confirm',
|
|
159
|
+
name: 'continueAnyway',
|
|
160
|
+
message: chalk.yellow(`${server.label || server.host} 失败,继续其余服务器?`),
|
|
161
|
+
default: false,
|
|
162
|
+
}]);
|
|
163
|
+
if (!continueAnyway) break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
} else if (strategy === 'rolling') {
|
|
168
|
+
for (let i = 0; i < servers.length; i++) {
|
|
169
|
+
const ok = await deployToServer(servers[i]);
|
|
170
|
+
results.push(ok);
|
|
171
|
+
if (ok && i < servers.length - 1) {
|
|
172
|
+
const { goNext } = await inquirer.prompt([{
|
|
173
|
+
type: 'confirm',
|
|
174
|
+
name: 'goNext',
|
|
175
|
+
message: `继续下一台 ${servers[i + 1].label || servers[i + 1].host}?`,
|
|
176
|
+
default: true,
|
|
177
|
+
}]);
|
|
178
|
+
if (!goNext) break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 汇总
|
|
184
|
+
const successCount = results.filter(Boolean).length;
|
|
185
|
+
const failCount = results.length - successCount;
|
|
186
|
+
|
|
187
|
+
console.log('');
|
|
188
|
+
if (failCount === 0) {
|
|
189
|
+
console.log(chalk.green.bold(`✅ 全部 ${successCount} 台服务器部署成功!`));
|
|
190
|
+
} else {
|
|
191
|
+
console.log(chalk.yellow.bold(`⚠ ${successCount} 台成功,${failCount} 台失败`));
|
|
192
|
+
console.log(chalk.gray(' 运行 deploy-helper rollback 可回滚'));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const main = servers[0];
|
|
196
|
+
const url = main.useHttps && main.domain
|
|
197
|
+
? `https://${main.domain}`
|
|
198
|
+
: main.domain ? `http://${main.domain}` : `http://${main.host}`;
|
|
199
|
+
console.log(` 访问地址:${chalk.cyan.underline(url)}\n`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 多服务器配置管理命令
|
|
204
|
+
*/
|
|
205
|
+
export async function manageServers() {
|
|
206
|
+
const config = loadConfig();
|
|
207
|
+
if (!config) {
|
|
208
|
+
console.log(chalk.red('\n没有找到部署配置,请先运行:') + chalk.cyan(' deploy-helper init\n'));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const servers = config.servers || [{ ...config, label: '主服务器' }];
|
|
213
|
+
|
|
214
|
+
const { action } = await inquirer.prompt([{
|
|
215
|
+
type: 'list',
|
|
216
|
+
name: 'action',
|
|
217
|
+
message: '服务器管理:',
|
|
218
|
+
choices: [
|
|
219
|
+
{ name: `查看列表(当前 ${servers.length} 台)`, value: 'list' },
|
|
220
|
+
{ name: '添加服务器', value: 'add' },
|
|
221
|
+
{ name: '删除服务器', value: 'remove' },
|
|
222
|
+
],
|
|
223
|
+
}]);
|
|
224
|
+
|
|
225
|
+
if (action === 'list') {
|
|
226
|
+
console.log(chalk.bold(`\n 服务器列表(${servers.length} 台):\n`));
|
|
227
|
+
servers.forEach((s, i) => {
|
|
228
|
+
console.log(` ${chalk.cyan(i + 1 + '.')} ${chalk.bold(s.label || s.host)}`);
|
|
229
|
+
console.log(chalk.gray(` ${s.user}@${s.host}:${s.port || 22} → ${s.remotePath}`));
|
|
230
|
+
});
|
|
231
|
+
console.log('');
|
|
232
|
+
|
|
233
|
+
} else if (action === 'add') {
|
|
234
|
+
const newServer = await inquirer.prompt([
|
|
235
|
+
{ type: 'input', name: 'label', message: '服务器别名(如"备用节点"):' },
|
|
236
|
+
{ type: 'input', name: 'host', message: 'IP 地址:', validate: v => v.trim() ? true : '必填' },
|
|
237
|
+
{ type: 'input', name: 'port', message: 'SSH 端口:', default: '22' },
|
|
238
|
+
{ type: 'input', name: 'user', message: '用户名:', default: config.user || 'root' },
|
|
239
|
+
{
|
|
240
|
+
type: 'list', name: 'authType', message: '登录方式:',
|
|
241
|
+
choices: [{ name: 'SSH 密钥', value: 'key' }, { name: '密码', value: 'password' }],
|
|
242
|
+
default: config.authType,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: 'input', name: 'keyPath', message: 'SSH 密钥路径:',
|
|
246
|
+
default: config.keyPath, when: a => a.authType === 'key',
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
type: 'password', name: 'password', message: '密码:',
|
|
250
|
+
mask: '*', when: a => a.authType === 'password',
|
|
251
|
+
},
|
|
252
|
+
{ type: 'input', name: 'remotePath', message: '部署路径:', default: config.remotePath },
|
|
253
|
+
]);
|
|
254
|
+
|
|
255
|
+
const sp = ora('测试连接...').start();
|
|
256
|
+
try {
|
|
257
|
+
const ssh = await connectSSH({ ...config, ...newServer });
|
|
258
|
+
ssh.dispose();
|
|
259
|
+
sp.succeed('连接测试成功');
|
|
260
|
+
} catch (err) {
|
|
261
|
+
sp.fail('连接测试失败:' + err.message);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const updatedServers = [...servers, { ...config, ...newServer }];
|
|
266
|
+
saveConfig({ ...config, servers: updatedServers });
|
|
267
|
+
console.log(chalk.green(`\n✅ 服务器 "${newServer.label || newServer.host}" 已添加。\n`));
|
|
268
|
+
|
|
269
|
+
} else if (action === 'remove') {
|
|
270
|
+
if (servers.length === 1) {
|
|
271
|
+
console.log(chalk.yellow('\n只剩一台服务器,无法删除。\n'));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const { toRemove } = await inquirer.prompt([{
|
|
275
|
+
type: 'checkbox',
|
|
276
|
+
name: 'toRemove',
|
|
277
|
+
message: '选择要删除的服务器:',
|
|
278
|
+
choices: servers.map((s, i) => ({ name: `${s.label || s.host} (${s.host})`, value: i })),
|
|
279
|
+
}]);
|
|
280
|
+
const remaining = servers.filter((_, i) => !toRemove.includes(i));
|
|
281
|
+
saveConfig({ ...config, servers: remaining });
|
|
282
|
+
console.log(chalk.green(`\n✅ 已删除 ${toRemove.length} 台服务器。\n`));
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { deployInit } from './commands/init.js';
|
|
5
|
+
import { deployUpdate, manageServers } from './commands/update.js';
|
|
6
|
+
import { deployStatus } from './commands/status.js';
|
|
7
|
+
import { deployRollback } from './commands/rollback.js';
|
|
8
|
+
import { deployEnv } from './commands/env.js';
|
|
9
|
+
import { deployBackup } from './commands/backup.js';
|
|
10
|
+
|
|
11
|
+
console.log(chalk.cyan.bold('\n🚀 deploy-helper') + chalk.gray(' v0.2.0 — 把项目部署到服务器,就这么简单\n'));
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('deploy-helper')
|
|
15
|
+
.description('交互式部署工具,帮你把项目从本地跑到任意 VPS 服务器上')
|
|
16
|
+
.version('0.2.0');
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.command('init')
|
|
20
|
+
.description('首次部署:引导你完成全套配置')
|
|
21
|
+
.action(deployInit);
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.command('update')
|
|
25
|
+
.description('更新部署:推送最新代码并重启(支持多服务器)')
|
|
26
|
+
.action(deployUpdate);
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.command('status')
|
|
30
|
+
.description('查看服务运行状态、内存占用、最近日志')
|
|
31
|
+
.action(deployStatus);
|
|
32
|
+
|
|
33
|
+
program
|
|
34
|
+
.command('rollback')
|
|
35
|
+
.description('版本回滚:从历史快照中选择一个版本还原')
|
|
36
|
+
.action(deployRollback);
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command('env')
|
|
40
|
+
.description('环境变量管理:上传/下载/对比 .env 文件')
|
|
41
|
+
.action(deployEnv);
|
|
42
|
+
|
|
43
|
+
program
|
|
44
|
+
.command('backup')
|
|
45
|
+
.description('数据库备份:MySQL / PostgreSQL / MongoDB,支持定时任务')
|
|
46
|
+
.action(deployBackup);
|
|
47
|
+
|
|
48
|
+
program
|
|
49
|
+
.command('servers')
|
|
50
|
+
.description('服务器管理:查看、添加、删除多台服务器')
|
|
51
|
+
.action(manageServers);
|
|
52
|
+
|
|
53
|
+
// 无命令时显示帮助 + 进入 init
|
|
54
|
+
if (process.argv.length === 2) {
|
|
55
|
+
console.log(chalk.bold('可用命令:\n'));
|
|
56
|
+
console.log(` ${chalk.cyan('init')} 首次部署,全程引导`);
|
|
57
|
+
console.log(` ${chalk.cyan('update')} 推送新代码到服务器(支持多台)`);
|
|
58
|
+
console.log(` ${chalk.cyan('rollback')} 回滚到历史版本`);
|
|
59
|
+
console.log(` ${chalk.cyan('env')} 管理 .env 环境变量`);
|
|
60
|
+
console.log(` ${chalk.cyan('backup')} 数据库备份`);
|
|
61
|
+
console.log(` ${chalk.cyan('servers')} 管理多台服务器`);
|
|
62
|
+
console.log(` ${chalk.cyan('status')} 查看运行状态\n`);
|
|
63
|
+
console.log(chalk.gray('首次使用?运行:') + chalk.cyan(' deploy-helper init\n'));
|
|
64
|
+
} else {
|
|
65
|
+
program.parse();
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.deploy-helper');
|
|
6
|
+
const CONFIG_FILE = path.join(process.cwd(), '.deploy-config.json');
|
|
7
|
+
|
|
8
|
+
export function saveConfig(config) {
|
|
9
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function loadConfig() {
|
|
13
|
+
if (!fs.existsSync(CONFIG_FILE)) return null;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configExists() {
|
|
22
|
+
return fs.existsSync(CONFIG_FILE);
|
|
23
|
+
}
|