@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 doozThe 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-packageRun them with extra flags or arguments:
dooz test cool-package --watch
dooz build other-package --watch --env productiondooz passes everything after the matched tokens through to your command, so this:
dooz test cool-package --watchrenders 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 --watchdooz sees:
test ui --watchThen it matches that input against your patterns.
test -> literal token
ui -> captured as name
--watch -> captured inside restThen it renders the run command.
pnpm --filter @great-name/ui run test --watchThat 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 --coverageAnd 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 --coverageThe 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 --watchBecause 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 componentsbeats:
test allwhich 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 --coveragerenders:
pnpm --filter @great-name/ui run test --watch --coverageThe 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: productioncwd 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 YAMLThink 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 argvResolvers 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 corecan render:
pnpm --filter @great-name/core run buildAnd this:
dooz build docscan render:
pnpm --filter @great-name/docs exec buildThe 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-packagecan fail before anything runs:
"fake-package" is not a package in this workspaceValidators 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-packageAnd the final command becomes:
pnpm --filter @great-name/cool-package run test --watchFilters can also be used with arrays.
commands:
- match: echo [...words]
run: echo {{words | join(' ')}}Now this:
dooz echo hello there friendrenders:
echo hello there friendBuilt-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 thererenders:
echo HELLO THERECustom 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: productionAnd 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.ymlExtension files are discovered relative to the config file that was found.
dooz.ts
dooz.js
.dooz/dooz.ts
.dooz/dooz.jsSo 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.jsonYou can also override either path from the CLI.
dooz --config ./ci/dooz.yaml test ui
dooz --extension ./shared/dooz.ts test uiCLI 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 --watchHere, --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 --listExample 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 --watchExample 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 --watchExample 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 detailsResolve and execute:
import { dooz } from 'dooz'
const result = await dooz.run(['test', 'ui', '--watch'])
result.exitCodeList 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 buildThe 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.
