pathenger 0.1.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,567 +1,493 @@
1
- # pathenger
1
+ # Pathenger
2
2
 
3
- Step-by-step flows for command-line apps. Explicit, branching, testable.
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
- 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.
5
+ Common prompt libraries hand you widgets and wish you luck with the orchestration. Pathenger does the opposite.
6
6
 
7
- ```ts
8
- import { pathenger } from 'pathenger'
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.
9
8
 
10
- const io = pathenger.create()
9
+ ```
10
+ npm i -S pathenger
11
+ ```
11
12
 
12
- io.step.output({
13
- id: 'welcome',
14
- message: 'Welcome aboard.',
15
- duration: 1000,
16
- next: 'name'
17
- })
13
+ ## Let's Go
18
14
 
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
- })
15
+ A step is either:
26
16
 
27
- io.step.output({
28
- id: 'goodbye',
29
- message: () => `Nice to meet you, ${io.results.name}.`,
30
- level: 'success',
31
- next: io.exit()
32
- })
17
+ - A class extending `Pathenger.OutputStep` or `Pathenger.InputStep`
18
+ - A typed object created with `Pathenger.createOutputStep()` or `Pathenger.createInputStep()`
33
19
 
34
- io.start('welcome')
35
- ```
20
+ Both forms work together in the same flow.
36
21
 
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.
22
+ ## Create a flow
38
23
 
39
- ## Install
24
+ Create one flow per CLI application. Its generic types define shared app state and the results produced by steps.
40
25
 
41
- ```bash
42
- pnpm add pathenger
43
- ```
26
+ ```ts
27
+ import { Pathenger } from 'pathenger'
44
28
 
45
- ## The mental model
29
+ type Store = { bundleKb?: number; projectDirectory?: string }
46
30
 
47
- Three ideas, and you know the whole library:
31
+ type Results = { confirmBuild?: boolean; build?: { bundleKb: number } }
48
32
 
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.
33
+ const flow = Pathenger.create<Store, Results>()
34
+ ```
50
35
 
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.
36
+ `flow.store` is app-owned shared state. `flow.results` is engine-owned and receives each completed step’s result by step id.
52
37
 
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.
38
+ ## Define output steps with classes
54
39
 
55
- ## Creating your instance
40
+ An output step shows a message, may run work, and then moves to its next step.
56
41
 
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.
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
+ ```
58
54
 
59
55
  ```ts
60
- // source/io.ts
61
- import { pathenger } from 'pathenger'
56
+ class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
57
+ id = 'build'
58
+ message = 'Building…'
59
+ next = BuildDone
62
60
 
63
- type StoreT {
64
- isPnpmInstalled?: boolean
65
- templates?: Template[]
66
- }
61
+ task = async () => {
62
+ const bundleKb = await buildProject()
67
63
 
68
- export const io = pathenger.create<StoreT>()
69
- ```
64
+ flow.store.bundleKb = bundleKb
65
+ return { bundleKb }
66
+ }
70
67
 
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).
68
+ post = async (result) => {
69
+ console.log(`Built ${result.bundleKb} KB.`)
70
+ }
71
+ }
72
+ ```
72
73
 
73
- 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`.
74
75
 
75
76
  ```ts
76
- // source/index.ts
77
- import { io } from './io'
78
- import './steps/welcome'
79
- import './steps/mainMenu'
80
- 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.'))
81
81
 
82
- io.start('welcome')
82
+ task = async () => {
83
+ return await publishProject()
84
+ }
85
+ }
83
86
  ```
84
87
 
85
- ## Output steps
88
+ ## Define input steps with classes
86
89
 
87
- 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.
88
91
 
89
92
  ```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
- ```
93
+ class ConfirmBuild extends Pathenger.InputStep<boolean> {
94
+ type: 'boolean' = 'boolean'
98
95
 
99
- Lifecycle: **pre → message → task → post → next**
96
+ id = 'confirmBuild'
97
+ message = 'Build the project?'
98
+ yes = 'Build'
99
+ no = 'Cancel'
100
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. |
101
+ next = (shouldBuild) => {
102
+ return shouldBuild ? Build : flow.exit()
103
+ }
104
+ }
105
+ ```
111
106
 
