@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.
@@ -3,10 +3,21 @@ import chalk from 'chalk';
3
3
  import ora from 'ora';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
- import { connectSSH, runRemote, runRemoteSilent, uploadDirectory } from '../utils/ssh.js';
6
+ import {
7
+ connectSSH, runRemote, runRemoteSilent, runRemoteStrict, uploadDirectory,
8
+ } from '../utils/ssh.js';
7
9
  import { saveConfig, loadConfig, configExists } from '../utils/config.js';
8
- import { detectProjectType, PROJECT_TYPE_LABELS, getStartCommand } from '../utils/detect.js';
9
- import { getSetupCommands, getStartCommands, getNginxConfig } from '../utils/setup.js';
10
+ import fs from 'fs';
11
+ import {
12
+ detectProjectType, detectNodeVersion, detectPythonFramework, detectPythonVersion,
13
+ detectPythonEnvManager,
14
+ getNodeStartCommand, getPythonStartCommand,
15
+ hasDockerfile, detectComposeFile, detectDockerPort,
16
+ PROJECT_TYPE_LABELS, PYTHON_FRAMEWORK_LABELS,
17
+ } from '../utils/detect.js';
18
+ import {
19
+ getSetupCommands, getStartCommands, getNginxConfig, getHealthCheck, writeFileHeredoc,
20
+ } from '../utils/setup.js';
10
21
 
11
22
  const step = (n, total, msg) =>
12
23
  console.log(chalk.cyan(`\n[${n}/${total}] `) + chalk.bold(msg));
@@ -14,6 +25,81 @@ const step = (n, total, msg) =>
14
25
  const success = (msg) => console.log(chalk.green(' ✓ ') + msg);
15
26
  const info = (msg) => console.log(chalk.gray(' ℹ ') + msg);
16
27
 
