@zhengyizhao/deploy-helper 0.1.0 → 0.2.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.
- package/PUBLISHING.md +86 -0
- package/README.md +506 -59
- package/package.json +7 -1
- package/src/commands/backup.js +55 -16
- package/src/commands/env.js +25 -16
- package/src/commands/init.js +444 -67
- package/src/commands/rollback.js +82 -25
- package/src/commands/status.js +149 -47
- package/src/commands/update.js +72 -37
- package/src/utils/config.js +25 -1
- package/src/utils/detect.js +144 -12
- package/src/utils/setup.js +379 -31
- package/src/utils/ssh.js +32 -7
package/src/commands/rollback.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
|
-
import { connectSSH, runRemoteSilent } from '../utils/ssh.js';
|
|
5
|
-
import { loadConfig
|
|
4
|
+
import { connectSSH, runRemoteSilent, runRemoteStrict } from '../utils/ssh.js';
|
|
5
|
+
import { loadConfig } from '../utils/config.js';
|
|
6
|
+
import { getStartCommands, getStopCommand, getHealthCheck } from '../utils/setup.js';
|
|
6
7
|
|
|
7
8
|
const SNAPSHOTS_DIR = '/var/deploy-helper/snapshots';
|
|
8
9
|
|
|
@@ -20,7 +21,11 @@ export async function createSnapshot(ssh, config) {
|
|
|
20
21
|
// 如果当前部署目录存在,就复制一份作为快照
|
|
21
22
|
const exists = await runRemoteSilent(ssh, `test -d ${config.remotePath} && echo yes || echo no`);
|
|
22
23
|
if (exists.stdout.trim() === 'yes') {
|
|
23
|
-
|
|
24
|
+
// 排除大目录(venv / node_modules)节省空间
|
|
25
|
+
await runRemoteSilent(
|
|
26
|
+
ssh,
|
|
27
|
+
`rsync -a --exclude=venv --exclude=node_modules --exclude=__pycache__ ${config.remotePath}/ ${snapshotPath}/`
|
|
28
|
+
);
|
|
24
29
|
|
|
25
30
|
// 记录快照元信息
|
|
26
31
|
const meta = JSON.stringify({
|
|
@@ -29,7 +34,8 @@ export async function createSnapshot(ssh, config) {
|
|
|
29
34
|
appName: config.appName,
|
|
30
35
|
remotePath: config.remotePath,
|
|
31
36
|
});
|
|
32
|
-
|
|
37
|
+
// 用 printf 写入小型元数据 OK(meta 是 JSON,无 % 字符)
|
|
38
|
+
await runRemoteSilent(ssh, `cat > ${snapshotPath}/.snapshot-meta.json <<'DH_EOF'\n${meta}\nDH_EOF`);
|
|
33
39
|
|
|
34
40
|
// 只保留最近 5 个快照
|
|
35
41
|
await runRemoteSilent(
|
|
@@ -68,6 +74,46 @@ async function listSnapshots(ssh, config) {
|
|
|
68
74
|
return snapshots;
|
|
69
75
|
}
|
|
70
76
|
|
|
77
|
+
// 根据 config 重启服务(appMode/conda/composeFile 一致)
|
|
78
|
+
async function restartService(ssh, config) {
|
|
79
|
+
if (config.appMode === 'cron') {
|
|
80
|
+
// cron 不需要"重启进程",crontab 条目仍在;下次定时使用新代码即可
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (config.projectType === 'nodejs') {
|
|
85
|
+
// 复用 init 启动逻辑,确保 .start.sh / pm2 配置与新代码匹配
|
|
86
|
+
const steps = getStartCommands(config);
|
|
87
|
+
for (const s of steps) {
|
|
88
|
+
await runRemoteStrict(ssh, s.cmd);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (config.projectType === 'python') {
|
|
94
|
+
// 已有 supervisor 配置和 venv/conda 环境,直接重启进程即可
|
|
95
|
+
await runRemoteStrict(ssh, `supervisorctl restart ${config.appName}`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (config.projectType === 'docker') {
|
|
100
|
+
if (config.composeFile) {
|
|
101
|
+
await runRemoteStrict(ssh, `cd ${config.remotePath} && docker compose -f ${config.composeFile} up -d --build`);
|
|
102
|
+
} else {
|
|
103
|
+
// 单容器:重新构建并启动
|
|
104
|
+
const steps = getStartCommands(config);
|
|
105
|
+
for (const s of steps) {
|
|
106
|
+
await runRemoteStrict(ssh, s.cmd);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (config.projectType === 'static') {
|
|
113
|
+
await runRemoteStrict(ssh, `chown -R www-data:www-data ${config.remotePath}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
71
117
|
/**
|
|
72
118
|
* rollback 命令主体
|
|
73
119
|
*/
|
|
@@ -143,36 +189,47 @@ export async function deployRollback() {
|
|
|
143
189
|
await createSnapshot(ssh, config);
|
|
144
190
|
preRollbackSpinner.succeed('当前版本已备份');
|
|
145
191
|
|
|
146
|
-
//
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
} else if (config.projectType === 'docker') {
|
|
153
|
-
await runRemoteSilent(ssh, `cd ${config.remotePath} && docker compose down 2>/dev/null || true`);
|
|
192
|
+
// 停止服务(cron 模式跳过)
|
|
193
|
+
const stopCmd = getStopCommand(config);
|
|
194
|
+
if (stopCmd) {
|
|
195
|
+
const stopSpinner = ora('停止当前服务...').start();
|
|
196
|
+
await runRemoteSilent(ssh, stopCmd);
|
|
197
|
+
stopSpinner.succeed('服务已停止');
|
|
154
198
|
}
|
|
155
|
-
stopSpinner.succeed('服务已停止');
|
|
156
199
|
|
|
157
|
-
//
|
|
200
|
+
// 替换代码目录(保留 venv —— 快照排除了它,避免误删环境)
|
|
158
201
|
const restoreSpinner = ora(`还原版本 ${selectedSnapshot}...`).start();
|
|
159
|
-
|
|
160
|
-
await runRemoteSilent(
|
|
202
|
+
// 保留 venv / node_modules,只覆盖代码部分
|
|
203
|
+
await runRemoteSilent(
|
|
204
|
+
ssh,
|
|
205
|
+
`rsync -a --delete --exclude=venv --exclude=node_modules ${SNAPSHOTS_DIR}/${selectedSnapshot}/ ${config.remotePath}/`
|
|
206
|
+
);
|
|
161
207
|
await runRemoteSilent(ssh, `rm -f ${config.remotePath}/.snapshot-meta.json`);
|
|
162
208
|
restoreSpinner.succeed('版本已还原');
|
|
163
209
|
|
|
164
210
|
// 重启服务
|
|
165
211
|
const startSpinner = ora('重启服务...').start();
|
|
166
|
-
|
|
167
|
-
await
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
212
|
+
try {
|
|
213
|
+
await restartService(ssh, config);
|
|
214
|
+
startSpinner.succeed('服务已重启');
|
|
215
|
+
} catch (err) {
|
|
216
|
+
startSpinner.fail('重启失败:' + err.message);
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 健康检查
|
|
221
|
+
const health = getHealthCheck(config);
|
|
222
|
+
if (health) {
|
|
223
|
+
const hSpinner = ora('验证服务运行状态...').start();
|
|
224
|
+
await runRemoteSilent(ssh, 'sleep 2');
|
|
225
|
+
const result = await runRemoteSilent(ssh, health.cmd);
|
|
226
|
+
const parsed = health.parse(result);
|
|
227
|
+
if (parsed.ok) {
|
|
228
|
+
hSpinner.succeed(`服务正常 — ${chalk.gray(parsed.detail)}`);
|
|
229
|
+
} else {
|
|
230
|
+
hSpinner.warn(`健康检查未通过 — ${chalk.yellow(parsed.detail)}`);
|
|
231
|
+
}
|
|
174
232
|
}
|
|
175
|
-
startSpinner.succeed('服务已重启');
|
|
176
233
|
|
|
177
234
|
ssh.dispose();
|
|
178
235
|
console.log(chalk.green.bold('\n✅ 回滚成功!'));
|
package/src/commands/status.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
|
-
import inquirer from 'inquirer';
|
|
4
3
|
import { connectSSH, runRemoteSilent } from '../utils/ssh.js';
|
|
5
4
|
import { loadConfig } from '../utils/config.js';
|
|
6
5
|
|
|
@@ -38,63 +37,166 @@ export async function deployStatus() {
|
|
|
38
37
|
// 应用状态
|
|
39
38
|
console.log(chalk.bold('\n 应用状态:'));
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
40
|
+
const appMode = config.appMode || 'web';
|
|
41
|
+
|
|
42
|
+
// cron 模式:单独处理(不走 PM2 / supervisor / docker 分支)
|
|
43
|
+
if (appMode === 'cron') {
|
|
44
|
+
await showCronStatus(ssh, config);
|
|
45
|
+
} else if (config.projectType === 'nodejs') {
|
|
46
|
+
await showNodeStatus(ssh, config);
|
|
47
|
+
} else if (config.projectType === 'python') {
|
|
48
|
+
await showPythonStatus(ssh, config);
|
|
49
|
+
} else if (config.projectType === 'docker') {
|
|
50
|
+
await showDockerStatus(ssh, config);
|
|
51
|
+
} else if (config.projectType === 'static') {
|
|
52
|
+
console.log(chalk.gray(' 静态站点,无独立进程(由 Nginx 直接托管)'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Nginx 状态(仅 web 服务需要)
|
|
56
|
+
if (appMode === 'web') {
|
|
57
|
+
console.log(chalk.bold('\n Nginx 状态:'));
|
|
58
|
+
const nginxStatus = await runRemoteSilent(ssh, `systemctl is-active nginx`);
|
|
59
|
+
const isActive = nginxStatus.stdout.trim() === 'active';
|
|
60
|
+
console.log(` ${isActive ? chalk.green('● 运行中') : chalk.red('✗ 未运行')}`);
|
|
61
|
+
|
|
62
|
+
// 访问地址
|
|
63
|
+
const accessUrl = config.useHttps && config.domain
|
|
64
|
+
? `https://${config.domain}`
|
|
65
|
+
: config.domain ? `http://${config.domain}` : `http://${config.host}`;
|
|
66
|
+
console.log(chalk.bold('\n 访问地址:') + chalk.cyan.underline(accessUrl));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (config.deployedAt) {
|
|
70
|
+
console.log(chalk.gray(`\n 上次部署:${new Date(config.deployedAt).toLocaleString('zh-CN')}\n`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ssh.dispose();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function showNodeStatus(ssh, config) {
|
|
77
|
+
const pm2Status = await runRemoteSilent(ssh, `pm2 jlist`);
|
|
78
|
+
try {
|
|
79
|
+
const list = JSON.parse(pm2Status.stdout || '[]');
|
|
80
|
+
const app = list.find(p => p.name === config.appName);
|
|
81
|
+
if (app) {
|
|
82
|
+
const statusColor = app.pm2_env.status === 'online' ? chalk.green : chalk.red;
|
|
83
|
+
console.log(` ${statusColor('●')} ${config.appName}`);
|
|
84
|
+
console.log(chalk.gray(` 状态:${statusColor(app.pm2_env.status)}`));
|
|
85
|
+
console.log(chalk.gray(` PID:${app.pid}`));
|
|
86
|
+
console.log(chalk.gray(` 重启次数:${app.pm2_env.restart_time}`));
|
|
87
|
+
console.log(chalk.gray(` 运行时间:${formatUptime(app.pm2_env.pm_uptime)}`));
|
|
88
|
+
console.log(chalk.gray(` CPU:${app.monit?.cpu ?? 0}% 内存:${Math.round((app.monit?.memory ?? 0) / 1024 / 1024)}MB`));
|
|
89
|
+
} else {
|
|
90
|
+
console.log(chalk.red(` ✗ 未找到 ${config.appName},服务可能没有运行`));
|
|
59
91
|
}
|
|
92
|
+
} catch {
|
|
93
|
+
console.log(chalk.yellow(' 无法解析 PM2 状态'));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 最近日志
|
|
97
|
+
console.log(chalk.bold('\n 最近日志(最后 20 行):'));
|
|
98
|
+
const logs = await runRemoteSilent(ssh, `pm2 logs ${config.appName} --lines 20 --nostream 2>&1 | tail -20`);
|
|
99
|
+
printLogLines(logs.stdout);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function showPythonStatus(ssh, config) {
|
|
103
|
+
const supStatus = await runRemoteSilent(ssh, `supervisorctl status ${config.appName} 2>&1`);
|
|
104
|
+
const line = supStatus.stdout.trim();
|
|
105
|
+
if (/RUNNING/.test(line)) {
|
|
106
|
+
console.log(` ${chalk.green('●')} ${line}`);
|
|
107
|
+
} else if (/STOPPED|FATAL|EXITED|BACKOFF/.test(line)) {
|
|
108
|
+
console.log(` ${chalk.red('✗')} ${line}`);
|
|
109
|
+
} else {
|
|
110
|
+
console.log(` ${chalk.yellow('?')} ${line || '未知状态'}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 最近日志
|
|
114
|
+
console.log(chalk.bold('\n 输出日志(最后 20 行):'));
|
|
115
|
+
const out = await runRemoteSilent(ssh, `tail -20 /var/log/${config.appName}.out.log 2>/dev/null || echo ""`);
|
|
116
|
+
printLogLines(out.stdout);
|
|
117
|
+
|
|
118
|
+
console.log(chalk.bold('\n 错误日志(最后 10 行):'));
|
|
119
|
+
const err = await runRemoteSilent(ssh, `tail -10 /var/log/${config.appName}.err.log 2>/dev/null || echo ""`);
|
|
120
|
+
printLogLines(err.stdout || '(无错误日志)');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function showDockerStatus(ssh, config) {
|
|
124
|
+
if (config.composeFile) {
|
|
125
|
+
const dockerStatus = await runRemoteSilent(
|
|
126
|
+
ssh,
|
|
127
|
+
`cd ${config.remotePath} && docker compose -f ${config.composeFile} ps`
|
|
128
|
+
);
|
|
129
|
+
console.log(chalk.gray(' ') + (dockerStatus.stdout || '(无运行容器)').split('\n').join('\n '));
|
|
60
130
|
|
|
61
131
|
// 最近日志
|
|
62
132
|
console.log(chalk.bold('\n 最近日志(最后 20 行):'));
|
|
63
|
-
const logs = await runRemoteSilent(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
133
|
+
const logs = await runRemoteSilent(
|
|
134
|
+
ssh,
|
|
135
|
+
`cd ${config.remotePath} && docker compose -f ${config.composeFile} logs --tail=20 2>&1`
|
|
136
|
+
);
|
|
137
|
+
printLogLines(logs.stdout);
|
|
138
|
+
} else {
|
|
139
|
+
// 单容器模式
|
|
140
|
+
const result = await runRemoteSilent(
|
|
141
|
+
ssh,
|
|
142
|
+
`docker inspect -f '{{.State.Status}} | PID:{{.State.Pid}} | StartedAt:{{.State.StartedAt}}' ${config.appName} 2>/dev/null || echo missing`
|
|
143
|
+
);
|
|
144
|
+
const line = result.stdout.trim();
|
|
145
|
+
if (line === 'missing' || !line) {
|
|
146
|
+
console.log(chalk.red(` ✗ 未找到容器 ${config.appName}`));
|
|
147
|
+
} else if (line.startsWith('running')) {
|
|
148
|
+
console.log(` ${chalk.green('●')} ${config.appName}`);
|
|
149
|
+
console.log(chalk.gray(` ${line}`));
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` ${chalk.red('✗')} ${line}`);
|
|
72
152
|
}
|
|
73
153
|
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
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));
|
|
154
|
+
console.log(chalk.bold('\n 最近日志(最后 20 行):'));
|
|
155
|
+
const logs = await runRemoteSilent(ssh, `docker logs --tail=20 ${config.appName} 2>&1 || true`);
|
|
156
|
+
printLogLines(logs.stdout);
|
|
81
157
|
}
|
|
158
|
+
}
|
|
82
159
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
160
|
+
async function showCronStatus(ssh, config) {
|
|
161
|
+
const marker = `deploy-helper:${config.appName}`;
|
|
162
|
+
const cronResult = await runRemoteSilent(ssh, `crontab -l 2>/dev/null | grep -A1 -F "${marker}" || true`);
|
|
163
|
+
|
|
164
|
+
if (!cronResult.stdout.trim()) {
|
|
165
|
+
console.log(chalk.red(` ✗ 未在 crontab 中找到 ${config.appName}`));
|
|
166
|
+
console.log(chalk.gray(` 可能未部署或已被手动删除`));
|
|
167
|
+
} else {
|
|
168
|
+
console.log(` ${chalk.green('●')} 定时任务已注册`);
|
|
169
|
+
cronResult.stdout.trim().split('\n').forEach(line => {
|
|
170
|
+
console.log(chalk.gray(` ${line}`));
|
|
171
|
+
});
|
|
172
|
+
}
|
|
88
173
|
|
|
89
|
-
//
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
174
|
+
// 上次执行时间(看日志文件 mtime)
|
|
175
|
+
const logInfo = await runRemoteSilent(
|
|
176
|
+
ssh,
|
|
177
|
+
`stat -c '%y' /var/log/${config.appName}.log 2>/dev/null || echo missing`
|
|
178
|
+
);
|
|
179
|
+
if (logInfo.stdout.trim() !== 'missing' && logInfo.stdout.trim()) {
|
|
180
|
+
console.log(chalk.gray(` 上次执行:${logInfo.stdout.trim()}`));
|
|
181
|
+
}
|
|
94
182
|
|
|
95
|
-
console.log(chalk.
|
|
183
|
+
console.log(chalk.bold('\n 最近日志(最后 30 行):'));
|
|
184
|
+
const logs = await runRemoteSilent(ssh, `tail -30 /var/log/${config.appName}.log 2>/dev/null || echo "(无日志)"`);
|
|
185
|
+
printLogLines(logs.stdout);
|
|
186
|
+
}
|
|
96
187
|
|
|
97
|
-
|
|
188
|
+
function printLogLines(stdout) {
|
|
189
|
+
if (!stdout || !stdout.trim()) {
|
|
190
|
+
console.log(chalk.gray(' (无)'));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
stdout.split('\n').forEach(line => {
|
|
194
|
+
if (/\berror\b/i.test(line) || /\bexception\b/i.test(line) || /\btraceback\b/i.test(line)) {
|
|
195
|
+
console.log(chalk.red(' ' + line));
|
|
196
|
+
} else {
|
|
197
|
+
console.log(chalk.gray(' ' + line));
|
|
198
|
+
}
|
|
199
|
+
});
|
|
98
200
|
}
|
|
99
201
|
|
|
100
202
|
function formatUptime(timestamp) {
|
package/src/commands/update.js
CHANGED
|
@@ -1,23 +1,29 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import inquirer from 'inquirer';
|
|
4
|
-
import { connectSSH, runRemoteSilent, uploadDirectory } from '../utils/ssh.js';
|
|
4
|
+
import { connectSSH, runRemoteSilent, runRemoteStrict, uploadDirectory } from '../utils/ssh.js';
|
|
5
5
|
import { loadConfig, saveConfig } from '../utils/config.js';
|
|
6
6
|
import { createSnapshot } from './rollback.js';
|
|
7
7
|
import { doBackup } from './backup.js';
|
|
8
|
-
|
|
9
|
-
const info = (msg) => console.log(chalk.gray(' ℹ ') + msg);
|
|
8
|
+
import { getStartCommands, getHealthCheck } from '../utils/setup.js';
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
11
|
* 对单台服务器执行部署流程
|
|
12
|
+
*
|
|
13
|
+
* 流程:连接 → 快照 → 上传代码 → 复用 init 的 getStartCommands → 健康检查
|
|
14
|
+
*
|
|
15
|
+
* 兼容性:旧版 config 没有 appMode 字段时,根据 projectType + domain 推断:
|
|
16
|
+
* - 有 domain 或 projectType=static → web
|
|
17
|
+
* - 否则 → script(保守起见)
|
|
13
18
|
*/
|
|
14
19
|
async function deployToServer(serverConfig) {
|
|
15
20
|
const label = serverConfig.label ? chalk.cyan(`[${serverConfig.label}] `) : '';
|
|
21
|
+
const cfg = normalizeConfig(serverConfig);
|
|
16
22
|
|
|
17
23
|
let ssh;
|
|
18
|
-
const connectSpinner = ora(`${label}连接服务器 ${
|
|
24
|
+
const connectSpinner = ora(`${label}连接服务器 ${cfg.host}...`).start();
|
|
19
25
|
try {
|
|
20
|
-
ssh = await connectSSH(
|
|
26
|
+
ssh = await connectSSH(cfg);
|
|
21
27
|
connectSpinner.succeed(`${label}连接成功`);
|
|
22
28
|
} catch (err) {
|
|
23
29
|
connectSpinner.fail(`${label}连接失败:${err.message}`);
|
|
@@ -27,40 +33,50 @@ async function deployToServer(serverConfig) {
|
|
|
27
33
|
try {
|
|
28
34
|
// 1. 创建快照(部署前备份当前版本)
|
|
29
35
|
const snapSpinner = ora(`${label}创建版本快照...`).start();
|
|
30
|
-
const snapName = await createSnapshot(ssh,
|
|
36
|
+
const snapName = await createSnapshot(ssh, cfg);
|
|
31
37
|
if (snapName) {
|
|
32
38
|
snapSpinner.succeed(`${label}快照已创建:${chalk.gray(snapName)}`);
|
|
33
39
|
} else {
|
|
34
40
|
snapSpinner.info(`${label}跳过快照(首次部署)`);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
|
-
// 2.
|
|
43
|
+
// 2. 上传代码(static 不跳 dist,并透传 uploadEnv)
|
|
38
44
|
const uploadSpinner = ora(`${label}上传代码...`).start();
|
|
39
|
-
|
|
45
|
+
const skipPatterns = cfg.projectType === 'static'
|
|
46
|
+
? ['node_modules', '.git', '__pycache__', '.DS_Store', '.venv', 'venv']
|
|
47
|
+
: undefined;
|
|
48
|
+
await uploadDirectory(ssh, process.cwd(), cfg.remotePath, {
|
|
49
|
+
uploadEnv: !!cfg.uploadEnv,
|
|
50
|
+
skipPatterns,
|
|
51
|
+
});
|
|
40
52
|
uploadSpinner.succeed(`${label}代码上传完成`);
|
|
41
53
|
|
|
42
|
-
// 3.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
// 3. 复用 init 的启动命令(保证 update 与 init 行为一致)
|
|
55
|
+
const startSteps = getStartCommands(cfg);
|
|
56
|
+
for (const s of startSteps) {
|
|
57
|
+
const sp = ora(`${label}${s.label}...`).start();
|
|
58
|
+
try {
|
|
59
|
+
await runRemoteStrict(ssh, s.cmd);
|
|
60
|
+
sp.succeed(`${label}${s.label}`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
sp.fail(`${label}${s.label} 失败`);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 4. 健康检查
|
|
68
|
+
const health = getHealthCheck(cfg);
|
|
69
|
+
if (health) {
|
|
70
|
+
const hSpinner = ora(`${label}验证服务运行状态...`).start();
|
|
71
|
+
await runRemoteSilent(ssh, 'sleep 2');
|
|
72
|
+
const result = await runRemoteSilent(ssh, health.cmd);
|
|
73
|
+
const parsed = health.parse(result);
|
|
74
|
+
if (parsed.ok) {
|
|
75
|
+
hSpinner.succeed(`${label}服务正常 — ${chalk.gray(parsed.detail)}`);
|
|
76
|
+
} else {
|
|
77
|
+
hSpinner.warn(`${label}健康检查未通过 — ${chalk.yellow(parsed.detail)}`);
|
|
78
|
+
console.log(chalk.gray(` ${label}如服务异常,可运行 deploy-helper rollback 回滚`));
|
|
79
|
+
}
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
ssh.dispose();
|
|
@@ -68,12 +84,25 @@ async function deployToServer(serverConfig) {
|
|
|
68
84
|
|
|
69
85
|
} catch (err) {
|
|
70
86
|
console.log(chalk.red(`\n${label}部署失败:${err.message}`));
|
|
71
|
-
console.log(chalk.gray(` 运行 deploy-helper rollback 可恢复上一个版本`));
|
|
87
|
+
console.log(chalk.gray(` 运行 ${chalk.cyan('deploy-helper rollback')} 可恢复上一个版本`));
|
|
72
88
|
ssh.dispose();
|
|
73
89
|
return false;
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
|
|
93
|
+
// 旧配置兼容:补齐 appMode 等新字段,方便老用户升级 deploy-helper 后 update
|
|
94
|
+
function normalizeConfig(serverConfig) {
|
|
95
|
+
const cfg = { ...serverConfig };
|
|
96
|
+
if (!cfg.appMode) {
|
|
97
|
+
// 老配置:有 domain / useHttps 或是 static 都视为 web
|
|
98
|
+
cfg.appMode = (cfg.domain || cfg.useHttps || cfg.projectType === 'static') ? 'web' : 'web';
|
|
99
|
+
}
|
|
100
|
+
if (cfg.projectType === 'python' && !cfg.pythonEnvManager) {
|
|
101
|
+
cfg.pythonEnvManager = 'pip';
|
|
102
|
+
}
|
|
103
|
+
return cfg;
|
|
104
|
+
}
|
|
105
|
+
|
|
77
106
|
export async function deployUpdate() {
|
|
78
107
|
const config = loadConfig();
|
|
79
108
|
if (!config) {
|
|
@@ -89,9 +118,9 @@ export async function deployUpdate() {
|
|
|
89
118
|
// 显示目标信息
|
|
90
119
|
console.log('');
|
|
91
120
|
if (servers.length === 1) {
|
|
92
|
-
|
|
121
|
+
console.log(chalk.gray(' ℹ ') + `服务器:${servers[0].host} 应用:${config.appName} 模式:${config.appMode || 'web'}`);
|
|
93
122
|
} else {
|
|
94
|
-
|
|
123
|
+
console.log(chalk.gray(' ℹ ') + `将部署到 ${chalk.bold(servers.length)} 台服务器:`);
|
|
95
124
|
servers.forEach(s => {
|
|
96
125
|
console.log(chalk.gray(` • ${s.label || s.host} (${s.host})`));
|
|
97
126
|
});
|
|
@@ -193,10 +222,16 @@ export async function deployUpdate() {
|
|
|
193
222
|
}
|
|
194
223
|
|
|
195
224
|
const main = servers[0];
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
225
|
+
if (main.appMode === 'web' || !main.appMode) {
|
|
226
|
+
const url = main.useHttps && main.domain
|
|
227
|
+
? `https://${main.domain}`
|
|
228
|
+
: main.domain ? `http://${main.domain}` : `http://${main.host}`;
|
|
229
|
+
console.log(` 访问地址:${chalk.cyan.underline(url)}\n`);
|
|
230
|
+
} else if (main.appMode === 'cron') {
|
|
231
|
+
console.log(` 定时计划:${chalk.cyan(main.cronSchedule || '见 crontab -l')}\n`);
|
|
232
|
+
} else {
|
|
233
|
+
console.log(` 进程状态:${chalk.cyan(`supervisorctl status ${main.appName}`)}\n`);
|
|
234
|
+
}
|
|
200
235
|
}
|
|
201
236
|
|
|
202
237
|
/**
|
package/src/utils/config.js
CHANGED
|
@@ -3,10 +3,12 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
|
|
5
5
|
const CONFIG_DIR = path.join(os.homedir(), '.deploy-helper');
|
|
6
|
-
const
|
|
6
|
+
const CONFIG_FILENAME = '.deploy-config.json';
|
|
7
|
+
const CONFIG_FILE = path.join(process.cwd(), CONFIG_FILENAME);
|
|
7
8
|
|
|
8
9
|
export function saveConfig(config) {
|
|
9
10
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
11
|
+
ensureGitignored();
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export function loadConfig() {
|
|
@@ -21,3 +23,25 @@ export function loadConfig() {
|
|
|
21
23
|
export function configExists() {
|
|
22
24
|
return fs.existsSync(CONFIG_FILE);
|
|
23
25
|
}
|
|
26
|
+
|
|
27
|
+
// 如果项目目录是 git 仓库且 .gitignore 还没忽略 .deploy-config.json,自动追加一行。
|
|
28
|
+
// 配置含密码/密钥,进 git 会泄密。
|
|
29
|
+
function ensureGitignored() {
|
|
30
|
+
const gitDir = path.join(process.cwd(), '.git');
|
|
31
|
+
if (!fs.existsSync(gitDir)) return; // 非 git 项目跳过
|
|
32
|
+
|
|
33
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
34
|
+
let content = '';
|
|
35
|
+
if (fs.existsSync(gitignorePath)) {
|
|
36
|
+
content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
37
|
+
const already = content.split('\n').some(line => {
|
|
38
|
+
const t = line.trim();
|
|
39
|
+
return t === CONFIG_FILENAME || t === `/${CONFIG_FILENAME}` || t === `./${CONFIG_FILENAME}`;
|
|
40
|
+
});
|
|
41
|
+
if (already) return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const prefix = content.length === 0 || content.endsWith('\n') ? '' : '\n';
|
|
45
|
+
const block = `${prefix}\n# deploy-helper:含服务器密码/密钥,请勿提交\n${CONFIG_FILENAME}\n`;
|
|
46
|
+
fs.appendFileSync(gitignorePath, block);
|
|
47
|
+
}
|