@portosaur/wizard 0.1.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 ADDED
@@ -0,0 +1,66 @@
1
+ # @portosaur/wizard
2
+
3
+ A general-purpose interactive CLI prompt library designed for managing complex, multi-step workflows. Built on top of `@clack/prompts`. It adds many additional features like back-navigation, history persistence, and dynamic branching.
4
+
5
+ ## 🧩 Features
6
+
7
+ - **🔙 Back-Navigation** — Return to previous steps using the **Escape** key without terminal artifacts.
8
+ - **🔄 History Replay** — Maintains session context by redrawing previous responses at the top of the terminal.
9
+ - **🛡️ Signal Handling** — Robust handling for `Ctrl+C` with clean terminal termination.
10
+ - **🔀 Dynamic Branching** — Skip or modify steps based on previous answers.
11
+
12
+ ## 🚀 Installation
13
+
14
+ ```bash
15
+ npm install @portosaur/wizard
16
+ ```
17
+
18
+ ## 📖 Usage
19
+
20
+ ```javascript
21
+ import { runWizard } from "@portosaur/wizard";
22
+
23
+ const state = await runWizard({
24
+ intro: "Project Setup",
25
+ steps: [
26
+ {
27
+ id: "name",
28
+ type: "text",
29
+ label: "Project Name",
30
+ message: "Enter your project name",
31
+ required: true,
32
+ },
33
+ {
34
+ id: "vcs",
35
+ type: "select",
36
+ label: "VCS",
37
+ message: "Choose a provider",
38
+ options: [
39
+ { value: "github", label: "GitHub" },
40
+ { value: "gitlab", label: "GitLab" },
41
+ ],
42
+ },
43
+ {
44
+ id: "confirm",
45
+ type: "confirm",
46
+ label: "Confirm",
47
+ message: "Are you sure?",
48
+ backOn: false, // Return to previous step if "No" is selected
49
+ },
50
+ ],
51
+ });
52
+
53
+ console.log(state);
54
+ ```
55
+
56
+ - Output:
57
+
58
+ ```json
59
+ { "name": "sosf", "vcs": "github", "confirm": true }
60
+ ```
61
+
62
+ ## 📖 Documentation
63
+
64
+ For the full API reference, advanced examples, and detailed guides on dynamic branching, please visit our official documentation site:
65
+
66
+ 👉 **[Wizard Documentation](https://soymadip.github.io/portosaur/dev/wizard)**
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@portosaur/wizard",
3
+ "version": "0.1.0",
4
+ "description": "Wizard: Interactive CLI prompt library with back-navigation.",
5
+ "license": "GPL-3.0-only",
6
+ "author": "soymadip",
7
+ "homepage": "https://soymadip.github.io/portosaur",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/soymadip/portosaur"
11
+ },
12
+ "keywords": [
13
+ "cli",
14
+ "prompt",
15
+ "wizard",
16
+ "clack",
17
+ "workflow",
18
+ "interactive"
19
+ ],
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "type": "module",
24
+ "types": "./src/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./src/index.d.ts",
28
+ "import": "./src/index.mjs",
29
+ "default": "./src/index.mjs"
30
+ }
31
+ },
32
+ "dependencies": {
33
+ "@clack/prompts": "^1.2.0",
34
+ "chalk": "^5.6.2"
35
+ }
36
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ export * from "@clack/prompts";
2
+
3
+ export interface WizardStep {
4
+ /** Unique identifier for the step value in the state object. */
5
+ id: string;
6
+
7
+ /** Interaction pattern for the prompt. */
8
+ type:
9
+ | "text"
10
+ | "password"
11
+ | "number"
12
+ | "select"
13
+ | "multiselect"
14
+ | "confirm"
15
+ | "pause";
16
+
17
+ /** Short label used in the history display (e.g., "vcs › github"). */
18
+ label: string;
19
+
20
+ /** Main question shown to the user. Can be a string or a function of current state. */
21
+ prompt: string | ((state: any) => string);
22
+
23
+ /** Secondary context shown in parentheses. */
24
+ hint?: string | ((state: any) => string);
25
+
26
+ /** Grayed-out placeholder text inside the input. */
27
+ placeholder?: string;
28
+
29
+ /** Initial value for the prompt or the default if skipped. */
30
+ initialValue?: any | ((state: any) => any);
31
+
32
+ /** Custom validation logic. Return a string to show an error. */
33
+ validate?: (value: any) => string | undefined | void;
34
+
35
+ /** Mutates the answer before it hits the state. */
36
+ transform?: (value: any, state: any) => any;
37
+
38
+ /** If false, the step is skipped and reactive defaults are applied. */
39
+ runIf?: (state: any) => boolean;
40
+
41
+ /** Required for select/multiselect types. */
42
+ options?: any[] | ((state: any) => any[]);
43
+
44
+ /** If true, validation fails on empty input. */
45
+ required?: boolean;
46
+
47
+ /** Minimum value for "number" type. */
48
+ min?: number;
49
+
50
+ /** Maximum value for "number" type. */
51
+ max?: number;
52
+
53
+ /** Visual style for the message (e.g., "warn" for yellow). */
54
+ level?: "info" | "warn" | "error" | "success";
55
+
56
+ /** Custom formatter for the history view. */
57
+ display?: (value: any, state: any) => string;
58
+
59
+ /** Programmatic back-navigation based on the answer. */
60
+ backOn?: any | ((value: any, state: any) => boolean);
61
+
62
+ /** Post-submission hook for side-effects or manual navigation. */
63
+ onResponse?: (
64
+ value: any,
65
+ state: any,
66
+ tools: { back: () => "back" },
67
+ ) => void | "back" | Promise<void | "back">;
68
+ }
69
+
70
+ export interface WizardOptions {
71
+ /** Heading displayed at the very start. */
72
+ intro?: string;
73
+
74
+ /** Closing behavior. string = message, false = skip, undefined = bare line. */
75
+ outro?: string | false;
76
+
77
+ /** Sequential list of interactive steps. */
78
+ steps: WizardStep[];
79
+
80
+ /** Optional starting values. */
81
+ initialState?: Record<string, any>;
82
+ }
83
+
84
+ /**
85
+ * Runs an interactive, multi-step CLI wizard with back-navigation and state persistence.
86
+ */
87
+ export function runWizard(options: WizardOptions): Promise<Record<string, any>>;
package/src/index.mjs ADDED
@@ -0,0 +1,180 @@
1
+ import * as prmpt from "@clack/prompts";
2
+ import chalk from "chalk";
3
+ import { renderHistory } from "./renderer.mjs";
4
+ import { renderStep, coerceAnswer, buildPromptOpts } from "./prompts.mjs";
5
+ import readline from "readline";
6
+
7
+ export * from "@clack/prompts";
8
+ import * as prmpt_original from "@clack/prompts";
9
+
10
+ /**
11
+ * Custom cancel that adds a connector bar for the tree-style logs
12
+ */
13
+ export const cancel = (msg) => {
14
+ process.stdout.write(`${chalk.gray("│")}\n`);
15
+ prmpt_original.cancel(msg);
16
+ };
17
+
18
+ /**
19
+ * Runs an interactive CLI wizard.
20
+ * Handles navigation (forward/back), state management, and alternate screen buffering.
21
+ * @param {Object} options - Wizard configuration.
22
+ * @param {string} [options.intro] - Text to show at the start.
23
+ * @param {string|false} [options.outro] - Text to show at the end.
24
+ * @param {Array<Object>} options.steps - List of step definitions.
25
+ * @param {Object} [options.initialState={}] - Initial state for the wizard.
26
+ * @returns {Promise<Object>} The final state object.
27
+ */
28
+ export async function runWizard({ intro, outro, steps, initialState = {} }) {
29
+ const ENTER_ALT_SCREEN = "\x1b[?1049h";
30
+ const EXIT_ALT_SCREEN = "\x1b[?1049l";
31
+ const CLEAR_SCREEN = "\x1b[2J\x1b[H";
32
+
33
+ let currentStep = 0;
34
+ const state = { ...initialState };
35
+ const maxSteps = steps.length;
36
+ let isAltScreenActive = false;
37
+
38
+ const handleInterrupt = (_char, key) => {
39
+ if (key?.ctrl && key.name === "c") {
40
+ if (isAltScreenActive) {
41
+ process.stdout.write(EXIT_ALT_SCREEN);
42
+ }
43
+ process.stdout.write("\x1B[2A\x1B[0J");
44
+ process.stdout.write(`\n${chalk.gray("│")}\n`);
45
+ prmpt.cancel("Setup aborted.");
46
+ process.exit(130);
47
+ }
48
+ };
49
+
50
+ if (process.stdin.isTTY) {
51
+ readline.emitKeypressEvents(process.stdin);
52
+ process.stdin.on("keypress", handleInterrupt);
53
+
54
+ if (process.stdout.isTTY) {
55
+ process.stdout.write(ENTER_ALT_SCREEN + CLEAR_SCREEN);
56
+ isAltScreenActive = true;
57
+ }
58
+ }
59
+
60
+ try {
61
+ const goBack = () => {
62
+ currentStep--;
63
+
64
+ while (
65
+ currentStep > 0 &&
66
+ steps[currentStep].runIf &&
67
+ !steps[currentStep].runIf(state)
68
+ ) {
69
+ currentStep--;
70
+ }
71
+ renderHistory(steps, currentStep, state, intro);
72
+ };
73
+
74
+ renderHistory(steps, currentStep, state, intro);
75
+
76
+ while (currentStep >= 0 && currentStep < maxSteps) {
77
+ const step = steps[currentStep];
78
+
79
+ if (step.runIf && !step.runIf(state)) {
80
+ if (state[step.id] == null && step.initialValue !== undefined) {
81
+ const { initialValue } = buildPromptOpts(step, state);
82
+ state[step.id] = initialValue;
83
+ }
84
+ currentStep++;
85
+ continue;
86
+ }
87
+
88
+ // Note: `onEnter` hooks were removed to avoid automatic side-effects
89
+ // (e.g. reopening a browser) when a user navigates back to a step.
90
+
91
+ // Race the prompt with an Escape watcher: some prompts don't always
92
+ // surface a cancel sentinel, so we listen for raw Escape and treat it
93
+ // as a back/cancel. The watcher is only active for TTY sessions.
94
+ let answer;
95
+ if (process.stdin.isTTY) {
96
+ let removeEscListener;
97
+ const escPromise = new Promise((resolve) => {
98
+ const escHandler = (_char, key) => {
99
+ if (
100
+ key?.name === "escape" ||
101
+ key?.name === "esc" ||
102
+ key?.sequence === "\u001b"
103
+ ) {
104
+ resolve({ __escape: true });
105
+ }
106
+ };
107
+ process.stdin.on("keypress", escHandler);
108
+ removeEscListener = () =>
109
+ process.stdin.removeListener("keypress", escHandler);
110
+ });
111
+
112
+ const renderPromise = renderStep(step, state);
113
+ answer = await Promise.race([renderPromise, escPromise]);
114
+ if (removeEscListener) removeEscListener();
115
+ } else {
116
+ answer = await renderStep(step, state);
117
+ }
118
+
119
+ // Special case: pause step can return its own escape marker; handle
120
+ // both that and the generic escape marker here.
121
+ if (answer && (answer.__pauseEscape || answer.__escape)) {
122
+ goBack();
123
+ continue;
124
+ }
125
+
126
+ if (prmpt.isCancel(answer)) {
127
+ if (currentStep === 0) {
128
+ process.stdout.write("\x1B[2A\x1B[0J");
129
+ process.stdout.write(`\n${chalk.gray("│")}\n`);
130
+ prmpt.cancel("Setup aborted.");
131
+
132
+ if (isAltScreenActive) {
133
+ process.stdout.write(EXIT_ALT_SCREEN);
134
+ }
135
+ process.exit(0);
136
+ }
137
+ goBack();
138
+ continue;
139
+ }
140
+
141
+ const shouldGoBack =
142
+ typeof step.backOn === "function"
143
+ ? step.backOn(answer, state)
144
+ : step.backOn !== undefined && answer === step.backOn;
145
+
146
+ if (shouldGoBack) {
147
+ goBack();
148
+ continue;
149
+ }
150
+
151
+ if (step.onResponse) {
152
+ const action = step.onResponse(answer, state, { back: () => "back" });
153
+ if (action === "back") {
154
+ goBack();
155
+ continue;
156
+ }
157
+ }
158
+
159
+ state[step.id] = coerceAnswer(step, answer, state);
160
+ currentStep++;
161
+ renderHistory(steps, currentStep, state, intro);
162
+ }
163
+ } finally {
164
+ if (isAltScreenActive) {
165
+ process.stdout.write(EXIT_ALT_SCREEN);
166
+ }
167
+
168
+ if (process.stdin.isTTY) {
169
+ process.stdin.removeListener("keypress", handleInterrupt);
170
+ }
171
+ }
172
+
173
+ if (outro !== false) {
174
+ typeof outro === "string"
175
+ ? prmpt.outro(outro)
176
+ : console.log(chalk.gray("└"));
177
+ }
178
+
179
+ return state;
180
+ }
@@ -0,0 +1,210 @@
1
+ import * as prmpt from "@clack/prompts";
2
+ import chalk from "chalk";
3
+ import readline from "readline";
4
+
5
+ const levelStyles = {
6
+ info: chalk.blueBright,
7
+ warn: chalk.yellowBright,
8
+ error: chalk.redBright,
9
+ success: chalk.greenBright,
10
+ };
11
+
12
+ /**
13
+ * Builds the options for a Clack prompt based on a wizard step definition.
14
+ * @param {Object} step - The step definition.
15
+ * @param {Object} state - The current wizard state.
16
+ * @returns {Object} An object containing the initial value and Clack options.
17
+ */
18
+ export function buildPromptOpts(step, state) {
19
+ const promptText =
20
+ typeof step.prompt === "function" ? step.prompt(state) : step.prompt;
21
+ const hint = typeof step.hint === "function" ? step.hint(state) : step.hint;
22
+
23
+ const levelStyle = levelStyles[step.level] || ((str) => str);
24
+ const styledMessage = chalk.bold(levelStyle(promptText));
25
+
26
+ const initialValue =
27
+ typeof step.initialValue === "function"
28
+ ? step.initialValue(state)
29
+ : (state[step.id] ?? step.initialValue);
30
+
31
+ return {
32
+ initialValue,
33
+ opts: {
34
+ message: `${styledMessage}${hint ? ` ${chalk.gray(`(${hint})`)}` : ""}`,
35
+ placeholder: step.placeholder ? chalk.gray(step.placeholder) : undefined,
36
+ initialValue,
37
+ validate: (val) => {
38
+ if (step.validate) {
39
+ return step.validate(val);
40
+ }
41
+ if (step.required && (!val || !val.trim())) {
42
+ return `${step.label || step.id} is required!`;
43
+ }
44
+ },
45
+ },
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Renders a single wizard step.
51
+ * @param {Object} step - The step definition to render.
52
+ * @param {Object} state - The current wizard state.
53
+ * @returns {Promise<any>} The answer from the user.
54
+ */
55
+ export async function renderStep(step, state) {
56
+ const { initialValue, opts } = buildPromptOpts(step, state);
57
+
58
+ switch (step.type) {
59
+ case "text":
60
+ return await prmpt.text(opts);
61
+
62
+ case "password":
63
+ return await prmpt.password(opts);
64
+
65
+ case "select":
66
+ return await prmpt.select({
67
+ ...opts,
68
+ options:
69
+ typeof step.options === "function"
70
+ ? step.options(state)
71
+ : step.options,
72
+ });
73
+
74
+ case "multiselect":
75
+ return await prmpt.multiselect({
76
+ ...opts,
77
+ options:
78
+ typeof step.options === "function"
79
+ ? step.options(state)
80
+ : step.options,
81
+ initialValues: initialValue || [],
82
+ });
83
+
84
+ case "confirm":
85
+ return await prmpt.confirm(opts);
86
+
87
+ case "number": {
88
+ const raw = await prmpt.text({
89
+ ...opts,
90
+ validate: (val) => {
91
+ if (step.validate) {
92
+ return step.validate(val);
93
+ }
94
+ if (step.required && !val?.trim()) {
95
+ return `${step.label || step.id} is required!`;
96
+ }
97
+ if (val && isNaN(Number(val))) {
98
+ return "Please enter a valid number.";
99
+ }
100
+ if (step.min !== undefined && Number(val) < step.min) {
101
+ return `Must be at least ${step.min}.`;
102
+ }
103
+ if (step.max !== undefined && Number(val) > step.max) {
104
+ return `Must be at most ${step.max}.`;
105
+ }
106
+ },
107
+ });
108
+ return raw;
109
+ }
110
+
111
+ case "pause": {
112
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
113
+ let frameIdx = 0;
114
+
115
+ if (!process.stdin.isTTY) {
116
+ process.stdout.write(
117
+ `${chalk.gray("│")}\n${chalk.gray("◆")} ${opts.message}\n`,
118
+ );
119
+ return true;
120
+ }
121
+
122
+ process.stdout.write(
123
+ `${chalk.gray("│")}\n${chalk.gray("◆")} ${opts.message}\n`,
124
+ );
125
+
126
+ const animate = setInterval(() => {
127
+ process.stdout.write(
128
+ `\r${chalk.gray("│")} ${chalk.cyan(frames[frameIdx++ % frames.length])} ${chalk.dim("press Enter to continue...")}`,
129
+ );
130
+ }, 80);
131
+
132
+ await new Promise((resolve) => {
133
+ let resolved = false;
134
+ const rl = readline.createInterface({
135
+ input: process.stdin,
136
+ output: process.stdout,
137
+ });
138
+
139
+ let onKey;
140
+ const cleanup = () => {
141
+ clearInterval(animate);
142
+ try {
143
+ rl.close();
144
+ } catch (e) {}
145
+ if (onKey) {
146
+ process.stdin.removeListener("keypress", onKey);
147
+ }
148
+ };
149
+
150
+ const onDone = () => {
151
+ if (resolved) {
152
+ return;
153
+ }
154
+ resolved = true;
155
+ cleanup();
156
+ process.stdout.write(
157
+ `\r${chalk.gray("│")} ${chalk.green("✓")} ${chalk.dim("done")}\n`,
158
+ );
159
+ resolve();
160
+ };
161
+
162
+ onKey = (_char, key) => {
163
+ if (key?.name === "return" || key?.name === "enter") {
164
+ onDone();
165
+ }
166
+
167
+ if (
168
+ key?.name === "escape" ||
169
+ key?.name === "esc" ||
170
+ key?.sequence === "\u001b"
171
+ ) {
172
+ if (resolved) {
173
+ return;
174
+ }
175
+ resolved = true;
176
+ cleanup();
177
+ resolve({ __pauseEscape: true });
178
+ }
179
+ };
180
+
181
+ rl.once("line", onDone);
182
+ process.stdin.on("keypress", onKey);
183
+ });
184
+
185
+ return true;
186
+ }
187
+
188
+ default:
189
+ throw new Error(`Unsupported wizard step type: ${step.type}`);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Coerces and transforms a user's answer based on the step configuration.
195
+ * @param {Object} step - The step definition.
196
+ * @param {any} answer - The raw answer from the prompt.
197
+ * @param {Object} state - The current wizard state.
198
+ * @returns {any} The transformed value.
199
+ */
200
+ export function coerceAnswer(step, answer, state) {
201
+ let value =
202
+ step.type === "number" && typeof answer === "string"
203
+ ? answer === ""
204
+ ? answer
205
+ : Number(answer)
206
+ : answer;
207
+
208
+ if (step.transform) value = step.transform(value, state);
209
+ return value;
210
+ }
@@ -0,0 +1,37 @@
1
+ import * as prmpt from "@clack/prompts";
2
+ import chalk from "chalk";
3
+
4
+ export function renderHistory(steps, currentStep, state, intro) {
5
+ if (process.env.NODE_ENV !== "test") {
6
+ console.log("\n\u200B\n\n\n\n\n\u200B\n\u00A0");
7
+ process.stdout.write("\x1B[2J\x1B[H");
8
+ }
9
+
10
+ console.log("");
11
+
12
+ if (intro) {
13
+ prmpt.intro(chalk.bgCyan.black(` ${intro} `));
14
+ console.log(chalk.gray("│"));
15
+ console.log(chalk.gray("│ [Enter] submit • [Esc] back • [Ctrl+C] exit "));
16
+ }
17
+
18
+ for (let i = 0; i < currentStep; i++) {
19
+ const step = steps[i];
20
+ if (step.runIf && !step.runIf(state)) continue;
21
+ if (step.type === "pause" || step.type === "password") continue;
22
+
23
+ if (i === 0) console.log(chalk.gray("│"));
24
+
25
+ const value = state[step.id];
26
+
27
+ if (value !== undefined) {
28
+ const displayValue = step.display ? step.display(value, state) : value;
29
+
30
+ if (displayValue !== null && displayValue !== "") {
31
+ console.log(
32
+ `${chalk.gray("│")} ${chalk.dim(step.label || step.id)} ${chalk.gray("›")} ${chalk.cyan(displayValue)}`,
33
+ );
34
+ }
35
+ }
36
+ }
37
+ }