@qse/ssh-sftp 1.2.0 → 1.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # 更新日志
2
2
 
3
+ ## 1.3.0 (2026-03-04)
4
+
5
+ - feat: 优化上传速度
6
+
3
7
  ## 1.0.1 (2023-12-15)
4
8
 
5
9
  - fix: 延长 timeout 时间
@@ -0,0 +1,3 @@
1
+ import config from '@qse/eslint-config'
2
+
3
+ export default config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qse/ssh-sftp",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "教育代码部署工具",
5
5
  "main": "src/index.js",
6
6
  "author": "Ironkinoko <kinoko_main@outlook.com>",
@@ -10,7 +10,7 @@
10
10
  "docs:build": "mkdir -p docs-dist && cp sftprc.schema.json docs-dist",
11
11
  "docs:deploy": "node ./src/cli.js",
12
12
  "deploy": "npm run docs:build && npm run docs:deploy && rm -rf docs-dist",
13
- "release": "npm publish &&",
13
+ "release": "npm publish",
14
14
  "prettier": "prettier -c -w \"src/**/*.{js,jsx,tsx,ts,less,md,json}\"",
15
15
  "postversion": "npm run release"
16
16
  },
@@ -31,9 +31,12 @@
31
31
  "inquirer": "^13.1.0",
32
32
  "minimatch": "^10.1.1",
33
33
  "ora": "^9.0.0",
34
+ "p-limit": "^7.3.0",
34
35
  "ssh2-sftp-client": "^12.0.1",
35
- "update-notifier": "^7.3.1",
36
36
  "yargs": "^18.0.0"
37
37
  },
38
- "devDependencies": {}
38
+ "devDependencies": {
39
+ "eslint": "^9.39.3",
40
+ "@qse/eslint-config": "^1.0.0"
41
+ }
39
42
  }
package/src/cli.js CHANGED
@@ -4,21 +4,13 @@ import { sshSftp, sshSftpLS, sshSftpShowUrl, sshSftpShowConfig } from './index.j
4
4
  import fs from 'fs'
5
5
  import ora from 'ora'
6
6
  import { exitWithError } from './utils.js'
7
- import updateNotifier from 'update-notifier'
8
7
  import { presets } from './presets.js'
9
8
  import Yargs from 'yargs'
10
9
 
11
- const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'))
12
-
13
- updateNotifier({
14
- pkg,
15
- updateCheckInterval: 1000 * 60 * 60 * 24 * 7,
16
- shouldNotifyInNpmScript: true,
17
- }).notify()
18
-
10
+ // eslint-disable-next-line no-unused-expressions
19
11
  Yargs(process.argv.slice(2))
20
12
  .usage(
21
- '使用: $0 [command] \n\n代码:svn://192.168.10.168/edu/code/A0.New-system/0A2.front-end-component/ssh-sftp/trunk'
13
+ '使用: $0 [command] \n\n代码:svn://192.168.10.168/edu/code/A0.New-system/0A2.front-end-component/ssh-sftp/trunk',
22
14
  )
23
15
  .command(
24
16
  '*',
@@ -29,7 +21,7 @@ Yargs(process.argv.slice(2))
29
21
  desc: '不存在的目录不再询问,直接创建',
30
22
  type: 'boolean',
31
23
  }),
32
- upload
24
+ upload,
33
25
  )
34
26
  .command('init', '生成 .sftprc.json 配置文件', {}, generateDefaultConfigJSON)
35
27
  .command(
@@ -40,7 +32,7 @@ Yargs(process.argv.slice(2))
40
32
  .option('u', { desc: '列出需要上传的文件' })
41
33
  .option('d', { desc: '列出需要删除的文件' })
42
34
  .option('i', { desc: '列出忽略的文件' }),
43
- ls
35
+ ls,
44
36
  )
45
37
  .command(['show-config', 'sc'], '显示部署的完整信息', {}, showConfig)
46
38
  .command(['show-presets', 'sp'], '显示预设配置', {}, showPresets)
@@ -97,9 +89,9 @@ function generateDefaultConfigJSON() {
97
89
  cleanRemoteFiles: false,
98
90
  },
99
91
  null,
100
- 2
92
+ 2,
101
93
  ),
102
- { encoding: 'utf-8' }
94
+ { encoding: 'utf-8' },
103
95
  )
104
96
  ora().succeed('.sftprc.json 生成在项目根目录')
105
97
  }
package/src/index.js CHANGED
@@ -4,10 +4,13 @@ import * as glob from 'glob'
4
4
  import fs from 'fs'
5
5
  import * as minimatch from 'minimatch'
