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

seniority

v1.2.0

Published

Resolves flags, environment variables, config files and defaults in one fixed order, with provenance: every value can say where it came from. Zero dependencies. Drop-in paths for cosmiconfig, lilconfig, dotenv and rc.

Readme

seniority resolves flags, environment variables, config files, a package.json field and declared defaults in one fixed order, with provenance: every value can say where it came from. The provenance is data too — explanationJson() is the --json record and explanationEvent() the agent event — so an agent asking "why is this set?" gets the answer a person does.

Drop-in for cosmiconfig, dotenv and rc: npx burgee migrate moves the imports, each path graded by that incumbent's own suite.

Over the platform. Node reads one .env file itself, with process.loadEnvFile and util.parseEnv. The platform's parser is not dotenv's: it reads an escaped quote differently, and on Node 20 and 22 it differs on more. seniority parses the way dotenv does, and adds what no builtin has: one precedence over flags, environment, config files and defaults, discovery, and the provenance of every value. The repository's absorption probe asks every supported Node line how far util.parseEnv has caught up.

flag  >  env  >  config file  >  package.json field  >  default

Zero dependencies. Node builtins only. Works with any option type you already have.

Install

npm install seniority
pnpm add seniority
yarn add seniority
bun add seniority

Quick start

import { resolve, explain } from "seniority";

const specs = {
  region: { type: "string", default: "us-1" },
  dryRun: { type: "boolean" },
  token: { type: "string", env: "MY_TOKEN" },
};

const result = resolve(specs, {
  flags: { dryRun: true }, // only what the user actually typed
  env: process.env,
  envPrefix: "APP", // so `region` also reads APP_REGION
  config: { path: "./app.config.json", data: { region: "eu-2" } },
});

result.values;
// { region: 'eu-2', dryRun: true }

result.provenance["region"];
// { source: 'config', location: './app.config.json' }

And the part that makes it worth a package:

process.stdout.write(explain("region", result));
region = "eu-2"   from config file ./app.config.json
         candidates: flag --region (unset), env APP_REGION (unset), default "us-1"

Every candidate, including the ones that were unset and the ones that lost. That output is generated by the same code that picked the value, so it cannot drift from the truth.

The problem

Every CLI grows the same tangle. A flag overrides an env var, which overrides a config file, which overrides a default — except in the one place somebody wrote the checks in a different order, and now --verbose loses to VERBOSE=0 on Tuesdays. Then a user asks "why is this set to us-east-1?" and nobody can answer without reading the source.

seniority makes the order a property of the library rather than of each call site, and makes the answer to "where did this come from" a return value.

The order is not configurable

Deliberately. A precedence a program can rearrange is a precedence nobody can reason about from the outside — including the person reading your --help, and the agent reading your --schema. One order, documented once, true everywhere.

It is one exported array, ORDER, and RANK gives each entry a number spaced by ten:

import { ORDER, RANK } from 'seniority';
//  ['flag', 'env', 'config', 'package', 'default']
//     0       10       20        30         40

TRUTH_TABLE is the same order as one row per combination of sources: 32 rows, each the sources that set a value and the one that wins. It is generated from ORDER, so it cannot disagree with a run.

resolve is pure

Layers in, values and provenance out. No filesystem, no process.env, no globals — pass the environment in and it is testable without a process.

The half that does touch the disk is a separate import, for exactly that reason:

import { discover } from "seniority";

const loaded = await discover({
  name: "mytool",
  cwd: process.cwd(),
  env: process.env,
});
// searches, in order:
//   MYTOOL_CONFIG
//   ./mytool.config.{json,mjs,js,cjs}
//   $XDG_CONFIG_HOME/mytool/config.json

extends is resolved relative to the extending file (or through node_modules), deep-merged left to right, and cycles are rejected with the chain that formed them. An explicit --config that is missing is an error; a discovered file that is missing is silence.

YAML is one loader away, and still not a dependency. seniority/yaml is this package's own parser — the part of YAML a config file is written in, read the way js-yaml 5 reads it — in an entry of its own, so a program that never imports it never carries it:

import { discover } from "seniority";
import { parse } from "seniority/yaml";

const yaml = (_path, text) => parse(text);
await discover({
  name: "mytool",
  cwd: process.cwd(),
  env: process.env,
  loaders: { ".yaml": yaml, ".yml": yaml },
  extensions: [".json", ".yaml", ".yml", ".js"],
});

Supplying the parser and asking discovery to look for the format are two options on purpose: a program may want either without the other.

Bring your own option type

OptionSpec here declares the three fields resolution actually reads:

interface OptionSpec {
  type?: string; // only 'boolean' changes how env is read
  env?: string;
  default?: unknown;
}

burgee's own option type carries eighteen fields — choices, schemas, placeholders, deprecations. It is accepted here with no adapter and no import, because TypeScript's structural typing does the work. Your richer type is already an OptionSpec.

That is the point of this package sitting at the bottom of the stack: nothing above it has to exist for it to be useful to you.

Environment rules

  • With envPrefix: 'APP', an option without an explicit env: reads APP_REGION — dryRun becomes APP_DRY_RUN, log-level becomes APP_LOG_LEVEL, and a camel-cased name is never mapped back.
  • Booleans accept 1/true/yes and 0/false/no. Anything else is an error naming the variable and the value, not a silently-falsy string.
  • APP_NO_THING is refused, pointing at APP_THING=false. Two spellings of one fact is how a config becomes unreadable.
  • Env applies only to options the running command declares — never to a sibling command's.

Plugins

A plugin can add a source — a vault, a CI variable set, a remote config — under the family's one plugin key, sources. It cannot reorder the five above it.

import { register, sources } from 'seniority/plugin';
import { explain, RANK, resolve } from 'seniority';

register({
  name: 'acme-vault',
  sources: {
    vault: {
      rank: RANK.env + 1, // between the environment and the config file
      read: (rt) => ({ location: 'acme://vault/ci', values: { region: rt.env.CI_REGION } }),
    },
  },
});

const r = resolve(specs, { flags, env, sources: sources({ env, cwd }) });
explain('region', r); // region = "eu-1"   from vault acme://vault/ci
  • rank must be an integer strictly between RANK.flag and RANK.default. A plugin may never beat the flag the user typed, nor sink below the declared default.
  • A source is data or a reader, and exactly one. values: { … } for a constant source, which a tool can read without running it; read(runtime) for one that has to go and look. read gets the { env, cwd } you pass it — this package still touches no globals.
  • Every other key is ignored, so one plugin object works across the whole family.

A lower rank wins. Where a source lands, as the number to write:

| to rank… | rank | | :-- | :-- | | above the environment, below a flag | 1–9 | | above the config file, below the environment | 11–19 | | above package.json, below the config file | 21–29 | | above the default, below package.json | 31–39 |

The same plugin as a file of its own — one module whose default export is a plain object, with no import, which is the shape check loads:

// acme-vault.mjs
export default {
  name: 'acme-vault',
  contract: 1,
  sources: {
    vault: {
      rank: 11, // env is 10 and the config file 20: this beats the file and loses to the environment
      read: (runtime) => (runtime.env.CI_REGION === undefined ? undefined : { location: 'acme://vault/ci', values: { region: runtime.env.CI_REGION } }),
    },
  },
};

Check it before it ships:

npx seniority check ./acme-vault.mjs
# in a clone of this repository, where dist/ is not committed:
npx turbo run build --filter=seniority && node packages/seniority/dist/cli.js check ./acme-vault.mjs
acme-vault — 1 sources
  vault
acme-vault: ok

A refusal exits 1 with a code and the edit to make: a rank outside 1–39, both values and read or neither, or a sources that is not an object keyed by name.

Migrating

Each path starts with one import change. npx burgee migrate --dry-run lists the imports the codemod would rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes the change (Migrate).

cosmiconfig

The root export carries cosmiconfig's own surface, so a migration is the import line:

- import { cosmiconfig } from "cosmiconfig";
+ import { cosmiconfig } from "seniority";

cosmiconfig, cosmiconfigSync, Explorer, ExplorerSync, defaultLoaders, defaultLoadersSync, getDefaultSearchPlaces, globalConfigSearchPlaces, metaSearchPlaces — all three search strategies, both caches, $import, and the meta-config merge.

seniority/dotenv

config, configDotenv, parse and populate are dotenv 18's, both of its parsers included, so a migration is the import line:

- import dotenv from "dotenv";
+ import dotenv from "seniority/dotenv";
- import "dotenv/config";
+ import "seniority/dotenv/config";

config() behaves as dotenv's does when told nothing: it reads ./.env from the working directory into process.env, takes its defaults from DOTENV_PATH, DOTENV_QUIET and the rest (or their older DOTENV_CONFIG_* names), and prints ◇ injected env (n) from .env on stderr unless quiet. Pass processEnv to populate another object, and path for another file (or several, a URL, a leading ~). parse(src, { fast: true }) is dotenv's character scanner. seniority/dotenv/config is the side-effect entry, quiet unless asked, and seniority/dotenv/cli is dotenv run — run(['run', '-f', '.env.local', '--', 'node', 'app.js']) loads the files, starts the command, and forwards its signals. decrypt, DOTENV_KEY and the .env.vault format are not built, because dotenv 18 removed them.

