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

@tasteee/dooz

v1.1.1

Published

script maps

Readme

dooz

dooz lets you template, validate, augment, enhance, route and run your complex scripts.

npm add -g dooz

The basics explain themselves.

Drop a dooz.yaml in your repo with some commands. Run those commands in the terminal, prefixed with dooz.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run test {{rest}}

 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run build {{rest}}

That's it. Now run them:

dooz test cool-package
dooz test other-package

dooz build cool-package
dooz build other-package

Run them with extra flags or arguments:

dooz test cool-package --watch
dooz build other-package --watch --env production

dooz passes everything after the matched tokens through to your command, so this:

dooz test cool-package --watch

renders this:

pnpm --filter @great-name/cool-package run test --watch

<name> captures a single token. [...rest] captures everything after it as an array, passed through verbatim.

No shell string interpolation happens. dooz builds the final argv directly, so quoting edge cases just aren't a thing.


dooz is a small command router.

A dooz command starts with a match pattern.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run test {{rest}}

When you run:

dooz test ui --watch

dooz sees:

test ui --watch

Then it matches that input against your patterns.

test       -> literal token
ui         -> captured as name
--watch    -> captured inside rest

Then it renders the run command.

pnpm --filter @great-name/ui run test --watch

That is the core idea.

Your YAML defines little routes for terminal commands.


The pattern syntax is simple.

Patterns are token-based, not regex.

| Token | Example | Captures | | ---------------- | ----------- | -------------------------------- | | literal | test | nothing, must match exactly | | single capture | <name> | one token as a string | | variadic capture | [...rest] | all remaining tokens as string[] |

[...rest] must be the last token.

It is always optional. If no tokens are left, it becomes an empty array.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run test {{rest}}

These all match:

dooz test ui
dooz test ui --watch
dooz test ui --watch --coverage

And they render like this:

pnpm --filter @great-name/ui run test
pnpm --filter @great-name/ui run test --watch
pnpm --filter @great-name/ui run test --watch --coverage

The most specific command wins.

When multiple patterns match your input, dooz picks the most specific one.