6
6
  import inquirer from 'inquirer'
7
- import { exitWithError, warn } from './utils.js'
7
+ import { exitWithError, injectTiming, warn } from './utils.js'
8
8
  import chalk from 'chalk'
9
9
  import { presets, servers } from './presets.js'
10
10
  import path from 'path'
11
+ import pLimit from 'p-limit'
12
+
13
+ const limit = pLimit(6)
11
14
 
12
15
  /** @type {Options} */
13
16
  const defualtOpts = {
@@ -52,30 +55,40 @@ function getFilesPath(localPath, remotePath, ignore) {
52
55
  */
53
56
  async function getRemoteDeepFiles(sftp, remotePath, options) {
54
57
  const { patterns } = options
55
- /**
56
- * @param {string} remotePath
57
- * @returns {Promise<string[]|string>}
58
- */
59
- async function getFiles(remotePath, data = []) {
60
- const list = await sftp.list(remotePath)
61
- for (const item of list) {
62
- const path = remotePath + '/' + item.name
63
- if (item.type === 'd') {
64
- data.push({ isDir: true, path })
65
- await getFiles(path, data)
66
- } else {
67
- data.push({ isDir: false, path })
58
+ const data = []
59
+ const queue = [remotePath]
60
+ const LIST_CONCURRENCY = 6
61
+
62
+ while (queue.length > 0) {
63
+ const batch = queue.splice(0, LIST_CONCURRENCY)
64
+ const results = await Promise.all(
65
+ batch.map(async (base) => {
66
+ try {
67
+ return { base, list: await sftp.list(base) }
68
+ } catch {
69
+ return { base, list: [] }
70
+ }
71
+ }),
72
+ )
73
+
74
+ for (const { base, list } of results) {
75
+ for (const item of list) {
76
+ const p = `${base}/${item.name}`
77
+ if (item.type === 'd') {
78
+ data.push({ isDir: true, path: p })
79
+ queue.push(p)
80
+ } else {
81
+ data.push({ isDir: false, path: p })
82
+ }
68
83
  }
69
84
  }
70
- return data
71
85
  }
72
- const ls = (await getFiles(remotePath)).filter((o) => o.path)
86
+
87
+ const ls = data.filter((o) => o.path)
73
88
 
74
89
  if (patterns.length > 0) {
75
- let tmp = ls
76
90
  const safePatterns = getSafePattern(patterns, remotePath)
77
- tmp = tmp.filter((o) => safePatterns.some((reg) => minimatch(o.path, reg)))
78
- return tmp
91
+ return ls.filter((o) => safePatterns.some((reg) => minimatch(o.path, reg)))
79
92
  }
80
93
  return ls
81
94
  }
@@ -121,9 +134,8 @@ async function _sshSftp(opts) {
121
134
  const { deployedURL, sftpURL } = getDeployURL(opts)
122
135
 
123
136
  console.log('部署网址:', chalk.green(deployedURL))
124
- const spinner = ora(`连接服务器 ${sftpURL}`).start()
137
+ const spinner = injectTiming(ora(`连接服务器 ${sftpURL}`)).start()
125
138
  const sftp = new Client()
126
-
127
139
  try {
128
140
  await sftp.connect(opts.connectOptions)
129
141
  spinner.succeed(`已连接 ${sftpURL}`)
@@ -205,12 +217,19 @@ async function _sshSftp(opts) {
205
217
  if (confirm === Confirm.delete) {
206
218
  spinner.start('开始删除远程文件')
207
219
  remoteDeletefiles = mergeDelete(remoteDeletefiles)
208
- for (const i in remoteDeletefiles) {
209
- const o = remoteDeletefiles[i]
210
- spinner.text = `[${i + 1}/${remoteDeletefiles.length}] 正在删除 ${o.path}`
211
- if (o.isDir) await sftp.rmdir(o.path, true)
212
- else await sftp.delete(o.path)
213
- }
220
+ await Promise.all(
221
+ remoteDeletefiles.map((o, i) =>
222
+ limit(async () => {
223
+ spinner.text = `[${i + 1}/${remoteDeletefiles.length}] 正在删除 ${o.path}`
224
+ try {
225
+ if (o.isDir) await sftp.rmdir(o.path, true)
226
+ else await sftp.delete(o.path)
227
+ } catch (e) {
228
+ console.error(`删除失败: ${o.path}`, e.message)
229
+ }
230
+ }),
231
+ ),
232
+ )
214
233
  spinner.succeed(`已删除 ${opts.remotePath}`)
215
234
  }
216
235
  }
@@ -218,17 +237,31 @@ async function _sshSftp(opts) {
218
237
  spinner.start(`开始上传 ${opts.localPath} 到 ${opts.remotePath}`)
219
238
 
220
239
  if (Array.isArray(opts.ignore) && opts.ignore.length > 0) {
221
- for (const i in localUploadFiles) {
222
- const o = localUploadFiles[i]
223
- spinner.text = `[${i}/${localUploadFiles.length}] 正在上传 ${o.localPath} 到 ${o.remotePath}`
224
- if (fs.statSync(o.localPath).isDirectory()) {
225
- if (!(await sftp.exists(o.remotePath))) {
226
- await sftp.mkdir(o.remotePath)
227
- }
228
- continue
229
- }
230
- await sftp.fastPut(o.localPath, o.remotePath)
231
- }
240
+ const dirs = localUploadFiles.filter((o) => fs.statSync(o.localPath).isDirectory())
241
+ const files = localUploadFiles.filter((o) => !fs.statSync(o.localPath).isDirectory())
242
+
243
+ // 先并发创建目录
244
+ await Promise.all(
245
+ dirs.map((o) =>
246
+ limit(async () => {
247
+ try {
248
+ if (!(await sftp.exists(o.remotePath))) {
249
+ await sftp.mkdir(o.remotePath)
250
+ }
251
+ } catch {}
252
+ }),
253
+ ),
254
+ )
255
+
256
+ // 再并发上传文件
257
+ await Promise.all(
258
+ files.map((o, i) =>
259
+ limit(async () => {
260
+ spinner.text = `[${i + 1}/${files.length}] 正在上传 ${o.localPath} 到 ${o.remotePath}`
261
+ await sftp.fastPut(o.localPath, o.remotePath)
262
+ }),
263
+ ),
264
+ )
232
265
  } else {
233
266
  await sftp.uploadDir(opts.localPath, opts.remotePath)
234
267
  }
@@ -281,7 +314,7 @@ async function _sshSftpLS(opts, lsOpts) {
281
314
  if (lsOpts.i && Array.isArray(opts.ignore) && opts.ignore.length > 0) {
282
315
  let ls = glob.sync(`${opts.localPath}/**/*`)
283
316
  ls = ls.filter((s) =>
284
- getSafePattern(opts.ignore, opts.localPath).some((reg) => minimatch(s, reg))
317
+ getSafePattern(opts.ignore, opts.localPath).some((reg) => minimatch(s, reg)),
285
318
  )
286
319
 
287
320
  console.log(`忽略文件 (${ls.length}):`)
@@ -325,7 +358,7 @@ function mergeDelete(files) {
325
358
  */
326
359
  function getSafePattern(patterns, prefixPath) {
327
360
  const safePatterns = patterns
328
- .map((s) => s.replace(/^[\.\/]*/, prefixPath + '/'))
361
+ .map((s) => s.replace(/^[./]*/, prefixPath + '/'))
329
362
  .reduce((acc, s) => [...acc, s, s + '/**/*'], [])
330
363
  return safePatterns
331
364
  }
@@ -407,7 +440,7 @@ function parseOpts(opts) {
407
440
  `remotePath:${target.remotePath}`,
408
441
  `项目名称:${pkg.name} // 源自 package.json 中的 name 字段,忽略scope字段`,
409
442
  `\n你可以设置 "securityLock": false 来关闭这个验证`,
410
- ].join('\n')
443
+ ].join('\n'),
411
444
  )
412
445
  }
413
446
  }
package/src/utils.js CHANGED
@@ -32,4 +32,26 @@ function splitOnFirst(string, separator) {
32
32
  return [string.slice(0, separatorIndex), string.slice(separatorIndex + separator.length)]
33
33
  }
34
34
 
35
- export { splitOnFirst, exitWithError, warn }
35
+ /**
36
+ *
37
+ * @param {import('ora').Ora} ora
38
+ */
39
+ function injectTiming(ora) {
40
+ const oldStart = ora.start
41
+
42
+ let current = 0
43
+ ora.start = (message) => {
44
+ current = performance.now()
45
+ return oldStart.call(ora, message)
46
+ }
47
+ const oldSucceed = ora.succeed
48
+ ora.succeed = (message) => {
49
+ const now = performance.now()
50
+ const diff = (now - current) / 1000
51
+ return oldSucceed.call(ora, `${message} ${chalk.dim(`(${diff.toFixed(2)}s)`)}`)
52
+ }
53
+
54
+ return ora
55
+ }
56
+
57
+ export { splitOnFirst, exitWithError, warn, injectTiming }