112
- ### `duration`
107
+ ```ts
108
+ class ProjectName extends Pathenger.InputStep<string> {
109
+ type: 'text' = 'text'
113
110
 
114
- `duration` is the **minimum time the step stays on screen**. That single rule covers everything:
111
+ id = 'projectName'
112
+ message = 'Project name?'
113
+ placeholder = 'my-app'
115
114
 
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.
115
+ validate = (name) => {
116
+ return name.length >= 2 || 'Use at least two characters.'
117
+ }
118
118
 
119
- ### `pre` vs `task`
119
+ next = Build
120
+ }
121
+ ```
120
122
 
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`.
123
+ The generic on `InputStep<Value>` types the value received by `validate`, `post`, `back`, and `next`.
122
124
 
123
- ### The step's result
125
+ Input classes declare their prompt type with `type`:
124
126
 
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.)
127
+ - `'text'`
128
+ - `'boolean'`
129
+ - `'select'`
130
+ - `'multiselect'`
126
131
 
127
- ## Input steps
132
+ ## Define typed object steps
128
133
 
129
- An input step shows a message and collects an answer.
134
+ Use the factories for concise, fully typed object declarations.
130
135
 
131
136
  ```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'
137
+ const Welcome = Pathenger.createOutputStep({
138
+ id: 'welcome',
139
+ message: 'Welcome to Pathenger.',
140
+ duration: 500,
141
+ next: ConfirmBuild
144
142
  })
145
143
  ```
146
144
 
147
- Lifecycle: **pre → message → input (filter per keystroke) → validate (on submit) → post → next**
145
+ ```ts
146
+ const Deployment = Pathenger.createInputStep({
147
+ type: 'select',
148
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. |
149
+ id: 'deployment',
150
+ message: 'Where should this run?',
151
+ options: [
152
+ { label: 'Cloud', value: 'cloud' },
153
+ { label: 'On-premise', value: 'on-prem' }
154
+ ],
159
155
 
160
- An input step's result is the submitted answer. It lands in `io.results[id]` and is passed to `validate`, `post`, and `next`.
156
+ next: (deployment) => {
157
+ return deployment === 'cloud' ? Publish : ConfigureServer
158
+ }
159
+ })
160
+ ```
161
161
 
162
- ### `type: 'text'`
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.
163
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. |
164
+ ## Start a flow
169
165
 
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.
166
+ Pass the complete flow definition to `start`.
171
167
 
172
168
  ```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'
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
+ }
180
177
  })
181
178
  ```
182
179
 
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. |
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`.
188
181
 
189
- The result is the chosen option's `value` the string itself, not the `{ label, value }` object.
182
+ A flow can use classes and object steps together:
190
183
 
191
184
  ```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')
185
+ await flow.start({
186
+ firstStep: Welcome,
187
+ steps: [Welcome, Deployment, ConfirmBuild, Build, BuildDone]
201
188
  })
202
189
  ```
203
190
 
204
- ### `type: 'multiselect'`
191
+ ## Link steps
205
192
 
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).
193
+ Use a step reference when linking steps. This keeps links safe when IDs are renamed.
207
194
 
208
195
  ```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
- })
196
+ next = BuildDone
217
197
  ```
218
198
 
219
- ### `type: 'confirm'`
199
+ A string id is also valid when needed:
220
200
 
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'`. |
201
+ ```ts
202
+ next = 'buildDone'
203
+ ```
225
204
 
226
- The result is a `boolean`.
205
+ Use a function to branch based on the completed step’s result:
227
206
 
228
207
  ```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
- })
208
+ next = (answer) => (answer ? Build : flow.exit())
237
209
  ```
238
210
 
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.
211
+ Use `flow.exit()` for a successful terminal step:
242
212
 
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`.
213
+ ```ts
214
+ next = flow.exit()
215
+ ```
244
216
 
245
- So "the select options come from an API" looks like this — `pre` fetches, `options` reads:
217
+ Use `flow.exit.error()` for a failed terminal step:
246
218
 
247
219
  ```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
- })
220
+ next = flow.exit.error('Build failed. Check the compiler output.')
259
221
  ```
260
222
 
261
- The render loop never awaits a property, and you always know exactly when your code runs.
223
+ ## Output-step API
224
+
225
+ Every output step requires:
226
+
227
+ - `id: string`
228
+ - `message: string | (() => string)`
229
+ - `next: StepReference | string | ExitToken | ((result) => StepReference | string | ExitToken)`
230
+
231
+ Optional output properties:
232
+
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>`
239
+
240
+ `pre` runs before rendering. Use it to fetch data needed by a dynamic message.
241
+
242
+ `task` runs while the message is visible. Use it for the work the message describes.
262
243
 
