npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pathenger

v1.1.0

Published

Step-by-step flows for command-line apps. Explicit, branching, testable.

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 pathenger

Let's Go

A step is either:

  • A class extending Pathenger.OutputStep or Pathenger.InputStep
  • A typed object created with Pathenger.createOutputStep() or one of the create*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.projectDirectory

Define 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.TextInputStep answers with a string
  • Pathenger.BooleanInputStep answers with a boolean
  • Pathenger.SelectInputStep answers with a string
  • Pathenger.MultiselectInputStep answers with a string[]
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 = BuildDone

A 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: string
  • message: 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 extend SuccessOutputStep, WarningOutputStep, ErrorOutputStep, or DebugOutputStep instead
  • minimumDuration: number
  • pre: () => 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:

  • type
  • id: string
  • message: 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: boolean
  • back: (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?: string
  • no?: 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?.bundleKb

Use 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 clean

A 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.
  • firstStep exists in steps.
  • Static next ids resolve to registered steps.
  • Unreachable steps produce a warning.
  • Dynamic next targets 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.