@untangle-code/config 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE +90 -0
  3. package/README.md +262 -2
  4. package/THIRD_PARTY_LICENSES.md +29 -0
  5. package/dist/chunker-budget.d.ts +58 -0
  6. package/dist/chunker-budget.d.ts.map +1 -0
  7. package/dist/chunker-budget.js +11 -0
  8. package/dist/chunker-budget.js.map +1 -0
  9. package/dist/defaults.d.ts +50 -0
  10. package/dist/defaults.d.ts.map +1 -0
  11. package/dist/defaults.js +179 -0
  12. package/dist/defaults.js.map +1 -0
  13. package/dist/discovery.d.ts +40 -0
  14. package/dist/discovery.d.ts.map +1 -0
  15. package/dist/discovery.js +76 -0
  16. package/dist/discovery.js.map +1 -0
  17. package/dist/env.d.ts +37 -0
  18. package/dist/env.d.ts.map +1 -0
  19. package/dist/env.js +100 -0
  20. package/dist/env.js.map +1 -0
  21. package/dist/hash.d.ts +37 -0
  22. package/dist/hash.d.ts.map +1 -0
  23. package/dist/hash.js +52 -0
  24. package/dist/hash.js.map +1 -0
  25. package/dist/index.d.ts +22 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +37 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/load.d.ts +61 -0
  30. package/dist/load.d.ts.map +1 -0
  31. package/dist/load.js +264 -0
  32. package/dist/load.js.map +1 -0
  33. package/dist/merge.d.ts +32 -0
  34. package/dist/merge.d.ts.map +1 -0
  35. package/dist/merge.js +65 -0
  36. package/dist/merge.js.map +1 -0
  37. package/dist/migrations.d.ts +26 -0
  38. package/dist/migrations.d.ts.map +1 -0
  39. package/dist/migrations.js +40 -0
  40. package/dist/migrations.js.map +1 -0
  41. package/dist/parse.d.ts +7 -0
  42. package/dist/parse.d.ts.map +1 -0
  43. package/dist/parse.js +14 -0
  44. package/dist/parse.js.map +1 -0
  45. package/dist/paths.d.ts +14 -0
  46. package/dist/paths.d.ts.map +1 -0
  47. package/dist/paths.js +37 -0
  48. package/dist/paths.js.map +1 -0
  49. package/dist/schema.d.ts +5 -0
  50. package/dist/schema.d.ts.map +1 -0
  51. package/dist/schema.js +19 -0
  52. package/dist/schema.js.map +1 -0
  53. package/dist/secrets.d.ts +33 -0
  54. package/dist/secrets.d.ts.map +1 -0
  55. package/dist/secrets.js +69 -0
  56. package/dist/secrets.js.map +1 -0
  57. package/dist/types.d.ts +323 -0
  58. package/dist/types.d.ts.map +1 -0
  59. package/dist/types.js +20 -0
  60. package/dist/types.js.map +1 -0
  61. package/dist/user-config.d.ts +13 -0
  62. package/dist/user-config.d.ts.map +1 -0
  63. package/dist/user-config.js +34 -0
  64. package/dist/user-config.js.map +1 -0
  65. package/dist/validate.d.ts +36 -0
  66. package/dist/validate.d.ts.map +1 -0
  67. package/dist/validate.js +337 -0
  68. package/dist/validate.js.map +1 -0
  69. package/package.json +65 -3
  70. package/schemas/config-v1.json +375 -0
  71. package/src/chunker-budget.ts +63 -0
  72. package/src/defaults.ts +207 -0
  73. package/src/discovery.ts +95 -0
  74. package/src/env.ts +141 -0
  75. package/src/hash.ts +71 -0
  76. package/src/index.ts +51 -0
  77. package/src/load.ts +346 -0
  78. package/src/merge.ts +69 -0
  79. package/src/migrations.ts +64 -0
  80. package/src/parse.ts +18 -0
  81. package/src/paths.ts +42 -0
  82. package/src/schema.ts +23 -0
  83. package/src/secrets.ts +78 -0
  84. package/src/types.ts +366 -0
  85. package/src/user-config.ts +48 -0
  86. package/src/validate.ts +430 -0