263
- ## Branching and why explicit `next` is the point
244
+ `duration` is the minimum time an output step remains visible. A task may keep the step on screen longer, but never shorter.
264
245
 
265
- `next` is required on every step (it is literally the feature). Because progression is always explicit:
246
+ `post` runs after the task settles.
266
247
 
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:
248
+ `back` compensates for external side effects if the user navigates back past the step.
249
+
250
+ ## Input-step API
251
+
252
+ Every input step requires:
253
+
254
+ - `type`
255
+ - `id: string`
256
+ - `message: string | (() => string)`
257
+ - `next: StepReference | string | ExitToken | ((answer) => StepReference | string | ExitToken)`
258
+
259
+ All input types may use:
260
+
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>`
267
+
268
+ ### Text input
269
269
 
270
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)
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
281
279
  })
282
280
  ```
283
281
 
284
- Notice the trick in that last line: option values can _be_ step ids, so the menu routes itself.
282
+ Text-only properties:
285
283
 
286
- ## Exiting
284
+ - `value: string | (() => string)`
285
+ - `placeholder: string | (() => string)`
286
+ - `filter: RegExp | ((value: string) => boolean)`
287
287
 
288
- `io.exit()` returns an exit token. Use it anywhere a step id is accepted:
288
+ ### Boolean input
289
289
 
290
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
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
+ })
294
299
  ```
295
300
 
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.
301
+ Boolean-only properties:
297
302
 
298
- ## `io.results` and `io.store`
303
+ - `yes?: string`
304
+ - `no?: string`
299
305
 
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:
306
+ ### Select input
303
307
 
304
308
  ```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
- }
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
+ })
316
319
  ```
317
320
 
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
+ ### Multiselect input
321
322
 
322
323
  ```ts
323
- 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
+ })
324
331
  ```
325
332
 
326
- `io.start` validates the graph **before rendering anything**:
333
+ Select and multiselect properties:
327
334
 
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).
335
+ - `options: Array<{ label: string; value: string }> | (() => Array<{ label: string; value: string }>)`
331
336
 
332
- Duplicate ids don't even make it that far — `io.step.output` / `io.step.input` throw at registration.
337
+ ## Dynamic values
333
338
 
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:
339
+ Display properties may be values or synchronous functions:
337
340
 
338
341
  ```ts
339
- 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 ?? []
340
345
  ```
341
346
 
342
- ## Preseeded answers
347
+ Dynamic functions run at render time. They should read already-available values from `flow.results` or `flow.store`.
348
+
349
+ Do not fetch or perform async work in a dynamic function. Use `pre` instead.
343
350
 
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:
351
+ ## Shared state and results
352
+
353
+ A task’s return value becomes that step’s result:
345
354
 
346
355
  ```ts
347
- io.start('welcome', {
348
- answers: { productId: 'demo-product', deployment: 'cloud', shouldInstall: false }
349
- })
350
- ```
356
+ class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
357
+ id = 'build'
358
+ message = 'Building…'
359
+ next = BuildDone
351
360
 
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.
361
+ task = async () => {
362
+ return { bundleKb: 128 }
363
+ }
364
+ }
353
365
 
354
- ## Testing
366
+ const bundleKb = flow.results.build?.bundleKb
367
+ ```
355
368
 
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:
369
+ Use `flow.store` for shared or shaped data, especially data written from helpers:
357
370
 
358
371
  ```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
- })
372
+ flow.store.projectDirectory = 'dist'
378
373
  ```
379
374
 
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.
375
+ Rule of thumb:
381
376
 
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.
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`.
383
379
 
384
- ## Handing over the terminal
380
+ ## Going back
385
381
 
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:
382
+ Set `canGoBack` on an input step to let the user return to a prior input and edit its answer.
387
383
 
388
384
  ```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.')
385
+ const ConfirmBuild = Pathenger.createInputStep({
386
+ type: 'boolean',
387
+ id: 'confirmBuild',
388
+ message: 'Build now?',
389
+ canGoBack: true,
390
+ next: Build
401
391
  })
