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

@centient/config-loader

v0.1.0

Published

Layered configuration resolution (env > project > user > defaults) with caching, write-back, and project-root discovery

Readme

@centient/config-loader

Layered configuration resolution for Centient packages. Resolves a single value per key across four layers — environment variables > project config file > user config file > defaults — with caching, non-fatal warnings, write-back to the user layer, tilde-app path helpers, and walk-up project-root discovery.

Zero runtime dependencies. The filesystem, environment, home directory, and logger are all injectable, so the whole loader is testable without touching real disk or process state.

What this package does NOT do

It resolves and layers — it does not validate domain shapes. Validation schemas stay in the consumer. If you need an env string coerced into a typed value before it participates in precedence, supply a per-key EnvCoercer; that is the only place the loader inspects a value's shape.

Installation

npm install @centient/config-loader

Or as a workspace dependency in the monorepo:

pnpm add @centient/config-loader --workspace

Precedence

Highest to lowest. The first layer that supplies a key wins, and the resolved value carries its source for diagnostics.

| Priority | Layer | Source | |----------|-------|--------| | 1 (highest) | env | Environment variables — only keys you bind via envBindings | | 2 | project | .{appName}.json, located by walking up from cwd | | 3 | user | ~/.{appName}/config.json | | 4 (lowest) | default | The defaults option |

No silent fallthrough

A config file that exists but cannot be parsed is a hard ConfigError — never a silent skip to the next layer (no-silent-degradation principle). A missing file is a legitimate empty layer. An EnvCoercer that throws on a malformed env override is likewise a ConfigError, not a dropped value.

Quick Start

import { createConfigLoader } from "@centient/config-loader";

const loader = createConfigLoader({
  appName: "centient",
  defaults: {
    "engram.url": "http://localhost:3100",
    "engram.timeoutMs": 10000,
  },
  envBindings: {
    "engram.url": { env: "ENGRAM_URL" },
    "engram.timeoutMs": {
      env: "CENTIENT_ENGRAM_TIMEOUT_MS",
      coerce: (raw) => {
        const n = Number.parseInt(raw, 10);
        if (!Number.isFinite(n) || n < 0) {
          throw new Error(`must be a non-negative integer; got "${raw}"`);
        }
        return n;
      },
    },
  },
});

// Highest-precedence value for a dotted key.
const url = loader.get<string>("engram.url");

// ...with provenance ("env" | "project" | "user" | "default").
const resolved = loader.getResolved<number>("engram.timeoutMs");
// => { value: 10000, source: "default" }

Keys are dotted; files are nested

Defaults use a flat dotted keyspace; config files use ordinary nested JSON. The loader flattens files to the same dotted keys before layering, so a default and a nested file value for the same logical key always collide correctly.

// ~/.centient/config.json
{ "engram": { "url": "http://user-host:3100" } }
// resolves the key "engram.url"

Environment-reference expansion

String values inside config files support ${VAR} and ${VAR:-default} expansion. An empty env var is treated as unset and falls back to the default.

{ "logs": { "path": "${CENTIENT_LOGS:-~/.centient/logs}" } }

Write-back

write() merges a partial (flat or nested) over the current user config file and persists it. The app home is created 0o700, the file written with configFileMode (default 0o600, asserted on every write), and unrelated keys are preserved. Env-sourced values are never written — write-back targets the user layer only. A partial whose dotted keys contradict the existing file's shape (e.g. setting a.b as a scalar when a.b.c already exists) raises ConfigError("KEY_CONFLICT") rather than silently overwriting.

loader.write({ "engram.apiKey": "...", "engram.userId": "u-1" });

Path helpers

import { resolveAppHome, ensureAppHome, expandTilde } from "@centient/config-loader";

// Resolve ~/.centient (or a CENTIENT_HOME override).
const home = resolveAppHome({ appName: "centient", homeDir });

// Create it 0o700, or tighten it if it already exists with looser bits.
ensureAppHome(fs, home);

expandTilde("~/data", "/home/u"); // -> "/home/u/data"

Project-root discovery

discoverProjectRoot walks up from a start directory looking for the project config file, any configured marker (default [".git"]), or a package.json with a workspaces field. Returns { root, configPath }, both null when nothing matches before the filesystem root.

import { discoverProjectRoot, createNodeFileSystem } from "@centient/config-loader";

const { root, configPath } = discoverProjectRoot(createNodeFileSystem(), {
  startDir: process.cwd(),
  configFilename: ".centient.json",
  markers: [".git"],
});

Caching

Resolution is computed once and cached. Call reload() to recompute after a file changes; write() invalidates the cache automatically.

Testing your integration

Inject in-memory fs/env (and a fixed homeDir/cwd) to make resolution fully deterministic:

const loader = createConfigLoader({
  appName: "centient",
  fs: myInMemoryFs,
  env: { get: (n) => myEnv[n] },
  homeDir: "/home/tester",
  cwd: "/proj",
});

API

| Export | Kind | Purpose | |--------|------|---------| | createConfigLoader(options) | factory | Build a layered loader | | ConfigLoader | type | get / getResolved / has / snapshot / reload / write | | ConfigError, ConfigErrorCode | error | Typed resolution failures | | discoverProjectRoot | fn | Walk-up project-root discovery | | expandTilde, resolveAppHome, ensureAppHome | fn | Tilde-app path helpers | | expandEnvRefs, expandEnvRefsDeep | fn | ${VAR} expansion | | flatten, unflatten | fn | Dotted-key <-> nested conversion | | createNodeFileSystem, createProcessEnv | fn | Default Node adapters | | APP_HOME_MODE (0o700), CONFIG_FILE_MODE (0o600) | const | Enforced perms |

License

MIT