Literals beat captures. Captures beat variadics.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run test {{rest}}

 - match: test all [...rest]
   run: pnpm --filter @great-name/* run test {{rest}}

 - match: test all components [...rest]
   run: pnpm --filter @great-name/components-* run test {{rest}}

Now these commands all do different things:

dooz test ui --watch
dooz test all --watch
dooz test all components --watch

Because dooz sees the difference:

test ui --watch              -> test <name> [...rest]
test all --watch             -> test all [...rest]
test all components --watch  -> test all components [...rest]

Essentially, the more specific your pattern is, the higher its priority.

So:

test all components

beats:

test all

which beats:

test <name>

This gives you a lot of flexibility in your command design. You can have a general catch-all pattern with captures, then add more specific patterns for common cases or special handling.


Templates use the captured values.

Captured values from your match pattern are available in run.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run test {{rest}}

{{name}} becomes the package name.

{{rest}} becomes all extra arguments.

dooz test ui --watch --coverage

renders:

pnpm --filter @great-name/ui run test --watch --coverage

The template does not run through a shell. It becomes an argv array.

That means this:

run: pnpm --filter @great-name/{{name}} run test {{rest}}

is treated like this:

;['pnpm', '--filter', '@great-name/ui', 'run', 'test', '--watch', '--coverage']

Not like this:

"pnpm --filter @great-name/ui run test --watch --coverage"

Commands can set cwd and env.

Sometimes a command needs to run somewhere else or with specific environment variables.

commands:
 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run build {{rest}}
   cwd: ./apps
   env:
    NODE_ENV: production

cwd can be relative or absolute.

Relative paths are resolved from the directory where your dooz.yaml was found.


YAML can stay boring.

For a lot of repos, this is enough:

commands:
 - match: dev
   run: pnpm dev

 - match: <script> <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run =<script> {{rest}}

Short commands. Clear patterns. No JavaScript required.

But sometimes YAML needs a little help.

Maybe you need to check if a package exists.

Maybe you need to choose between pnpm run and pnpm exec.

Maybe you need to transform Cool Package into cool-package.

That is where dooz.ts and dooz.js come in.


Commands can be extended with TS/JS.

dooz will search for and load a dooz.ts or dooz.js setup file before loading your dooz.yaml.

In this setup file, you can define:

  • resolvers
  • validators
  • filters

Then you can reference them by name in your YAML.

This keeps your dooz.yaml clean and focused on command definitions, while still giving you the full power of a programming language when you need it.

dooz.yaml      -> command patterns and templates
dooz.ts        -> reusable logic that augments the YAML

Think of it like this:

commands:
 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name}} ((kind)) build {{rest}}
   use: [packageKind]
   check: [packageExists]

The YAML still owns the command.

The TS/JS file just teaches dooz what packageKind and packageExists mean.


Execution order

When you run a command, dooz does this:

1. find the config
2. load extensions
3. match the command pattern
4. capture arguments
5. run resolvers
6. run validators
7. render the command template
8. execute the final argv

Resolvers run before validators.

Validators run before execution.

Filters run while the template is being rendered.


Resolvers inject data into your scripts.

Sometimes the value you need in run is not something the caller types.

It has to be derived.

That is what resolvers do.

A resolver is a function that computes values based on the command's arguments and/or other context. It runs before the command executes, and its output can be used in the run template.

import { define } from 'dooz'

const packageKind = define.resolver({
	name: 'packageKind',
	provides: ['kind'],

	resolve: async (context) => {
		const packageName = context.args.name

		const isCorePackage = packageName === 'core'
		const kind = isCorePackage ? 'run' : 'exec'

		return { kind }
	}
})

export default define.extensions({
	resolvers: [packageKind]
})

Then reference it from dooz.yaml:

commands:
 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name}} ((kind)) build {{rest}}
   use: [packageKind]

Now this:

dooz build core

can render:

pnpm --filter @great-name/core run build

And this:

dooz build docs

can render:

pnpm --filter @great-name/docs exec build

The caller did not need to know which one to use. The resolver handled it.


Resolver values use ((value)).

Captured pattern values use {{value}}.

Resolver values use ((value)).

commands:
 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name}} ((kind)) build {{rest}}
   use: [packageKind]

That means this part came from the command line:

{{name}}
{{rest}}

And this part came from a resolver:

((kind))

The split is intentional. It keeps caller input and computed values visually separate.


Resolvers can depend on other resolvers.

Resolvers can declare what they provide.

const packageKind = define.resolver({
	name: 'packageKind',
	provides: ['kind'],

	resolve: async (context) => {
		const packageName = context.args.name
		const kind = packageName === 'core' ? 'run' : 'exec'

		return { kind }
	}
})

If another resolver needs that value, it can declare needs.

const commandLabel = define.resolver({
	name: 'commandLabel',
	needs: ['kind'],
	provides: ['label'],

	resolve: async (context) => {
		const kind = context.vars.kind
		const label = kind === 'run' ? 'workspace script' : 'package binary'

		return { label }
	}
})

dooz uses needs and provides to run resolvers in the right order.

It also uses them to guard against surprises.

A resolver must return the exact keys it says it provides. No hidden keys. No accidental collisions.


Validators guard before execution.

Validators run after resolvers, but before the command executes.

A validator can return:

| Return value | Meaning | | ------------ | ------------------------------ | | true | validation passed | | false | validation failed | | string | validation failed with message |

import { define } from 'dooz'
import fs from 'node:fs/promises'
import path from 'node:path'

const packageExists = define.validator({
	name: 'packageExists',

	check: async (context) => {
		const packageName = context.args.name
		const packageNames = await getWorkspacePackages()

		const hasPackage = packageNames.has(packageName)

		if (!hasPackage) {
			return `"${packageName}" is not a package in this workspace`
		}

		return true
	}
})

const dependenciesInstalled = define.validator({
	name: 'dependenciesInstalled',

	check: async (context) => {
		const nodeModulesPath = path.join(context.cwd, 'node_modules')

		try {
			await fs.access(nodeModulesPath)
			return true
		} catch {
			return 'dependencies are not installed'
		}
	}
})

export default define.extensions({
	validators: [packageExists, dependenciesInstalled]
})

Then reference them in YAML:

commands:
 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run build {{rest}}
   check: [packageExists, dependenciesInstalled]

Now this:

dooz build fake-package

can fail before anything runs:

"fake-package" is not a package in this workspace

Validators are useful for checks like:

  • does this package exist?
  • are dependencies installed?
  • is the current branch allowed?
  • is this command allowed in CI?
  • does this environment exist?
  • did the user pass a valid target?

Filters transform values in templates.

When you're using a string template and need to transform a value before it is placed into the argv, use filters.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name | kebab}} run test {{rest}}

With this command:

dooz test "Cool Package" --watch

{{name | kebab}} becomes:

cool-package

And the final command becomes:

pnpm --filter @great-name/cool-package run test --watch

Filters can also be used with arrays.

commands:
 - match: echo [...words]
   run: echo {{words | join(' ')}}

Now this:

dooz echo hello there friend

renders:

echo hello there friend

Built-in filters

dooz includes a few filters by default.

run: echo {{name | lower}}
run: echo {{name | upper}}
run: echo {{rest | join(' ')}}
run: echo {{value | json}}

| Filter | What it does | | ----------- | ---------------------------------------------- | | lower | lowercases a value | | upper | uppercases a value | | join(sep) | joins a string array; default separator is , | | json | runs JSON.stringify |

Filters chain left to right.

commands:
 - match: echo [...words]
   run: echo {{words | join(' ') | upper}}
dooz echo hello there

renders:

echo HELLO THERE

Custom filters live in dooz.ts or dooz.js.

import { define } from 'dooz'

const kebab = define.filter({
	name: 'kebab',

	apply: (value) => {
		return String(value).trim().toLowerCase().replaceAll(' ', '-')
	}
})

export default define.extensions({
	filters: [kebab]
})

Then reference it from YAML:

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name | kebab}} run test {{rest}}

Custom filters are best for small template transformations.

Use a resolver when you need to compute new data.

Use a validator when you need to block execution.

Use a filter when you just need to reshape a value while rendering.


A more complete YAML example

commands:
 - match: dev
   run: pnpm dev

 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name | kebab}} run test {{rest}}
   check: [packageExists]

 - match: test all [...rest]
   run: pnpm --filter @great-name/* run test {{rest}}
   check: [dependenciesInstalled]

 - match: build <name> [...rest]
   run: pnpm --filter @great-name/{{name | kebab}} ((kind)) build {{rest}}
   use: [packageKind]
   check: [packageExists, dependenciesInstalled]
   env:
    NODE_ENV: production

And the matching dooz.ts:

import { define } from 'dooz'
import fs from 'node:fs/promises'
import path from 'node:path'

const kebab = define.filter({
	name: 'kebab',

	apply: (value) => {
		return String(value).trim().toLowerCase().replaceAll(' ', '-')
	}
})

const packageKind = define.resolver({
	name: 'packageKind',
	provides: ['kind'],

	resolve: async (context) => {
		const packageName = context.args.name
		const kind = packageName === 'core' ? 'run' : 'exec'

		return { kind }
	}
})

const packageExists = define.validator({
	name: 'packageExists',

	check: async (context) => {
		const packageName = String(context.args.name)
		const packageNames = await getWorkspacePackages()

		const hasPackage = packageNames.has(packageName)

		if (!hasPackage) {
			return `"${packageName}" is not a package in this workspace`
		}

		return true
	}
})

const dependenciesInstalled = define.validator({
	name: 'dependenciesInstalled',

	check: async (context) => {
		const nodeModulesPath = path.join(context.cwd, 'node_modules')

		try {
			await fs.access(nodeModulesPath)
			return true
		} catch {
			return 'dependencies are not installed'
		}
	}
})

export default define.extensions({
	filters: [kebab],
	resolvers: [packageKind],
	validators: [packageExists, dependenciesInstalled]
})

That gives you a YAML-first command system with just enough TypeScript to make the YAML smarter.


Config discovery

dooz walks upward from your current directory looking for a config, stopping at the git root or filesystem root.

At each level, it checks for YAML config files in this order:

dooz.yaml
dooz.yml
.dooz/dooz.yaml
.dooz/dooz.yml

Extension files are discovered relative to the config file that was found.

dooz.ts
dooz.js
.dooz/dooz.ts
.dooz/dooz.js

So a project can look like this:

my-repo/
  dooz.yaml
  dooz.ts
  package.json
  packages/
    ui/
    utils/

Or like this:

my-repo/
  dooz.yaml
  .dooz/
    dooz.ts
  package.json

You can also override either path from the CLI.

dooz --config ./ci/dooz.yaml test ui
dooz --extension ./shared/dooz.ts test ui

CLI flags

Flags go before the command tokens.

dooz [flags] <command tokens>

Everything after the first bare command token belongs to the matched command.

dooz --dry test ui --watch

Here, --dry belongs to dooz.

test ui --watch belongs to your command.

If your command starts with a flag, use --.

dooz -- --help

| Flag | What it does | | -------------------- | ------------------------------------------- | | --dry | print what would run, then exit | | --explain | print the full execution trace, then exit | | --list | print all defined commands | | --config <path> | override config file path | | --extension <path> | override extension file path | | -- | treat everything after it as command tokens |


List your commands

dooz --list

Example output:

Commands:

  dev
    pnpm dev

  test <name> [...rest]
    pnpm --filter @great-name/{{name}} run test {{rest}}

  build <name> [...rest]
    pnpm --filter @great-name/{{name}} run build {{rest}}

This is useful when a repo has a shared command vocabulary and you do not want everyone memorizing it.


Dry run

Not sure what a command will actually do?

Use --dry.

dooz --dry test ui --watch

Example output:

pnpm --filter @great-name/ui run test --watch

--dry renders the final command without running it.


Explain

Use --explain when you want to see how dooz made its decision.

dooz --explain test ui --watch

Example output:

Explain:

Config path:
  /your/repo/dooz.yaml

Extension path:
  /your/repo/dooz.ts

Matched pattern:
  test <name> [...rest]

Captured arguments:
  {
    "name": "ui",
    "rest": ["--watch"]
  }

Resolver trace:
  [
    {
      "resolverName": "packageKind",
      "output": {
        "kind": "run"
      }
    }
  ]

Validator trace:
  ["packageExists"]

Run template:
  pnpm --filter @great-name/{{name}} ((kind)) test {{rest}}

Rendered command:
  pnpm --filter @great-name/ui run test --watch

Final argv:
  ["pnpm", "--filter", "@great-name/ui", "run", "test", "--watch"]

This is helpful when you are debugging a new command or onboarding someone into a repo.


Or write the full config in TypeScript.

YAML is the default path.

But if you want to skip YAML entirely, you can write a full TypeScript config.

Create dooz.config.ts.

import { define } from 'dooz'

export default define.config({
	commands: [
		define.command({
			match: 'test <name> [...rest]',

			run: ({ args }) => {
				return ['pnpm', '--filter', `@great-name/${args.name}`, 'run', 'test', ...args.rest]
			}
		})
	]
})

The big win here is that args is typed from the match pattern.

defineCommand({
	match: 'test <name> [...rest]',

	run: ({ args }) => {
		args.name
		//   ^ string

		args.rest
		//   ^ string[]

		return ['pnpm', '--filter', args.name, 'run', 'test', ...args.rest]
	}
})

No annotation needed.

dooz reads the pattern and gives you typed args.


The run field accepts three forms.

In YAML, you will usually use a string template.

commands:
 - match: test <name> [...rest]
   run: pnpm --filter @great-name/{{name}} run test {{rest}}

In TypeScript, run can be a string template too.

defineCommand({
	match: 'test <name> [...rest]',
	run: 'pnpm --filter @great-name/{{name}} run test {{rest}}'
})

Or an argv array.

defineCommand({
	match: 'test <name> [...rest]',

	run: ({ args }) => {
		return ['pnpm', '--filter', `@great-name/${args.name}`, 'run', 'test', ...args.rest]
	}
})

Or a function that returns either.

defineCommand({
	match: 'deploy <env>',

	run: ({ args }) => {
		return `kubectl apply -f ./k8s/${args.env}/`
	}
})

All forms build an argv array before execution.

Nothing is passed through a shell.


TypeScript config can define everything inline.

import { define } from 'dooz'

const kebab = define.filter({
	name: 'kebab',

	apply: (value) => {
		return String(value).trim().toLowerCase().replaceAll(' ', '-')
	}
})

const packageExists = define.validator({
	name: 'packageExists',

	check: async (context) => {
		const packageName = context.args.name
		const packageNames = await getWorkspacePackages()
		const hasPackage = packageNames.has(packageName)
		if (!hasPackage) return `"${packageName}" is not a package in this workspace`
		return true
	}
})

export default define.config({
	filters: [kebab],
	validators: [packageExists],

	commands: [
		defineCommand({
			match: 'test <name> [...rest]',
			check: ['packageExists'],

			run: ({ args }) => {
				return ['pnpm', '--filter', `@great-name/${args.name}`, 'run', 'test', ...args.rest]
			}
		})
	]
})

This is useful when your commands are more like tooling than configuration.

But most repos should probably start with YAML.


Programmatic API

dooz also exposes a first-class programmatic API for scripts, tooling, and tests.

import { dooz } from 'dooz'

const plan = await dooz.resolve(['test', 'ui', '--watch'])

plan.command
// "pnpm --filter @great-name/ui run test --watch"

plan.argv
// ["pnpm", "--filter", "@great-name/ui", "run", "test", "--watch"]

plan.matched
// "test <name> [...rest]"

plan.trace
// resolver trace, validator trace, and render details

Resolve and execute:

import { dooz } from 'dooz'

const result = await dooz.run(['test', 'ui', '--watch'])

result.exitCode

List commands:

import { dooz } from 'dooz'

const commands = await dooz.list()

All three accept options.

await dooz.run(['test', 'ui'], {
	cwd: '/path/to/repo',
	config: './ci/dooz.yaml',
	extension: './dooz.ts'
})

Errors

Every error has a typed kind field so you can handle it programmatically.

| Kind | When it happens | | ------------------------- | --------------------------------------------------- | | config-parse-error | config file cannot be found or read | | config-schema-error | config structure is invalid | | pattern-parse-error | a match pattern has invalid syntax | | no-match-error | no pattern matched the input tokens | | ambiguous-match-error | two patterns are equally specific and both match | | missing-variable-error | a template references a value that was not provided | | resolver-failure-error | a resolver threw or returned the wrong shape | | validator-failure-error | a validator failed or threw | | filter-failure-error | a filter threw or could not be found | | execution-failure-error | the subprocess itself failed to start |

Example:

import { dooz } from 'dooz'

try {
	await dooz.run(['test', 'fake-package'])
} catch (error) {
	if (error.kind === 'validator-failure-error') {
		console.error(error.message)
	}
}

Development

pnpm install
pnpm test
pnpm build

The point

dooz is a thin, opinionated layer between "long commands nobody remembers" and "a full scripting language nobody wants."

It stays declarative when YAML is enough.

It gives you TypeScript when YAML needs help.

And it gets out of your way the moment a command works.

If your repo has command chaos, dooz is a calm upgrade.