@topcli/prompts 1.1.0 → 1.3.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/README.md CHANGED
@@ -5,10 +5,10 @@
5
5
  [![isc](https://img.shields.io/badge/License-ISC-blue.svg?style=for-the-badge)](https://github.com/TopCli/prompts/blob/main/LICENSE)
6
6
  ![build](https://img.shields.io/github/actions/workflow/status/TopCli/prompts/node.js.yml?style=for-the-badge)
7
7
 
8
- Node.js user input library for command-line interfaces.
8
+ Node.js user prompt library for command-line interfaces.
9
9
 
10
10
  ## Requirements
11
- - [Node.js](https://nodejs.org/en/) v14 or higher
11
+ - [Node.js](https://nodejs.org/en/) v16 or higher
12
12
 
13
13
  ## Getting Started
14
14
 
@@ -27,20 +27,15 @@ $ yarn add @topcli/prompts
27
27
  You can locally run `node ./demo.js`
28
28
 
29
29
  ```js
30
- import { prompt, confirm, select } from '@topcli/prompts';
30
+ import { question, confirm, select } from "@topcli/prompts";
31
31
 
32
- const kTestRunner = ['node', 'tap', 'tape', 'vitest', 'mocha', 'ava'];
32
+ const kTestRunner = ["node", "tap", "tape", "vitest", "mocha", "ava"];
33
33
 
34
- const name = await prompt('Project name ?');
35
- const runner = await select('Choose a test runner', {
36
- choices: kTestRunner,
37
- maxVisible: 5
38
- });
39
- const isCLI = await confirm('Your project is a CLI ?', {
40
- initial: true
41
- });
34
+ const name = await question("Project name ?", { defaultValue: "foo" });
35
+ const runner = await select("Choose a test runner", { choices: kTestRunner, maxVisible: 5 });
36
+ const isCLI = await confirm("Your project is a CLI ?", { initial: true });
42
37
 
43
- console.log(name, runner, isCLI);^
38
+ console.log(name, runner, isCLI);
44
39
  ```
45
40
 
46
41
  ## API
@@ -94,38 +89,64 @@ Use `ignoreValues` to skip result render & clear lines after a selected one.
94
89
  confirm(message: string, options?: ConfirmOptions): Promise<boolean>
95
90
  ```
96
91
 
97
- Boolean prompt, return `options.initial` if user input is different from "y"/"yes"/"n"/"no", (default `false`).
92
+ Boolean prompt, return `options.initial` if user input is different from `y`/`yes`/`n`/`no` (case insensitive), (default `false`).
93
+
94
+ ### `PromptAgent`
95
+
96
+ The `PromptAgent` class allows to programmatically set the next answers for any prompt function, this can be useful for testing.
97
+
98
+ ```ts
99
+ const agent = PromptAgent.agent();
100
+ agent.nextAnswer("John");
101
+
102
+ const input = await question("What's your name?");
103
+ assert.equal(input, "John");
104
+ ```
105
+
106
+ > [!WARNING]
107
+ > Answers set with `PromptAgent` will **bypass** any logical & validation rules.
108
+ > Examples:
109
+ > - When using `question()`, `validators` functions will not be executed.
110
+ > - When using `select()`, the answer can be different from the available choices.
111
+ > - When using `confirm()`, the answer can be any type other than boolean.
112
+ > **Use with caution**
98
113
 
99
114
  ## Interfaces
100
115
 
101
116
  ```ts
102
- export interface PromptOptions {
117
+ export interface SharedOptions {
118
+ stdin?: NodeJS.ReadStream & {
119
+ fd: 0;
120
+ };
121
+ stdout?: NodeJS.WriteStream & {
122
+ fd: 1;
123
+ };
124
+ }
125
+
126
+ export interface Validator {
127
+ validate: (input: string) => boolean;
128
+ error: (input?: string) => string;
129
+ }
130
+
131
+ export interface QuestionOptions extends SharedOptions {
103
132
  defaultValue?: string;
104
- validators?: {
105
- validate: (input: string) => boolean;
106
- error: (input: string) => string;
107
- }[];
133
+ validators?: Validator[];
108
134
  }
109
- ```
110
- ```ts
135
+
111
136
  export interface Choice {
112
137
  value: any;
113
138
  label: string;
114
139
  description?: string;
115
140
  }
116
- ```
117
141
 
118
- ```ts
119
- export interface SelectOptions {
142
+ export interface SelectOptions extends SharedOptions {
120
143
  choices: (Choice | string)[];
121
- maxVisible?: number = 8;
144
+ maxVisible?: number;
122
145
  ignoreValues?: (string | number | boolean)[];
123
146
  }
124
- ```
125
147
 
126
- ```ts
127
- export interface ConfirmOptions {
128
- initial?: boolean = false;
148
+ export interface ConfirmOptions extends SharedOptions {
149
+ initial?: boolean;
129
150
  }
130
151
  ```
131
152
 
@@ -141,6 +162,7 @@ export interface ConfirmOptions {
141
162
  <td align="center" valign="top" width="14.28%"><a href="https://www.linkedin.com/in/thomas-gentilhomme/"><img src="https://avatars.githubusercontent.com/u/4438263?v=4?s=100" width="100px;" alt="Gentilhomme"/><br /><sub><b>Gentilhomme</b></sub></a><br /><a href="https://github.com/TopCli/prompts/pulls?q=is%3Apr+reviewed-by%3Afraxken" title="Reviewed Pull Requests">👀</a></td>
142
163
  <td align="center" valign="top" width="14.28%"><a href="http://tonygo.dev"><img src="https://avatars0.githubusercontent.com/u/22824417?v=4?s=100" width="100px;" alt="Tony Gorez"/><br /><sub><b>Tony Gorez</b></sub></a><br /><a href="https://github.com/TopCli/prompts/pulls?q=is%3Apr+reviewed-by%3Atony-go" title="Reviewed Pull Requests">👀</a></td>
143
164
  <td align="center" valign="top" width="14.28%"><a href="http://sofiand.github.io/portfolio-client/"><img src="https://avatars.githubusercontent.com/u/39944043?v=4?s=100" width="100px;" alt="Yefis"/><br /><sub><b>Yefis</b></sub></a><br /><a href="https://github.com/TopCli/prompts/commits?author=SofianD" title="Code">💻</a> <a href="https://github.com/TopCli/prompts/commits?author=SofianD" title="Documentation">📖</a></td>
165
+ <td align="center" valign="top" width="14.28%"><a href="http://justie.dev"><img src="https://avatars.githubusercontent.com/u/7118300?v=4?s=100" width="100px;" alt="Ben"/><br /><sub><b>Ben</b></sub></a><br /><a href="https://github.com/TopCli/prompts/commits?author=JUSTIVE" title="Documentation">📖</a> <a href="#maintenance-JUSTIVE" title="Maintenance">🚧</a></td>
144
166
  </tr>
145
167
  </tbody>
146
168
  </table>
package/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
- export interface PromptOptions {
2
- defaultValue?: string;
3
- validators?: Validator[];
1
+ export interface SharedOptions {
2
+ stdin?: NodeJS.ReadStream & {
3
+ fd: 0;
4
+ };
5
+ stdout?: NodeJS.WriteStream & {
6
+ fd: 1;
7
+ };
4
8
  }
5
9
 
6
10
  export interface Validator {
@@ -8,24 +12,56 @@ export interface Validator {
8
12
  error: (input?: string) => string;
9
13
  }
10
14
 
15
+ export interface QuestionOptions extends SharedOptions {
16
+ defaultValue?: string;
17
+ validators?: Validator[];
18
+ }
19
+
20
+
11
21
  export interface Choice {
12
22
  value: any;
13
23
  label: string;
14
24
  description?: string;
15
25
  }
16
26
 
17
- export interface SelectOptions {
27
+ export interface SelectOptions extends SharedOptions {
18
28
  choices: (Choice | string)[];
19
29
  maxVisible?: number;
20
30
  ignoreValues?: (string | number | boolean)[];
21
31
  }
22
32
 
23
- export interface ConfirmOptions {
33
+ export interface ConfirmOptions extends SharedOptions {
24
34
  initial?: boolean;
25
35
  }
26
36
 
27
- export function question(message: string, options?: PromptOptions): Promise<string>;
37
+ export function question(message: string, options?: QuestionOptions): Promise<string>;
28
38
  export function select(message: string, options: SelectOptions): Promise<string>;
29
39
  export function confirm(message: string, options?: ConfirmOptions): Promise<boolean>;
30
40
 
31
41
  export function required(): Validator;
42
+
43
+ export class PromptAgent {
44
+ /**
45
+ * The prompts answers queue.
46
+ * When not empty, any prompt will be answered by the first answer in this list.
47
+ */
48
+ nextAnswers: Array<string | boolean>;
49
+
50
+ static get agent(): PromptAgent;
51
+
52
+ /**
53
+ * Programmatically set the next answer for any prompt (`question()`, `confirm()`, `select()`)
54
+ *
55
+ * This is useful for testing.
56
+ *
57
+ * @example
58
+ * ```js
59
+ * const promptAgent = PromptAgent.agent();
60
+ * promptAgent.nextAnswer("toto");
61
+ *
62
+ * const input = await question("what is your name?");
63
+ * assert.equal(input, "toto");
64
+ * ```
65
+ */
66
+ nextAnswer(value: string | boolean | Array<string | boolean>): void
67
+ }
package/index.js CHANGED
@@ -3,6 +3,7 @@ import { ConfirmPrompt } from "./src/confirm-prompt.js";
3
3
  import { SelectPrompt } from "./src/select-prompt.js";
4
4
  import { QuestionPrompt } from "./src/question-prompt.js";
5
5
  import { required } from "./src/validators.js";
6
+ import { PromptAgent } from "./src/prompt-agent.js";
6
7
 
7
8
  export async function question(message, options = {}) {
8
9
  const questionPrompt = new QuestionPrompt(message, options);
@@ -22,4 +23,4 @@ export async function confirm(message, options) {
22
23
  return confirmPrompt.confirm();
23
24
  }
24
25
 
25
- export { required };
26
+ export { required, PromptAgent };
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@topcli/prompts",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Node.js user input library for command-line interfaces.",
5
5
  "scripts": {
6
- "test": "node --loader=esmock --test test/",
6
+ "test": "node --no-warnings=ExperimentalWarning --loader=esmock --test test/",
7
7
  "coverage": "c8 -r html npm run test",
8
8
  "lint": "eslint .",
9
9
  "lint:fix": "eslint . --fix"
@@ -24,14 +24,15 @@
24
24
  "type": "module",
25
25
  "devDependencies": {
26
26
  "@nodesecure/eslint-config": "^1.7.0",
27
- "c8": "^7.13.0",
28
- "eslint": "^8.38.0",
29
- "esmock": "^2.1.0"
27
+ "@types/node": "^20.4.5",
28
+ "c8": "^8.0.0",
29
+ "eslint": "^8.44.0",
30
+ "esmock": "^2.3.1"
30
31
  },
31
32
  "dependencies": {
32
33
  "is-unicode-supported": "^1.3.0",
33
34
  "kleur": "^4.1.5",
34
- "strip-ansi": "^7.0.1"
35
+ "strip-ansi": "^7.1.0"
35
36
  },
36
37
  "engines": {
37
38
  "node": ">=14"
@@ -5,6 +5,9 @@ import { createInterface } from "node:readline";
5
5
  // Import Third-party Dependencies
6
6
  import stripAnsi from "strip-ansi";
7
7
 
8
+ // Import Internal Dependencies
9
+ import { PromptAgent } from "./prompt-agent.js";
10
+
8
11
  export class AbstractPrompt {
9
12
  constructor(message, input = process.stdin, output = process.stdout) {
10
13
  if (this.constructor === AbstractPrompt) {
@@ -19,6 +22,7 @@ export class AbstractPrompt {
19
22
  this.stdout = output;
20
23
  this.message = message;
21
24
  this.history = [];
25
+ this.agent = PromptAgent.agent();
22
26
 
23
27
  if (this.stdout.isTTY) {
24
28
  this.stdin.setRawMode(true);
@@ -8,7 +8,25 @@ import kleur from "kleur";
8
8
  import { AbstractPrompt } from "./abstract-prompt.js";
9
9
  import { SYMBOLS } from "./constants.js";
10
10
 
11
+ // CONSTANTS
12
+ const kToggleKeys = new Set([
13
+ "left",
14
+ "right",
15
+ "tab",
16
+ "q",
17
+ "a",
18
+ "d",
19
+ "h",
20
+ "j",
21
+ "k",
22
+ "l",
23
+ "space"
24
+ ]);
25
+
11
26
  export class ConfirmPrompt extends AbstractPrompt {
27
+ #boundKeyPressEvent;
28
+ #boundExitEvent;
29
+
12
30
  constructor(message, options = {}) {
13
31
  const {
14
32
  stdin = process.stdin,
@@ -17,55 +35,92 @@ export class ConfirmPrompt extends AbstractPrompt {
17
35
  } = options;
18
36
  super(message, stdin, stdout);
19
37
 
20
- const Yes = kleur.bold("Yes");
21
- const No = kleur.bold("No");
22
- this.tip = kleur.gray(initial ? `(${Yes}/no)` : `(yes/${No})`);
23
-
24
38
  this.initial = initial;
39
+ this.selectedValue = initial;
40
+ }
41
+
42
+ #getHint() {
43
+ const Yes = kleur.bold().underline().cyan("Yes");
44
+ const No = kleur.bold().underline().cyan("No");
45
+
46
+ return this.selectedValue ? `${Yes}/No` : `Yes/${No}`;
47
+ }
48
+
49
+ #render() {
50
+ this.write(this.#getQuestionQuery());
25
51
  }
26
52
 
27
53
  #question() {
28
54
  return new Promise((resolve) => {
29
55
  const questionQuery = this.#getQuestionQuery();
30
56
 
31
- this.rl.question(questionQuery,
32
- (answer) => {
33
- this.history.push(questionQuery + answer);
57
+ this.write(questionQuery);
58
+
59
+ this.#boundKeyPressEvent = this.#onKeypress.bind(this, resolve);
60
+ this.stdin.on("keypress", this.#boundKeyPressEvent);
34
61
 
35
- resolve(answer);
36
- }
37
- );
62
+ this.#boundExitEvent = this.#onProcessExit.bind(this);
63
+ process.once("exit", this.#boundExitEvent);
38
64
  });
39
65
  }
40
66
 
67
+ #onKeypress(resolve, value, key) {
68
+ this.stdin.pause();
69
+ this.stdout.moveCursor(-this.#getQuestionQuery().length, 0);
70
+ this.stdout.clearScreenDown(() => this.stdin.resume());
71
+
72
+ if (key.name === "return") {
73
+ resolve(this.selectedValue);
74
+
75
+ return;
76
+ }
77
+
78
+ if (kToggleKeys.has(key.name)) {
79
+ this.selectedValue = !this.selectedValue;
80
+ }
81
+
82
+ this.#render();
83
+ }
84
+
85
+ #onProcessExit() {
86
+ this.stdin.off("keypress", this.#boundKeyPressEvent);
87
+ }
88
+
41
89
  #getQuestionQuery() {
42
90
  const query = kleur.bold(`${SYMBOLS.QuestionMark} ${this.message}`);
43
91
 
44
- return `${query} ${this.tip} `;
92
+ return `${query} ${this.#getHint()}`;
45
93
  }
46
94
 
47
95
  #onQuestionAnswer() {
48
96
  this.clearLastLine();
49
- this.write(`${this.answer ? SYMBOLS.Tick : SYMBOLS.Cross} ${kleur.bold(this.message)}${EOL}`);
97
+ this.write(`${this.selectedValue ? SYMBOLS.Tick : SYMBOLS.Cross} ${kleur.bold(this.message)}${EOL}`);
50
98
  }
51
99
 
52
- #validateResult(result) {
53
- if (typeof result !== "string") {
54
- return false;
100
+ async confirm() {
101
+ if (this.agent.nextAnswers.length > 0) {
102
+ const answer = this.agent.nextAnswers.shift();
103
+ this.selectedValue = answer;
104
+ this.#onQuestionAnswer();
105
+ this.destroy();
106
+
107
+ return answer;
55
108
  }
56
109
 
57
- return ["y", "yes", "no", "n"].includes(result.toLowerCase());
58
- }
110
+ this.write(SYMBOLS.HideCursor);
59
111
 
60
- async confirm() {
61
112
  try {
62
- const result = await this.#question();
63
- this.answer = this.#validateResult(result) ? ["y", "yes"].includes(result.toLocaleLowerCase()) : this.initial;
113
+ await this.#question();
64
114
  this.#onQuestionAnswer();
65
115
 
66
- return this.answer;
116
+ return this.selectedValue;
67
117
  }
68
118
  finally {
119
+ this.write(SYMBOLS.ShowCursor);
120
+
121
+ this.#onProcessExit();
122
+ process.off("exit", this.#boundExitEvent);
123
+
69
124
  this.destroy();
70
125
  }
71
126
  }
@@ -0,0 +1,56 @@
1
+ // CONSTANTS
2
+ const kPrivateInstancier = Symbol("instancier");
3
+
4
+ export class PromptAgent {
5
+ /**
6
+ * The prompts answers queue.
7
+ * When not empty, any prompt will be answered by the first answer in this list.
8
+ *
9
+ * @type {string[]}
10
+ */
11
+ nextAnswers = [];
12
+
13
+ /**
14
+ * The shared PromptAgent.
15
+ *
16
+ * @type {PromptAgent}
17
+ */
18
+ static #this;
19
+
20
+ static agent() {
21
+ // eslint-disable-next-line no-return-assign
22
+ return this.#this ??= new PromptAgent(kPrivateInstancier);
23
+ }
24
+
25
+ constructor(instancier) {
26
+ if (instancier !== kPrivateInstancier) {
27
+ throw new Error("Cannot instanciate PromptAgent, use PromptAgent.agent() instead");
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Programmatically set the next answer for any prompt (`question()`, `confirm()`, `select()`)
33
+ *
34
+ * This is useful for testing.
35
+ *
36
+ * @param {string | boolean | Array<string | boolean>} value
37
+ *
38
+ * @example
39
+ * ```js
40
+ * const promptAgent = PromptAgent.agent();
41
+ * promptAgent.nextAnswer("toto");
42
+ *
43
+ * const input = await question("what is your name?");
44
+ * assert.equal(input, "toto");
45
+ * ```
46
+ */
47
+ nextAnswer(value) {
48
+ if (Array.isArray(value)) {
49
+ this.nextAnswers.push(...value);
50
+
51
+ return;
52
+ }
53
+
54
+ this.nextAnswers.push(value);
55
+ }
56
+ }
@@ -76,6 +76,14 @@ export class QuestionPrompt extends AbstractPrompt {
76
76
  }
77
77
 
78
78
  async question() {
79
+ if (this.agent.nextAnswers.length > 0) {
80
+ this.answer = this.agent.nextAnswers.shift();
81
+ this.#writeAnswer();
82
+ this.destroy();
83
+
84
+ return this.answer;
85
+ }
86
+
79
87
  this.answer = await this.#question();
80
88
 
81
89
  if (this.answer === "" && this.defaultValue) {
@@ -114,6 +114,14 @@ export class SelectPrompt extends AbstractPrompt {
114
114
  }
115
115
 
116
116
  async select() {
117
+ if (this.agent.nextAnswers.length > 0) {
118
+ const answer = this.agent.nextAnswers.shift();
119
+ this.#showAnsweredQuestion(answer);
120
+ this.destroy();
121
+
122
+ return answer;
123
+ }
124
+
117
125
  this.write(SYMBOLS.HideCursor);
118
126
  this.#showQuestion();
119
127