@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.
@@ -0,0 +1,264 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import inquirer from 'inquirer';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import crypto from 'crypto';
7
+ import { connectSSH, runRemoteSilent } from '../utils/ssh.js';
8
+ import { loadConfig } from '../utils/config.js';
9
+
10
+ const ENV_BACKUP_DIR = '/var/deploy-helper/env-backups';
11
+
12
+ /**
13
+ * 简单对称加密(AES-256-GCM),用于传输时保护内容
14
+ */
15
+ function encryptContent(text, key) {
16
+ const salt = crypto.randomBytes(16);
17
+ const derivedKey = crypto.scryptSync(key, salt, 32);
18
+ const iv = crypto.randomBytes(12);
19
+ const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv);
20
+ const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
21
+ const tag = cipher.getAuthTag();
22
+ return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
23
+ }
24
+
25
+ function decryptContent(encryptedBase64, key) {
26
+ const data = Buffer.from(encryptedBase64, 'base64');
27
+ const salt = data.slice(0, 16);
28
+ const iv = data.slice(16, 28);
29
+ const tag = data.slice(28, 44);
30
+ const encrypted = data.slice(44);
31
+ const derivedKey = crypto.scryptSync(key, salt, 32);
32
+ const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv);
33
+ decipher.setAuthTag(tag);
34
+ return decipher.update(encrypted) + decipher.final('utf8');
35
+ }
36
+
37
+ /**
38
+ * 解析 .env 文件,返回键值对数组(过滤注释和空行)
39
+ */
40
+ function parseEnvFile(content) {
41
+ return content
42
+ .split('\n')
43
+ .map((line, i) => ({ line: line.trim(), num: i + 1 }))
44
+ .filter(({ line }) => line && !line.startsWith('#'))
45
+ .map(({ line, num }) => {
46
+ const eqIdx = line.indexOf('=');
47
+ if (eqIdx === -1) return null;
48
+ const key = line.slice(0, eqIdx).trim();
49
+ const value = line.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
50
+ return { key, value, num };
51
+ })
52
+ .filter(Boolean);
53
+ }
54
+
55
+ /**
56
+ * 展示 .env 内容预览(隐藏敏感值)
57
+ */
58
+ function previewEnv(vars) {
59
+ console.log(chalk.bold('\n .env 文件内容预览:\n'));
60
+ vars.forEach(({ key, value }) => {
61
+ const isSensitive = /secret|password|key|token|pwd|pass/i.test(key);
62
+ const displayVal = isSensitive
63
+ ? chalk.gray(value.slice(0, 3) + '***' + value.slice(-2))
64
+ : chalk.gray(value.length > 40 ? value.slice(0, 40) + '...' : value);
65
+ console.log(` ${chalk.cyan(key)}=${displayVal}`);
66
+ });
67
+ console.log('');
68
+ }
69
+
70
+ export async function deployEnv() {
71
+ const config = loadConfig();
72
+ if (!config) {
73
+ console.log(chalk.red('\n没有找到部署配置,请先运行:') + chalk.cyan(' deploy-helper init\n'));
74
+ return;
75
+ }
76
+
77
+ // 检查本地 .env 是否存在
78
+ const localEnvPath = path.join(process.cwd(), '.env');
79
+ if (!fs.existsSync(localEnvPath)) {
80
+ console.log(chalk.yellow('\n本地没有找到 .env 文件。'));
81
+ const { create } = await inquirer.prompt([{
82
+ type: 'confirm',
83
+ name: 'create',
84
+ message: '是否从服务器拉取现有的 .env?',
85
+ default: true,
86
+ }]);
87
+ if (create) {
88
+ await pullEnv(config);
89
+ }
90
+ return;
91
+ }
92
+
93
+ const envContent = fs.readFileSync(localEnvPath, 'utf-8');
94
+ const vars = parseEnvFile(envContent);
95
+
96
+ if (vars.length === 0) {
97
+ console.log(chalk.yellow('\n.env 文件是空的或只有注释。\n'));
98
+ return;
99
+ }
100
+
101
+ previewEnv(vars);
102
+
103
+ const { action } = await inquirer.prompt([{
104
+ type: 'list',
105
+ name: 'action',
106
+ message: '要做什么?',
107
+ choices: [
108
+ { name: '上传本地 .env 到服务器(覆盖)', value: 'push' },
109
+ { name: '从服务器拉取 .env 到本地', value: 'pull' },
110
+ { name: '对比本地和服务器的 .env 差异', value: 'diff' },
111
+ ],
112
+ }]);
113
+
114
+ if (action === 'push') await pushEnv(config, envContent, vars);
115
+ else if (action === 'pull') await pullEnv(config);
116
+ else if (action === 'diff') await diffEnv(config, vars);
117
+ }
118
+
119
+ async function pushEnv(config, envContent, vars) {
120
+ const { confirm } = await inquirer.prompt([{
121
+ type: 'confirm',
122
+ name: 'confirm',
123
+ message: `确认上传 ${vars.length} 个变量到服务器 ${config.host}?(将覆盖服务器上现有的 .env)`,
124
+ default: true,
125
+ }]);
126
+ if (!confirm) return;
127
+
128
+ let ssh;
129
+ const spinner = ora('连接服务器...').start();
130
+ try {
131
+ ssh = await connectSSH(config);
132
+ spinner.succeed('连接成功');
133
+ } catch (err) {
134
+ spinner.fail('连接失败:' + err.message);
135
+ return;
136
+ }
137
+
138
+ try {
139
+ // 备份服务器现有 .env
140
+ const backupSpinner = ora('备份服务器现有 .env...').start();
141
+ await runRemoteSilent(ssh, `mkdir -p ${ENV_BACKUP_DIR}`);
142
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
143
+ await runRemoteSilent(
144
+ ssh,
145
+ `[ -f ${config.remotePath}/.env ] && cp ${config.remotePath}/.env ${ENV_BACKUP_DIR}/.env_${config.appName}_${timestamp} || true`
146
+ );
147
+ backupSpinner.succeed('已备份旧 .env');
148
+
149
+ // 上传新 .env(通过 SSH 直接写入,不走文件上传避免明文落盘)
150
+ const uploadSpinner = ora('上传 .env 到服务器...').start();
151
+ const escaped = envContent.replace(/'/g, "'\\''");
152
+ await runRemoteSilent(ssh, `cat > ${config.remotePath}/.env << 'DEPLOY_HELPER_EOF'\n${escaped}\nDEPLOY_HELPER_EOF`);
153
+ await runRemoteSilent(ssh, `chmod 600 ${config.remotePath}/.env`);
154
+ uploadSpinner.succeed('.env 上传完成,权限已设为 600');
155
+
156
+ // 重启服务让新环境变量生效
157
+ const { restart } = await inquirer.prompt([{
158
+ type: 'confirm',
159
+ name: 'restart',
160
+ message: '是否重启服务让新变量生效?',
161
+ default: true,
162
+ }]);
163
+
164
+ if (restart) {
165
+ const restartSpinner = ora('重启服务...').start();
166
+ if (config.projectType === 'nodejs') {
167
+ await runRemoteSilent(ssh, `pm2 restart ${config.appName}`);
168
+ } else if (config.projectType === 'python') {
169
+ await runRemoteSilent(ssh, `supervisorctl restart ${config.appName}`);
170
+ } else if (config.projectType === 'docker') {
171
+ await runRemoteSilent(ssh, `cd ${config.remotePath} && docker compose up -d`);
172
+ }
173
+ restartSpinner.succeed('服务已重启');
174
+ }
175
+
176
+ ssh.dispose();
177
+ console.log(chalk.green.bold('\n✅ .env 同步完成!\n'));
178
+
179
+ } catch (err) {
180
+ console.log(chalk.red('\n上传失败:' + err.message));
181
+ ssh.dispose();
182
+ }
183
+ }
184
+
185
+ async function pullEnv(config) {
186
+ let ssh;
187
+ const spinner = ora('连接服务器...').start();
188
+ try {
189
+ ssh = await connectSSH(config);
190
+ spinner.succeed('连接成功');
191
+ } catch (err) {
192
+ spinner.fail('连接失败:' + err.message);
193
+ return;
194
+ }
195
+
196
+ try {
197
+ const result = await runRemoteSilent(ssh, `cat ${config.remotePath}/.env 2>/dev/null || echo ""`);
198
+ ssh.dispose();
199
+
200
+ if (!result.stdout.trim()) {
201
+ console.log(chalk.yellow('\n服务器上没有 .env 文件。\n'));
202
+ return;
203
+ }
204
+
205
+ const localEnvPath = path.join(process.cwd(), '.env');
206
+ if (fs.existsSync(localEnvPath)) {
207
+ const { overwrite } = await inquirer.prompt([{
208
+ type: 'confirm',
209
+ name: 'overwrite',
210
+ message: '本地已有 .env,确认覆盖?',
211
+ default: false,
212
+ }]);
213
+ if (!overwrite) return;
214
+ }
215
+
216
+ fs.writeFileSync(localEnvPath, result.stdout);
217
+ console.log(chalk.green.bold('\n✅ 已从服务器拉取 .env 到本地。\n'));
218
+
219
+ } catch (err) {
220
+ console.log(chalk.red('\n拉取失败:' + err.message));
221
+ ssh.dispose();
222
+ }
223
+ }
224
+
225
+ async function diffEnv(config, localVars) {
226
+ let ssh;
227
+ const spinner = ora('获取服务器 .env...').start();
228
+ try {
229
+ ssh = await connectSSH(config);
230
+ const result = await runRemoteSilent(ssh, `cat ${config.remotePath}/.env 2>/dev/null || echo ""`);
231
+ ssh.dispose();
232
+ spinner.succeed('获取完成');
233
+
234
+ const remoteVars = parseEnvFile(result.stdout);
235
+ const localMap = Object.fromEntries(localVars.map(v => [v.key, v.value]));
236
+ const remoteMap = Object.fromEntries(remoteVars.map(v => [v.key, v.value]));
237
+
238
+ const allKeys = new Set([...Object.keys(localMap), ...Object.keys(remoteMap)]);
239
+
240
+ console.log(chalk.bold('\n 差异对比(本地 vs 服务器):\n'));
241
+ let hasDiff = false;
242
+
243
+ for (const key of allKeys) {
244
+ if (!(key in localMap)) {
245
+ console.log(chalk.red(` - ${key}`) + chalk.gray('(仅服务器有)'));
246
+ hasDiff = true;
247
+ } else if (!(key in remoteMap)) {
248
+ console.log(chalk.green(` + ${key}`) + chalk.gray('(仅本地有)'));
249
+ hasDiff = true;
250
+ } else if (localMap[key] !== remoteMap[key]) {
251
+ console.log(chalk.yellow(` ~ ${key}`) + chalk.gray('(值不同)'));
252
+ hasDiff = true;
253
+ }
254
+ }
255
+
256
+ if (!hasDiff) {
257
+ console.log(chalk.green(' 本地和服务器 .env 完全一致 ✓'));
258
+ }
259
+ console.log('');
260
+
261
+ } catch (err) {
262
+ spinner.fail('获取失败:' + err.message);
263
+ }
264
+ }
@@ -0,0 +1,261 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import path from 'path';
5
+ import os from 'os';
6
+ import { connectSSH, runRemote, runRemoteSilent, uploadDirectory } from '../utils/ssh.js';
7
+ 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
+
11
+ const step = (n, total, msg) =>
12
+ console.log(chalk.cyan(`\n[${n}/${total}] `) + chalk.bold(msg));
13
+
14
+ const success = (msg) => console.log(chalk.green(' ✓ ') + msg);
15
+ const info = (msg) => console.log(chalk.gray(' ℹ ') + msg);
16
+
17
+ export async function deployInit() {
18
+ // 已有配置,询问是否覆盖
19
+ if (configExists()) {
20
+ const existing = loadConfig();
21
+ const { overwrite } = await inquirer.prompt([{
22
+ type: 'confirm',
23
+ name: 'overwrite',
24
+ message: `检测到已有部署配置(服务器:${existing.host}),要重新配置吗?`,
25
+ default: false,
26
+ }]);
27
+ if (!overwrite) {
28
+ console.log(chalk.yellow('\n已取消。如需更新代码,运行:') + chalk.cyan(' deploy-helper update\n'));
29
+ return;
30
+ }
31
+ }
32
+
33
+ console.log(chalk.gray('回答几个问题,我来帮你搞定剩下的一切 👇\n'));
34
+
35
+ // ── Step 1: 服务器信息 ──────────────────────────────────────────
36
+ step(1, 5, '服务器连接信息');
37
+
38
+ const serverAnswers = await inquirer.prompt([
39
+ {
40
+ type: 'input',
41
+ name: 'host',
42
+ message: '服务器 IP 地址:',
43
+ validate: (v) => v.trim() ? true : '请输入 IP 地址',
44
+ },
45
+ {
46
+ type: 'input',
47
+ name: 'port',
48
+ message: 'SSH 端口:',
49
+ default: '22',
50
+ },
51
+ {
52
+ type: 'input',
53
+ name: 'user',
54
+ message: '登录用户名:',
55
+ default: 'root',
56
+ },
57
+ {
58
+ type: 'list',
59
+ name: 'authType',
60
+ message: '登录方式:',
61
+ choices: [
62
+ { name: 'SSH 密钥(推荐)', value: 'key' },
63
+ { name: '密码', value: 'password' },
64
+ ],
65
+ },
66
+ {
67
+ type: 'input',
68
+ name: 'keyPath',
69
+ message: 'SSH 密钥路径:',
70
+ default: `${os.homedir()}/.ssh/id_rsa`,
71
+ when: (a) => a.authType === 'key',
72
+ },
73
+ {
74
+ type: 'password',
75
+ name: 'password',
76
+ message: '服务器密码:',
77
+ when: (a) => a.authType === 'password',
78
+ mask: '*',
79
+ },
80
+ ]);
81
+
82
+ // 测试连接
83
+ const spinner = ora('正在连接服务器...').start();
84
+ let ssh;
85
+ try {
86
+ ssh = await connectSSH(serverAnswers);
87
+ spinner.succeed(chalk.green('服务器连接成功!'));
88
+ } catch (err) {
89
+ spinner.fail(chalk.red('连接失败:' + err.message));
90
+ console.log(chalk.yellow('\n排查建议:'));
91
+ console.log(' • 检查 IP 和端口是否正确');
92
+ console.log(' • 确认服务器安全组已开放 22 端口');
93
+ console.log(' • 如用密钥登录,确认密钥路径和权限(chmod 600)');
94
+ return;
95
+ }
96
+
97
+ // ── Step 2: 项目信息 ──────────────────────────────────────────
98
+ step(2, 5, '项目信息');
99
+
100
+ const detectedType = detectProjectType();
101
+ info(`自动检测到项目类型:${PROJECT_TYPE_LABELS[detectedType]}`);
102
+
103
+ const projectAnswers = await inquirer.prompt([
104
+ {
105
+ type: 'list',
106
+ name: 'projectType',
107
+ message: '确认项目类型:',
108
+ default: detectedType,
109
+ choices: Object.entries(PROJECT_TYPE_LABELS).map(([value, name]) => ({ name, value })),
110
+ },
111
+ {
112
+ type: 'input',
113
+ name: 'appName',
114
+ message: '应用名称(用于进程管理):',
115
+ default: path.basename(process.cwd()),
116
+ validate: (v) => /^[a-z0-9_-]+$/i.test(v) ? true : '只能包含字母、数字、下划线和连字符',
117
+ },
118
+ {
119
+ type: 'input',
120
+ name: 'remotePath',
121
+ message: '部署到服务器的路径:',
122
+ default: (a) => `/var/www/${a.appName}`,
123
+ },
124
+ {
125
+ type: 'input',
126
+ name: 'startCmd',
127
+ message: '启动命令:',
128
+ default: 'node index.js',
129
+ when: (a) => ['nodejs', 'python'].includes(a.projectType),
130
+ },
131
+ {
132
+ type: 'input',
133
+ name: 'port',
134
+ message: '应用监听的端口:',
135
+ default: '3000',
136
+ when: (a) => ['nodejs', 'python', 'docker'].includes(a.projectType),
137
+ },
138
+ ]);
139
+
140
+ // ── Step 3: 域名 & HTTPS ──────────────────────────────────────
141
+ step(3, 5, '域名 & HTTPS(可选)');
142
+
143
+ const domainAnswers = await inquirer.prompt([
144
+ {
145
+ type: 'confirm',
146
+ name: 'useDomain',
147
+ message: '是否配置域名?(没有域名用 IP 也可以)',
148
+ default: true,
149
+ },
150
+ {
151
+ type: 'input',
152
+ name: 'domain',
153
+ message: '你的域名(如 example.com):',
154
+ when: (a) => a.useDomain,
155
+ validate: (v) => v.trim() ? true : '请输入域名',
156
+ },
157
+ {
158
+ type: 'confirm',
159
+ name: 'useHttps',
160
+ message: '是否自动申请 HTTPS 证书?(免费,需要域名已解析到此服务器)',
161
+ default: true,
162
+ when: (a) => a.useDomain,
163
+ },
164
+ ]);
165
+
166
+ const config = {
167
+ ...serverAnswers,
168
+ ...projectAnswers,
169
+ ...domainAnswers,
170
+ domain: domainAnswers.domain || serverAnswers.host,
171
+ deployedAt: new Date().toISOString(),
172
+ };
173
+
174
+ // ── Step 4: 安装环境 ──────────────────────────────────────────
175
+ step(4, 5, '在服务器上安装运行环境');
176
+ console.log(chalk.gray(' 首次部署需要安装依赖,大约需要 2-5 分钟...\n'));
177
+
178
+ try {
179
+ // 创建部署目录
180
+ await runRemote(ssh, `mkdir -p ${config.remotePath}`, '创建部署目录');
181
+
182
+ const setupSteps = getSetupCommands(config);
183
+ for (const s of setupSteps) {
184
+ const sp = ora(` ${s.label}...`).start();
185
+ try {
186
+ await runRemoteSilent(ssh, s.cmd);
187
+ sp.succeed(chalk.green(s.label));
188
+ } catch (err) {
189
+ sp.warn(chalk.yellow(`${s.label} 失败,继续... (${err.message.slice(0, 60)})`));
190
+ }
191
+ }
192
+ } catch (err) {
193
+ console.log(chalk.red('\n环境安装失败:' + err.message));
194
+ ssh.dispose();
195
+ return;
196
+ }
197
+
198
+ // ── Step 5: 上传代码 & 启动 ──────────────────────────────────
199
+ step(5, 5, '上传代码并启动服务');
200
+
201
+ try {
202
+ // 上传代码
203
+ const uploadSpinner = ora(' 上传项目文件...').start();
204
+ await uploadDirectory(ssh, process.cwd(), config.remotePath);
205
+ uploadSpinner.succeed('项目文件上传完成');
206
+
207
+ // 启动应用
208
+ const startSteps = getStartCommands(config);
209
+ for (const s of startSteps) {
210
+ const sp = ora(` ${s.label}...`).start();
211
+ await runRemoteSilent(ssh, s.cmd);
212
+ sp.succeed(s.label);
213
+ }
214
+
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 证书申请成功`);
233
+ } else {
234
+ httpsSpinner.warn('HTTPS 申请失败(可能是域名还没解析),可以之后手动运行 certbot)');
235
+ }
236
+ }
237
+
238
+ } catch (err) {
239
+ console.log(chalk.red('\n部署失败:' + err.message));
240
+ ssh.dispose();
241
+ return;
242
+ }
243
+
244
+ // 保存配置
245
+ saveConfig(config);
246
+ ssh.dispose();
247
+
248
+ // 完成!
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
+ console.log(chalk.green.bold('\n🎉 部署成功!\n'));
256
+ console.log(` 访问地址:${chalk.cyan.underline(accessUrl)}`);
257
+ console.log(` 配置已保存至:${chalk.gray('.deploy-config.json')}`);
258
+ console.log('\n后续操作:');
259
+ console.log(` 更新代码 → ${chalk.cyan('deploy-helper update')}`);
260
+ console.log(` 查看状态 → ${chalk.cyan('deploy-helper status')}\n`);
261
+ }