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 +330 -404
- package/dist/backSignal.d.ts +10 -0
- package/dist/backSignal.js +32 -0
- package/dist/cancelSignal.d.ts +9 -0
- package/dist/cancelSignal.js +15 -0
- package/dist/create.d.ts +65 -0
- package/dist/create.js +300 -0
- package/dist/exitToken.d.ts +9 -0
- package/dist/exitToken.js +10 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +8 -0
- package/dist/levels.d.ts +2 -0
- package/dist/levels.js +14 -0
- package/dist/prompts/confirmPrompt.d.ts +12 -0
- package/dist/prompts/confirmPrompt.js +73 -0
- package/dist/prompts/multiselectPrompt.d.ts +11 -0
- package/dist/prompts/multiselectPrompt.js +105 -0
- package/dist/prompts/promptStyles.d.ts +5 -0
- package/dist/prompts/promptStyles.js +15 -0
- package/dist/prompts/runValidation.d.ts +2 -0
- package/dist/prompts/runValidation.js +23 -0
- package/dist/prompts/selectPrompt.d.ts +11 -0
- package/dist/prompts/selectPrompt.js +77 -0
- package/dist/prompts/textPrompt.d.ts +12 -0
- package/dist/prompts/textPrompt.js +86 -0
- package/dist/resolveDynamicValue.d.ts +2 -0
- package/dist/resolveDynamicValue.js +10 -0
- package/dist/runFlow.d.ts +19 -0
- package/dist/runFlow.js +451 -0
- package/dist/spinner.d.ts +5 -0
- package/dist/spinner.js +22 -0
- package/dist/terminal.d.ts +14 -0
- package/dist/terminal.js +84 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +1 -0
- package/dist/validateGraph.d.ts +2 -0
- package/dist/validateGraph.js +51 -0
- package/package.json +30 -4
package/README.md
CHANGED
|
@@ -1,567 +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
|
-
|
|
5
|
+
Common prompt libraries hand you widgets and wish you luck with the orchestration. Pathenger does the opposite.
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
9
|
+
```
|
|
10
|
+
npm i -S pathenger
|
|
11
|
+
```
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
id: 'welcome',
|
|
14
|
-
message: 'Welcome aboard.',
|
|
15
|
-
duration: 1000,
|
|
16
|
-
next: 'name'
|
|
17
|
-
})
|
|
13
|
+
## Let's Go
|
|
18
14
|
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
```
|
|
20
|
+
Both forms work together in the same flow.
|
|
36
21
|
|
|
37
|
-
|
|
22
|
+
## Create a flow
|
|
38
23
|
|
|
39
|
-
|
|
24
|
+
Create one flow per CLI application. Its generic types define shared app state and the results produced by steps.
|
|
40
25
|
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
```
|
|
26
|
+
```ts
|
|
27
|
+
import { Pathenger } from 'pathenger'
|
|
44
28
|
|
|
45
|
-
|
|
29
|
+
type Store = { bundleKb?: number; projectDirectory?: string }
|
|
46
30
|
|
|
47
|
-
|
|
31
|
+
type Results = { confirmBuild?: boolean; build?: { bundleKb: number } }
|
|
48
32
|
|
|
49
|
-
|
|
33
|
+
const flow = Pathenger.create<Store, Results>()
|
|
34
|
+
```
|
|
50
35
|
|
|
51
|
-
|
|
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
|
-
|
|
38
|
+
## Define output steps with classes
|
|
54
39
|
|
|
55
|
-
|
|
40
|
+
An output step shows a message, may run work, and then moves to its next step.
|
|
56
41
|
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
56
|
+
class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
|
|
57
|
+
id = 'build'
|
|
58
|
+
message = 'Building…'
|
|
59
|
+
next = BuildDone
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
templates?: Template[]
|
|
66
|
-
}
|
|
61
|
+
task = async () => {
|
|
62
|
+
const bundleKb = await buildProject()
|
|
67
63
|
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
flow.store.bundleKb = bundleKb
|
|
65
|
+
return { bundleKb }
|
|
66
|
+
}
|
|
70
67
|
|
|
71
|
-
|
|
68
|
+
post = async (result) => {
|
|
69
|
+
console.log(`Built ${result.bundleKb} KB.`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
The generic on `OutputStep<Result>` types the values received by `next`, `post`, and `back`.
|
|
74
75
|
|
|
75
76
|
```ts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
+
task = async () => {
|
|
83
|
+
return await publishProject()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
83
86
|
```
|
|
84
87
|
|
|
85
|
-
##
|
|
88
|
+
## Define input steps with classes
|
|
86
89
|
|
|
87
|
-
An
|
|
90
|
+
An input step asks one question and produces one answer.
|
|
88
91
|
|
|
89
92
|
```ts
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
+
id = 'confirmBuild'
|
|
97
|
+
message = 'Build the project?'
|
|
98
|
+
yes = 'Build'
|
|
99
|
+
no = 'Cancel'
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
107
|
+
```ts
|
|
108
|
+
class ProjectName extends Pathenger.InputStep<string> {
|
|
109
|
+
type: 'text' = 'text'
|
|
113
110
|
|
|
114
|
-
|
|
111
|
+
id = 'projectName'
|
|
112
|
+
message = 'Project name?'
|
|
113
|
+
placeholder = 'my-app'
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
validate = (name) => {
|
|
116
|
+
return name.length >= 2 || 'Use at least two characters.'
|
|
117
|
+
}
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
next = Build
|
|
120
|
+
}
|
|
121
|
+
```
|
|
120
122
|
|
|
121
|
-
|
|
123
|
+
The generic on `InputStep<Value>` types the value received by `validate`, `post`, `back`, and `next`.
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
Input classes declare their prompt type with `type`:
|
|
124
126
|
|
|
125
|
-
|
|
127
|
+
- `'text'`
|
|
128
|
+
- `'boolean'`
|
|
129
|
+
- `'select'`
|
|
130
|
+
- `'multiselect'`
|
|
126
131
|
|
|
127
|
-
##
|
|
132
|
+
## Define typed object steps
|
|
128
133
|
|
|
129
|
-
|
|
134
|
+
Use the factories for concise, fully typed object declarations.
|
|
130
135
|
|
|
131
136
|
```ts
|
|
132
|
-
|
|
133
|
-
id: '
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
145
|
+
```ts
|
|
146
|
+
const Deployment = Pathenger.createInputStep({
|
|
147
|
+
type: 'select',
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
156
|
+
next: (deployment) => {
|
|
157
|
+
return deployment === 'cloud' ? Publish : ConfigureServer
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
161
|
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
+
Pass the complete flow definition to `start`.
|
|
171
167
|
|
|
172
168
|
```ts
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
+
A flow can use classes and object steps together:
|
|
190
183
|
|
|
191
184
|
```ts
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
191
|
+
## Link steps
|
|
205
192
|
|
|
206
|
-
|
|
193
|
+
Use a step reference when linking steps. This keeps links safe when IDs are renamed.
|
|
207
194
|
|
|
208
195
|
```ts
|
|
209
|
-
|
|
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
|
-
|
|
199
|
+
A string id is also valid when needed:
|
|
220
200
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
| `no` | `string` | Label for the negative. Defaults to `'No'`. |
|
|
201
|
+
```ts
|
|
202
|
+
next = 'buildDone'
|
|
203
|
+
```
|
|
225
204
|
|
|
226
|
-
|
|
205
|
+
Use a function to branch based on the completed step’s result:
|
|
227
206
|
|
|
228
207
|
```ts
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
+
```ts
|
|
214
|
+
next = flow.exit()
|
|
215
|
+
```
|
|
244
216
|
|
|
245
|
-
|
|
217
|
+
Use `flow.exit.error()` for a failed terminal step:
|
|
246
218
|
|
|
247
219
|
```ts
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
246
|
+
`post` runs after the task settles.
|
|
266
247
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
message: '
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
+
Text-only properties:
|
|
285
283
|
|
|
286
|
-
|
|
284
|
+
- `value: string | (() => string)`
|
|
285
|
+
- `placeholder: string | (() => string)`
|
|
286
|
+
- `filter: RegExp | ((value: string) => boolean)`
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
### Boolean input
|
|
289
289
|
|
|
290
290
|
```ts
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
301
|
+
Boolean-only properties:
|
|
297
302
|
|
|
298
|
-
|
|
303
|
+
- `yes?: string`
|
|
304
|
+
- `no?: string`
|
|
299
305
|
|
|
300
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
## Starting the flow — and what gets validated
|
|
321
|
+
### Multiselect input
|
|
321
322
|
|
|
322
323
|
```ts
|
|
323
|
-
|
|
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
|
-
|
|
333
|
+
Select and multiselect properties:
|
|
327
334
|
|
|
328
|
-
-
|
|
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
|
-
|
|
337
|
+
## Dynamic values
|
|
333
338
|
|
|
334
|
-
|
|
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
|
-
|
|
342
|
+
message = () => `Building ${flow.results.projectName}…`
|
|
343
|
+
placeholder = () => flow.results.projectName ?? 'my-app'
|
|
344
|
+
options = () => flow.store.templates ?? []
|
|
340
345
|
```
|
|
341
346
|
|
|
342
|
-
|
|
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
|
-
|
|
351
|
+
## Shared state and results
|
|
352
|
+
|
|
353
|
+
A task’s return value becomes that step’s result:
|
|
345
354
|
|
|
346
355
|
```ts
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
356
|
+
class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
|
|
357
|
+
id = 'build'
|
|
358
|
+
message = 'Building…'
|
|
359
|
+
next = BuildDone
|
|
351
360
|
|
|
352
|
-
|
|
361
|
+
task = async () => {
|
|
362
|
+
return { bundleKb: 128 }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
353
365
|
|
|
354
|
-
|
|
366
|
+
const bundleKb = flow.results.build?.bundleKb
|
|
367
|
+
```
|
|
355
368
|
|
|
356
|
-
|
|
369
|
+
Use `flow.store` for shared or shaped data, especially data written from helpers:
|
|
357
370
|
|
|
358
371
|
```ts
|
|
359
|
-
|
|
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
|
-
|
|
375
|
+
Rule of thumb:
|
|
381
376
|
|
|
382
|
-
|
|
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
|
-
##
|
|
380
|
+
## Going back
|
|
385
381
|
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
394
|
+
When going back, Pathenger restores the `flow.results` and `flow.store` snapshot from when the target input was first rendered.
|
|
405
395
|
|
|
406
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
399
|
+
class CloneTemplate extends Pathenger.OutputStep {
|
|
400
|
+
id = 'cloneTemplate'
|
|
401
|
+
message = 'Cloning template…'
|
|
402
|
+
next = Build
|
|
415
403
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
deployment: 'on-prem' | 'cloud'
|
|
420
|
-
shouldInstall: boolean
|
|
421
|
-
features: string[]
|
|
422
|
-
}
|
|
404
|
+
task = async () => {
|
|
405
|
+
return await cloneTemplate()
|
|
406
|
+
}
|
|
423
407
|
|
|
424
|
-
|
|
408
|
+
back = async () => {
|
|
409
|
+
await removeClonedTemplate()
|
|
410
|
+
}
|
|
411
|
+
}
|
|
425
412
|
```
|
|
426
413
|
|
|
427
|
-
|
|
414
|
+
Back hooks run in reverse order as steps are unwound.
|
|
428
415
|
|
|
429
|
-
##
|
|
416
|
+
## Preseeded answers and non-interactive runs
|
|
430
417
|
|
|
431
|
-
|
|
418
|
+
Pass answers to skip matching prompts:
|
|
432
419
|
|
|
433
420
|
```ts
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
421
|
+
await flow.start({
|
|
422
|
+
firstStep: Welcome,
|
|
423
|
+
steps: [Welcome, ConfirmBuild, Build, BuildDone],
|
|
424
|
+
answers: { confirmBuild: true }
|
|
425
|
+
})
|
|
426
|
+
```
|
|
437
427
|
|
|
438
|
-
|
|
439
|
-
didCloneSucceed?: boolean
|
|
440
|
-
}
|
|
428
|
+
Preseeded values still run `validate`, `post`, and `next`.
|
|
441
429
|
|
|
442
|
-
|
|
430
|
+
For CI or scripts, disable prompting:
|
|
443
431
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
+
Pathenger releases the terminal, runs the supplied function, then restores the flow terminal state afterward.
|
|
550
480
|
|
|
551
|
-
|
|
481
|
+
## Flow guarantees
|
|
552
482
|
|
|
553
|
-
|
|
483
|
+
Before rendering, Pathenger validates the supplied flow:
|
|
554
484
|
|
|
555
|
-
|
|
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.
|