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

@hublo/sentinel

v1.0.1

Published

One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks.

Readme

@hublo/sentinel

One CLI that guards code health across Hublo repos: shared lint/typescript/build/test presets, static & dynamic analysis, and architecture checks, behind a single command.

sentinel is a standalone, semver-versioned package (published to a registry, consumed by a repo as a normal dependency) that unifies a repo's tooling, config, and quality checks into one place, so projects stop copying config files everywhere and stop carrying a pile of duplicated tooling dependencies.

Status: foundation + the TypeScript tool are shipped (@hublo/[email protected] on npm). The rest of this README is the design reference for the tools still to come, added one at a time on top of this foundation.


Why

A large monorepo accumulates:

  • Config sprawl — the Hublo monorepo has ~116 eslint configs, ~528 tsconfig files, per-project vite/vitest configs. Almost all just re-extend a shared base, but the base drifts and every change means touching many files.
  • Centralized, all-or-nothing dependencies — ~165 devDependencies at the root, ~93% of projects declare none of their own. Bumping a tool is a "big bang": all projects, at once, untested in isolation.

sentinel fixes both: one versioned source of truth for tooling + config, adopted per module so you can migrate gradually and bump the whole toolchain atomically.

What it gives you

The shift: instead of today's big-bang (change every project and every tool at once, untested in isolation), evolution is app-scoped and versioned, evolving a project is a version bump, a new/swapped tool is just a new runner, and a new stack a new flavour. No repo-wide edits.

  • One source of truth for config — every project just extends @hublo/sentinel/...; the actual rules live in one versioned place. Change a rule once, everyone gets it on the next version bump.
  • One source of truth for tooling dependencies — a project depends on @hublo/sentinel, not on a scattered pile of eslint / vitest / plugin devDeps. Bump one version and the whole toolchain moves, atomically, tested in isolation first.
  • --init sets a module up — the one command generates the stubs the first time (adopt), regenerates them after a change like a runner swap (refresh), and applies the workspace prep the module needs. Run it module by module to roll out gradually. (--migrate, for changing an already-initialized setup, is a reserved future verb.)
  • Move one module at a time — installed per module, so you adopt at your pace; a module can adopt sentinel while its neighbour keeps the old setup. No big-bang.
  • Swap tools without touching projects — change eslint → biome (or benchmark them) in one place; --init regenerates the stubs.
  • No silent drift — the guard keeps every project's config converged on the source of truth.

Before → after

| | Before | After | | ------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------- | | Config | ~116 eslint + ~528 tsconfig files with real, drifting content | thin stubs that extends a versioned preset; rules in one place | | Tooling deps | ~165 devDeps at the root, shared by all | one @hublo/sentinel per module; the toolchain rides its version | | Upgrade a tool | big-bang: every project at once, untested in isolation | bump one version, tested in sentinel first, atomic | | Swap a tool | edit config in every project | swap an adapter + --init; zero project churn | | A rule change | edit many configs, hope they stay consistent | change once; the drift guard enforces it | | Migration | all-or-nothing | module by module, at your pace |

How you use it

sentinel writes standard config files into a project (each just extends a sentinel preset) and runs the checks. Your editor and the tools read those normal files natively, they never call sentinel at runtime, so nothing is coupled to it or brittle.

