standardized project configuration loading
Config files, rc files, presets, and layered merge with provenance
Standardized project configuration loading for CLI tools — the project-level complement to appstash (user-level directories).
Give it a tool name and it discovers mytool.config.{ts,js,mjs,cjs}, .mytoolrc{,.json,.yaml,.yml,.js}, mytool.json, and package.json keys via walk-up search, resolves extends/preset chains, and merges layered configuration with per-key provenance:
defaults -> presets (extends) -> user stash -> project file -> env vars -> runtime overrides
npm install confstash- Walk-up discovery: finds config in the current directory or any parent
- Every format:
.config.ts/js/mjs/cjsmodules, rc files (JSON/YAML), JSON,package.jsonkeys - Presets & extends: named presets, relative paths, or npm packages — recursively, with cycle detection
- Layered merge: deterministic precedence with
replaceorconcatarray strategies - Provenance:
explainSync()tells you which layer supplied every value (--print-configUX) - Sync and async: fully synchronous path for CLIs (ESM configs require
load()) - User layer: optional
~/.<tool>/config/config.jsonlayer via appstash - Typed:
defineConfig<T>()+ generic loader for full type safety
import { createConfigLoader } from 'confstash';
interface MyConfig {
level: string;
rules: Record<string, string>;
}
const loader = createConfigLoader<MyConfig>({
tool: 'mytool',
defaults: { level: 'medium', rules: {} }
});
const { config, filepath, layers, isEmpty } = loader.loadSync();
// or: await loader.load() — also supports .mjs / ESM configsconst loader = createConfigLoader<MyConfig>({
tool: 'mytool',
presets: {
'mytool:recommended': { level: 'medium', rules: { A1: 'error' } },
'mytool:strict': { extends: 'mytool:recommended', level: 'high' }
}
});// mytool.config.ts
import { defineConfig } from 'confstash';
export default defineConfig({
extends: 'mytool:recommended',
level: 'high'
});const loader = createConfigLoader<MyConfig>({
tool: 'mytool',
envLayer: (env) => (env.MYTOOL_LEVEL ? { level: env.MYTOOL_LEVEL } : {}),
userStash: true // include ~/.mytool/config/config.json (via appstash)
});
const { config } = loader.loadSync({
overrides: parsedCliFlags // highest precedence
});for (const entry of loader.explainSync()) {
console.log(`${entry.path} = ${JSON.stringify(entry.value)} (${entry.source}: ${entry.origin})`);
}
// level = "high" (file: /repo/mytool.config.ts)
// rules.A1 = "error" (preset: mytool:recommended)// pgpm-compatible discovery
const loader = createConfigLoader({
tool: 'pgpm',
searchPlaces: ['pgpm.config.js', 'pgpm.json'],
arrayMerge: 'replace'
});Options:
tool(string): tool name; drives default search places and the user stash directorysearchPlaces(SearchPlace[]): filenames or{ packageJson: key }entries, in precedence orderdefaults(Partial): lowest-precedence layerpresets(Record<string, Partial>): named presets resolvable viaextendsenvLayer((env) => Partial): map environment variables into a layeruserStash(boolean): include~/.<tool>/config/config.json(defaultfalse)arrayMerge('replace' | 'concat'): array strategy (default'replace')validate((config: T) => T | void): validate/normalize the merged resultwalkUp(boolean): search parent directories (defaulttrue)
Returns: ConfigLoader<T> with load(params), loadSync(params), explainSync(params), searchPlaces.
Load params: cwd, overrides, configFile (skip discovery), env.
defineConfig<T>(config)— identity helper for typed config filesdefaultSearchPlaces(tool)— the derived search place listfindConfigSync(startDir, searchPlaces, walkUp?)/findUpDir(startDir, filename)— discovery primitivesloadFileSync(found)/loadFile(found)— format-aware file loadingdeepMerge(target, source, arrayMerge?)/mergeLayers(layers)/explainLayers(layers)— merge primitivesConfigLoadError— thrown for unreadable/invalid config files
appstash— user-level directories (~/.<tool>/{config,cache,data,logs}) and the optionaluserStashlayer