@qse/ssh-sftp 1.0.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/LICENSE +21 -0
- package/README.md +85 -0
- package/lib/cli.js +126 -0
- package/lib/index.js +491 -0
- package/lib/presets.js +34 -0
- package/lib/utils.js +40 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 IronKinoko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @qse/ssh-sftp
|
|
2
|
+
|
|
3
|
+
简单易用的 SFTP 工具,可以上传/忽略/删除远程的文件
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 全局
|
|
9
|
+
npm i @qse/ssh-sftp -g
|
|
10
|
+
|
|
11
|
+
# 局部
|
|
12
|
+
npm i @qse/ssh-sftp -D
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 使用
|
|
16
|
+
|
|
17
|
+
1. 首先初始化一份配置文件
|
|
18
|
+
|
|
19
|
+
`npx ssh-sftp init`
|
|
20
|
+
|
|
21
|
+
2. 将生成的`.sftprc.json`文件里的信息填写完整
|
|
22
|
+
|
|
23
|
+
3. 添加脚本到 `package.json`
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"scripts": {
|
|
28
|
+
"deploy": "ssh-sftp"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**建议在部署前使用`npx ssh-sftp ls`查看哪些文件会被上传或删除**
|
|
34
|
+
|
|
35
|
+
## 字段说明
|
|
36
|
+
|
|
37
|
+
### `.sftprc.json`
|
|
38
|
+
|
|
39
|
+
| 字段名 | 类型 | 描述 | 默认值 |
|
|
40
|
+
| ---------------- | ---------------------------------------------------- | ---------------------------------------------- | ---------------------- |
|
|
41
|
+
| localPath | `string` | | `'dist'` |
|
|
42
|
+
| remotePath | `string` | | - |
|
|
43
|
+
| connectOptions | `ConnectOptions` | 登录信息 | - |
|
|
44
|
+
| ignore | `string[]` | 忽略`localPath`中的部分文件,`glob`类型 | `['**/*.LICENSE.txt']` |
|
|
45
|
+
| cleanRemoteFiles | `boolean \| string[]` | 清空远程文件夹,或按`glob`匹配清空远程部分文件 | `true` |
|
|
46
|
+
| securityLock | `boolean` | 安全锁,默认开启 | `true` |
|
|
47
|
+
| keepAlive | `boolean` | 保持连接 | `false` |
|
|
48
|
+
| noWarn | `boolean` | 禁止提示 | `false` |
|
|
49
|
+
| skipPrompt | `boolean` | 跳过询问,全部同意 | `false` |
|
|
50
|
+
| preset | `{context: string; folder?: string; server?:string}` | 预设登录项 | - |
|
|
51
|
+
|
|
52
|
+
`preset.context` 目前只有三个值 `"qsxxwapdev", "eduwebngv1", "qsxxadminv1"`
|
|
53
|
+
|
|
54
|
+
`preset.folder` 文件夹名称默认使用项目名称,也可自定义文件夹名称,支持多层文件夹,例如 `parent/child`
|
|
55
|
+
|
|
56
|
+
`preset.server` 部署服务器地址 `"19", "171"`
|
|
57
|
+
|
|
58
|
+
### `connectOptions`
|
|
59
|
+
|
|
60
|
+
| 字段名 | 类型 | 描述 |
|
|
61
|
+
| -------- | -------- | ---- |
|
|
62
|
+
| host | `string` | |
|
|
63
|
+
| port | `number` | |
|
|
64
|
+
| username | `string` | |
|
|
65
|
+
| password | `string` | |
|
|
66
|
+
|
|
67
|
+
### `securityLock`
|
|
68
|
+
|
|
69
|
+
**安全锁** 默认开启,会校验项目名称与远程地址是否匹配防止误传,关闭后忽略验证
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
### `ssh-sftp init`
|
|
74
|
+
|
|
75
|
+
初始化生成配置文件 `.sftprc.json`
|
|
76
|
+
|
|
77
|
+
### `ssh-sftp ls`
|
|
78
|
+
|
|
79
|
+
列出所有需要上传/删除/忽略的文件
|
|
80
|
+
|
|
81
|
+
`ssh-sftp ls -u` 单独列出所有需要上传的文件
|
|
82
|
+
|
|
83
|
+
`ssh-sftp ls -d` 单独列出所有需要删除的文件
|
|
84
|
+
|
|
85
|
+
`ssh-sftp ls -i` 单独列出所有忽略的文件
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
sshSftp,
|
|
6
|
+
sshSftpLS,
|
|
7
|
+
sshSftpShowUrl,
|
|
8
|
+
sshSftpShowConfig
|
|
9
|
+
} = require('./index');
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
const ora = require('ora');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
exitWithError
|
|
17
|
+
} = require('./utils');
|
|
18
|
+
|
|
19
|
+
const updateNotifier = require('update-notifier');
|
|
20
|
+
|
|
21
|
+
const pkg = require('../package.json');
|
|
22
|
+
|
|
23
|
+
const presets = require('./presets');
|
|
24
|
+
|
|
25
|
+
updateNotifier({
|
|
26
|
+
pkg,
|
|
27
|
+
updateCheckInterval: 1000 * 60 * 60 * 24 * 7,
|
|
28
|
+
shouldNotifyInNpmScript: true
|
|
29
|
+
}).notify();
|
|
30
|
+
require('yargs').usage('使用: $0 [command] \n\n代码:svn://192.168.10.168/edu/code/A0.New-system/0A2.front-end-component/ssh-sftp/trunk').command('*', '上传文件', yargs => yargs.option('no-clear', {
|
|
31
|
+
desc: '不删除文件'
|
|
32
|
+
}).option('yes', {
|
|
33
|
+
alias: 'y',
|
|
34
|
+
desc: '不存在的目录不再询问,直接创建',
|
|
35
|
+
type: 'boolean'
|
|
36
|
+
}), upload).command('init', '生成 .sftprc.json 配置文件', {}, generateDefaultConfigJSON).command(['list', 'ls'], '列出所有需要上传/忽略/删除的文件', yargs => yargs.option('u', {
|
|
37
|
+
desc: '列出需要上传的文件'
|
|
38
|
+
}).option('d', {
|
|
39
|
+
desc: '列出需要删除的文件'
|
|
40
|
+
}).option('i', {
|
|
41
|
+
desc: '列出忽略的文件'
|
|
42
|
+
}), ls).command(['show-config', 'sc'], '显示部署的完整信息', {}, showConfig).command(['show-presets', 'sp'], '显示预设配置', {}, showPresets).command(['show-url', 'su'], '显示部署网址', {}, showUrl).alias({
|
|
43
|
+
v: 'version',
|
|
44
|
+
h: 'help'
|
|
45
|
+
}).argv;
|
|
46
|
+
|
|
47
|
+
function getOpts() {
|
|
48
|
+
isRoot();
|
|
49
|
+
|
|
50
|
+
if (!fs.existsSync('.sftprc.json')) {
|
|
51
|
+
return exitWithError('没找到 .sftprc.json 文件,请先执行 ssh-sftp init');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const opts = fs.readFileSync('.sftprc.json', 'utf-8');
|
|
55
|
+
return JSON.parse(opts);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isRoot() {
|
|
59
|
+
if (!fs.existsSync('package.json')) {
|
|
60
|
+
exitWithError('请在项目的根目录运行(package.json所在的目录)');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function upload({
|
|
65
|
+
clear,
|
|
66
|
+
yes
|
|
67
|
+
}) {
|
|
68
|
+
const opts = getOpts();
|
|
69
|
+
|
|
70
|
+
if (clear === false) {
|
|
71
|
+
opts.cleanRemoteFiles = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (yes) {
|
|
75
|
+
opts.skipPrompt = true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
sshSftp(opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function generateDefaultConfigJSON() {
|
|
82
|
+
isRoot();
|
|
83
|
+
|
|
84
|
+
if (fs.existsSync('.sftprc.json')) {
|
|
85
|
+
return exitWithError('已存在 .sftprc.json 文件,请勿重复生成');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fs.writeFileSync('.sftprc.json', JSON.stringify({
|
|
89
|
+
$schema: 'http://www.zhidianbao.cn:8088/qsxxwapdev/edu-ssh-sftp/sftprc.schema.json',
|
|
90
|
+
localPath: '/path/to/localDir',
|
|
91
|
+
remotePath: '/path/to/remoteDir',
|
|
92
|
+
connectOptions: {
|
|
93
|
+
host: '127.0.0.1',
|
|
94
|
+
port: 22,
|
|
95
|
+
username: '',
|
|
96
|
+
password: ''
|
|
97
|
+
},
|
|
98
|
+
ignore: ['**/something[optional].js'],
|
|
99
|
+
cleanRemoteFiles: false
|
|
100
|
+
}, null, 2), {
|
|
101
|
+
encoding: 'utf-8'
|
|
102
|
+
});
|
|
103
|
+
ora().succeed('.sftprc.json 生成在项目根目录');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ls(argv) {
|
|
107
|
+
if (!argv.u && !argv.d && !argv.i) {
|
|
108
|
+
argv.u = true;
|
|
109
|
+
argv.d = true;
|
|
110
|
+
argv.i = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
sshSftpLS(getOpts(), argv);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function showUrl() {
|
|
117
|
+
sshSftpShowUrl(getOpts());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function showConfig() {
|
|
121
|
+
sshSftpShowConfig(getOpts());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function showPresets() {
|
|
125
|
+
console.log(JSON.stringify(presets, null, 2));
|
|
126
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const Client = require('ssh2-sftp-client');
|
|
4
|
+
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
|
|
7
|
+
const glob = require('glob');
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
|
|
11
|
+
const minimatch = require('minimatch');
|
|
12
|
+
|
|
13
|
+
const inquirer = require('inquirer');
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
exitWithError,
|
|
17
|
+
warn
|
|
18
|
+
} = require('./utils');
|
|
19
|
+
|
|
20
|
+
const chalk = require('chalk');
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
presets,
|
|
24
|
+
servers
|
|
25
|
+
} = require('./presets');
|
|
26
|
+
|
|
27
|
+
const path = require('path');
|
|
28
|
+
/** @type {Options} */
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const defualtOpts = {
|
|
32
|
+
localPath: 'dist',
|
|
33
|
+
noWarn: false,
|
|
34
|
+
keepAlive: false,
|
|
35
|
+
cleanRemoteFiles: true,
|
|
36
|
+
ignore: ['**/*.LICENSE.txt'],
|
|
37
|
+
securityLock: true
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* get upload files
|
|
41
|
+
*
|
|
42
|
+
* @param {string} localPath
|
|
43
|
+
* @param {string} remotePath
|
|
44
|
+
* @param {string[]} ignore
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
function getFilesPath(localPath, remotePath, ignore) {
|
|
48
|
+
const files = glob.sync(`${localPath}/**/*`, {
|
|
49
|
+
ignore: getSafePattern(ignore, localPath),
|
|
50
|
+
dot: true
|
|
51
|
+
});
|
|
52
|
+
return files.map(localFilePath => {
|
|
53
|
+
return {
|
|
54
|
+
localPath: localFilePath,
|
|
55
|
+
remotePath: localFilePath.replace(localPath, remotePath)
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* get remote ls deep
|
|
61
|
+
*
|
|
62
|
+
* @typedef {Object} FilesOptions
|
|
63
|
+
* @property {true|string[]} [patterns]
|
|
64
|
+
*
|
|
65
|
+
* @param {Client} sftp
|
|
66
|
+
* @param {string} remotePath
|
|
67
|
+
* @param {FilesOptions} [options]
|
|
68
|
+
* @return {Promise<{isDir:boolean;path:string}[]>}
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async function getRemoteDeepFiles(sftp, remotePath, options) {
|
|
73
|
+
const {
|
|
74
|
+
patterns
|
|
75
|
+
} = options;
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} remotePath
|
|
78
|
+
* @returns {Promise<string[]|string>}
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
async function getFiles(remotePath, data = []) {
|
|
82
|
+
const list = await sftp.list(remotePath);
|
|
83
|
+
|
|
84
|
+
for (const item of list) {
|
|
85
|
+
const path = remotePath + '/' + item.name;
|
|
86
|
+
|
|
87
|
+
if (item.type === 'd') {
|
|
88
|
+
data.push({
|
|
89
|
+
isDir: true,
|
|
90
|
+
path
|
|
91
|
+
});
|
|
92
|
+
await getFiles(path, data);
|
|
93
|
+
} else {
|
|
94
|
+
data.push({
|
|
95
|
+
isDir: false,
|
|
96
|
+
path
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ls = (await getFiles(remotePath)).filter(o => o.path);
|
|
105
|
+
|
|
106
|
+
if (patterns.length > 0) {
|
|
107
|
+
let tmp = ls;
|
|
108
|
+
const safePatterns = getSafePattern(patterns, remotePath);
|
|
109
|
+
tmp = tmp.filter(o => safePatterns.some(reg => minimatch(o.path, reg)));
|
|
110
|
+
return tmp;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return ls;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function ensureDiff(local, remote) {
|
|
117
|
+
return remote.map(file => {
|
|
118
|
+
const isSame = local.some(local => local.remotePath === file.path);
|
|
119
|
+
return { ...file,
|
|
120
|
+
isSame
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {Object} Options
|
|
126
|
+
* @property {string} [localPath]
|
|
127
|
+
* @property {string} [remotePath]
|
|
128
|
+
* @property {{context?:string;folder?:string;server?:string}} [preset]
|
|
129
|
+
* @property {import('ssh2').ConnectConfig} [connectOptions]
|
|
130
|
+
* @property {string[]} [ignore]
|
|
131
|
+
* @property {boolean|string[]} [cleanRemoteFiles]
|
|
132
|
+
* @property {boolean} [securityLock]
|
|
133
|
+
* @property {boolean} [keepAlive]
|
|
134
|
+
* @property {boolean} [noWarn]
|
|
135
|
+
* @property {boolean} [skipPrompt]
|
|
136
|
+
* @param {Options} opts
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async function sshSftp(opts) {
|
|
141
|
+
opts = parseOpts(opts);
|
|
142
|
+
const {
|
|
143
|
+
deployedURL,
|
|
144
|
+
sftpURL
|
|
145
|
+
} = getDeployURL(opts);
|
|
146
|
+
console.log('部署网址:', chalk.green(deployedURL));
|
|
147
|
+
const spinner = ora(`连接服务器 ${sftpURL}`).start();
|
|
148
|
+
const sftp = new Client();
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await sftp.connect(opts.connectOptions);
|
|
152
|
+
spinner.succeed(`已连接 ${sftpURL}`);
|
|
153
|
+
|
|
154
|
+
if (!(await sftp.exists(opts.remotePath))) {
|
|
155
|
+
let confirm = false;
|
|
156
|
+
|
|
157
|
+
if (opts.skipPrompt) {
|
|
158
|
+
confirm = true;
|
|
159
|
+
} else {
|
|
160
|
+
const ans = await inquirer.prompt({
|
|
161
|
+
name: 'confirm',
|
|
162
|
+
message: `远程文件夹不存在,是否要创建一个`,
|
|
163
|
+
type: 'confirm'
|
|
164
|
+
});
|
|
165
|
+
confirm = ans.confirm;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (confirm) {
|
|
169
|
+
await sftp.mkdir(opts.remotePath, true);
|
|
170
|
+
} else {
|
|
171
|
+
process.exit();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let remoteDeletefiles = [];
|
|
176
|
+
let localUploadFiles = [];
|
|
177
|
+
spinner.start('对比本地/远程的文件数量');
|
|
178
|
+
localUploadFiles = getFilesPath(opts.localPath, opts.remotePath, opts.ignore || []);
|
|
179
|
+
spinner.succeed(`本地文件数量:${localUploadFiles.length}`);
|
|
180
|
+
|
|
181
|
+
if (opts.cleanRemoteFiles) {
|
|
182
|
+
remoteDeletefiles = await getRemoteDeepFiles(sftp, opts.remotePath, {
|
|
183
|
+
patterns: opts.cleanRemoteFiles === true ? [] : opts.cleanRemoteFiles
|
|
184
|
+
});
|
|
185
|
+
spinner.succeed(`远程文件数量:${remoteDeletefiles.length}`);
|
|
186
|
+
const Confirm = {
|
|
187
|
+
delete: Symbol(),
|
|
188
|
+
skip: Symbol(),
|
|
189
|
+
stop: Symbol(),
|
|
190
|
+
showDeleteFile: Symbol()
|
|
191
|
+
};
|
|
192
|
+
let confirm = Confirm.delete;
|
|
193
|
+
|
|
194
|
+
if (remoteDeletefiles.length > localUploadFiles.length && !opts.skipPrompt) {
|
|
195
|
+
const showSelect = async () => {
|
|
196
|
+
const {
|
|
197
|
+
confirm
|
|
198
|
+
} = await inquirer.prompt({
|
|
199
|
+
name: 'confirm',
|
|
200
|
+
message: `远程需要删除的文件数(${remoteDeletefiles.length})比本地(${localUploadFiles.length})多,确定要删除吗?`,
|
|
201
|
+
type: 'list',
|
|
202
|
+
choices: [{
|
|
203
|
+
name: '删除',
|
|
204
|
+
value: Confirm.delete
|
|
205
|
+
}, {
|
|
206
|
+
name: '不删除,继续部署',
|
|
207
|
+
value: Confirm.skip
|
|
208
|
+
}, {
|
|
209
|
+
name: '中止部署',
|
|
210
|
+
value: Confirm.stop
|
|
211
|
+
}, {
|
|
212
|
+
name: '显示需要删除的文件',
|
|
213
|
+
value: Confirm.showDeleteFile
|
|
214
|
+
}]
|
|
215
|
+
});
|
|
216
|
+
return confirm;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
do {
|
|
220
|
+
confirm = await showSelect();
|
|
221
|
+
|
|
222
|
+
if (confirm === Confirm.stop) {
|
|
223
|
+
process.exit();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (confirm === Confirm.showDeleteFile) {
|
|
227
|
+
const diffFiles = ensureDiff(localUploadFiles, remoteDeletefiles);
|
|
228
|
+
diffFiles.forEach(o => {
|
|
229
|
+
const path = o.isSame ? o.path : chalk.red(o.path);
|
|
230
|
+
console.log(` - ${path}`);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
} while (confirm === Confirm.showDeleteFile);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (confirm === Confirm.delete) {
|
|
237
|
+
spinner.start('开始删除远程文件');
|
|
238
|
+
remoteDeletefiles = mergeDelete(remoteDeletefiles);
|
|
239
|
+
|
|
240
|
+
for (const i in remoteDeletefiles) {
|
|
241
|
+
const o = remoteDeletefiles[i];
|
|
242
|
+
spinner.text = `[${i + 1}/${remoteDeletefiles.length}] 正在删除 ${o.path}`;
|
|
243
|
+
if (o.isDir) await sftp.rmdir(o.path, true);else await sftp.delete(o.path);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
spinner.succeed(`已删除 ${opts.remotePath}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
spinner.start(`开始上传 ${opts.localPath} 到 ${opts.remotePath}`);
|
|
251
|
+
|
|
252
|
+
if (Array.isArray(opts.ignore) && opts.ignore.length > 0) {
|
|
253
|
+
for (const i in localUploadFiles) {
|
|
254
|
+
const o = localUploadFiles[i];
|
|
255
|
+
spinner.text = `[${i}/${localUploadFiles.length}] 正在上传 ${o.localPath} 到 ${o.remotePath}`;
|
|
256
|
+
|
|
257
|
+
if (fs.statSync(o.localPath).isDirectory()) {
|
|
258
|
+
if (!(await sftp.exists(o.remotePath))) {
|
|
259
|
+
await sftp.mkdir(o.remotePath);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await sftp.fastPut(o.localPath, o.remotePath);
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
await sftp.uploadDir(opts.localPath, opts.remotePath);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
spinner.succeed(`已上传 ${opts.localPath} 到 ${opts.remotePath}`);
|
|
272
|
+
return {
|
|
273
|
+
sftp,
|
|
274
|
+
opts
|
|
275
|
+
};
|
|
276
|
+
} catch (error) {
|
|
277
|
+
spinner.fail('异常中断');
|
|
278
|
+
|
|
279
|
+
if (error.message.includes('sftpConnect')) {
|
|
280
|
+
exitWithError(`登录失败,请检查 connectOptions 配置项\n原始信息:${error.message}`);
|
|
281
|
+
} else {
|
|
282
|
+
console.error(error);
|
|
283
|
+
}
|
|
284
|
+
} finally {
|
|
285
|
+
if (!opts.keepAlive) {
|
|
286
|
+
sftp.end();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* @param {Options} opts
|
|
292
|
+
* @param {{d:boolean,u:boolean,i:boolean}} lsOpts
|
|
293
|
+
*/
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async function sshSftpLS(opts, lsOpts) {
|
|
297
|
+
opts = parseOpts(opts);
|
|
298
|
+
const sftp = new Client();
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await sftp.connect(opts.connectOptions);
|
|
302
|
+
|
|
303
|
+
if (lsOpts.d) {
|
|
304
|
+
if (opts.cleanRemoteFiles) {
|
|
305
|
+
const ls = await getRemoteDeepFiles(sftp, opts.remotePath, {
|
|
306
|
+
patterns: opts.cleanRemoteFiles === true ? [] : opts.cleanRemoteFiles
|
|
307
|
+
});
|
|
308
|
+
console.log(`删除文件 ${opts.remotePath}(${ls.length}):`);
|
|
309
|
+
|
|
310
|
+
for (const o of ls) {
|
|
311
|
+
console.log(` - ${o.path}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (lsOpts.i && Array.isArray(opts.ignore) && opts.ignore.length > 0) {
|
|
317
|
+
let ls = glob.sync(`${opts.localPath}/**/*`);
|
|
318
|
+
ls = ls.filter(s => getSafePattern(opts.ignore, opts.localPath).some(reg => minimatch(s, reg)));
|
|
319
|
+
console.log(`忽略文件 (${ls.length}):`);
|
|
320
|
+
|
|
321
|
+
for (const s of ls) {
|
|
322
|
+
console.log(` - ${s}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (lsOpts.u) {
|
|
327
|
+
if (opts.ignore && opts.ignore.length > 0) {
|
|
328
|
+
const ls = getFilesPath(opts.localPath, opts.remotePath, opts.ignore);
|
|
329
|
+
console.log(`上传文件 (${ls.length}): `);
|
|
330
|
+
|
|
331
|
+
for (const o of ls) {
|
|
332
|
+
console.log(` + ${o.localPath}`);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
console.log(`上传 ${opts.localPath} 全部文件到 ${opts.remotePath}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
console.error(error);
|
|
340
|
+
} finally {
|
|
341
|
+
sftp.end();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* @param {{isDir:boolean;path:string}[]} files
|
|
346
|
+
*/
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
function mergeDelete(files) {
|
|
350
|
+
let dirs = files.filter(o => o.isDir);
|
|
351
|
+
dirs.forEach(({
|
|
352
|
+
path
|
|
353
|
+
}) => {
|
|
354
|
+
files = files.filter(o => !(o.path.startsWith(path) && path !== o.path));
|
|
355
|
+
});
|
|
356
|
+
return files;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* @param {string[]} patterns
|
|
360
|
+
* @param {string} prefixPath
|
|
361
|
+
*/
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
function getSafePattern(patterns, prefixPath) {
|
|
365
|
+
const safePatterns = patterns.map(s => s.replace(/^[\.\/]*/, prefixPath + '/')).reduce((acc, s) => [...acc, s, s + '/**/*'], []);
|
|
366
|
+
return safePatterns;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* @param {Options} opts
|
|
370
|
+
*/
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
function parseOpts(opts) {
|
|
374
|
+
opts = Object.assign({}, defualtOpts, opts);
|
|
375
|
+
process.env.__SFTP_NO_WARN = opts.noWarn;
|
|
376
|
+
|
|
377
|
+
const pkg = require(path.resolve('package.json'));
|
|
378
|
+
|
|
379
|
+
if (!pkg.name) {
|
|
380
|
+
exitWithError('package.json 中的 name 字段不能为空');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (pkg.name.startsWith('@')) {
|
|
384
|
+
// 如果包含scope需要无视掉
|
|
385
|
+
pkg.name = pkg.name.replace(/@.*\//, '');
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (opts.preset && opts.preset.server) {
|
|
389
|
+
if (!servers[opts.preset.server]) exitWithError('未知的 preset.server');
|
|
390
|
+
const server = { ...servers[opts.preset.server]
|
|
391
|
+
};
|
|
392
|
+
opts.connectOptions = server;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (opts.preset && opts.preset.context) {
|
|
396
|
+
if (!presets[opts.preset.context]) exitWithError('未知的 preset.context');
|
|
397
|
+
const preset = { ...presets[opts.preset.context]
|
|
398
|
+
};
|
|
399
|
+
const folder = opts.preset.folder || pkg.name;
|
|
400
|
+
preset.remotePath = path.join(preset.remotePath, folder);
|
|
401
|
+
opts = Object.assign({}, preset, opts);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!fs.existsSync(opts.localPath)) {
|
|
405
|
+
exitWithError(`localPath 配置错误,未找到需要上传的文件夹(${opts.localPath})`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!fs.statSync(opts.localPath).isDirectory()) {
|
|
409
|
+
exitWithError('localPath 配置错误,必须是一个文件夹');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (!opts.remotePath) {
|
|
413
|
+
exitWithError('remotePath 未配置');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (typeof opts.securityLock !== 'boolean') {
|
|
417
|
+
opts.securityLock = true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (opts.securityLock === false) {
|
|
421
|
+
warn('请确保自己清楚关闭安全锁(securityLock)后的风险');
|
|
422
|
+
} else {
|
|
423
|
+
if (!opts.remotePath.includes(pkg.name)) {
|
|
424
|
+
exitWithError([`remotePath 中不包含项目名称`, `为防止错误上传/删除和保证服务器目录可读性,你必须让remotePath中包含你的项目名称`, `remotePath:${opts.remotePath}`, `项目名称:${pkg.name} // 源自 package.json 中的 name 字段,忽略scope字段`, `\n你可以设置 "securityLock": false 来关闭这个验证`].join('\n'));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (opts.ignore) {
|
|
429
|
+
opts.ignore = [opts.ignore].flat(1).filter(Boolean);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (opts.cleanRemoteFiles === true) {
|
|
433
|
+
opts.cleanRemoteFiles = [];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (opts.cleanRemoteFiles) {
|
|
437
|
+
opts.cleanRemoteFiles = [opts.cleanRemoteFiles].flat(1).filter(Boolean);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return opts;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function getDeployURL(opts) {
|
|
444
|
+
const _r = opts.connectOptions;
|
|
445
|
+
const sftpURL = `sftp://${_r.username}:${_r.password}@${_r.host}:${_r.port}/${opts.remotePath}`;
|
|
446
|
+
let deployedURL = '未知';
|
|
447
|
+
|
|
448
|
+
for (const context in presets) {
|
|
449
|
+
const preset = presets[context];
|
|
450
|
+
if (!opts.remotePath.startsWith(preset.remotePath)) continue;
|
|
451
|
+
const fullPath = opts.remotePath.replace(preset.remotePath, '').slice(1);
|
|
452
|
+
deployedURL = ['http://www.zhidianbao.cn:8088', context, fullPath, ''].join('/');
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
sftpURL,
|
|
458
|
+
deployedURL
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* @param {Options} opts
|
|
463
|
+
*/
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
function sshSftpShowUrl(opts) {
|
|
467
|
+
opts = parseOpts(opts);
|
|
468
|
+
const {
|
|
469
|
+
deployedURL
|
|
470
|
+
} = getDeployURL(opts);
|
|
471
|
+
console.log('部署网址:', chalk.green(deployedURL));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function sshSftpShowConfig(opts) {
|
|
475
|
+
opts = parseOpts(opts);
|
|
476
|
+
console.log(JSON.stringify(opts, null, 2));
|
|
477
|
+
sshSftpShowUrl(opts);
|
|
478
|
+
sshSftpLS(opts, {
|
|
479
|
+
u: true,
|
|
480
|
+
d: true,
|
|
481
|
+
i: true
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
module.exports = sshSftp;
|
|
486
|
+
module.exports.sshSftp = sshSftp;
|
|
487
|
+
module.exports.sshSftpLS = sshSftpLS;
|
|
488
|
+
module.exports.sshSftpShowUrl = sshSftpShowUrl;
|
|
489
|
+
module.exports.sshSftpShowConfig = sshSftpShowConfig;
|
|
490
|
+
module.exports.parseOpts = parseOpts;
|
|
491
|
+
module.exports.Client = Client;
|
package/lib/presets.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const servers = {
|
|
4
|
+
19: {
|
|
5
|
+
host: '192.168.10.19',
|
|
6
|
+
port: 22,
|
|
7
|
+
username: 'root',
|
|
8
|
+
password: 'eduweb19@'
|
|
9
|
+
},
|
|
10
|
+
171: {
|
|
11
|
+
host: '192.168.10.171',
|
|
12
|
+
port: 22,
|
|
13
|
+
username: 'root',
|
|
14
|
+
password: 'qsweb1@'
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const presets = {
|
|
18
|
+
qsxxwapdev: {
|
|
19
|
+
remotePath: '/erp/edumaven/edu-page-v1',
|
|
20
|
+
connectOptions: servers[19]
|
|
21
|
+
},
|
|
22
|
+
eduwebngv1: {
|
|
23
|
+
remotePath: '/erp/edumaven/edu-web-page-v1',
|
|
24
|
+
connectOptions: servers[19]
|
|
25
|
+
},
|
|
26
|
+
qsxxadminv1: {
|
|
27
|
+
remotePath: '/erp/edumaven/edu-admin-page-dev',
|
|
28
|
+
connectOptions: servers[19]
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
module.exports = {
|
|
32
|
+
presets,
|
|
33
|
+
servers
|
|
34
|
+
};
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
/**
|
|
5
|
+
* @param {string} message
|
|
6
|
+
* @return {never}
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
function exitWithError(message) {
|
|
11
|
+
console.log(`${chalk.bgRed.white.bold(' ERROR ')} ${chalk.redBright(message)}`);
|
|
12
|
+
process.exit();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function warn(message) {
|
|
16
|
+
if (process.env.__SFTP_NO_WARN) return;
|
|
17
|
+
console.log(`${chalk.bgYellow.white.bold(' WARN ')} ${chalk.yellow.bold(message)}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function splitOnFirst(string, separator) {
|
|
21
|
+
if (!(typeof string === 'string' && typeof separator === 'string')) {
|
|
22
|
+
throw new TypeError('Expected the arguments to be of type `string`');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (string === '' || separator === '') {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const separatorIndex = string.indexOf(separator);
|
|
30
|
+
|
|
31
|
+
if (separatorIndex === -1) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [string.slice(0, separatorIndex), string.slice(separatorIndex + separator.length)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports.splitOnFirst = splitOnFirst;
|
|
39
|
+
module.exports.exitWithError = exitWithError;
|
|
40
|
+
module.exports.warn = warn;
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qse/ssh-sftp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "教育代码部署工具",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"author": "Ironkinoko <kinoko_main@outlook.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "dumi dev",
|
|
10
|
+
"docs:build": "dumi build && cp sftprc.schema.json docs-dist",
|
|
11
|
+
"docs:deploy": "ssh-sftp",
|
|
12
|
+
"build": "father-build",
|
|
13
|
+
"deploy": "yarn docs:build && yarn docs:deploy && rm -rf docs-dist",
|
|
14
|
+
"release": "yarn build && npm publish && rm -rf lib",
|
|
15
|
+
"prettier": "prettier -c -w \"src/**/*.{js,jsx,tsx,ts,less,md,json}\"",
|
|
16
|
+
"postversion": "npm run release"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"ssh-sftp": "lib/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"sftp"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"lib"
|
|
26
|
+
],
|
|
27
|
+
"homepage": "http://www.zhidianbao.cn:8088/qsxxwapdev/edu-ssh-sftp/",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"registry": "https://registry.npmjs.org/",
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"chalk": "^4.1.1",
|
|
34
|
+
"glob": "^7.1.6",
|
|
35
|
+
"inquirer": "^8.1.1",
|
|
36
|
+
"minimatch": "^3.0.4",
|
|
37
|
+
"ora": "^5.1.0",
|
|
38
|
+
"ssh2-sftp-client": "^6.0.0",
|
|
39
|
+
"update-notifier": "^5.1.0",
|
|
40
|
+
"yargs": "^16.2.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"dumi": "^1.1.42",
|
|
44
|
+
"father-build": "^1.22.3"
|
|
45
|
+
}
|
|
46
|
+
}
|