import { type RootConfig, type RootConfigRead } from "./rootConfigSchema.js"; /** * The root `fjall-config.json` merge, as pure functions. * * The file has two writers — the CLI's `Config.saveConfig` and the webapp * scaffold — and a merge each writer implements separately drifts silently: a * rule tightened on one side ships green through the other's typecheck, lint * and tests. Everything here is bytes-and-values only (no `fs`, no `path`, no * logger) so both writers can call the SAME implementation from their own * I/O boundary. */ /** * On-disk state re-read immediately before a save: the recognised keys * (tolerantly parsed) plus any top-level keys this fjall version does not * recognise, carried through the save verbatim so another version's config * survives ours. */ export interface RootConfigDiskState { known: RootConfigRead; foreign: Record; } /** * Outcome of classifying the existing file. `absent` (no file, or a zero-byte * one) lets a first-ever save proceed unmerged; `unmergeable` (corrupt JSON, a * non-object JSON value, or a malformed recognised key) REFUSES the save — the * caller's in-memory state came through the tolerant stripping read, so writing * it unmerged would silently delete every key the merge could not see (e.g. a * newer version's `capacityIdentity` pins). * * `reason` is pre-masked at construction — every arm in `readRootConfigForMerge` * (this file) routes echoed file content through `maskSensitiveOutput` or is a * static literal, and the CLI's file-read arm (`Config.readDiskConfigForMerge`, * `util/src/config.ts`) masks the same field — so consumers may surface it to * any sink directly without re-masking. */ export type RootConfigMergeRead = { kind: "absent"; } | { kind: "unmergeable"; reason: string; } | { kind: "ok"; disk: RootConfigDiskState; }; /** * Classify the BYTES of an existing fjall-config.json for a merge. `undefined` * and `""` are both `absent` — loader parity: `Config.loadConfig` treats a * zero-byte file as a valid empty config, so there is nothing to preserve and * nothing to refuse over. * * Reasons are masked at construction (JSON parse errors and zod issue text * both echo file content), so callers may surface them to any sink directly. */ export declare function readRootConfigForMerge(raw: string | undefined): RootConfigMergeRead; /** * Session-intent merge: start from the freshly-read disk state and assert ONLY * what the session actually changed relative to its load-time `snapshot`. An * untouched key keeps the disk's current value, the `domains` key merges PER * ENTRY (keyed by name) so concurrently-registered siblings survive, keys this * version does not recognise ride through verbatim, and keys the session * explicitly cleared are removed even when the disk copy still carries them. * * The returned value deliberately carries the foreign keys at runtime - * preserving another version's config is the point, not schema drift on OUR * keys. */ export declare function mergeRootConfig(disk: RootConfigDiskState, live: RootConfig, snapshot: RootConfig): RootConfig; /** * `reason` carries `RootConfigMergeRead.reason` verbatim and is therefore * pre-masked at construction (see that type). */ export type RootConfigMergeResult = { kind: "ok"; content: string; } | { kind: "unmergeable"; reason: string; }; /** * Bytes-in → bytes-out composition of the two functions above: classify the * existing file, merge the session's intent into it, and serialise. An absent * (or zero-byte) file writes `live` as-is. * * `snapshot` defaults to `{}` — the shape of a writer whose whole state IS * session intent, such as the webapp scaffold asserting exactly * `{ organisation }` against a config it never loaded. A writer that loaded * the file first MUST pass the state it loaded, or every key it read back * would be re-asserted over whatever a concurrent process has since written. */ export declare function mergeRootConfigContent(existing: string | undefined, live: RootConfig, snapshot?: RootConfig): RootConfigMergeResult;