@ontrails/config 1.0.0-beta.12

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 (159) hide show
  1. package/.turbo/turbo-build.log +1 -0
  2. package/.turbo/turbo-lint.log +3 -0
  3. package/.turbo/turbo-typecheck.log +1 -0
  4. package/CHANGELOG.md +19 -0
  5. package/dist/app-config.d.ts +65 -0
  6. package/dist/app-config.d.ts.map +1 -0
  7. package/dist/app-config.js +172 -0
  8. package/dist/app-config.js.map +1 -0
  9. package/dist/collect.d.ts +11 -0
  10. package/dist/collect.d.ts.map +1 -0
  11. package/dist/collect.js +81 -0
  12. package/dist/collect.js.map +1 -0
  13. package/dist/compose.d.ts +26 -0
  14. package/dist/compose.d.ts.map +1 -0
  15. package/dist/compose.js +19 -0
  16. package/dist/compose.js.map +1 -0
  17. package/dist/config-layer.d.ts +11 -0
  18. package/dist/config-layer.d.ts.map +1 -0
  19. package/dist/config-layer.js +6 -0
  20. package/dist/config-layer.js.map +1 -0
  21. package/dist/config-service.d.ts +3 -0
  22. package/dist/config-service.d.ts.map +1 -0
  23. package/dist/config-service.js +26 -0
  24. package/dist/config-service.js.map +1 -0
  25. package/dist/define-config.d.ts +61 -0
  26. package/dist/define-config.d.ts.map +1 -0
  27. package/dist/define-config.js +90 -0
  28. package/dist/define-config.js.map +1 -0
  29. package/dist/describe.d.ts +25 -0
  30. package/dist/describe.d.ts.map +1 -0
  31. package/dist/describe.js +147 -0
  32. package/dist/describe.js.map +1 -0
  33. package/dist/doctor.d.ts +27 -0
  34. package/dist/doctor.d.ts.map +1 -0
  35. package/dist/doctor.js +167 -0
  36. package/dist/doctor.js.map +1 -0
  37. package/dist/explain.d.ts +30 -0
  38. package/dist/explain.d.ts.map +1 -0
  39. package/dist/explain.js +114 -0
  40. package/dist/explain.js.map +1 -0
  41. package/dist/extensions.d.ts +38 -0
  42. package/dist/extensions.d.ts.map +1 -0
  43. package/dist/extensions.js +35 -0
  44. package/dist/extensions.js.map +1 -0
  45. package/dist/generate/env.d.ts +15 -0
  46. package/dist/generate/env.d.ts.map +1 -0
  47. package/dist/generate/env.js +65 -0
  48. package/dist/generate/env.js.map +1 -0
  49. package/dist/generate/example.d.ts +16 -0
  50. package/dist/generate/example.d.ts.map +1 -0
  51. package/dist/generate/example.js +136 -0
  52. package/dist/generate/example.js.map +1 -0
  53. package/dist/generate/helpers.d.ts +35 -0
  54. package/dist/generate/helpers.d.ts.map +1 -0
  55. package/dist/generate/helpers.js +116 -0
  56. package/dist/generate/helpers.js.map +1 -0
  57. package/dist/generate/index.d.ts +4 -0
  58. package/dist/generate/index.d.ts.map +1 -0
  59. package/dist/generate/index.js +4 -0
  60. package/dist/generate/index.js.map +1 -0
  61. package/dist/generate/json-schema.d.ts +18 -0
  62. package/dist/generate/json-schema.d.ts.map +1 -0
  63. package/dist/generate/json-schema.js +97 -0
  64. package/dist/generate/json-schema.js.map +1 -0
  65. package/dist/index.d.ts +21 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +21 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/merge.d.ts +16 -0
  70. package/dist/merge.d.ts.map +1 -0
  71. package/dist/merge.js +34 -0
  72. package/dist/merge.js.map +1 -0
  73. package/dist/ref.d.ts +24 -0
  74. package/dist/ref.d.ts.map +1 -0
  75. package/dist/ref.js +25 -0
  76. package/dist/ref.js.map +1 -0
  77. package/dist/registry.d.ts +24 -0
  78. package/dist/registry.d.ts.map +1 -0
  79. package/dist/registry.js +12 -0
  80. package/dist/registry.js.map +1 -0
  81. package/dist/resolve.d.ts +21 -0
  82. package/dist/resolve.d.ts.map +1 -0
  83. package/dist/resolve.js +174 -0
  84. package/dist/resolve.js.map +1 -0
  85. package/dist/secret-heuristics.d.ts +10 -0
  86. package/dist/secret-heuristics.d.ts.map +1 -0
  87. package/dist/secret-heuristics.js +11 -0
  88. package/dist/secret-heuristics.js.map +1 -0
  89. package/dist/trails/config-check.d.ts +11 -0
  90. package/dist/trails/config-check.d.ts.map +1 -0
  91. package/dist/trails/config-check.js +53 -0
  92. package/dist/trails/config-check.js.map +1 -0
  93. package/dist/trails/config-describe.d.ts +12 -0
  94. package/dist/trails/config-describe.d.ts.map +1 -0
  95. package/dist/trails/config-describe.js +41 -0
  96. package/dist/trails/config-describe.js.map +1 -0
  97. package/dist/trails/config-explain.d.ts +8 -0
  98. package/dist/trails/config-explain.d.ts.map +1 -0
  99. package/dist/trails/config-explain.js +74 -0
  100. package/dist/trails/config-explain.js.map +1 -0
  101. package/dist/trails/config-init.d.ts +9 -0
  102. package/dist/trails/config-init.d.ts.map +1 -0
  103. package/dist/trails/config-init.js +78 -0
  104. package/dist/trails/config-init.js.map +1 -0
  105. package/dist/workspace.d.ts +9 -0
  106. package/dist/workspace.d.ts.map +1 -0
  107. package/dist/workspace.js +44 -0
  108. package/dist/workspace.js.map +1 -0
  109. package/dist/zod-utils.d.ts +14 -0
  110. package/dist/zod-utils.d.ts.map +1 -0
  111. package/dist/zod-utils.js +41 -0
  112. package/dist/zod-utils.js.map +1 -0
  113. package/package.json +20 -0
  114. package/src/__tests__/app-config.test.ts +329 -0
  115. package/src/__tests__/compose.test.ts +59 -0
  116. package/src/__tests__/config-check.test.ts +171 -0
  117. package/src/__tests__/config-describe.test.ts +154 -0
  118. package/src/__tests__/config-explain.test.ts +167 -0
  119. package/src/__tests__/config-init.test.ts +210 -0
  120. package/src/__tests__/config-layer.test.ts +53 -0
  121. package/src/__tests__/config-service.test.ts +87 -0
  122. package/src/__tests__/define-config.test.ts +263 -0
  123. package/src/__tests__/describe.test.ts +158 -0
  124. package/src/__tests__/doctor.test.ts +172 -0
  125. package/src/__tests__/explain.test.ts +139 -0
  126. package/src/__tests__/extensions.test.ts +134 -0
  127. package/src/__tests__/generate.test.ts +269 -0
  128. package/src/__tests__/ref.test.ts +35 -0
  129. package/src/__tests__/resolve.test.ts +246 -0
  130. package/src/__tests__/workspace.test.ts +64 -0
  131. package/src/app-config.ts +307 -0
  132. package/src/collect.ts +118 -0
  133. package/src/compose.ts +46 -0
  134. package/src/config-layer.ts +15 -0
  135. package/src/config-service.ts +32 -0
  136. package/src/define-config.ts +134 -0
  137. package/src/describe.ts +252 -0
  138. package/src/doctor.ts +219 -0
  139. package/src/explain.ts +176 -0
  140. package/src/extensions.ts +51 -0
  141. package/src/generate/env.ts +104 -0
  142. package/src/generate/example.ts +222 -0
  143. package/src/generate/helpers.ts +158 -0
  144. package/src/generate/index.ts +3 -0
  145. package/src/generate/json-schema.ts +137 -0
  146. package/src/index.ts +44 -0
  147. package/src/merge.ts +43 -0
  148. package/src/ref.ts +38 -0
  149. package/src/registry.ts +33 -0
  150. package/src/resolve.ts +279 -0
  151. package/src/secret-heuristics.ts +13 -0
  152. package/src/trails/config-check.ts +60 -0
  153. package/src/trails/config-describe.ts +44 -0
  154. package/src/trails/config-explain.ts +93 -0
  155. package/src/trails/config-init.ts +96 -0
  156. package/src/workspace.ts +51 -0
  157. package/src/zod-utils.ts +53 -0
  158. package/tsconfig.json +9 -0
  159. package/tsconfig.tsbuildinfo +1 -0
