@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/utils/detect.js
CHANGED
|
@@ -6,14 +6,16 @@ export function detectProjectType(projectPath = process.cwd()) {
|
|
|
6
6
|
|
|
7
7
|
if (files.includes('package.json')) {
|
|
8
8
|
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf-8'));
|
|
9
|
-
// 判断是否是纯前端项目
|
|
10
9
|
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
11
|
-
if (deps['next'] || deps['nuxt']) return 'nodejs';
|
|
12
|
-
if (pkg.scripts?.build && !pkg.scripts?.start) return 'static';
|
|
10
|
+
if (deps['next'] || deps['nuxt']) return 'nodejs';
|
|
11
|
+
if (pkg.scripts?.build && !pkg.scripts?.start) return 'static';
|
|
13
12
|
return 'nodejs';
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
if (
|
|
15
|
+
if (
|
|
16
|
+
files.includes('requirements.txt') || files.includes('pyproject.toml') ||
|
|
17
|
+
files.includes('setup.py') || files.includes('environment.yml') || files.includes('conda-lock.yml')
|
|
18
|
+
) {
|
|
17
19
|
return 'python';
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -28,6 +30,109 @@ export function detectProjectType(projectPath = process.cwd()) {
|
|
|
28
30
|
return 'unknown';
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// 从 .nvmrc / .node-version / package.json engines.node 读取主版本号
|
|
34
|
+
export function detectNodeVersion(projectPath = process.cwd()) {
|
|
35
|
+
for (const file of ['.nvmrc', '.node-version']) {
|
|
36
|
+
const p = path.join(projectPath, file);
|
|
37
|
+
if (fs.existsSync(p)) {
|
|
38
|
+
const raw = fs.readFileSync(p, 'utf-8').trim().replace(/^v/, '');
|
|
39
|
+
const major = parseInt(raw);
|
|
40
|
+
if (!isNaN(major)) return { version: String(major), source: file };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
45
|
+
if (fs.existsSync(pkgPath)) {
|
|
46
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
47
|
+
if (pkg.engines?.node) {
|
|
48
|
+
const match = pkg.engines.node.match(/(\d+)/);
|
|
49
|
+
if (match) return { version: match[1], source: 'package.json engines.node' };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 检测 Python 依赖管理方式:conda(environment.yml)或 pip(requirements.txt)
|
|
57
|
+
export function detectPythonEnvManager(projectPath = process.cwd()) {
|
|
58
|
+
if (
|
|
59
|
+
fs.existsSync(path.join(projectPath, 'environment.yml')) ||
|
|
60
|
+
fs.existsSync(path.join(projectPath, 'conda-lock.yml'))
|
|
61
|
+
) return 'conda';
|
|
62
|
+
if (
|
|
63
|
+
fs.existsSync(path.join(projectPath, 'requirements.txt')) ||
|
|
64
|
+
fs.existsSync(path.join(projectPath, 'pyproject.toml'))
|
|
65
|
+
) return 'pip';
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 从 requirements.txt 检测 Python 框架
|
|
70
|
+
export function detectPythonFramework(projectPath = process.cwd()) {
|
|
71
|
+
const reqPath = path.join(projectPath, 'requirements.txt');
|
|
72
|
+
if (!fs.existsSync(reqPath)) return null;
|
|
73
|
+
|
|
74
|
+
const content = fs.readFileSync(reqPath, 'utf-8').toLowerCase();
|
|
75
|
+
// 按优先级检测,fastapi 优先(flask 是 fastapi 的间接依赖有时也会出现)
|
|
76
|
+
if (content.match(/^fastapi[>=\[<\s]/m) || content.includes('\nfastapi')) return 'fastapi';
|
|
77
|
+
if (content.match(/^django[>=\[<\s]/m) || content.includes('\ndjango')) return 'django';
|
|
78
|
+
if (content.match(/^flask[>=\[<\s]/m) || content.includes('\nflask')) return 'flask';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 从 .python-version 或 pyproject.toml 读取 Python 版本
|
|
83
|
+
export function detectPythonVersion(projectPath = process.cwd()) {
|
|
84
|
+
const pyVersionPath = path.join(projectPath, '.python-version');
|
|
85
|
+
if (fs.existsSync(pyVersionPath)) {
|
|
86
|
+
const raw = fs.readFileSync(pyVersionPath, 'utf-8').trim();
|
|
87
|
+
const match = raw.match(/^(\d+\.\d+)/);
|
|
88
|
+
if (match) return { version: match[1], source: '.python-version' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const pyprojectPath = path.join(projectPath, 'pyproject.toml');
|
|
92
|
+
if (fs.existsSync(pyprojectPath)) {
|
|
93
|
+
const content = fs.readFileSync(pyprojectPath, 'utf-8');
|
|
94
|
+
const match = content.match(/python\s*=\s*["'][^"']*?(\d+\.\d+)/);
|
|
95
|
+
if (match) return { version: match[1], source: 'pyproject.toml' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 读取 package.json scripts.start,或扫描常见入口文件
|
|
102
|
+
export function getNodeStartCommand(projectPath = process.cwd()) {
|
|
103
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
104
|
+
if (fs.existsSync(pkgPath)) {
|
|
105
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
106
|
+
|
|
107
|
+
if (pkg.scripts?.start) {
|
|
108
|
+
return { cmd: pkg.scripts.start, source: 'package.json scripts.start' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
112
|
+
if (deps['next']) return { cmd: 'next start', source: 'next.js 依赖' };
|
|
113
|
+
if (deps['nuxt']) return { cmd: 'nuxt start', source: 'nuxt.js 依赖' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const file of ['index.js', 'app.js', 'server.js', 'main.js']) {
|
|
117
|
+
if (fs.existsSync(path.join(projectPath, file))) {
|
|
118
|
+
return { cmd: `node ${file}`, source: `检测到入口文件 ${file}` };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { cmd: 'node index.js', source: '默认值' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 根据框架生成 Python 启动命令;framework 为 other 时扫描常见入口文件
|
|
126
|
+
export function getPythonStartCommand(framework, appName, port, projectPath = process.cwd()) {
|
|
127
|
+
if (framework === 'fastapi') return `uvicorn main:app --host 0.0.0.0 --port ${port}`;
|
|
128
|
+
if (framework === 'django') return `gunicorn ${appName}.wsgi:application --bind 0.0.0.0:${port}`;
|
|
129
|
+
if (framework === 'flask') return `gunicorn app:app --bind 0.0.0.0:${port}`;
|
|
130
|
+
for (const file of ['main.py', 'app.py', 'run.py', 'server.py', 'manage.py']) {
|
|
131
|
+
if (fs.existsSync(path.join(projectPath, file))) return `python ${file}`;
|
|
132
|
+
}
|
|
133
|
+
return 'python main.py';
|
|
134
|
+
}
|
|
135
|
+
|
|
31
136
|
export const PROJECT_TYPE_LABELS = {
|
|
32
137
|
nodejs: 'Node.js 应用(Express / Koa / Next.js 等)',
|
|
33
138
|
python: 'Python 应用(Flask / FastAPI / Django 等)',
|
|
@@ -36,13 +141,40 @@ export const PROJECT_TYPE_LABELS = {
|
|
|
36
141
|
unknown: '其他 / 我来手动指定',
|
|
37
142
|
};
|
|
38
143
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
144
|
+
export const PYTHON_FRAMEWORK_LABELS = {
|
|
145
|
+
fastapi: 'FastAPI',
|
|
146
|
+
django: 'Django',
|
|
147
|
+
flask: 'Flask',
|
|
148
|
+
other: '其他',
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export function hasDockerfile(projectPath = process.cwd()) {
|
|
152
|
+
return fs.existsSync(path.join(projectPath, 'Dockerfile'));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function detectComposeFile(projectPath = process.cwd()) {
|
|
156
|
+
for (const name of ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']) {
|
|
157
|
+
if (fs.existsSync(path.join(projectPath, name))) return name;
|
|
44
158
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 从 Dockerfile EXPOSE 或 docker-compose ports 映射读取宿主机端口
|
|
163
|
+
export function detectDockerPort(projectPath = process.cwd()) {
|
|
164
|
+
const dockerfilePath = path.join(projectPath, 'Dockerfile');
|
|
165
|
+
if (fs.existsSync(dockerfilePath)) {
|
|
166
|
+
const content = fs.readFileSync(dockerfilePath, 'utf-8');
|
|
167
|
+
const match = content.match(/^EXPOSE\s+(\d+)/m);
|
|
168
|
+
if (match) return { port: match[1], source: 'Dockerfile EXPOSE' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const composeName = detectComposeFile(projectPath);
|
|
172
|
+
if (composeName) {
|
|
173
|
+
const content = fs.readFileSync(path.join(projectPath, composeName), 'utf-8');
|
|
174
|
+
// 匹配 "- 8080:3000" 或 "- '8080:3000'" 格式,取宿主机端口
|
|
175
|
+
const match = content.match(/^\s*-\s*["']?(\d+):\d+/m);
|
|
176
|
+
if (match) return { port: match[1], source: composeName };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return null;
|
|
48
180
|
}
|
package/src/utils/setup.js
CHANGED
|
@@ -1,80 +1,298 @@
|
|
|
1
|
+
// shell heredoc:用单引号 EOF 防止 $/反引号/% 被解释,正文本身 single-quote 也安全
|
|
2
|
+
function writeFileHeredoc(remotePath, content) {
|
|
3
|
+
// 使用唯一 sentinel 避免与正文冲突
|
|
4
|
+
const sentinel = 'DEPLOY_HELPER_EOF';
|
|
5
|
+
return `cat > ${remotePath} <<'${sentinel}'\n${content}\n${sentinel}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
1
8
|
// 返回在服务器上执行的 shell 命令数组
|
|
2
9
|
export function getSetupCommands(config) {
|
|
3
|
-
const { projectType, nodeVersion = '20', pythonVersion = '3.11' } = config;
|
|
10
|
+
const { projectType, nodeVersion = '20', pythonVersion = '3.11', appMode = 'web' } = config;
|
|
4
11
|
const steps = [];
|
|
5
12
|
|
|
6
|
-
// 通用:更新系统 & 安装 nginx
|
|
7
13
|
steps.push({
|
|
8
14
|
label: '更新系统包',
|
|
9
15
|
cmd: 'apt-get update -qq',
|
|
10
16
|
});
|
|
11
|
-
steps.push({
|
|
12
|
-
label: '安装 Nginx',
|
|
13
|
-
cmd: 'apt-get install -y -qq nginx',
|
|
14
|
-
});
|
|
15
|
-
steps.push({
|
|
16
|
-
label: '安装 Certbot(用于 HTTPS)',
|
|
17
|
-
cmd: 'apt-get install -y -qq certbot python3-certbot-nginx',
|
|
18
|
-
});
|
|
19
17
|
|
|
20
|
-
|
|
18
|
+
// Nginx 和 Certbot 只有 web 服务需要
|
|
19
|
+
if (appMode === 'web') {
|
|
21
20
|
steps.push({
|
|
22
|
-
label:
|
|
23
|
-
cmd:
|
|
21
|
+
label: '安装 Nginx',
|
|
22
|
+
cmd: 'apt-get install -y -qq nginx',
|
|
24
23
|
});
|
|
25
24
|
steps.push({
|
|
26
|
-
label: '安装
|
|
27
|
-
cmd: '
|
|
25
|
+
label: '安装 Certbot(用于 HTTPS)',
|
|
26
|
+
cmd: 'apt-get install -y -qq certbot python3-certbot-nginx',
|
|
28
27
|
});
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
if (projectType === '
|
|
30
|
+
if (projectType === 'nodejs') {
|
|
32
31
|
steps.push({
|
|
33
|
-
label:
|
|
34
|
-
cmd: `
|
|
32
|
+
label: `安装 Node.js ${nodeVersion}`,
|
|
33
|
+
cmd: `command -v node >/dev/null 2>&1 && node -v | grep -q "^v${nodeVersion}\\." || (curl -fsSL https://deb.nodesource.com/setup_${nodeVersion}.x | bash - && apt-get install -y nodejs)`,
|
|
35
34
|
});
|
|
36
35
|
steps.push({
|
|
37
|
-
label: '安装
|
|
38
|
-
cmd: '
|
|
36
|
+
label: '安装 PM2(进程管理器)',
|
|
37
|
+
cmd: 'command -v pm2 >/dev/null 2>&1 || npm install -g pm2',
|
|
39
38
|
});
|
|
40
39
|
}
|
|
41
40
|
|
|
41
|
+
if (projectType === 'python') {
|
|
42
|
+
const { pythonEnvManager = 'pip' } = config;
|
|
43
|
+
|
|
44
|
+
if (pythonEnvManager === 'conda') {
|
|
45
|
+
// 安装 Miniconda 到固定路径,已存在则跳过
|
|
46
|
+
steps.push({
|
|
47
|
+
label: '安装 Miniconda(如未安装)',
|
|
48
|
+
cmd: [
|
|
49
|
+
'command -v /opt/miniconda3/bin/conda >/dev/null 2>&1 || (',
|
|
50
|
+
' curl -fsSL https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -o /tmp/miniconda.sh &&',
|
|
51
|
+
' bash /tmp/miniconda.sh -b -p /opt/miniconda3 &&',
|
|
52
|
+
' rm /tmp/miniconda.sh',
|
|
53
|
+
')',
|
|
54
|
+
].join(' '),
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
const pyBin = `python${pythonVersion}`;
|
|
58
|
+
steps.push({
|
|
59
|
+
label: `安装 Python ${pythonVersion}(如未安装)`,
|
|
60
|
+
cmd: [
|
|
61
|
+
`command -v ${pyBin} >/dev/null 2>&1 || (`,
|
|
62
|
+
' apt-get install -y -qq software-properties-common &&',
|
|
63
|
+
' add-apt-repository -y ppa:deadsnakes/ppa &&',
|
|
64
|
+
' apt-get update -qq &&',
|
|
65
|
+
` apt-get install -y -qq ${pyBin} ${pyBin}-venv ${pyBin}-distutils`,
|
|
66
|
+
` || apt-get install -y -qq python3 python3-venv`,
|
|
67
|
+
')',
|
|
68
|
+
`&& (apt-get install -y -qq ${pyBin}-venv 2>/dev/null || apt-get install -y -qq python3-venv 2>/dev/null || true)`,
|
|
69
|
+
].join(' '),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// cron 模式不需要 supervisor
|
|
74
|
+
if (appMode !== 'cron') {
|
|
75
|
+
steps.push({
|
|
76
|
+
label: '安装 supervisor(进程管理)',
|
|
77
|
+
cmd: 'command -v supervisorctl >/dev/null 2>&1 || apt-get install -y -qq supervisor',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
if (projectType === 'docker') {
|
|
83
|
+
// Docker 可能已预装(云服务商镜像常见),先检查再安装
|
|
43
84
|
steps.push({
|
|
44
|
-
label: '
|
|
45
|
-
cmd: `curl -fsSL https://get.docker.com | sh`,
|
|
85
|
+
label: '检查/安装 Docker',
|
|
86
|
+
cmd: `command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh`,
|
|
46
87
|
});
|
|
47
88
|
steps.push({
|
|
48
|
-
label: '
|
|
49
|
-
cmd: `apt-get install -y -qq docker-compose-plugin`,
|
|
89
|
+
label: '检查/安装 docker-compose-plugin',
|
|
90
|
+
cmd: `docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin`,
|
|
91
|
+
});
|
|
92
|
+
steps.push({
|
|
93
|
+
label: '确保 Docker 服务运行',
|
|
94
|
+
cmd: `systemctl enable docker && systemctl start docker`,
|
|
50
95
|
});
|
|
51
96
|
}
|
|
52
97
|
|
|
53
98
|
return steps;
|
|
54
99
|
}
|
|
55
100
|
|
|
101
|
+
// 写入 crontab(幂等:先删除同名旧条目再添加)
|
|
102
|
+
function buildCronEntry(appName, schedule, fullCmd) {
|
|
103
|
+
const marker = `deploy-helper:${appName}`;
|
|
104
|
+
const logFile = `/var/log/${appName}.log`;
|
|
105
|
+
// 注意:crontab 行本身不能有未转义的 % —— 这里 grep -v 用 marker 去重
|
|
106
|
+
// fullCmd 中如有 % 由调用方负责(typical 启动命令不会有)
|
|
107
|
+
return `(crontab -l 2>/dev/null | grep -v "${marker}"; echo "# ${marker}"; echo "${schedule} ${fullCmd} >> ${logFile} 2>&1") | crontab -`;
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
// 启动/重启应用的命令
|
|
57
111
|
export function getStartCommands(config) {
|
|
58
|
-
const {
|
|
112
|
+
const {
|
|
113
|
+
projectType, remotePath, startCmd, appName, port,
|
|
114
|
+
pythonVersion = '3.11', pythonFramework, pythonEnvManager = 'pip',
|
|
115
|
+
appMode = 'web', cronSchedule, composeFile,
|
|
116
|
+
} = config;
|
|
59
117
|
|
|
60
118
|
if (projectType === 'nodejs') {
|
|
61
|
-
|
|
119
|
+
const isNpmCmd = /^npm\s/.test(startCmd);
|
|
120
|
+
const steps = [
|
|
62
121
|
{ label: '安装依赖', cmd: `cd ${remotePath} && npm install --production` },
|
|
63
|
-
{ label: '启动应用(PM2)', cmd: `cd ${remotePath} && pm2 delete ${appName} 2>/dev/null; pm2 start ${startCmd} --name ${appName}` },
|
|
64
|
-
{ label: '设置 PM2 开机自启', cmd: `pm2 save && pm2 startup | tail -1 | bash || true` },
|
|
65
122
|
];
|
|
123
|
+
|
|
124
|
+
if (appMode === 'cron') {
|
|
125
|
+
const cronFullCmd = `cd ${remotePath} && ${startCmd}`;
|
|
126
|
+
steps.push({
|
|
127
|
+
label: '写入定时任务(crontab)',
|
|
128
|
+
cmd: buildCronEntry(appName, cronSchedule, cronFullCmd),
|
|
129
|
+
});
|
|
130
|
+
steps.push({
|
|
131
|
+
label: '立即执行一次(验证)',
|
|
132
|
+
cmd: `${cronFullCmd} >> /var/log/${appName}.log 2>&1 || true`,
|
|
133
|
+
});
|
|
134
|
+
return steps;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 非 npm 命令通过 .start.sh 包装;npm 命令用 pm2 start npm -- ...
|
|
138
|
+
const startScript = `${remotePath}/.start.sh`;
|
|
139
|
+
const startScriptContent = `#!/bin/bash\ncd ${remotePath}\nexec ${startCmd}\n`;
|
|
140
|
+
|
|
141
|
+
if (!isNpmCmd) {
|
|
142
|
+
steps.push({
|
|
143
|
+
label: '写入启动脚本',
|
|
144
|
+
cmd: `${writeFileHeredoc(startScript, startScriptContent)} && chmod +x ${startScript}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const pm2Target = isNpmCmd
|
|
149
|
+
? `npm --name ${appName} -- ${startCmd.replace(/^npm\s+/, '')}`
|
|
150
|
+
: `${startScript} --name ${appName} --interpreter bash`;
|
|
151
|
+
|
|
152
|
+
// script 模式不自动重启;web 模式正常重启
|
|
153
|
+
const pm2StartCmd = appMode === 'script'
|
|
154
|
+
? `pm2 delete ${appName} 2>/dev/null || true; pm2 start ${pm2Target} --no-autorestart`
|
|
155
|
+
: `pm2 delete ${appName} 2>/dev/null || true; pm2 start ${pm2Target}`;
|
|
156
|
+
|
|
157
|
+
steps.push(
|
|
158
|
+
{ label: `启动应用(PM2${appMode === 'script' ? ',不自动重启' : ''})`, cmd: pm2StartCmd },
|
|
159
|
+
{ label: '设置 PM2 开机自启', cmd: `pm2 save && (pm2 startup | tail -1 | bash || true)` },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return steps;
|
|
66
163
|
}
|
|
67
164
|
|
|
68
165
|
if (projectType === 'python') {
|
|
166
|
+
const supervisorConfPath = `/etc/supervisor/conf.d/${appName}.conf`;
|
|
167
|
+
|
|
168
|
+
if (pythonEnvManager === 'conda') {
|
|
169
|
+
const condaBin = '/opt/miniconda3/bin/conda';
|
|
170
|
+
const condaCmd = `${condaBin} run -n ${appName} --no-capture-output ${startCmd}`;
|
|
171
|
+
const installSteps = [
|
|
172
|
+
{
|
|
173
|
+
label: '创建/更新 conda 环境',
|
|
174
|
+
cmd: [
|
|
175
|
+
`${condaBin} env update -f ${remotePath}/environment.yml -n ${appName} --prune 2>/dev/null`,
|
|
176
|
+
`|| ${condaBin} env create -f ${remotePath}/environment.yml -n ${appName}`,
|
|
177
|
+
].join(' '),
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
if (appMode === 'cron') {
|
|
182
|
+
return [
|
|
183
|
+
...installSteps,
|
|
184
|
+
{
|
|
185
|
+
label: '写入定时任务(crontab)',
|
|
186
|
+
cmd: buildCronEntry(appName, cronSchedule, condaCmd),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
label: '立即执行一次(验证)',
|
|
190
|
+
cmd: `${condaCmd} >> /var/log/${appName}.log 2>&1 || true`,
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const supervisorConf = getSupervisorConfig({
|
|
196
|
+
appName, remotePath, venvCmd: condaCmd,
|
|
197
|
+
autorestart: appMode === 'script' ? 'unexpected' : 'true',
|
|
198
|
+
});
|
|
199
|
+
return [
|
|
200
|
+
...installSteps,
|
|
201
|
+
{
|
|
202
|
+
label: '写入 supervisor 配置',
|
|
203
|
+
cmd: writeFileHeredoc(supervisorConfPath, supervisorConf),
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
label: '启动应用(supervisor)',
|
|
207
|
+
cmd: `supervisorctl reread && supervisorctl update && (supervisorctl restart ${appName} 2>/dev/null || supervisorctl start ${appName})`,
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// pip + venv 路径
|
|
213
|
+
const pyBin = `python${pythonVersion}`;
|
|
214
|
+
const venvPip = `${remotePath}/venv/bin/pip`;
|
|
215
|
+
const venvCmd = startCmd.replace(/^(\S+)/, `${remotePath}/venv/bin/$1`);
|
|
216
|
+
|
|
217
|
+
const installSteps = [
|
|
218
|
+
{
|
|
219
|
+
label: '创建 Python 虚拟环境',
|
|
220
|
+
cmd: `test -d ${remotePath}/venv || (${pyBin} -m venv ${remotePath}/venv || python3 -m venv ${remotePath}/venv)`,
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
label: '安装 Python 依赖',
|
|
224
|
+
cmd: `cd ${remotePath} && ${venvPip} install -r requirements.txt -q`,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
if (appMode === 'web') {
|
|
229
|
+
const extraPkg = pythonFramework === 'fastapi' ? 'uvicorn' : 'gunicorn';
|
|
230
|
+
installSteps.push({
|
|
231
|
+
label: `安装 ${extraPkg}(WSGI/ASGI 服务器)`,
|
|
232
|
+
cmd: `${venvPip} install ${extraPkg} -q`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (appMode === 'cron') {
|
|
237
|
+
return [
|
|
238
|
+
...installSteps,
|
|
239
|
+
{
|
|
240
|
+
label: '写入定时任务(crontab)',
|
|
241
|
+
cmd: buildCronEntry(appName, cronSchedule, `cd ${remotePath} && ${venvCmd}`),
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
label: '立即执行一次(验证)',
|
|
245
|
+
cmd: `cd ${remotePath} && ${venvCmd} >> /var/log/${appName}.log 2>&1 || true`,
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const supervisorConf = getSupervisorConfig({
|
|
251
|
+
appName, remotePath, venvCmd,
|
|
252
|
+
autorestart: appMode === 'script' ? 'unexpected' : 'true',
|
|
253
|
+
});
|
|
69
254
|
return [
|
|
70
|
-
|
|
71
|
-
{
|
|
255
|
+
...installSteps,
|
|
256
|
+
{
|
|
257
|
+
label: '写入 supervisor 配置',
|
|
258
|
+
cmd: writeFileHeredoc(supervisorConfPath, supervisorConf),
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
label: '启动应用(supervisor)',
|
|
262
|
+
cmd: `supervisorctl reread && supervisorctl update && (supervisorctl restart ${appName} 2>/dev/null || supervisorctl start ${appName})`,
|
|
263
|
+
},
|
|
72
264
|
];
|
|
73
265
|
}
|
|
74
266
|
|
|
75
267
|
if (projectType === 'docker') {
|
|
268
|
+
if (composeFile) {
|
|
269
|
+
const composeCmd = `docker compose -f ${composeFile}`;
|
|
270
|
+
return [
|
|
271
|
+
{
|
|
272
|
+
label: '拉取基础镜像(如有)',
|
|
273
|
+
cmd: `cd ${remotePath} && ${composeCmd} pull 2>/dev/null || true`,
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
label: '构建并启动容器',
|
|
277
|
+
cmd: `cd ${remotePath} && ${composeCmd} up -d --build --remove-orphans`,
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 无 compose 文件:单容器模式
|
|
76
283
|
return [
|
|
77
|
-
{
|
|
284
|
+
{
|
|
285
|
+
label: '构建 Docker 镜像',
|
|
286
|
+
cmd: `cd ${remotePath} && docker build -t ${appName} .`,
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
label: '启动容器',
|
|
290
|
+
cmd: [
|
|
291
|
+
`docker stop ${appName} 2>/dev/null || true`,
|
|
292
|
+
`docker rm ${appName} 2>/dev/null || true`,
|
|
293
|
+
`docker run -d --name ${appName} --restart unless-stopped -p ${port}:${port} ${appName}`,
|
|
294
|
+
].join(' && '),
|
|
295
|
+
},
|
|
78
296
|
];
|
|
79
297
|
}
|
|
80
298
|
|
|
@@ -87,6 +305,133 @@ export function getStartCommands(config) {
|
|
|
87
305
|
return [];
|
|
88
306
|
}
|
|
89
307
|
|
|
308
|
+
// 停止服务(供 rollback 使用)
|
|
309
|
+
export function getStopCommand(config) {
|
|
310
|
+
const { projectType, appName, remotePath, appMode = 'web', composeFile } = config;
|
|
311
|
+
|
|
312
|
+
if (appMode === 'cron') {
|
|
313
|
+
// cron 不需要停止;下次定时不命中即可。
|
|
314
|
+
// 如果想立即停,可以从 crontab 移除——但 rollback 之后通常想保留定时
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (projectType === 'nodejs') {
|
|
319
|
+
return `pm2 stop ${appName} 2>/dev/null || true`;
|
|
320
|
+
}
|
|
321
|
+
if (projectType === 'python') {
|
|
322
|
+
return `supervisorctl stop ${appName} 2>/dev/null || true`;
|
|
323
|
+
}
|
|
324
|
+
if (projectType === 'docker') {
|
|
325
|
+
if (composeFile) {
|
|
326
|
+
return `cd ${remotePath} && docker compose -f ${composeFile} down 2>/dev/null || true`;
|
|
327
|
+
}
|
|
328
|
+
return `docker stop ${appName} 2>/dev/null || true; docker rm ${appName} 2>/dev/null || true`;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 健康检查:返回 { cmd, parse } —— parse 接受 {stdout, code} 返回 { ok, detail }
|
|
334
|
+
export function getHealthCheck(config) {
|
|
335
|
+
const { projectType, appName, remotePath, appMode = 'web', composeFile, pythonEnvManager } = config;
|
|
336
|
+
|
|
337
|
+
if (appMode === 'cron') {
|
|
338
|
+
const marker = `deploy-helper:${appName}`;
|
|
339
|
+
return {
|
|
340
|
+
cmd: `crontab -l 2>/dev/null | grep -F "${marker}" | head -1`,
|
|
341
|
+
parse: ({ stdout }) => stdout
|
|
342
|
+
? { ok: true, detail: '已写入 crontab' }
|
|
343
|
+
: { ok: false, detail: '未在 crontab 中找到该任务' },
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (projectType === 'nodejs') {
|
|
348
|
+
return {
|
|
349
|
+
cmd: `pm2 jlist 2>/dev/null`,
|
|
350
|
+
parse: ({ stdout }) => {
|
|
351
|
+
try {
|
|
352
|
+
const list = JSON.parse(stdout || '[]');
|
|
353
|
+
const app = list.find(p => p.name === appName);
|
|
354
|
+
if (!app) return { ok: false, detail: `PM2 中未找到 ${appName}` };
|
|
355
|
+
const status = app.pm2_env?.status;
|
|
356
|
+
return status === 'online'
|
|
357
|
+
? { ok: true, detail: `PM2 状态: online (PID ${app.pid})` }
|
|
358
|
+
: { ok: false, detail: `PM2 状态: ${status}` };
|
|
359
|
+
} catch {
|
|
360
|
+
return { ok: false, detail: 'pm2 jlist 解析失败' };
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (projectType === 'python') {
|
|
367
|
+
return {
|
|
368
|
+
cmd: `supervisorctl status ${appName} 2>&1 || true`,
|
|
369
|
+
parse: ({ stdout }) => {
|
|
370
|
+
const line = stdout.trim();
|
|
371
|
+
if (/RUNNING/.test(line)) return { ok: true, detail: line };
|
|
372
|
+
return { ok: false, detail: line || 'supervisor 未返回状态' };
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (projectType === 'docker') {
|
|
378
|
+
if (composeFile) {
|
|
379
|
+
return {
|
|
380
|
+
cmd: `cd ${remotePath} && docker compose -f ${composeFile} ps --format json 2>/dev/null || true`,
|
|
381
|
+
parse: ({ stdout }) => {
|
|
382
|
+
const raw = stdout.trim();
|
|
383
|
+
if (!raw) return { ok: false, detail: 'docker compose ps 无输出' };
|
|
384
|
+
// 兼容两种格式:旧版 docker compose 输出 JSON 数组,新版输出 NDJSON
|
|
385
|
+
let services = [];
|
|
386
|
+
try {
|
|
387
|
+
const parsed = JSON.parse(raw);
|
|
388
|
+
services = Array.isArray(parsed) ? parsed : [parsed];
|
|
389
|
+
} catch {
|
|
390
|
+
services = raw.split('\n').filter(Boolean)
|
|
391
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
392
|
+
.filter(Boolean);
|
|
393
|
+
}
|
|
394
|
+
if (services.length === 0) return { ok: false, detail: '未找到运行中的服务' };
|
|
395
|
+
const allRunning = services.every(s => /running|up/i.test(s.State || s.Status || ''));
|
|
396
|
+
return {
|
|
397
|
+
ok: allRunning,
|
|
398
|
+
detail: services.map(s => `${s.Service || s.Name}: ${s.State || s.Status}`).join('; '),
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
cmd: `docker inspect -f '{{.State.Status}}' ${appName} 2>/dev/null || echo missing`,
|
|
405
|
+
parse: ({ stdout }) => stdout.trim() === 'running'
|
|
406
|
+
? { ok: true, detail: '容器运行中' }
|
|
407
|
+
: { ok: false, detail: `容器状态: ${stdout.trim()}` },
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// static 没有进程,认为只要 Nginx 在跑即可
|
|
412
|
+
return {
|
|
413
|
+
cmd: `systemctl is-active nginx 2>/dev/null || true`,
|
|
414
|
+
parse: ({ stdout }) => stdout.trim() === 'active'
|
|
415
|
+
? { ok: true, detail: 'Nginx active' }
|
|
416
|
+
: { ok: false, detail: `Nginx 状态: ${stdout.trim()}` },
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// 生成 supervisor 配置内容(heredoc 写入,使用真实换行)
|
|
421
|
+
// autorestart: 'true'(始终重启)| 'unexpected'(仅崩溃时重启)| 'false'
|
|
422
|
+
function getSupervisorConfig({ appName, remotePath, venvCmd, autorestart = 'true' }) {
|
|
423
|
+
return [
|
|
424
|
+
`[program:${appName}]`,
|
|
425
|
+
`command=${venvCmd}`,
|
|
426
|
+
`directory=${remotePath}`,
|
|
427
|
+
`autostart=true`,
|
|
428
|
+
`autorestart=${autorestart}`,
|
|
429
|
+
`stderr_logfile=/var/log/${appName}.err.log`,
|
|
430
|
+
`stdout_logfile=/var/log/${appName}.out.log`,
|
|
431
|
+
``,
|
|
432
|
+
].join('\n');
|
|
433
|
+
}
|
|
434
|
+
|
|
90
435
|
// 生成 Nginx 配置
|
|
91
436
|
export function getNginxConfig(config) {
|
|
92
437
|
const { domain, port, projectType, remotePath } = config;
|
|
@@ -124,3 +469,6 @@ export function getNginxConfig(config) {
|
|
|
124
469
|
}
|
|
125
470
|
}`;
|
|
126
471
|
}
|
|
472
|
+
|
|
473
|
+
// 把任意字符串内容写入服务器文件的 shell 命令(heredoc 方式)
|
|
474
|
+
export { writeFileHeredoc };
|