@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 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",
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",
@@ -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
- const envName = envNameFor(key, input);
44
- let value;
45
- if (Object.prototype.hasOwnProperty.call(options.set || {}, key)) {
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 === undefined || value === null || 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
+ });