@mindbase/deploy 1.2.0 → 1.2.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/README.md +229 -0
- package/package.json +2 -1
- package/src/app.ts +0 -1
- package/src/init/app.ts +8 -3
- package/src/script/parser.ts +49 -9
- package/src/script/runner.ts +15 -14
package/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# deploy CLI
|
|
2
|
+
|
|
3
|
+
多项目部署工具,支持批量部署到多台服务器。
|
|
4
|
+
|
|
5
|
+
## 功能特点
|
|
6
|
+
|
|
7
|
+
- **交互式部署**:通过友好的命令行界面选择目标服务器和项目
|
|
8
|
+
- **笛卡尔组合部署**:选中的每个服务器都会部署全部选中的项目(N 个服务器 × M 个项目 = N×M 次部署)
|
|
9
|
+
- **灵活配置**:项目部署步骤完全自定义,支持本地命令、远程命令、文件上传/下载等
|
|
10
|
+
- **多环境支持**:部署时现场选择目标服务器,不再写死在配置中
|
|
11
|
+
|
|
12
|
+
## 命令结构
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
deploy # 部署:选择服务器 → 选择项目 → 开始部署
|
|
16
|
+
deploy init [path] # 初始化项目配置(path 缺省为当前目录)
|
|
17
|
+
deploy server ... # 服务器管理
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 快速开始
|
|
21
|
+
|
|
22
|
+
### 1. 配置服务器
|
|
23
|
+
|
|
24
|
+
首次使用需要先添加目标服务器:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# 添加服务器
|
|
28
|
+
deploy server add
|
|
29
|
+
|
|
30
|
+
# 列出所有服务器
|
|
31
|
+
deploy server list
|
|
32
|
+
|
|
33
|
+
# 测试连接
|
|
34
|
+
deploy server test
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
服务器配置保存在 `~/.mindbase/deploy.json`,支持密码和私钥两种认证方式。
|
|
38
|
+
|
|
39
|
+
### 2. 初始化项目
|
|
40
|
+
|
|
41
|
+
在项目目录下运行:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cd /path/to/your/project
|
|
45
|
+
deploy init
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
交互式引导会询问:
|
|
49
|
+
- 项目名称
|
|
50
|
+
- 目标服务器(选择已配置的服务器,用于 SFTP 连接浏览远端目录)
|
|
51
|
+
- 本地目录(默认 `./`)
|
|
52
|
+
- 远端目录(可浏览服务器目录或手动输入)
|
|
53
|
+
- 部署步骤(添加构建、上传、重启等步骤)
|
|
54
|
+
|
|
55
|
+
完成后会在当前目录生成 `deploy.config.json` 配置文件。
|
|
56
|
+
|
|
57
|
+
### 3. 部署
|
|
58
|
+
|
|
59
|
+
在包含多个 `deploy.config.json` 的目录(如 monorepo 根目录)运行:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
deploy
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
交互流程:
|
|
66
|
+
1. **选择服务器**(多选,空格切换,a 全选)
|
|
67
|
+
2. **选择项目**(多选)
|
|
68
|
+
3. **确认摘要**(显示 N×M=K 次部署)
|
|
69
|
+
4. **开始部署**(依次连接每个服务器,部署所有选中项目)
|
|
70
|
+
|
|
71
|
+
## 配置文件结构
|
|
72
|
+
|
|
73
|
+
`deploy.config.json` 示例:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"name": "photo",
|
|
78
|
+
"localDir": "./",
|
|
79
|
+
"remoteDir": "/application/lightUp-web/photo/",
|
|
80
|
+
"steps": [
|
|
81
|
+
{ "type": "log", "message": "构建相册" },
|
|
82
|
+
{ "type": "local", "cmd": "pnpm run build", "cwd": "${localDir}" },
|
|
83
|
+
{ "type": "remote", "cmd": "mkdir -p ${remoteDir}" },
|
|
84
|
+
{ "type": "uploadDir", "local": "${localDir}/dist", "remote": "${remoteDir}" }
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 配置字段说明
|
|
90
|
+
|
|
91
|
+
| 字段 | 类型 | 说明 |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `name` | string | 项目显示名称 |
|
|
94
|
+
| `localDir` | string | 本地目录(相对或绝对路径) |
|
|
95
|
+
| `remoteDir` | string | 远端部署目录 |
|
|
96
|
+
| `steps` | Step[] | 部署步骤数组 |
|
|
97
|
+
|
|
98
|
+
### 步骤类型(Step)
|
|
99
|
+
|
|
100
|
+
| 类型 | 说明 | 示例 |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `log` | 输出日志信息 | `{ "type": "log", "message": "开始构建" }` |
|
|
103
|
+
| `local` | 本地执行命令 | `{ "type": "local", "cmd": "npm run build", "cwd": "${localDir}" }` |
|
|
104
|
+
| `remote` | 远程执行命令 | `{ "type": "remote", "cmd": "pm2 restart myapp" }` |
|
|
105
|
+
| `uploadDir` | 上传整个目录 | `{ "type": "uploadDir", "local": "./dist", "remote": "/app/dist" }` |
|
|
106
|
+
| `uploadFile` | 上传单个文件 | `{ "type": "uploadFile", "local": "./package.json", "remote": "/app/package.json" }` |
|
|
107
|
+
| `downloadDir` | 下载目录(备份) | `{ "type": "downloadDir", "remote": "/app/data", "local": "./backup" }` |
|
|
108
|
+
| `downloadFile` | 下载文件(备份) | `{ "type": "downloadFile", "remote": "/app/config.json", "local": "./backup.json" }` |
|
|
109
|
+
|
|
110
|
+
### 变量替换
|
|
111
|
+
|
|
112
|
+
步骤中支持以下变量,运行时自动替换:
|
|
113
|
+
|
|
114
|
+
| 变量 | 说明 | 示例值 |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `${localDir}` | 项目本地目录 | `/path/to/project` |
|
|
117
|
+
| `${remoteDir}` | 项目远端目录 | `/app/photo/` |
|
|
118
|
+
| `${projectName}` | 项目名称 | `photo` |
|
|
119
|
+
| `${date}` | 当前日期 | `2026-06-17` |
|
|
120
|
+
| `${datetime}` | 当前日期时间 | `2026-06-17_14-30-52` |
|
|
121
|
+
| `${timestamp}` | Unix 毫秒时间戳 | `1718621452000` |
|
|
122
|
+
|
|
123
|
+
日期变量可用 `${name:格式}` 自定义格式,如 `${datetime:YYYYMMDD-HHmmss}` → `20260617-143052`。
|
|
124
|
+
|
|
125
|
+
备份示例:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"type": "downloadDir",
|
|
130
|
+
"remote": "${remoteDir}",
|
|
131
|
+
"local": "${localDir}/backup_${datetime}"
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 服务器管理
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 添加服务器
|
|
139
|
+
deploy server add
|
|
140
|
+
|
|
141
|
+
# 编辑服务器
|
|
142
|
+
deploy server edit
|
|
143
|
+
|
|
144
|
+
# 删除服务器
|
|
145
|
+
deploy server delete
|
|
146
|
+
|
|
147
|
+
# 测试连接
|
|
148
|
+
deploy server test
|
|
149
|
+
|
|
150
|
+
# 列出所有服务器
|
|
151
|
+
deploy server list
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 全局配置文件
|
|
155
|
+
|
|
156
|
+
服务器配置保存在 `~/.mindbase/deploy.json`:
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"servers": {
|
|
161
|
+
"own": {
|
|
162
|
+
"host": "192.168.1.100",
|
|
163
|
+
"port": 22,
|
|
164
|
+
"username": "root",
|
|
165
|
+
"password": "***"
|
|
166
|
+
},
|
|
167
|
+
"backup": {
|
|
168
|
+
"host": "192.168.1.101",
|
|
169
|
+
"port": 22,
|
|
170
|
+
"username": "root",
|
|
171
|
+
"privateKeyPath": "~/.ssh/id_rsa",
|
|
172
|
+
"passphrase": "***"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## 使用场景
|
|
179
|
+
|
|
180
|
+
### Monorepo 批量部署
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
cd /path/to/monorepo # 包含多个子项目,每个都有 deploy.config.json
|
|
184
|
+
deploy # 选择服务器和项目,一键部署所有
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### 单项目部署
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
cd /path/to/project
|
|
191
|
+
deploy # 单选本项目,部署到选中服务器
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 多环境部署
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
deploy # 多选 staging、production 两台服务器
|
|
198
|
+
# 选择所有项目,一次性部署到所有环境
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## 边界情况处理
|
|
202
|
+
|
|
203
|
+
- **服务器连接失败**:询问是否跳过并继续下一个服务器
|
|
204
|
+
- **项目部署失败**:询问是否继续部署下一个项目(当前服务器连接保持)
|
|
205
|
+
- **无可用服务器**:提示先运行 `deploy server add`
|
|
206
|
+
- **无可用项目**:提示先运行 `deploy init`
|
|
207
|
+
- **用户取消**:任何交互步骤按 Ctrl+C 均可安全退出
|
|
208
|
+
|
|
209
|
+
## 技术实现
|
|
210
|
+
|
|
211
|
+
- **依赖**:`@clack/prompts`(交互)、`ssh2-sftp-client`(SFTP)、`commander.js`(命令框架)
|
|
212
|
+
- **变量解析**:步骤中的变量在运行时替换
|
|
213
|
+
- **错误处理**:失败时询问用户决策,支持跳过/中止
|
|
214
|
+
- **日志输出**:统一使用 `@clack/prompts` 格式,逐行显示命令输出
|
|
215
|
+
|
|
216
|
+
## 开发
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# 直接运行(无需构建)
|
|
220
|
+
cd cli/apps/deploy
|
|
221
|
+
tsx bin/index.ts
|
|
222
|
+
|
|
223
|
+
# 或在项目根目录
|
|
224
|
+
tsx cli/apps/deploy/bin/index.ts
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindbase/deploy",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "MindBase 部署工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@clack/prompts": "^0.9.1",
|
|
22
22
|
"commander": "^12.0.0",
|
|
23
23
|
"cross-spawn": "^7.0.6",
|
|
24
|
+
"dayjs": "^1.11.13",
|
|
24
25
|
"picocolors": "^1.1.1",
|
|
25
26
|
"ssh2-sftp-client": "^9.1.0",
|
|
26
27
|
"tsx": "^4.19.0",
|
package/src/app.ts
CHANGED
|
@@ -80,7 +80,6 @@ export async function runApp(): Promise<void> {
|
|
|
80
80
|
const summaryLines = [
|
|
81
81
|
`服务器: ${selectedServers.map((s) => `${s} (${globalConfig.servers[s].host})`).join(', ')}`,
|
|
82
82
|
`项目: ${selectedProjects.map((p) => p.config.name).join(', ')}`,
|
|
83
|
-
`组合: ${selectedServers.length} × ${selectedProjects.length} = ${selectedServers.length * selectedProjects.length} 次部署`,
|
|
84
83
|
].join('\n');
|
|
85
84
|
noteBox(summaryLines, '即将部署');
|
|
86
85
|
|
package/src/init/app.ts
CHANGED
|
@@ -50,6 +50,11 @@ const VARS_REFERENCE = [
|
|
|
50
50
|
' ${localDir} 项目本地目录',
|
|
51
51
|
' ${remoteDir} 项目远程目录',
|
|
52
52
|
' ${projectName} 项目名称',
|
|
53
|
+
' ${date} 日期 (YYYY-MM-DD)',
|
|
54
|
+
' ${datetime} 日期时间 (YYYY-MM-DD_HH-mm-ss)',
|
|
55
|
+
' ${timestamp} Unix 毫秒时间戳',
|
|
56
|
+
'',
|
|
57
|
+
' 可用 ${name:格式} 自定义,如 ${datetime:YYYYMMDD-HHmmss}',
|
|
53
58
|
].join('\n');
|
|
54
59
|
|
|
55
60
|
/** 步骤类型示例 */
|
|
@@ -94,15 +99,15 @@ const STEP_EXAMPLES: Record<StepType, string> = {
|
|
|
94
99
|
VARS_REFERENCE,
|
|
95
100
|
'',
|
|
96
101
|
'示例:',
|
|
97
|
-
' 远端: ${remoteDir}
|
|
98
|
-
' 本地: ${localDir}/
|
|
102
|
+
' 远端: ${remoteDir}',
|
|
103
|
+
' 本地: ${localDir}/backup_${datetime}',
|
|
99
104
|
].join('\n'),
|
|
100
105
|
downloadFile: [
|
|
101
106
|
VARS_REFERENCE,
|
|
102
107
|
'',
|
|
103
108
|
'示例:',
|
|
104
109
|
' 远端: ${remoteDir}config.json',
|
|
105
|
-
' 本地: ${localDir}/config.json.
|
|
110
|
+
' 本地: ${localDir}/config.json.bak_${date}',
|
|
106
111
|
].join('\n'),
|
|
107
112
|
};
|
|
108
113
|
|
package/src/script/parser.ts
CHANGED
|
@@ -1,22 +1,62 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
1
2
|
import type { ProjectConfig } from '../config/schema.js';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/** 内置变量名(含日期时间变量) */
|
|
5
|
+
const BUILT_IN_VARS = ['localDir', 'remoteDir', 'projectName', 'date', 'datetime', 'timestamp'] as const;
|
|
6
|
+
|
|
7
|
+
/** 日期变量预设及其默认格式(timestamp 固定为毫秒,无 format) */
|
|
8
|
+
const DATE_VAR_DEFAULTS: Record<string, string | null> = {
|
|
9
|
+
date: 'YYYY-MM-DD',
|
|
10
|
+
datetime: 'YYYY-MM-DD_HH-mm-ss',
|
|
11
|
+
timestamp: null, // 特殊:输出毫秒
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** 变量解析上下文(运行时动态变量) */
|
|
15
|
+
export interface ResolveContext {
|
|
16
|
+
/** 时间锚点;缺省取当前时间。同一次部署应传同一个 Date 保证一致性 */
|
|
17
|
+
now?: Date;
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
/**
|
|
9
|
-
* 替换字符串中的 ${varName} 占位符
|
|
21
|
+
* 替换字符串中的 ${varName} 或 ${name:format} 占位符
|
|
22
|
+
* - 静态变量:${localDir}、${remoteDir}、${projectName}
|
|
23
|
+
* - 日期变量:${date}、${datetime}、${timestamp},可加 :format 自定义格式,如 ${datetime:YYYYMMDD-HHmmss}
|
|
24
|
+
* - 未知变量:原样保留(向后兼容)
|
|
10
25
|
*/
|
|
11
|
-
export function resolveVariables(
|
|
12
|
-
|
|
26
|
+
export function resolveVariables(
|
|
27
|
+
template: string,
|
|
28
|
+
project: ProjectConfig,
|
|
29
|
+
ctx: ResolveContext = {},
|
|
30
|
+
): string {
|
|
31
|
+
const staticVars: Record<string, string> = {
|
|
13
32
|
localDir: project.localDir,
|
|
14
33
|
remoteDir: project.remoteDir,
|
|
15
34
|
projectName: project.name,
|
|
16
35
|
};
|
|
36
|
+
const now = ctx.now ?? new Date();
|
|
37
|
+
|
|
38
|
+
return template.replace(/\$\{([^}]+)\}/g, (match, expr: string) => {
|
|
39
|
+
// 按第一个冒号分割 name 和 format(format 内部可能含冒号,如 HH:mm:ss)
|
|
40
|
+
const colon = expr.indexOf(':');
|
|
41
|
+
const name = colon === -1 ? expr : expr.slice(0, colon);
|
|
42
|
+
const format = colon === -1 ? '' : expr.slice(colon + 1);
|
|
43
|
+
|
|
44
|
+
// 1. 静态变量优先
|
|
45
|
+
if (staticVars[name] !== undefined) {
|
|
46
|
+
return staticVars[name];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. 日期变量
|
|
50
|
+
if (name in DATE_VAR_DEFAULTS) {
|
|
51
|
+
if (name === 'timestamp') {
|
|
52
|
+
return String(dayjs(now).valueOf());
|
|
53
|
+
}
|
|
54
|
+
// date/datetime 用默认格式或自定义格式
|
|
55
|
+
return dayjs(now).format(format || (DATE_VAR_DEFAULTS[name] as string));
|
|
56
|
+
}
|
|
17
57
|
|
|
18
|
-
|
|
19
|
-
return
|
|
58
|
+
// 3. 未知变量原样保留(向后兼容)
|
|
59
|
+
return match;
|
|
20
60
|
});
|
|
21
61
|
}
|
|
22
62
|
|
package/src/script/runner.ts
CHANGED
|
@@ -38,27 +38,27 @@ function runLocal(cmd: string, cwd?: string): Promise<void> {
|
|
|
38
38
|
/**
|
|
39
39
|
* 执行单个步骤
|
|
40
40
|
*/
|
|
41
|
-
async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Promise<void> {
|
|
41
|
+
async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient, now: Date): Promise<void> {
|
|
42
42
|
switch (step.type) {
|
|
43
43
|
case 'log': {
|
|
44
|
-
const msg = resolveVariables(step.message, project);
|
|
44
|
+
const msg = resolveVariables(step.message, project, { now });
|
|
45
45
|
clack.log.message(msg);
|
|
46
46
|
break;
|
|
47
47
|
}
|
|
48
48
|
case 'local': {
|
|
49
|
-
const cmd = resolveVariables(step.cmd, project);
|
|
50
|
-
const cwd = step.cwd ? resolveVariables(step.cwd, project) : undefined;
|
|
49
|
+
const cmd = resolveVariables(step.cmd, project, { now });
|
|
50
|
+
const cwd = step.cwd ? resolveVariables(step.cwd, project, { now }) : undefined;
|
|
51
51
|
await runLocal(cmd, cwd);
|
|
52
52
|
break;
|
|
53
53
|
}
|
|
54
54
|
case 'remote': {
|
|
55
|
-
const cmd = resolveVariables(step.cmd, project);
|
|
55
|
+
const cmd = resolveVariables(step.cmd, project, { now });
|
|
56
56
|
await execRemote(sftp, cmd);
|
|
57
57
|
break;
|
|
58
58
|
}
|
|
59
59
|
case 'uploadDir': {
|
|
60
|
-
const local = resolveVariables(step.local, project);
|
|
61
|
-
const remote = resolveVariables(step.remote, project);
|
|
60
|
+
const local = resolveVariables(step.local, project, { now });
|
|
61
|
+
const remote = resolveVariables(step.remote, project, { now });
|
|
62
62
|
clack.log.step(`上传目录: ${local} → ${remote}`);
|
|
63
63
|
await sftp.mkdir(remote, true);
|
|
64
64
|
await sftp.uploadDir(local, remote, { useFastput: true });
|
|
@@ -66,24 +66,24 @@ async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Pr
|
|
|
66
66
|
break;
|
|
67
67
|
}
|
|
68
68
|
case 'uploadFile': {
|
|
69
|
-
const local = resolveVariables(step.local, project);
|
|
70
|
-
const remote = resolveVariables(step.remote, project);
|
|
69
|
+
const local = resolveVariables(step.local, project, { now });
|
|
70
|
+
const remote = resolveVariables(step.remote, project, { now });
|
|
71
71
|
clack.log.step(`上传文件: ${local} → ${remote}`);
|
|
72
72
|
await sftp.fastPut(local, remote);
|
|
73
73
|
clack.log.success('上传文件完成');
|
|
74
74
|
break;
|
|
75
75
|
}
|
|
76
76
|
case 'downloadDir': {
|
|
77
|
-
const remote = resolveVariables(step.remote, project);
|
|
78
|
-
const local = resolveVariables(step.local, project);
|
|
77
|
+
const remote = resolveVariables(step.remote, project, { now });
|
|
78
|
+
const local = resolveVariables(step.local, project, { now });
|
|
79
79
|
clack.log.step(`下载目录: ${remote} → ${local}`);
|
|
80
80
|
await sftp.downloadDir(remote, local);
|
|
81
81
|
clack.log.success('下载目录完成');
|
|
82
82
|
break;
|
|
83
83
|
}
|
|
84
84
|
case 'downloadFile': {
|
|
85
|
-
const remote = resolveVariables(step.remote, project);
|
|
86
|
-
const local = resolveVariables(step.local, project);
|
|
85
|
+
const remote = resolveVariables(step.remote, project, { now });
|
|
86
|
+
const local = resolveVariables(step.local, project, { now });
|
|
87
87
|
clack.log.step(`下载文件: ${remote} → ${local}`);
|
|
88
88
|
await sftp.fastGet(remote, local);
|
|
89
89
|
clack.log.success('下载文件完成');
|
|
@@ -96,7 +96,8 @@ async function runStep(step: Step, project: ProjectConfig, sftp: SftpClient): Pr
|
|
|
96
96
|
* 执行项目的所有步骤
|
|
97
97
|
*/
|
|
98
98
|
export async function runSteps(steps: Step[], project: ProjectConfig, sftp: SftpClient): Promise<void> {
|
|
99
|
+
const now = new Date(); // 整个项目部署共用同一时间戳
|
|
99
100
|
for (const step of steps) {
|
|
100
|
-
await runStep(step, project, sftp);
|
|
101
|
+
await runStep(step, project, sftp, now);
|
|
101
102
|
}
|
|
102
103
|
}
|