package/src/merge.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Simple recursive deep merge for config objects.
3
+ *
4
+ * - Objects merge recursively
5
+ * - Arrays replace (no concatenation)
6
+ * - Primitives replace
7
+ * - `undefined` values in source are skipped
8
+ */
9
+
10
+ /** Check whether a value is a plain object (not array, null, or class instance). */
11
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
12
+ typeof value === 'object' &&
13
+ value !== null &&
14
+ !Array.isArray(value) &&
15
+ Object.getPrototypeOf(value) === Object.prototype;
16
+
17
+ /**
18
+ * Deep-merge `source` into `target`, returning a new object.
19
+ *
20
+ * Does not mutate either input. Undefined values in source are skipped,
21
+ * preserving the target's value at that key.
22
+ */
23
+ export const deepMerge = (
24
+ target: Record<string, unknown>,
25
+ source: Record<string, unknown>
26
+ ): Record<string, unknown> => {
27
+ const result: Record<string, unknown> = { ...target };
28
+
29
+ for (const [key, sourceValue] of Object.entries(source)) {
30
+ if (sourceValue === undefined) {
31
+ continue;
32
+ }
33
+
34
+ const targetValue = result[key];
35
+
36
+ result[key] =
37
+ isPlainObject(targetValue) && isPlainObject(sourceValue)
38
+ ? deepMerge(targetValue, sourceValue)
39
+ : sourceValue;
40
+ }
41
+
42
+ return result;
43
+ };
package/src/ref.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Lazy config reference markers for trail input defaults.
3
+ *
4
+ * A `ConfigRef` is a marker object that can be embedded as a trail input
5
+ * default. When resolution is wired into the execution pipeline, it will
6
+ * be replaced with the live config value at the given path.
7
+ *
8
+ * Note: resolution is not yet wired into the execution pipeline.
9
+ * Currently this module provides the marker type and type guard only.
10
+ * Trail input defaults using `configRef()` will not be resolved
11
+ * automatically until the execution pipeline integration ships.
12
+ */
13
+
14
+ /** Marker object representing a lazy reference to a config field. */
15
+ export interface ConfigRef {
16
+ readonly __configRef: true;
17
+ readonly path: string;
18
+ }
19
+
20
+ /**
21
+ * Create a lazy reference to a config field for use as a trail input default.
22
+ *
23
+ * Note: resolution is not yet automatic. See module-level docs.
24
+ *
25
+ */
26
+ export const configRef = (path: string): ConfigRef => ({
27
+ __configRef: true,
28
+ path,
29
+ });
30
+
31
+ /**
32
+ * Type guard: detect whether an unknown value is a `ConfigRef` marker.
33
+ */
34
+ export const isConfigRef = (value?: unknown): value is ConfigRef =>
35
+ typeof value === 'object' &&
36
+ value !== null &&
37
+ '__configRef' in value &&
38
+ (value as Record<string, unknown>)['__configRef'] === true;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Module-level config state registry.
3
+ *
4
+ * Config is resolved once at bootstrap (two-phase init per ADR-010) and
5
+ * registered here so `configService` can surface it to trails. This is
6
+ * a process-level singleton — config resolution is inherently global.
7
+ */
8
+ import type { z } from 'zod';
9
+
10
+ /** Resolved config state carrying the schema and all layer values. */
11
+ export interface ConfigState {
12
+ readonly schema: z.ZodObject<Record<string, z.ZodType>>;
13
+ readonly resolved: Record<string, unknown>;
14
+ readonly base?: Record<string, unknown>;
15
+ readonly loadout?: Record<string, unknown>;
16
+ readonly local?: Record<string, unknown>;
17
+ readonly env?: Record<string, string | undefined>;
18
+ }
19
+
20
+ let current: ConfigState | undefined;
21
+
22
+ /** Register resolved config state at bootstrap. */
23
+ export const registerConfigState = (state: ConfigState): void => {
24
+ current = state;
25
+ };
26
+
27
+ /** Read the registered config state. Returns `undefined` before registration. */
28
+ export const getConfigState = (): ConfigState | undefined => current;
29
+
30
+ /** Clear registered state. Primarily useful in tests. */
31
+ export const clearConfigState = (): void => {
32
+ current = undefined;
33
+ };
package/src/resolve.ts ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Config resolution engine — merges config from multiple sources through
3
+ * a deterministic stack: defaults → base → loadout → local → env.
4
+ */
5
+
6
+ import type { z } from 'zod';
7
+
8
+ import { Result } from '@ontrails/core';
9
+
10
+ import { collectConfigMeta } from './collect.js';
11
+ import { deepMerge } from './merge.js';
12
+ import { zodDef } from './zod-utils.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Options for resolving config through the full stack. */
19
+ export interface ResolveConfigOptions<T extends z.ZodType> {
20
+ readonly schema: T;
21
+ readonly base?: Record<string, unknown> | undefined;
22
+ readonly loadouts?: Record<string, Record<string, unknown>> | undefined;
23
+ readonly loadout?: string | undefined;
24
+ readonly localOverrides?: Record<string, unknown> | undefined;
25
+ readonly env?: Record<string, string | undefined> | undefined;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Env coercion helpers (defined before consumers)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** Boolean string values we accept from environment variables. */
33
+ const BOOL_TRUE = new Set(['true', '1']);
34
+ const BOOL_FALSE = new Set(['false', '0']);
35
+
36
+ /** Primitive type names we can coerce env strings into. */
37
+ const PRIMITIVE_TYPES = new Set(['number', 'boolean', 'string']);
38
+
39
+ /** Try to advance one level through a Zod wrapper, returning the inner type or undefined. */
40
+ const unwrapOne = (
41
+ schema: z.ZodType
42
+ ): { typeName: string | undefined; inner: z.ZodType | undefined } => {
43
+ const def = zodDef(schema);
44
+ return {
45
+ inner: def['innerType'] as z.ZodType | undefined,
46
+ typeName: def['type'] as string | undefined,
47
+ };
48
+ };
49
+
50
+ /** Unwrap ZodDefault / ZodOptional / ZodNullable to find the base type name. */
51
+ const resolveBaseTypeName = (schema: z.ZodType): string => {
52
+ let current: z.ZodType = schema;
53
+
54
+ for (let depth = 0; depth < 10; depth += 1) {
55
+ const { typeName, inner } = unwrapOne(current);
56
+ if (typeName && PRIMITIVE_TYPES.has(typeName)) {
57
+ return typeName;
58
+ }
59
+ if (!inner) {
60
+ break;
61
+ }
62
+ current = inner;
63
+ }
64
+
65
+ return 'string';
66
+ };
67
+
68
+ /** Coerce a boolean env string. Returns the original string if unrecognized. */
69
+ const coerceBooleanEnv = (raw: string): unknown => {
70
+ if (BOOL_TRUE.has(raw)) {
71
+ return true;
72
+ }
73
+ if (BOOL_FALSE.has(raw)) {
74
+ return false;
75
+ }
76
+ return raw;
77
+ };
78
+
79
+ /** Coerce env var lookup table keyed by base type name. */
80
+ const ENV_COERCERS: Record<string, (raw: string) => unknown> = {
81
+ boolean: coerceBooleanEnv,
82
+ number: (raw: string) => {
83
+ const n = Number(raw);
84
+ return Number.isNaN(n) ? raw : n;
85
+ },
86
+ };
87
+
88
+ /** Coerce a string env value to the type expected by the schema field. */
89
+ const coerceEnvValue = (raw: string, schema: z.ZodType): unknown => {
90
+ const coercer = ENV_COERCERS[resolveBaseTypeName(schema)];
91
+ return coercer ? coercer(raw) : raw;
92
+ };
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Path utilities
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** Navigate one step of a nested object, creating an intermediate if needed. */
99
+ const navigateOrCreate = (
100
+ current: Record<string, unknown>,
101
+ key: string
102
+ ): Record<string, unknown> => {
103
+ const next = current[key];
104
+ if (typeof next === 'object' && next !== null && !Array.isArray(next)) {
105
+ return next as Record<string, unknown>;
106
+ }
107
+ const nested: Record<string, unknown> = {};
108
+ current[key] = nested;
109
+ return nested;
110
+ };
111
+
112
+ /** Ensure a nested path exists in an object, creating intermediates as needed. */
113
+ const ensurePath = (
114
+ obj: Record<string, unknown>,
115
+ parts: readonly string[]
116
+ ): Record<string, unknown> => {
117
+ let current = obj;
118
+ for (const part of parts) {
119
+ current = navigateOrCreate(current, part);
120
+ }
121
+ return current;
122
+ };
123
+
124
+ /** Set a value at a dot-separated path in a plain object. */
125
+ const setAtPath = (
126
+ obj: Record<string, unknown>,
127
+ path: string,
128
+ value: unknown
129
+ ): void => {
130
+ const parts = path.split('.');
131
+ const parent = ensurePath(obj, parts.slice(0, -1));
132
+ parent[parts.at(-1) as string] = value;
133
+ };
134
+
135
+ /** Resolve one step of a Zod shape walk: find the field schema for a key. */
136
+ const resolveShapeStep = (
137
+ current: z.ZodType,
138
+ key: string
139
+ ): z.ZodType | undefined => {
140
+ const shape = zodDef(current)['shape'] as
141
+ | Record<string, z.ZodType>
142
+ | undefined;
143
+ return shape?.[key];
144
+ };
145
+
146
+ /** Walk a Zod schema shape to find the field at a dot-separated path. */
147
+ const getFieldSchema = (
148
+ schema: z.ZodType,
149
+ path: string
150
+ ): z.ZodType | undefined => {
151
+ let current: z.ZodType = schema;
152
+ for (const part of path.split('.')) {
153
+ const next = resolveShapeStep(current, part);
154
+ if (!next) {
155
+ return undefined;
156
+ }
157
+ current = next;
158
+ }
159
+ return current;
160
+ };
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Env overlay
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Coerce and set a single env override into the result object. */
167
+ const applyOneEnvOverride = (
168
+ result: Record<string, unknown>,
169
+ schema: z.ZodType,
170
+ path: string,
171
+ envValue: string
172
+ ): void => {
173
+ const fieldSchema = getFieldSchema(schema, path);
174
+ const coerced = fieldSchema
175
+ ? coerceEnvValue(envValue, fieldSchema)
176
+ : envValue;
177
+ setAtPath(result, path, coerced);
178
+ };
179
+
180
+ /** Resolve a single env binding: look up the var, apply if present. */
181
+ const resolveEnvBinding = (
182
+ result: Record<string, unknown>,
183
+ schema: z.ZodType,
184
+ path: string,
185
+ envVar: string,
186
+ envVars: Record<string, string | undefined>
187
+ ): void => {
188
+ const envValue = envVars[envVar];
189
+ if (envValue !== undefined) {
190
+ applyOneEnvOverride(result, schema, path, envValue);
191
+ }
192
+ };
193
+
194
+ /** Apply env var overrides based on schema metadata. */
195
+ const applyEnvOverrides = (
196
+ merged: Record<string, unknown>,
197
+ schema: z.ZodType,
198
+ envVars: Record<string, string | undefined>
199
+ ): Record<string, unknown> => {
200
+ if (zodDef(schema)['type'] !== 'object') {
201
+ return merged;
202
+ }
203
+
204
+ const meta = collectConfigMeta(
205
+ schema as z.ZodObject<Record<string, z.ZodType>>
206
+ );
207
+ const result = deepMerge({}, merged);
208
+
209
+ for (const [path, fieldMeta] of meta) {
210
+ if (fieldMeta.env) {
211
+ resolveEnvBinding(result, schema, path, fieldMeta.env, envVars);
212
+ }
213
+ }
214
+
215
+ return result;
216
+ };
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Merge pipeline
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /** Apply the layered merge: base → loadout → local overrides. */
223
+ const mergeLayers = (
224
+ base: Record<string, unknown> | undefined,
225
+ loadouts: Record<string, Record<string, unknown>> | undefined,
226
+ loadout: string | undefined,
227
+ localOverrides: Record<string, unknown> | undefined
228
+ ): Record<string, unknown> => {
229
+ let merged: Record<string, unknown> = {};
230
+ if (base) {
231
+ merged = deepMerge(merged, base);
232
+ }
233
+
234
+ const selected = loadout && loadouts ? loadouts[loadout] : undefined;
235
+ if (selected) {
236
+ merged = deepMerge(merged, selected);
237
+ }
238
+
239
+ if (localOverrides) {
240
+ merged = deepMerge(merged, localOverrides);
241
+ }
242
+ return merged;
243
+ };
244
+
245
+ /** Format Zod issues into a human-readable error message. */
246
+ const formatValidationError = (
247
+ issues: readonly { path: PropertyKey[]; message: string }[]
248
+ ): string =>
249
+ `Config validation failed: ${issues.map((i) => `${String(i.path.join('.'))}: ${i.message}`).join(', ')}`;
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Public API
253
+ // ---------------------------------------------------------------------------
254
+
255
+ /**
256
+ * Resolve config through the full stack: defaults → base → loadout → local → env.
257
+ * Returns `Result.ok` with the validated config, or `Result.err` on validation failure.
258
+ */
259
+ export const resolveConfig = <T extends z.ZodType>(
260
+ options: ResolveConfigOptions<T>
261
+ ): Result<z.infer<T>, Error> => {
262
+ let merged = mergeLayers(
263
+ options.base,
264
+ options.loadouts,
265
+ options.loadout,
266
+ options.localOverrides
267
+ );
268
+
269
+ if (options.env) {
270
+ merged = applyEnvOverrides(merged, options.schema, options.env);
271
+ }
272
+
273
+ const parsed = options.schema.safeParse(merged);
274
+ if (parsed.success) {
275
+ return Result.ok(parsed.data as z.infer<T>);
276
+ }
277
+
278
+ return Result.err(new Error(formatValidationError(parsed.error.issues)));
279
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Heuristic detection of secret env var names.
3
+ *
4
+ * Matches common suffixes like `_SECRET`, `_TOKEN`, `_KEY`, `_PASSWORD`,
5
+ * and `_CREDENTIALS` so that generated `.env.example` files and provenance
6
+ * output can redact likely-secret values even without explicit `secret()`.
7
+ */
8
+
9
+ const SECRET_PATTERN = /_SECRET$|_TOKEN$|_KEY$|_PASSWORD$|_CREDENTIALS$/i;
10
+
11
+ /** Return true when `envName` looks like it holds a secret value. */
12
+ export const isLikelySecret = (envName: string): boolean =>
13
+ SECRET_PATTERN.test(envName);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Infrastructure trail that validates config values against a schema.
3
+ *
4
+ * Returns structured diagnostics indicating which fields are valid,
5
+ * missing, invalid, deprecated, or using defaults.
6
+ */
7
+ import { Result, trail } from '@ontrails/core';
8
+ import { z } from 'zod';
9
+
10
+ import { configService } from '../config-service.js';
11
+ import { checkConfig } from '../doctor.js';
12
+ import { deepMerge } from '../merge.js';
13
+
14
+ const diagnosticSchema = z.object({
15
+ message: z.string(),
16
+ path: z.string(),
17
+ status: z.enum(['valid', 'missing', 'invalid', 'deprecated', 'default']),
18
+ });
19
+
20
+ const outputSchema = z.object({
21
+ diagnostics: z.array(diagnosticSchema),
22
+ valid: z.boolean(),
23
+ });
24
+
25
+ /** Merge input values on top of resolved config values. */
26
+ const mergeValues = (
27
+ resolved: Record<string, unknown>,
28
+ overrides: Record<string, unknown>
29
+ ): Record<string, unknown> => {
30
+ const hasOverrides = Object.keys(overrides).length > 0;
31
+ return hasOverrides ? deepMerge(resolved, overrides) : resolved;
32
+ };
33
+
34
+ export const configCheck = trail('config.check', {
35
+ examples: [
36
+ {
37
+ input: {},
38
+ name: 'Check current config',
39
+ },
40
+ ],
41
+ input: z.object({
42
+ values: z
43
+ .record(z.string(), z.unknown())
44
+ .describe('Config values to check (merged with resolved)')
45
+ .default({}),
46
+ }),
47
+ intent: 'read',
48
+ metadata: { category: 'infrastructure' },
49
+ output: outputSchema,
50
+ run: (input, ctx) => {
51
+ const state = configService.from(ctx);
52
+ const effective = mergeValues(state.resolved, input.values);
53
+ const checked = checkConfig(state.schema, effective);
54
+ return Result.ok({
55
+ diagnostics: [...checked.diagnostics],
56
+ valid: checked.valid,
57
+ });
58
+ },
59
+ services: [configService],
60
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Infrastructure trail that describes all config fields in a schema.
3
+ *
4
+ * Returns a structured catalog of field definitions suitable for
5
+ * CLI rendering or agent inspection.
6
+ */
7
+ import { Result, trail } from '@ontrails/core';
8
+ import { z } from 'zod';
9
+
10
+ import { configService } from '../config-service.js';
11
+ import { describeConfig } from '../describe.js';
12
+
13
+ const fieldSchema = z.object({
14
+ deprecated: z.string().optional(),
15
+ description: z.string().optional(),
16
+ env: z.string().optional(),
17
+ path: z.string(),
18
+ required: z.boolean(),
19
+ secret: z.boolean().optional(),
20
+ type: z.string(),
21
+ });
22
+
23
+ const outputSchema = z.object({
24
+ fields: z.array(fieldSchema),
25
+ });
26
+
27
+ export const configDescribe = trail('config.describe', {
28
+ examples: [
29
+ {
30
+ input: {},
31
+ name: 'Describe all config fields',
32
+ },
33
+ ],
34
+ input: z.object({}),
35
+ intent: 'read',
36
+ metadata: { category: 'infrastructure' },
37
+ output: outputSchema,
38
+ run: (_input, ctx) => {
39
+ const state = configService.from(ctx);
40
+ const fields = describeConfig(state.schema);
41
+ return Result.ok({ fields: [...fields] });
42
+ },
43
+ services: [configService],
44
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Infrastructure trail that exposes config provenance.
3
+ *
4
+ * Returns resolved config entries with source information so agents
5
+ * and operators can answer "where did this value come from?"
6
+ */
7
+ import { Result, trail } from '@ontrails/core';
8
+ import { z } from 'zod';
9
+
10
+ import { configService } from '../config-service.js';
11
+ import type { ExplainConfigOptions } from '../explain.js';
12
+ import { explainConfig } from '../explain.js';
13
+ import type { ConfigState } from '../registry.js';
14
+
15
+ const provenanceEntrySchema = z.object({
16
+ path: z.string(),
17
+ redacted: z.boolean(),
18
+ source: z.string(),
19
+ value: z.unknown(),
20
+ });
21
+
22
+ const outputSchema = z.object({
23
+ entries: z.array(provenanceEntrySchema),
24
+ });
25
+
26
+ /** Filter provenance entries by path prefix when specified. */
27
+ const filterByPath = (
28
+ entries: readonly { readonly path: string }[],
29
+ prefix: string
30
+ ): readonly { readonly path: string }[] =>
31
+ prefix
32
+ ? entries.filter(
33
+ (entry) => entry.path === prefix || entry.path.startsWith(`${prefix}.`)
34
+ )
35
+ : entries;
36
+
37
+ /** Build ExplainConfigOptions from ConfigState, omitting undefined layers. */
38
+ const toExplainOptions = (
39
+ state: ConfigState
40
+ ): ExplainConfigOptions<typeof state.schema> => {
41
+ const base: ExplainConfigOptions<typeof state.schema> = {
42
+ resolved: state.resolved,
43
+ schema: state.schema,
44
+ };
45
+ if (state.base) {
46
+ return { ...base, base: state.base };
47
+ }
48
+ return base;
49
+ };
50
+
51
+ /** Enrich explain options with env and layer overrides from state. */
52
+ const enrichOptions = (
53
+ state: ConfigState,
54
+ options: ExplainConfigOptions<typeof state.schema>
55
+ ): ExplainConfigOptions<typeof state.schema> => {
56
+ let enriched = options;
57
+ if (state.env) {
58
+ enriched = { ...enriched, env: state.env };
59
+ }
60
+ if (state.loadout) {
61
+ enriched = { ...enriched, loadout: state.loadout };
62
+ }
63
+ if (state.local) {
64
+ enriched = { ...enriched, local: state.local };
65
+ }
66
+ return enriched;
67
+ };
68
+
69
+ export const configExplain = trail('config.explain', {
70
+ examples: [
71
+ {
72
+ input: {},
73
+ name: 'Explain all fields',
74
+ },
75
+ ],
76
+ input: z.object({
77
+ path: z
78
+ .string()
79
+ .describe('Config field path to explain (or empty for all)')
80
+ .default(''),
81
+ }),
82
+ intent: 'read',
83
+ metadata: { category: 'infrastructure' },
84
+ output: outputSchema,
85
+ run: (input, ctx) => {
86
+ const state = configService.from(ctx);
87
+ const options = enrichOptions(state, toExplainOptions(state));
88
+ const entries = explainConfig(options);
89
+ const filtered = filterByPath(entries, input.path);
90
+ return Result.ok({ entries: [...filtered] });
91
+ },
92
+ services: [configService],
93
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Infrastructure trail that generates an example config file.
3
+ *
4
+ * Produces TOML, JSON, JSONC, or YAML output from the registered
5
+ * config schema, with defaults shown and deprecated fields annotated.
6
+ *
7
+ * When `dir` is provided, also writes `.env.example` and `.schema.json`
8
+ * to the specified directory.
9
+ */
10
+ import { join } from 'node:path';
11
+ import { mkdir } from 'node:fs/promises';
12
+
13
+ import { Result, trail } from '@ontrails/core';
14
+ import type { z } from 'zod';
15
+ import { z as zod } from 'zod';
16
+
17
+ import { configService } from '../config-service.js';
18
+ import {
19
+ generateEnvExample,
20
+ generateExample,
21
+ generateJsonSchema,
22
+ } from '../generate/index.js';
23
+
24
+ const formatEnum = zod.enum(['toml', 'json', 'jsonc', 'yaml']);
25
+
26
+ const outputSchema = zod.object({
27
+ content: zod.string(),
28
+ format: zod.string(),
29
+ writtenFiles: zod.array(zod.string()).optional(),
30
+ });
31
+
32
+ /** Collect artifacts to write: [relativeName, content] pairs. */
33
+ const collectArtifacts = (
34
+ schema: z.ZodObject<Record<string, z.ZodType>>
35
+ ): [string, string][] => {
36
+ const artifacts: [string, string][] = [];
37
+ const envContent = generateEnvExample(schema);
38
+ if (envContent.length > 0) {
39
+ artifacts.push(['.env.example', envContent]);
40
+ }
41
+ artifacts.push([
42
+ '.schema.json',
43
+ JSON.stringify(generateJsonSchema(schema), null, 2),
44
+ ]);
45
+ return artifacts;
46
+ };
47
+
48
+ /** Write generated artifacts to the target directory. */
49
+ const writeArtifacts = async (
50
+ dir: string,
51
+ schema: z.ZodObject<Record<string, z.ZodType>>
52
+ ): Promise<string[]> => {
53
+ await mkdir(dir, { recursive: true });
54
+ const artifacts = collectArtifacts(schema);
55
+ const written: string[] = [];
56
+ for (const [name, content] of artifacts) {
57
+ const fullPath = join(dir, name);
58
+ await Bun.write(fullPath, content);
59
+ written.push(fullPath);
60
+ }
61
+ return written;
62
+ };
63
+
64
+ export const configInit = trail('config.init', {
65
+ examples: [
66
+ {
67
+ input: {},
68
+ name: 'Generate TOML example',
69
+ },
70
+ ],
71
+ input: zod.object({
72
+ dir: zod
73
+ .string()
74
+ .describe('Directory to write generated artifacts to')
75
+ .optional(),
76
+ format: formatEnum
77
+ .describe('Output format for the example config file')
78
+ .default('toml'),
79
+ }),
80
+ intent: 'write',
81
+ metadata: { category: 'infrastructure' },
82
+ output: outputSchema,
83
+ run: async (input, ctx) => {
84
+ const state = configService.from(ctx);
85
+ const schema = state.schema as z.ZodObject<Record<string, z.ZodType>>;
86
+ const content = generateExample(schema, input.format);
87
+
88
+ if (input.dir) {
89
+ const writtenFiles = await writeArtifacts(input.dir, schema);
90
+ return Result.ok({ content, format: input.format, writtenFiles });
91
+ }
92
+
93
+ return Result.ok({ content, format: input.format });
94
+ },
95
+ services: [configService],
96
+ });