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
22
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.77
to
0.1.79
+92
src/bootstrap.js
import path from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { readEnvFile, updateEnvFile } from "@forwardimpact/libsecret";
import { mergeConfigFragment, mergeEnvEntries } from "./merge.js";
import { bootstrapRefusal } from "./errors.js";
/**
* Bootstrap a Forward Impact product directory.
*
* Materialises `target/config/config.json` (always) and `target/.env`
* (when at least one entry is supplied), under namespace-scoped ownership
* semantics. A same-key-different-value write refuses by default with
* `bootstrapRefusal`; pass `overwrites.config` (top-level keys) or
* `overwrites.env` (bare keys) to opt in.
*
* Both surfaces are classified before any filesystem mutation, so a refused
* write never leaves a half-written `config.json` on disk. Cross-file
* atomicity between `config.json` and `.env` mid-write remains deferred
* (spec § *Out of scope*).
*
* @param {object} params
* @param {string} [params.target] Absolute path; defaults to `process.cwd()`.
* @param {object} [params.fragment] Top-level keys are product-owned
* namespaces; may be `{}` or omitted.
* @param {Record<string,string>} [params.env] `.env` entries the product
* wants written; may be `{}` or omitted.
* @param {{ config?: string[], env?: string[] }} [params.overwrites]
* Explicit overwrite intent, partitioned per file. `config` entries are
* top-level namespace names (single segment); `env` entries are bare keys.
* @returns {Promise<void>}
*/
export async function bootstrapProject({
target = process.cwd(),
fragment = {},
env = {},
overwrites = {},
} = {}) {
const configDir = path.join(target, "config");
const configPath = path.join(configDir, "config.json");
const envPath = path.join(target, ".env");
const existingConfig = await readJsonOrEmpty(configPath);
const existingEnv = await readEnvSubset(Object.keys(env), envPath);
const cfg = mergeConfigFragment({
existing: existingConfig,
fragment,
overwrites: overwrites.config ?? [],
});
const ev = mergeEnvEntries({
existing: existingEnv,
fragment: env,
overwrites: overwrites.env ?? [],
});
// Config conflicts take precedence over env conflicts so stderr
// diagnostics surface deterministically regardless of input ordering.
const conflicts = [...cfg.conflicts, ...ev.conflicts];
if (conflicts.length > 0) throw bootstrapRefusal(conflicts[0]);
await mkdir(configDir, { recursive: true });
await writeFile(configPath, JSON.stringify(cfg.result, null, 2) + "\n");
// Iterate the input `env`, not `ev.result`. Same-key-same-value writes are
// idempotent line rewrites in updateEnvFile (the line content is unchanged),
// and updateEnvFile's per-call chmod 0o600 is the mechanism that re-enforces
// mode 0o600 on every invocation — required for the spec's `.env` mode
// criterion to survive a pre-existing .env with mode 0o644. The merged
// `ev.result` is computed for classification only.
for (const [key, value] of Object.entries(env)) {
await updateEnvFile(key, value, envPath);
}
}
async function readJsonOrEmpty(filePath) {
try {
const text = await readFile(filePath, "utf8");
return JSON.parse(text);
} catch (err) {
if (err.code === "ENOENT") return {};
throw err;
}
}
async function readEnvSubset(keys, envPath) {
const out = {};
for (const key of keys) {
const value = await readEnvFile(key, envPath);
if (value !== undefined) out[key] = value;
}
return out;
}
/**
* Construct the refusal `Error` `bootstrapProject` throws when a write
* conflicts with the on-disk state and the caller did not signal overwrite
* intent. The message names the conflicting key and the overwrite-intent
* surface so a contributor reading stderr can suppress the refusal without
* reading the library source; `cause` carries the structured fields for
* programmatic introspection.
*
* @param {{ kind: "config" | "env", path: string }} args
* @returns {Error}
*/
export function bootstrapRefusal({ kind, path }) {
const overwriteSurface =
kind === "config" ? "overwrites.config" : "overwrites.env";
const subject =
kind === "config" ? `config key "${path}"` : `.env key "${path}"`;
const topKey = kind === "config" ? path.split(".")[0] : path;
const err = new Error(
`bootstrapProject: refused to overwrite ${subject}; ` +
`pass ${overwriteSurface}: ["${topKey}"] to allow.`,
);
err.cause = { kind, path, overwriteSurface };
return err;
}
/**
* Pure namespace-ownership classifier used by `bootstrapProject`.
*
* `mergeConfigFragment` enforces ownership at the **top-level key** but
* surfaces diagnostics at the **leaf path** that disagrees — design 1000-c
* Decision #3. `mergeEnvEntries` applies the same three rows at bare-key
* granularity.
*/
function canonicalize(value) {
if (value === undefined) {
throw new Error("canonicalize: undefined not allowed");
}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return JSON.stringify(value);
}
const sortedKeys = Object.keys(value).sort();
const parts = sortedKeys.map(
(k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`,
);
return `{${parts.join(",")}}`;
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Recursively merge `fragment` into `existing`, recording per-leaf
* conflicts at their dotted path. When both sides are plain objects with
* disjoint sub-keys (no leaf disagrees), the result is the deep-merge of
* the two subtrees — this is the cross-namespace-always-succeeds rule
* applied one level deeper than the top-level. When a leaf disagrees
* (or shapes mismatch), the conflict is recorded at that leaf path and
* the existing value is preserved in the partial result (which the
* orchestrator discards when conflicts is non-empty).
*/
function deepMergeOrConflict(existing, fragment, prefix, conflicts) {
if (canonicalize(existing) === canonicalize(fragment)) return existing;
if (isPlainObject(existing) && isPlainObject(fragment)) {
const result = { ...existing };
for (const [key, value] of Object.entries(fragment)) {
const subPath = `${prefix}.${key}`;
if (!(key in existing)) {
result[key] = value;
continue;
}
result[key] = deepMergeOrConflict(
existing[key],
value,
subPath,
conflicts,
);
}
return result;
}
// At least one side is a scalar/array, or the two sides have different
// shapes (object vs scalar) — record the conflict at this path.
conflicts.push({ kind: "config", path: prefix });
return existing;
}
/**
* Classify a config fragment against the on-disk subtree.
* @param {object} params
* @param {object} [params.existing] Current `config.json` contents (or `{}`).
* @param {object} [params.fragment] Caller's proposed contribution.
* @param {string[]} [params.overwrites] Top-level keys the caller has signed
* off to replace wholesale.
* @returns {{ result: object, conflicts: {kind: "config", path: string}[] }}
*/
export function mergeConfigFragment({
existing = {},
fragment = {},
overwrites = [],
} = {}) {
const overwriteSet = new Set(overwrites);
const conflicts = [];
const result = { ...existing };
for (const [topKey, subtree] of Object.entries(fragment)) {
if (!(topKey in existing)) {
result[topKey] = subtree;
continue;
}
if (canonicalize(existing[topKey]) === canonicalize(subtree)) continue;
if (overwriteSet.has(topKey)) {
result[topKey] = subtree;
continue;
}
result[topKey] = deepMergeOrConflict(
existing[topKey],
subtree,
topKey,
conflicts,
);
}
return { result, conflicts };
}
/**
* Classify `.env` entries at bare-key granularity. Value comparison is
* byte-for-byte after `KEY=`.
* @param {object} params
* @param {Record<string,string>} [params.existing]
* @param {Record<string,string>} [params.fragment]
* @param {string[]} [params.overwrites]
* @returns {{ result: Record<string,string>, conflicts: {kind: "env", path: string}[] }}
*/
export function mergeEnvEntries({
existing = {},
fragment = {},
overwrites = [],
} = {}) {
const overwriteSet = new Set(overwrites);
const conflicts = [];
const result = { ...existing };
for (const [key, value] of Object.entries(fragment)) {
if (!(key in existing)) {
result[key] = value;
continue;
}
if (existing[key] === value) continue;
if (overwriteSet.has(key)) {
result[key] = value;
continue;
}
conflicts.push({ kind: "env", path: key });
}
return { result, conflicts };
}
+2
-1
{
"name": "@forwardimpact/libconfig",
"version": "0.1.77",
"version": "0.1.79",
"description": "Environment-aware application settings — services and CLIs load configuration without custom plumbing.",

@@ -31,2 +31,3 @@ "keywords": [

"dependencies": {
"@forwardimpact/libsecret": "^0.1.15",
"@forwardimpact/libstorage": "^0.1.53"

@@ -33,0 +34,0 @@ },

@@ -17,1 +17,62 @@ # libconfig

```
## Bootstrap
A product's `init` verb hands its starter material to `bootstrapProject`,
which writes `config/config.json` and `.env` under namespace-scoped
ownership semantics. Same-key-same-value writes are no-ops; same-key-
different-value writes refuse without explicit overwrite intent, so two
products with disjoint top-level namespaces can converge against the same
target directory.
```js
import { bootstrapProject } from '@forwardimpact/libconfig';
await bootstrapProject({
target, // absolute path; defaults to process.cwd()
fragment: { // top-level keys are product-owned namespaces; {} or omitted is allowed
product: {
guide: { systemPrompt: '…' }, // fit-guide's slice under top-level `product`
},
service: {
mcp: { systemPrompt: '…' }, // fit-guide's slice under top-level `service`
},
},
env: { // .env entries; {} or omitted is allowed
SERVICE_SECRET: '…',
MCP_TOKEN: '…',
},
overwrites: { // explicit overwrite intent, partitioned per file
config: ['product'], // top-level namespace names (single segment)
env: ['MCP_TOKEN'], // bare keys
},
});
```
- **Entry point** — `bootstrapProject({ target, fragment, env, overwrites })`.
Returns `void` on success; throws a refusal `Error` whose `cause` carries
`{ kind, path, overwriteSurface }` when a write conflicts and the caller
did not signal overwrite intent.
- **Namespace declaration** — the top-level keys of `fragment` are the
namespaces a product owns. Use the **nested form** (`{ product: { guide:
… } }`) — that's the shape the libconfig reader resolves and the shape
every in-tree caller emits. Cross-namespace writes (different top-level
keys, or disjoint sub-keys under a shared top-level) never collide;
within a namespace, any leaf disagreement refuses with a leaf-path
diagnostic.
- **Overwrite intent** — pass `overwrites.config: [topLevelKey]` (single-
segment names) or `overwrites.env: [bareKey]` to opt in to replacing
a conflicting value. The refusal message names both the conflicting
leaf path (e.g. `product.guide.systemPrompt`) and the surface; the
overwrite-intent entry remains the **top-level** key (`product`) —
forgiving a single leaf forgives the whole namespace by design, so
pick the smallest top-level that contains the disputed leaf.
- **`.env` primitives** — `bootstrapProject` delegates per-key `.env`
writes to `@forwardimpact/libsecret`'s `updateEnvFile`, which preserves
comment lines, the trailing newline, and mode `0o600`.
`bootstrapProject` always materialises `target/config/config.json` (writing
`{}` when fragment is empty and the file is absent) so subsequent reader
invocations anchor locally rather than upward-walking into an ancestor
`config/`. `.env` is created only when at least one entry is supplied; an
empty `env` against an existing `.env` is a no-op.

@@ -22,2 +22,15 @@ import { execSync } from "node:child_process";

/**
* Parse an env value as JSON when possible, falling back to the raw string.
* @param {string} raw
* @returns {*}
*/
function parseEnvValue(raw) {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
/**
* Parses one line of a .env file.

@@ -54,2 +67,6 @@ * @param {string} line

"MCP_TOKEN",
"PRODUCT_LANDMARK_TOKEN",
"SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY",
"SUPABASE_JWT_SECRET",
]);

@@ -122,13 +139,10 @@

// 3. Environment overrides — SERVICE_{NAME}_{PARAM} env vars win over
// config file values. These are on process.env (set by shell or by
// #loadEnvFile for non-credential keys like SERVICE_MCP_URL).
// config file values. Shell process.env wins over .env #envOverrides.
// Credential keys treat empty string as absent so a workflow ternary
// emitting '' cannot clobber a .env-supplied value; non-credential
// service params keep today's empty-string-wins behaviour.
for (const param of Object.keys(data)) {
const varName = `${namespaceUpper}_${nameUpper}_${param.toUpperCase()}`;
if (this.#process.env[varName] !== undefined) {
try {
data[param] = JSON.parse(this.#process.env[varName]);
} catch {
data[param] = this.#process.env[varName];
}
}
const resolved = this.#resolveOverride(varName);
if (resolved !== undefined) data[param] = resolved;
}

@@ -168,2 +182,22 @@

/** @returns {string} Supabase base URL (trailing slashes stripped) */
supabaseUrl() {
return this.#resolve(["SUPABASE_URL"], stripTrailingSlashes);
}
/** @returns {string} Supabase anon key JWT */
supabaseAnonKey() {
return this.#resolve(["SUPABASE_ANON_KEY"]);
}
/** @returns {string} Supabase service-role key JWT */
supabaseServiceRoleKey() {
return this.#resolve(["SUPABASE_SERVICE_ROLE_KEY"]);
}
/** @returns {string} Supabase HS256 JWT signing secret */
supabaseJwtSecret() {
return this.#resolve(["SUPABASE_JWT_SECRET"]);
}
/**

@@ -313,2 +347,25 @@ * Returns a usable Anthropic credential. Resolution order: env var →

/**
* Resolves a config-param override against shell env then #envOverrides,
* applying credential-key semantics (empty string treated as absent so a
* workflow ternary emitting '' cannot clobber a .env value). Returns the
* JSON-parsed value or raw string, or undefined if nothing is set.
* @param {string} varName - Fully-qualified env var name
* @returns {*|undefined}
* @private
*/
#resolveOverride(varName) {
const isCredential = Config.#CREDENTIAL_KEYS.has(varName);
const shell = this.#process.env[varName];
const shellOk = isCredential
? shell !== undefined && shell !== ""
: shell !== undefined;
if (shellOk) return parseEnvValue(shell);
const fallback = this.#envOverrides[varName];
const fallbackOk = isCredential
? fallback !== undefined && fallback !== ""
: fallback !== undefined;
return fallbackOk ? parseEnvValue(fallback) : undefined;
}
/**
* Cached lookup across one or more environment variable names. Returns

@@ -315,0 +372,0 @@ * the first set value (in array order), trimmed and optionally

@@ -130,1 +130,4 @@ import { createStorage } from "@forwardimpact/libstorage";

export { Config } from "./config.js";
// Writer surface — see src/bootstrap.js
export { bootstrapProject } from "./bootstrap.js";