pathenger 0.9.0 → 1.0.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
@@ -1,649 +1,493 @@
1
- # pathenger
1
+ # Pathenger
2
2
 
3
- Easy to use, explicit, predictable, dynamic yet structured; pathenger is step driven CLI orchestration framework.
3
+ Pathenger is a step-driven framework for command-line flows. You declare every step in one place, connect them explicitly, and start the flow from a named first step.
4
4
 
5
- Common prompt libraries hand you widgets and wish you luck with the orchestration. pathenger does the opposite.
5
+ Common prompt libraries hand you widgets and wish you luck with the orchestration. Pathenger does the opposite.
6
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.
7
+ You declare your CLI as a graph of steps. Each one shows a message, does some work, or asks a question. The engine walks the graph until you tell it to exit. Branches, loops, validation, and async work are all first-class.
8
8
 
9
- Branches, loops, validation, and async work are all first-class. No other tool provides this approach.
9
+ ```
10
+ npm i -S pathenger
11
+ ```
10
12
 
11
- ```ts
12
- import { pathenger } from 'pathenger'
13
+ ## Let's Go
13
14
 
14
- const io = pathenger.create()
15
+ A step is either:
15
16
 
16
- io.step.output({
17
- id: 'welcome',
18
- message: 'Welcome aboard.',
19
- duration: 1000,
20
- next: 'name'
21
- })
17
+ - A class extending `Pathenger.OutputStep` or `Pathenger.InputStep`
18
+ - A typed object created with `Pathenger.createOutputStep()` or `Pathenger.createInputStep()`
22
19
 
23
- io.step.input({
24
- id: 'name',
25
- type: 'text',
26
- message: 'What is your name?',
27
- next: 'goodbye',
28
- validate: (value) => {
29
- const isInputLongEnough = value.length > 1
30
- return isInputLongEnough || 'Must be more than 1 character.'
31
- }
32
- })
20
+ Both forms work together in the same flow.
33
21
 
34
- io.step.output({
35
- id: 'goodbye',
36
- level: 'success',
37
- next: io.exit(),
38
- message: () => `Nice to meet you, ${io.results.name}.`
39
- })
22
+ ## Create a flow
40
23
 
41
- io.start('welcome')
42
- ```
24
+ Create one flow per CLI application. Its generic types define shared app state and the results produced by steps.
43
25
 
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:
26
+ ```ts
27
+ import { Pathenger } from 'pathenger'
49
28
 
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
29
+ type Store = { bundleKb?: number; projectDirectory?: string }
56
30
 
57
- ## Install
31
+ type Results = { confirmBuild?: boolean; build?: { bundleKb: number } }
58
32
 
59
- ```bash
60
- pnpm add pathenger
33
+ const flow = Pathenger.create<Store, Results>()
61
34
  ```
62
35
 
63
- ## The mental model
64
-
65
- Three ideas, and you know the whole library:
66
-
67
- 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.
36
+ `flow.store` is app-owned shared state. `flow.results` is engine-owned and receives each completed step’s result by step id.
68
37
 
69
- 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.
38
+ ## Define output steps with classes
70
39
 
71
- 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.
40
+ An output step shows a message, may run work, and then moves to its next step.
72
41
 
73
- ## Creating your instance
74
-
75
- 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.
42
+ ```ts
43
+ class BuildDone extends Pathenger.OutputStep {
44
+ id = 'buildDone'
45
+ level = 'success'
46
+ next = flow.exit()
47
+
48
+ message = () => {
49
+ const bundleSize = flow.store.bundleKb ?? 0
50
+ return `Build complete. Output written to dist/ (${bundleSize} KB).`
51
+ }
52
+ }
53
+ ```
76
54
 
77
55
  ```ts
78
- // source/io.ts
79
- import { pathenger } from 'pathenger'
56
+ class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
57
+ id = 'build'
58
+ message = 'Building…'
59
+ next = BuildDone
80
60
 
