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 +316 -472
- package/dist/create.d.ts +42 -11
- package/dist/create.js +224 -43
- package/dist/index.d.ts +9 -4
- package/dist/index.js +8 -2
- package/dist/runFlow.js +220 -146
- package/dist/types.d.ts +24 -19
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,649 +1,493 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pathenger
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
9
|
+
```
|
|
10
|
+
npm i -S pathenger
|
|
11
|
+
```
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
import { pathenger } from 'pathenger'
|
|
13
|
+
## Let's Go
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
A step is either:
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
```
|
|
24
|
+
Create one flow per CLI application. Its generic types define shared app state and the results produced by steps.
|
|
43
25
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
type Results = { confirmBuild?: boolean; build?: { bundleKb: number } }
|
|
58
32
|
|
|
59
|
-
|
|
60
|
-
pnpm add pathenger
|
|
33
|
+
const flow = Pathenger.create<Store, Results>()
|
|
61
34
|
```
|
|
62
35
|
|
|
63
|
-
|
|
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
|
-
|
|
38
|
+
## Define output steps with classes
|
|
70
39
|
|
|
71
|
-
|
|
40
|
+
An output step shows a message, may run work, and then moves to its next step.
|
|
72
41
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
56
|
+
class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
|
|
57
|
+
id = 'build'
|
|
58
|
+
message = 'Building…'
|
|
59
|
+
next = BuildDone
|
|
80
60
|
|
|
81
|
-
|
|
61
|
+
task = async () => {
|
|
62
|
+
const bundleKb = await buildProject()
|
|
82
63
|
|
|
83
|
-
|
|
84
|
-
|
|
64
|
+
flow.store.bundleKb = bundleKb
|
|
65
|
+
return { bundleKb }
|
|
66
|
+
}
|
|
85
67
|
|
|
86
|
-
|
|
68
|
+
post = async (result) => {
|
|
69
|
+
console.log(`Built ${result.bundleKb} KB.`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
87
73
|
|
|
88
|
-
|
|
74
|
+
The generic on `OutputStep<Result>` types the values received by `next`, `post`, and `back`.
|
|
89
75
|
|
|
90
76
|
```ts
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
82
|
+
task = async () => {
|
|
83
|
+
return await publishProject()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
98
86
|
```
|
|
99
87
|
|
|
100
|
-
##
|
|
88
|
+
## Define input steps with classes
|
|
101
89
|
|
|
102
|
-
An
|
|
90
|
+
An input step asks one question and produces one answer.
|
|
103
91
|
|
|
104
92
|
```ts
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
96
|
+
id = 'confirmBuild'
|
|
97
|
+
message = 'Build the project?'
|
|
98
|
+
yes = 'Build'
|
|
99
|
+
no = 'Cancel'
|
|
115
100
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
107
|
+
```ts
|
|
108
|
+
class ProjectName extends Pathenger.InputStep<string> {
|
|
109
|
+
type: 'text' = 'text'
|
|
129
110
|
|
|
130
|
-
|
|
111
|
+
id = 'projectName'
|
|
112
|
+
message = 'Project name?'
|
|
113
|
+
placeholder = 'my-app'
|
|
131
114
|
|
|
132
|
-
|
|
133
|
-
|
|
115
|
+
validate = (name) => {
|
|
116
|
+
return name.length >= 2 || 'Use at least two characters.'
|
|
117
|
+
}
|
|
134
118
|
|
|
135
|
-
|
|
119
|
+
next = Build
|
|
120
|
+
}
|
|
121
|
+
```
|
|
136
122
|
|
|
137
|
-
|
|
123
|
+
The generic on `InputStep<Value>` types the value received by `validate`, `post`, `back`, and `next`.
|
|
138
124
|
|
|
139
|
-
|
|
125
|
+
Input classes declare their prompt type with `type`:
|
|
140
126
|
|
|
141
|
-
|
|
127
|
+
- `'text'`
|
|
128
|
+
- `'boolean'`
|
|
129
|
+
- `'select'`
|
|
130
|
+
- `'multiselect'`
|
|
142
131
|
|
|
143
|
-
##
|
|
132
|
+
## Define typed object steps
|
|
144
133
|
|
|
145
|
-
|
|
134
|
+
Use the factories for concise, fully typed object declarations.
|
|
146
135
|
|
|
147
136
|
```ts
|
|
148
|
-
|
|
149
|
-
id: '
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
| --------- | --------------------------------------------------------------------------------------------------- | -------------------------------- |
|
|
205
|
-
| `options` | `SelectOptionT[] \| () => SelectOptionT[]` where `SelectOptionT = { label: string, value: string }` | The choices. Arrow keys + ENTER. |
|
|
164
|
+
## Start a flow
|
|
206
165
|
|
|
207
|
-
|
|
166
|
+
Pass the complete flow definition to `start`.
|
|
208
167
|
|
|
209
168
|
```ts
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
+
A flow can use classes and object steps together:
|
|
225
183
|
|
|
226
184
|
```ts
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
+
Use a step reference when linking steps. This keeps links safe when IDs are renamed.
|
|
245
194
|
|
|
246
195
|
```ts
|
|
247
|
-
|
|
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
|
-
|
|
199
|
+
A string id is also valid when needed:
|
|
258
200
|
|
|
259
|
-
|
|
201
|
+
```ts
|
|
202
|
+
next = 'buildDone'
|
|
203
|
+
```
|
|
260
204
|
|
|
261
|
-
|
|
205
|
+
Use a function to branch based on the completed step’s result:
|
|
262
206
|
|
|
263
207
|
```ts
|
|
264
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
## Branching and why explicit `next` is the point
|
|
211
|
+
Use `flow.exit()` for a successful terminal step:
|
|
280
212
|
|
|
281
|
-
|
|
213
|
+
```ts
|
|
214
|
+
next = flow.exit()
|
|
215
|
+
```
|
|
282
216
|
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
+
## Output-step API
|
|
301
224
|
|
|
302
|
-
|
|
225
|
+
Every output step requires:
|
|
303
226
|
|
|
304
|
-
`
|
|
227
|
+
- `id: string`
|
|
228
|
+
- `message: string | (() => string)`
|
|
229
|
+
- `next: StepReference | string | ExitToken | ((result) => StepReference | string | ExitToken)`
|
|
305
230
|
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
+
`pre` runs before rendering. Use it to fetch data needed by a dynamic message.
|
|
315
241
|
|
|
316
|
-
`
|
|
242
|
+
`task` runs while the message is visible. Use it for the work the message describes.
|
|
317
243
|
|
|
318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
`back` compensates for external side effects if the user navigates back past the step.
|
|
340
249
|
|
|
341
|
-
|
|
250
|
+
## Input-step API
|
|
342
251
|
|
|
343
|
-
|
|
252
|
+
Every input step requires:
|
|
344
253
|
|
|
345
|
-
|
|
254
|
+
- `type`
|
|
255
|
+
- `id: string`
|
|
256
|
+
- `message: string | (() => string)`
|
|
257
|
+
- `next: StepReference | string | ExitToken | ((answer) => StepReference | string | ExitToken)`
|
|
346
258
|
|
|
347
|
-
|
|
259
|
+
All input types may use:
|
|
348
260
|
|
|
349
|
-
|
|
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
|
-
|
|
268
|
+
### Text input
|
|
352
269
|
|
|
353
270
|
```ts
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
282
|
+
Text-only properties:
|
|
364
283
|
|
|
365
|
-
|
|
284
|
+
- `value: string | (() => string)`
|
|
285
|
+
- `placeholder: string | (() => string)`
|
|
286
|
+
- `filter: RegExp | ((value: string) => boolean)`
|
|
366
287
|
|
|
367
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
### Cost
|
|
301
|
+
Boolean-only properties:
|
|
382
302
|
|
|
383
|
-
|
|
303
|
+
- `yes?: string`
|
|
304
|
+
- `no?: string`
|
|
384
305
|
|
|
385
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
## Starting the flow
|
|
321
|
+
### Multiselect input
|
|
408
322
|
|
|
409
323
|
```ts
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
+
- `options: Array<{ label: string; value: string }> | (() => Array<{ label: string; value: string }>)`
|
|
420
336
|
|
|
421
|
-
|
|
337
|
+
## Dynamic values
|
|
422
338
|
|
|
423
|
-
|
|
339
|
+
Display properties may be values or synchronous functions:
|
|
424
340
|
|
|
425
341
|
```ts
|
|
426
|
-
|
|
342
|
+
message = () => `Building ${flow.results.projectName}…`
|
|
343
|
+
placeholder = () => flow.results.projectName ?? 'my-app'
|
|
344
|
+
options = () => flow.store.templates ?? []
|
|
427
345
|
```
|
|
428
346
|
|
|
429
|
-
|
|
347
|
+
Dynamic functions run at render time. They should read already-available values from `flow.results` or `flow.store`.
|
|
430
348
|
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
356
|
+
class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
|
|
357
|
+
id = 'build'
|
|
358
|
+
message = 'Building…'
|
|
359
|
+
next = BuildDone
|
|
438
360
|
|
|
439
|
-
|
|
361
|
+
task = async () => {
|
|
362
|
+
return { bundleKb: 128 }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
440
365
|
|
|
441
|
-
|
|
366
|
+
const bundleKb = flow.results.build?.bundleKb
|
|
367
|
+
```
|
|
442
368
|
|
|
443
|
-
|
|
369
|
+
Use `flow.store` for shared or shaped data, especially data written from helpers:
|
|
444
370
|
|
|
445
371
|
```ts
|
|
446
|
-
|
|
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
|
-
|
|
375
|
+
Rule of thumb:
|
|
468
376
|
|
|
469
|
-
|
|
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
|
-
##
|
|
380
|
+
## Going back
|
|
472
381
|
|
|
473
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
+
Pathenger cannot automatically undo filesystem, network, database, or process effects. Give side-effecting steps a `back` hook:
|
|
496
397
|
|
|
497
398
|
```ts
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
408
|
+
back = async () => {
|
|
409
|
+
await removeClonedTemplate()
|
|
410
|
+
}
|
|
411
|
+
}
|
|
509
412
|
```
|
|
510
413
|
|
|
511
|
-
|
|
414
|
+
Back hooks run in reverse order as steps are unwound.
|
|
512
415
|
|
|
513
|
-
##
|
|
416
|
+
## Preseeded answers and non-interactive runs
|
|
514
417
|
|
|
515
|
-
|
|
418
|
+
Pass answers to skip matching prompts:
|
|
516
419
|
|
|
517
420
|
```ts
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
421
|
+
await flow.start({
|
|
422
|
+
firstStep: Welcome,
|
|
423
|
+
steps: [Welcome, ConfirmBuild, Build, BuildDone],
|
|
424
|
+
answers: { confirmBuild: true }
|
|
425
|
+
})
|
|
426
|
+
```
|
|
521
427
|
|
|
522
|
-
|
|
428
|
+
Preseeded values still run `validate`, `post`, and `next`.
|
|
523
429
|
|
|
524
|
-
|
|
430
|
+
For CI or scripts, disable prompting:
|
|
525
431
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
+
Pathenger releases the terminal, runs the supplied function, then restores the flow terminal state afterward.
|
|
632
480
|
|
|
633
|
-
|
|
481
|
+
## Flow guarantees
|
|
634
482
|
|
|
635
|
-
|
|
483
|
+
Before rendering, Pathenger validates the supplied flow:
|
|
636
484
|
|
|
637
|
-
|
|
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.
|