package/src/load.ts ADDED
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Top-level configuration loading: discovery + six-level layering (spec.md
3
+ * §4.1) + path resolution (spec.md §4.3) + validation (spec.md §4.8) +
4
+ * canonical hashing and embedding profile derivation (architecture.md §6.3).
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
9
+ import { buildWorkspaceId } from "@untangle-code/domain";
10
+ import { parse as parseToml } from "smol-toml";
11
+
12
+ import { PRODUCT_DEFAULT_CONFIG } from "./defaults.js";
13
+ import { discoverConfigFiles } from "./discovery.js";
14
+ import { buildEnvConfigLayer } from "./env.js";
15
+ import { computeConfigHash, deriveEmbeddingProfileId } from "./hash.js";
16
+ import { mergeConfigLayers } from "./merge.js";
17
+ import { migrateConfig } from "./migrations.js";
18
+ import { resolveConfigPath } from "./paths.js";
19
+ import type { RawConfigLayer, EffectiveUntangleConfigV1 } from "./types.js";
20
+ import { resolveUserConfigPath } from "./user-config.js";
21
+ import type { ConfigIssue } from "./validate.js";
22
+ import { validateEffectiveConfig } from "./validate.js";
23
+
24
+ export interface LoadConfigOptions {
25
+ /** Directory to start upward config-file discovery from. Defaults to `process.cwd()`. */
26
+ cwd?: string;
27
+ /** Highest-priority layer (spec.md §4.1 layer 1): explicit API/CLI arguments. */
28
+ explicit?: RawConfigLayer;
29
+ env?: NodeJS.ProcessEnv;
30
+ platform?: NodeJS.Platform;
31
+ }
32
+
33
+ export interface ConfigSources {
34
+ userConfigPath: string | undefined;
35
+ projectConfigPath: string | undefined;
36
+ /**
37
+ * `untangle.toml` (layer 4b) — the file `untangle init` writes. Reported
38
+ * separately so a diagnostic command can name the file the user actually
39
+ * has instead of the one it expected (WP-222).
40
+ */
41
+ tomlConfigPath: string | undefined;
42
+ localConfigPath: string | undefined;
43
+ explicitProvided: boolean;
44
+ /** `UNTANGLE_*` variables from `ENV_MAPPING` that were actually set. */
45
+ envVarsApplied: string[];
46
+ /**
47
+ * Fallback directory shown for diagnostics only: `.untangle.json`'s
48
+ * directory, else `untangle.toml`'s, else `.untangle.local.json`'s, else
49
+ * `cwd`. It is NOT
50
+ * necessarily what `repositories`/`contextArtifacts` paths were resolved
51
+ * against — each of those resolves against the directory of whichever
52
+ * layer actually defined it (spec.md §4.3; see `loadConfig`'s
53
+ * `originBaseDirFor`), which may be the OS user config directory or `cwd`
54
+ * (env/explicit layers) instead.
55
+ */
56
+ configBaseDir: string;
57
+ /** Logical project root for `.untangle/config.json`; legacy layouts keep their existing base. */
58
+ projectRoot?: string;
59
+ }
60
+
61
+ export interface LoadConfigResult {
62
+ effectiveConfig: EffectiveUntangleConfigV1;
63
+ warnings: ConfigIssue[];
64
+ errors: ConfigIssue[];
65
+ sources: ConfigSources;
66
+ /** Canonical effective-config hash; Qdrant excludes additive storage fields for legacy identity. */
67
+ configHash: string;
68
+ /** `buildWorkspaceId({ explicitId: effectiveConfig.workspaceId, configHash })` (spec.md §5.1). */
69
+ workspaceId: string;
70
+ /** `buildEmbeddingProfileId` over the derived profile (spec.md §5.7). */
71
+ embeddingProfileId: string;
72
+ }
73
+
74
+ interface FileLayerOutcome {
75
+ layer: RawConfigLayer;
76
+ error: ConfigIssue | undefined;
77
+ }
78
+
79
+ function readJsonConfigFile(path: string): FileLayerOutcome {
80
+ let text: string;
81
+ try {
82
+ text = readFileSync(path, "utf8");
83
+ } catch (error) {
84
+ return {
85
+ layer: {},
86
+ error: {
87
+ code: "CONFIG_INVALID",
88
+ message: `failed to read config file "${path}": ${String(error)}`,
89
+ path,
90
+ },
91
+ };
92
+ }
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(text);
96
+ } catch (error) {
97
+ return {
98
+ layer: {},
99
+ error: {
100
+ code: "CONFIG_INVALID",
101
+ message: `"${path}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
102
+ path,
103
+ },
104
+ };
105
+ }
106
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
107
+ return {
108
+ layer: {},
109
+ error: { code: "CONFIG_INVALID", message: `"${path}" must contain a JSON object`, path },
110
+ };
111
+ }
112
+ const migrated = migrateConfig(parsed as Record<string, unknown>);
113
+ return { layer: migrated.config as RawConfigLayer, error: undefined };
114
+ }
115
+
116
+ /**
117
+ * Reads `untangle.toml` into the same raw layer shape a JSON config produces.
118
+ *
119
+ * The TOML file `untangle init` writes carries the SAME keys as
120
+ * `.untangle.json` — `schemaVersion`, `repositories`, `qdrant`, `embeddings`
121
+ * and so on — so it needs no separate schema, only a separate parser. It
122
+ * runs through `migrateConfig` and the ordinary validation like every other
123
+ * layer; a typo in it is reported the same way, in the same list, against
124
+ * the same paths.
125
+ */
126
+ function readTomlConfigFile(path: string): FileLayerOutcome {
127
+ let text: string;
128
+ try {
129
+ text = readFileSync(path, "utf8");
130
+ } catch (error) {
131
+ return {
132
+ layer: {},
133
+ error: {
134
+ code: "CONFIG_INVALID",
135
+ message: `failed to read config file "${path}": ${String(error)}`,
136
+ path,
137
+ },
138
+ };
139
+ }
140
+ let parsed: unknown;
141
+ try {
142
+ parsed = parseToml(text);
143
+ } catch (error) {
144
+ return {
145
+ layer: {},
146
+ error: {
147
+ code: "CONFIG_INVALID",
148
+ message: `"${path}" is not valid TOML: ${error instanceof Error ? error.message : String(error)}`,
149
+ path,
150
+ },
151
+ };
152
+ }
153
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
154
+ return {
155
+ layer: {},
156
+ error: { code: "CONFIG_INVALID", message: `"${path}" must contain a TOML table`, path },
157
+ };
158
+ }
159
+ const migrated = migrateConfig(parsed as Record<string, unknown>);
160
+ return { layer: migrated.config as RawConfigLayer, error: undefined };
161
+ }
162
+
163
+ /**
164
+ * Loads and resolves the effective Untangle configuration through all six
165
+ * layers of spec.md §4.1: explicit args > env (`UNTANGLE_*`) >
166
+ * `.untangle.local.json` > `.untangle.json` > OS user config > product
167
+ * defaults. Never throws for validation problems — see {@link ConfigIssue}
168
+ * and `validate.ts`'s `assertConfigValid` for the "MUST abort" behavior
169
+ * spec.md §4.8 requires from a caller.
170
+ */
171
+ export function loadConfig(options: LoadConfigOptions = {}): LoadConfigResult {
172
+ const cwd = options.cwd ?? process.cwd();
173
+ const env = options.env ?? process.env;
174
+ const platform = options.platform ?? process.platform;
175
+
176
+ const { projectConfigPath, tomlConfigPath, localConfigPath, projectRoot } =
177
+ discoverConfigFiles(cwd);
178
+ const userConfigPath = resolveUserConfigPath(env, platform);
179
+
180
+ const errors: ConfigIssue[] = [];
181
+
182
+ const userLayerOutcome: FileLayerOutcome = userConfigPath
183
+ ? readJsonConfigFile(userConfigPath)
184
+ : { layer: {}, error: undefined };
185
+ const projectLayerOutcome: FileLayerOutcome = projectConfigPath
186
+ ? readJsonConfigFile(projectConfigPath)
187
+ : { layer: {}, error: undefined };
188
+ const tomlLayerOutcome: FileLayerOutcome = tomlConfigPath
189
+ ? readTomlConfigFile(tomlConfigPath)
190
+ : { layer: {}, error: undefined };
191
+ const localLayerOutcome: FileLayerOutcome = localConfigPath
192
+ ? readJsonConfigFile(localConfigPath)
193
+ : { layer: {}, error: undefined };
194
+ for (const outcome of [
195
+ userLayerOutcome,
196
+ tomlLayerOutcome,
197
+ projectLayerOutcome,
198
+ localLayerOutcome,
199
+ ]) {
200
+ if (outcome.error) {
201
+ errors.push(outcome.error);
202
+ }
203
+ }
204
+
205
+ const envLayerResult = buildEnvConfigLayer(env);
206
+ for (const parseError of envLayerResult.parseErrors) {
207
+ errors.push({
208
+ code: "CONFIG_INVALID",
209
+ message: `environment variable ${parseError.env}: ${parseError.message}`,
210
+ path: parseError.env,
211
+ });
212
+ }
213
+
214
+ const explicitLayer: RawConfigLayer = options.explicit ?? {};
215
+
216
+ // Each layer's OWN directory (spec.md §4.3: a path resolves relative to
217
+ // the config file that declared it). Layers with no backing file (product
218
+ // defaults, the env layer, explicit args) have no directory of their own,
219
+ // so they fall back to `cwd` — the directory the caller is running from.
220
+ const namedLayers: readonly { layer: RawConfigLayer; baseDir: string }[] = [
221
+ { layer: PRODUCT_DEFAULT_CONFIG, baseDir: cwd },
222
+ { layer: userLayerOutcome.layer, baseDir: userConfigPath ? dirname(userConfigPath) : cwd },
223
+ // Layer 4b, directly beneath `.untangle.json`: `untangle.toml`. Where
224
+ // both exist the JSON file wins — and `warnings` says so out loud, so a
225
+ // user never wonders why an edit had no effect (WP-222).
226
+ { layer: tomlLayerOutcome.layer, baseDir: tomlConfigPath ? dirname(tomlConfigPath) : cwd },
227
+ {
228
+ layer: projectLayerOutcome.layer,
229
+ baseDir: projectConfigPath ? dirname(projectConfigPath) : cwd,
230
+ },
231
+ { layer: localLayerOutcome.layer, baseDir: localConfigPath ? dirname(localConfigPath) : cwd },
232
+ { layer: envLayerResult.layer, baseDir: cwd },
233
+ { layer: explicitLayer, baseDir: cwd },
234
+ ];
235
+
236
+ // Preserve existing Qdrant projects without letting the product-default
237
+ // qdrant section choose a backend for new projects. This compatibility
238
+ // default sits below every user layer: an explicit storage.backend at any
239
+ // priority overrides it, while storage.path alone never migrates an index.
240
+ const configuredLayers = namedLayers.slice(1).map((named) => named.layer);
241
+ const legacyStorageDefault: RawConfigLayer = configuredLayers.some(
242
+ (layer) => layer.qdrant !== undefined,
243
+ )
244
+ ? { storage: { backend: "qdrant" } }
245
+ : {};
246
+ const merged = mergeConfigLayers<Record<string, unknown>>([
247
+ PRODUCT_DEFAULT_CONFIG,
248
+ legacyStorageDefault,
249
+ ...configuredLayers,
250
+ ]);
251
+
252
+ const configBaseDir = projectConfigPath
253
+ ? dirname(projectConfigPath)
254
+ : tomlConfigPath
255
+ ? dirname(tomlConfigPath)
256
+ : localConfigPath
257
+ ? dirname(localConfigPath)
258
+ : cwd;
259
+
260
+ /**
261
+ * `mergeConfigLayers` REPLACES arrays wholesale rather than concatenating
262
+ * them (merge.ts) — so the final `repositories`/`contextArtifacts` array
263
+ * in `merged` originates entirely from ONE layer: the highest-priority
264
+ * layer that defined it. Its entries' `path` fields MUST resolve against
265
+ * THAT layer's own directory (spec.md §4.3), not a single global
266
+ * `configBaseDir` computed after the merge — otherwise a repository
267
+ * defined in the OS user config (or via an env/explicit layer) silently
268
+ * loses its origin and resolves against the wrong directory. Walking
269
+ * `namedLayers` highest-priority-first and taking the first one that set
270
+ * `key` recovers exactly the layer the merge itself used.
271
+ */
272
+ function originBaseDirFor(key: "repositories" | "contextArtifacts"): string {
273
+ for (let i = namedLayers.length - 1; i >= 0; i -= 1) {
274
+ const candidate = namedLayers[i];
275
+ if (candidate && Array.isArray((candidate.layer as Record<string, unknown>)[key])) {
276
+ return candidate.baseDir;
277
+ }
278
+ }
279
+ return cwd;
280
+ }
281
+
282
+ if (Array.isArray(merged["repositories"])) {
283
+ const baseDir = originBaseDirFor("repositories");
284
+ merged["repositories"] = (merged["repositories"] as { path: string }[]).map((repo) => ({
285
+ ...repo,
286
+ path: resolveConfigPath(repo.path, baseDir),
287
+ }));
288
+ }
289
+ if (Array.isArray(merged["contextArtifacts"])) {
290
+ const baseDir = originBaseDirFor("contextArtifacts");
291
+ merged["contextArtifacts"] = (merged["contextArtifacts"] as { path: string }[]).map(
292
+ (artifact) => ({
293
+ ...artifact,
294
+ path: resolveConfigPath(artifact.path, baseDir),
295
+ }),
296
+ );
297
+ }
298
+
299
+ const { errors: validationErrors, warnings } = validateEffectiveConfig(merged);
300
+ errors.push(...validationErrors);
301
+
302
+ if (projectConfigPath !== undefined && tomlConfigPath !== undefined) {
303
+ warnings.push({
304
+ code: "CONFIG_DUPLICATE_PROJECT_FILE",
305
+ message:
306
+ `both "${projectConfigPath}" and "${tomlConfigPath}" exist; ` +
307
+ `"${projectConfigPath}" wins for every key it sets`,
308
+ path: "(project config)",
309
+ });
310
+ }
311
+
312
+ const effectiveConfig = merged as unknown as EffectiveUntangleConfigV1;
313
+ const configHash = computeConfigHash(effectiveConfig);
314
+ const workspaceId = buildWorkspaceId(
315
+ effectiveConfig.workspaceId === undefined
316
+ ? { configHash }
317
+ : { explicitId: effectiveConfig.workspaceId, configHash },
318
+ );
319
+ const embeddingProfileId = deriveEmbeddingProfileId(effectiveConfig);
320
+
321
+ return {
322
+ effectiveConfig,
323
+ warnings,
324
+ errors,
325
+ sources: {
326
+ userConfigPath,
327
+ projectConfigPath,
328
+ tomlConfigPath,
329
+ localConfigPath,
330
+ explicitProvided: options.explicit !== undefined,
331
+ envVarsApplied: Object.keys(env).filter(
332
+ (key) => key.startsWith("UNTANGLE_") && env[key] !== undefined,
333
+ ),
334
+ configBaseDir,
335
+ ...(projectRoot === undefined ? {} : { projectRoot }),
336
+ },
337
+ configHash,
338
+ workspaceId,
339
+ embeddingProfileId,
340
+ };
341
+ }
342
+
343
+ /** Re-exported for callers that only need to check whether a discovered path exists (rare; mostly test convenience). */
344
+ export function configFileExists(path: string | undefined): boolean {
345
+ return path !== undefined && existsSync(path);
346
+ }
package/src/merge.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Deep merge for the layering pipeline (spec.md §4.1).
3
+ *
4
+ * Array semantics: arrays REPLACE, they never concatenate. A layer with
5
+ * `index.extraExtensions: [".proto"]` fully replaces a lower layer's array —
6
+ * two layers are never spliced together, because there is no reasonable way
7
+ * to know whether a higher layer meant to add to or reset a list (e.g.
8
+ * `exclude` globs), and silent concatenation would make higher-priority
9
+ * layers unable to shrink a list at all.
10
+ *
11
+ * Relevance-policy bindings are another deliberate replacement boundary:
12
+ * entries below `search.relevancePolicies.<profileId>` are calibrated,
13
+ * corpus-bound units. Merging a new workspace/collection with thresholds
14
+ * inherited from a lower layer would silently apply one corpus's policy to
15
+ * another corpus.
16
+ */
17
+
18
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ /**
23
+ * Merges `override` onto `base`. Plain objects merge key by key (recursively);
24
+ * arrays, primitives, `null` and any non-plain object (e.g. would-be class
25
+ * instances) from `override` replace the corresponding value in `base`
26
+ * wholesale. `undefined` values in `override` are treated as "not set" and do
27
+ * not overwrite a present `base` value — this lets a layer omit a field
28
+ * without needing to know the value from lower layers.
29
+ */
30
+ export function deepMergeConfig<T>(base: T, override: unknown): T {
31
+ return deepMergeConfigAtPath(base, override, []);
32
+ }
33
+
34
+ function isAtomicReplacementPath(path: readonly string[]): boolean {
35
+ return path.length === 3 && path[0] === "search" && path[1] === "relevancePolicies";
36
+ }
37
+
38
+ function deepMergeConfigAtPath<T>(base: T, override: unknown, path: readonly string[]): T {
39
+ if (override === undefined) {
40
+ return base;
41
+ }
42
+ if (isAtomicReplacementPath(path)) {
43
+ return override as T;
44
+ }
45
+ if (!isPlainObject(base) || !isPlainObject(override)) {
46
+ return override as T;
47
+ }
48
+ const result: Record<string, unknown> = { ...base };
49
+ for (const [key, value] of Object.entries(override)) {
50
+ if (value === undefined) {
51
+ continue;
52
+ }
53
+ const baseValue = (base as Record<string, unknown>)[key];
54
+ result[key] =
55
+ isPlainObject(baseValue) && isPlainObject(value)
56
+ ? deepMergeConfigAtPath(baseValue, value, [...path, key])
57
+ : value;
58
+ }
59
+ return result as T;
60
+ }
61
+
62
+ /**
63
+ * Merges layers left to right — later layers win — mirroring spec.md §4.1's
64
+ * priority order when called with `[productDefaults, userConfig, projectConfig,
65
+ * localConfig, envLayer, explicitArgs]` (lowest priority first).
66
+ */
67
+ export function mergeConfigLayers<T>(layers: readonly unknown[]): T {
68
+ return layers.reduce<unknown>((acc, layer) => deepMergeConfig(acc, layer), {}) as T;
69
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Migration scaffold for future `schemaVersion` jumps (architecture.md §6.3:
3
+ * "Migration älterer Configversionen"). spec.md is at `schemaVersion: 1`
4
+ * today; this registry lets a future WP register a `1 -> 2` step (etc.)
5
+ * without `loadConfig` needing to change.
6
+ */
7
+ import { CURRENT_SCHEMA_VERSION } from "./defaults.js";
8
+
9
+ export interface MigrationStep {
10
+ fromVersion: number;
11
+ toVersion: number;
12
+ /** Pure transform: old raw config shape in, new raw config shape out. */
13
+ migrate: (raw: Record<string, unknown>) => Record<string, unknown>;
14
+ }
15
+
16
+ const registry = new Map<number, MigrationStep>();
17
+
18
+ /** Registers a migration step keyed by its `fromVersion`. Overwrites a step already registered for that version. */
19
+ export function registerMigration(step: MigrationStep): void {
20
+ registry.set(step.fromVersion, step);
21
+ }
22
+
23
+ /** Removes every registered migration step (test isolation). */
24
+ export function clearMigrations(): void {
25
+ registry.clear();
26
+ }
27
+
28
+ export interface MigrationResult {
29
+ config: Record<string, unknown>;
30
+ finalVersion: number;
31
+ /** Versions the config was migrated through, e.g. `[1, 2]` for a single `1 -> 2` hop. */
32
+ appliedFrom: number[];
33
+ }
34
+
35
+ /**
36
+ * Applies registered migration steps in sequence starting from
37
+ * `raw.schemaVersion` (defaulting to `1` when absent) until
38
+ * `targetVersion` (default: {@link CURRENT_SCHEMA_VERSION}) is reached.
39
+ *
40
+ * A config already at `targetVersion` is returned unchanged — the identity
41
+ * migration — without requiring any step to be registered for it.
42
+ */
43
+ export function migrateConfig(
44
+ raw: Record<string, unknown>,
45
+ targetVersion: number = CURRENT_SCHEMA_VERSION,
46
+ ): MigrationResult {
47
+ let current: Record<string, unknown> = raw;
48
+ let version = typeof raw["schemaVersion"] === "number" ? raw["schemaVersion"] : 1;
49
+ const appliedFrom: number[] = [version];
50
+
51
+ while (version < targetVersion) {
52
+ const step = registry.get(version);
53
+ if (step === undefined) {
54
+ throw new Error(
55
+ `no migration registered from schemaVersion ${version} towards ${targetVersion}`,
56
+ );
57
+ }
58
+ current = { ...step.migrate(current), schemaVersion: step.toVersion };
59
+ version = step.toVersion;
60
+ appliedFrom.push(version);
61
+ }
62
+
63
+ return { config: current, finalVersion: version, appliedFrom };
64
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { parse as parseToml } from "smol-toml";
2
+
3
+ import { migrateConfig } from "./migrations.js";
4
+ import type { RawConfigLayer } from "./types.js";
5
+
6
+ /**
7
+ * Parses a config document without filesystem access, defaults or environment
8
+ * overrides. Syntax and non-object input errors propagate to the caller.
9
+ */
10
+ export function parseConfigText(text: string, format: "json" | "toml"): RawConfigLayer {
11
+ const parsed: unknown = format === "json" ? JSON.parse(text) : parseToml(text);
12
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
13
+ throw new TypeError(
14
+ `Configuration must contain a ${format === "json" ? "JSON object" : "TOML table"}.`,
15
+ );
16
+ }
17
+ return migrateConfig(parsed as Record<string, unknown>).config as RawConfigLayer;
18
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Path resolution relative to the configuration file (spec.md §4.3:
3
+ * "Pfade MÜSSEN relativ zur Konfigurationsdatei oder absolut auflösbar
4
+ * sein."). Results are canonical: absolute, `/`-separated, no trailing slash,
5
+ * no `.`/`..` segments.
6
+ */
7
+ import { isAbsolute, resolve, sep } from "node:path";
8
+
9
+ /** Canonicalizes a resolved absolute filesystem path to forward slashes, no trailing slash. */
10
+ function toCanonicalAbsolute(absolutePath: string): string {
11
+ const normalized = absolutePath.split(sep).join("/");
12
+ if (normalized.length > 1 && normalized.endsWith("/")) {
13
+ return normalized.slice(0, -1);
14
+ }
15
+ return normalized;
16
+ }
17
+
18
+ /**
19
+ * Resolves `candidatePath` against `configFileDir` (the directory containing
20
+ * the config file that declared it). An already-absolute `candidatePath` is
21
+ * returned canonicalized as-is (spec.md §4.3 allows absolute paths).
22
+ */
23
+ export function resolveConfigPath(candidatePath: string, configFileDir: string): string {
24
+ const absolute = isAbsolute(candidatePath)
25
+ ? candidatePath
26
+ : resolve(configFileDir, candidatePath);
27
+ return toCanonicalAbsolute(absolute);
28
+ }
29
+
30
+ /**
31
+ * Resolves every `RepositoryConfig.path` in `repositories` relative to
32
+ * `configFileDir`, returning new objects (inputs are not mutated).
33
+ */
34
+ export function resolveRepositoryPaths<T extends { path: string }>(
35
+ repositories: readonly T[],
36
+ configFileDir: string,
37
+ ): T[] {
38
+ return repositories.map((repo) => ({
39
+ ...repo,
40
+ path: resolveConfigPath(repo.path, configFileDir),
41
+ }));
42
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * JSON Schema export (architecture.md §6.3, §7.3; docs/BUILD_PLAN.md §3
3
+ * assigns the repo-level `schemas/` directory to this package's WP).
4
+ *
5
+ * NOTE (openIssues): the task that produced this package restricts writes to
6
+ * `packages/config/**`, so the authoritative file lives at
7
+ * `packages/config/schemas/config-v1.json`, not the (currently empty, WP-02
8
+ * owned per docs/BUILD_PLAN.md §4) repo-root `schemas/`. It still needs to be
9
+ * mirrored to `schemas/config-v1.json` by whoever is authorized to write
10
+ * there.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const schemaUrl = new URL("../schemas/config-v1.json", import.meta.url);
16
+
17
+ /** Absolute filesystem path to the hand-authored JSON Schema (Draft 2020-12) for `UntangleConfigV1`. */
18
+ export const CONFIG_SCHEMA_PATH: string = fileURLToPath(schemaUrl);
19
+
20
+ /** The parsed JSON Schema object itself, read once at module load. */
21
+ export const CONFIG_SCHEMA: Record<string, unknown> = JSON.parse(
22
+ readFileSync(schemaUrl, "utf8"),
23
+ ) as Record<string, unknown>;
package/src/secrets.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Secret references and redaction (spec.md §4.4/§4.5 `apiKeyEnv`; §24
3
+ * "API-Keys DÜRFEN nicht in Status, Logs oder Crash Reports erscheinen";
4
+ * architecture.md §23.2: "Der Wert wird nie in Effective Config Dumps
5
+ * geschrieben.").
6
+ *
7
+ * The primary guarantee is structural: `QdrantConfig`/`EmbeddingConfig` only
8
+ * ever carry `apiKeyEnv` — the NAME of an environment variable — never a
9
+ * resolved secret value, so an `EffectiveUntangleConfigV1` cannot contain a
10
+ * secret by construction. `redactSecrets` is a defense-in-depth belt: it
11
+ * scrubs any resolved-secret-shaped field (e.g. code elsewhere mistakenly
12
+ * attaching a resolved key onto a config object for convenience) before that
13
+ * object is serialized, dumped, or logged.
14
+ */
15
+
16
+ const REDACTED_PLACEHOLDER = "[REDACTED]";
17
+
18
+ // Field name ends in one of these -> looks like a *resolved* secret value.
19
+ const SECRET_VALUE_KEY_PATTERN = /(apikey|secret|password|token|credential)$/i;
20
+ // ...unless it ends in "Env" (e.g. `apiKeyEnv`), which holds an env var NAME
21
+ // and is exactly what spec.md §24 requires to stay visible for transparency.
22
+ const ENV_REFERENCE_SUFFIX_PATTERN = /env$/i;
23
+
24
+ function looksLikeResolvedSecretKey(key: string): boolean {
25
+ if (ENV_REFERENCE_SUFFIX_PATTERN.test(key)) {
26
+ return false;
27
+ }
28
+ return SECRET_VALUE_KEY_PATTERN.test(key);
29
+ }
30
+
31
+ function redactRecursive(value: unknown): unknown {
32
+ if (Array.isArray(value)) {
33
+ return value.map(redactRecursive);
34
+ }
35
+ if (value !== null && typeof value === "object") {
36
+ const result: Record<string, unknown> = {};
37
+ for (const [key, entryValue] of Object.entries(value)) {
38
+ result[key] = looksLikeResolvedSecretKey(key)
39
+ ? REDACTED_PLACEHOLDER
40
+ : redactRecursive(entryValue);
41
+ }
42
+ return result;
43
+ }
44
+ return value;
45
+ }
46
+
47
+ /**
48
+ * Returns a deep copy of `value` with any field whose name looks like a
49
+ * resolved secret (`apiKey`, `token`, `password`, `secret`, `credential`, but
50
+ * NOT the `...Env` reference fields) replaced by a fixed placeholder. Safe to
51
+ * call on an `EffectiveUntangleConfigV1`, a partial layer, or arbitrary
52
+ * nested data.
53
+ */
54
+ export function redactSecrets<T>(value: T): T {
55
+ return redactRecursive(value) as T;
56
+ }
57
+
58
+ /** `JSON.stringify` through {@link redactSecrets} — the one safe way to dump a config. */
59
+ export function toRedactedJson(value: unknown, space?: number): string {
60
+ return JSON.stringify(redactSecrets(value), null, space);
61
+ }
62
+
63
+ /**
64
+ * Resolves an `apiKeyEnv` reference to its actual secret value AT CALL TIME.
65
+ * The result MUST NOT be stored back onto any config object — callers (e.g.
66
+ * a Qdrant/Ollama adapter) use it immediately for one connection attempt.
67
+ * Returns `undefined` when `envVarName` is unset or the variable is not
68
+ * present in `env`.
69
+ */
70
+ export function resolveApiKey(
71
+ envVarName: string | undefined,
72
+ env: NodeJS.ProcessEnv = process.env,
73
+ ): string | undefined {
74
+ if (envVarName === undefined || envVarName.length === 0) {
75
+ return undefined;
76
+ }
77
+ return env[envVarName];
78
+ }