@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
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Config file discovery (spec.md §4.1, §4.3 mention `.untangle.json` /
3
+ * `.untangle.local.json` without specifying how they are found;
4
+ * architecture.md §6.3 assigns "Config Discovery" to this package).
5
+ *
6
+ * Upward search from a start path, checking one directory at a time, until
7
+ * either the file is found, the repository root is reached (a directory
8
+ * containing `.git`, checked AFTER that directory's own candidate file so the
9
+ * repo root itself is still searched), or the filesystem root is reached.
10
+ */
11
+ import { existsSync, statSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+
14
+ export const PROJECT_CONFIG_FILE_NAME = ".untangle.json";
15
+ export const LOCAL_CONFIG_FILE_NAME = ".untangle.local.json";
16
+ /** Folder-contained project configuration; legacy root files retain priority. */
17
+ export const PROJECT_FOLDER_CONFIG_FILE_NAME = join(".untangle", "config.json");
18
+ /**
19
+ * `untangle.toml`, the file `untangle init` writes (WP-125).
20
+ *
21
+ * WP-222: it used to be invisible here. `init` wrote `untangle.toml`,
22
+ * discovery looked only for `.untangle.json`, and so `untangle doctor`
23
+ * reported "no configuration found" about a configuration that had just been
24
+ * written — and advised running `untangle init`, which had just run. A user
25
+ * following that advice loops forever. One file name known in one place ends
26
+ * that: whatever `init` writes is exactly what discovery finds.
27
+ */
28
+ export const PROJECT_TOML_CONFIG_FILE_NAME = "untangle.toml";
29
+
30
+ function isRepoRoot(dir: string): boolean {
31
+ return existsSync(join(dir, ".git"));
32
+ }
33
+
34
+ /**
35
+ * Searches `startPath` and its ancestors for `fileName`, stopping after the
36
+ * first directory that is a repository root (has `.git`) or after the
37
+ * filesystem root. Returns the absolute path of the first match, or
38
+ * `undefined` if none is found.
39
+ */
40
+ export function discoverConfigFile(startPath: string, fileName: string): string | undefined {
41
+ let dir = resolve(startPath);
42
+ // If startPath is a file (e.g. process.cwd() equivalent passed by mistake),
43
+ // callers are expected to pass a directory; we do not stat startPath itself.
44
+ for (;;) {
45
+ const candidate = join(dir, fileName);
46
+ if (existsSync(candidate)) {
47
+ try {
48
+ if (statSync(candidate).isFile()) {
49
+ return candidate;
50
+ }
51
+ } catch {
52
+ // Unreadable candidate (race, permissions): treat as not found here.
53
+ }
54
+ }
55
+ const atRepoRoot = isRepoRoot(dir);
56
+ const parent = dirname(dir);
57
+ const atFilesystemRoot = parent === dir;
58
+ if (atRepoRoot || atFilesystemRoot) {
59
+ return undefined;
60
+ }
61
+ dir = parent;
62
+ }
63
+ }
64
+
65
+ /** Result of discovering the project-level config files (spec.md §4.1, layers 3–4). */
66
+ export interface DiscoveredConfigFiles {
67
+ /** `.untangle.json`, or `.untangle/config.json` when no legacy project file exists. */
68
+ projectConfigPath: string | undefined;
69
+ /**
70
+ * `untangle.toml`, layer 4b — the file `untangle init` writes. It sits
71
+ * directly BENEATH `.untangle.json`: where both exist, the JSON file wins,
72
+ * and `loadConfig` warns rather than picking a winner in silence.
73
+ */
74
+ tomlConfigPath: string | undefined;
75
+ /** `.untangle.local.json`, layer 3 — SHOULD be gitignored (spec.md §4.1). */
76
+ localConfigPath: string | undefined;
77
+ /** Logical project directory for the folder layout, distinct from the config directory. */
78
+ projectRoot?: string;
79
+ }
80
+
81
+ /** Discovers project configuration upward, without crossing a repository boundary. */
82
+ export function discoverConfigFiles(startPath: string): DiscoveredConfigFiles {
83
+ const projectConfigPath = discoverConfigFile(startPath, PROJECT_CONFIG_FILE_NAME);
84
+ const tomlConfigPath = discoverConfigFile(startPath, PROJECT_TOML_CONFIG_FILE_NAME);
85
+ const folderConfigPath =
86
+ projectConfigPath === undefined && tomlConfigPath === undefined
87
+ ? discoverConfigFile(startPath, PROJECT_FOLDER_CONFIG_FILE_NAME)
88
+ : undefined;
89
+ return {
90
+ projectConfigPath: projectConfigPath ?? folderConfigPath,
91
+ tomlConfigPath,
92
+ localConfigPath: discoverConfigFile(startPath, LOCAL_CONFIG_FILE_NAME),
93
+ ...(folderConfigPath === undefined ? {} : { projectRoot: dirname(dirname(folderConfigPath)) }),
94
+ };
95
+ }
package/src/env.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Environment variable layer (spec.md §4.1 layer 2, prefix `UNTANGLE_`).
3
+ *
4
+ * spec.md does not enumerate the mapping, so this table is an explicit
5
+ * Untangle decision: it covers the fields most likely to be overridden
6
+ * outside a checked-in config file (endpoints, modes, secrets-by-reference,
7
+ * gate strictness, telemetry opt-in), not every leaf field of
8
+ * `UntangleConfigV1`. New mappings MAY be added additively; removing or
9
+ * renaming one is a breaking change to this package's public contract.
10
+ *
11
+ * `UNTANGLE_QDRANT_API_KEY_ENV` / `UNTANGLE_EMBEDDINGS_API_KEY_ENV` set the
12
+ * NAME of a further environment variable holding a secret; they never carry
13
+ * the secret value itself (architecture.md §23.2).
14
+ */
15
+ import type { RawConfigLayer } from "./types.js";
16
+
17
+ type Parser = (raw: string) => unknown;
18
+
19
+ const parseString: Parser = (raw) => raw;
20
+ const parseNumber: Parser = (raw) => {
21
+ const value = Number(raw);
22
+ if (!Number.isFinite(value)) {
23
+ throw new Error(`expected a finite number, got "${raw}"`);
24
+ }
25
+ return value;
26
+ };
27
+ const parseBoolean: Parser = (raw) => {
28
+ const normalized = raw.trim().toLowerCase();
29
+ if (normalized === "true" || normalized === "1") {
30
+ return true;
31
+ }
32
+ if (normalized === "false" || normalized === "0") {
33
+ return false;
34
+ }
35
+ throw new Error(`expected a boolean ("true"/"false"/"1"/"0"), got "${raw}"`);
36
+ };
37
+ const parseCsv: Parser = (raw) =>
38
+ raw
39
+ .split(",")
40
+ .map((entry) => entry.trim())
41
+ .filter((entry) => entry.length > 0);
42
+
43
+ export interface EnvMappingEntry {
44
+ /** Environment variable name, e.g. `UNTANGLE_QDRANT_URL`. */
45
+ env: string;
46
+ /** Dotted path into `UntangleConfigV1`, e.g. `["qdrant", "url"]`. */
47
+ path: readonly string[];
48
+ parse: Parser;
49
+ }
50
+
51
+ /** The complete, documented `UNTANGLE_*` → config-path mapping table. */
52
+ export const ENV_MAPPING: readonly EnvMappingEntry[] = [
53
+ { env: "UNTANGLE_WORKSPACE_ID", path: ["workspaceId"], parse: parseString },
54
+ { env: "UNTANGLE_PROJECT_ID", path: ["projectId"], parse: parseString },
55
+
56
+ { env: "UNTANGLE_STORAGE_BACKEND", path: ["storage", "backend"], parse: parseString },
57
+ { env: "UNTANGLE_LANCEDB_PATH", path: ["storage", "path"], parse: parseString },
58
+
59
+ { env: "UNTANGLE_QDRANT_MODE", path: ["qdrant", "mode"], parse: parseString },
60
+ { env: "UNTANGLE_QDRANT_URL", path: ["qdrant", "url"], parse: parseString },
61
+ { env: "UNTANGLE_QDRANT_API_KEY_ENV", path: ["qdrant", "apiKeyEnv"], parse: parseString },
62
+ {
63
+ env: "UNTANGLE_QDRANT_COLLECTION_PREFIX",
64
+ path: ["qdrant", "collectionPrefix"],
65
+ parse: parseString,
66
+ },
67
+ {
68
+ env: "UNTANGLE_QDRANT_REQUEST_TIMEOUT_MS",
69
+ path: ["qdrant", "requestTimeoutMs"],
70
+ parse: parseNumber,
71
+ },
72
+ { env: "UNTANGLE_QDRANT_MAX_RETRIES", path: ["qdrant", "maxRetries"], parse: parseNumber },
73
+
74
+ { env: "UNTANGLE_EMBEDDINGS_MODE", path: ["embeddings", "mode"], parse: parseString },
75
+ { env: "UNTANGLE_EMBEDDINGS_URL", path: ["embeddings", "url"], parse: parseString },
76
+ { env: "UNTANGLE_EMBEDDINGS_API_KEY_ENV", path: ["embeddings", "apiKeyEnv"], parse: parseString },
77
+ { env: "UNTANGLE_EMBEDDINGS_MODEL", path: ["embeddings", "model"], parse: parseString },
78
+ { env: "UNTANGLE_EMBEDDINGS_DIMENSIONS", path: ["embeddings", "dimensions"], parse: parseNumber },
79
+ {
80
+ env: "UNTANGLE_EMBEDDINGS_REQUEST_TIMEOUT_MS",
81
+ path: ["embeddings", "requestTimeoutMs"],
82
+ parse: parseNumber,
83
+ },
84
+
85
+ { env: "UNTANGLE_INDEX_WATCH", path: ["index", "watch"], parse: parseBoolean },
86
+ { env: "UNTANGLE_INDEX_MAX_FILE_SIZE_MB", path: ["index", "maxFileSizeMb"], parse: parseNumber },
87
+ { env: "UNTANGLE_INDEX_EXTRA_EXTENSIONS", path: ["index", "extraExtensions"], parse: parseCsv },
88
+
89
+ { env: "UNTANGLE_QUALITY_GATE_MODE", path: ["qualityGate", "mode"], parse: parseString },
90
+ {
91
+ env: "UNTANGLE_QUALITY_GATE_MINIMUM_PARSE_QUALITY",
92
+ path: ["qualityGate", "minimumParseQuality"],
93
+ parse: parseString,
94
+ },
95
+
96
+ { env: "UNTANGLE_TELEMETRY_ENABLED", path: ["telemetry", "enabled"], parse: parseBoolean },
97
+ { env: "UNTANGLE_TELEMETRY_LOG_LEVEL", path: ["telemetry", "logLevel"], parse: parseString },
98
+
99
+ { env: "UNTANGLE_PORTFOLIO_ENABLED", path: ["portfolio", "enabled"], parse: parseBoolean },
100
+ ] as const;
101
+
102
+ function setAtPath(target: Record<string, unknown>, path: readonly string[], value: unknown): void {
103
+ let cursor: Record<string, unknown> = target;
104
+ for (let i = 0; i < path.length - 1; i += 1) {
105
+ const key = path[i] as string;
106
+ const next = cursor[key];
107
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
108
+ cursor[key] = {};
109
+ }
110
+ cursor = cursor[key] as Record<string, unknown>;
111
+ }
112
+ const lastKey = path[path.length - 1] as string;
113
+ cursor[lastKey] = value;
114
+ }
115
+
116
+ export interface EnvLayerResult {
117
+ layer: RawConfigLayer;
118
+ /** Env vars that were present but failed to parse (e.g. non-numeric `UNTANGLE_EMBEDDINGS_DIMENSIONS`). */
119
+ parseErrors: { env: string; message: string }[];
120
+ }
121
+
122
+ /** Builds a `RawConfigLayer` from `env` using {@link ENV_MAPPING}. Unset variables are skipped. */
123
+ export function buildEnvConfigLayer(env: NodeJS.ProcessEnv = process.env): EnvLayerResult {
124
+ const layer: Record<string, unknown> = {};
125
+ const parseErrors: { env: string; message: string }[] = [];
126
+ for (const entry of ENV_MAPPING) {
127
+ const raw = env[entry.env];
128
+ if (raw === undefined) {
129
+ continue;
130
+ }
131
+ try {
132
+ setAtPath(layer, entry.path, entry.parse(raw));
133
+ } catch (error) {
134
+ parseErrors.push({
135
+ env: entry.env,
136
+ message: error instanceof Error ? error.message : String(error),
137
+ });
138
+ }
139
+ }
140
+ return { layer: layer as RawConfigLayer, parseErrors };
141
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Canonical effective config and embedding profile derivation
3
+ * (architecture.md §6.3: "kanonische Effective Config", "Embedding- und
4
+ * Indexprofil-Hashing"; spec.md §5.1 `workspace_id` fallback derivation;
5
+ * spec.md §5.7 `embedding_profile_id`).
6
+ */
7
+ import type { EmbeddingProfile } from "@untangle-code/domain";
8
+ import { buildEmbeddingProfileId, canonicalJson, sha256Hex } from "@untangle-code/domain";
9
+
10
+ import { DEFAULT_EMBEDDING_NORMALIZATION, PREPARED_TEXT_VERSION } from "./defaults.js";
11
+ import type { EffectiveUntangleConfigV1 } from "./types.js";
12
+
13
+ /**
14
+ * SHA-256 of the canonical JSON of `config`. The additive storage section
15
+ * is excluded for Qdrant to preserve existing workspace identities; its
16
+ * connection remains represented by the unchanged qdrant section. Used as `buildWorkspaceId`'s
17
+ * `configHash` input (spec.md §5.1) when no explicit `workspaceId` is
18
+ * configured, and to detect effective-config drift between index runs
19
+ * (architecture.md §6.3, §13.4 "Generation-Commit").
20
+ */
21
+ export function computeConfigHash(config: unknown): string {
22
+ if (typeof config === "object" && config !== null && !Array.isArray(config)) {
23
+ const record = config as Record<string, unknown>;
24
+ const storage = record["storage"];
25
+ if (
26
+ typeof storage === "object" &&
27
+ storage !== null &&
28
+ "backend" in storage &&
29
+ storage.backend === "qdrant"
30
+ ) {
31
+ const legacyConfig = { ...record };
32
+ delete legacyConfig["storage"];
33
+ return sha256Hex(canonicalJson(legacyConfig));
34
+ }
35
+ }
36
+ return sha256Hex(canonicalJson(config));
37
+ }
38
+
39
+ /**
40
+ * Assembles the `EmbeddingProfile` (domain, spec.md §5.7's eight mandatory
41
+ * fields) from an effective config's `embeddings` section and derives
42
+ * `embedding_profile_id` via `buildEmbeddingProfileId`.
43
+ *
44
+ * `normalization` and `preparedTextVersion` are not part of `EmbeddingConfig`
45
+ * (spec.md §4.5 does not list them); they default to this package's fixed
46
+ * constants (`defaults.ts`) unless overridden here — e.g. a future config
47
+ * revision that exposes them per-project.
48
+ */
49
+ export function deriveEmbeddingProfile(
50
+ config: Pick<EffectiveUntangleConfigV1, "embeddings">,
51
+ overrides: { normalization?: string; preparedTextVersion?: string } = {},
52
+ ): EmbeddingProfile {
53
+ return {
54
+ provider: config.embeddings.provider,
55
+ model: config.embeddings.model,
56
+ dimensions: config.embeddings.dimensions,
57
+ queryPrefix: config.embeddings.queryPrefix,
58
+ documentPrefix: config.embeddings.documentPrefix,
59
+ includePath: config.embeddings.includePath,
60
+ normalization: overrides.normalization ?? DEFAULT_EMBEDDING_NORMALIZATION,
61
+ preparedTextVersion: overrides.preparedTextVersion ?? PREPARED_TEXT_VERSION,
62
+ };
63
+ }
64
+
65
+ /** Convenience: `buildEmbeddingProfileId(deriveEmbeddingProfile(config, overrides))`. */
66
+ export function deriveEmbeddingProfileId(
67
+ config: Pick<EffectiveUntangleConfigV1, "embeddings">,
68
+ overrides?: { normalization?: string; preparedTextVersion?: string },
69
+ ): string {
70
+ return buildEmbeddingProfileId(deriveEmbeddingProfile(config, overrides));
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @untangle-code/config — Untangle configuration: types, discovery,
3
+ * layering, JSON Schema, secret redaction, canonical hashing and migration.
4
+ *
5
+ * This module is the ONLY public export of the package.
6
+ */
7
+
8
+ // Configuration types (spec.md §4.2-4.8; portfolio.md §14.4)
9
+ export * from "./types.js";
10
+
11
+ // Product defaults (spec.md §4.5; product.md §11)
12
+ export * from "./defaults.js";
13
+
14
+ // JSON Schema (Draft 2020-12)
15
+ export * from "./schema.js";
16
+
17
+ // Config file discovery (spec.md §4.1)
18
+ export * from "./discovery.js";
19
+
20
+ // OS user config directory (spec.md §4.1 layer 5)
21
+ export * from "./user-config.js";
22
+
23
+ // Environment variable layer (spec.md §4.1 layer 2)
24
+ export * from "./env.js";
25
+
26
+ // Deep merge with array-replace semantics (spec.md §4.1)
27
+ export * from "./merge.js";
28
+
29
+ // Path resolution relative to the config file (spec.md §4.3)
30
+ export * from "./paths.js";
31
+
32
+ // Secret references and redaction (spec.md §4.4/§4.5, §24; architecture.md §23.2)
33
+ export * from "./secrets.js";
34
+
35
+ // Canonical effective config hash and embedding profile derivation (architecture.md §6.3)
36
+ export * from "./hash.js";
37
+
38
+ // Validation (spec.md §4.8)
39
+ export * from "./validate.js";
40
+
41
+ // Migration scaffold for future schemaVersion jumps (architecture.md §6.3)
42
+ export * from "./migrations.js";
43
+
44
+ // Top-level orchestration
45
+ export * from "./load.js";
46
+
47
+ // Parse an existing document without loading defaults or environment layers.
48
+ export * from "./parse.js";
49
+
50
+ // IndexConfig -> chunker token-budget wiring (spec.md §4.6)
51
+ export * from "./chunker-budget.js";