ql-publish 0.0.8 → 0.0.9

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 ADDED
@@ -0,0 +1,259 @@
1
+ # ql-publish
2
+
3
+ 一个面向前端项目的自动化发布工具,封装了 SSH/SFTP 上传、阿里云 CDN 刷新、OSS 上传与远程旧文件清理等常见发布动作。
4
+
5
+ ## 如何发布本项目到 npm
6
+
7
+ > 面向本仓库维护者:发布新版本到 [npm registry](https://www.npmjs.com/package/ql-publish) 的标准流程。
8
+
9
+ 1. **登录 npm 账号**(首次或登录过期时执行)
10
+
11
+ ```bash
12
+ npm login
13
+ # 按提示输入用户名、密码、邮箱、OTP
14
+ # 验证当前登录身份:
15
+ npm whoami
16
+ ```
17
+
18
+ 2. **确认 registry 指向官方源**(如果用过淘宝镜像需切回)
19
+
20
+ ```bash
21
+ npm config get registry
22
+ # 不是 https://registry.npmjs.org/ 时执行:
23
+ npm config set registry https://registry.npmjs.org/
24
+ ```
25
+
26
+ 3. **更新版本号**:修改 [package.json](package.json) 里的 `version`,或使用:
27
+
28
+ ```bash
29
+ npm version patch # 0.0.9 -> 0.0.10
30
+ npm version minor # 0.0.9 -> 0.1.0
31
+ npm version major # 0.0.9 -> 1.0.0
32
+ ```
33
+
34
+ 4. **同步更新文档**:在本 README 「更新日志」追加新版本变更说明。
35
+
36
+ 5. **本地自检**:发布前先看看会被打包进 npm 的文件有哪些。
37
+
38
+ ```bash
39
+ npm pack --dry-run
40
+ ```
41
+
42
+ 6. **正式发布**
43
+
44
+ ```bash
45
+ npm publish
46
+ # 如果包名带 scope(如 @xxx/ql-publish)且需要公开:
47
+ # npm publish --access public
48
+ ```
49
+
50
+ 7. **验证发布结果**
51
+
52
+ ```bash
53
+ npm view ql-publish version
54
+ ```
55
+
56
+ > 注意:npm 已发布的版本号**不可覆盖**,发错了只能 `npm unpublish`(24 小时内)或发布更高版本号修复。
57
+
58
+ ## 安装
59
+
60
+ ```bash
61
+ npm i ql-publish
62
+ # or
63
+ yarn add ql-publish
64
+ # or
65
+ pnpm add ql-publish
66
+ ```
67
+
68
+ ## 导出的 API
69
+
70
+ | 方法 | 用途 |
71
+ | --- | --- |
72
+ | `initConfig(config, coreConfig)` | 注入业务配置与凭证配置,所有后续方法必须先调用一次 |
73
+ | `upload()` | 把本地构建产物通过 SFTP 上传到远程服务器,并写入 `fileMap.json` 作为发布基线 |
74
+ | `clear()` | 根据远程 `fileMap.json` 的 `mtime` 基线,递归清理「修改时间不晚于基线」的旧文件,支持白名单 |
75
+ | `refresh()` | 刷新阿里云 CDN(按 `config[NODE_ENV].cdn` 列表) |
76
+ | `reset()` | 回滚到上一次发布的备份版本(依赖 `backUp: true`) |
77
+
78
+ ## 配置结构
79
+
80
+ 调用方需要构造两个对象:业务配置 `config`、凭证配置 `coreConfig`,分别通过 `initConfig` 传入。
81
+
82
+ ### `config`(业务配置)
83
+
84
+ ```js
85
+ module.exports = {
86
+ test: {
87
+ localPath: '本地构建产物目录(绝对路径)',
88
+ path: '远程服务器部署路径',
89
+ backUpSeconds: 60 * 60 * 24 * 30, // 备份保留时长(秒)
90
+ backUp: false, // 是否开启备份
91
+ clearExcludes: ['dist.tar.gz'], // 清理白名单,详见下文
92
+ },
93
+ pro: {
94
+ localPath: '...',
95
+ path: '...',
96
+ backUpSeconds: 60 * 60 * 24 * 7,
97
+ backUp: true,
98
+ clearExcludes: ['dist.tar.gz'],
99
+ cdn: [
100
+ { type: 'File', url: 'https://example.com/your-app/' },
101
+ { type: 'File', url: 'https://example.com/your-app/index.html' },
102
+ ],
103
+ },
104
+ oss: {
105
+ localDir: '本地构建产物目录(绝对路径)',
106
+ targetDir: 'oss 上的目标前缀',
107
+ backUpSeconds: 60 * 60 * 24 * 30,
108
+ cdn: [/* 同上 */],
109
+ },
110
+ };
111
+ ```
112
+
113
+ ### `coreConfig`(凭证配置)
114
+
115
+ ```js
116
+ const fs = require('fs');
117
+
118
+ module.exports = {
119
+ cdn: {
120
+ accessKeyId: process.env.cdn_accessKeyId,
121
+ accessKeySecret: process.env.cdn_accessKeySecret,
122
+ endpoint: 'cdn.aliyuncs.com',
123
+ },
124
+ test: {
125
+ host: '服务器 IP',
126
+ port: 22,
127
+ username: 'root',
128
+ privateKey: fs.readFileSync(process.env.id_ed25519),
129
+ },
130
+ pro: {
131
+ host: '...',
132
+ port: 22,
133
+ username: 'root',
134
+ privateKey: fs.readFileSync(process.env.ali_rsa),
135
+ },
136
+ oss: {
137
+ accessKeyId: process.env.oss_accessKeyId,
138
+ accessKeySecret: process.env.oss_accessKeySecret,
139
+ region: 'oss-cn-shenzhen',
140
+ bucket: process.env.oss_bucket,
141
+ },
142
+ };
143
+ ```
144
+
145
+ ## 使用示例
146
+
147
+ 最佳实践是把 4 个动作各自拆成独立脚本,再用 `package.json` 的 `scripts` 串联 `cross-env NODE_ENV=test|pro`。
148
+
149
+ ```js
150
+ // upload.js
151
+ const config = require('./config');
152
+ const coreConfig = require('./core/core');
153
+ const { initConfig, upload } = require('ql-publish');
154
+
155
+ initConfig(config, coreConfig);
156
+ upload();
157
+ ```
158
+
159
+ ```js
160
+ // clear.js
161
+ const config = require('./config');
162
+ const coreConfig = require('./core/core');
163
+ const { initConfig, clear } = require('ql-publish');
164
+
165
+ initConfig(config, coreConfig);
166
+ clear();
167
+ ```
168
+
169
+ ```js
170
+ // refresh.js
171
+ const config = require('./config');
172
+ const coreConfig = require('./core/core');
173
+ const { initConfig, refresh } = require('ql-publish');
174
+
175
+ initConfig(config, coreConfig);
176
+ refresh();
177
+ ```
178
+
179
+ ```js
180
+ // reset.js
181
+ const config = require('./config');
182
+ const coreConfig = require('./core/core');
183
+ const { initConfig, reset } = require('ql-publish');
184
+
185
+ initConfig(config, coreConfig);
186
+ reset();
187
+ ```
188
+
189
+ ```json
190
+ // package.json 片段
191
+ {
192
+ "scripts": {
193
+ "upload:test": "cross-env NODE_ENV=test node ./upload/index.js",
194
+ "upload:pro": "cross-env NODE_ENV=pro node ./upload/index.js",
195
+ "clear:test": "cross-env NODE_ENV=test node ./upload/clear.js",
196
+ "clear:pro": "cross-env NODE_ENV=pro node ./upload/clear.js",
197
+ "refresh:cdn": "cross-env NODE_ENV=pro node ./upload/refresh.js",
198
+ "reset:test": "cross-env NODE_ENV=test node ./upload/reset.js",
199
+ "reset:pro": "cross-env NODE_ENV=pro node ./upload/reset.js"
200
+ }
201
+ }
202
+ ```
203
+
204
+ ## `clearExcludes` 清理白名单(v0.0.9+)
205
+
206
+ `clear()` 默认会把远程目录下「mtime 不晚于发布基线」的旧文件全部删除。若你的发布流程在服务器上保留了**与版本无关的常驻文件**(例如压缩包归档、人工上传的资源),可以通过 `clearExcludes` 让它们免删。
207
+
208
+ ### 配置位置
209
+
210
+ ```js
211
+ // config.js
212
+ module.exports = {
213
+ test: {
214
+ // ...其它字段
215
+ clearExcludes: ['dist.tar.gz'],
216
+ },
217
+ pro: {
218
+ // ...其它字段
219
+ clearExcludes: ['dist.tar.gz'],
220
+ },
221
+ };
222
+ ```
223
+
224
+ ### 三类匹配规则
225
+
226
+ | 规则形态 | 匹配方式 | 大小写 | 示例 |
227
+ | --- | --- | --- | --- |
228
+ | 以 `.` 开头 | 扩展名后缀匹配(`endsWith`),支持多段后缀 | 不区分 | `.tar.gz`、`.zip` |
229
+ | 含 `*` 字符 | 子串匹配(去掉所有 `*` 后用 `includes`) | 区分 | `*keep*`、`backup-*` |
230
+ | 其它 | 文件名精确匹配(`===`) | 区分 | `dist.tar.gz`、`fileMap.json` |
231
+
232
+ 任一规则命中即跳过删除。
233
+
234
+ ### 配置示例
235
+
236
+ ```js
237
+ // 1) 只想保留某个具体文件
238
+ clearExcludes: ['dist.tar.gz']
239
+
240
+ // 2) 想保留全部压缩包归档
241
+ clearExcludes: ['.tar.gz', '.zip']
242
+
243
+ // 3) 想保留所有名字里带 keep 的文件,且排除所有 .zip
244
+ clearExcludes: ['*keep*', '.zip']
245
+ ```
246
+
247
+ ### 行为说明
248
+
249
+ - 白名单**只作用于文件层级**:目录始终会被递归进入,以便清理其子文件。
250
+ - 命中白名单时控制台会打印 `⏭ 已保留文件: <远程路径>`,便于核对。
251
+ - 启动时若 `clearExcludes` 非空,会先打印 `🛡 本次清理将保留以下规则命中的文件: [...]`,供发布者在二次确认之前可视化检查规则。
252
+ - 未配置 / 配置为空数组 / 非数组类型 → 退化为**无白名单**,行为与 `0.0.8` 完全一致,可零成本升级。
253
+
254
+ ## 更新日志
255
+
256
+ ### 0.0.9
257
+
258
+ - 新增 `clearExcludes` 配置,支持「精确文件名 / 扩展名后缀 / 子串」三类规则跳过清理。
259
+ - 旧版本零破坏性升级:未配置该字段时行为与 `0.0.8` 完全一致。
package/config/config.js CHANGED
@@ -24,11 +24,13 @@ module.exports = {
24
24
  // 项目旧版本代码保存最近3个月备份
25
25
  backUpSeconds: _config.test.backUpSeconds,
26
26
  // 如果私钥有密码,需要提供
27
- // passphrase: 'your-passphrase',
27
+ // passphrase: 'your-passphrase',
28
28
  // 服务器上的部署路径
29
29
  path: _config.test.path,
30
30
  // 是否备份
31
31
  backUp: _config.test.backUp,
32
+ // 清理时跳过的文件白名单(支持精确名 / .扩展名 / *子串 三种规则)
33
+ clearExcludes: _config.test.clearExcludes,
32
34
  },
33
35
  pro: {
34
36
  localPath: _config.pro.localPath,
@@ -40,11 +42,13 @@ module.exports = {
40
42
  // 项目旧版本代码保存最近3个月备份
41
43
  backUpSeconds: _config.pro.backUpSeconds,
42
44
  // 如果私钥有密码,需要提供
43
- // passphrase: 'your-passphrase',
45
+ // passphrase: 'your-passphrase',
44
46
  // 服务器上的部署路径
45
47
  path: _config.pro.path,
46
48
  // 是否备份
47
49
  backUp: _config.pro.backUp,
50
+ // 清理时跳过的文件白名单(支持精确名 / .扩展名 / *子串 三种规则)
51
+ clearExcludes: _config.pro.clearExcludes,
48
52
  // cdn 配置
49
53
  ...ossConfigCdn(),
50
54
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ql-publish",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "scripts": {},
5
5
  "dependencies": {
6
6
  "@alicloud/cdn20180510": "^7.0.1",
package/upload/clear.js CHANGED
@@ -1,159 +1,267 @@
1
- const config = require('../config/config.js')[process.env.NODE_ENV];
2
- const logger = require('../utils/logger');
3
- const { Client } = require('ssh2');
4
- const readline = require("readline");
5
- const path = require("path");
6
-
7
- const readStreamByFile = async (sftp, remoteFilePath) => {
8
- return new Promise((resolve) => {
9
- console.log(`准备读取文件: ${remoteFilePath}`);
10
-
11
- // 创建读取流
12
- const readStream = sftp.createReadStream(remoteFilePath, {
13
- encoding: 'utf8' // 以UTF-8编码读取
14
- });
15
-
16
- let fileContent = '';
17
-
18
- // 收集文件内容
19
- readStream.on('data', (chunk) => {
20
- fileContent += chunk;
21
- });
22
-
23
- // 读取完成处理
24
- readStream.on('end', () => {
25
- try {
26
- // 解析JSON数据
27
- const jsonData = JSON.parse(fileContent);
28
- console.log('文件内容解析成功:');
29
- resolve(jsonData)
30
- } catch (parseErr) {
31
- logger.error('JSON解析失败:', parseErr);
32
- console.log('原始文件内容:', fileContent);
33
- process.exit(1);
34
- }
35
- });
36
-
37
- // 处理读取错误
38
- readStream.on('error', (err) => {
39
- logger.error('读取文件时发生错误:', err);
40
- process.exit(1);
41
- });
42
- })
43
- }
44
-
45
- const isHasDir = (sftp, dirPath) => {
46
- return new Promise(resolve => {
47
- sftp.stat(dirPath, (err) => {
48
- resolve(!err);
49
- })
50
- })
51
- }
52
-
53
- const authProjectName = () => {
54
- return new Promise((resolve) => {
55
- // 创建readline接口
56
- const rl = readline.createInterface({
57
- input: process.stdin,
58
- output: process.stdout
59
- });
60
-
61
- // 向用户提问
62
- rl.question(`${process.env.NODE_ENV}环境:是否确认删除map映射文件 ${config.path} 项目? (输入 y 继续) `, (answer) => {
63
- // 检查用户输入是否为'y'(不区分大小写)
64
- if (answer.trim().toLowerCase() === 'y') {
65
- resolve(true);
66
- } else {
67
- logger.error('❌ 发布程序终止');
68
- resolve(false);
69
- }
70
- // 关闭接口
71
- rl.close();
72
- });
73
- })
74
- }
75
-
76
- const deleteFileByMapJson = async (sftp, currentSourceDir, mtime) => {
77
- // 获取源目录中的所有项目
78
- const items = await new Promise((resolve, reject) => {
79
- sftp.readdir(currentSourceDir, (err, list) => {
80
- if (err) {
81
- reject(new Error(`读取目录失败 ${currentSourceDir}: ${err.message}`));
82
- } else {
83
- resolve(list);
84
- }
85
- });
86
- });
87
-
88
- // 处理每个项目
89
- for (const item of items) {
90
- const sourcePath = path.posix.join(currentSourceDir, item.filename);
91
-
92
- if (item.attrs.isDirectory()) {
93
- // 如果是目录,递归处理
94
- await deleteFileByMapJson(sftp, sourcePath, mtime);
95
- } else {
96
- // 删除文件
97
- if (mtime >= item.attrs.mtime) {
98
- // logger.info(`✅ 已删除文件: ${sourcePath}`);
99
- await sftp.unlink(sourcePath);
100
- }
101
- }
102
- }
103
- }
104
-
105
- async function run() {
106
- if (!await authProjectName()) {
107
- process.exit(1);
108
- }
109
-
110
- let conn, sftp;
111
-
112
- try {
113
- logger.warn('连接到服务器...');
114
- // 连接到服务器
115
- conn = new Client();
116
- await new Promise((resolve, reject) => {
117
- conn.on('ready', resolve);
118
- conn.on('error', (err) => reject(new Error(`❌ 服务器连接失败: ${err.message}`)));
119
- conn.connect(config);
120
- });
121
- logger.info(' 已成功连接到服务器');
122
-
123
- // 初始化SFTP
124
- sftp = await new Promise((resolve, reject) => {
125
- conn.sftp((err, sftpClient) => {
126
- if (err) {
127
- reject(new Error(`SFTP初始化失败: ${err.message}`));
128
- } else {
129
- resolve(sftpClient);
130
- }
131
- });
132
- });
133
-
134
- // 没有项目目录,直接返回
135
- if (!await isHasDir(sftp, config.path)) {
136
- logger.warn('✅ 新项目,无需清理旧文件')
137
- return
138
- }
139
- // 没有map文件,直接返回
140
- if (!await isHasDir(sftp, `${config.path}/fileMap.json`)) {
141
- logger.warn('✅ 没有map映射文件,无需清理旧文件')
142
- return
143
- }
144
- const { mtime } = await readStreamByFile(sftp, `${config.path}/fileMap.json`);
145
- logger.warn('正在删除文件...')
146
- await deleteFileByMapJson(sftp, config.path, mtime);
147
- await deleteFileByMapJson(sftp, config.path, mtime);
148
- logger.success(`✅ ${process.env.NODE_ENV}环境,根据map删除映射文件成功!`)
149
- } catch (err) {
150
- logger.error('❌ 过程出错:', err.message);
151
- } finally {
152
- // 关闭连接
153
- if (sftp) sftp.end();
154
- if (conn) conn.end();
155
- // console.log('连接已关闭');
156
- }
157
- }
158
-
159
- run();
1
+ /**
2
+ * 远程目录清理脚本
3
+ * 根据已上传的 fileMap.json 中的 mtime(上一次发布时间基线),
4
+ * 通过 SFTP 递归删除服务器上「修改时间不晚于该基线」的旧文件。
5
+ * 支持通过 config.clearExcludes 配置白名单跳过指定文件。
6
+ */
7
+
8
+ const config = require('../config/config.js')[process.env.NODE_ENV];
9
+ const logger = require('../utils/logger');
10
+ const { Client } = require('ssh2');
11
+ const readline = require("readline");
12
+ const path = require("path");
13
+
14
+ /**
15
+ * 通过 SFTP 读取远程 JSON 文件并解析
16
+ * @param {object} sftp SFTP 客户端
17
+ * @param {string} remoteFilePath 远程文件路径
18
+ * @returns {Promise<object>} 解析后的 JSON 对象
19
+ */
20
+ const readStreamByFile = async (sftp, remoteFilePath) => {
21
+ return new Promise((resolve) => {
22
+ console.log(`准备读取文件: ${remoteFilePath}`);
23
+
24
+ // 创建读取流
25
+ const readStream = sftp.createReadStream(remoteFilePath, {
26
+ encoding: 'utf8' // 以UTF-8编码读取
27
+ });
28
+
29
+ let fileContent = '';
30
+
31
+ // 收集文件内容
32
+ readStream.on('data', (chunk) => {
33
+ fileContent += chunk;
34
+ });
35
+
36
+ // 读取完成处理
37
+ readStream.on('end', () => {
38
+ try {
39
+ // 解析JSON数据
40
+ const jsonData = JSON.parse(fileContent);
41
+ console.log('文件内容解析成功:');
42
+ resolve(jsonData)
43
+ } catch (parseErr) {
44
+ logger.error('JSON解析失败:', parseErr);
45
+ console.log('原始文件内容:', fileContent);
46
+ process.exit(1);
47
+ }
48
+ });
49
+
50
+ // 处理读取错误
51
+ readStream.on('error', (err) => {
52
+ logger.error('读取文件时发生错误:', err);
53
+ process.exit(1);
54
+ });
55
+ })
56
+ }
57
+
58
+ /**
59
+ * 判断远程路径是否存在(文件或目录均可)
60
+ * @param {object} sftp SFTP 客户端
61
+ * @param {string} dirPath 远程路径
62
+ * @returns {Promise<boolean>}
63
+ */
64
+ const isHasDir = (sftp, dirPath) => {
65
+ return new Promise(resolve => {
66
+ sftp.stat(dirPath, (err) => {
67
+ resolve(!err);
68
+ })
69
+ })
70
+ }
71
+
72
+ /**
73
+ * 终端二次确认,避免误删
74
+ * @returns {Promise<boolean>} 用户输入 y 返回 true,其它返回 false
75
+ */
76
+ const authProjectName = () => {
77
+ return new Promise((resolve) => {
78
+ // 创建readline接口
79
+ const rl = readline.createInterface({
80
+ input: process.stdin,
81
+ output: process.stdout
82
+ });
83
+
84
+ // 向用户提问
85
+ rl.question(`${process.env.NODE_ENV}环境:是否确认删除map映射文件 ${config.path} 项目? (输入 y 继续) `, (answer) => {
86
+ // 检查用户输入是否为'y'(不区分大小写)
87
+ if (answer.trim().toLowerCase() === 'y') {
88
+ resolve(true);
89
+ } else {
90
+ logger.error('❌ 发布程序终止');
91
+ resolve(false);
92
+ }
93
+ // 关闭接口
94
+ rl.close();
95
+ });
96
+ })
97
+ }
98
+
99
+ /**
100
+ * 构建清理白名单匹配器
101
+ * 支持三类规则:
102
+ * 1) 以 "." 开头:按扩展名后缀匹配(不区分大小写),可处理多段扩展名如 ".tar.gz"
103
+ * 2) 含 "*" 字符:按子串匹配(去掉所有 "*" 后用 includes 判断,区分大小写)
104
+ * 3) 其它:按完整文件名精确匹配(区分大小写)
105
+ * @param {string[]} rawList 原始白名单数组
106
+ * @returns {(filename: string) => boolean} 匹配函数,命中任一规则即返回 true
107
+ */
108
+ const buildExcludeMatcher = (rawList) => {
109
+ // 无配置或类型不合法时,退化为「永不跳过」,保持向后兼容
110
+ if (!Array.isArray(rawList) || rawList.length === 0) {
111
+ return () => false;
112
+ }
113
+
114
+ // 将规则按类型分桶,避免每次遍历都重复判断规则形态
115
+ const extRules = []; // 扩展名后缀(.tar.gz 等),小写存储
116
+ const includeRules = []; // 子串规则
117
+ const exactRules = []; // 精确文件名
118
+
119
+ for (const item of rawList) {
120
+ // 跳过非字符串与空串,防御性处理
121
+ if (typeof item !== 'string' || item.trim() === '') continue;
122
+ const rule = item.trim();
123
+
124
+ if (rule.startsWith('.')) {
125
+ // 扩展名规则:转小写以便不区分大小写匹配
126
+ extRules.push(rule.toLowerCase());
127
+ } else if (rule.includes('*')) {
128
+ // 子串规则:去掉所有 * 通配符,仅取剩余子串做 includes 判断
129
+ const sub = rule.replace(/\*/g, '');
130
+ if (sub) includeRules.push(sub);
131
+ } else {
132
+ // 精确文件名规则
133
+ exactRules.push(rule);
134
+ }
135
+ }
136
+
137
+ // 返回闭包匹配函数,命中任一桶即视为跳过
138
+ return (filename) => {
139
+ if (!filename) return false;
140
+ const lower = filename.toLowerCase();
141
+
142
+ // 1) 扩展名后缀匹配(不区分大小写)
143
+ for (const ext of extRules) {
144
+ if (lower.endsWith(ext)) return true;
145
+ }
146
+ // 2) 子串匹配(区分大小写)
147
+ for (const sub of includeRules) {
148
+ if (filename.includes(sub)) return true;
149
+ }
150
+ // 3) 精确文件名匹配(区分大小写)
151
+ for (const exact of exactRules) {
152
+ if (filename === exact) return true;
153
+ }
154
+ return false;
155
+ };
156
+ };
157
+
158
+ /**
159
+ * 递归遍历远程目录,删除 mtime 不晚于基线的旧文件
160
+ * 命中白名单的文件保留不删,目录始终递归进入
161
+ * @param {object} sftp SFTP 客户端
162
+ * @param {string} currentSourceDir 当前遍历的远程目录
163
+ * @param {number} mtime 来自 fileMap.json 的发布时间基线
164
+ * @param {(filename: string) => boolean} shouldSkip 白名单匹配函数
165
+ */
166
+ const deleteFileByMapJson = async (sftp, currentSourceDir, mtime, shouldSkip) => {
167
+ // 获取源目录中的所有项目
168
+ const items = await new Promise((resolve, reject) => {
169
+ sftp.readdir(currentSourceDir, (err, list) => {
170
+ if (err) {
171
+ reject(new Error(`读取目录失败 ${currentSourceDir}: ${err.message}`));
172
+ } else {
173
+ resolve(list);
174
+ }
175
+ });
176
+ });
177
+
178
+ // 处理每个项目
179
+ for (const item of items) {
180
+ const sourcePath = path.posix.join(currentSourceDir, item.filename);
181
+
182
+ if (item.attrs.isDirectory()) {
183
+ // 如果是目录,递归处理(目录不参与白名单匹配,确保能下钻清理子文件)
184
+ await deleteFileByMapJson(sftp, sourcePath, mtime, shouldSkip);
185
+ } else {
186
+ // 白名单命中则保留不删,并打印日志方便发布者核对
187
+ if (shouldSkip(item.filename)) {
188
+ logger.info(`⏭ 已保留文件: ${sourcePath}`);
189
+ continue;
190
+ }
191
+ // 删除文件:仅当文件 mtime 不晚于基线(属于"旧版本残留")才删
192
+ if (mtime >= item.attrs.mtime) {
193
+ await sftp.unlink(sourcePath);
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 主流程:确认 -> 连接 -> 校验 -> 读取基线 -> 递归清理 -> 关闭连接
201
+ */
202
+ async function run() {
203
+ // 1. 终端二次确认,避免误删生产环境
204
+ if (!await authProjectName()) {
205
+ process.exit(1);
206
+ }
207
+
208
+ // 2. 构建白名单匹配器,并把规则回显给发布者
209
+ const shouldSkip = buildExcludeMatcher(config.clearExcludes);
210
+ if (Array.isArray(config.clearExcludes) && config.clearExcludes.length > 0) {
211
+ logger.warn(`🛡 本次清理将保留以下规则命中的文件: ${JSON.stringify(config.clearExcludes)}`);
212
+ }
213
+
214
+ let conn, sftp;
215
+
216
+ try {
217
+ logger.warn('连接到服务器...');
218
+ // 3. 建立 SSH 连接
219
+ conn = new Client();
220
+ await new Promise((resolve, reject) => {
221
+ conn.on('ready', resolve);
222
+ conn.on('error', (err) => reject(new Error(`❌ 服务器连接失败: ${err.message}`)));
223
+ conn.connect(config);
224
+ });
225
+ logger.info('✅ 已成功连接到服务器');
226
+
227
+ // 4. 初始化 SFTP 通道
228
+ sftp = await new Promise((resolve, reject) => {
229
+ conn.sftp((err, sftpClient) => {
230
+ if (err) {
231
+ reject(new Error(`SFTP初始化失败: ${err.message}`));
232
+ } else {
233
+ resolve(sftpClient);
234
+ }
235
+ });
236
+ });
237
+
238
+ // 5. 前置校验:目标目录不存在 → 视为新项目,直接退出
239
+ if (!await isHasDir(sftp, config.path)) {
240
+ logger.warn('✅ 新项目,无需清理旧文件')
241
+ return
242
+ }
243
+ // 6. 前置校验:缺少 fileMap.json → 缺少基线,无法判定旧文件,直接退出
244
+ if (!await isHasDir(sftp, `${config.path}/fileMap.json`)) {
245
+ logger.warn('✅ 没有map映射文件,无需清理旧文件')
246
+ return
247
+ }
248
+
249
+ // 7. 读取基线 mtime
250
+ const { mtime } = await readStreamByFile(sftp, `${config.path}/fileMap.json`);
251
+ logger.warn('正在删除文件...')
252
+
253
+ // 8. 连续递归两次:第二次用于兜底清理首轮删除后变空的子目录残留
254
+ await deleteFileByMapJson(sftp, config.path, mtime, shouldSkip);
255
+ await deleteFileByMapJson(sftp, config.path, mtime, shouldSkip);
256
+
257
+ logger.success(`✅ ${process.env.NODE_ENV}环境,根据map删除映射文件成功!`)
258
+ } catch (err) {
259
+ logger.error('❌ 过程出错:', err.message);
260
+ } finally {
261
+ // 9. 关闭连接,避免句柄泄漏
262
+ if (sftp) sftp.end();
263
+ if (conn) conn.end();
264
+ }
265
+ }
266
+
267
+ run();