402
392
  ```
403
393
 
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.
394
+ When going back, Pathenger restores the `flow.results` and `flow.store` snapshot from when the target input was first rendered.
405
395
 
406
- ## TypeScript
407
-
408
- Declare your store and (optionally) your results shape when you create the instance:
396
+ Pathenger cannot automatically undo filesystem, network, database, or process effects. Give side-effecting steps a `back` hook:
409
397
 
410
398
  ```ts
411
- interface Store {
412
- isPnpmInstalled?: boolean
413
- templates?: Template[]
414
- }
399
+ class CloneTemplate extends Pathenger.OutputStep {
400
+ id = 'cloneTemplate'
401
+ message = 'Cloning template…'
402
+ next = Build
415
403
 
416
- interface Results {
417
- productId: string
418
- productName: string
419
- deployment: 'on-prem' | 'cloud'
420
- shouldInstall: boolean
421
- features: string[]
422
- }
404
+ task = async () => {
405
+ return await cloneTemplate()
406
+ }
423
407
 
424
- export const io = pathenger.create<Store, Results>()
408
+ back = async () => {
409
+ await removeClonedTemplate()
410
+ }
411
+ }
425
412
  ```
426
413
 
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.
414
+ Back hooks run in reverse order as steps are unwound.
428
415
 
429
- ## A complete example
416
+ ## Preseeded answers and non-interactive runs
430
417
 
431
- The product-creation CLI, end to end:
418
+ Pass answers to skip matching prompts:
432
419
 
433
420
  ```ts
434
- import { pathenger } from 'pathenger'
435
- import { titleCase } from 'change-case'
436
- 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
+ ```
437
427
 
438
- interface Store {
439
- didCloneSucceed?: boolean
440
- }
428
+ Preseeded values still run `validate`, `post`, and `next`.
441
429
 
442
- const io = pathenger.create<Store>()
430
+ For CI or scripts, disable prompting:
443
431
 
444
- io.step.output({
445
- id: 'welcome',
446
- message: 'Welcome to the product creator.',
447
- duration: 1000,
448
- next: 'productId'
432
+ ```ts
433
+ await flow.start({
434
+ firstStep: Welcome,
435
+ steps: [Welcome, ConfirmBuild, Build, BuildDone],
436
+ answers: { confirmBuild: true },
437
+ interactive: false
449
438
  })
439
+ ```
450
440
 
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
- })
441
+ An unseeded input in a non-interactive run is an error.
465
442
 
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
- })
443
+ ## Testing
473
444
 
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
- })
445
+ Use the same explicit step inventory in headless tests.
481
446
 
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')
447
+ ```ts
448
+ const run = await flow.test({
449
+ firstStep: Welcome,
450
+ steps: [Welcome, ConfirmBuild, Build, BuildDone],
451
+ answers: { confirmBuild: true }
491
452
  })
492
453
 
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
- })
454
+ expect(run.visited).toEqual(['welcome', 'confirmBuild', 'build', 'buildDone'])
501
455
 
502
- io.step.input({
503
- id: 'shouldInstall',
504
- type: 'confirm',
505
- message: 'Go ahead and install dependencies?',
506
- next: (result) => (result ? 'checkPnpm' : 'cloneTemplate')
507
- })
456
+ expect(run.results.build).toEqual({ bundleKb: expect.any(Number) })
508
457
 
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
- })
458
+ expect(run.exit.code).toBe(0)
459
+ ```
516
460
 
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
- })
461
+ Tests do not render the terminal, prompt for input, or wait for display durations.
524
462
 
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
- })
463
+ ## Release the terminal
536
464
 
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
- })
465
+ Use `flow.suspend()` when a child process needs direct terminal access.
545
466
 
546
- 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
+ }
547
477
  ```
548
478
 
549
- ## Why not just use a prompt library?
479
+ Pathenger releases the terminal, runs the supplied function, then restores the flow terminal state afterward.
550
480
 
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.
481
+ ## Flow guarantees
552
482
 
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.
483
+ Before rendering, Pathenger validates the supplied flow:
554
484
 
555
- ## 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.
556
492
 
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. |
493
+ The flow is explicit, typed, testable, and contained in its `start()` or `test()` call.