@icyouo/evt-cli 0.1.3 → 0.1.4
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 +36 -0
- package/README.zh-CN.md +35 -0
- package/data/flows/login.example.yaml +20 -0
- package/package.json +2 -2
- package/src/config/validate.js +19 -0
- package/src/flow/inputResolver.js +143 -25
- package/test/flow.test.js +86 -0
package/README.md
CHANGED
|
@@ -275,6 +275,42 @@ intermediate endpoint should include at least:
|
|
|
275
275
|
|
|
276
276
|
This JSON is used only for scanning, coverage, and sync. Runtime execution still reads YAML.
|
|
277
277
|
|
|
278
|
+
## Flow Inputs
|
|
279
|
+
|
|
280
|
+
Flow inputs can prompt users interactively. Inputs with `type: "password"` are
|
|
281
|
+
masked with `*` while typing.
|
|
282
|
+
|
|
283
|
+
Use `inputGroups.anyOf` when one value from a set is required. For example,
|
|
284
|
+
login flows can accept either an email verification code or a Google OTP:
|
|
285
|
+
|
|
286
|
+
```yaml
|
|
287
|
+
inputs:
|
|
288
|
+
email:
|
|
289
|
+
prompt: Email
|
|
290
|
+
type: string
|
|
291
|
+
required: true
|
|
292
|
+
password:
|
|
293
|
+
prompt: Password
|
|
294
|
+
type: password
|
|
295
|
+
required: true
|
|
296
|
+
code:
|
|
297
|
+
prompt: Email code
|
|
298
|
+
type: string
|
|
299
|
+
default: ""
|
|
300
|
+
googleCode:
|
|
301
|
+
prompt: Google OTP
|
|
302
|
+
type: string
|
|
303
|
+
default: ""
|
|
304
|
+
|
|
305
|
+
inputGroups:
|
|
306
|
+
anyOf:
|
|
307
|
+
- fields:
|
|
308
|
+
- code
|
|
309
|
+
- googleCode
|
|
310
|
+
prompt: Enter either an email code or Google OTP.
|
|
311
|
+
message: email code or Google OTP
|
|
312
|
+
```
|
|
313
|
+
|
|
278
314
|
## Common Commands
|
|
279
315
|
|
|
280
316
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -264,6 +264,41 @@ npm run sync:api -- --config-root /path/to/project/cli
|
|
|
264
264
|
|
|
265
265
|
这个 JSON 只参与扫描/覆盖率/sync,最终运行仍然读取 YAML。
|
|
266
266
|
|
|
267
|
+
## Flow 输入
|
|
268
|
+
|
|
269
|
+
flow inputs 支持交互式输入。`type: "password"` 的输入会在键入时显示为 `*`。
|
|
270
|
+
|
|
271
|
+
当一组输入里必须至少提供一个值时,可以使用 `inputGroups.anyOf`。例如登录 flow
|
|
272
|
+
可以支持邮箱验证码和 Google OTP 二选一:
|
|
273
|
+
|
|
274
|
+
```yaml
|
|
275
|
+
inputs:
|
|
276
|
+
email:
|
|
277
|
+
prompt: Email
|
|
278
|
+
type: string
|
|
279
|
+
required: true
|
|
280
|
+
password:
|
|
281
|
+
prompt: Password
|
|
282
|
+
type: password
|
|
283
|
+
required: true
|
|
284
|
+
code:
|
|
285
|
+
prompt: Email code
|
|
286
|
+
type: string
|
|
287
|
+
default: ""
|
|
288
|
+
googleCode:
|
|
289
|
+
prompt: Google OTP
|
|
290
|
+
type: string
|
|
291
|
+
default: ""
|
|
292
|
+
|
|
293
|
+
inputGroups:
|
|
294
|
+
anyOf:
|
|
295
|
+
- fields:
|
|
296
|
+
- code
|
|
297
|
+
- googleCode
|
|
298
|
+
prompt: Enter either an email code or Google OTP.
|
|
299
|
+
message: email code or Google OTP
|
|
300
|
+
```
|
|
301
|
+
|
|
267
302
|
## 常用命令
|
|
268
303
|
|
|
269
304
|
```bash
|
|
@@ -10,6 +10,24 @@ inputs:
|
|
|
10
10
|
prompt: Password
|
|
11
11
|
type: password
|
|
12
12
|
required: true
|
|
13
|
+
code:
|
|
14
|
+
prompt: Email code
|
|
15
|
+
type: string
|
|
16
|
+
required: false
|
|
17
|
+
default: ""
|
|
18
|
+
googleCode:
|
|
19
|
+
prompt: Google OTP
|
|
20
|
+
type: string
|
|
21
|
+
required: false
|
|
22
|
+
default: ""
|
|
23
|
+
|
|
24
|
+
inputGroups:
|
|
25
|
+
anyOf:
|
|
26
|
+
- fields:
|
|
27
|
+
- code
|
|
28
|
+
- googleCode
|
|
29
|
+
prompt: Enter either an email code or Google OTP.
|
|
30
|
+
message: email code or Google OTP
|
|
13
31
|
|
|
14
32
|
steps:
|
|
15
33
|
- id: login
|
|
@@ -17,6 +35,8 @@ steps:
|
|
|
17
35
|
body:
|
|
18
36
|
email: "{{inputs.email}}"
|
|
19
37
|
password: "{{inputs.password}}"
|
|
38
|
+
code: "{{inputs.code}}"
|
|
39
|
+
googleCode: "{{inputs.googleCode}}"
|
|
20
40
|
|
|
21
41
|
save:
|
|
22
42
|
cache:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@icyouo/evt-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Run YAML-defined HTTP APIs and interactive workflows from your terminal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"scripts": {
|
|
28
28
|
"ci:local": "node scripts/local-ci.js",
|
|
29
29
|
"test": "node --test",
|
|
30
|
-
"check": "node --check bin/evt.js && node --check src/index.js && node --check src/util/settings.js && node --check src/api/liveTester.js && node --check scripts/sync-api-coverage.js && node --check scripts/check-api-coverage.js && node --check scripts/check-api-audit.js && node --check scripts/local-ci.js && node --check scripts/build-package.js && node --check skills/evt-api-scanner/scripts/discover.js && node --check skills/evt-api-scanner/scripts/scan.js",
|
|
30
|
+
"check": "node --check bin/evt.js && node --check src/index.js && node --check src/util/settings.js && node --check src/flow/inputResolver.js && node --check src/api/liveTester.js && node --check scripts/sync-api-coverage.js && node --check scripts/check-api-coverage.js && node --check scripts/check-api-audit.js && node --check scripts/local-ci.js && node --check scripts/build-package.js && node --check skills/evt-api-scanner/scripts/discover.js && node --check skills/evt-api-scanner/scripts/scan.js",
|
|
31
31
|
"package:local": "node scripts/build-package.js",
|
|
32
32
|
"sync:api": "node scripts/sync-api-coverage.js",
|
|
33
33
|
"coverage:api": "node scripts/check-api-coverage.js",
|
package/src/config/validate.js
CHANGED
|
@@ -143,6 +143,25 @@ function validateFlow(flow, file) {
|
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
+
if (flow.inputGroups !== undefined) {
|
|
147
|
+
if (!isObject(flow.inputGroups)) {
|
|
148
|
+
push(errors, file, "flow.inputGroups must be an object");
|
|
149
|
+
} else if (flow.inputGroups.anyOf !== undefined) {
|
|
150
|
+
if (!Array.isArray(flow.inputGroups.anyOf)) {
|
|
151
|
+
push(errors, file, "flow.inputGroups.anyOf must be an array");
|
|
152
|
+
} else {
|
|
153
|
+
for (const [index, group] of flow.inputGroups.anyOf.entries()) {
|
|
154
|
+
if (!isObject(group)) {
|
|
155
|
+
push(errors, file, `inputGroups.anyOf[${index}] must be an object`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!Array.isArray(group.fields) || group.fields.length === 0) {
|
|
159
|
+
push(errors, file, `inputGroups.anyOf[${index}].fields must be a non-empty array`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
146
165
|
if (flow.steps !== undefined && !Array.isArray(flow.steps)) push(errors, file, "flow.steps must be an array");
|
|
147
166
|
for (const [index, step] of (flow.steps || []).entries()) {
|
|
148
167
|
if (!isObject(step)) {
|
|
@@ -4,11 +4,79 @@ function envNameFor(key, input) {
|
|
|
4
4
|
return input.env || `NEPTUNE_${key.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}`;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
function hasValue(value) {
|
|
8
|
+
return !(value === undefined || value === null || value === "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function promptPassword(question, input) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let value = "";
|
|
14
|
+
const stdin = process.stdin;
|
|
15
|
+
const stdout = process.stdout;
|
|
16
|
+
|
|
17
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
|
|
18
|
+
const rl = readline.createInterface({
|
|
19
|
+
input: stdin,
|
|
20
|
+
output: stdout,
|
|
21
|
+
terminal: true
|
|
22
|
+
});
|
|
23
|
+
rl.question(question, (answer) => {
|
|
24
|
+
rl.close();
|
|
25
|
+
resolve(answer === "" && input.default !== undefined ? input.default : answer);
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const wasRaw = stdin.isRaw;
|
|
31
|
+
stdout.write(question);
|
|
32
|
+
readline.emitKeypressEvents(stdin);
|
|
33
|
+
stdin.setRawMode(true);
|
|
34
|
+
stdin.resume();
|
|
35
|
+
|
|
36
|
+
const cleanup = () => {
|
|
37
|
+
stdin.removeListener("keypress", onKeypress);
|
|
38
|
+
stdin.setRawMode(Boolean(wasRaw));
|
|
39
|
+
stdout.write("\n");
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function onKeypress(sequence, key = {}) {
|
|
43
|
+
if (key.ctrl && key.name === "c") {
|
|
44
|
+
cleanup();
|
|
45
|
+
reject(new Error("Input cancelled"));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key.name === "return" || key.name === "enter") {
|
|
49
|
+
cleanup();
|
|
50
|
+
resolve(value === "" && input.default !== undefined ? input.default : value);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
54
|
+
if (value.length > 0) {
|
|
55
|
+
value = value.slice(0, -1);
|
|
56
|
+
stdout.write("\b \b");
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (key.ctrl || key.meta || !sequence || /[\r\n]/.test(sequence)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
value += sequence;
|
|
64
|
+
stdout.write("*");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stdin.on("keypress", onKeypress);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
7
71
|
function promptValue(key, input) {
|
|
8
72
|
const label = input.prompt || key;
|
|
9
73
|
const suffix = input.default !== undefined && input.default !== "" ? ` (${input.default})` : "";
|
|
10
74
|
const question = `${label}${suffix}: `;
|
|
11
75
|
|
|
76
|
+
if (input.type === "password") {
|
|
77
|
+
return promptPassword(question, input);
|
|
78
|
+
}
|
|
79
|
+
|
|
12
80
|
return new Promise((resolve) => {
|
|
13
81
|
const rl = readline.createInterface({
|
|
14
82
|
input: process.stdin,
|
|
@@ -16,47 +84,92 @@ function promptValue(key, input) {
|
|
|
16
84
|
terminal: true
|
|
17
85
|
});
|
|
18
86
|
|
|
19
|
-
if (input.type === "password") {
|
|
20
|
-
rl.stdoutMuted = true;
|
|
21
|
-
rl._writeToOutput = function writeToOutput(stringToWrite) {
|
|
22
|
-
if (!rl.stdoutMuted) {
|
|
23
|
-
rl.output.write(stringToWrite);
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
87
|
rl.question(question, (answer) => {
|
|
29
88
|
rl.close();
|
|
30
|
-
if (input.type === "password") {
|
|
31
|
-
process.stdout.write("\n");
|
|
32
|
-
}
|
|
33
89
|
resolve(answer === "" && input.default !== undefined ? input.default : answer);
|
|
34
90
|
});
|
|
35
91
|
});
|
|
36
92
|
}
|
|
37
93
|
|
|
94
|
+
function readConfiguredInput(key, input, options) {
|
|
95
|
+
const envName = envNameFor(key, input);
|
|
96
|
+
if (Object.prototype.hasOwnProperty.call(options.set || {}, key)) {
|
|
97
|
+
return options.set[key];
|
|
98
|
+
}
|
|
99
|
+
if (process.env[envName] !== undefined) {
|
|
100
|
+
return process.env[envName];
|
|
101
|
+
}
|
|
102
|
+
if (options.cache && options.cache.inputs && options.cache.inputs[key] !== undefined) {
|
|
103
|
+
return options.cache.inputs[key];
|
|
104
|
+
}
|
|
105
|
+
if (options.profile && options.profile.inputs && options.profile.inputs[key] !== undefined) {
|
|
106
|
+
return options.profile.inputs[key];
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function anyOfGroups(flow) {
|
|
112
|
+
const inputGroups = flow.inputGroups || {};
|
|
113
|
+
const groups = inputGroups.anyOf || [];
|
|
114
|
+
return Array.isArray(groups) ? groups : [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function groupedKeys(flow) {
|
|
118
|
+
const keys = new Set();
|
|
119
|
+
for (const group of anyOfGroups(flow)) {
|
|
120
|
+
for (const key of group.fields || []) keys.add(key);
|
|
121
|
+
}
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function resolveAnyOfGroups(flow, options, result) {
|
|
126
|
+
const definitions = flow.inputs || {};
|
|
127
|
+
|
|
128
|
+
for (const group of anyOfGroups(flow)) {
|
|
129
|
+
const fields = group.fields || [];
|
|
130
|
+
if (fields.some((key) => hasValue(result[key]))) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let resolved = false;
|
|
135
|
+
if (!options.noInteractive && process.stdin.isTTY) {
|
|
136
|
+
if (group.prompt) {
|
|
137
|
+
process.stdout.write(`${group.prompt}\n`);
|
|
138
|
+
}
|
|
139
|
+
for (const key of fields) {
|
|
140
|
+
const input = definitions[key];
|
|
141
|
+
if (!input) continue;
|
|
142
|
+
const value = await promptValue(key, { ...input, default: undefined });
|
|
143
|
+
if (hasValue(value)) {
|
|
144
|
+
result[key] = value;
|
|
145
|
+
resolved = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!resolved && !fields.some((key) => hasValue(result[key]))) {
|
|
152
|
+
const label = group.message || `One of ${fields.join(", ")} is required`;
|
|
153
|
+
throw new Error(`Missing required flow input group: ${label}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
38
158
|
async function resolveInputs(flow, options) {
|
|
39
159
|
const definitions = flow.inputs || {};
|
|
40
160
|
const result = {};
|
|
161
|
+
const anyOfKeys = groupedKeys(flow);
|
|
41
162
|
|
|
42
163
|
for (const [key, input] of Object.entries(definitions)) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
value = options.set[key];
|
|
47
|
-
} else if (process.env[envName] !== undefined) {
|
|
48
|
-
value = process.env[envName];
|
|
49
|
-
} else if (options.cache && options.cache.inputs && options.cache.inputs[key] !== undefined) {
|
|
50
|
-
value = options.cache.inputs[key];
|
|
51
|
-
} else if (options.profile && options.profile.inputs && options.profile.inputs[key] !== undefined) {
|
|
52
|
-
value = options.profile.inputs[key];
|
|
53
|
-
} else if (!options.noInteractive && process.stdin.isTTY) {
|
|
164
|
+
let value = readConfiguredInput(key, input, options);
|
|
165
|
+
const deferInteractive = anyOfKeys.has(key) && !input.required;
|
|
166
|
+
if (value === undefined && !deferInteractive && !options.noInteractive && process.stdin.isTTY) {
|
|
54
167
|
value = await promptValue(key, input);
|
|
55
|
-
} else if (input.default !== undefined) {
|
|
168
|
+
} else if (value === undefined && input.default !== undefined) {
|
|
56
169
|
value = input.default;
|
|
57
170
|
}
|
|
58
171
|
|
|
59
|
-
if (input.required && (value
|
|
172
|
+
if (input.required && !hasValue(value)) {
|
|
60
173
|
throw new Error(`Missing required flow input: ${key}`);
|
|
61
174
|
}
|
|
62
175
|
if (value !== undefined) {
|
|
@@ -64,9 +177,14 @@ async function resolveInputs(flow, options) {
|
|
|
64
177
|
}
|
|
65
178
|
}
|
|
66
179
|
|
|
180
|
+
await resolveAnyOfGroups(flow, options, result);
|
|
181
|
+
|
|
67
182
|
return result;
|
|
68
183
|
}
|
|
69
184
|
|
|
70
185
|
module.exports = {
|
|
186
|
+
hasValue,
|
|
187
|
+
promptPassword,
|
|
188
|
+
promptValue,
|
|
71
189
|
resolveInputs
|
|
72
190
|
};
|
package/test/flow.test.js
CHANGED
|
@@ -213,3 +213,89 @@ test("skips response expectations during dry-run", async () => {
|
|
|
213
213
|
assert.equal(result.flow, "dry-run-expect");
|
|
214
214
|
assert.deepEqual(result.vars, []);
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
test("accepts one input from an anyOf input group", async () => {
|
|
218
|
+
const result = await runFlow({
|
|
219
|
+
name: "login-any-of",
|
|
220
|
+
inputs: {
|
|
221
|
+
email: { required: true },
|
|
222
|
+
password: { required: true, type: "password" },
|
|
223
|
+
code: { default: "" },
|
|
224
|
+
googleCode: { default: "" }
|
|
225
|
+
},
|
|
226
|
+
inputGroups: {
|
|
227
|
+
anyOf: [
|
|
228
|
+
{ fields: ["code", "googleCode"], message: "email code or Google OTP" }
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
steps: [
|
|
232
|
+
{
|
|
233
|
+
id: "login",
|
|
234
|
+
call: "auth.login",
|
|
235
|
+
body: {
|
|
236
|
+
email: "{{inputs.email}}",
|
|
237
|
+
password: "{{inputs.password}}",
|
|
238
|
+
code: "{{inputs.code}}",
|
|
239
|
+
googleCode: "{{inputs.googleCode}}"
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
}, {
|
|
244
|
+
registry: loadApiRegistry(),
|
|
245
|
+
profile: loadProfile("local"),
|
|
246
|
+
cache: {},
|
|
247
|
+
cachePath: "/tmp/evt-cli-any-of-cache-test.json",
|
|
248
|
+
set: {
|
|
249
|
+
email: "user@example.com",
|
|
250
|
+
password: "secret",
|
|
251
|
+
googleCode: "123456"
|
|
252
|
+
},
|
|
253
|
+
dryRun: true,
|
|
254
|
+
noInteractive: true
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
assert.deepEqual(result.inputs.sort(), ["code", "email", "googleCode", "password"].sort());
|
|
258
|
+
assert.equal(result.steps.login.request.body.code, "");
|
|
259
|
+
assert.equal(result.steps.login.request.body.googleCode, "123456");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("fails when no input from an anyOf input group is available", async () => {
|
|
263
|
+
await assert.rejects(
|
|
264
|
+
runFlow({
|
|
265
|
+
name: "login-any-of-missing",
|
|
266
|
+
inputs: {
|
|
267
|
+
email: { required: true },
|
|
268
|
+
password: { required: true, type: "password" },
|
|
269
|
+
code: { default: "" },
|
|
270
|
+
googleCode: { default: "" }
|
|
271
|
+
},
|
|
272
|
+
inputGroups: {
|
|
273
|
+
anyOf: [
|
|
274
|
+
{ fields: ["code", "googleCode"], message: "email code or Google OTP" }
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
steps: [
|
|
278
|
+
{
|
|
279
|
+
id: "login",
|
|
280
|
+
call: "auth.login",
|
|
281
|
+
body: {
|
|
282
|
+
email: "{{inputs.email}}",
|
|
283
|
+
password: "{{inputs.password}}"
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
]
|
|
287
|
+
}, {
|
|
288
|
+
registry: loadApiRegistry(),
|
|
289
|
+
profile: { name: "test", baseUrl: "https://api.example.com", inputs: {} },
|
|
290
|
+
cache: {},
|
|
291
|
+
cachePath: "/tmp/evt-cli-any-of-missing-cache-test.json",
|
|
292
|
+
set: {
|
|
293
|
+
email: "user@example.com",
|
|
294
|
+
password: "secret"
|
|
295
|
+
},
|
|
296
|
+
dryRun: true,
|
|
297
|
+
noInteractive: true
|
|
298
|
+
}),
|
|
299
|
+
/Missing required flow input group: email code or Google OTP/
|
|
300
|
+
);
|
|
301
|
+
});
|