pathenger 0.1.0 → 0.9.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 +164 -82
- package/dist/backSignal.d.ts +10 -0
- package/dist/backSignal.js +32 -0
- package/dist/cancelSignal.d.ts +9 -0
- package/dist/cancelSignal.js +15 -0
- package/dist/create.d.ts +34 -0
- package/dist/create.js +119 -0
- package/dist/exitToken.d.ts +9 -0
- package/dist/exitToken.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2 -0
- package/dist/levels.d.ts +2 -0
- package/dist/levels.js +14 -0
- package/dist/prompts/confirmPrompt.d.ts +12 -0
- package/dist/prompts/confirmPrompt.js +73 -0
- package/dist/prompts/multiselectPrompt.d.ts +11 -0
- package/dist/prompts/multiselectPrompt.js +105 -0
- package/dist/prompts/promptStyles.d.ts +5 -0
- package/dist/prompts/promptStyles.js +15 -0
- package/dist/prompts/runValidation.d.ts +2 -0
- package/dist/prompts/runValidation.js +23 -0
- package/dist/prompts/selectPrompt.d.ts +11 -0
- package/dist/prompts/selectPrompt.js +77 -0
- package/dist/prompts/textPrompt.d.ts +12 -0
- package/dist/prompts/textPrompt.js +86 -0
- package/dist/resolveDynamicValue.d.ts +2 -0
- package/dist/resolveDynamicValue.js +10 -0
- package/dist/runFlow.d.ts +19 -0
- package/dist/runFlow.js +377 -0
- package/dist/spinner.d.ts +5 -0
- package/dist/spinner.js +22 -0
- package/dist/terminal.d.ts +14 -0
- package/dist/terminal.js +84 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.js +1 -0
- package/dist/validateGraph.d.ts +2 -0
- package/dist/validateGraph.js +51 -0
- package/package.json +29 -4
package/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# pathenger
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Easy to use, explicit, predictable, dynamic yet structured; pathenger is step driven CLI orchestration framework.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Common prompt libraries hand you widgets and wish you luck with the orchestration. pathenger does the opposite.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
Branches, loops, validation, and async work are all first-class. No other tool provides this approach.
|
|
6
10
|
|
|
7
11
|
```ts
|
|
8
12
|
import { pathenger } from 'pathenger'
|
|
@@ -20,21 +24,35 @@ io.step.input({
|
|
|
20
24
|
id: 'name',
|
|
21
25
|
type: 'text',
|
|
22
26
|
message: 'What is your name?',
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
next: 'goodbye',
|
|
28
|
+
validate: (value) => {
|
|
29
|
+
const isInputLongEnough = value.length > 1
|
|
30
|
+
return isInputLongEnough || 'Must be more than 1 character.'
|
|
31
|
+
}
|
|
25
32
|
})
|
|
26
33
|
|
|
27
34
|
io.step.output({
|
|
28
35
|
id: 'goodbye',
|
|
29
|
-
message: () => `Nice to meet you, ${io.results.name}.`,
|
|
30
36
|
level: 'success',
|
|
31
|
-
next: io.exit()
|
|
37
|
+
next: io.exit(),
|
|
38
|
+
message: () => `Nice to meet you, ${io.results.name}.`
|
|
32
39
|
})
|
|
33
40
|
|
|
34
41
|
io.start('welcome')
|
|
35
42
|
```
|
|
36
43
|
|
|
37
|
-
That's a complete program.
|
|
44
|
+
That's a complete program.
|
|
45
|
+
|
|
46
|
+
There is no `main()`, no free-form paths your program takes, no prompt-then-if-then-prompt spaghetti. You register steps, point them at each other, and start the flow.
|
|
47
|
+
|
|
48
|
+
What you get that widget libraries don't have:
|
|
49
|
+
|
|
50
|
+
- A validated flow graph that fails at startup, not six prompts deep
|
|
51
|
+
- Legal cycles, so menu-driven apps that run until quit come free
|
|
52
|
+
- Opt-in backtracking with side-effect compensation, so users can edit an earlier answer
|
|
53
|
+
- Preseeded answers for CI and instant `--yes` modes
|
|
54
|
+
- A headless test harness that asserts on the path your flow took
|
|
55
|
+
- Typed results and shared state across every step and helper
|
|
38
56
|
|
|
39
57
|
## Install
|
|
40
58
|
|
|
@@ -60,15 +78,12 @@ Create one `io` per app and export it from a module. Every step file and every e
|
|
|
60
78
|
// source/io.ts
|
|
61
79
|
import { pathenger } from 'pathenger'
|
|
62
80
|
|
|
63
|
-
type StoreT {
|
|
64
|
-
isPnpmInstalled?: boolean
|
|
65
|
-
templates?: Template[]
|
|
66
|
-
}
|
|
81
|
+
type StoreT = { isPnpmInstalled?: boolean; templates?: TemplateT[] }
|
|
67
82
|
|
|
68
83
|
export const io = pathenger.create<StoreT>()
|
|
69
84
|
```
|
|
70
85
|
|
|
71
|
-
The generic types `io.store` so every access is checked and autocompleted across your whole codebase.
|
|
86
|
+
The generic types `io.store` so every access is checked and autocompleted across your whole codebase. A second generic types `io.results` (see [TypeScript](#typescript)).
|
|
72
87
|
|
|
73
88
|
Your entry point imports the step files (importing registers them) and starts the flow:
|
|
74
89
|
|
|
@@ -102,27 +117,28 @@ Lifecycle: **pre → message → task → post → next**
|
|
|
102
117
|
| ---------- | -------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
|
|
103
118
|
| `id` | `string` | yes | Unique step id. Registering a duplicate throws immediately. |
|
|
104
119
|
| `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
|
|
106
|
-
| `duration` | `number` | no | Minimum milliseconds the step stays on screen
|
|
107
|
-
| `pre` | `() => void \| Promise<void>` | no | Async setup
|
|
108
|
-
| `task` | `() => unknown \| Promise<unknown>` | no | Async work
|
|
120
|
+
| `level` | `'info' \| 'success' \| 'warning' \| 'error' \| 'debug'` | no | Styling of the message. Defaults to `'info'`. |
|
|
121
|
+
| `duration` | `number` | no | Minimum milliseconds the step stays on screen. |
|
|
122
|
+
| `pre` | `() => void \| Promise<void>` | no | Async setup. Runs silently _before_ the message renders. |
|
|
123
|
+
| `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
124
|
| `post` | `(result) => void \| Promise<void>` | no | Side effects after the result is locked in. Cannot block progression. |
|
|
110
|
-
| `
|
|
125
|
+
| `back` | `(result) => void \| Promise<void>` | no | Compensation hook. Runs when the user goes back past this step, undoing its side effects. See [Going back](#going-back). |
|
|
126
|
+
| `next` | `string \| (result) => string \| ExitTokenT` | yes | Where to go. Receives the step's result. |
|
|
111
127
|
|
|
112
128
|
### `duration`
|
|
113
129
|
|
|
114
|
-
`duration` is the
|
|
130
|
+
`duration` is the minimum time the step stays on screen. One rule, two effects:
|
|
115
131
|
|
|
116
|
-
-
|
|
117
|
-
-
|
|
132
|
+
- A message-only step with `duration: 1000` shows for one second, then advances. Use this to pace output.
|
|
133
|
+
- A step with a `task` spins for _at least_ `duration`, longer if the task is still pending. Fast tasks don't flash, slow tasks aren't cut off.
|
|
118
134
|
|
|
119
135
|
### `pre` vs `task`
|
|
120
136
|
|
|
121
|
-
`pre` runs
|
|
137
|
+
`pre` runs before the message renders. It fetches whatever the step needs to display itself. `task` runs while the message is on screen. It is the work the message narrates: "Cloning product template…" with a spinner. 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.
|
|
122
138
|
|
|
123
139
|
### The step's result
|
|
124
140
|
|
|
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`.
|
|
141
|
+
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
142
|
|
|
127
143
|
## Input steps
|
|
128
144
|
|
|
@@ -146,28 +162,30 @@ io.step.input({
|
|
|
146
162
|
|
|
147
163
|
Lifecycle: **pre → message → input (filter per keystroke) → validate (on submit) → post → next**
|
|
148
164
|
|
|
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.
|
|
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
|
|
157
|
-
| `post` | `(result) => void \| Promise<void>` | no | Side effects after the answer is accepted. Cannot block progression
|
|
158
|
-
| `
|
|
165
|
+
| Property | Type | Required | What it does |
|
|
166
|
+
| ---------- | ------------------------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
167
|
+
| `id` | `string` | yes | Unique step id. |
|
|
168
|
+
| `type` | `'text' \| 'select' \| 'multiselect' \| 'confirm'` | yes | Which prompt to render. |
|
|
169
|
+
| `message` | `string \| () => string` | yes | The question. Functions run synchronously at render time. |
|
|
170
|
+
| `tip` | `string \| () => string` | no | Persistent hint rendered dim beneath the input. |
|
|
171
|
+
| `pre` | `() => void \| Promise<void>` | no | Async setup before the message renders. |
|
|
172
|
+
| `validate` | `(value) => true \| string \| Promise<true \| string>` | no | Runs on ENTER. Return a string to block progression and show it in red. Async is fine, a spinner shows while it runs. |
|
|
173
|
+
| `post` | `(result) => void \| Promise<void>` | no | Side effects after the answer is accepted. Cannot block progression, that's `validate`'s job. |
|
|
174
|
+
| `canGoBack`| `boolean` | no | Lets the user press the back key here to return to the previous input step and edit it. See [Going back](#going-back). |
|
|
175
|
+
| `back` | `(result) => void \| Promise<void>` | no | Compensation hook. Runs when the user goes back past this step. See [Going back](#going-back). |
|
|
176
|
+
| `next` | `string \| (result) => string \| ExitTokenT` | yes | Where to go. Receives the answer. |
|
|
159
177
|
|
|
160
178
|
An input step's result is the submitted answer. It lands in `io.results[id]` and is passed to `validate`, `post`, and `next`.
|
|
161
179
|
|
|
162
180
|
### `type: 'text'`
|
|
163
181
|
|
|
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
|
|
182
|
+
| Property | Type | What it does |
|
|
183
|
+
| ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
184
|
+
| `value` | `string \| () => string` | Initial _editable_ value, prefilled in the input. |
|
|
185
|
+
| `placeholder` | `string \| () => string` | Ghost text shown when the input is empty. Never submitted. |
|
|
186
|
+
| `filter` | `RegExp \| (value: string) => boolean` | Runs on every keystroke, synchronously. The keystroke is rejected if the candidate value fails. Regexes are full-match: `/^[a-z]*$/` and `/[a-z]*/` behave identically. |
|
|
169
187
|
|
|
170
|
-
`filter`
|
|
188
|
+
`filter` is cheap and sync, so it runs per keystroke and physically keeps bad characters out. `validate` runs once on submit, so it can 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
189
|
|
|
172
190
|
```ts
|
|
173
191
|
// Deriving the initial value from an earlier answer
|
|
@@ -182,11 +200,11 @@ io.step.input({
|
|
|
182
200
|
|
|
183
201
|
### `type: 'select'`
|
|
184
202
|
|
|
185
|
-
| Property | Type
|
|
186
|
-
| --------- |
|
|
187
|
-
| `options` | `
|
|
203
|
+
| Property | Type | What it does |
|
|
204
|
+
| --------- | --------------------------------------------------------------------------------------------------- | -------------------------------- |
|
|
205
|
+
| `options` | `SelectOptionT[] \| () => SelectOptionT[]` where `SelectOptionT = { label: string, value: string }` | The choices. Arrow keys + ENTER. |
|
|
188
206
|
|
|
189
|
-
The result is the chosen option's `value
|
|
207
|
+
The result is the chosen option's `value`, the string itself, not the object.
|
|
190
208
|
|
|
191
209
|
```ts
|
|
192
210
|
io.step.input({
|
|
@@ -203,7 +221,7 @@ io.step.input({
|
|
|
203
221
|
|
|
204
222
|
### `type: 'multiselect'`
|
|
205
223
|
|
|
206
|
-
Same `options` shape as `select
|
|
224
|
+
Same `options` shape as `select`. SPACE toggles, ENTER submits. The result is an array of the chosen values, in declaration order. Empty if nothing was chosen; require a minimum with `validate`.
|
|
207
225
|
|
|
208
226
|
```ts
|
|
209
227
|
io.step.input({
|
|
@@ -223,7 +241,7 @@ io.step.input({
|
|
|
223
241
|
| `yes` | `string` | Label for the affirmative. Defaults to `'Yes'`. |
|
|
224
242
|
| `no` | `string` | Label for the negative. Defaults to `'No'`. |
|
|
225
243
|
|
|
226
|
-
The result is a `boolean`.
|
|
244
|
+
The result is a `boolean`. Arrow keys toggle, `y`/`n` submit instantly.
|
|
227
245
|
|
|
228
246
|
```ts
|
|
229
247
|
io.step.input({
|
|
@@ -238,11 +256,9 @@ io.step.input({
|
|
|
238
256
|
|
|
239
257
|
## Dynamic properties are sync. Lifecycle hooks are async.
|
|
240
258
|
|
|
241
|
-
|
|
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`.
|
|
259
|
+
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
260
|
|
|
245
|
-
So "the select options come from an API" looks like this
|
|
261
|
+
So "the select options come from an API" looks like this. `pre` fetches, `options` reads:
|
|
246
262
|
|
|
247
263
|
```ts
|
|
248
264
|
io.step.input({
|
|
@@ -258,7 +274,7 @@ io.step.input({
|
|
|
258
274
|
})
|
|
259
275
|
```
|
|
260
276
|
|
|
261
|
-
The render loop never awaits a property
|
|
277
|
+
The render loop never awaits a property. You always know exactly when your code runs.
|
|
262
278
|
|
|
263
279
|
## Branching and why explicit `next` is the point
|
|
264
280
|
|
|
@@ -295,17 +311,88 @@ next: () => io.exit.error('Could not clone. Check your SSH credentials.') // red
|
|
|
295
311
|
|
|
296
312
|
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
313
|
|
|
314
|
+
## Going back
|
|
315
|
+
|
|
316
|
+
`next` moves the flow forward. Going back lets the user return to an earlier question and edit their answer. Opt an input step in with `canGoBack`, and pressing the back key (`Escape` by default) on that prompt returns to the previous input step.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
io.step.input({
|
|
320
|
+
id: 'deployment',
|
|
321
|
+
type: 'select',
|
|
322
|
+
message: 'On-prem or cloud?',
|
|
323
|
+
options: [
|
|
324
|
+
{ label: 'On-Prem', value: 'on-prem' },
|
|
325
|
+
{ label: 'Cloud', value: 'cloud' }
|
|
326
|
+
],
|
|
327
|
+
next: 'region'
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
io.step.input({
|
|
331
|
+
id: 'region',
|
|
332
|
+
type: 'text',
|
|
333
|
+
message: 'Which region?',
|
|
334
|
+
canGoBack: true, // Escape here returns to `deployment`
|
|
335
|
+
next: 'confirm'
|
|
336
|
+
})
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The previous step re-renders with its earlier answer already filled in, so the user edits instead of retyping. Resubmitting proceeds forward normally, and every step downstream runs fresh from there.
|
|
340
|
+
|
|
341
|
+
### The mental model: back is time travel to the moment the prompt was shown
|
|
342
|
+
|
|
343
|
+
When a step is entered, pathenger runs its `pre`, then snapshots `io.results` and `io.store` exactly as they stand, then renders. Going back restores that snapshot and re-renders the target **without** re-running its `pre`. So the step you land on looks precisely as it did when you first saw it — same options, same prefilled values, same store.
|
|
344
|
+
|
|
345
|
+
This is deliberate. It's the behavior you already know from a web page: scroll down, scroll back up, and the top of the page is still there — it doesn't re-fetch just because you returned to it. Back means _edit a previous answer_, not _refresh_. If you want completely fresh data, `Ctrl+C` and restart; that's cheap. And because every step below the one you edit runs its `pre` again on the way forward, anything that actually depends on the changed answer is recomputed anyway.
|
|
346
|
+
|
|
347
|
+
`pre` therefore runs exactly once per forward entry. No idempotency caveat, no "pre might run twice" footnote. You always know when your code runs.
|
|
348
|
+
|
|
349
|
+
### `back`: undoing side effects
|
|
350
|
+
|
|
351
|
+
Restoring a snapshot rolls back `io.results` and `io.store`. It cannot roll back the world. If a step cloned a repo, wrote a file, or hit an API, going past it should undo that work — so give the step a `back` hook.
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
io.step.output({
|
|
355
|
+
id: 'cloneTemplate',
|
|
356
|
+
message: 'Cloning template.',
|
|
357
|
+
task: async () => await cloneTemplate(io.results.productId),
|
|
358
|
+
back: async () => await removeDirectory(io.results.productId),
|
|
359
|
+
next: 'installDeps'
|
|
360
|
+
})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`back` lives on **any** step, output or input — side effects happen in `task` and in `pre` alike, so anywhere can need cleanup. When the user goes back across several steps at once, each unwound step's `back` runs in **reverse order** (last done, first undone — the saga pattern), each receiving the result being discarded. `back` may be async; a failing `back` surfaces its error and halts rather than leaving a half-undone world in silence.
|
|
364
|
+
|
|
365
|
+
Two orthogonal properties, each in its natural home: `canGoBack` on input steps says _the user may leave here backwards_; `back` on any step says _here's how to undo me when I'm unwound_.
|
|
366
|
+
|
|
367
|
+
### The world is your job
|
|
368
|
+
|
|
369
|
+
pathenger guarantees exactly one thing when going back: `io.results` and `io.store` are restored to the target step's post-`pre` snapshot. Everything else your steps touched — the filesystem, the network, a database, a spawned process — is outside its reach. That's what `back` hooks are for, and they only run on steps that declare them. Enable `canGoBack` on a step and you're accepting responsibility for making every side effect between there and the previous question reversible. If you don't write that cleanup, going back won't do it for you.
|
|
370
|
+
|
|
371
|
+
### Choosing the back key
|
|
372
|
+
|
|
373
|
+
The back key is global, set once when you start the flow. Default is `Escape`. Pass `backKey` to change it — a bare key name, or a combo joined with `+`:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
io.start('welcome', { backKey: 'ctrl+b' })
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Supported modifiers are `ctrl`, `shift`, and `meta` (`alt` is treated as `meta`). Examples: `'escape'`, `'ctrl+b'`, `'ctrl+shift+left'`.
|
|
380
|
+
|
|
381
|
+
### Cost
|
|
382
|
+
|
|
383
|
+
History is only tracked when at least one input step sets `canGoBack`. If none do, there are no snapshots and no overhead — the feature is entirely pay-as-you-go. Because snapshots use `structuredClone`, `io.results` and `io.store` must be structured-cloneable while back is enabled: no functions, class instances, or promises stored in them. pathenger throws a clear error at snapshot time if they aren't.
|
|
384
|
+
|
|
298
385
|
## `io.results` and `io.store`
|
|
299
386
|
|
|
300
|
-
**`io.results`** is engine-owned
|
|
387
|
+
**`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.
|
|
301
388
|
|
|
302
|
-
**`io.store`** is yours. It
|
|
389
|
+
**`io.store`** is yours. It holds 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:
|
|
303
390
|
|
|
304
391
|
```ts
|
|
305
392
|
// source/utilities/cloneTemplate.ts
|
|
306
393
|
import { io } from '../io'
|
|
307
394
|
|
|
308
|
-
export async
|
|
395
|
+
export const cloneTemplate = async () => {
|
|
309
396
|
try {
|
|
310
397
|
await git.clone(templateUrl, io.results.productId)
|
|
311
398
|
io.store.didCloneSucceed = true
|
|
@@ -317,23 +404,23 @@ export async function cloneTemplate() {
|
|
|
317
404
|
|
|
318
405
|
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
406
|
|
|
320
|
-
## Starting the flow
|
|
407
|
+
## Starting the flow
|
|
321
408
|
|
|
322
409
|
```ts
|
|
323
410
|
io.start('welcome')
|
|
324
411
|
```
|
|
325
412
|
|
|
326
|
-
`io.start` validates the graph
|
|
413
|
+
`io.start` validates the graph before rendering anything:
|
|
327
414
|
|
|
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
|
|
415
|
+
- 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
416
|
- The start id must exist.
|
|
330
|
-
- Unreachable steps produce a warning
|
|
417
|
+
- Unreachable steps produce a warning.
|
|
331
418
|
|
|
332
|
-
Duplicate ids
|
|
419
|
+
Duplicate ids fail even earlier: `io.step.output` and `io.step.input` throw at registration.
|
|
333
420
|
|
|
334
|
-
Function `next`s
|
|
421
|
+
Function `next`s are checked at runtime. Returning an unknown id produces an error naming _which step_ returned _which bad id_, not a stack trace from the engine's guts.
|
|
335
422
|
|
|
336
|
-
`Ctrl+C` is handled for you: the terminal is restored
|
|
423
|
+
`Ctrl+C` is handled for you: the terminal is restored, an optional `onCancel` runs, and the process exits cleanly.
|
|
337
424
|
|
|
338
425
|
```ts
|
|
339
426
|
io.start('welcome', { onCancel: () => console.log('\nNo worries. Nothing was changed.') })
|
|
@@ -341,7 +428,7 @@ io.start('welcome', { onCancel: () => console.log('\nNo worries. Nothing was cha
|
|
|
341
428
|
|
|
342
429
|
## Preseeded answers
|
|
343
430
|
|
|
344
|
-
Pass answers up front and their steps resolve without prompting
|
|
431
|
+
Pass answers up front and their steps resolve without prompting. The step still runs its full 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
432
|
|
|
346
433
|
```ts
|
|
347
434
|
io.start('welcome', {
|
|
@@ -349,11 +436,11 @@ io.start('welcome', {
|
|
|
349
436
|
})
|
|
350
437
|
```
|
|
351
438
|
|
|
352
|
-
Preseeded answers still go through `validate`.
|
|
439
|
+
Preseeded answers still go through `validate`. An invalid preseed falls back to prompting interactively, or with `interactive: false`, exits with an error. `interactive: false` also makes any unseeded input step an error: perfect for CI, where a hung prompt is a hung pipeline.
|
|
353
440
|
|
|
354
441
|
## Testing
|
|
355
442
|
|
|
356
|
-
Interactive CLIs are notoriously untestable. pathenger flows aren't, because the graph is data. `io.test` runs the flow headless
|
|
443
|
+
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
444
|
|
|
358
445
|
```ts
|
|
359
446
|
import { io } from '../source/io'
|
|
@@ -379,11 +466,11 @@ test('choosing on-prem routes through verification', async () => {
|
|
|
379
466
|
|
|
380
467
|
`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
468
|
|
|
382
|
-
`pathenger.create()` returns an instance precisely so tests can isolate
|
|
469
|
+
`pathenger.create()` returns an instance precisely so tests can isolate. Your _app_ makes `io` a singleton by exporting it. A test can always construct a fresh one.
|
|
383
470
|
|
|
384
471
|
## Handing over the terminal
|
|
385
472
|
|
|
386
|
-
Sometimes a task needs to run a child process
|
|
473
|
+
Sometimes a task needs to run a child process with its own interactive moment: `git clone` asking for an SSH passphrase, an editor, a pager. Don't fight it. Hand over the terminal:
|
|
387
474
|
|
|
388
475
|
```ts
|
|
389
476
|
io.step.output({
|
|
@@ -401,19 +488,16 @@ io.step.output({
|
|
|
401
488
|
})
|
|
402
489
|
```
|
|
403
490
|
|
|
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
|
|
491
|
+
`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
492
|
|
|
406
493
|
## TypeScript
|
|
407
494
|
|
|
408
|
-
Declare your store and
|
|
495
|
+
Declare your store and results shapes when you create the instance:
|
|
409
496
|
|
|
410
497
|
```ts
|
|
411
|
-
|
|
412
|
-
isPnpmInstalled?: boolean
|
|
413
|
-
templates?: Template[]
|
|
414
|
-
}
|
|
498
|
+
type StoreT = { isPnpmInstalled?: boolean; templates?: TemplateT[] }
|
|
415
499
|
|
|
416
|
-
|
|
500
|
+
type ResultsT = {
|
|
417
501
|
productId: string
|
|
418
502
|
productName: string
|
|
419
503
|
deployment: 'on-prem' | 'cloud'
|
|
@@ -421,10 +505,10 @@ interface Results {
|
|
|
421
505
|
features: string[]
|
|
422
506
|
}
|
|
423
507
|
|
|
424
|
-
export const io = pathenger.create<
|
|
508
|
+
export const io = pathenger.create<StoreT, ResultsT>()
|
|
425
509
|
```
|
|
426
510
|
|
|
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.
|
|
511
|
+
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. Explicit beats inferred-and-mysterious.
|
|
428
512
|
|
|
429
513
|
## A complete example
|
|
430
514
|
|
|
@@ -435,11 +519,9 @@ import { pathenger } from 'pathenger'
|
|
|
435
519
|
import { titleCase } from 'change-case'
|
|
436
520
|
import { fetcher, hasGlobalPnpm, cloneTemplate } from './utilities'
|
|
437
521
|
|
|
438
|
-
|
|
439
|
-
didCloneSucceed?: boolean
|
|
440
|
-
}
|
|
522
|
+
type StoreT = { didCloneSucceed?: boolean }
|
|
441
523
|
|
|
442
|
-
const io = pathenger.create<
|
|
524
|
+
const io = pathenger.create<StoreT>()
|
|
443
525
|
|
|
444
526
|
io.step.output({
|
|
445
527
|
id: 'welcome',
|
|
@@ -548,7 +630,7 @@ io.start('welcome')
|
|
|
548
630
|
|
|
549
631
|
## Why not just use a prompt library?
|
|
550
632
|
|
|
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
|
|
633
|
+
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. That pile is exactly the part that's hard to read, hard to change, and impossible to test.
|
|
552
634
|
|
|
553
635
|
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
636
|
|
|
@@ -556,12 +638,12 @@ pathenger owns the orchestration. The flow is declared, so it can be validated a
|
|
|
556
638
|
|
|
557
639
|
| | |
|
|
558
640
|
| -------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
559
|
-
| `pathenger.create<
|
|
641
|
+
| `pathenger.create<StoreT, ResultsT>()` | Create an `io` instance. Export one per app. |
|
|
560
642
|
| `io.step.output(step)` | Register an output step. Throws on duplicate id. |
|
|
561
643
|
| `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`. |
|
|
644
|
+
| `io.start(startId, options?)` | Validate the graph and run the flow. Options: `answers`, `interactive`, `onCancel`, `backKey`. |
|
|
563
645
|
| `io.test(startId, options?)` | Run headless. Returns `{ visited, results, exit }`. |
|
|
564
646
|
| `io.results` | Every completed step's result, keyed by step id. Engine-owned. |
|
|
565
|
-
| `io.store` | Your shared state. Typed by the `
|
|
647
|
+
| `io.store` | Your shared state. Typed by the `StoreT` generic. |
|
|
566
648
|
| `io.exit()` / `io.exit.error(message)` | Exit tokens, usable anywhere a step id is accepted. |
|
|
567
|
-
| `io.suspend(
|
|
649
|
+
| `io.suspend(run)` | Release the terminal for a child process, then resume the flow. |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { KeypressT } from './terminal.js';
|
|
2
|
+
declare const backSignalMark: unique symbol;
|
|
3
|
+
export type BackSignalT = {
|
|
4
|
+
[backSignalMark]: true;
|
|
5
|
+
};
|
|
6
|
+
export declare const createBackSignal: () => BackSignalT;
|
|
7
|
+
export declare const checkIsBackSignal: (candidate: unknown) => candidate is BackSignalT;
|
|
8
|
+
export type BackKeypressCheckT = (keypress: KeypressT) => boolean;
|
|
9
|
+
export declare const buildBackKeypressCheck: (backKey: string) => BackKeypressCheckT;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const backSignalMark = Symbol('pathenger.backSignal');
|
|
2
|
+
export const createBackSignal = () => {
|
|
3
|
+
return { [backSignalMark]: true };
|
|
4
|
+
};
|
|
5
|
+
export const checkIsBackSignal = (candidate) => {
|
|
6
|
+
const isObjectLike = typeof candidate === 'object' && candidate !== null;
|
|
7
|
+
if (!isObjectLike)
|
|
8
|
+
return false;
|
|
9
|
+
return backSignalMark in candidate;
|
|
10
|
+
};
|
|
11
|
+
// backKey is a lowercase key name with optional modifiers,
|
|
12
|
+
// joined by "+" — "escape", "ctrl+b", "ctrl+shift+left"
|
|
13
|
+
export const buildBackKeypressCheck = (backKey) => {
|
|
14
|
+
const parts = backKey.toLowerCase().split('+');
|
|
15
|
+
const keyName = parts[parts.length - 1];
|
|
16
|
+
const modifiers = parts.slice(0, -1);
|
|
17
|
+
const needsCtrl = modifiers.includes('ctrl');
|
|
18
|
+
const needsShift = modifiers.includes('shift');
|
|
19
|
+
const needsMeta = modifiers.includes('meta') || modifiers.includes('alt');
|
|
20
|
+
return (keypress) => {
|
|
21
|
+
const doesNameMatch = keypress.name === keyName;
|
|
22
|
+
if (!doesNameMatch)
|
|
23
|
+
return false;
|
|
24
|
+
if (keypress.isCtrl !== needsCtrl)
|
|
25
|
+
return false;
|
|
26
|
+
if (keypress.isShift !== needsShift)
|
|
27
|
+
return false;
|
|
28
|
+
if (keypress.isMeta !== needsMeta)
|
|
29
|
+
return false;
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { KeypressT } from './terminal.js';
|
|
2
|
+
declare const cancelSignalMark: unique symbol;
|
|
3
|
+
export type CancelSignalT = {
|
|
4
|
+
[cancelSignalMark]: true;
|
|
5
|
+
};
|
|
6
|
+
export declare const createCancelSignal: () => CancelSignalT;
|
|
7
|
+
export declare const checkIsCancelSignal: (candidate: unknown) => candidate is CancelSignalT;
|
|
8
|
+
export declare const throwIfCancelKeypress: (keypress: KeypressT) => void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const cancelSignalMark = Symbol('pathenger.cancelSignal');
|
|
2
|
+
export const createCancelSignal = () => {
|
|
3
|
+
return { [cancelSignalMark]: true };
|
|
4
|
+
};
|
|
5
|
+
export const checkIsCancelSignal = (candidate) => {
|
|
6
|
+
const isObjectLike = typeof candidate === 'object' && candidate !== null;
|
|
7
|
+
if (!isObjectLike)
|
|
8
|
+
return false;
|
|
9
|
+
return cancelSignalMark in candidate;
|
|
10
|
+
};
|
|
11
|
+
export const throwIfCancelKeypress = (keypress) => {
|
|
12
|
+
const isCancelKeypress = keypress.isCtrl && keypress.name === 'c';
|
|
13
|
+
if (isCancelKeypress)
|
|
14
|
+
throw createCancelSignal();
|
|
15
|
+
};
|
package/dist/create.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ExitTokenT } from './exitToken.js';
|
|
2
|
+
import type { FlowOutcomeT } from './runFlow.js';
|
|
3
|
+
import type { InputStepT, OutputStepT } from './types.js';
|
|
4
|
+
export type StartOptionsT = {
|
|
5
|
+
answers?: Record<string, unknown>;
|
|
6
|
+
interactive?: boolean;
|
|
7
|
+
onCancel?: () => void;
|
|
8
|
+
backKey?: string;
|
|
9
|
+
};
|
|
10
|
+
export type TestOptionsT = {
|
|
11
|
+
answers?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
export type TestRunT<ResultsT> = {
|
|
14
|
+
visited: string[];
|
|
15
|
+
results: ResultsT;
|
|
16
|
+
exit: FlowOutcomeT;
|
|
17
|
+
};
|
|
18
|
+
export type ExitFunctionT = {
|
|
19
|
+
(): ExitTokenT;
|
|
20
|
+
error: (message: string) => ExitTokenT;
|
|
21
|
+
};
|
|
22
|
+
export type IoT<StoreT, ResultsT> = {
|
|
23
|
+
step: {
|
|
24
|
+
output: <TaskResultT>(outputStep: OutputStepT<TaskResultT>) => OutputStepT<TaskResultT>;
|
|
25
|
+
input: (inputStep: InputStepT) => InputStepT;
|
|
26
|
+
};
|
|
27
|
+
start: (startId: string, startOptions?: StartOptionsT) => Promise<void>;
|
|
28
|
+
test: (startId: string, testOptions?: TestOptionsT) => Promise<TestRunT<ResultsT>>;
|
|
29
|
+
results: ResultsT;
|
|
30
|
+
store: StoreT;
|
|
31
|
+
exit: ExitFunctionT;
|
|
32
|
+
suspend: <ReturnT>(runWithTerminal: () => Promise<ReturnT>) => Promise<ReturnT>;
|
|
33
|
+
};
|
|
34
|
+
export declare const create: <StoreT extends object = Record<string, unknown>, ResultsT extends object = Record<string, unknown>>() => IoT<StoreT, ResultsT>;
|