pathenger 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 +567 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
# pathenger
|
|
2
|
+
|
|
3
|
+
Step-by-step flows for command-line apps. Explicit, branching, testable.
|
|
4
|
+
|
|
5
|
+
Most prompt libraries hand you widgets and wish you luck with the orchestration. pathenger inverts that: you declare your CLI as a graph of steps, each one of which shows a message, does some work, or asks a question, and the flow engine walks the graph until you tell it to exit. Branches, loops, validation, and async work are all first-class. Your CLI _is_ the flow.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { pathenger } from 'pathenger'
|
|
9
|
+
|
|
10
|
+
const io = pathenger.create()
|
|
11
|
+
|
|
12
|
+
io.step.output({
|
|
13
|
+
id: 'welcome',
|
|
14
|
+
message: 'Welcome aboard.',
|
|
15
|
+
duration: 1000,
|
|
16
|
+
next: 'name'
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
io.step.input({
|
|
20
|
+
id: 'name',
|
|
21
|
+
type: 'text',
|
|
22
|
+
message: 'What is your name?',
|
|
23
|
+
validate: (value) => value.length > 1 || 'A little longer, please.',
|
|
24
|
+
next: 'goodbye'
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
io.step.output({
|
|
28
|
+
id: 'goodbye',
|
|
29
|
+
message: () => `Nice to meet you, ${io.results.name}.`,
|
|
30
|
+
level: 'success',
|
|
31
|
+
next: io.exit()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
io.start('welcome')
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That's a complete program. No `main()`, no prompt-then-if-then-prompt spaghetti. Register steps, point them at each other, start the flow.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pnpm add pathenger
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The mental model
|
|
46
|
+
|
|
47
|
+
Three ideas, and you know the whole library:
|
|
48
|
+
|
|
49
|
+
1. **A step either _does_ something or _asks_ something.** Output steps show a message and optionally run a task. Input steps show a message and collect an answer. A step never does both. If you need both then that's two steps. Step by step by step. Explicit progression.
|
|
50
|
+
|
|
51
|
+
2. **Every step declares `next`.** There is no implicit "then the one below it." `next` is a step id, or a function that returns one, which is how you branch. Because `next` is explicit, it can point _backwards_ and cycles are _legal_, which is how you build menus and REPL-style apps that run until the user quits.
|
|
52
|
+
|
|
53
|
+
3. **`io` is your app's shared state.** `io.results` holds every step's result, keyed by step id (the engine writes it). `io.store` is yours: a typed grab-bag for anything that spans steps or lives in extracted functions.
|
|
54
|
+
|
|
55
|
+
## Creating your instance
|
|
56
|
+
|
|
57
|
+
Create one `io` per app and export it from a module. Every step file and every extracted helper imports it from there. No threading a context argument five levels deep.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// source/io.ts
|
|
61
|
+
import { pathenger } from 'pathenger'
|
|
62
|
+
|
|
63
|
+
type StoreT {
|
|
64
|
+
isPnpmInstalled?: boolean
|
|
65
|
+
templates?: Template[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const io = pathenger.create<StoreT>()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The generic types `io.store` so every access is checked and autocompleted across your whole codebase. You can also declare your results shape with a second generic — see [TypeScript](#typescript).
|
|
72
|
+
|
|
73
|
+
Your entry point imports the step files (importing registers them) and starts the flow:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// source/index.ts
|
|
77
|
+
import { io } from './io'
|
|
78
|
+
import './steps/welcome'
|
|
79
|
+
import './steps/mainMenu'
|
|
80
|
+
import './steps/createProduct'
|
|
81
|
+
|
|
82
|
+
io.start('welcome')
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Output steps
|
|
86
|
+
|
|
87
|
+
An output step shows a message, optionally runs async work while the message is on screen, then moves on.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
io.step.output({
|
|
91
|
+
id: 'checkPnpm',
|
|
92
|
+
message: 'Checking for pnpm.',
|
|
93
|
+
duration: 1000,
|
|
94
|
+
task: async () => await hasGlobalPnpm(),
|
|
95
|
+
next: (result) => (result ? 'cloneTemplate' : 'noPnpmWarning')
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Lifecycle: **pre → message → task → post → next**
|
|
100
|
+
|
|
101
|
+
| Property | Type | Required | What it does |
|
|
102
|
+
| ---------- | -------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| `id` | `string` | yes | Unique step id. Registering a duplicate throws immediately. |
|
|
104
|
+
| `message` | `string \| () => string` | yes | The text shown for this step. Functions run synchronously at render time. |
|
|
105
|
+
| `level` | `'info' \| 'success' \| 'warning' \| 'error' \| 'debug'` | no | Styling/color of the message. Defaults to `'info'`. |
|
|
106
|
+
| `duration` | `number` | no | Minimum milliseconds the step stays on screen (see below). |
|
|
107
|
+
| `pre` | `() => void \| Promise<void>` | no | Async setup, runs silently _before_ the message renders. |
|
|
108
|
+
| `task` | `() => unknown \| Promise<unknown>` | no | Async work, runs _while_ the message is on screen, with a spinner. Its return value becomes the step's result. |
|
|
109
|
+
| `post` | `(result) => void \| Promise<void>` | no | Side effects after the result is locked in. Cannot block progression. |
|
|
110
|
+
| `next` | `string \| (result) => string \| ExitToken` | yes | Where to go. Receives the step's result. |
|
|
111
|
+
|
|
112
|
+
### `duration`
|
|
113
|
+
|
|
114
|
+
`duration` is the **minimum time the step stays on screen**. That single rule covers everything:
|
|
115
|
+
|
|
116
|
+
- Message-only step with `duration: 1000` → the message shows for one second, then the flow advances. Use this to pace output so it reads as deliberate rather than dumped.
|
|
117
|
+
- Step with a `task` → the spinner runs for _at least_ `duration`, and longer if the task is still pending. Fast tasks don't flash; slow tasks aren't cut off.
|
|
118
|
+
|
|
119
|
+
### `pre` vs `task`
|
|
120
|
+
|
|
121
|
+
`pre` runs **before** the message renders. It's setup: fetch the data the step needs to display itself. `task` runs **while** the message is on screen. It's the work the message narrates, i.e "Cloning product template…" with a spinner, where the message _is the UX for the work_. If you fold your work into `pre`, the user stares at the previous step while it happens and your message flashes by after the fact. Put display prerequisites in `pre`; put the actual work in `task`.
|
|
122
|
+
|
|
123
|
+
### The step's result
|
|
124
|
+
|
|
125
|
+
An output step's result is its task's return value (`undefined` if there's no task). It lands in `io.results[id]` and is passed to `next` and `post`. Tasks that need to record more than one value write to `io.store`. (That is what it is for.)
|
|
126
|
+
|
|
127
|
+
## Input steps
|
|
128
|
+
|
|
129
|
+
An input step shows a message and collects an answer.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
io.step.input({
|
|
133
|
+
id: 'productId',
|
|
134
|
+
type: 'text',
|
|
135
|
+
message: 'What will your product ID be?',
|
|
136
|
+
tip: 'alpha-numeric with dashes and underscores',
|
|
137
|
+
placeholder: 'my-new-product',
|
|
138
|
+
filter: /^[a-zA-Z0-9_-]*$/,
|
|
139
|
+
validate: async (value) => {
|
|
140
|
+
const existingIds = await fetcher('/api/getProductIds')
|
|
141
|
+
return existingIds.includes(value) ? `"${value}" is already taken.` : true
|
|
142
|
+
},
|
|
143
|
+
next: 'productName'
|
|
144
|
+
})
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Lifecycle: **pre → message → input (filter per keystroke) → validate (on submit) → post → next**
|
|
148
|
+
|
|
149
|
+
| Property | Type | Required | What it does |
|
|
150
|
+
| ---------- | ------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
151
|
+
| `id` | `string` | yes | Unique step id. |
|
|
152
|
+
| `type` | `'text' \| 'select' \| 'multiselect' \| 'confirm'` | yes | Which prompt to render. |
|
|
153
|
+
| `message` | `string \| () => string` | yes | The question. Functions run synchronously at render time. |
|
|
154
|
+
| `tip` | `string \| () => string` | no | Persistent hint rendered dim beneath the input. Turns red when validation fails. |
|
|
155
|
+
| `pre` | `() => void \| Promise<void>` | no | Async setup before the message renders. |
|
|
156
|
+
| `validate` | `(value) => true \| string \| Promise<true \| string>` | no | Runs on ENTER. Return a string to block progression and show it as the reason, in red. May be async — a spinner shows while it runs. |
|
|
157
|
+
| `post` | `(result) => void \| Promise<void>` | no | Side effects after the answer is accepted. Cannot block progression — that's `validate`'s job. |
|
|
158
|
+
| `next` | `string \| (result) => string \| ExitToken` | yes | Where to go. Receives the answer. |
|
|
159
|
+
|
|
160
|
+
An input step's result is the submitted answer. It lands in `io.results[id]` and is passed to `validate`, `post`, and `next`.
|
|
161
|
+
|
|
162
|
+
### `type: 'text'`
|
|
163
|
+
|
|
164
|
+
| Property | Type | What it does |
|
|
165
|
+
| ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
166
|
+
| `value` | `string \| () => string` | Initial _editable_ value, prefilled in the input. |
|
|
167
|
+
| `placeholder` | `string \| () => string` | Ghost text shown when the input is empty. Never submitted. |
|
|
168
|
+
| `filter` | `RegExp \| (value: string) => boolean` | Runs on **every keystroke**, synchronously. The keystroke is rejected if the candidate value fails. Regexes use **full-match semantics** — `/^[a-z]*$/` and `/[a-z]*/` behave identically; the whole value must match. |
|
|
169
|
+
|
|
170
|
+
`filter` and `validate` split validation the way it actually needs to be split: `filter` is cheap and sync, so it can run per keystroke and physically keep bad characters out. `validate` runs once on submit, so it's allowed to be async and expensive — hit an API, read the filesystem, take its time behind a spinner. Don't put a network call in a keystroke handler; pathenger won't let you.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// Deriving the initial value from an earlier answer
|
|
174
|
+
io.step.input({
|
|
175
|
+
id: 'productName',
|
|
176
|
+
type: 'text',
|
|
177
|
+
message: 'What will your product title be?',
|
|
178
|
+
value: () => titleCase(io.results.productId),
|
|
179
|
+
next: 'productRoute'
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `type: 'select'`
|
|
184
|
+
|
|
185
|
+
| Property | Type | What it does |
|
|
186
|
+
| --------- | ------------------------------------------------------------------------------ | -------------------------------- |
|
|
187
|
+
| `options` | `Option[] \| () => Option[]` where `Option = { label: string, value: string }` | The choices. Arrow keys + ENTER. |
|
|
188
|
+
|
|
189
|
+
The result is the chosen option's `value` — the string itself, not the `{ label, value }` object.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
io.step.input({
|
|
193
|
+
id: 'deployment',
|
|
194
|
+
type: 'select',
|
|
195
|
+
message: 'Is your product deployed on-prem or in the cloud?',
|
|
196
|
+
options: [
|
|
197
|
+
{ label: 'On-Prem', value: 'on-prem' },
|
|
198
|
+
{ label: 'Cloud', value: 'cloud' }
|
|
199
|
+
],
|
|
200
|
+
next: (result) => (result === 'on-prem' ? 'verifyOnPrem' : 'shouldInstall')
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `type: 'multiselect'`
|
|
205
|
+
|
|
206
|
+
Same `options` shape as `select`; SPACE toggles, ENTER submits. The result is an **array** of the chosen values (empty array if nothing was chosen — use `validate` to require a minimum).
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
io.step.input({
|
|
210
|
+
id: 'features',
|
|
211
|
+
type: 'multiselect',
|
|
212
|
+
message: 'Which features should be scaffolded?',
|
|
213
|
+
options: featureOptions,
|
|
214
|
+
validate: (values) => values.length > 0 || 'Pick at least one.',
|
|
215
|
+
next: 'confirmPlan'
|
|
216
|
+
})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### `type: 'confirm'`
|
|
220
|
+
|
|
221
|
+
| Property | Type | What it does |
|
|
222
|
+
| -------- | -------- | ----------------------------------------------- |
|
|
223
|
+
| `yes` | `string` | Label for the affirmative. Defaults to `'Yes'`. |
|
|
224
|
+
| `no` | `string` | Label for the negative. Defaults to `'No'`. |
|
|
225
|
+
|
|
226
|
+
The result is a `boolean`.
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
io.step.input({
|
|
230
|
+
id: 'verifyOnPrem',
|
|
231
|
+
type: 'confirm',
|
|
232
|
+
message: 'Are you sureeee it is on-prem?',
|
|
233
|
+
yes: 'Yes, I am sure',
|
|
234
|
+
no: 'No, not really',
|
|
235
|
+
next: 'shouldInstall'
|
|
236
|
+
})
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Dynamic properties are sync. Lifecycle hooks are async.
|
|
240
|
+
|
|
241
|
+
This is the rule that keeps steps predictable, so it gets its own heading.
|
|
242
|
+
|
|
243
|
+
Any _display_ property — `message`, `tip`, `placeholder`, `value`, `options` — can be a function, and that function runs **synchronously at render time**. It reads from `io.results` and `io.store`; it never fetches. Anything async belongs in a lifecycle hook: `pre`, `task`, `validate`, or `post`.
|
|
244
|
+
|
|
245
|
+
So "the select options come from an API" looks like this — `pre` fetches, `options` reads:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
io.step.input({
|
|
249
|
+
id: 'chooseTemplate',
|
|
250
|
+
pre: async () => {
|
|
251
|
+
io.store.templates = await fetcher('/api/templates')
|
|
252
|
+
},
|
|
253
|
+
type: 'select',
|
|
254
|
+
message: 'Which template?',
|
|
255
|
+
options: () =>
|
|
256
|
+
io.store.templates.map((template) => ({ label: template.name, value: template.id })),
|
|
257
|
+
next: 'confirmTemplate'
|
|
258
|
+
})
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The render loop never awaits a property, and you always know exactly when your code runs.
|
|
262
|
+
|
|
263
|
+
## Branching and why explicit `next` is the point
|
|
264
|
+
|
|
265
|
+
`next` is required on every step (it is literally the feature). Because progression is always explicit:
|
|
266
|
+
|
|
267
|
+
- **Branches are just functions.** `next: (result) => result ? 'installDeps' : 'skipInstall'`
|
|
268
|
+
- **Cycles are legal.** A `next` can point at any registered step, including one "behind" it. Point every branch's final step back at a menu and you have an app that runs until the user quits:
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
io.step.input({
|
|
272
|
+
id: 'mainMenu',
|
|
273
|
+
type: 'select',
|
|
274
|
+
message: 'What would you like to do?',
|
|
275
|
+
options: [
|
|
276
|
+
{ label: 'Create a product', value: 'createProduct' },
|
|
277
|
+
{ label: 'Delete a product', value: 'deleteProduct' },
|
|
278
|
+
{ label: 'Quit', value: 'quit' }
|
|
279
|
+
],
|
|
280
|
+
next: (result) => (result === 'quit' ? io.exit() : result)
|
|
281
|
+
})
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Notice the trick in that last line: option values can _be_ step ids, so the menu routes itself.
|
|
285
|
+
|
|
286
|
+
## Exiting
|
|
287
|
+
|
|
288
|
+
`io.exit()` returns an exit token. Use it anywhere a step id is accepted:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
next: io.exit() // clean exit, status code 0
|
|
292
|
+
next: () => io.exit() // same, from a branch
|
|
293
|
+
next: () => io.exit.error('Could not clone. Check your SSH credentials.') // red message, status code 1
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
No magic strings. An exit token can't collide with a step id, can't be typo'd into a broken reference, and carries its message as data instead of a parsed prefix.
|
|
297
|
+
|
|
298
|
+
## `io.results` and `io.store`
|
|
299
|
+
|
|
300
|
+
**`io.results`** is engine-owned: after a step completes, `io.results[id]` holds its result (the answer for input steps, the task's return value for output steps). Read it anywhere — in later steps' dynamic properties, in `next` functions, in extracted helpers.
|
|
301
|
+
|
|
302
|
+
**`io.store`** is yours. It's the shared state for everything that isn't a single step's result: flags set by tasks, data fetched in `pre`, accumulations across a loop. Because your app exports one `io` instance, extracted functions just import it — no context parameter threading through five layers of helpers:
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
// source/utilities/cloneTemplate.ts
|
|
306
|
+
import { io } from '../io'
|
|
307
|
+
|
|
308
|
+
export async function cloneTemplate() {
|
|
309
|
+
try {
|
|
310
|
+
await git.clone(templateUrl, io.results.productId)
|
|
311
|
+
io.store.didCloneSucceed = true
|
|
312
|
+
} catch {
|
|
313
|
+
io.store.didCloneSucceed = false
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Rule of thumb: if one step produces it and the flow consumes it, let it be the step's result. If it's shared, shaped, or written from helpers, put it in the store.
|
|
319
|
+
|
|
320
|
+
## Starting the flow — and what gets validated
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
io.start('welcome')
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`io.start` validates the graph **before rendering anything**:
|
|
327
|
+
|
|
328
|
+
- Every static string `next` must resolve to a registered step. A typo fails at startup with a message naming the step and the missing id — never mid-flow after the user has answered six prompts.
|
|
329
|
+
- The start id must exist.
|
|
330
|
+
- Unreachable steps produce a warning (they're often a sign of a broken reference elsewhere).
|
|
331
|
+
|
|
332
|
+
Duplicate ids don't even make it that far — `io.step.output` / `io.step.input` throw at registration.
|
|
333
|
+
|
|
334
|
+
Function `next`s can't be checked statically, so they're checked at runtime: returning an unknown id produces an error that names _which step_ returned _which bad id_, not a stack trace from the engine's guts.
|
|
335
|
+
|
|
336
|
+
`Ctrl+C` is handled for you: the terminal is restored (no half-drawn prompt), an optional `onCancel` runs, and the process exits cleanly:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
io.start('welcome', { onCancel: () => console.log('\nNo worries. Nothing was changed.') })
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Preseeded answers
|
|
343
|
+
|
|
344
|
+
Pass answers up front and their steps resolve without prompting — the step still runs its lifecycle (`pre`, `validate`, `post`, `next`), it just skips the interactive part. This is how a scaffolding CLI grows a `--yes` mode, and how flows run in CI:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
io.start('welcome', {
|
|
348
|
+
answers: { productId: 'demo-product', deployment: 'cloud', shouldInstall: false }
|
|
349
|
+
})
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Preseeded answers still go through `validate`. If a preseeded answer fails validation, the flow falls back to prompting for that step interactively — or, with `interactive: false`, exits with an error. `interactive: false` also means any _unseeded_ input step is an error: perfect for CI, where a hung prompt is a hung pipeline.
|
|
353
|
+
|
|
354
|
+
## Testing
|
|
355
|
+
|
|
356
|
+
Interactive CLIs are notoriously untestable. pathenger flows aren't, because the graph is data. `io.test` runs the flow headless — no TTY, no rendering, durations skipped — and returns what happened:
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import { io } from '../source/io'
|
|
360
|
+
import '../source/steps'
|
|
361
|
+
|
|
362
|
+
test('choosing on-prem routes through verification', async () => {
|
|
363
|
+
const run = await io.test('welcome', {
|
|
364
|
+
answers: {
|
|
365
|
+
productId: 'demo-product',
|
|
366
|
+
productName: 'Demo Product',
|
|
367
|
+
productRoute: '/demo-product',
|
|
368
|
+
deployment: 'on-prem',
|
|
369
|
+
verifyOnPrem: true,
|
|
370
|
+
shouldInstall: false
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
expect(run.visited).toContain('verifyOnPrem')
|
|
375
|
+
expect(run.results.deployment).toBe('on-prem')
|
|
376
|
+
expect(run.exit.code).toBe(0)
|
|
377
|
+
})
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
`run.visited` is the ordered list of step ids the flow walked, `run.results` is the final results object, and `run.exit` tells you how it ended. Assert on paths, not pixels.
|
|
381
|
+
|
|
382
|
+
`pathenger.create()` returns an instance precisely so tests can isolate: your _app_ makes `io` a singleton by exporting it, but a test can always construct a fresh one.
|
|
383
|
+
|
|
384
|
+
## Handing over the terminal
|
|
385
|
+
|
|
386
|
+
Sometimes a task needs to run a child process that has its own interactive moment — `git clone` asking for an SSH passphrase, an editor, a pager. Don't fight it; hand over the terminal:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
io.step.output({
|
|
390
|
+
id: 'cloneTemplate',
|
|
391
|
+
message: 'Cloning product template.',
|
|
392
|
+
task: async () => {
|
|
393
|
+
return await io.suspend(() =>
|
|
394
|
+
spawnWithInheritedStdio('git', ['clone', templateUrl, io.results.productId])
|
|
395
|
+
)
|
|
396
|
+
},
|
|
397
|
+
next: (result) =>
|
|
398
|
+
result.succeeded
|
|
399
|
+
? 'complete'
|
|
400
|
+
: io.exit.error('Clone failed. Check your SSH credentials.')
|
|
401
|
+
})
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`io.suspend` restores the cursor, leaves raw mode, runs your function with the terminal fully released, then re-enters the flow and redraws. The child process prompts however it wants; pathenger picks up where it left off.
|
|
405
|
+
|
|
406
|
+
## TypeScript
|
|
407
|
+
|
|
408
|
+
Declare your store and (optionally) your results shape when you create the instance:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
interface Store {
|
|
412
|
+
isPnpmInstalled?: boolean
|
|
413
|
+
templates?: Template[]
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
interface Results {
|
|
417
|
+
productId: string
|
|
418
|
+
productName: string
|
|
419
|
+
deployment: 'on-prem' | 'cloud'
|
|
420
|
+
shouldInstall: boolean
|
|
421
|
+
features: string[]
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export const io = pathenger.create<Store, Results>()
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Now `io.store.isPnpmInstalled` is a checked `boolean | undefined`, `io.results.deployment` is `'on-prem' | 'cloud'`, and a typo'd key is a compile error. Declaring your shapes up front is very much in the spirit of the library: explicit beats inferred-and-mysterious.
|
|
428
|
+
|
|
429
|
+
## A complete example
|
|
430
|
+
|
|
431
|
+
The product-creation CLI, end to end:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
import { pathenger } from 'pathenger'
|
|
435
|
+
import { titleCase } from 'change-case'
|
|
436
|
+
import { fetcher, hasGlobalPnpm, cloneTemplate } from './utilities'
|
|
437
|
+
|
|
438
|
+
interface Store {
|
|
439
|
+
didCloneSucceed?: boolean
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const io = pathenger.create<Store>()
|
|
443
|
+
|
|
444
|
+
io.step.output({
|
|
445
|
+
id: 'welcome',
|
|
446
|
+
message: 'Welcome to the product creator.',
|
|
447
|
+
duration: 1000,
|
|
448
|
+
next: 'productId'
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
io.step.input({
|
|
452
|
+
id: 'productId',
|
|
453
|
+
type: 'text',
|
|
454
|
+
message: 'What will your product ID be?',
|
|
455
|
+
tip: 'alpha-numeric with dashes and underscores',
|
|
456
|
+
placeholder: 'my-new-product',
|
|
457
|
+
filter: /^[a-zA-Z0-9_-]*$/,
|
|
458
|
+
next: 'productName',
|
|
459
|
+
|
|
460
|
+
validate: async (value) => {
|
|
461
|
+
const existingIds = await fetcher('/api/getProductIds')
|
|
462
|
+
return existingIds.includes(value) ? `"${value}" is already taken.` : true
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
io.step.input({
|
|
467
|
+
id: 'productName',
|
|
468
|
+
type: 'text',
|
|
469
|
+
message: 'What will your product title be?',
|
|
470
|
+
value: () => titleCase(io.results.productId),
|
|
471
|
+
next: 'productRoute'
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
io.step.input({
|
|
475
|
+
id: 'productRoute',
|
|
476
|
+
type: 'text',
|
|
477
|
+
message: 'What route should it live at?',
|
|
478
|
+
placeholder: () => '/' + io.results.productId,
|
|
479
|
+
next: 'deployment'
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
io.step.input({
|
|
483
|
+
id: 'deployment',
|
|
484
|
+
type: 'select',
|
|
485
|
+
message: 'Is your product deployed on-prem or in the cloud?',
|
|
486
|
+
options: [
|
|
487
|
+
{ label: 'On-Prem', value: 'on-prem' },
|
|
488
|
+
{ label: 'Cloud', value: 'cloud' }
|
|
489
|
+
],
|
|
490
|
+
next: (result) => (result === 'on-prem' ? 'verifyOnPrem' : 'shouldInstall')
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
io.step.input({
|
|
494
|
+
id: 'verifyOnPrem',
|
|
495
|
+
type: 'confirm',
|
|
496
|
+
message: 'Are you sureeee it is on-prem?',
|
|
497
|
+
yes: 'Yes, I am sure',
|
|
498
|
+
no: 'No, not really',
|
|
499
|
+
next: 'shouldInstall'
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
io.step.input({
|
|
503
|
+
id: 'shouldInstall',
|
|
504
|
+
type: 'confirm',
|
|
505
|
+
message: 'Go ahead and install dependencies?',
|
|
506
|
+
next: (result) => (result ? 'checkPnpm' : 'cloneTemplate')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
io.step.output({
|
|
510
|
+
id: 'checkPnpm',
|
|
511
|
+
message: 'Checking for pnpm.',
|
|
512
|
+
duration: 1000,
|
|
513
|
+
task: async () => await hasGlobalPnpm(),
|
|
514
|
+
next: (result) => (result ? 'cloneTemplate' : 'noPnpmWarning')
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
io.step.output({
|
|
518
|
+
id: 'noPnpmWarning',
|
|
519
|
+
level: 'warning',
|
|
520
|
+
message: 'pnpm is not globally installed. Skipping dependency install.',
|
|
521
|
+
duration: 1000,
|
|
522
|
+
next: 'cloneTemplate'
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
io.step.output({
|
|
526
|
+
id: 'cloneTemplate',
|
|
527
|
+
message: 'Cloning product template.',
|
|
528
|
+
task: async () => {
|
|
529
|
+
await cloneTemplate()
|
|
530
|
+
},
|
|
531
|
+
next: () =>
|
|
532
|
+
io.store.didCloneSucceed
|
|
533
|
+
? 'complete'
|
|
534
|
+
: io.exit.error('Failed to clone the template. Check your SSH credentials.')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
io.step.output({
|
|
538
|
+
id: 'complete',
|
|
539
|
+
level: 'success',
|
|
540
|
+
message: () =>
|
|
541
|
+
`Product created. cd ${io.results.productId} && pnpm start to begin development.`,
|
|
542
|
+
duration: 1250,
|
|
543
|
+
next: io.exit()
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
io.start('welcome')
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
## Why not just use a prompt library?
|
|
550
|
+
|
|
551
|
+
inquirer, prompts, and @clack/prompts are good at what they do: individual prompt widgets. But the moment your CLI has branches, loops, validation against live data, or async work between questions, the orchestration lands in your lap as a pile of `if`s and `await`s, and it's exactly the part that's hard to read, hard to change, and impossible to test.
|
|
552
|
+
|
|
553
|
+
pathenger owns the orchestration. The flow is declared, so it can be validated at startup, preseeded for CI, walked headlessly in tests, and read top-to-bottom by the next person. The prompts are the easy part. The graph is the product.
|
|
554
|
+
|
|
555
|
+
## API summary
|
|
556
|
+
|
|
557
|
+
| | |
|
|
558
|
+
| -------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
559
|
+
| `pathenger.create<Store, Results>()` | Create an `io` instance. Export one per app. |
|
|
560
|
+
| `io.step.output(step)` | Register an output step. Throws on duplicate id. |
|
|
561
|
+
| `io.step.input(step)` | Register an input step. Throws on duplicate id. |
|
|
562
|
+
| `io.start(startId, options?)` | Validate the graph and run the flow. Options: `answers`, `interactive`, `onCancel`. |
|
|
563
|
+
| `io.test(startId, options?)` | Run headless. Returns `{ visited, results, exit }`. |
|
|
564
|
+
| `io.results` | Every completed step's result, keyed by step id. Engine-owned. |
|
|
565
|
+
| `io.store` | Your shared state. Typed by the `Store` generic. |
|
|
566
|
+
| `io.exit()` / `io.exit.error(message)` | Exit tokens, usable anywhere a step id is accepted. |
|
|
567
|
+
| `io.suspend(fn)` | Release the terminal for a child process, then resume the flow. |
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pathenger",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Step-by-step flows for command-line apps. Explicit, branching, testable.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"cli",
|
|
22
|
+
"prompts",
|
|
23
|
+
"flow",
|
|
24
|
+
"wizard",
|
|
25
|
+
"interactive",
|
|
26
|
+
"state-machine"
|
|
27
|
+
],
|
|
28
|
+
"license": "ISC",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"picocolors": "^1.1.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"typescript": "^5.5.0"
|
|
35
|
+
}
|
|
36
|
+
}
|