The process is read in one place, src/runtime.ts, and only by the drop-in paths whose incumbents read it by default. resolve never reads it, and parse works on a string you already have — in a test, in a browser build, anywhere.

seniority/lilconfig

lilconfig, lilconfigSync, defaultLoaders, defaultLoadersSync — lilconfig's search places, its loader tables (.json through require in the sync one, exactly as upstream), its caches, and its disagreements with cosmiconfig kept rather than smoothed over:

- import { lilconfigSync } from "lilconfig";
+ import { lilconfigSync } from "seniority/lilconfig";

Its own entry point, not the root: lilconfig's last test reads the keys of the module it is given and compares them with cosmiconfig's, so one module cannot honestly be both.

seniority/rc

rc's merge — the /etc, $HOME and upward-walk file stack in rc's own order, __ nesting for environment keys, JSON with comments, deep-extend's merge, and the configs / config report of which files were actually read — with none of rc's four dependencies.

- const config = require("rc")("mytool", defaults);
+ import rc from "seniority/rc";
+ const config = rc("mytool", defaults, argv);

Like rc, it reads the process's environment when you pass none; a fifth argument, { env, cwd, home, win }, supplies the world instead. Two differences, both on purpose. The command line is an argument: rc fills a missing argv with minimist(process.argv.slice(2)), and this treats it as empty. And an INI-shaped file is refused by name rather than parsed — pass ini.parse in rc's own fourth position and you have rc's behaviour, with the parser as your dependency.

seniority/find-up

find-up 8's surface — findUp, findUpSync, findUpMultiple, findUpMultipleSync, a matcher function and findUpStop, findDown and findDownSync, with locate-path's type and allowSymlinks — in one file. find-up → locate-path → p-locate is three packages for that.

- import { findUp } from "find-up";
+ import { findUp } from "seniority/find-up";

Compatibility

What cosmiconfig, dotenv, rc and find-up do between them — discovery, extends, env loading, precedence, the upward walk — is one problem. This is one package with no dependencies rather than four with a tree.

Each path below is graded by its incumbent's own suite, run unmodified through compat-oracle; the current grades are generated under Benchmarks below and published on the compatibility page.

cosmiconfig

Graded by cosmiconfig 10.0.1's own test suite: 242 of 243 cases, the same as the real cosmiconfig gets here. Not "compatible" — a number, from the incumbent's tests, run unmodified.

YAML is read the way cosmiconfig reads it. cosmiconfig takes js-yaml for .yaml, .yml and extensionless files; this package reads them with seniority/yaml, its own parser, loaded the first time a YAML file is — so a program whose configs are JSON or JavaScript never loads it. It returns what js-yaml 5 returns, and its errors carry js-yaml's reason and position, which cosmiconfig's suite asserts word for word.

The one case not passing fails for cosmiconfig too: the test harness reaching for a file path the vendored copy does not have. The global config directory is where cosmiconfig puts it, through env-paths: $XDG_CONFIG_HOME/<name> on Linux, ~/.config/<name> when that is unset, %APPDATA%\<name>\Config on Windows and ~/Library/Preferences/<name> on macOS. The suite's XDG pair, which registers only on Linux, passes. On macOS, where it does not register, both read 240 of 241.

seniority/dotenv

Graded by dotenv 18.0.9's own test suite: 197 of 197, the same as the real dotenv gets here. That includes its dotenv run command line and signal forwarding, both parsers, and import 'dotenv/config'. dotenv 17.4.2's suite is graded too and not claimed: 107 of 141, because 17's vault, tips and console.log line are what 18 changed.

seniority/find-up

Graded by find-up 8.0.0's own test suite: 104 of 104 cases, the same as the real find-up gets here. Two of the suite's 106 are subtracted by name from both sides, and the compatibility page says why: each expects a .git directory at the package's own root, which a vendored copy does not have. A name matches a file unless type says otherwise, and cwd defaults to the process's working directory, as find-up's do.

seniority/lilconfig

Graded by lilconfig 3.1.4's own test suite: 77 of 77 cases, the same as the real lilconfig gets here. Ten of them assert which files were read by mocking fs with jest.mock; since #549 the oracle applies that mock the way jest does, so they run and pass instead of failing for both.

seniority/rc

Graded by rc 1.2.8's own test: 1 of 1 — by exit code. That suite is one script of bare assertions with no reporter, so the grade is one bit: the script ran against this package and exited 0. It is not 100% of anything.

Benchmarks

Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.

