pathenger
v1.1.0
Published
Step-by-step flows for command-line apps. Explicit, branching, testable.
Maintainers
Readme
Pathenger
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.
Common prompt libraries hand you widgets and wish you luck with the orchestration. Pathenger does the opposite.
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.
npm i -S pathengerLet's Go
A step is either:
- A class extending
Pathenger.OutputSteporPathenger.InputStep - A typed object created with
Pathenger.createOutputStep()or one of thecreate*InputStep()factories
Both forms work together in the same flow.
Create a flow
Create one flow per CLI application. Its generic types define shared app state and the results produced by steps.
import { Pathenger } from 'pathenger'
type StoreT = { bundleKb: number; projectDirectory: string }
type ResultsT = { confirmBuild?: boolean; build?: { bundleKb: number } }
const flow = Pathenger.create<StoreT, ResultsT>({
store: { bundleKb: 0, projectDirectory: 'dist' }
})flow.store is app-owned shared state. flow.results is engine-owned and receives each completed step’s result by step id.
Seed the store in create so its fields can be required. Every read then gets a real value with no fallback:
const outputPath = flow.store.projectDirectoryDefine output steps with classes
An output step shows a message, may run work, and then moves to its next step.
class BuildDone extends Pathenger.SuccessOutputStep {
id = 'buildDone'
next = flow.exit()
message = () => {
const bundleSize = flow.store.bundleKb ?? 0
return `Build complete. Output written to dist/ (${bundleSize} KB).`
}
}Pathenger.OutputStep renders at the info level. To render at another level, extend the matching base class instead of setting level yourself: SuccessOutputStep, WarningOutputStep, ErrorOutputStep, DebugOutputStep.
class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
id = 'build'
message = 'Building…'
next = BuildDone
task = async () => {
const bundleKb = await buildProject()
flow.store.bundleKb = bundleKb
return { bundleKb }
}
post = async (result) => {
console.log(`Built ${result.bundleKb} KB.`)
}
}The generic on OutputStep<Result> types the values received by next, post, and back.
class Publish extends Pathenger.OutputStep<{ url: string }> {
id = 'publish'
message = 'Publishing…'
next = (result) => (result.url ? Published : flow.exit.error('Publish failed.'))
task = async () => {
return await publishProject()
}
}Define input steps with classes
An input step asks one question and produces one answer. Extend the class that matches the prompt:
Pathenger.TextInputStepanswers with astringPathenger.BooleanInputStepanswers with abooleanPathenger.SelectInputStepanswers with astringPathenger.MultiselectInputStepanswers with astring[]
class ConfirmBuild extends Pathenger.BooleanInputStep {
id = 'confirmBuild'
message = 'Build the project?'
yes = 'Build'
no = 'Cancel'
next = (shouldBuild) => {
return shouldBuild ? Build : flow.exit()
}
}class ProjectName extends Pathenger.TextInputStep {
id = 'projectName'
message = 'Project name?'
placeholder = 'my-app'
validate = (name) => {
return name.length >= 2 || 'Use at least two characters.'
}
next = Build
}Each subclass carries only the properties that belong to its prompt, and its answer type flows into validate, post, back, and next automatically.
Define typed object steps
Use the factories for concise, fully typed object declarations.
const Welcome = Pathenger.createOutputStep({
id: 'welcome',
message: 'Welcome to Pathenger.',
minimumDuration: 500,
next: ConfirmBuild
})const Deployment = Pathenger.createSelectInputStep({
id: 'deployment',
message: 'Where should this run?',
options: [
{ label: 'Cloud', value: 'cloud' },
{ label: 'On-premise', value: 'on-prem' }
],
next: (deployment) => {
return deployment === 'cloud' ? Publish : ConfigureServer
}
})These factories 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.
createOutputStep()createTextInputStep()createSelectInputStep()createMultiselectInputStep()createBooleanInputStep()
Each input factory supplies its own type, so you never write the discriminant. createInputStep() also exists for the union form — it accepts any input step but requires type on the object.
Start a flow
Pass the complete flow definition to start. The flow begins at the first entry in steps.
await flow.start({
steps: [Welcome, ConfirmBuild, Build, BuildDone],
onCancel: () => {
console.log('\nCancelled. Nothing was changed.')
}
})Pathenger instantiates every supplied step class once, uses factory-created objects directly, indexes the resulting steps by id, validates the graph, and begins at the first step.
Pass firstStep to start somewhere other than the head of the list:
await flow.start({
firstStep: Build,
steps: [Welcome, ConfirmBuild, Build, BuildDone]
})A flow can use classes and object steps together:
await flow.start({
steps: [Welcome, Deployment, ConfirmBuild, Build, BuildDone]
})Link steps
Use a step reference when linking steps. This keeps links safe when IDs are renamed.
next = BuildDoneA string id is also valid when needed:
next = 'buildDone'Use a function to branch based on the completed step’s result:
next = (answer) => (answer ? Build : flow.exit())Use flow.exit() for a successful terminal step:
next = flow.exit()Use flow.exit.error() for a failed terminal step:
next = flow.exit.error('Build failed. Check the compiler output.')Object steps evaluate next immediately, so a direct reference requires the target to be declared first. To declare steps top-down, wrap the reference in a function — it resolves when the step completes:
const Welcome = Pathenger.createOutputStep({
id: 'welcome',
message: 'Welcome.',
next: () => ConfirmBuild
})Class steps read next at instantiation, so they can always be declared top-down.
Output-step API
Every output step requires:
id: stringmessage: string | (() => string)next: StepReference | string | ExitToken | ((result) => StepReference | string | ExitToken)
Optional output properties:
level: 'info' | 'success' | 'warning' | 'error' | 'debug'— object steps set this directly; class steps extendSuccessOutputStep,WarningOutputStep,ErrorOutputStep, orDebugOutputStepinsteadminimumDuration: numberpre: () => void | Promise<void>task: (context: OutputTaskContextT) => Result | Promise<Result>post: (result: Result) => void | Promise<void>back: (result: Result | undefined) => void | Promise<void>
pre runs before rendering. Use it to fetch data needed by a dynamic message.
task runs while the message is visible. Use it for the work the message describes.
minimumDuration is the minimum time an output step remains visible. A task may keep the step on screen longer, but never shorter.
post runs after the task settles.
back compensates for external side effects if the user navigates back past the step.
Report progress from a task
A task receives a context with update and signal.
const InstallDependencies = Pathenger.createOutputStep({
id: 'installDependencies',
message: 'Installing dependencies…',
next: () => BuildDone,
task: async ({ update, signal }) => {
return await installDependencies({
signal,
onProgress: ({ current, total, packageName }) => {
update({
detail: packageName,
progress: { current, total, unit: 'packages' }
})
}
})
}
})update revises what the renderer shows while the task runs — the message, a detail line, or numeric progress. It costs nothing under non-interactive renderers, so task code never checks the environment.
signal is an AbortSignal for cooperative cancellation. Pass it to fetch, spawned processes, or anything else that accepts one.
Input-step API
Every input step requires:
typeid: stringmessage: string | (() => string)next: StepReference | string | ExitToken | ((answer) => StepReference | string | ExitToken)
All input types may use:
tip: string | (() => string)pre: () => void | Promise<void>validate: (answer) => true | string | Promise<true | string>post: (answer) => void | Promise<void>canGoBack: booleanback: (answer) => void | Promise<void>keys: Record<string, StepReference | string | ExitToken | (() => ...)>
Text input
const Directory = Pathenger.createTextInputStep({
id: 'directory',
message: 'Output directory?',
value: () => flow.store.projectDirectory,
placeholder: 'dist',
filter: /^[a-z0-9-]+$/i,
next: Build
})Text-only properties:
value: string | (() => string)placeholder: string | (() => string)filter: RegExp | ((value: string) => boolean)
Boolean input
const Overwrite = Pathenger.createBooleanInputStep({
id: 'overwrite',
message: 'Overwrite the existing directory?',
yes: 'Overwrite',
no: 'Cancel',
next: (shouldOverwrite) => (shouldOverwrite ? Build : flow.exit())
})Boolean-only properties:
yes?: stringno?: string
Select input
const Template = Pathenger.createSelectInputStep({
id: 'template',
message: 'Choose a template.',
options: [
{ label: 'Starter', value: 'starter' },
{ label: 'Library', value: 'library' }
],
next: Build
})Multiselect input
const Features = Pathenger.createMultiselectInputStep({
id: 'features',
message: 'Choose features.',
options: () => availableFeatures(),
next: Build
})Select and multiselect properties:
options: Array<{ label: string; value: string }> | (() => Array<{ label: string; value: string }>)
Dynamic values
Display properties may be values or synchronous functions:
message = () => `Building ${flow.results.projectName}…`
placeholder = () => flow.results.projectName ?? 'my-app'
options = () => flow.store.templates ?? []Dynamic functions run at render time. They should read already-available values from flow.results or flow.store.
Do not fetch or perform async work in a dynamic function. Use pre instead.
Shared state and results
A task’s return value becomes that step’s result:
class Build extends Pathenger.OutputStep<{ bundleKb: number }> {
id = 'build'
message = 'Building…'
next = BuildDone
task = async () => {
return { bundleKb: 128 }
}
}
const bundleKb = flow.results.build?.bundleKbUse flow.store for shared or shaped data, especially data written from helpers:
flow.store.projectDirectory = 'dist'Rule of thumb:
- One step produces one value for later flow decisions: return it from the step.
- Multiple steps or helpers need the value: write it to
flow.store.
Bind keys to other steps
next routes on an answer. keys routes on a keypress instead, which is how a
prompt offers a way out that is not one of its options — a secondary menu, a
help screen, an early exit.
const PickScript = Pathenger.createSelectInputStep({
id: 'pickScript',
message: 'Which script?',
tip: 'tab to browse packages',
options: () => listScripts(),
keys: { tab: BrowsePackages },
next: RunScript
})Pressing a bound key abandons the prompt. No answer is recorded, post never
runs, next is never consulted, and nothing lands in flow.results for that
step. The flow simply continues from the step the key names.
The step is still on the history stack, so a destination with canGoBack can
escape straight back to the prompt that jumped:
const BrowsePackages = Pathenger.createSelectInputStep({
id: 'browsePackages',
message: 'Which package?',
canGoBack: true,
options: () => listPackages(),
next: PickScript
})Keys are the same specs backKey accepts — a key name with optional modifiers,
joined by +:
keys: {
tab: BrowsePackages,
'ctrl+p': CommandPalette,
'?': Help
}A binding can name a step reference, an id string, or an exit token, and — like
next — may be wrapped in a thunk so an object step can reference a step
declared below it:
keys: { tab: () => BrowsePackages }Static bindings are validated at registration, and count as edges when pathenger
checks which steps are reachable. When a step both opts into canGoBack and
binds the back key itself, going back wins.
Going back
Set canGoBack on an input step to let the user return to a prior input and edit its answer.
const ConfirmBuild = Pathenger.createBooleanInputStep({
id: 'confirmBuild',
message: 'Build now?',
canGoBack: true,
next: Build
})When going back, Pathenger restores the flow.results and flow.store snapshot from when the target input was first rendered.
Pathenger cannot automatically undo filesystem, network, database, or process effects. Give side-effecting steps a back hook:
class CloneTemplate extends Pathenger.OutputStep {
id = 'cloneTemplate'
message = 'Cloning template…'
next = Build
task = async () => {
return await cloneTemplate()
}
back = async () => {
await removeClonedTemplate()
}
}Back hooks run in reverse order as steps are unwound.
Preseeded answers and non-interactive runs
Pass answers to skip matching prompts:
await flow.start({
firstStep: Welcome,
steps: [Welcome, ConfirmBuild, Build, BuildDone],
answers: { confirmBuild: true }
})Preseeded values still run validate, post, and next.
For CI or scripts, disable prompting:
await flow.start({
firstStep: Welcome,
steps: [Welcome, ConfirmBuild, Build, BuildDone],
answers: { confirmBuild: true },
interactive: false
})An unseeded input in a non-interactive run is an error.
Renderers
Pathenger separates what a flow does from how it is shown. The engine emits events; a renderer draws them.
The default is chosen from the environment: animated spinners and committed status lines on an interactive terminal, plain lines in CI and pipes. Override it by name:
await flow.start({
firstStep: Welcome,
steps: [Welcome, Build, BuildDone],
renderer: 'json'
})Built-in renderers:
'interactive'— spinners while tasks run, committed status lines after'plain'— one line per step, no animation'json'— one structured record per step on stdout'silent'— nothing
Output streams are fixed: human narration goes to stderr, machine output goes to stdout. Piping a flow's stdout always yields clean data.
nex create --json > result.json # progress still visible, file stays cleanA custom renderer is an object that handles render events:
await flow.start({
firstStep: Welcome,
steps: [Welcome, Build],
renderer: myRenderer
})Testing
Use the same explicit step inventory in headless tests.
const run = await flow.test({
firstStep: Welcome,
steps: [Welcome, ConfirmBuild, Build, BuildDone],
answers: { confirmBuild: true }
})
expect(run.visited).toEqual(['welcome', 'confirmBuild', 'build', 'buildDone'])
expect(run.results.build).toEqual({ bundleKb: expect.any(Number) })
expect(run.exit.code).toBe(0)Tests do not render the terminal, prompt for input, or wait for display durations.
run.events is the exact render sequence the flow produced — every step start, progress update, and settle — for asserting on presentation without a terminal:
const settled = run.events.filter((event) => event.type === 'stepSettled')
expect(settled.map((event) => event.execution.status)).toEqual(['success', 'success'])Release the terminal
Use flow.suspend() when a child process needs direct terminal access.
class CloneRepository extends Pathenger.OutputStep {
id = 'cloneRepository'
message = 'Cloning repository…'
next = Build
task = async () => {
return await flow.suspend(() => spawnGitCloneWithInheritedStdio())
}
}Pathenger releases the terminal, runs the supplied function, then restores the flow terminal state afterward.
Flow guarantees
Before rendering, Pathenger validates the supplied flow:
- Every step id is unique.
firstStepexists insteps.- Static
nextids resolve to registered steps. - Unreachable steps produce a warning.
- Dynamic
nexttargets are validated when they resolve. - Classes are instantiated once per flow run.
- Factory-created objects are used as declared.
The flow is explicit, typed, testable, and contained in its start() or test() call.
