@forwardimpact/libconfig
Advanced tools
| 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; | ||
| } |
+130
| /** | ||
| * 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 @@ }, |
+61
-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. |
+66
-9
@@ -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 |
+3
-0
@@ -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"; |
44975
44.48%8
60%792
56.52%78
358.82%7
-22.22%2
100%- Removed