Shipped today: only the TypeScript tool, so --init writes the tsconfig stub, and --run/--report/--status work for --typescript. The eslint.config.js / --lint / --test snippets below illustrate the end state; those subpaths (@hublo/sentinel/lint/*, …) land with their tool ticket.

Step 1 — put a module on sentinel (once per module, by a dev; the files are committed). Run from the app dir; --init does it all, nothing is hand-edited:

sentinel --init --typescript --flavour <react|nest|node>
pnpm install   # fetch what --init declared, then commit

--init writes the config stubs, the typecheck/lint/... scripts, and pins the @hublo/sentinel devDependency into the module (no manual pnpm add); it scaffolds a package.json for a project.json-only module. It also applies, once, the workspace prep that module needs at the root, only when the root's own config shows it is needed (e.g. an i18next singleton override when the repo runs a second TypeScript, a release-age allow-list when the repo uses that pnpm gate). See the adoption cheat sheet for the full list.

Those files are tiny, they just point at a sentinel preset. What gets committed:

// eslint.config.js — generated; overrides go through the sentinel allowlist, not inline
import react from '@hublo/sentinel/lint/react'
export default react
// tsconfig.json — COMPOSES the repo base with the sentinel preset (see "Composition
// & precedence"); only project-specific paths/include stay local
{
  "extends": ["../../tsconfig.base.json", "@hublo/sentinel/tsconfig/react"],
  "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } },
  "include": ["src"]
}

A module can extend the preset alone ("extends": "@hublo/sentinel/tsconfig/react") when there is no repo base. Compose with the base when the repo owns structure (a monorepo paths map) the preset should not, see "Composition & precedence".

And the app's package.json scripts route every check through the one CLI (run from the app dir, sentinel scopes to it):

// package.json
{
  "scripts": {
    "lint": "sentinel --run --lint",
    "lint:fix": "sentinel --run --lint --fix",
    "typecheck": "sentinel --run --typescript",
    "test": "sentinel --run --test"
  }
}

Step 2 — from then on you rarely touch sentinel. The committed config files do the work:

  • your editor reads them → live lint / type / format, exactly as before;
  • CI runs the checks: sentinel --run --lint from the module dir (or nx run my-app:lint, or even eslint directly, the generated config is self-sufficient);
  • the rules always come from the sentinel version the stubs point at.

Step 3 — evolve the toolchain, in one central place:

  • a rule change → bump the @hublo/sentinel version; the stubs already point at it, so there is nothing to regenerate;
  • a structural change (new tool, new preset, runner swap) → run sentinel --init once to refresh the stubs (sentinel tells you when this is needed).

Step 4 — stay converged: a drift guard in CI flags any module whose config quietly diverged from the shared source.

The per-tool knowledge (eslint → eslint.config.js, tsc → tsconfig, …) lives inside sentinel as an adapter, swappable centrally, but never a runtime dependency of the project.

Requirements & installing

Registry: public npm, under @hublo. We started on GitHub Packages (private) and moved off it: it authenticates every consumer, including a one-off pnpm dlx, which is incompatible with the zero-setup adoption above. Public npm needs no consumer auth, so a module adopts sentinel without any .npmrc or token. Releases go out through the repo's publish workflow, never from a laptop; a prerelease is published under its prerelease dist-tag (alpha) and a stable one under latest.

Node. sentinel needs Node >= 20.12 (its coloured output uses util.styleText, added in 20.12). It fails fast with a clear message on an older runtime rather than crashing. If a project runs on an older Node (e.g. a legacy app on Node 10), run sentinel with a modern Node via fnm/nvm; you do not need to change the project's own Node.

Try it without installing. A one-off run needs no auth and touches nothing:

pnpm dlx @hublo/sentinel@<exact-version> --inspect --typescript --module <name>

Installing a pre-release (minimumReleaseAge). The monorepo enforces a 3-day minimumReleaseAge supply-chain gate (a freshly published version cannot be installed until it has aged 3 days). A brand-new alpha therefore cannot be added yet, so while testing pre-releases you either exclude the package (pnpm-workspace.yamlminimumReleaseAgeExclude) or install with --config.minimumReleaseAge=0. This is a deliberate protection, not a bug: always pin the exact version (@hublo/[email protected]) rather than @latest, so a run is reproducible and the gate stays meaningful.

Docs & cheat sheets

  • docs/typescript-adoption.md — the adoption cheat sheet: the two adoption steps, the command model (verb x type x location), options, reading a report, and troubleshooting.
  • docs/typescript-traces.md — a generated, versioned reference of live command + output traces (every verb, option, config result and edge case) against the mock monorepo. Regenerate after CLI changes with pnpm docs:traces.

Architecture: target → runner → flavour

Every check is described by three layers:

| Layer | Flag | What it is | Examples | | -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- | | target (role) | --lint, --typescript, … | the kind of check, stable | lint format typescript build test static-analysis runtime-analysis | | runner (adapter) | --runner=<tool> | the tool behind the target, swappable | lint: eslint/biome/oxlint · types: tsc/tsgo · build: vite · test: vitest | | flavour (preset) | detected / --flavour | the variant per stack (strict by default) | react nest node (svelte declared, preset deferred) |

A run is target × runner × flavour, e.g. sentinel --run --lint --runner=eslint from the host-admin dir.

  • --runner is an optional override on a central default. sentinel --lint uses the configured default runner, so swapping a tool globally is a one-place change; --runner=biome overrides for a single run (great for benchmarking eslint vs biome vs oxlint, and for gradual migration).
  • The flavour is detected from the module's dependencies (deterministic: a framework dep → its flavour, else node), and --flavour overrides it. Detection can be wrong where deps are hoisted at the repo root (it returns node), so pass --flavour for --init (that is where the preset is chosen). --run/--report don't depend on it, they run the tool on the committed config.

Adapters & the engine (ports & adapters)

sentinel is a small ports & adapters design, which is what keeps tool logic out of the core:

  • the engine (core) is tool-agnostic: it parses the CLI, resolves an adapter, and owns all IO and repo structure, finding the project root, reading, merging and writing files;
  • an adapter is the boundary to one tool (eslint, tsc, vitest, …). It carries the tool knowledge (how to run it, what its config means) and implements one contract (types in src/core/types.ts, optional base in src/core/base-adapter.ts);
  • a context is a plain data object the engine passes to an adapter for a run (app, cwd, optional flavour, ci, fix). It is data only, it never carries a filesystem capability.

sentinel does not reimplement tools. The contract:

  • appliesTo(flavour) — which flavours this adapter handles (resolution filters on it, so a React-only adapter is never picked for Nest)
  • plan(flavour) — PURE: returns a declarative UpdatePlan of file operations (used by --init). The adapter never touches the disk; the engine applies the plan.
  • run(ctx) — invoke the tool's bin against the project (used by --run)
  • inspect(flavour) — the adapter's resolved base config (used by --inspect)
  • report(ctx) — metrics (used by --report)

--init is a declarative plan, not file-writing inside the adapter. The adapter describes intent as operations; the engine executes them:

  • write { path, contents } — a file the adapter fully owns (the thin stub)
  • merge-json { path, value } — pin the keys sentinel owns while preserving a project's own (this is how a tsconfig's paths/include survive)
  • ensure-lines { path, lines } — idempotently add lines (e.g. an import into an existing test setup)

So the tool-meaning lives in the adapter and the read/merge/write mechanics live in the engine, which keeps adapters pure and decoupled from where files live.

Adding a new tool = writing one adapter plus one line in the bootstrap (src/adapters.ts). The CLI (verbs × targets) never changes: swap a tool = swap an adapter (one place); benchmark = run two adapters on the same target.

Schema

How a command flows (the CLI stays generic; only adapters are tool-specific):

flowchart LR
  CLI["sentinel --verb --target<br/>[--runner]"] --> D[dispatch]
  D --> R["registry.resolve<br/>(target, flavour, runner)"]
  R --> A["adapter<br/>eslint / tsc / vitest / ..."]
  A -->|"--run"| Run["tool binary on the project"]
  A -->|"--init"| Upd["declarative plan → engine writes"]
  A -->|"--inspect"| Ins["resolved config"]
  A -->|"--report"| Rep["metrics"]

How config lives (one source of truth in sentinel; thin generated stubs keep the editor and nx working):

flowchart TB
  S["@hublo/sentinel<br/>rules = one source of truth"]
  S -->|"--init generates"| Stub["thin stub per module<br/>(extends sentinel)"]
  Stub --> IDE["editor: live lint / type / format"]
  Stub --> NX["nx: target inference"]
  S -->|"--run injects config"| CI["CLI / CI: run tool on target"]
  Guard["drift guard + allowlist"] -. "validates the stub stays thin" .-> Stub

How config lives (the model)

The rules live in sentinel. Each module keeps a thin, generated stub per tool, a few lines that extends/re-export the sentinel preset:

  • Rules in sentinel — one source of truth, versioned.
  • Thin stubs per module — the stub is what keeps the editor working (VS Code discovers config by file, real-time lint/type/format stay live) and what nx uses to infer targets. Stubs are generated by sentinel --init, never hand-written; swapping a runner regenerates them.
  • Drift guard — sentinel validates that each stub is only the sanctioned extends, with nothing added or overridden. Unsanctioned drift is flagged in CI; a genuine exception must be declared in an allowlist (visible, reviewed), never silent.
  • Per-module install@hublo/sentinel is added per module, so adoption is gradual (migrate lot by lot; a module can adopt sentinel while its neighbour still uses the old config). Root configs are removed only once the last module has migrated.
  • Runner binaries (eslint, typescript, vite, vitest, …) are resolved from the adopting module at run time (sentinel looks for the tool in the module, then falls back to PATH), so the editor and nx keep using the exact binary the project already installs. sentinel does not declare them as dependencies today, so it does not pin their versions: the module still owns its own typescript. Having sentinel dictate those versions (as peer dependencies, so the whole toolchain rides the sentinel version) is the intended end state, and it lands with the tool tickets that actually bundle a runner.

Composition & precedence

A repo often has a base config that is structural, not just tooling, e.g. a monorepo tsconfig.base.json carrying the workspace paths map. sentinel does not replace it; it composes with it, so the structure survives and sentinel owns the standards:

"extends": ["../../tsconfig.base.json", "@hublo/sentinel/tsconfig/react"]

Precedence — sentinel wins. With array extends (TS 5.0), entries merge in order and the last one wins; the module's own compilerOptions win over both. So it is always base → sentinel → module-local. sentinel is listed last on purpose: it is a reference (a source of truth), not a suggestion the base can silently veto. For any option sentinel declares, sentinel's value is the effective one.

For a module composing the base, the resolved config (tsc --showConfig) looks like:

| Option | Effective value | Owner | | ----------------------------- | -------------------- | ------------------------------------------ | | strict, jsx, decorators | preset's | sentinel (quality + framework) | | module / moduleResolution | esnext / bundler | sentinel | | target / lib / paths | the repo's | base (environment; sentinel won't set) | | noImplicitAny | off (phase 1) | base, the preset defers it (see below) |

  • sentinel owns quality + framework semantics (strict, jsx, decorators, module system). The base can never quietly undo those, which is what makes sentinel a reliable reference.
  • the base owns the environment (paths, target, lib, project references). sentinel does not set these, extends REPLACES arrays rather than merging, so overriding lib/target would drop the repo's DOM libs or flip class-field emit.

Least astonishment / phased strictness. So a reference doesn't turn every rule on the instant a module adopts it, sentinel phases the rules that would surface new errors (noImplicitAny, noUnusedLocals, noUnusedParameters). They are kept commented in the preset (visible, never silently dropped) and announced by a runtime warning. strict stays on (so a standalone consumer still gets noImplicitAny), but a repo base that relaxes noImplicitAny keeps it off until that relaxation is removed. First adoption is therefore a non-breaking lateral move; the rules are enabled centrally in a later wave.

Removing the base is two decoupled moves, and neither loses quality:

  1. Compose (extends: [base, preset]). Quality flips to sentinel immediately, because it wins. The base's own tooling options are still present but now dead config (overridden).
  2. Slim the base later to structure only (paths + references). This moves nothing quality-wise, sentinel already won in step 1, so it is safe. The base is never fully removed while it still owns paths.

The discipline this demands: sentinel must explicitly declare every quality option it means to own. An option sentinel forgets to set falls through to the base, a silent gap in the reference. (Be deliberate with array/replace options like types: setting them overrides the base's rather than merging, so only own them when you mean to.)

CLI

A command composes three axes: verb + type + location.

sentinel <verb> [type] [options]

VERBS      --run       execute the target's tool
           --inspect   show the resolved configuration (incl. deferred rules)
           --report    metrics and health
           --status    adoption + conformity (coverage + drift), read from configs
           --init    generate/apply the config stubs (writes; one module only)

TYPES      --lint  --format  --typescript  --build  --test
           --static-analysis  --runtime-analysis  --arch
           (omit a type → ALL types; or --all)

LOCATION   in a MODULE dir      → that module (do NOT pass --module)
           at the workspace ROOT → --module <name> (one) · --ci (affected) · else all

OPTIONS    --module <name>    from the root: scope to one module
           --flavour <name>   override the detected stack preset (react, nest, ...)
           --runner <tool>    override the default runner
           --ci               from the root: affected only; non-zero exit on failure
           --fix              auto-fix where applicable
           --dry-run          preview a --init without writing
           --json             machine-readable output (report / inspect / status / --dry-run)

EXAMPLES   sentinel --run --typescript                        # in a module → that module
           sentinel --report --typescript --module bff-admin  # from root  → one module
           sentinel --report                                  # from root  → all types, all modules
           sentinel --status --typescript                     # from root  → adoption coverage
           sentinel --status --ci                             # from root  → fail CI on drift
           sentinel --init --typescript --flavour react     # write stubs for the current module

--run/--inspect/--report/--status share one context rule (developer from a module, or from the root for a name / affected / all); --init writes, so it targets one module only (adopting every module at once is refused, adopt gradually).

--status — adoption + conformity. A cheap, workspace-wide read (no tool run, no flavour guessing): for each module it reads the committed tsconfig extends chain and reports whether it is adopted (extends a sentinel preset), which preset, and whether it is conformant (drift-free, a re-update would change nothing), plus a coverage footer. This is the drift guard as a command, --status --ci exits non-zero when an adopted module has drifted.

  ✓ host-admin (react) typescript — adopted (react) conformant
  ✗ some-bff   (nest)  typescript — adopted (nest) drift: strict, target
  · legacy-app (node)  typescript — not adopted
  coverage: 12/40 adopted · 11/12 conformant · 1 drifted

Repository layout

bin/sentinel.ts              # CLI entry (parse verb × target × runner; never lists tools)
src/
  adapters.ts                # bootstrap: the one place adapters are wired in
  core/
    types.ts                 # the adapter contract, pure types (Adapter, FileOperation, UpdatePlan)
    base-adapter.ts          # optional convenience base class for adapters
    domain.ts                # vocabulary + derived types (verbs, targets, flavours)
    settings.ts              # tunables (workspace-root marker, ...)
    registry.ts              # register + flavour-aware resolve
    dispatch.ts              # verb → adapter method
    apply-plan.ts            # the engine's filesystem port (applies --init operations)
  roles/<config-role>/       # lint, format, typescript, build, test
    adapters/<runner>/       # one adapter per tool (implements the contract)
    flavours/<stack>/        # config presets per stack (react, nest, svelte, ...)
  roles/{static-analysis,runtime-analysis}/   # analysis roles
    configs/                 # fixed configs (internal, not exported)
    runners/                 # one runner per sub-tool (duplication, complexity, ...)
  shared/                    # reusable utils (package-json, deep-merge, text)
tests/                       # unit tests + tests/e2e (runs the built dist binary)
.github/workflows/           # ci.yml (PR checks) + publish.yml (manual, version-input publish)

Subpath exports (in package.json) expose presets to consumers. Shipped today: @hublo/sentinel/tsconfig/react · .../tsconfig/nest · .../tsconfig/node. Other subpaths (e.g. .../lint/react) land with their tool ticket.

FAQ

Is every tool call proxied through sentinel? No, and it's not a runtime proxy. sentinel manages the widespread, config-driven tools (eslint, tsc, vitest, …); something like nx doesn't need it at all, it's the task runner, it just runs the scripts. And for the tools it does manage, the config files are standard, so they and your editor run natively, sentinel --run is a convenience dispatcher and you can run eslint . directly.

What does running eslint look like after sentinel? Exactly like before. eslint reads eslint.config.js, which imports a sentinel preset. sentinel --run --lint, nx run app:lint, and eslint . all run the same eslint with the same rules.

Who decides which tool --lint runs? The target's default runner, configured in sentinel (e.g. lint → eslint). Override per run with --runner=biome. So --lint is the what; the runner is the how, swappable in one place.

Can two different lint tools coexist in the repo? Yes. Per app (app A defaults to eslint, app B to biome), or even in the same app: sentinel --run --lint --runner=oxlint (fast) alongside --runner=eslint (full). The registry holds every runner for a target; the default is just the common case.

What about tools that can't extends / compose config? Most tools expose extends or a plugin mechanism to compose config, so the stub just points at the sentinel preset. For the rare tool that doesn't, sentinel exposes the config directly, it generates the full config from its preset (still one source, drift-checked).

What does an nx project.json look like? nx is a task runner: it just runs the target's script. So project.json barely changes, the lint / test / typecheck targets run the package.json scripts (which call sentinel), or stay nx-inferred. nx keeps the graph, affected set, and cache; sentinel provides the config + execution.

Roadmap

This scaffold is the foundation; each tool is added one at a time on top of it:

  1. Foundation — repo, exports, CLI skeleton, adapter contract, CI + the single dispatch publish workflow. ✅ shipped
  2. TypeScript (--typescript) — runner tsc (later tsgo): presets react/nest/node, --run/--inspect/--report/--init, the composable grid, phased (non-breaking) strictness. ✅ shipped in 0.1.0-alpha.x
  3. Lint (--lint) — benchmark eslint vs biome vs oxlint.
  4. Build (--build) — vite.
  5. Test (--test) — vitest, plus a11y / w3c setups.
  6. Static analysis (--static-analysis) — cycles, complexity, duplication, centrality.
  7. Runtime analysis (--runtime-analysis) — bundle, Lighthouse, web vitals.
  8. Unified CI workflow + --report --all dashboard.

Open decisions & risks being validated

  • Performance: prefer adopting Rust-native tools (oxlint/biome, tsgo) over hand-written Rust+WASM; reserve custom WASM for a measured hot path only.
  • Validation spike (before wide rollout): wire one real module to sentinel and confirm on real pnpm layout that (1) the editor keeps live lint/type/format, (2) runner binaries + plugins resolve per module, (3) nx target inference survives a runner swap, (4) old-config and sentinel modules coexist during migration.

Contributing

See CONTRIBUTING.md for how to add a tool adapter, from design to test.

License

MIT © Hublo