pathenger 0.1.0 → 0.9.0

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