81
- type StoreT = { isPnpmInstalled?: boolean; templates?: TemplateT[] }
61
+ task = async () => {
62
+ const bundleKb = await buildProject()
82
63
 
83
- export const io = pathenger.create<StoreT>()
84
- ```
64
+ flow.store.bundleKb = bundleKb
65
+ return { bundleKb }
66
+ }
85
67
 
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)).
68
+ post = async (result) => {
69
+ console.log(`Built ${result.bundleKb} KB.`)
70
+ }
71
+ }
72
+ ```
87
73
 
88
- Your entry point imports the step files (importing registers them) and starts the flow:
74
+ The generic on `OutputStep<Result>` types the values received by `next`, `post`, and `back`.
89
75
 
90
76
  ```ts
91
- // source/index.ts
92
- import { io } from './io'
93
- import './steps/welcome'
94
- import './steps/mainMenu'
95
- import './steps/createProduct'
77
+ class Publish extends Pathenger.OutputStep<{ url: string }> {
78
+ id = 'publish'
79
+ message = 'Publishing…'
80
+ next = (result) => (result.url ? Published : flow.exit.error('Publish failed.'))
96
81
 
97
- io.start('welcome')
82
+ task = async () => {
83
+ return await publishProject()
84
+ }
85
+ }
98
86
  ```
99
87
 
100
- ## Output steps
88
+ ## Define input steps with classes
101
89
 
102
- An output step shows a message, optionally runs async work while the message is on screen, then moves on.
90
+ An input step asks one question and produces one answer.
103
91
 
104
92
  ```ts
105
- io.step.output({
106
- id: 'checkPnpm',
107
- message: 'Checking for pnpm.',
108
- duration: 1000,
109
- task: async () => await hasGlobalPnpm(),
110
- next: (result) => (result ? 'cloneTemplate' : 'noPnpmWarning')
111
- })
112
- ```
93
+ class ConfirmBuild extends Pathenger.InputStep<boolean> {
94
+ type: 'boolean' = 'boolean'
113
95
 
114
- Lifecycle: **pre → message → task → post → next**
96
+ id = 'confirmBuild'
97
+ message = 'Build the project?'
98
+ yes = 'Build'
99
+ no = 'Cancel'
115
100
 
116
- | Property | Type | Required | What it does |
117
- | ---------- | -------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------- |
118
- | `id` | `string` | yes | Unique step id. Registering a duplicate throws immediately. |
119
- | `message` | `string \| () => string` | yes | The text shown for this step. Functions run synchronously at render time. |
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. |
124
- | `post` | `(result) => void \| Promise<void>` | no | Side effects after the result is locked in. Cannot block progression. |
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. |
101
+ next = (shouldBuild) => {
102
+ return shouldBuild ? Build : flow.exit()
103
+ }
104
+ }
105
+ ```
127
106
 
128
- ### `duration`
107
+ ```ts
108
+ class ProjectName extends Pathenger.InputStep<string> {
109
+ type: 'text' = 'text'
129
110
 
130
- `duration` is the minimum time the step stays on screen. One rule, two effects:
111
+ id = 'projectName'
112
+ message = 'Project name?'
113
+ placeholder = 'my-app'
131
114
 
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.
115
+ validate = (name) => {
116
+ return name.length >= 2 || 'Use at least two characters.'
117
+ }
134
118
 
135
- ### `pre` vs `task`
119
+ next = Build
120
+ }
121
+ ```
136
122
 
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.
123
+ The generic on `InputStep<Value>` types the value received by `validate`, `post`, `back`, and `next`.
138
124
 
139
- ### The step's result
125
+ Input classes declare their prompt type with `type`:
140
126
 
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.
127
+ - `'text'`
128
+ - `'boolean'`
129
+ - `'select'`
130
+ - `'multiselect'`
142
131
 
143
- ## Input steps
132
+ ## Define typed object steps
144
133
 
145
- An input step shows a message and collects an answer.
134
+ Use the factories for concise, fully typed object declarations.
146
135
 
147
136
  ```ts
148
- io.step.input({
149
- id: 'productId',
150
- type: 'text',
151
- message: 'What will your product ID be?',
152
- tip: 'alpha-numeric with dashes and underscores',
153
- placeholder: 'my-new-product',
154
- filter: /^[a-zA-Z0-9_-]*$/,
155
- validate: async (value) => {
156
- const existingIds = await fetcher('/api/getProductIds')
157
- return existingIds.includes(value) ? `"${value}" is already taken.` : true
158
- },
159
- next: 'productName'
137
+ const Welcome = Pathenger.createOutputStep({
138
+ id: 'welcome',
139
+ message: 'Welcome to Pathenger.',
140
+ duration: 500,
141
+ next: ConfirmBuild
160
142
  })
161
143
  ```
162
144
 
163
- Lifecycle: **pre → message → input (filter per keystroke) → validate (on submit) → post → next**
164
-
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. |
177
-
178
- An input step's result is the submitted answer. It lands in `io.results[id]` and is passed to `validate`, `post`, and `next`.
179
-
180
- ### `type: 'text'`
181
-
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. |
145
+ ```ts
146
+ const Deployment = Pathenger.createInputStep({
147
+ type: 'select',
187
148
 
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.
149
+ id: 'deployment',
150
+ message: 'Where should this run?',
151
+ options: [
152
+ { label: 'Cloud', value: 'cloud' },
153
+ { label: 'On-premise', value: 'on-prem' }
154
+ ],
189
155
 
190
- ```ts
191
- // Deriving the initial value from an earlier answer
192
- io.step.input({
193
- id: 'productName',
194
- type: 'text',
195
- message: 'What will your product title be?',
196
- value: () => titleCase(io.results.productId),
197
- next: 'productRoute'
156
+ next: (deployment) => {
157
+ return deployment === 'cloud' ? Publish : ConfigureServer
158
+ }
198
159
  })
199
160
  ```
200
161
 
201
- ### `type: 'select'`
162
+ `createOutputStep()` and `createInputStep()` are the plain-object equivalents of the class base types. They provide contextual types for lifecycle callbacks and reject properties that do not belong to the selected step type.
202
163
 
203
- | Property | Type | What it does |
204
- | --------- | --------------------------------------------------------------------------------------------------- | -------------------------------- |
205
- | `options` | `SelectOptionT[] \| () => SelectOptionT[]` where `SelectOptionT = { label: string, value: string }` | The choices. Arrow keys + ENTER. |
164
+ ## Start a flow
206
165
 
207
- The result is the chosen option's `value`, the string itself, not the object.
166
+ Pass the complete flow definition to `start`.
208
167
 
209
168
  ```ts
210
- io.step.input({
211
- id: 'deployment',
212
- type: 'select',
213
- message: 'Is your product deployed on-prem or in the cloud?',
214
- options: [
215
- { label: 'On-Prem', value: 'on-prem' },
216
- { label: 'Cloud', value: 'cloud' }
217
- ],
218
- next: (result) => (result === 'on-prem' ? 'verifyOnPrem' : 'shouldInstall')
169
+ await flow.start({
170
+ firstStep: Welcome,
171
+
172
+ steps: [Welcome, ConfirmBuild, Build, BuildDone],
173
+
174
+ onCancel: () => {
175
+ console.log('\nCancelled. Nothing was changed.')
176
+ }
219
177
  })
220
178
  ```
221
179
 
222
- ### `type: 'multiselect'`
180
+ Pathenger instantiates every supplied step class once, uses factory-created objects directly, indexes the resulting steps by `id`, validates the graph, and begins at `firstStep`.
223
181
 
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`.
182
+ A flow can use classes and object steps together:
225
183
 
226
184
  ```ts
227
- io.step.input({
228
- id: 'features',
229
- type: 'multiselect',
230
- message: 'Which features should be scaffolded?',
231
- options: featureOptions,
232
- validate: (values) => values.length > 0 || 'Pick at least one.',
233
- next: 'confirmPlan'
185
+ await flow.start({
186
+ firstStep: Welcome,
187
+ steps: [Welcome, Deployment, ConfirmBuild, Build, BuildDone]
234
188
  })
235
189
  ```
236
190
 
237
- ### `type: 'confirm'`
238
-
239
- | Property | Type | What it does |
240
- | -------- | -------- | ----------------------------------------------- |
241
- | `yes` | `string` | Label for the affirmative. Defaults to `'Yes'`. |
242
- | `no` | `string` | Label for the negative. Defaults to `'No'`. |
191
+ ## Link steps
243
192
 
244
- The result is a `boolean`. Arrow keys toggle, `y`/`n` submit instantly.
193
+ Use a step reference when linking steps. This keeps links safe when IDs are renamed.
245
194
 
246
195
  ```ts
247
- io.step.input({
248
- id: 'verifyOnPrem',
249
- type: 'confirm',
250
- message: 'Are you sureeee it is on-prem?',
251
- yes: 'Yes, I am sure',
252
- no: 'No, not really',
253
- next: 'shouldInstall'
254
- })
196
+ next = BuildDone
255
197
  ```
256
198
 
257
- ## Dynamic properties are sync. Lifecycle hooks are async.
199
+ A string id is also valid when needed:
258
200
 
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`.
201
+ ```ts
202
+ next = 'buildDone'
203
+ ```
260
204
 
261
- So "the select options come from an API" looks like this. `pre` fetches, `options` reads:
205
+ Use a function to branch based on the completed step’s result:
262
206
 
263
207
  ```ts
264
- io.step.input({
265
- id: 'chooseTemplate',
266
- pre: async () => {
267
- io.store.templates = await fetcher('/api/templates')
268
- },
269
- type: 'select',
270
- message: 'Which template?',
271
- options: () =>
272
- io.store.templates.map((template) => ({ label: template.name, value: template.id })),
273
- next: 'confirmTemplate'
274
- })
208
+ next = (answer) => (answer ? Build : flow.exit())
275
209
  ```
276
210
 
277
- The render loop never awaits a property. You always know exactly when your code runs.
278
-
279
- ## Branching and why explicit `next` is the point
211
+ Use `flow.exit()` for a successful terminal step:
280
212
 
281
- `next` is required on every step (it is literally the feature). Because progression is always explicit:
213
+ ```ts
214
+ next = flow.exit()
215
+ ```
282
216
 
283
- - **Branches are just functions.** `next: (result) => result ? 'installDeps' : 'skipInstall'`
284
- - **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:
217
+ Use `flow.exit.error()` for a failed terminal step:
285
218
 
286
219
  ```ts
287
- io.step.input({
288
- id: 'mainMenu',
289
- type: 'select',
290
- message: 'What would you like to do?',
291
- options: [
292
- { label: 'Create a product', value: 'createProduct' },
293
- { label: 'Delete a product', value: 'deleteProduct' },
294
- { label: 'Quit', value: 'quit' }
295
- ],
296
- next: (result) => (result === 'quit' ? io.exit() : result)
297
- })
220
+ next = flow.exit.error('Build failed. Check the compiler output.')
298
221
  ```
299
222
 
300
- Notice the trick in that last line: option values can _be_ step ids, so the menu routes itself.
223
+ ## Output-step API
301
224
 
302
- ## Exiting
225
+ Every output step requires:
303
226
 
304
- `io.exit()` returns an exit token. Use it anywhere a step id is accepted:
227
+ - `id: string`
228
+ - `message: string | (() => string)`
229
+ - `next: StepReference | string | ExitToken | ((result) => StepReference | string | ExitToken)`
305
230
 
306
- ```ts
307
- next: io.exit() // clean exit, status code 0
308
- next: () => io.exit() // same, from a branch
309
- next: () => io.exit.error('Could not clone. Check your SSH credentials.') // red message, status code 1
310
- ```
231
+ Optional output properties:
311
232
 
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.
233
+ - `level: 'info' | 'success' | 'warning' | 'error' | 'debug'`
234
+ - `duration: number`
235
+ - `pre: () => void | Promise<void>`
236
+ - `task: () => Result | Promise<Result>`
237
+ - `post: (result: Result) => void | Promise<void>`
238
+ - `back: (result: Result | undefined) => void | Promise<void>`
313
239
 
314
- ## Going back
240
+ `pre` runs before rendering. Use it to fetch data needed by a dynamic message.
315
241
 
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.
242
+ `task` runs while the message is visible. Use it for the work the message describes.
317
243
 
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
- })
244
+ `duration` is the minimum time an output step remains visible. A task may keep the step on screen longer, but never shorter.
329
245
 
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
- ```
246
+ `post` runs after the task settles.
338
247
 
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.
248
+ `back` compensates for external side effects if the user navigates back past the step.
340
249
 
341
- ### The mental model: back is time travel to the moment the prompt was shown
250
+ ## Input-step API
342
251
 
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.
252
+ Every input step requires:
344
253
 
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.
254
+ - `type`
255
+ - `id: string`
256
+ - `message: string | (() => string)`
257
+ - `next: StepReference | string | ExitToken | ((answer) => StepReference | string | ExitToken)`
346
258
 
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.
259
+ All input types may use:
348
260
 
349
- ### `back`: undoing side effects
261
+ - `tip: string | (() => string)`
262
+ - `pre: () => void | Promise<void>`
263
+ - `validate: (answer) => true | string | Promise<true | string>`
264
+ - `post: (answer) => void | Promise<void>`
265
+ - `canGoBack: boolean`
266
+ - `back: (answer) => void | Promise<void>`
350
267
 
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.
268
+ ### Text input
352
269
 
353
270
  ```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'
271
+ const Directory = Pathenger.createInputStep({
272
+ type: 'text',
273
+ id: 'directory',
274
+ message: 'Output directory?',
275
+ value: () => flow.store.projectDirectory ?? 'dist',
276
+ placeholder: 'dist',
277
+ filter: /^[a-z0-9-]+$/i,
278
+ next: Build
360
279
  })
361
280
  ```
362
281
 
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.
282
+ Text-only properties:
364
283
 
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_.
284
+ - `value: string | (() => string)`
285
+ - `placeholder: string | (() => string)`
286
+ - `filter: RegExp | ((value: string) => boolean)`
366
287
 
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 `+`:
288
+ ### Boolean input
374
289
 
375
290
  ```ts
376
- io.start('welcome', { backKey: 'ctrl+b' })
291
+ const Overwrite = Pathenger.createInputStep({
292
+ type: 'boolean',
293
+ id: 'overwrite',
294
+ message: 'Overwrite the existing directory?',
295
+ yes: 'Overwrite',
296
+ no: 'Cancel',
297
+ next: (shouldOverwrite) => (shouldOverwrite ? Build : flow.exit())
298
+ })
377
299
  ```
378
300
 
379
- Supported modifiers are `ctrl`, `shift`, and `meta` (`alt` is treated as `meta`). Examples: `'escape'`, `'ctrl+b'`, `'ctrl+shift+left'`.
380
-
381
- ### Cost
301
+ Boolean-only properties:
382
302
 
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.
303
+ - `yes?: string`
304
+ - `no?: string`
384
305
 
385
- ## `io.results` and `io.store`
386
-
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.
388
-
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:
306
+ ### Select input
390
307
 
391
308
  ```ts
392
- // source/utilities/cloneTemplate.ts
393
- import { io } from '../io'
394
-
395
- export const cloneTemplate = async () => {
396
- try {
397
- await git.clone(templateUrl, io.results.productId)
398
- io.store.didCloneSucceed = true
399
- } catch {
400
- io.store.didCloneSucceed = false
401
- }
402
- }
309
+ const Template = Pathenger.createInputStep({
310
+ type: 'select',
311
+ id: 'template',
312
+ message: 'Choose a template.',
313
+ options: [
314
+ { label: 'Starter', value: 'starter' },
315
+ { label: 'Library', value: 'library' }
316
+ ],
317
+ next: Build
318
+ })
403
319
  ```
404
320
 
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.
406
-
407
- ## Starting the flow
321
+ ### Multiselect input
408
322
 
409
323
  ```ts
410
- io.start('welcome')
324
+ const Features = Pathenger.createInputStep({
325
+ type: 'multiselect',
326
+ id: 'features',
327
+ message: 'Choose features.',
328
+ options: () => availableFeatures(),
329
+ next: Build
330
+ })
411
331
  ```
412
332
 
413
- `io.start` validates the graph before rendering anything:
414
-
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.
416
- - The start id must exist.
417
- - Unreachable steps produce a warning.
333
+ Select and multiselect properties:
418
334
 
419
- Duplicate ids fail even earlier: `io.step.output` and `io.step.input` throw at registration.
335
+ - `options: Array<{ label: string; value: string }> | (() => Array<{ label: string; value: string }>)`
420
336
 
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.
337
+ ## Dynamic values
422
338
 
423
- `Ctrl+C` is handled for you: the terminal is restored, an optional `onCancel` runs, and the process exits cleanly.
339
+ Display properties may be values or synchronous functions:
424
340
 
425
341
  ```ts
426
- io.start('welcome', { onCancel: () => console.log('\nNo worries. Nothing was changed.') })
342
+ message = () => `Building ${flow.results.projectName}…`
343
+ placeholder = () => flow.results.projectName ?? 'my-app'
344
+ options = () => flow.store.templates ?? []
427
345
  ```
428
346
 
429
- ## Preseeded answers
347
+ Dynamic functions run at render time. They should read already-available values from `flow.results` or `flow.store`.
430
348
 
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:
349
+ Do not fetch or perform async work in a dynamic function. Use `pre` instead.
350
+
351
+ ## Shared state and results
352
+
353
+ A task’s return value becomes that step’s result:
432
354
 
433
355
  ```ts
434
- io.start('welcome', {
435
- answers: { productId: 'demo-product', deployment: 'cloud', shouldInstall: false }
436
- })
437
- ```
356
+ class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
357
+ id = 'build'
358
+ message = 'Building…'
359
+ next = BuildDone
438
360
 
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.
361
+ task = async () => {
362
+ return { bundleKb: 128 }
363
+ }
364
+ }
440
365
 
441
- ## Testing
366
+ const bundleKb = flow.results.build?.bundleKb
367
+ ```
442
368
 
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:
369
+ Use `flow.store` for shared or shaped data, especially data written from helpers:
444
370
 
445
371
  ```ts
446
- import { io } from '../source/io'
447
- import '../source/steps'
448
-
449
- test('choosing on-prem routes through verification', async () => {
450
- const run = await io.test('welcome', {
451
- answers: {
452
- productId: 'demo-product',
453
- productName: 'Demo Product',
454
- productRoute: '/demo-product',
455
- deployment: 'on-prem',
456
- verifyOnPrem: true,
457
- shouldInstall: false
458
- }
459
- })
460
-
461
- expect(run.visited).toContain('verifyOnPrem')
462
- expect(run.results.deployment).toBe('on-prem')
463
- expect(run.exit.code).toBe(0)
464
- })
372
+ flow.store.projectDirectory = 'dist'
465
373
  ```
466
374
 
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.
375
+ Rule of thumb:
468
376
 
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.
377
+ - One step produces one value for later flow decisions: return it from the step.
378
+ - Multiple steps or helpers need the value: write it to `flow.store`.
470
379
 
471
- ## Handing over the terminal
380
+ ## Going back
472
381
 
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:
382
+ Set `canGoBack` on an input step to let the user return to a prior input and edit its answer.
474
383
 
475
384
  ```ts
476
- io.step.output({
477
- id: 'cloneTemplate',
478
- message: 'Cloning product template.',
479
- task: async () => {
480
- return await io.suspend(() =>
481
- spawnWithInheritedStdio('git', ['clone', templateUrl, io.results.productId])
482
- )
483
- },
484
- next: (result) =>
485
- result.succeeded
486
- ? 'complete'
487
- : io.exit.error('Clone failed. Check your SSH credentials.')
385
+ const ConfirmBuild = Pathenger.createInputStep({
386
+ type: 'boolean',
387
+ id: 'confirmBuild',
388
+ message: 'Build now?',
389
+ canGoBack: true,
390
+ next: Build
488
391
  })
489
392
  ```
490
393
 
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.
492
-
493
- ## TypeScript
394
+ When going back, Pathenger restores the `flow.results` and `flow.store` snapshot from when the target input was first rendered.
494
395
 
495
- Declare your store and results shapes when you create the instance:
396
+ Pathenger cannot automatically undo filesystem, network, database, or process effects. Give side-effecting steps a `back` hook:
496
397
 
497
398
  ```ts
498
- type StoreT = { isPnpmInstalled?: boolean; templates?: TemplateT[] }
499
-
500
- type ResultsT = {
501
- productId: string
502
- productName: string
503
- deployment: 'on-prem' | 'cloud'
504
- shouldInstall: boolean
505
- features: string[]
506
- }
399
+ class CloneTemplate extends Pathenger.OutputStep {
400
+ id = 'cloneTemplate'
401
+ message = 'Cloning template…'
402
+ next = Build
403
+
404
+ task = async () => {
405
+ return await cloneTemplate()
406
+ }
507
407
 
508
- export const io = pathenger.create<StoreT, ResultsT>()
408
+ back = async () => {
409
+ await removeClonedTemplate()
410
+ }
411
+ }
509
412
  ```
510
413
 
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.
414
+ Back hooks run in reverse order as steps are unwound.
512
415
 
513
- ## A complete example
416
+ ## Preseeded answers and non-interactive runs
514
417
 
515
- The product-creation CLI, end to end:
418
+ Pass answers to skip matching prompts:
516
419
 
517
420
  ```ts
518
- import { pathenger } from 'pathenger'
519
- import { titleCase } from 'change-case'
520
- import { fetcher, hasGlobalPnpm, cloneTemplate } from './utilities'
421
+ await flow.start({
422
+ firstStep: Welcome,
423
+ steps: [Welcome, ConfirmBuild, Build, BuildDone],
424
+ answers: { confirmBuild: true }
425
+ })
426
+ ```
521
427
 
522
- type StoreT = { didCloneSucceed?: boolean }
428
+ Preseeded values still run `validate`, `post`, and `next`.
523
429
 
524
- const io = pathenger.create<StoreT>()
430
+ For CI or scripts, disable prompting:
525
431
 
526
- io.step.output({
527
- id: 'welcome',
528
- message: 'Welcome to the product creator.',
529
- duration: 1000,
530
- next: 'productId'
432
+ ```ts
433
+ await flow.start({
434
+ firstStep: Welcome,
435
+ steps: [Welcome, ConfirmBuild, Build, BuildDone],
436
+ answers: { confirmBuild: true },
437
+ interactive: false
531
438
  })
439
+ ```
532
440
 
533
- io.step.input({
534
- id: 'productId',
535
- type: 'text',
536
- message: 'What will your product ID be?',
537
- tip: 'alpha-numeric with dashes and underscores',
538
- placeholder: 'my-new-product',
539
- filter: /^[a-zA-Z0-9_-]*$/,
540
- next: 'productName',
541
-
542
- validate: async (value) => {
543
- const existingIds = await fetcher('/api/getProductIds')
544
- return existingIds.includes(value) ? `"${value}" is already taken.` : true
545
- }
546
- })
441
+ An unseeded input in a non-interactive run is an error.
547
442
 
548
- io.step.input({
549
- id: 'productName',
550
- type: 'text',
551
- message: 'What will your product title be?',
552
- value: () => titleCase(io.results.productId),
553
- next: 'productRoute'
554
- })
443
+ ## Testing
555
444
 
556
- io.step.input({
557
- id: 'productRoute',
558
- type: 'text',
559
- message: 'What route should it live at?',
560
- placeholder: () => '/' + io.results.productId,
561
- next: 'deployment'
562
- })
445
+ Use the same explicit step inventory in headless tests.
563
446
 
564
- io.step.input({
565
- id: 'deployment',
566
- type: 'select',
567
- message: 'Is your product deployed on-prem or in the cloud?',
568
- options: [
569
- { label: 'On-Prem', value: 'on-prem' },
570
- { label: 'Cloud', value: 'cloud' }
571
- ],
572
- next: (result) => (result === 'on-prem' ? 'verifyOnPrem' : 'shouldInstall')
447
+ ```ts
448
+ const run = await flow.test({
449
+ firstStep: Welcome,
450
+ steps: [Welcome, ConfirmBuild, Build, BuildDone],
451
+ answers: { confirmBuild: true }
573
452
  })
574
453
 
575
- io.step.input({
576
- id: 'verifyOnPrem',
577
- type: 'confirm',
578
- message: 'Are you sureeee it is on-prem?',
579
- yes: 'Yes, I am sure',
580
- no: 'No, not really',
581
- next: 'shouldInstall'
582
- })
454
+ expect(run.visited).toEqual(['welcome', 'confirmBuild', 'build', 'buildDone'])
583
455
 
584
- io.step.input({
585
- id: 'shouldInstall',
586
- type: 'confirm',
587
- message: 'Go ahead and install dependencies?',
588
- next: (result) => (result ? 'checkPnpm' : 'cloneTemplate')
589
- })
456
+ expect(run.results.build).toEqual({ bundleKb: expect.any(Number) })
590
457
 
591
- io.step.output({
592
- id: 'checkPnpm',
593
- message: 'Checking for pnpm.',
594
- duration: 1000,
595
- task: async () => await hasGlobalPnpm(),
596
- next: (result) => (result ? 'cloneTemplate' : 'noPnpmWarning')
597
- })
458
+ expect(run.exit.code).toBe(0)
459
+ ```
598
460
 
599
- io.step.output({
600
- id: 'noPnpmWarning',
601
- level: 'warning',
602
- message: 'pnpm is not globally installed. Skipping dependency install.',
603
- duration: 1000,
604
- next: 'cloneTemplate'
605
- })
461
+ Tests do not render the terminal, prompt for input, or wait for display durations.
606
462
 
607
- io.step.output({
608
- id: 'cloneTemplate',
609
- message: 'Cloning product template.',
610
- task: async () => {
611
- await cloneTemplate()
612
- },
613
- next: () =>
614
- io.store.didCloneSucceed
615
- ? 'complete'
616
- : io.exit.error('Failed to clone the template. Check your SSH credentials.')
617
- })
463
+ ## Release the terminal
618
464
 
619
- io.step.output({
620
- id: 'complete',
621
- level: 'success',
622
- message: () =>
623
- `Product created. cd ${io.results.productId} && pnpm start to begin development.`,
624
- duration: 1250,
625
- next: io.exit()
626
- })
465
+ Use `flow.suspend()` when a child process needs direct terminal access.
627
466
 
628
- io.start('welcome')
467
+ ```ts
468
+ class CloneRepository extends Pathenger.OutputStep {
469
+ id = 'cloneRepository'
470
+ message = 'Cloning repository…'
471
+ next = Build
472
+
473
+ task = async () => {
474
+ return await flow.suspend(() => spawnGitCloneWithInheritedStdio())
475
+ }
476
+ }
629
477
  ```
630
478
 
631
- ## Why not just use a prompt library?
479
+ Pathenger releases the terminal, runs the supplied function, then restores the flow terminal state afterward.
632
480
 
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.
481
+ ## Flow guarantees
634
482
 
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.
483
+ Before rendering, Pathenger validates the supplied flow:
636
484
 
637
- ## API summary
485
+ - Every step id is unique.
486
+ - `firstStep` exists in `steps`.
487
+ - Static `next` ids resolve to registered steps.
488
+ - Unreachable steps produce a warning.
489
+ - Dynamic `next` targets are validated when they resolve.
490
+ - Classes are instantiated once per flow run.
491
+ - Factory-created objects are used as declared.
638
492
 
639
- | | |
640
- | -------------------------------------- | ----------------------------------------------------------------------------------- |
641
- | `pathenger.create<StoreT, ResultsT>()` | Create an `io` instance. Export one per app. |
642
- | `io.step.output(step)` | Register an output step. Throws on duplicate id. |
643
- | `io.step.input(step)` | Register an input step. Throws on duplicate id. |
644
- | `io.start(startId, options?)` | Validate the graph and run the flow. Options: `answers`, `interactive`, `onCancel`, `backKey`. |
645
- | `io.test(startId, options?)` | Run headless. Returns `{ visited, results, exit }`. |
646
- | `io.results` | Every completed step's result, keyed by step id. Engine-owned. |
647
- | `io.store` | Your shared state. Typed by the `StoreT` generic. |
648
- | `io.exit()` / `io.exit.error(message)` | Exit tokens, usable anywhere a step id is accepted. |
649
- | `io.suspend(run)` | Release the terminal for a child process, then resume the flow. |
493
+ The flow is explicit, typed, testable, and contained in its `start()` or `test()` call.