@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.
- package/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +19 -0
- package/dist/app-config.d.ts +65 -0
- package/dist/app-config.d.ts.map +1 -0
- package/dist/app-config.js +172 -0
- package/dist/app-config.js.map +1 -0
- package/dist/collect.d.ts +11 -0
- package/dist/collect.d.ts.map +1 -0
- package/dist/collect.js +81 -0
- package/dist/collect.js.map +1 -0
- package/dist/compose.d.ts +26 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +19 -0
- package/dist/compose.js.map +1 -0
- package/dist/config-layer.d.ts +11 -0
- package/dist/config-layer.d.ts.map +1 -0
- package/dist/config-layer.js +6 -0
- package/dist/config-layer.js.map +1 -0
- package/dist/config-service.d.ts +3 -0
- package/dist/config-service.d.ts.map +1 -0
- package/dist/config-service.js +26 -0
- package/dist/config-service.js.map +1 -0
- package/dist/define-config.d.ts +61 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +90 -0
- package/dist/define-config.js.map +1 -0
- package/dist/describe.d.ts +25 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +147 -0
- package/dist/describe.js.map +1 -0
- package/dist/doctor.d.ts +27 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +167 -0
- package/dist/doctor.js.map +1 -0
- package/dist/explain.d.ts +30 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +114 -0
- package/dist/explain.js.map +1 -0
- package/dist/extensions.d.ts +38 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/extensions.js +35 -0
- package/dist/extensions.js.map +1 -0
- package/dist/generate/env.d.ts +15 -0
- package/dist/generate/env.d.ts.map +1 -0
- package/dist/generate/env.js +65 -0
- package/dist/generate/env.js.map +1 -0
- package/dist/generate/example.d.ts +16 -0
- package/dist/generate/example.d.ts.map +1 -0
- package/dist/generate/example.js +136 -0
- package/dist/generate/example.js.map +1 -0
- package/dist/generate/helpers.d.ts +35 -0
- package/dist/generate/helpers.d.ts.map +1 -0
- package/dist/generate/helpers.js +116 -0
- package/dist/generate/helpers.js.map +1 -0
- package/dist/generate/index.d.ts +4 -0
- package/dist/generate/index.d.ts.map +1 -0
- package/dist/generate/index.js +4 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/generate/json-schema.d.ts +18 -0
- package/dist/generate/json-schema.d.ts.map +1 -0
- package/dist/generate/json-schema.js +97 -0
- package/dist/generate/json-schema.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/merge.d.ts +16 -0
- package/dist/merge.d.ts.map +1 -0
- package/dist/merge.js +34 -0
- package/dist/merge.js.map +1 -0
- package/dist/ref.d.ts +24 -0
- package/dist/ref.d.ts.map +1 -0
- package/dist/ref.js +25 -0
- package/dist/ref.js.map +1 -0
- package/dist/registry.d.ts +24 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +12 -0
- package/dist/registry.js.map +1 -0
- package/dist/resolve.d.ts +21 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +174 -0
- package/dist/resolve.js.map +1 -0
- package/dist/secret-heuristics.d.ts +10 -0
- package/dist/secret-heuristics.d.ts.map +1 -0
- package/dist/secret-heuristics.js +11 -0
- package/dist/secret-heuristics.js.map +1 -0
- package/dist/trails/config-check.d.ts +11 -0
- package/dist/trails/config-check.d.ts.map +1 -0
- package/dist/trails/config-check.js +53 -0
- package/dist/trails/config-check.js.map +1 -0
- package/dist/trails/config-describe.d.ts +12 -0
- package/dist/trails/config-describe.d.ts.map +1 -0
- package/dist/trails/config-describe.js +41 -0
- package/dist/trails/config-describe.js.map +1 -0
- package/dist/trails/config-explain.d.ts +8 -0
- package/dist/trails/config-explain.d.ts.map +1 -0
- package/dist/trails/config-explain.js +74 -0
- package/dist/trails/config-explain.js.map +1 -0
- package/dist/trails/config-init.d.ts +9 -0
- package/dist/trails/config-init.d.ts.map +1 -0
- package/dist/trails/config-init.js +78 -0
- package/dist/trails/config-init.js.map +1 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +44 -0
- package/dist/workspace.js.map +1 -0
- package/dist/zod-utils.d.ts +14 -0
- package/dist/zod-utils.d.ts.map +1 -0
- package/dist/zod-utils.js +41 -0
- package/dist/zod-utils.js.map +1 -0
- package/package.json +20 -0
- package/src/__tests__/app-config.test.ts +329 -0
- package/src/__tests__/compose.test.ts +59 -0
- package/src/__tests__/config-check.test.ts +171 -0
- package/src/__tests__/config-describe.test.ts +154 -0
- package/src/__tests__/config-explain.test.ts +167 -0
- package/src/__tests__/config-init.test.ts +210 -0
- package/src/__tests__/config-layer.test.ts +53 -0
- package/src/__tests__/config-service.test.ts +87 -0
- package/src/__tests__/define-config.test.ts +263 -0
- package/src/__tests__/describe.test.ts +158 -0
- package/src/__tests__/doctor.test.ts +172 -0
- package/src/__tests__/explain.test.ts +139 -0
- package/src/__tests__/extensions.test.ts +134 -0
- package/src/__tests__/generate.test.ts +269 -0
- package/src/__tests__/ref.test.ts +35 -0
- package/src/__tests__/resolve.test.ts +246 -0
- package/src/__tests__/workspace.test.ts +64 -0
- package/src/app-config.ts +307 -0
- package/src/collect.ts +118 -0
- package/src/compose.ts +46 -0
- package/src/config-layer.ts +15 -0
- package/src/config-service.ts +32 -0
- package/src/define-config.ts +134 -0
- package/src/describe.ts +252 -0
- package/src/doctor.ts +219 -0
- package/src/explain.ts +176 -0
- package/src/extensions.ts +51 -0
- package/src/generate/env.ts +104 -0
- package/src/generate/example.ts +222 -0
- package/src/generate/helpers.ts +158 -0
- package/src/generate/index.ts +3 -0
- package/src/generate/json-schema.ts +137 -0
- package/src/index.ts +44 -0
- package/src/merge.ts +43 -0
- package/src/ref.ts +38 -0
- package/src/registry.ts +33 -0
- package/src/resolve.ts +279 -0
- package/src/secret-heuristics.ts +13 -0
- package/src/trails/config-check.ts +60 -0
- package/src/trails/config-describe.ts +44 -0
- package/src/trails/config-explain.ts +93 -0
- package/src/trails/config-init.ts +96 -0
- package/src/workspace.ts +51 -0
- package/src/zod-utils.ts +53 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App config factory — declare a config contract once, discover and validate at runtime.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { NotFoundError, Result, ValidationError } from '@ontrails/core';
|
|
8
|
+
import type { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
import type { CheckResult } from './doctor.js';
|
|
11
|
+
import { checkConfig } from './doctor.js';
|
|
12
|
+
import type { FieldDescription } from './describe.js';
|
|
13
|
+
import { describeConfig } from './describe.js';
|
|
14
|
+
import type { ExplainConfigOptions, ProvenanceEntry } from './explain.js';
|
|
15
|
+
import { explainConfig } from './explain.js';
|
|
16
|
+
import type { ConfigRef } from './ref.js';
|
|
17
|
+
import { configRef } from './ref.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Supported config file formats. */
|
|
24
|
+
export type ConfigFormat = 'toml' | 'json' | 'jsonc' | 'yaml';
|
|
25
|
+
|
|
26
|
+
/** Options for creating an app config. */
|
|
27
|
+
export interface AppConfigOptions<T extends z.ZodType> {
|
|
28
|
+
readonly schema: T;
|
|
29
|
+
readonly formats?: readonly ConfigFormat[];
|
|
30
|
+
readonly dotfile?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options for resolving (discovering + parsing) a config file. */
|
|
34
|
+
export interface ResolveOptions {
|
|
35
|
+
/** Working directory for discovery. Defaults to `process.cwd()`. */
|
|
36
|
+
readonly cwd?: string;
|
|
37
|
+
/** Explicit file path — skips discovery when provided. */
|
|
38
|
+
readonly path?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for the `explain()` method on AppConfig, excluding schema. */
|
|
42
|
+
export type AppConfigExplainOptions = Omit<
|
|
43
|
+
ExplainConfigOptions<z.ZodType>,
|
|
44
|
+
'schema'
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
/** The resolved config contract returned by `appConfig()`. */
|
|
48
|
+
export interface AppConfig<T extends z.ZodType> {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly schema: T;
|
|
51
|
+
readonly formats: readonly ConfigFormat[];
|
|
52
|
+
readonly dotfile: boolean;
|
|
53
|
+
resolve(options?: ResolveOptions): Promise<Result<z.infer<T>, Error>>;
|
|
54
|
+
|
|
55
|
+
/** Describe all fields in the schema without needing values. */
|
|
56
|
+
describe(): readonly FieldDescription[];
|
|
57
|
+
|
|
58
|
+
/** Check a config object against the schema and return diagnostics. */
|
|
59
|
+
check(
|
|
60
|
+
values: Record<string, unknown>,
|
|
61
|
+
options?: { readonly env?: Record<string, string | undefined> }
|
|
62
|
+
): CheckResult;
|
|
63
|
+
|
|
64
|
+
/** Show which source won for each config field. */
|
|
65
|
+
explain(options: AppConfigExplainOptions): readonly ProvenanceEntry[];
|
|
66
|
+
|
|
67
|
+
/** Create a lazy reference to a config field for use as a trail input default. */
|
|
68
|
+
ref(fieldPath: string): ConfigRef;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Default values
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const DEFAULT_FORMATS: readonly ConfigFormat[] = ['toml', 'json', 'yaml'];
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Internal helpers (defined before consumers — no-use-before-define)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** Build the config filename for a given format. */
|
|
82
|
+
const configFileName = (
|
|
83
|
+
name: string,
|
|
84
|
+
format: ConfigFormat,
|
|
85
|
+
dotfile: boolean
|
|
86
|
+
): string => (dotfile ? `.${name}rc.${format}` : `${name}.config.${format}`);
|
|
87
|
+
|
|
88
|
+
/** Check whether a file exists at the given path. */
|
|
89
|
+
const fileExists = (filePath: string): Promise<boolean> =>
|
|
90
|
+
Bun.file(filePath).exists();
|
|
91
|
+
|
|
92
|
+
/** Known config suffixes, ordered to avoid `.json` matching `.jsonc`. */
|
|
93
|
+
const FORMAT_SUFFIXES: readonly [ConfigFormat, `.${string}`][] = [
|
|
94
|
+
['jsonc', '.jsonc'],
|
|
95
|
+
['json', '.json'],
|
|
96
|
+
['toml', '.toml'],
|
|
97
|
+
['yaml', '.yaml'],
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** Detect the declared config format from a file path. */
|
|
101
|
+
const detectFormat = (filePath: string): ConfigFormat | undefined => {
|
|
102
|
+
for (const [format, suffix] of FORMAT_SUFFIXES) {
|
|
103
|
+
if (filePath.endsWith(suffix)) {
|
|
104
|
+
return format;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Parse config file text with Bun's native format parsers. */
|
|
111
|
+
const parseConfigText = (
|
|
112
|
+
filePath: string,
|
|
113
|
+
text: string
|
|
114
|
+
): Result<unknown, Error> => {
|
|
115
|
+
const format = detectFormat(filePath);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
switch (format) {
|
|
119
|
+
case 'json': {
|
|
120
|
+
return Result.ok(JSON.parse(text));
|
|
121
|
+
}
|
|
122
|
+
case 'jsonc': {
|
|
123
|
+
return Result.ok(Bun.JSONC.parse(text));
|
|
124
|
+
}
|
|
125
|
+
case 'toml': {
|
|
126
|
+
return Result.ok(Bun.TOML.parse(text));
|
|
127
|
+
}
|
|
128
|
+
case 'yaml': {
|
|
129
|
+
return Result.ok(Bun.YAML.parse(text));
|
|
130
|
+
}
|
|
131
|
+
default: {
|
|
132
|
+
return Result.err(
|
|
133
|
+
new ValidationError(`Unsupported config file format: ${filePath}`, {
|
|
134
|
+
context: { path: filePath },
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return Result.err(
|
|
141
|
+
new ValidationError(`Failed to parse config file: ${filePath}`, {
|
|
142
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
143
|
+
context: { path: filePath },
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Read and parse a config file, always reflecting the latest on-disk content. */
|
|
150
|
+
const readConfigFile = async (
|
|
151
|
+
filePath: string
|
|
152
|
+
): Promise<Result<unknown, Error>> => {
|
|
153
|
+
const exists = await fileExists(filePath);
|
|
154
|
+
if (!exists) {
|
|
155
|
+
return Result.err(
|
|
156
|
+
new NotFoundError(`Config file not found: ${filePath}`, {
|
|
157
|
+
context: { path: filePath },
|
|
158
|
+
})
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const text = await Bun.file(filePath).text();
|
|
163
|
+
return parseConfigText(filePath, text);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Validate parsed data against a Zod schema. */
|
|
167
|
+
const validateConfig = <T extends z.ZodType>(
|
|
168
|
+
schema: T,
|
|
169
|
+
data: unknown,
|
|
170
|
+
filePath: string
|
|
171
|
+
): Result<z.infer<T>, Error> => {
|
|
172
|
+
const parsed = schema.safeParse(data);
|
|
173
|
+
if (parsed.success) {
|
|
174
|
+
return Result.ok(parsed.data as z.infer<T>);
|
|
175
|
+
}
|
|
176
|
+
return Result.err(
|
|
177
|
+
new ValidationError(`Config validation failed: ${filePath}`, {
|
|
178
|
+
context: {
|
|
179
|
+
issues: parsed.error.issues,
|
|
180
|
+
path: filePath,
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** Check all format candidates in a single directory. */
|
|
187
|
+
const findInDir = async (
|
|
188
|
+
dir: string,
|
|
189
|
+
name: string,
|
|
190
|
+
formats: readonly ConfigFormat[],
|
|
191
|
+
dotfile: boolean
|
|
192
|
+
): Promise<string | undefined> => {
|
|
193
|
+
for (const format of formats) {
|
|
194
|
+
const candidate = join(dir, configFileName(name, format, dotfile));
|
|
195
|
+
if (await fileExists(candidate)) {
|
|
196
|
+
return candidate;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/** Walk up from `startDir` looking for any matching config filename. */
|
|
203
|
+
const discoverConfigFile = async (
|
|
204
|
+
name: string,
|
|
205
|
+
formats: readonly ConfigFormat[],
|
|
206
|
+
dotfile: boolean,
|
|
207
|
+
startDir: string
|
|
208
|
+
): Promise<string | undefined> => {
|
|
209
|
+
let dir = startDir;
|
|
210
|
+
|
|
211
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
212
|
+
const found = await findInDir(dir, name, formats, dotfile);
|
|
213
|
+
if (found !== undefined) {
|
|
214
|
+
return found;
|
|
215
|
+
}
|
|
216
|
+
const parent = dirname(dir);
|
|
217
|
+
// Reached filesystem root
|
|
218
|
+
if (parent === dir) {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
dir = parent;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return undefined;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Resolve a config file — either from an explicit path or via discovery. */
|
|
228
|
+
const resolveConfig = async <T extends z.ZodType>(
|
|
229
|
+
name: string,
|
|
230
|
+
schema: T,
|
|
231
|
+
formats: readonly ConfigFormat[],
|
|
232
|
+
dotfile: boolean,
|
|
233
|
+
options?: ResolveOptions
|
|
234
|
+
): Promise<Result<z.infer<T>, Error>> => {
|
|
235
|
+
const filePath =
|
|
236
|
+
options?.path ??
|
|
237
|
+
(await discoverConfigFile(
|
|
238
|
+
name,
|
|
239
|
+
formats,
|
|
240
|
+
dotfile,
|
|
241
|
+
options?.cwd ?? process.cwd()
|
|
242
|
+
));
|
|
243
|
+
|
|
244
|
+
if (filePath === undefined) {
|
|
245
|
+
return Result.err(
|
|
246
|
+
new NotFoundError(`No config file found for "${name}"`, {
|
|
247
|
+
context: { dotfile, formats: [...formats], name },
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const readResult = await readConfigFile(filePath);
|
|
253
|
+
if (readResult.isErr()) {
|
|
254
|
+
return readResult;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return validateConfig(schema, readResult.value, filePath);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Factory
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Declare a config contract for an app.
|
|
266
|
+
*
|
|
267
|
+
* The returned `AppConfig` exposes `resolve()` to discover, parse, and validate
|
|
268
|
+
* a config file matching the app name and format conventions.
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```ts
|
|
272
|
+
* const config = appConfig('myapp', {
|
|
273
|
+
* schema: z.object({
|
|
274
|
+
* output: z.string().default('./output'),
|
|
275
|
+
* verbose: z.boolean().default(false),
|
|
276
|
+
* }),
|
|
277
|
+
* });
|
|
278
|
+
*
|
|
279
|
+
* const result = await config.resolve();
|
|
280
|
+
* if (result.isOk()) console.log(result.value.output);
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
export const appConfig = <T extends z.ZodType>(
|
|
284
|
+
name: string,
|
|
285
|
+
options: AppConfigOptions<T>
|
|
286
|
+
): AppConfig<T> => {
|
|
287
|
+
const formats = options.formats ?? DEFAULT_FORMATS;
|
|
288
|
+
const dotfile = options.dotfile ?? false;
|
|
289
|
+
|
|
290
|
+
const { schema } = options;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
check: (values, checkOpts) => checkConfig(schema, values, checkOpts),
|
|
294
|
+
describe: () =>
|
|
295
|
+
describeConfig(
|
|
296
|
+
schema as unknown as z.ZodObject<Record<string, z.ZodType>>
|
|
297
|
+
),
|
|
298
|
+
dotfile,
|
|
299
|
+
explain: (explainOpts) => explainConfig({ ...explainOpts, schema }),
|
|
300
|
+
formats,
|
|
301
|
+
name,
|
|
302
|
+
ref: (fieldPath) => configRef(fieldPath),
|
|
303
|
+
resolve: (resolveOptions?: ResolveOptions) =>
|
|
304
|
+
resolveConfig(name, schema, formats, dotfile, resolveOptions),
|
|
305
|
+
schema,
|
|
306
|
+
};
|
|
307
|
+
};
|
package/src/collect.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { globalRegistry } from 'zod';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import type { ConfigFieldMeta } from './extensions.js';
|
|
5
|
+
import { isZodObject, unwrapToBase } from './zod-utils.js';
|
|
6
|
+
|
|
7
|
+
/** Config meta keys we look for in Zod registry entries. */
|
|
8
|
+
const META_EXTRACTORS: readonly {
|
|
9
|
+
test: (raw: Record<string, unknown>) => boolean;
|
|
10
|
+
extract: (raw: Record<string, unknown>) => Partial<ConfigFieldMeta>;
|
|
11
|
+
}[] = [
|
|
12
|
+
{
|
|
13
|
+
extract: (r) => ({ env: r['env'] as string }),
|
|
14
|
+
test: (r) => typeof r['env'] === 'string',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
extract: () => ({ secret: true }),
|
|
18
|
+
test: (r) => r['secret'] === true,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
extract: (r) => ({ deprecated: r['deprecationMessage'] as string }),
|
|
22
|
+
test: (r) => typeof r['deprecationMessage'] === 'string',
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pick only `ConfigFieldMeta` keys from a raw registry entry.
|
|
28
|
+
* Uses a lookup table to stay under the max-statements limit.
|
|
29
|
+
*/
|
|
30
|
+
const pickConfigMeta = (
|
|
31
|
+
raw: Record<string, unknown> | undefined
|
|
32
|
+
): ConfigFieldMeta | undefined => {
|
|
33
|
+
if (!raw) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const parts = META_EXTRACTORS.filter((e) => e.test(raw)).map((e) =>
|
|
38
|
+
e.extract(raw)
|
|
39
|
+
);
|
|
40
|
+
return parts.length > 0
|
|
41
|
+
? (Object.assign({}, ...parts) as ConfigFieldMeta)
|
|
42
|
+
: undefined;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract `ConfigFieldMeta` from a schema, unwrapping through
|
|
47
|
+
* `.default()`, `.optional()`, `.nullable()` wrappers as needed.
|
|
48
|
+
*/
|
|
49
|
+
const extractConfigMeta = (schema: z.ZodType): ConfigFieldMeta | undefined => {
|
|
50
|
+
let current: z.ZodType | undefined = schema;
|
|
51
|
+
|
|
52
|
+
while (current) {
|
|
53
|
+
const meta = pickConfigMeta(globalRegistry.get(current));
|
|
54
|
+
if (meta) {
|
|
55
|
+
return meta;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const def = current.def as unknown as Record<string, unknown>;
|
|
59
|
+
current = def['innerType'] as z.ZodType | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Entry in the iterative work queue for schema walking. */
|
|
66
|
+
interface WalkEntry {
|
|
67
|
+
readonly schema: z.ZodObject<Record<string, z.ZodType>>;
|
|
68
|
+
readonly prefix: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Process one level of an object schema, queuing nested objects. */
|
|
72
|
+
const walkObjectShape = (
|
|
73
|
+
schema: z.ZodObject<Record<string, z.ZodType>>,
|
|
74
|
+
prefix: string,
|
|
75
|
+
result: Map<string, ConfigFieldMeta>,
|
|
76
|
+
queue: WalkEntry[]
|
|
77
|
+
): void => {
|
|
78
|
+
const shape = schema.shape as Record<string, z.ZodType>;
|
|
79
|
+
|
|
80
|
+
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
81
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
82
|
+
|
|
83
|
+
if (isZodObject(fieldSchema)) {
|
|
84
|
+
queue.push({
|
|
85
|
+
prefix: path,
|
|
86
|
+
schema: unwrapToBase(fieldSchema) as z.ZodObject<
|
|
87
|
+
Record<string, z.ZodType>
|
|
88
|
+
>,
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
const meta = extractConfigMeta(fieldSchema);
|
|
92
|
+
if (meta) {
|
|
93
|
+
result.set(path, meta);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Walk a Zod object schema and collect `ConfigFieldMeta` for each field.
|
|
101
|
+
*
|
|
102
|
+
* Handles unwrapping `.default()`, `.optional()`, `.nullable()` wrappers
|
|
103
|
+
* that don't carry inner metadata forward. Recurses into nested `ZodObject`
|
|
104
|
+
* fields using dot-separated paths.
|
|
105
|
+
*/
|
|
106
|
+
export const collectConfigMeta = (
|
|
107
|
+
schema: z.ZodObject<Record<string, z.ZodType>>,
|
|
108
|
+
prefix = ''
|
|
109
|
+
): Map<string, ConfigFieldMeta> => {
|
|
110
|
+
const result = new Map<string, ConfigFieldMeta>();
|
|
111
|
+
const queue: WalkEntry[] = [{ prefix, schema }];
|
|
112
|
+
|
|
113
|
+
for (let entry = queue.pop(); entry; entry = queue.pop()) {
|
|
114
|
+
walkObjectShape(entry.schema, entry.prefix, result, queue);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
};
|
package/src/compose.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config composition utilities for services.
|
|
3
|
+
*
|
|
4
|
+
* Collects config schemas from service declarations so they can be
|
|
5
|
+
* composed into a unified config structure via `defineConfig`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
/** A service config schema entry extracted from a service declaration. */
|
|
15
|
+
export interface ServiceConfigEntry {
|
|
16
|
+
readonly serviceId: string;
|
|
17
|
+
readonly schema: z.ZodType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Minimal shape needed to extract config from a service-like object. */
|
|
21
|
+
interface ServiceWithOptionalConfig {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly config?: z.ZodType | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public API
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collect config schemas from services that declare them.
|
|
32
|
+
*
|
|
33
|
+
* Returns entries keyed by service ID for composition into `defineConfig`.
|
|
34
|
+
* Services without a `config` schema are excluded.
|
|
35
|
+
*/
|
|
36
|
+
export const collectServiceConfigs = (
|
|
37
|
+
services: readonly ServiceWithOptionalConfig[]
|
|
38
|
+
): ServiceConfigEntry[] =>
|
|
39
|
+
services
|
|
40
|
+
.filter(
|
|
41
|
+
(
|
|
42
|
+
svc
|
|
43
|
+
): svc is ServiceWithOptionalConfig & { readonly config: z.ZodType } =>
|
|
44
|
+
svc.config !== undefined
|
|
45
|
+
)
|
|
46
|
+
.map((svc) => ({ schema: svc.config, serviceId: svc.id }));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config layer — attaches resolved config to the execution context.
|
|
3
|
+
*
|
|
4
|
+
* For v1, the layer is a pass-through: config resolution happens at
|
|
5
|
+
* bootstrap time and the service pipeline (TRL-91) injects the resolved
|
|
6
|
+
* config before any trail runs. The layer reserves a named slot so
|
|
7
|
+
* future versions can add per-trail config overrides or validation.
|
|
8
|
+
*/
|
|
9
|
+
import type { Layer } from '@ontrails/core';
|
|
10
|
+
|
|
11
|
+
export const configLayer: Layer = {
|
|
12
|
+
description: 'Ensures resolved config is available in the execution context',
|
|
13
|
+
name: 'config',
|
|
14
|
+
wrap: (_trail, impl) => (input, ctx) => impl(input, ctx),
|
|
15
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config service — manages resolved config lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* The config is resolved during bootstrap (two-phase init per ADR-010)
|
|
5
|
+
* and registered via `registerConfigState`. This service reads from the
|
|
6
|
+
* global registry so trails can access it through `configService.from(ctx)`.
|
|
7
|
+
*/
|
|
8
|
+
import { InternalError, Result, service } from '@ontrails/core';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import type { ConfigState } from './registry.js';
|
|
12
|
+
import { getConfigState } from './registry.js';
|
|
13
|
+
|
|
14
|
+
export const configService = service<ConfigState>('config', {
|
|
15
|
+
create: () => {
|
|
16
|
+
const state = getConfigState();
|
|
17
|
+
if (state === undefined) {
|
|
18
|
+
return Result.err(
|
|
19
|
+
new InternalError(
|
|
20
|
+
'Config state not registered — call registerConfigState at bootstrap'
|
|
21
|
+
)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return Result.ok(state);
|
|
25
|
+
},
|
|
26
|
+
description: 'Resolved application configuration',
|
|
27
|
+
metadata: { category: 'infrastructure' },
|
|
28
|
+
mock: (): ConfigState => ({
|
|
29
|
+
resolved: {},
|
|
30
|
+
schema: z.object({}),
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trails-specific config wrapper — `appConfig('trails', ...)` with
|
|
3
|
+
* framework conventions for loadout selection and local overrides.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import type { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import { appConfig } from './app-config.js';
|
|
12
|
+
import { resolveConfig } from './resolve.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** Options for defining a Trails app config. */
|
|
19
|
+
export interface DefineConfigOptions<T extends z.ZodType> {
|
|
20
|
+
readonly schema: T;
|
|
21
|
+
readonly base?: Partial<z.infer<T>>;
|
|
22
|
+
readonly loadouts?: Record<string, Partial<z.infer<T>>>;
|
|
23
|
+
/** When true, fall back to `NODE_ENV` when `TRAILS_ENV` is unset. */
|
|
24
|
+
readonly envFromNodeEnv?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Options passed to `resolve()` on a defined config. */
|
|
28
|
+
interface DefineConfigResolveOptions {
|
|
29
|
+
readonly loadout?: string;
|
|
30
|
+
readonly env?: Record<string, string | undefined>;
|
|
31
|
+
/** Working directory for local overrides discovery. Defaults to `process.cwd()`. */
|
|
32
|
+
readonly cwd?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Local overrides discovery
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
const LOCAL_OVERRIDE_CANDIDATES = ['local.ts', 'local.js'] as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Discover and synchronously import a `.trails/config/local.{ts,js}` file.
|
|
43
|
+
*
|
|
44
|
+
* Skipped when `TRAILS_ENV=test` for hermetic test environments.
|
|
45
|
+
*/
|
|
46
|
+
const discoverLocalOverrides = async (
|
|
47
|
+
cwd: string,
|
|
48
|
+
envRecord: Record<string, string | undefined>
|
|
49
|
+
): Promise<Record<string, unknown> | undefined> => {
|
|
50
|
+
if (envRecord['TRAILS_ENV'] === 'test') {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const filename of LOCAL_OVERRIDE_CANDIDATES) {
|
|
55
|
+
const candidate = join(cwd, '.trails', 'config', filename);
|
|
56
|
+
if (existsSync(candidate)) {
|
|
57
|
+
const mod: Record<string, unknown> = await import(candidate);
|
|
58
|
+
return (mod['default'] ?? mod) as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Public API
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Define Trails app config.
|
|
71
|
+
*
|
|
72
|
+
* This is `appConfig('trails', ...)` with the framework's own conventions:
|
|
73
|
+
* `TRAILS_ENV` selects the loadout. When `envFromNodeEnv` is true,
|
|
74
|
+
* `NODE_ENV` is used as a fallback when `TRAILS_ENV` is unset.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* const config = defineConfig({
|
|
79
|
+
* schema: z.object({
|
|
80
|
+
* port: z.number().default(3000),
|
|
81
|
+
* debug: z.boolean().default(false),
|
|
82
|
+
* }),
|
|
83
|
+
* base: { port: 8080 },
|
|
84
|
+
* loadouts: {
|
|
85
|
+
* production: { debug: false },
|
|
86
|
+
* test: { debug: true, port: 0 },
|
|
87
|
+
* },
|
|
88
|
+
* });
|
|
89
|
+
*
|
|
90
|
+
* const result = config.resolve();
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export const defineConfig = <T extends z.ZodType>(
|
|
94
|
+
options: DefineConfigOptions<T>
|
|
95
|
+
) => {
|
|
96
|
+
const config = appConfig('trails', {
|
|
97
|
+
formats: ['toml', 'json'],
|
|
98
|
+
schema: options.schema,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...config,
|
|
103
|
+
base: options.base,
|
|
104
|
+
loadouts: options.loadouts,
|
|
105
|
+
resolve: async (resolveOpts?: DefineConfigResolveOptions) => {
|
|
106
|
+
const envRecord = {
|
|
107
|
+
...(resolveOpts?.env ?? process.env),
|
|
108
|
+
} as Record<string, string | undefined>;
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
options.envFromNodeEnv &&
|
|
112
|
+
envRecord['TRAILS_ENV'] === undefined &&
|
|
113
|
+
envRecord['NODE_ENV'] !== undefined
|
|
114
|
+
) {
|
|
115
|
+
envRecord['TRAILS_ENV'] = envRecord['NODE_ENV'];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cwd = resolveOpts?.cwd ?? process.cwd();
|
|
119
|
+
const localOverrides = await discoverLocalOverrides(cwd, envRecord);
|
|
120
|
+
|
|
121
|
+
return resolveConfig({
|
|
122
|
+
base: options.base as Record<string, unknown> | undefined,
|
|
123
|
+
env: envRecord,
|
|
124
|
+
loadout: resolveOpts?.loadout ?? envRecord['TRAILS_ENV'],
|
|
125
|
+
loadouts: options.loadouts as
|
|
126
|
+
| Record<string, Record<string, unknown>>
|
|
127
|
+
| undefined,
|
|
128
|
+
localOverrides,
|
|
129
|
+
schema: options.schema,
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
schema: options.schema,
|
|
133
|
+
};
|
|
134
|
+
};
|