Graded by the incumbent's own test suite:

| suite | passing | | :-- | --: | | cosmiconfig | 242 / 243 | | dotenv | 197 / 197 | | find-up | 104 / 104 | | lilconfig | 77 / 77 | | rc | 1 / 1 |

Weight, installed and tree-inclusive: 225,522 bytes against 1,972,507 for the incumbents it replaces — a ratio of 0.1143 (measured).

For agents

  • "Why is this set?" has a machine answer. explanation(name, res) is --explain as a record; explanationJson() is the same record as --json data and explanationEvent() as an agent event, so an agent gets the answer a person does — every candidate, including the ones that were unset and the ones that lost.
  • The order is data. ORDER and RANK are exported, so a tool reading your --schema can state the precedence without guessing it.
  • resolve touches nothing. No filesystem, no process.env, no globals: pass the environment in and the same inputs give the same answer anywhere.
  • A source plugin can be checked before it ships. npx seniority check ./vault.mjs validates it against the family schema and exits 0, 1 with a code and a fix, or 2 on a usage error. The whole plugin, the rank to write and the report are under Plugins.
  • The docs are machine-readable at seniority.interlace.tools/llms.txt and llms-full.txt.

API

| | | | :---------------------------- | :--------------------------------------------------------------------------- | | resolve(specs, layers) | every declared option, resolved, with provenance and the full candidate list | | explain(name, resolution) | the winning source and everything it beat, formatted | | discover(discovery) | find and load a config file, following extends | | loadWithExtends(path) | load one file and its extends chain | | packageLayer(path, name) | the program's package.json field as a layer, each key cited at its line | | candidates(discovery) | the paths discovery would try, and why | | deepMerge(base, over) | objects merge recursively; anything else, later wins | | envName(name, spec, prefix) | the variable an option reads | | envBoolean(raw) | the boolean spellings, or undefined | | screaming(name) | dryRun → DRY_RUN | | ConfigError | a value that cannot be used as configured; carries an optional hint | | ORDER, RANK | the precedence as data, and each built-in's rank | | TRUTH_TABLE, truthTable | every combination of sources and the one that wins, generated from ORDER | | explanation(name, res) | --explain as a record; explain is its text rendering | | explanationJson(e) | the same record as --json data, with set spelled out | | explanationEvent(e) | the same record as an agent event | | search(names, options) | the bounded upward walk — stopAt, a depth limit, symlink-cycle-safe | | searchAll(names, options) | every match on the way up, nearest first | | loadPath(path, options) | read and parse one file, with your loaders merged over the four builtins | | loaderFor(path, loaders) | the loader for an extension, or the error naming what would supply one | | LoaderError | an extension with no loader: a usage error, not a config error | | validate(shape, resolution) | every violation, each naming the file, line and value that caused it | | check(shape, resolution) | the values, or one ConfigError listing every violation |

From seniority/plugin:

| | | | :------------------- | :---------------------------------------------------------------- | | register(plugin) | keep a plugin's sources; refuse a bad rank or shape at the door | | sources(runtime) | every registered source, read and ranked — pass to resolve | | reset() | forget every registered plugin | | registered() | the plugins registered, in order | | PluginError | E_PLUGIN_SCHEMA or E_PLUGIN_CONTRACT, with a fix |

Every export, with its types, is on seniority.interlace.tools.

Where it sits

Plugins register under the sources key, against the one schema the whole family shares.

burgee builds on it, and it builds on nothing in this family.

The family

Ten packages, one repository, one release pipeline. Each installs and works on its own, and each owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority — depend on nothing; the others depend only on packages in this table, and every dependency points one way, down the layers.

No package declares a dependency from outside the family; react and react-reconciler are optional peers of controlroom, which npm does not install. package-shape-lock holds that for every manifest, and independence-install-lock installs each package alone and finds nothing but the family packages it declares.

Releases are published by one workflow through npm trusted publishing: no npm token is used, and each release carries SLSA provenance, which npm view <package> dist.attestations shows. trusted-publishing-lock keeps both true.

| Package | What it is | Migrates from | | :-- | :-- | :-- | | burgee | The CLI framework: one declaration, every surface | commander, yargs and meow | | roundel | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors | | flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | caique | Prompts that are flags first, and never hang | @inquirer/core | | linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line | | paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | seniority (this package) | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc | | closeout | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime | | bellpull | Subprocesses, and which executable actually ran | cross-spawn and which | | controlroom | Keyboard-driven terminal screens, inline or full-screen | ink |

Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.

Contributing

Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.

Licence

MIT © Ofri Peretz — see LICENSE.