@qse/ssh-sftp 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 更新日志
2
2
 
3
+ ## 1.3.1 (2026-03-09)
4
+
5
+ - fix: 升级 inquirer 修复相关 bug
6
+
7
+ ## 1.3.0 (2026-03-04)
8
+
9
+ - feat: 优化上传速度
10
+
3
11
  ## 1.0.1 (2023-12-15)
4
12
 
5
13
  - 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.1",
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
  },
@@ -26,14 +26,17 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
+ "@inquirer/prompts": "^8.3.0",
29
30
  "chalk": "^5.6.2",
30
31
  "glob": "^13.0.0",
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
+ "@qse/eslint-config": "^1.0.0",
40
+ "eslint": "^10.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
@@ -3,11 +3,14 @@ import ora from 'ora'
3
3
  import * as glob from 'glob'
4
4
  import fs from 'fs'
5
5
  import * as minimatch from 'minimatch'
6
- import inquirer from 'inquirer'
7
- import { exitWithError, warn } from './utils.js'
6
+ import * as inquirer from '@inquirer/prompts'
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}`)
@@ -133,12 +145,7 @@ async function _sshSftp(opts) {
133
145
  if (opts.skipPrompt) {
134
146
  confirm = true
135
147
  } else {
136
- const ans = await inquirer.prompt({
137
- name: 'confirm',
138
- message: `远程文件夹不存在,是否要创建一个`,
139
- type: 'confirm',
140
- })
141
- confirm = ans.confirm
148
+ confirm = await inquirer.confirm({ message: `远程文件夹不存在,是否要创建一个` })
142
149
  }
143
150
 
144
151
  if (confirm) {
@@ -172,10 +179,8 @@ async function _sshSftp(opts) {
172
179
  let confirm = Confirm.delete
173
180
  if (remoteDeletefiles.length > localUploadFiles.length && !opts.skipPrompt) {
174
181
  const showSelect = async () => {
175
- const { confirm } = await inquirer.prompt({
176
- name: 'confirm',
182
+ return await inquirer.select({
177
183
  message: `远程需要删除的文件数(${remoteDeletefiles.length})比本地(${localUploadFiles.length})多,确定要删除吗?`,
178
- type: 'list',
179
184
  choices: [
180
185
  { name: '删除', value: Confirm.delete },
181
186
  { name: '不删除,继续部署', value: Confirm.skip },
@@ -183,7 +188,6 @@ async function _sshSftp(opts) {
183
188
  { name: '显示需要删除的文件', value: Confirm.showDeleteFile },
184
189
  ],
185
190
  })
186
- return confirm
187
191
  }
188
192
 
189
193
  do {
@@ -205,12 +209,19 @@ async function _sshSftp(opts) {
205
209
  if (confirm === Confirm.delete) {
206
210
  spinner.start('开始删除远程文件')
207
211
  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
- }
212
+ await Promise.all(
213
+ remoteDeletefiles.map((o, i) =>
214
+ limit(async () => {
215
+ spinner.text = `[${i + 1}/${remoteDeletefiles.length}] 正在删除 ${o.path}`
216
+ try {
217
+ if (o.isDir) await sftp.rmdir(o.path, true)
218
+ else await sftp.delete(o.path)
219
+ } catch (e) {
220
+ console.error(`删除失败: ${o.path}`, e.message)
221
+ }
222
+ }),
223
+ ),
224
+ )
214
225
  spinner.succeed(`已删除 ${opts.remotePath}`)
215
226
  }
216
227
  }
@@ -218,17 +229,31 @@ async function _sshSftp(opts) {
218
229
  spinner.start(`开始上传 ${opts.localPath} 到 ${opts.remotePath}`)
219
230
 
220
231
  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
- }
232
+ const dirs = localUploadFiles.filter((o) => fs.statSync(o.localPath).isDirectory())
233
+ const files = localUploadFiles.filter((o) => !fs.statSync(o.localPath).isDirectory())
234
+
235
+ // 先并发创建目录
236
+ await Promise.all(
237
+ dirs.map((o) =>
238
+ limit(async () => {
239
+ try {
240
+ if (!(await sftp.exists(o.remotePath))) {
241
+ await sftp.mkdir(o.remotePath)
242
+ }
243
+ } catch {}
244
+ }),
245
+ ),
246
+ )
247
+
248
+ // 再并发上传文件
249
+ await Promise.all(
250
+ files.map((o, i) =>
251
+ limit(async () => {
252
+ spinner.text = `[${i + 1}/${files.length}] 正在上传 ${o.localPath} 到 ${o.remotePath}`
253
+ await sftp.fastPut(o.localPath, o.remotePath)
254
+ }),
255
+ ),
256
+ )
232
257
  } else {
233
258
  await sftp.uploadDir(opts.localPath, opts.remotePath)
234
259
  }
@@ -281,7 +306,7 @@ async function _sshSftpLS(opts, lsOpts) {
281
306
  if (lsOpts.i && Array.isArray(opts.ignore) && opts.ignore.length > 0) {
282
307
  let ls = glob.sync(`${opts.localPath}/**/*`)
283
308
  ls = ls.filter((s) =>
284
- getSafePattern(opts.ignore, opts.localPath).some((reg) => minimatch(s, reg))
309
+ getSafePattern(opts.ignore, opts.localPath).some((reg) => minimatch(s, reg)),
285
310
  )
286
311
 
287
312
  console.log(`忽略文件 (${ls.length}):`)
@@ -325,7 +350,7 @@ function mergeDelete(files) {
325
350
  */
326
351
  function getSafePattern(patterns, prefixPath) {
327
352
  const safePatterns = patterns
328
- .map((s) => s.replace(/^[\.\/]*/, prefixPath + '/'))
353
+ .map((s) => s.replace(/^[./]*/, prefixPath + '/'))
329
354
  .reduce((acc, s) => [...acc, s, s + '/**/*'], [])
330
355
  return safePatterns
331
356
  }
@@ -407,7 +432,7 @@ function parseOpts(opts) {
407
432
  `remotePath:${target.remotePath}`,
408
433
  `项目名称:${pkg.name} // 源自 package.json 中的 name 字段,忽略scope字段`,
409
434
  `\n你可以设置 "securityLock": false 来关闭这个验证`,
410
- ].join('\n')
435
+ ].join('\n'),
411
436
  )
412
437
  }
413
438
  }
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 }