Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@forwardimpact/libconfig

Package Overview
Dependencies
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@forwardimpact/libconfig - npm Package Compare versions

Comparing version
0.1.82
to
0.1.83
+3
-2
package.json
{
"name": "@forwardimpact/libconfig",
"version": "0.1.82",
"version": "0.1.83",
"description": "Environment-aware application settings — services and CLIs load configuration without custom plumbing.",

@@ -32,3 +32,4 @@ "keywords": [

"@forwardimpact/libsecret": "^0.1.15",
"@forwardimpact/libstorage": "^0.1.53"
"@forwardimpact/libstorage": "^0.1.53",
"@forwardimpact/libutil": "^0.1.60"
},

@@ -35,0 +36,0 @@ "devDependencies": {

+18
-10
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { readEnvFile, updateEnvFile } from "@forwardimpact/libsecret";
import { createDefaultRuntime } from "@forwardimpact/libutil/runtime";

@@ -23,3 +23,3 @@ import { mergeConfigFragment, mergeEnvEntries } from "./merge.js";

* @param {object} params
* @param {string} [params.target] Absolute path; defaults to `process.cwd()`.
* @param {string} [params.target] Absolute path; defaults to `runtime.proc.cwd()`.
* @param {object} [params.fragment] Top-level keys are product-owned

@@ -32,15 +32,23 @@ * namespaces; may be `{}` or omitted.

* top-level namespace names (single segment); `env` entries are bare keys.
* @param {{ runtime?: import("@forwardimpact/libutil/runtime").Runtime }} [params.deps]
* Injected collaborators. `runtime.fs` is used for all filesystem I/O;
* `runtime.proc.cwd()` resolves the default `target`. When omitted the
* production runtime is used (backward-compatible).
* @returns {Promise<void>}
*/
export async function bootstrapProject({
target = process.cwd(),
target,
fragment = {},
env = {},
overwrites = {},
deps,
} = {}) {
const configDir = path.join(target, "config");
const { fs, proc } = deps?.runtime ?? createDefaultRuntime();
const resolvedTarget = target ?? proc.cwd();
const configDir = path.join(resolvedTarget, "config");
const configPath = path.join(configDir, "config.json");
const envPath = path.join(target, ".env");
const envPath = path.join(resolvedTarget, ".env");
const existingConfig = await readJsonOrEmpty(configPath);
const existingConfig = await readJsonOrEmpty(fs, configPath);
const existingEnv = await readEnvSubset(Object.keys(env), envPath);

@@ -63,4 +71,4 @@

await mkdir(configDir, { recursive: true });
await writeFile(configPath, JSON.stringify(cfg.result, null, 2) + "\n");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(cfg.result, null, 2) + "\n");

@@ -78,5 +86,5 @@ // Iterate the input `env`, not `ev.result`. Same-key-same-value writes are

async function readJsonOrEmpty(filePath) {
async function readJsonOrEmpty(fs, filePath) {
try {
const text = await readFile(filePath, "utf8");
const text = await fs.readFile(filePath, "utf8");
return JSON.parse(text);

@@ -83,0 +91,0 @@ } catch (err) {

@@ -1,7 +0,10 @@

import { execSync } from "node:child_process";
import path from "node:path";
import { readFile } from "node:fs/promises";
import { createStorage } from "@forwardimpact/libstorage";
import {
createDefaultProc,
createDefaultRuntime,
} from "@forwardimpact/libutil/runtime";
/** @typedef {import("@forwardimpact/libstorage").StorageInterface} StorageInterface */
/** @typedef {import("@forwardimpact/libutil/runtime").Runtime} Runtime */

@@ -53,2 +56,35 @@ /** @param {string} url */

/**
* Resolve a runtime collaborator from the new or legacy call shapes.
*
* New shape — callers pass `{ runtime }` as the third positional arg; the
* runtime bag carries `{ proc, fs, clock, subprocess }`.
* Legacy shape — callers pass a bare `process`-like object (with `.env` and
* `.cwd()`). This is mapped onto a minimal runtime for one deprecation cycle.
* Absent — falls back to `createDefaultRuntime()`.
*
* @param {object|undefined} runtimeOrProcess
* @returns {{ proc: object, fs: object, clock: object }}
*/
function resolveRuntime(runtimeOrProcess) {
if (!runtimeOrProcess) {
return createDefaultRuntime();
}
// New shape: { runtime: <bag> }
if (runtimeOrProcess.runtime) {
return runtimeOrProcess.runtime;
}
// Legacy shape: bare process-like object ({ env, cwd })
// Map onto a minimal runtime, preserving the original proc surface.
const legacyProc =
typeof runtimeOrProcess.cwd === "function"
? runtimeOrProcess
: createDefaultProc({ source: runtimeOrProcess });
const defaultRt = createDefaultRuntime();
return {
...defaultRt,
proc: legacyProc,
};
}
/**
* Centralized configuration management class

@@ -58,5 +94,5 @@ */

// Keys containing secrets or tokens. Values from .env are loaded into
// a private map (#envOverrides) instead of process.env, so they never
// leak through child-process inheritance or process.env inspection.
// Getter methods read via #env(), which checks process.env first —
// a private map (#envOverrides) instead of proc.env, so they never
// leak through child-process inheritance or proc.env inspection.
// Getter methods read via #env(), which checks proc.env first —
// so shell-exported credentials still work; .env is the fallback.

@@ -84,14 +120,26 @@ static #CREDENTIAL_KEYS = new Set([

#storage = null;
#process;
#proc;
#fs;
#clock;
#storageFn;
#execSync;
#subprocess;
/**
* Creates a new Config instance
* @param {string} namespace - Namespace for the configuration (e.g., "service", "extension")
* @param {string} name - Name of the configuration (used for environment variable prefix)
* @param {object} defaults - Default configuration values
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function that takes basePath and returns storage instance
* @param {(command: string, options?: object) => Buffer | string} execSyncFn - Optional child_process.execSync override (for testing)
* Creates a new Config instance.
*
* Preferred call shape (new):
* `new Config(namespace, name, defaults, { runtime }, storageFn)`
* where `runtime` is a runtime bag from `createDefaultRuntime()` or
* `createTestRuntime()`.
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `new Config(namespace, name, defaults, process, storageFn)`
* where `process` is a bare process-like object with `.env` and `.cwd()`.
*
* @param {string} namespace - Namespace for the configuration
* @param {string} name - Name of the configuration
* @param {object} [defaults] - Default configuration values
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper
* or legacy bare process object
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
*/

@@ -102,9 +150,11 @@ constructor(

defaults = {},
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
execSyncFn = execSync,
) {
this.#process = process;
const rt = resolveRuntime(runtimeOrProcess);
this.#proc = rt.proc;
this.#fs = rt.fs;
this.#clock = rt.clock;
this.#storageFn = storageFn;
this.#execSync = execSyncFn;
this.#subprocess = rt.subprocess;

@@ -121,6 +171,6 @@ this.name = name;

async load() {
this.#storage = this.#storageFn("config", null, this.#process);
this.#storage = this.#storageFn("config", null, this.#proc);
// 1. Load .env — credentials go to #envOverrides, everything else
// goes to process.env (so SERVICE_*_URL etc. are available below)
// goes to proc.env (so SERVICE_*_URL etc. are available below)
await this.#loadEnvFile();

@@ -145,3 +195,3 @@ // 2. Load config/config.json for file-based configuration

// 3. Environment overrides — SERVICE_{NAME}_{PARAM} env vars win over
// config file values. Shell process.env wins over .env #envOverrides.
// config file values. Shell proc.env wins over .env #envOverrides.
// Credential keys treat empty string as absent so a workflow ternary

@@ -235,3 +285,3 @@ // emitting '' cannot clobber a .env-supplied value; non-credential

if (Date.now() >= oauth.expires_at - 5 * 60 * 1000) {
if (this.#clock.now() >= oauth.expires_at - 5 * 60 * 1000) {
try {

@@ -329,3 +379,3 @@ const refreshed = await this.#refreshOAuthToken(oauth.refresh_token);

const tokenUrl =
this.#process.env.ANTHROPIC_OAUTH_TOKEN_URL ||
this.#proc.env.ANTHROPIC_OAUTH_TOKEN_URL ||
"https://auth.anthropic.com/oauth/token";

@@ -347,3 +397,3 @@ const res = await fetch(tokenUrl, {

refresh_token: body.refresh_token ?? refreshToken,
expires_at: Date.now() + (body.expires_in ?? 3600) * 1000,
expires_at: this.#clock.now() + (body.expires_in ?? 3600) * 1000,
};

@@ -353,5 +403,5 @@ }

/**
* Resolves an environment variable. Shell environment (process.env)
* Resolves an environment variable. Shell environment (proc.env)
* always wins; .env credential values in #envOverrides are the fallback.
* Non-credential .env keys are already on process.env (set by #loadEnvFile).
* Non-credential .env keys are already on proc.env (set by #loadEnvFile).
* @param {string} key - Environment variable name

@@ -362,3 +412,3 @@ * @returns {string|undefined}

#env(key) {
return this.#process.env[key] ?? this.#envOverrides[key];
return this.#proc.env[key] ?? this.#envOverrides[key];
}

@@ -377,3 +427,3 @@

const isCredential = Config.#CREDENTIAL_KEYS.has(varName);
const shell = this.#process.env[varName];
const shell = this.#proc.env[varName];
const shellOk = isCredential

@@ -396,3 +446,3 @@ ? shell !== undefined && shell !== ""

* @param {string[]} keys - Environment variable names in priority order
* @param {((v: string) => string)|null} [transform] - Optional value transform (e.g. strip slashes)
* @param {((v: string) => string)|null} [transform] - Optional value transform
* @returns {string}

@@ -419,3 +469,6 @@ * @private

/**
* Spawns `gh auth token` and caches the result under the GH_TOKEN key.
* Shells out to `gh auth token` (synchronously, via the injected
* `runtime.subprocess.runSync`) and caches the result under the GH_TOKEN
* key. The sync surface is used because `ghToken()` is a synchronous
* accessor called across the codebase.
* @returns {string}

@@ -429,7 +482,9 @@ * @private

try {
token = this.#execSync("gh auth token", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
env: this.#process.env,
}).trim();
const result = this.#subprocess.runSync("gh", ["auth", "token"], {
env: this.#proc.env,
});
if (result.exitCode !== 0) {
throw new Error(`gh auth token exited ${result.exitCode}`);
}
token = (result.stdout ?? "").trim();
} catch {

@@ -454,4 +509,4 @@ throw new Error(

* Credential keys (tokens, secrets) are loaded into a private override
* map so they never leak onto process.env. All other keys (SERVICE_*_URL,
* LLM_BASE_URL, etc.) are set directly on process.env when not already
* map so they never leak onto proc.env. All other keys (SERVICE_*_URL,
* LLM_BASE_URL, etc.) are set directly on proc.env when not already
* present.

@@ -463,4 +518,4 @@ * @returns {Promise<void>}

try {
const envPath = path.join(this.#process.cwd(), ".env");
const content = await readFile(envPath, "utf8");
const envPath = path.join(this.#proc.cwd(), ".env");
const content = await this.#fs.readFile(envPath, "utf8");
for (const line of content.split("\n")) {

@@ -480,14 +535,14 @@ const parsed = parseEnvLine(line);

// Credentials → private map. #env() reads them without
// exposing them on process.env. Shell env still wins at
// read time because #env() checks process.env first.
// exposing them on proc.env. Shell env still wins at
// read time because #env() checks proc.env first.
this.#envOverrides[key] = value;
} else {
// Non-credentials → process.env unconditionally. The .env file
// Non-credentials → proc.env unconditionally. The .env file
// is the persistent source of truth for SERVICE_* URLs and other
// runtime config. Supervised processes (svscan children) inherit
// their parent's process.env, so a stale inherited value is
// their parent's proc.env, so a stale inherited value is
// indistinguishable from an explicit shell export — always
// applying the .env value ensures that editing .env and
// restarting the service picks up the change.
this.#process.env[key] = value;
this.#proc.env[key] = value;
}

@@ -494,0 +549,0 @@ }

@@ -5,11 +5,19 @@ import { createStorage } from "@forwardimpact/libstorage";

/** @typedef {import("@forwardimpact/libstorage").StorageInterface} StorageInterface */
/** @typedef {import("@forwardimpact/libutil/runtime").Runtime} Runtime */
/**
* Creates and initializes a new Config instance asynchronously
* @param {string} namespace - Namespace for the configuration (e.g., "service", "extension", "script")
* @param {string} name - Name of the configuration (used for environment variable prefix)
* @param {object} defaults - Default configuration values
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function that takes basePath and returns storage instance
* @param {(command: string, options?: object) => Buffer | string} [execSyncFn] - Optional child_process.execSync override (for testing)
* Creates and initializes a new Config instance asynchronously.
*
* Preferred call shape (new):
* `createConfig(namespace, name, defaults, { runtime }, storageFn)`
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `createConfig(namespace, name, defaults, process, storageFn)`
*
* @param {string} namespace - Namespace for the configuration
* @param {string} name - Name of the configuration
* @param {object} [defaults] - Default configuration values
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper or
* legacy bare process object. Defaults to the production runtime when omitted.
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
* @returns {Promise<Config>} Initialized Config instance

@@ -21,5 +29,4 @@ */

defaults = {},
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
execSyncFn,
) {

@@ -30,5 +37,4 @@ const instance = new Config(

defaults,
process,
runtimeOrProcess,
storageFn,
execSyncFn,
);

@@ -40,7 +46,15 @@ await instance.load();

/**
* Creates and initializes a new service configuration instance asynchronously
* Creates and initializes a new service configuration instance asynchronously.
*
* Preferred call shape (new):
* `createServiceConfig(name, defaults, { runtime }, storageFn)`
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `createServiceConfig(name, defaults, process, storageFn)`
*
* @param {string} name - Name of the service configuration
* @param {object} defaults - Default configuration values
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function
* @param {object} [defaults] - Default configuration values
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper or
* legacy bare process object
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
* @returns {Promise<Config>} Initialized Config instance for service namespace

@@ -51,6 +65,12 @@ */

defaults = {},
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
) {
const instance = new Config("service", name, defaults, process, storageFn);
const instance = new Config(
"service",
name,
defaults,
runtimeOrProcess,
storageFn,
);
await instance.load();

@@ -61,7 +81,15 @@ return instance;

/**
* Creates and initializes a new extension configuration instance asynchronously
* Creates and initializes a new extension configuration instance asynchronously.
*
* Preferred call shape (new):
* `createExtensionConfig(name, defaults, { runtime }, storageFn)`
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `createExtensionConfig(name, defaults, process, storageFn)`
*
* @param {string} name - Name of the extension configuration
* @param {object} defaults - Default configuration values
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function
* @param {object} [defaults] - Default configuration values
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper or
* legacy bare process object
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
* @returns {Promise<Config>} Initialized Config instance for extension namespace

@@ -72,6 +100,12 @@ */

defaults = {},
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
) {
const instance = new Config("extension", name, defaults, process, storageFn);
const instance = new Config(
"extension",
name,
defaults,
runtimeOrProcess,
storageFn,
);
await instance.load();

@@ -82,7 +116,15 @@ return instance;

/**
* Creates and initializes a new script configuration instance asynchronously
* Creates and initializes a new script configuration instance asynchronously.
*
* Preferred call shape (new):
* `createScriptConfig(name, defaults, { runtime }, storageFn)`
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `createScriptConfig(name, defaults, process, storageFn)`
*
* @param {string} name - Name of the script configuration
* @param {object} defaults - Default configuration values
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function
* @param {object} [defaults] - Default configuration values
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper or
* legacy bare process object
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
* @returns {Promise<Config>} Initialized Config instance for script namespace

@@ -93,6 +135,12 @@ */

defaults = {},
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
) {
const instance = new Config("script", name, defaults, process, storageFn);
const instance = new Config(
"script",
name,
defaults,
runtimeOrProcess,
storageFn,
);
await instance.load();

@@ -103,7 +151,15 @@ return instance;

/**
* Creates and initializes a new product configuration instance asynchronously
* Creates and initializes a new product configuration instance asynchronously.
*
* Preferred call shape (new):
* `createProductConfig(name, defaults, { runtime }, storageFn)`
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `createProductConfig(name, defaults, process, storageFn)`
*
* @param {string} name - Name of the product configuration
* @param {object} defaults - Default configuration values
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function
* @param {object} [defaults] - Default configuration values
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper or
* legacy bare process object
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
* @returns {Promise<Config>} Initialized Config instance for product namespace

@@ -114,6 +170,12 @@ */

defaults = {},
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
) {
const instance = new Config("product", name, defaults, process, storageFn);
const instance = new Config(
"product",
name,
defaults,
runtimeOrProcess,
storageFn,
);
await instance.load();

@@ -126,11 +188,19 @@ return instance;

* Used by rc.js to bootstrap services.
* @param {object} process - Process environment access
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} storageFn - Optional storage factory function
*
* Preferred call shape (new):
* `createInitConfig({ runtime }, storageFn)`
*
* Legacy call shape (one-cycle deprecation alias — still supported):
* `createInitConfig(process, storageFn)`
*
* @param {{ runtime: Runtime }|object} [runtimeOrProcess] - Runtime bag wrapper or
* legacy bare process object
* @param {(bucket: string, type?: string, process?: object) => StorageInterface} [storageFn]
* @returns {Promise<Config>} Initialized Config instance with init() and rootDir()
*/
export async function createInitConfig(
process = global.process,
runtimeOrProcess = undefined,
storageFn = createStorage,
) {
const instance = new Config("init", "rc", {}, process, storageFn);
const instance = new Config("init", "rc", {}, runtimeOrProcess, storageFn);
await instance.load();

@@ -137,0 +207,0 @@ return instance;