@ontrails/config 0.2.0
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/CHANGELOG.md +366 -0
- package/README.md +262 -0
- package/package.json +37 -0
- package/src/app-config.ts +318 -0
- package/src/collect.ts +117 -0
- package/src/compose.ts +47 -0
- package/src/config-resource.ts +32 -0
- package/src/define-config.ts +126 -0
- package/src/derive/env.ts +108 -0
- package/src/derive/example.ts +222 -0
- package/src/derive/helpers.ts +158 -0
- package/src/derive/index.ts +3 -0
- package/src/derive/json-schema.ts +137 -0
- package/src/derive-fields.ts +252 -0
- package/src/derive-provenance.ts +240 -0
- package/src/doctor.ts +238 -0
- package/src/extensions.ts +51 -0
- package/src/index.ts +74 -0
- package/src/merge.ts +43 -0
- package/src/path-boundary.ts +40 -0
- package/src/ref.ts +38 -0
- package/src/registry.ts +33 -0
- package/src/resolve.ts +196 -0
- package/src/secret-heuristics.ts +13 -0
- package/src/trails/config-check.ts +95 -0
- package/src/trails/config-describe.ts +44 -0
- package/src/trails/config-init.ts +96 -0
- package/src/trails-config-file.ts +136 -0
- package/src/trails-conventions.ts +240 -0
- package/src/workspace-config-collection.ts +371 -0
- package/src/workspace-config-source.ts +532 -0
- package/src/workspace-config.ts +552 -0
- package/src/zod-utils.ts +152 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config doctor — structured field reports for a config object against a schema.
|
|
3
|
+
*
|
|
4
|
+
* Reports which fields are valid, missing, using defaults, deprecated, or invalid.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
import { collectConfigMeta } from './collect.js';
|
|
10
|
+
import {
|
|
11
|
+
coerceEnvValue,
|
|
12
|
+
getAtPath,
|
|
13
|
+
getSchemaAtPath,
|
|
14
|
+
isZodContainer,
|
|
15
|
+
isZodObject,
|
|
16
|
+
unwrapToBase,
|
|
17
|
+
zodDef,
|
|
18
|
+
} from './zod-utils.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Validation status for a single config field. */
|
|
25
|
+
export interface ConfigFieldReport {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly status: 'valid' | 'missing' | 'invalid' | 'deprecated' | 'default';
|
|
28
|
+
readonly message: string;
|
|
29
|
+
readonly value?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Aggregated result from checking config against a schema. */
|
|
33
|
+
export interface ConfigReport {
|
|
34
|
+
readonly fields: readonly ConfigFieldReport[];
|
|
35
|
+
readonly valid: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Helpers (defined before consumers)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/** Check if a schema wraps a ZodDefault. */
|
|
43
|
+
const isDefaultWrapper = (schema: z.ZodType): boolean =>
|
|
44
|
+
zodDef(schema)['type'] === 'default';
|
|
45
|
+
|
|
46
|
+
/** Check if a schema wraps a ZodOptional. */
|
|
47
|
+
const isOptionalWrapper = (schema: z.ZodType): boolean =>
|
|
48
|
+
zodDef(schema)['type'] === 'optional';
|
|
49
|
+
|
|
50
|
+
/** Get the default value from a ZodDefault wrapper. */
|
|
51
|
+
const getDefaultValue = (schema: z.ZodType): unknown =>
|
|
52
|
+
zodDef(schema)['defaultValue'];
|
|
53
|
+
|
|
54
|
+
/** Set a value at a dot-separated path, creating intermediate objects. */
|
|
55
|
+
const setAtPath = (
|
|
56
|
+
obj: Record<string, unknown>,
|
|
57
|
+
path: string,
|
|
58
|
+
value: unknown
|
|
59
|
+
): void => {
|
|
60
|
+
const parts = path.split('.');
|
|
61
|
+
let current: Record<string, unknown> = obj;
|
|
62
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
63
|
+
const part = parts[i] as string;
|
|
64
|
+
const next = current[part];
|
|
65
|
+
const nested =
|
|
66
|
+
typeof next === 'object' && next !== null
|
|
67
|
+
? (next as Record<string, unknown>)
|
|
68
|
+
: {};
|
|
69
|
+
current[part] = nested;
|
|
70
|
+
current = nested;
|
|
71
|
+
}
|
|
72
|
+
const lastPart = parts.at(-1) as string;
|
|
73
|
+
current[lastPart] = value;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Build values object with env overrides applied. */
|
|
77
|
+
const applyEnvToValues = (
|
|
78
|
+
values: Record<string, unknown>,
|
|
79
|
+
schema: z.ZodObject<Record<string, z.ZodType>>,
|
|
80
|
+
envVars: Record<string, string | undefined>
|
|
81
|
+
): Record<string, unknown> => {
|
|
82
|
+
const meta = collectConfigMeta(schema);
|
|
83
|
+
const result = structuredClone(values) as Record<string, unknown>;
|
|
84
|
+
for (const [path, fieldMeta] of meta) {
|
|
85
|
+
const fieldSchema = getSchemaAtPath(schema, path);
|
|
86
|
+
const envName = fieldMeta.env;
|
|
87
|
+
const envValue = envName ? envVars[envName] : undefined;
|
|
88
|
+
if (!envName || envValue === undefined) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (fieldSchema && isZodContainer(fieldSchema)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
setAtPath(
|
|
95
|
+
result,
|
|
96
|
+
path,
|
|
97
|
+
fieldSchema ? coerceEnvValue(envValue, fieldSchema) : envValue
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Schema walking
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Entry for the iterative schema walk queue. */
|
|
108
|
+
interface WalkEntry {
|
|
109
|
+
readonly schema: z.ZodType;
|
|
110
|
+
readonly path: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validate a single field value against its schema. */
|
|
114
|
+
const validateFieldValue = (
|
|
115
|
+
path: string,
|
|
116
|
+
fieldSchema: z.ZodType,
|
|
117
|
+
value: unknown
|
|
118
|
+
): ConfigFieldReport => {
|
|
119
|
+
const result = fieldSchema.safeParse(value);
|
|
120
|
+
if (result.success) {
|
|
121
|
+
return { message: 'OK', path, status: 'valid', value };
|
|
122
|
+
}
|
|
123
|
+
const issue = result.error?.issues?.[0];
|
|
124
|
+
const msg = issue ? issue.message : 'Invalid value';
|
|
125
|
+
return { message: msg, path, status: 'invalid', value };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Classify a single field and produce a diagnostic. */
|
|
129
|
+
const classifyField = (
|
|
130
|
+
path: string,
|
|
131
|
+
fieldSchema: z.ZodType,
|
|
132
|
+
values: Record<string, unknown>,
|
|
133
|
+
deprecatedMeta: Map<string, string>
|
|
134
|
+
): ConfigFieldReport => {
|
|
135
|
+
const value = getAtPath(values, path);
|
|
136
|
+
const deprecationMsg = deprecatedMeta.get(path);
|
|
137
|
+
|
|
138
|
+
if (deprecationMsg && value !== undefined) {
|
|
139
|
+
return { message: deprecationMsg, path, status: 'deprecated', value };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (value === undefined && isDefaultWrapper(fieldSchema)) {
|
|
143
|
+
return {
|
|
144
|
+
message: 'Using default value',
|
|
145
|
+
path,
|
|
146
|
+
status: 'default',
|
|
147
|
+
value: getDefaultValue(fieldSchema),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (value === undefined && !isOptionalWrapper(fieldSchema)) {
|
|
152
|
+
return {
|
|
153
|
+
message: `Required field "${path}" is missing`,
|
|
154
|
+
path,
|
|
155
|
+
status: 'missing',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return validateFieldValue(path, fieldSchema, value);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Collect deprecated metadata paths from config meta. */
|
|
163
|
+
const collectDeprecatedPaths = (
|
|
164
|
+
schema: z.ZodObject<Record<string, z.ZodType>>
|
|
165
|
+
): Map<string, string> => {
|
|
166
|
+
const meta = collectConfigMeta(schema);
|
|
167
|
+
const result = new Map<string, string>();
|
|
168
|
+
for (const [path, fieldMeta] of meta) {
|
|
169
|
+
if (fieldMeta.deprecated) {
|
|
170
|
+
result.set(path, fieldMeta.deprecated);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Walk an object shape and enqueue leaf fields or nested objects. */
|
|
177
|
+
const walkShape = (
|
|
178
|
+
schema: z.ZodType,
|
|
179
|
+
prefix: string,
|
|
180
|
+
queue: WalkEntry[],
|
|
181
|
+
leaves: WalkEntry[]
|
|
182
|
+
): void => {
|
|
183
|
+
const shape = zodDef(schema)['shape'] as Record<string, z.ZodType>;
|
|
184
|
+
for (const [key, fieldSchema] of Object.entries(shape)) {
|
|
185
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
186
|
+
if (isZodObject(fieldSchema)) {
|
|
187
|
+
queue.push({ path, schema: unwrapToBase(fieldSchema) });
|
|
188
|
+
} else {
|
|
189
|
+
leaves.push({ path, schema: fieldSchema });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** Collect all leaf fields from a schema, walking nested objects iteratively. */
|
|
195
|
+
const collectLeaves = (schema: z.ZodType): WalkEntry[] => {
|
|
196
|
+
const queue: WalkEntry[] = [];
|
|
197
|
+
const leaves: WalkEntry[] = [];
|
|
198
|
+
walkShape(schema, '', queue, leaves);
|
|
199
|
+
|
|
200
|
+
for (let entry = queue.pop(); entry; entry = queue.pop()) {
|
|
201
|
+
walkShape(entry.schema, entry.path, queue, leaves);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return leaves;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Public API
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check a config object against a schema and return structured field reports.
|
|
213
|
+
*
|
|
214
|
+
* Reports which fields are valid, missing, using defaults, deprecated, or invalid.
|
|
215
|
+
*/
|
|
216
|
+
export const checkConfig = <T extends z.ZodType>(
|
|
217
|
+
schema: T,
|
|
218
|
+
values: Record<string, unknown>,
|
|
219
|
+
options?: { readonly env?: Record<string, string | undefined> }
|
|
220
|
+
): ConfigReport => {
|
|
221
|
+
const objSchema = schema as unknown as z.ZodObject<Record<string, z.ZodType>>;
|
|
222
|
+
const effectiveValues = options?.env
|
|
223
|
+
? applyEnvToValues(values, objSchema, options.env)
|
|
224
|
+
: values;
|
|
225
|
+
|
|
226
|
+
const deprecatedMeta = collectDeprecatedPaths(objSchema);
|
|
227
|
+
const leaves = collectLeaves(objSchema);
|
|
228
|
+
|
|
229
|
+
const fields = leaves.map((leaf) =>
|
|
230
|
+
classifyField(leaf.path, leaf.schema, effectiveValues, deprecatedMeta)
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const valid = fields.every(
|
|
234
|
+
(d) => d.status !== 'missing' && d.status !== 'invalid'
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return { fields, valid };
|
|
238
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** Metadata shape stored on Zod schemas via `.meta()`. */
|
|
4
|
+
export interface ConfigFieldMeta {
|
|
5
|
+
readonly env?: string;
|
|
6
|
+
readonly secret?: boolean;
|
|
7
|
+
readonly deprecated?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bind a schema field to an environment variable.
|
|
12
|
+
*
|
|
13
|
+
* Must be called BEFORE `.default()`, `.optional()`, or other transforms
|
|
14
|
+
* so that the metadata lives on the inner type where `collectConfigMeta`
|
|
15
|
+
* can find it by unwrapping wrappers.
|
|
16
|
+
*/
|
|
17
|
+
export const env = <T extends z.ZodType>(schema: T, varName: string): T =>
|
|
18
|
+
schema.meta({ ...schema.meta(), env: varName }) as T;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mark a schema field as sensitive. Redacted in survey, explain, and logs.
|
|
22
|
+
*
|
|
23
|
+
* Must be called BEFORE `.default()`, `.optional()`, or other transforms.
|
|
24
|
+
*/
|
|
25
|
+
export const secret = <T extends z.ZodType>(schema: T): T =>
|
|
26
|
+
schema.meta({ ...schema.meta(), secret: true }) as T;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mark a schema field as deprecated with migration guidance.
|
|
30
|
+
*
|
|
31
|
+
* Stores **two** meta keys: `deprecated: true` and `deprecationMessage: string`.
|
|
32
|
+
* This indirection exists because Zod 4's `GlobalMeta` types `deprecated` as
|
|
33
|
+
* `boolean | undefined` — there is no way to attach a migration message to the
|
|
34
|
+
* standard key. We set `deprecated: true` so Zod-native tooling (schema
|
|
35
|
+
* serializers, OpenAPI generators) recognises the field as deprecated, and store
|
|
36
|
+
* the human-readable message under `deprecationMessage` for our own
|
|
37
|
+
* `collectConfigMeta` / survey / explain surfaces.
|
|
38
|
+
*
|
|
39
|
+
* Must be called BEFORE `.default()`, `.optional()`, or other transforms
|
|
40
|
+
* so that the metadata lives on the inner type where `collectConfigMeta`
|
|
41
|
+
* can find it by unwrapping wrappers.
|
|
42
|
+
*/
|
|
43
|
+
export const deprecated = <T extends z.ZodType>(
|
|
44
|
+
schema: T,
|
|
45
|
+
message: string
|
|
46
|
+
): T =>
|
|
47
|
+
schema.meta({
|
|
48
|
+
...schema.meta(),
|
|
49
|
+
deprecated: true,
|
|
50
|
+
deprecationMessage: message,
|
|
51
|
+
}) as T;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export {
|
|
2
|
+
appConfig,
|
|
3
|
+
type AppConfig,
|
|
4
|
+
type AppConfigDeriveProvenanceOptions,
|
|
5
|
+
type AppConfigOptions,
|
|
6
|
+
type ConfigFormat,
|
|
7
|
+
type ResolveOptions,
|
|
8
|
+
} from './app-config.js';
|
|
9
|
+
export { collectConfigMeta } from './collect.js';
|
|
10
|
+
export { collectResourceConfigs, type ResourceConfigEntry } from './compose.js';
|
|
11
|
+
export { defineConfig, type DefineConfigOptions } from './define-config.js';
|
|
12
|
+
export {
|
|
13
|
+
findTrailsConfigPaths,
|
|
14
|
+
findTrailsLocalConfigPaths,
|
|
15
|
+
trailsConfigDataCandidates,
|
|
16
|
+
trailsConfigFileCandidates,
|
|
17
|
+
findTrailsConfigModulePath,
|
|
18
|
+
findTrailsLocalConfigModulePath,
|
|
19
|
+
findTrailsProjectRoot,
|
|
20
|
+
resolveTrailsProjectRoot,
|
|
21
|
+
trailsConfigModuleCandidates,
|
|
22
|
+
trailsAppEntryRelativePath,
|
|
23
|
+
trailsLockFileName,
|
|
24
|
+
trailsLocalConfigDataCandidates,
|
|
25
|
+
trailsLocalConfigFileCandidates,
|
|
26
|
+
trailsLocalConfigModuleCandidates,
|
|
27
|
+
trailsSourceRootCandidates,
|
|
28
|
+
type TrailsProjectRootMarker,
|
|
29
|
+
type TrailsProjectRootResolution,
|
|
30
|
+
} from './trails-conventions.js';
|
|
31
|
+
export {
|
|
32
|
+
readTrailsProjectIdentity,
|
|
33
|
+
type ReadTrailsProjectIdentityOptions,
|
|
34
|
+
type ReadTrailsProjectIdentityResult,
|
|
35
|
+
type ResolvedTrailsWorkspaceApp,
|
|
36
|
+
type TrailsWorkspaceAppConfig,
|
|
37
|
+
type TrailsWorkspaceConfig,
|
|
38
|
+
} from './workspace-config.js';
|
|
39
|
+
export {
|
|
40
|
+
loadTrailsConfigFileValue,
|
|
41
|
+
loadTrailsConfigValue,
|
|
42
|
+
loadTrailsLocalConfigValue,
|
|
43
|
+
type LoadedTrailsConfigValue,
|
|
44
|
+
} from './trails-config-file.js';
|
|
45
|
+
export { deriveConfigFields, type FieldDescription } from './derive-fields.js';
|
|
46
|
+
export {
|
|
47
|
+
checkConfig,
|
|
48
|
+
type ConfigReport,
|
|
49
|
+
type ConfigFieldReport,
|
|
50
|
+
} from './doctor.js';
|
|
51
|
+
export { env, secret, deprecated, type ConfigFieldMeta } from './extensions.js';
|
|
52
|
+
export {
|
|
53
|
+
deriveConfigProvenance,
|
|
54
|
+
type DeriveConfigProvenanceOptions,
|
|
55
|
+
type ProvenanceEntry,
|
|
56
|
+
} from './derive-provenance.js';
|
|
57
|
+
export {
|
|
58
|
+
deriveConfigEnvExample,
|
|
59
|
+
deriveConfigExample,
|
|
60
|
+
deriveConfigJsonSchema,
|
|
61
|
+
} from './derive/index.js';
|
|
62
|
+
export { configResource } from './config-resource.js';
|
|
63
|
+
export {
|
|
64
|
+
clearConfigState,
|
|
65
|
+
type ConfigState,
|
|
66
|
+
getConfigState,
|
|
67
|
+
registerConfigState,
|
|
68
|
+
} from './registry.js';
|
|
69
|
+
export { deepMerge } from './merge.js';
|
|
70
|
+
export { configRef, isConfigRef, type ConfigRef } from './ref.js';
|
|
71
|
+
export { deriveConfig, type DeriveConfigOptions } from './resolve.js';
|
|
72
|
+
export { configCheck } from './trails/config-check.js';
|
|
73
|
+
export { configDescribe } from './trails/config-describe.js';
|
|
74
|
+
export { configInit } from './trails/config-init.js';
|
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
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from '@ontrails/core';
|
|
5
|
+
|
|
6
|
+
export const isWithinBoundary = (
|
|
7
|
+
boundaryDir: string,
|
|
8
|
+
targetDir: string
|
|
9
|
+
): boolean => {
|
|
10
|
+
const path = relative(boundaryDir, targetDir);
|
|
11
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path));
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Resolve symlinks in the existing prefix without requiring the leaf to exist. */
|
|
15
|
+
export const canonicalBoundaryPath = (path: string): string => {
|
|
16
|
+
let existing = resolve(path);
|
|
17
|
+
const missingSegments: string[] = [];
|
|
18
|
+
while (!existsSync(existing)) {
|
|
19
|
+
const parent = dirname(existing);
|
|
20
|
+
if (parent === existing) {
|
|
21
|
+
throw new ValidationError(
|
|
22
|
+
`Unable to resolve project discovery path "${path}" canonically.`,
|
|
23
|
+
{ context: { path } }
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
missingSegments.unshift(basename(existing));
|
|
27
|
+
existing = parent;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return resolve(realpathSync(existing), ...missingSegments);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new ValidationError(
|
|
33
|
+
`Unable to resolve project discovery path "${path}" canonically.`,
|
|
34
|
+
{
|
|
35
|
+
...(error instanceof Error ? { cause: error } : {}),
|
|
36
|
+
context: { path },
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
};
|
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;
|
package/src/registry.ts
ADDED
|
@@ -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 `configResource` can expose it to surfaces. 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 profile?: 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
|
+
};
|