28
+ function showDockerTemplateGuide() {
29
+ console.log(chalk.yellow('\n 对于 Java、C++、Go、CUDA、conda 等环境,推荐 Docker 部署:'));
30
+ console.log(chalk.gray(' 在项目根目录创建 Dockerfile,然后选择 Docker 类型,deploy-helper 负责把容器跑起来。\n'));
31
+
32
+ const templates = [
33
+ {
34
+ title: 'Python + conda / CUDA',
35
+ lines: [
36
+ 'FROM continuumio/miniconda3',
37
+ 'WORKDIR /app',
38
+ 'COPY environment.yml .',
39
+ 'RUN conda env create -f environment.yml -n myenv',
40
+ 'COPY . .',
41
+ 'CMD ["conda", "run", "-n", "myenv", "--no-capture-output", "python", "main.py"]',
42
+ ],
43
+ },
44
+ {
45
+ title: 'Java (Maven + JDK 21)',
46
+ lines: [
47
+ 'FROM maven:3.9-eclipse-temurin-21-alpine AS build',
48
+ 'WORKDIR /app',
49
+ 'COPY pom.xml .',
50
+ 'RUN mvn dependency:resolve -q',
51
+ 'COPY src ./src',
52
+ 'RUN mvn package -DskipTests -q',
53
+ 'FROM eclipse-temurin:21-jre-alpine',
54
+ 'COPY --from=build /app/target/*.jar app.jar',
55
+ 'EXPOSE 8080',
56
+ 'CMD ["java", "-jar", "app.jar"]',
57
+ ],
58
+ },
59
+ {
60
+ title: 'Go',
61
+ lines: [
62
+ 'FROM golang:1.22-alpine AS build',
63
+ 'WORKDIR /app',
64
+ 'COPY go.mod go.sum ./',
65
+ 'RUN go mod download',
66
+ 'COPY . .',
67
+ 'RUN go build -o main .',
68
+ 'FROM alpine:latest',
69
+ 'COPY --from=build /app/main .',
70
+ 'EXPOSE 8080',
71
+ 'CMD ["./main"]',
72
+ ],
73
+ },
74
+ {
75
+ title: 'Node.js(含构建步骤)',
76
+ lines: [
77
+ 'FROM node:20-alpine AS build',
78
+ 'WORKDIR /app',
79
+ 'COPY package*.json ./',
80
+ 'RUN npm ci',
81
+ 'COPY . .',
82
+ 'RUN npm run build',
83
+ 'FROM node:20-alpine',
84
+ 'WORKDIR /app',
85
+ 'COPY --from=build /app/dist ./dist',
86
+ 'COPY --from=build /app/node_modules ./node_modules',
87
+ 'EXPOSE 3000',
88
+ 'CMD ["node", "dist/index.js"]',
89
+ ],
90
+ },
91
+ ];
92
+
93
+ for (const { title, lines } of templates) {
94
+ console.log(chalk.bold(` ── ${title} ─`));
95
+ for (const line of lines) {
96
+ console.log(chalk.gray(' │ ') + chalk.white(line));
97
+ }
98
+ console.log('');
99
+ }
100
+ console.log(chalk.gray(' 写好 Dockerfile 后,重新运行 deploy-helper 并选择 Docker 类型即可。\n'));
101
+ }
102
+
17
103
  export async function deployInit() {
18
104
  // 已有配置,询问是否覆盖
19
105
  if (configExists()) {
@@ -67,7 +153,7 @@ export async function deployInit() {
67
153
  type: 'input',
68
154
  name: 'keyPath',
69
155
  message: 'SSH 密钥路径:',
70
- default: `${os.homedir()}/.ssh/id_rsa`,
156
+ default: path.join(os.homedir(), '.ssh', 'id_rsa'),
71
157
  when: (a) => a.authType === 'key',
72
158
  },
73
159
  {
@@ -100,7 +186,8 @@ export async function deployInit() {
100
186
  const detectedType = detectProjectType();
101
187
  info(`自动检测到项目类型:${PROJECT_TYPE_LABELS[detectedType]}`);
102
188
 
103
- const projectAnswers = await inquirer.prompt([
189
+ // 第一步:确认类型和应用名
190
+ const typeAnswers = await inquirer.prompt([
104
191
  {
105
192
  type: 'list',
106
193
  name: 'projectType',
@@ -115,78 +202,322 @@ export async function deployInit() {
115
202
  default: path.basename(process.cwd()),
116
203
  validate: (v) => /^[a-z0-9_-]+$/i.test(v) ? true : '只能包含字母、数字、下划线和连字符',
117
204
  },
205
+ ]);
206
+
207
+ const { projectType, appName } = typeAnswers;
208
+
209
+ // 根据确认的类型做二次检测,结果全部展示给用户
210
+ let detectedNodeVer = null;
211
+ let detectedNodeCmd = null;
212
+ let detectedPyFramework = null;
213
+ let detectedPyVersion = null;
214
+ let detectedPyEnvManager = null;
215
+ let detectedDockerInfo = null;
216
+
217
+ if (projectType === 'nodejs') {
218
+ detectedNodeVer = detectNodeVersion();
219
+ detectedNodeCmd = getNodeStartCommand();
220
+
221
+ if (detectedNodeVer) {
222
+ info(`检测到 Node.js 版本要求:v${detectedNodeVer.version}(来源:${detectedNodeVer.source})`);
223
+ } else {
224
+ info('未检测到 Node.js 版本要求(.nvmrc / engines.node),将默认使用 Node.js 20 LTS');
225
+ }
226
+ info(`检测到启动命令:${detectedNodeCmd.cmd}(来源:${detectedNodeCmd.source})`);
227
+ }
228
+
229
+ if (projectType === 'python') {
230
+ detectedPyFramework = detectPythonFramework();
231
+ detectedPyVersion = detectPythonVersion();
232
+ detectedPyEnvManager = detectPythonEnvManager();
233
+
234
+ // 依赖管理方式
235
+ if (detectedPyEnvManager === 'conda') {
236
+ const hasEnvYml = fs.existsSync(path.join(process.cwd(), 'environment.yml'));
237
+ if (hasEnvYml) {
238
+ info('检测到 conda 环境(environment.yml ✓)');
239
+ } else {
240
+ console.log(chalk.yellow('\n ⚠ 检测到 conda 项目(conda-lock.yml),但未找到 environment.yml'));
241
+ console.log(chalk.gray(' 部署需要 environment.yml 来在服务器上重建 conda 环境。'));
242
+ console.log(chalk.gray(' 请在本地激活 conda 环境后运行:\n'));
243
+ console.log(chalk.cyan(' conda activate <你的环境名>'));
244
+ console.log(chalk.cyan(' conda env export > environment.yml\n'));
245
+ console.log(chalk.gray(' 或者改用 Docker 部署(见下方模板),跳过这个问题。\n'));
246
+ }
247
+ } else if (detectedPyEnvManager === 'pip') {
248
+ info('检测到 pip 环境(requirements.txt ✓)');
249
+ } else {
250
+ console.log(chalk.yellow('\n ⚠ 未找到 requirements.txt 或 environment.yml'));
251
+ console.log(chalk.gray(' 部署时无法自动安装依赖,请先生成依赖文件:\n'));
252
+ console.log(chalk.gray(' conda 项目:'));
253
+ console.log(chalk.cyan(' conda activate <你的环境名>'));
254
+ console.log(chalk.cyan(' conda env export > environment.yml\n'));
255
+ console.log(chalk.gray(' pip 项目:'));
256
+ console.log(chalk.cyan(' pip freeze > requirements.txt\n'));
257
+ console.log(chalk.gray(' 或者用 Docker 把整个环境封装进镜像(见下方模板)。\n'));
258
+ }
259
+
260
+ if (detectedPyFramework) {
261
+ info(`检测到 Python 框架:${PYTHON_FRAMEWORK_LABELS[detectedPyFramework]}(来源:requirements.txt)`);
262
+ } else {
263
+ info('未检测到已知 web 框架(FastAPI / Django / Flask),将按纯脚本处理');
264
+ }
265
+ if (detectedPyVersion) {
266
+ info(`检测到 Python 版本:${detectedPyVersion.version}(来源:${detectedPyVersion.source})`);
267
+ } else {
268
+ info('未检测到 Python 版本要求(.python-version / pyproject.toml),将默认使用 3.11');
269
+ }
270
+ }
271
+
272
+ if (projectType === 'docker') {
273
+ const dockerfileExists = hasDockerfile();
274
+ const composeFile = detectComposeFile();
275
+ const dockerPort = detectDockerPort();
276
+
277
+ if (!dockerfileExists && !composeFile) {
278
+ console.log(chalk.yellow('\n ⚠ 未检测到 Dockerfile 或 docker-compose 文件'));
279
+ console.log(chalk.gray(' Docker 部署需要 Dockerfile 或 docker-compose.yml。'));
280
+ console.log(chalk.gray(' 如果你的语言/框架比较特殊(Java、C++、CUDA 等),'));
281
+ console.log(chalk.gray(' 推荐用 Dockerfile 把运行环境完整封装,deploy-helper 只负责把它跑起来。'));
282
+ } else if (dockerfileExists) {
283
+ info('检测到 Dockerfile ✓');
284
+ }
285
+
286
+ if (composeFile) {
287
+ info(`检测到编排文件:${composeFile}(将使用 docker compose 启动)`);
288
+ } else {
289
+ info('未检测到 docker-compose 文件,将以单容器模式(docker build + docker run)启动');
290
+ }
291
+
292
+ if (dockerPort) {
293
+ info(`检测到容器映射端口:${dockerPort.port}(来源:${dockerPort.source})`);
294
+ } else {
295
+ info('未从 Dockerfile/compose 文件中读取到端口,请手动填写');
296
+ }
297
+
298
+ const localEnvExists = fs.existsSync(path.join(process.cwd(), '.env'));
299
+ if (localEnvExists) {
300
+ console.log(chalk.gray('\n 本地存在 .env 文件(默认不上传,包含敏感信息请谨慎)'));
301
+ }
302
+
303
+ detectedDockerInfo = { dockerfileExists, composeFile, dockerPort, localEnvExists };
304
+ }
305
+
306
+ if (projectType === 'unknown') {
307
+ showDockerTemplateGuide();
308
+ }
309
+
310
+ // 推断默认应用模式,供用户确认
311
+ let detectedAppMode = null;
312
+ if (projectType === 'static') {
313
+ detectedAppMode = 'web';
314
+ } else if (projectType === 'nodejs') {
315
+ detectedAppMode = 'web';
316
+ } else if (projectType === 'python' && detectedPyFramework) {
317
+ detectedAppMode = 'web';
318
+ } else if (projectType === 'docker' && detectedDockerInfo?.dockerPort) {
319
+ detectedAppMode = 'web';
320
+ }
321
+
322
+ if (detectedAppMode === 'web') {
323
+ info('应用模式:Web 服务(将配置端口 + Nginx 反向代理)');
324
+ } else if (projectType !== 'unknown') {
325
+ info('应用模式未能自动判断,请手动选择(影响是否配置 Nginx 和端口)');
326
+ }
327
+
328
+ // 第二步:让用户确认所有检测结果,port 在 startCmd 之前以便生成默认命令
329
+ const detailAnswers = await inquirer.prompt([
118
330
  {
119
331
  type: 'input',
120
332
  name: 'remotePath',
121
333
  message: '部署到服务器的路径:',
122
- default: (a) => `/var/www/${a.appName}`,
334
+ default: `/var/www/${appName}`,
123
335
  },
124
336
  {
125
337
  type: 'input',
126
- name: 'startCmd',
127
- message: '启动命令:',
128
- default: 'node index.js',
129
- when: (a) => ['nodejs', 'python'].includes(a.projectType),
338
+ name: 'nodeVersion',
339
+ message: '确认 Node.js 版本(主版本号,如 18 / 20 / 22):',
340
+ default: detectedNodeVer?.version || '20',
341
+ when: () => projectType === 'nodejs',
342
+ },
343
+ {
344
+ type: 'list',
345
+ name: 'pythonFramework',
346
+ message: '确认 Python 框架:',
347
+ default: detectedPyFramework || 'other',
348
+ choices: [
349
+ { name: 'FastAPI(uvicorn 启动)', value: 'fastapi' },
350
+ { name: 'Django(gunicorn 启动)', value: 'django' },
351
+ { name: 'Flask(gunicorn 启动)', value: 'flask' },
352
+ { name: '其他(手动填写启动命令)', value: 'other' },
353
+ ],
354
+ when: () => projectType === 'python',
355
+ },
356
+ {
357
+ type: 'input',
358
+ name: 'pythonVersion',
359
+ message: '确认 Python 版本(如 3.11):',
360
+ default: detectedPyVersion?.version || '3.11',
361
+ when: () => projectType === 'python',
362
+ },
363
+ {
364
+ type: 'list',
365
+ name: 'pythonEnvManager',
366
+ message: '确认依赖管理方式:',
367
+ default: detectedPyEnvManager || 'pip',
368
+ choices: [
369
+ { name: 'pip + venv(从 requirements.txt 安装)', value: 'pip' },
370
+ { name: 'conda(从 environment.yml 安装,服务器将安装 Miniconda)', value: 'conda' },
371
+ ],
372
+ when: () => projectType === 'python',
373
+ },
374
+ {
375
+ type: 'list',
376
+ name: 'appMode',
377
+ message: '确认应用运行方式:',
378
+ default: detectedAppMode || 'web',
379
+ choices: [
380
+ { name: 'Web 服务(监听端口,通过浏览器 / API 访问)', value: 'web' },
381
+ { name: '后台脚本(长期运行,不对外提供 HTTP 服务)', value: 'script' },
382
+ { name: '定时任务(按计划执行,跑完自动退出)', value: 'cron' },
383
+ ],
384
+ when: () => projectType !== 'static',
130
385
  },
131
386
  {
132
387
  type: 'input',
133
388
  name: 'port',
134
- message: '应用监听的端口:',
135
- default: '3000',
136
- when: (a) => ['nodejs', 'python', 'docker'].includes(a.projectType),
389
+ message: '应用监听的端口(宿主机端口,Nginx 将代理到此):',
390
+ default: () => {
391
+ if (projectType === 'docker') return detectedDockerInfo?.dockerPort?.port || '8080';
392
+ if (projectType === 'python') return '8000';
393
+ return '3000';
394
+ },
395
+ when: (a) => ['nodejs', 'python', 'docker'].includes(projectType) && (a.appMode ?? 'web') === 'web',
396
+ },
397
+ {
398
+ type: 'input',
399
+ name: 'composeFile',
400
+ message: 'docker-compose 文件名(留空则使用单容器模式):',
401
+ default: detectedDockerInfo?.composeFile || '',
402
+ when: () => projectType === 'docker',
137
403
  },
138
- ]);
139
-
140
- // ── Step 3: 域名 & HTTPS ──────────────────────────────────────
141
- step(3, 5, '域名 & HTTPS(可选)');
142
-
143
- const domainAnswers = await inquirer.prompt([
144
404
  {
145
405
  type: 'confirm',
146
- name: 'useDomain',
147
- message: '是否配置域名?(没有域名用 IP 也可以)',
148
- default: true,
406
+ name: 'uploadEnv',
407
+ message: '是否将本地 .env 文件上传到服务器?(包含数据库密码等敏感信息,请谨慎)',
408
+ default: false,
409
+ when: () => projectType === 'docker' && detectedDockerInfo?.localEnvExists,
149
410
  },
150
411
  {
151
412
  type: 'input',
152
- name: 'domain',
153
- message: '你的域名(如 example.com):',
154
- when: (a) => a.useDomain,
155
- validate: (v) => v.trim() ? true : '请输入域名',
413
+ name: 'startCmd',
414
+ message: '确认启动命令:',
415
+ default: (a) => {
416
+ if (projectType === 'nodejs') return detectedNodeCmd.cmd;
417
+ if (projectType === 'python') {
418
+ return getPythonStartCommand(a.pythonFramework, appName, a.port);
419
+ }
420
+ return '';
421
+ },
422
+ when: () => ['nodejs', 'python'].includes(projectType),
156
423
  },
157
424
  {
158
- type: 'confirm',
159
- name: 'useHttps',
160
- message: '是否自动申请 HTTPS 证书?(免费,需要域名已解析到此服务器)',
161
- default: true,
162
- when: (a) => a.useDomain,
425
+ type: 'list',
426
+ name: 'cronPreset',
427
+ message: '执行频率:',
428
+ choices: [
429
+ { name: '每天凌晨 2 点 (0 2 * * *)', value: '0 2 * * *' },
430
+ { name: '每小时整点 (0 * * * *)', value: '0 * * * *' },
431
+ { name: '每 6 小时 (0 */6 * * *)', value: '0 */6 * * *' },
432
+ { name: '每周一凌晨 2 点 (0 2 * * 1)', value: '0 2 * * 1' },
433
+ { name: '自定义 cron 表达式', value: 'custom' },
434
+ ],
435
+ when: (a) => a.appMode === 'cron',
436
+ },
437
+ {
438
+ type: 'input',
439
+ name: 'cronSchedule',
440
+ message: 'Cron 表达式(分 时 日 月 周):',
441
+ default: '0 2 * * *',
442
+ validate: (v) => /^(\S+\s+){4}\S+$/.test(v.trim()) ? true : '格式:分 时 日 月 周,如 0 2 * * *(每天凌晨 2 点)',
443
+ when: (a) => a.appMode === 'cron' && a.cronPreset === 'custom',
163
444
  },
164
445
  ]);
165
446
 
447
+ const projectAnswers = { ...typeAnswers, ...detailAnswers };
448
+
449
+ // 确定应用模式与步骤总数
450
+ const appMode = projectType === 'static' ? 'web' : (detailAnswers.appMode || 'web');
451
+ const cronSchedule = detailAnswers.appMode === 'cron'
452
+ ? (detailAnswers.cronPreset === 'custom' ? detailAnswers.cronSchedule : detailAnswers.cronPreset)
453
+ : null;
454
+ const isWebService = appMode === 'web';
455
+ const totalSteps = isWebService ? 5 : 4;
456
+
457
+ // ── Step 3: 域名 & HTTPS(仅 web 服务需要)────────────────────
458
+ let domainAnswers = {};
459
+ if (isWebService) {
460
+ step(3, totalSteps, '域名 & HTTPS(可选)');
461
+ domainAnswers = await inquirer.prompt([
462
+ {
463
+ type: 'confirm',
464
+ name: 'useDomain',
465
+ message: '是否配置域名?(没有域名用 IP 也可以)',
466
+ default: true,
467
+ },
468
+ {
469
+ type: 'input',
470
+ name: 'domain',
471
+ message: '你的域名(如 example.com):',
472
+ when: (a) => a.useDomain,
473
+ validate: (v) => v.trim() ? true : '请输入域名',
474
+ },
475
+ {
476
+ type: 'confirm',
477
+ name: 'useHttps',
478
+ message: '是否自动申请 HTTPS 证书?(免费,需要域名已解析到此服务器)',
479
+ default: true,
480
+ when: (a) => a.useDomain,
481
+ },
482
+ {
483
+ type: 'input',
484
+ name: 'certEmail',
485
+ message: 'Let\'s Encrypt 续期通知邮箱(留空则不注册邮箱):',
486
+ default: '',
487
+ when: (a) => a.useHttps,
488
+ },
489
+ ]);
490
+ } else {
491
+ const modeLabel = appMode === 'cron' ? '定时任务' : '后台脚本';
492
+ info(`跳过域名配置(${modeLabel}无需 Nginx 反向代理)`);
493
+ }
494
+
166
495
  const config = {
167
496
  ...serverAnswers,
168
497
  ...projectAnswers,
169
498
  ...domainAnswers,
499
+ appMode,
500
+ cronSchedule,
170
501
  domain: domainAnswers.domain || serverAnswers.host,
171
502
  deployedAt: new Date().toISOString(),
172
503
  };
173
504
 
174
505
  // ── Step 4: 安装环境 ──────────────────────────────────────────
175
- step(4, 5, '在服务器上安装运行环境');
506
+ step(isWebService ? 4 : 3, totalSteps, '在服务器上安装运行环境');
176
507
  console.log(chalk.gray(' 首次部署需要安装依赖,大约需要 2-5 分钟...\n'));
177
508
 
178
509
  try {
179
- // 创建部署目录
180
510
  await runRemote(ssh, `mkdir -p ${config.remotePath}`, '创建部署目录');
181
511
 
182
512
  const setupSteps = getSetupCommands(config);
183
513
  for (const s of setupSteps) {
184
514
  const sp = ora(` ${s.label}...`).start();
185
515
  try {
186
- await runRemoteSilent(ssh, s.cmd);
516
+ await runRemoteStrict(ssh, s.cmd);
187
517
  sp.succeed(chalk.green(s.label));
188
518
  } catch (err) {
189
- sp.warn(chalk.yellow(`${s.label} 失败,继续... (${err.message.slice(0, 60)})`));
519
+ // 安装步骤失败提示但继续(多数是"已安装"或网络抖动)
520
+ sp.warn(chalk.yellow(`${s.label} 失败:${err.message.split('\n')[0].slice(0, 80)}`));
190
521
  }
191
522
  }
192
523
  } catch (err) {
@@ -196,42 +527,77 @@ export async function deployInit() {
196
527
  }
197
528
 
198
529
  // ── Step 5: 上传代码 & 启动 ──────────────────────────────────
199
- step(5, 5, '上传代码并启动服务');
530
+ step(isWebService ? 5 : 4, totalSteps, '上传代码并启动服务');
200
531
 
201
532
  try {
202
- // 上传代码
533
+ // 上传代码(static 项目需要 dist 目录)
203
534
  const uploadSpinner = ora(' 上传项目文件...').start();
204
- await uploadDirectory(ssh, process.cwd(), config.remotePath);
535
+ const skipPatterns = config.projectType === 'static'
536
+ ? ['node_modules', '.git', '__pycache__', '.DS_Store', '.venv', 'venv']
537
+ : undefined;
538
+ await uploadDirectory(ssh, process.cwd(), config.remotePath, {
539
+ uploadEnv: !!config.uploadEnv,
540
+ skipPatterns,
541
+ });
205
542
  uploadSpinner.succeed('项目文件上传完成');
543
+ if (config.projectType === 'docker' && !config.uploadEnv && detectedDockerInfo?.localEnvExists) {
544
+ console.log(chalk.yellow(' ℹ .env 未上传,如需环境变量可在服务器手动创建:') + chalk.gray(` ${config.remotePath}/.env`));
545
+ }
206
546
 
207
- // 启动应用
547
+ // 启动应用(每一步都必须成功,失败立即抛错)
208
548
  const startSteps = getStartCommands(config);
209
549
  for (const s of startSteps) {
210
550
  const sp = ora(` ${s.label}...`).start();
211
- await runRemoteSilent(ssh, s.cmd);
212
- sp.succeed(s.label);
551
+ try {
552
+ await runRemoteStrict(ssh, s.cmd);
553
+ sp.succeed(s.label);
554
+ } catch (err) {
555
+ sp.fail(chalk.red(`${s.label} 失败`));
556
+ throw err;
557
+ }
558
+ }
559
+
560
+ // Nginx 只在 web 模式下配置
561
+ if (isWebService) {
562
+ const nginxConf = getNginxConfig(config);
563
+ const nginxPath = `/etc/nginx/sites-available/${config.appName}`;
564
+ await runRemoteStrict(ssh, writeFileHeredoc(nginxPath, nginxConf));
565
+ await runRemoteStrict(ssh, `ln -sf ${nginxPath} /etc/nginx/sites-enabled/${config.appName}`);
566
+ await runRemoteStrict(ssh, `rm -f /etc/nginx/sites-enabled/default`);
567
+ await runRemoteStrict(ssh, `nginx -t && systemctl reload nginx`);
568
+ success('Nginx 配置完成');
569
+
570
+ if (domainAnswers.useHttps && domainAnswers.domain) {
571
+ const httpsSpinner = ora(' 申请 SSL 证书...').start();
572
+ const emailArg = domainAnswers.certEmail && domainAnswers.certEmail.trim()
573
+ ? `--email ${domainAnswers.certEmail.trim()}`
574
+ : '--register-unsafely-without-email';
575
+ const certResult = await runRemoteSilent(
576
+ ssh,
577
+ `certbot --nginx -d ${config.domain} --non-interactive --agree-tos ${emailArg} --redirect`
578
+ );
579
+ if (certResult.code === 0) {
580
+ httpsSpinner.succeed('HTTPS 证书申请成功');
581
+ } else {
582
+ const tail = (certResult.stderr || certResult.stdout || '').split('\n').slice(-3).join(' ');
583
+ httpsSpinner.warn(`HTTPS 申请失败(可能是域名还没解析):${tail.slice(0, 120)}`);
584
+ }
585
+ }
213
586
  }
214
587
 
215
- // 配置 Nginx
216
- const nginxConf = getNginxConfig(config);
217
- const nginxPath = `/etc/nginx/sites-available/${config.appName}`;
218
- await runRemoteSilent(ssh, `echo '${nginxConf.replace(/'/g, "'\\''")}' > ${nginxPath}`);
219
- await runRemoteSilent(ssh, `ln -sf ${nginxPath} /etc/nginx/sites-enabled/${config.appName}`);
220
- await runRemoteSilent(ssh, `rm -f /etc/nginx/sites-enabled/default`);
221
- await runRemoteSilent(ssh, `nginx -t && systemctl reload nginx`);
222
- success('Nginx 配置完成');
223
-
224
- // HTTPS
225
- if (domainAnswers.useHttps && domainAnswers.domain) {
226
- const httpsSpinner = ora(' 申请 SSL 证书...').start();
227
- const certResult = await runRemoteSilent(
228
- ssh,
229
- `certbot --nginx -d ${config.domain} --non-interactive --agree-tos --email admin@${config.domain} --redirect`
230
- );
231
- if (certResult.code === 0) {
232
- httpsSpinner.succeed(`HTTPS 证书申请成功`);
588
+ // ── 健康检查 ─────────────────────────────────────────────
589
+ const health = getHealthCheck(config);
590
+ if (health) {
591
+ const hSpinner = ora(' 验证服务运行状态...').start();
592
+ // 给服务 2 秒启动时间
593
+ await runRemoteSilent(ssh, 'sleep 2');
594
+ const result = await runRemoteSilent(ssh, health.cmd);
595
+ const parsed = health.parse(result);
596
+ if (parsed.ok) {
597
+ hSpinner.succeed(chalk.green(`服务运行正常 — ${parsed.detail}`));
233
598
  } else {
234
- httpsSpinner.warn('HTTPS 申请失败(可能是域名还没解析),可以之后手动运行 certbot)');
599
+ hSpinner.warn(chalk.yellow(`健康检查未通过 ${parsed.detail}`));
600
+ console.log(chalk.gray(` 可运行 ${chalk.cyan('deploy-helper status')} 进一步排查。`));
235
601
  }
236
602
  }
237
603
 
@@ -246,14 +612,25 @@ export async function deployInit() {
246
612
  ssh.dispose();
247
613
 
248
614
  // 完成!
249
- const accessUrl = domainAnswers.useHttps && domainAnswers.domain
250
- ? `https://${config.domain}`
251
- : domainAnswers.domain
252
- ? `http://${config.domain}`
253
- : `http://${config.host}:${config.port || 80}`;
254
-
255
615
  console.log(chalk.green.bold('\n🎉 部署成功!\n'));
256
- console.log(` 访问地址:${chalk.cyan.underline(accessUrl)}`);
616
+
617
+ if (isWebService) {
618
+ const accessUrl = domainAnswers.useHttps && domainAnswers.domain
619
+ ? `https://${config.domain}`
620
+ : domainAnswers.domain
621
+ ? `http://${config.domain}`
622
+ : `http://${config.host}:${config.port || 80}`;
623
+ console.log(` 访问地址:${chalk.cyan.underline(accessUrl)}`);
624
+ } else if (appMode === 'cron') {
625
+ console.log(` 定时计划:${chalk.cyan(config.cronSchedule)}`);
626
+ console.log(` 日志查看:${chalk.cyan(`tail -f /var/log/${config.appName}.log`)}`);
627
+ console.log(` 修改计划:${chalk.gray('crontab -e')}`);
628
+ } else {
629
+ console.log(` 进程状态:${chalk.cyan(`supervisorctl status ${config.appName}`)}`);
630
+ console.log(` 输出日志:${chalk.cyan(`tail -f /var/log/${config.appName}.out.log`)}`);
631
+ console.log(` 错误日志:${chalk.cyan(`tail -f /var/log/${config.appName}.err.log`)}`);
632
+ }
633
+
257
634
  console.log(` 配置已保存至:${chalk.gray('.deploy-config.json')}`);
258
635
  console.log('\n后续操作:');
259
636
  console.log(` 更新代码 → ${chalk.cyan('deploy-helper update')}`);