@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,158 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { deprecated, env, secret } from '../extensions.js';
|
|
5
|
+
import { describeConfig } from '../describe.js';
|
|
6
|
+
|
|
7
|
+
describe('describeConfig', () => {
|
|
8
|
+
describe('basic field descriptions', () => {
|
|
9
|
+
test('returns path, type, and required for each field', () => {
|
|
10
|
+
const schema = z.object({
|
|
11
|
+
debug: z.boolean(),
|
|
12
|
+
host: z.string(),
|
|
13
|
+
port: z.number(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const fields = describeConfig(schema);
|
|
17
|
+
|
|
18
|
+
expect(fields).toHaveLength(3);
|
|
19
|
+
const host = fields.find((f) => f.path === 'host');
|
|
20
|
+
expect(host?.type).toBe('string');
|
|
21
|
+
expect(host?.required).toBe(true);
|
|
22
|
+
|
|
23
|
+
const port = fields.find((f) => f.path === 'port');
|
|
24
|
+
expect(port?.type).toBe('number');
|
|
25
|
+
expect(port?.required).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('Zod describe() metadata', () => {
|
|
30
|
+
test('includes description from .describe()', () => {
|
|
31
|
+
const schema = z.object({
|
|
32
|
+
host: z.string().describe('The server hostname'),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const fields = describeConfig(schema);
|
|
36
|
+
const host = fields.find((f) => f.path === 'host');
|
|
37
|
+
expect(host?.description).toBe('The server hostname');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('config metadata', () => {
|
|
42
|
+
test('includes env, secret, deprecated from metadata', () => {
|
|
43
|
+
const schema = z.object({
|
|
44
|
+
apiKey: secret(env(z.string(), 'API_KEY')),
|
|
45
|
+
legacyMode: deprecated(z.boolean(), 'Use "mode" instead').default(
|
|
46
|
+
false
|
|
47
|
+
),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const fields = describeConfig(schema);
|
|
51
|
+
|
|
52
|
+
const apiKey = fields.find((f) => f.path === 'apiKey');
|
|
53
|
+
expect(apiKey?.env).toBe('API_KEY');
|
|
54
|
+
expect(apiKey?.secret).toBe(true);
|
|
55
|
+
|
|
56
|
+
const legacy = fields.find((f) => f.path === 'legacyMode');
|
|
57
|
+
expect(legacy?.deprecated).toBe('Use "mode" instead');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('defaults', () => {
|
|
62
|
+
test('detects default values', () => {
|
|
63
|
+
const schema = z.object({
|
|
64
|
+
debug: z.boolean().default(false),
|
|
65
|
+
port: z.number().default(3000),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const fields = describeConfig(schema);
|
|
69
|
+
|
|
70
|
+
const port = fields.find((f) => f.path === 'port');
|
|
71
|
+
expect(port?.default).toBe(3000);
|
|
72
|
+
expect(port?.required).toBe(false);
|
|
73
|
+
|
|
74
|
+
const debug = fields.find((f) => f.path === 'debug');
|
|
75
|
+
expect(debug?.default).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('nested objects', () => {
|
|
80
|
+
test('handles nested object schemas with dot paths', () => {
|
|
81
|
+
const schema = z.object({
|
|
82
|
+
db: z.object({
|
|
83
|
+
host: z.string(),
|
|
84
|
+
port: z.number().default(5432),
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const fields = describeConfig(schema);
|
|
89
|
+
|
|
90
|
+
const dbHost = fields.find((f) => f.path === 'db.host');
|
|
91
|
+
expect(dbHost?.type).toBe('string');
|
|
92
|
+
expect(dbHost?.required).toBe(true);
|
|
93
|
+
|
|
94
|
+
const dbPort = fields.find((f) => f.path === 'db.port');
|
|
95
|
+
expect(dbPort?.type).toBe('number');
|
|
96
|
+
expect(dbPort?.default).toBe(5432);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('unwraps optional nested object schemas before walking', () => {
|
|
100
|
+
const schema = z.object({
|
|
101
|
+
db: z
|
|
102
|
+
.object({
|
|
103
|
+
host: z.string(),
|
|
104
|
+
port: z.number().default(5432),
|
|
105
|
+
})
|
|
106
|
+
.optional(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const fields = describeConfig(schema);
|
|
110
|
+
|
|
111
|
+
expect(fields).toEqual([
|
|
112
|
+
expect.objectContaining({ path: 'db.host', required: true }),
|
|
113
|
+
expect.objectContaining({ default: 5432, path: 'db.port' }),
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('constraints', () => {
|
|
119
|
+
test('detects enum values', () => {
|
|
120
|
+
const schema = z.object({
|
|
121
|
+
env: z.enum(['development', 'production', 'test']),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const fields = describeConfig(schema);
|
|
125
|
+
const envField = fields.find((f) => f.path === 'env');
|
|
126
|
+
expect(envField?.type).toBe('enum');
|
|
127
|
+
expect(envField?.constraints?.values).toEqual([
|
|
128
|
+
'development',
|
|
129
|
+
'production',
|
|
130
|
+
'test',
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('detects number min/max constraints', () => {
|
|
135
|
+
const schema = z.object({
|
|
136
|
+
port: z.number().min(1).max(65_535),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const fields = describeConfig(schema);
|
|
140
|
+
const port = fields.find((f) => f.path === 'port');
|
|
141
|
+
expect(port?.constraints?.min).toBe(1);
|
|
142
|
+
expect(port?.constraints?.max).toBe(65_535);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('optional fields', () => {
|
|
147
|
+
test('marks optional fields as not required', () => {
|
|
148
|
+
const schema = z.object({
|
|
149
|
+
host: z.string(),
|
|
150
|
+
nickname: z.string().optional(),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const fields = describeConfig(schema);
|
|
154
|
+
const nickname = fields.find((f) => f.path === 'nickname');
|
|
155
|
+
expect(nickname?.required).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { deprecated, env } from '../extensions.js';
|
|
5
|
+
import { checkConfig } from '../doctor.js';
|
|
6
|
+
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
debug: z.boolean().default(false),
|
|
9
|
+
host: z.string(),
|
|
10
|
+
port: z.number().default(3000),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('checkConfig', () => {
|
|
14
|
+
describe('valid fields', () => {
|
|
15
|
+
test('reports status "valid" for present and valid fields', () => {
|
|
16
|
+
const result = checkConfig(schema, { host: 'localhost', port: 8080 });
|
|
17
|
+
|
|
18
|
+
const hostDiag = result.diagnostics.find((d) => d.path === 'host');
|
|
19
|
+
expect(hostDiag?.status).toBe('valid');
|
|
20
|
+
expect(hostDiag?.value).toBe('localhost');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('missing required fields', () => {
|
|
25
|
+
test('reports status "missing" for absent required fields', () => {
|
|
26
|
+
const result = checkConfig(schema, { port: 8080 });
|
|
27
|
+
|
|
28
|
+
const hostDiag = result.diagnostics.find((d) => d.path === 'host');
|
|
29
|
+
expect(hostDiag?.status).toBe('missing');
|
|
30
|
+
expect(hostDiag?.message).toContain('host');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('default fields', () => {
|
|
35
|
+
test('reports status "default" when field uses schema default', () => {
|
|
36
|
+
const result = checkConfig(schema, { host: 'localhost' });
|
|
37
|
+
|
|
38
|
+
const portDiag = result.diagnostics.find((d) => d.path === 'port');
|
|
39
|
+
expect(portDiag?.status).toBe('default');
|
|
40
|
+
expect(portDiag?.value).toBe(3000);
|
|
41
|
+
|
|
42
|
+
const debugDiag = result.diagnostics.find((d) => d.path === 'debug');
|
|
43
|
+
expect(debugDiag?.status).toBe('default');
|
|
44
|
+
expect(debugDiag?.value).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('invalid fields', () => {
|
|
49
|
+
test('reports status "invalid" with message for wrong-type values', () => {
|
|
50
|
+
const result = checkConfig(schema, {
|
|
51
|
+
host: 'localhost',
|
|
52
|
+
port: 'not-a-number',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const portDiag = result.diagnostics.find((d) => d.path === 'port');
|
|
56
|
+
expect(portDiag?.status).toBe('invalid');
|
|
57
|
+
expect(portDiag?.message).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('deprecated fields', () => {
|
|
62
|
+
test('reports status "deprecated" with migration message', () => {
|
|
63
|
+
const deprecatedSchema = z.object({
|
|
64
|
+
host: z.string(),
|
|
65
|
+
legacyMode: deprecated(z.boolean(), 'Use "mode" instead').default(
|
|
66
|
+
false
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = checkConfig(deprecatedSchema, {
|
|
71
|
+
host: 'localhost',
|
|
72
|
+
legacyMode: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const legacyDiag = result.diagnostics.find(
|
|
76
|
+
(d) => d.path === 'legacyMode'
|
|
77
|
+
);
|
|
78
|
+
expect(legacyDiag?.status).toBe('deprecated');
|
|
79
|
+
expect(legacyDiag?.message).toContain('Use "mode" instead');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('valid flag', () => {
|
|
84
|
+
test('returns valid: true when no missing or invalid fields', () => {
|
|
85
|
+
const result = checkConfig(schema, { host: 'localhost', port: 8080 });
|
|
86
|
+
expect(result.valid).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('returns valid: false when required field is missing', () => {
|
|
90
|
+
const result = checkConfig(schema, { port: 8080 });
|
|
91
|
+
expect(result.valid).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('returns valid: false when field is invalid', () => {
|
|
95
|
+
const result = checkConfig(schema, {
|
|
96
|
+
host: 'localhost',
|
|
97
|
+
port: 'bad',
|
|
98
|
+
});
|
|
99
|
+
expect(result.valid).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('returns valid: true when deprecated fields are present', () => {
|
|
103
|
+
const deprecatedSchema = z.object({
|
|
104
|
+
host: z.string(),
|
|
105
|
+
legacyMode: deprecated(z.boolean(), 'Use "mode" instead').default(
|
|
106
|
+
false
|
|
107
|
+
),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = checkConfig(deprecatedSchema, {
|
|
111
|
+
host: 'localhost',
|
|
112
|
+
legacyMode: true,
|
|
113
|
+
});
|
|
114
|
+
expect(result.valid).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('env resolution', () => {
|
|
119
|
+
test('treats env-provided value as valid', () => {
|
|
120
|
+
const envSchema = z.object({
|
|
121
|
+
host: env(z.string(), 'APP_HOST'),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const result = checkConfig(
|
|
125
|
+
envSchema,
|
|
126
|
+
{},
|
|
127
|
+
{ env: { APP_HOST: 'envhost' } }
|
|
128
|
+
);
|
|
129
|
+
const hostDiag = result.diagnostics.find((d) => d.path === 'host');
|
|
130
|
+
expect(hostDiag?.status).toBe('valid');
|
|
131
|
+
expect(hostDiag?.value).toBe('envhost');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('resolves env vars for nested schema fields', () => {
|
|
135
|
+
const nestedSchema = z.object({
|
|
136
|
+
db: z.object({
|
|
137
|
+
host: env(z.string(), 'DB_HOST'),
|
|
138
|
+
port: z.number().default(5432),
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const result = checkConfig(
|
|
143
|
+
nestedSchema,
|
|
144
|
+
{ db: {} },
|
|
145
|
+
{ env: { DB_HOST: 'dbhost.local' } }
|
|
146
|
+
);
|
|
147
|
+
const dbHostDiag = result.diagnostics.find((d) => d.path === 'db.host');
|
|
148
|
+
expect(dbHostDiag?.status).toBe('valid');
|
|
149
|
+
expect(dbHostDiag?.value).toBe('dbhost.local');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('walks optional nested object schemas for env overrides', () => {
|
|
153
|
+
const nestedSchema = z.object({
|
|
154
|
+
db: z
|
|
155
|
+
.object({
|
|
156
|
+
host: env(z.string(), 'DB_HOST'),
|
|
157
|
+
})
|
|
158
|
+
.optional(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = checkConfig(
|
|
162
|
+
nestedSchema,
|
|
163
|
+
{},
|
|
164
|
+
{ env: { DB_HOST: 'dbhost.local' } }
|
|
165
|
+
);
|
|
166
|
+
const dbHostDiag = result.diagnostics.find((d) => d.path === 'db.host');
|
|
167
|
+
|
|
168
|
+
expect(dbHostDiag?.status).toBe('valid');
|
|
169
|
+
expect(dbHostDiag?.value).toBe('dbhost.local');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { env, secret } from '../extensions.js';
|
|
5
|
+
import { explainConfig } from '../explain.js';
|
|
6
|
+
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
debug: z.boolean().default(false),
|
|
9
|
+
host: z.string().default('localhost'),
|
|
10
|
+
port: z.number().default(3000),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('explainConfig', () => {
|
|
14
|
+
describe('default source', () => {
|
|
15
|
+
test('reports "default" when no other source provides value', () => {
|
|
16
|
+
const entries = explainConfig({
|
|
17
|
+
resolved: { debug: false, host: 'localhost', port: 3000 },
|
|
18
|
+
schema,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const host = entries.find((e) => e.path === 'host');
|
|
22
|
+
expect(host?.source).toBe('default');
|
|
23
|
+
expect(host?.value).toBe('localhost');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('base overrides default', () => {
|
|
28
|
+
test('reports "base" when base provides value', () => {
|
|
29
|
+
const entries = explainConfig({
|
|
30
|
+
base: { host: 'base.example.com' },
|
|
31
|
+
resolved: { debug: false, host: 'base.example.com', port: 3000 },
|
|
32
|
+
schema,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const host = entries.find((e) => e.path === 'host');
|
|
36
|
+
expect(host?.source).toBe('base');
|
|
37
|
+
expect(host?.value).toBe('base.example.com');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('loadout overrides base', () => {
|
|
42
|
+
test('reports "loadout" when loadout provides winning value', () => {
|
|
43
|
+
const entries = explainConfig({
|
|
44
|
+
base: { host: 'base.example.com' },
|
|
45
|
+
loadout: { host: 'loadout.example.com' },
|
|
46
|
+
resolved: { debug: false, host: 'loadout.example.com', port: 3000 },
|
|
47
|
+
schema,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const host = entries.find((e) => e.path === 'host');
|
|
51
|
+
expect(host?.source).toBe('loadout');
|
|
52
|
+
expect(host?.value).toBe('loadout.example.com');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('local overrides loadout', () => {
|
|
57
|
+
test('reports "local" when local provides winning value', () => {
|
|
58
|
+
const entries = explainConfig({
|
|
59
|
+
base: { host: 'base.example.com' },
|
|
60
|
+
loadout: { host: 'loadout.example.com' },
|
|
61
|
+
local: { host: 'local.example.com' },
|
|
62
|
+
resolved: { debug: false, host: 'local.example.com', port: 3000 },
|
|
63
|
+
schema,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const host = entries.find((e) => e.path === 'host');
|
|
67
|
+
expect(host?.source).toBe('local');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('env overrides everything', () => {
|
|
72
|
+
test('reports "env" when env provides winning value', () => {
|
|
73
|
+
const envSchema = z.object({
|
|
74
|
+
host: env(z.string(), 'APP_HOST').default('localhost'),
|
|
75
|
+
port: z.number().default(3000),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const entries = explainConfig({
|
|
79
|
+
base: { host: 'base.example.com' },
|
|
80
|
+
env: { APP_HOST: 'env.example.com' },
|
|
81
|
+
resolved: { host: 'env.example.com', port: 3000 },
|
|
82
|
+
schema: envSchema,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const host = entries.find((e) => e.path === 'host');
|
|
86
|
+
expect(host?.source).toBe('env');
|
|
87
|
+
expect(host?.value).toBe('env.example.com');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('walks optional nested object schemas for env-backed entries', () => {
|
|
91
|
+
const envSchema = z.object({
|
|
92
|
+
db: z
|
|
93
|
+
.object({
|
|
94
|
+
host: env(z.string(), 'DB_HOST').default('localhost'),
|
|
95
|
+
})
|
|
96
|
+
.optional(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const entries = explainConfig({
|
|
100
|
+
env: { DB_HOST: 'env.example.com' },
|
|
101
|
+
resolved: { db: { host: 'env.example.com' } },
|
|
102
|
+
schema: envSchema,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const host = entries.find((entry) => entry.path === 'db.host');
|
|
106
|
+
expect(host?.source).toBe('env');
|
|
107
|
+
expect(host?.value).toBe('env.example.com');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('secret redaction', () => {
|
|
112
|
+
test('redacts secret fields', () => {
|
|
113
|
+
const secretSchema = z.object({
|
|
114
|
+
apiKey: secret(env(z.string(), 'API_KEY')),
|
|
115
|
+
host: z.string().default('localhost'),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const entries = explainConfig({
|
|
119
|
+
env: { API_KEY: 'super-secret-key' },
|
|
120
|
+
resolved: { apiKey: 'super-secret-key', host: 'localhost' },
|
|
121
|
+
schema: secretSchema,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const apiKey = entries.find((e) => e.path === 'apiKey');
|
|
125
|
+
expect(apiKey?.redacted).toBe(true);
|
|
126
|
+
expect(apiKey?.value).toBe('[REDACTED]');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('does not redact non-secret fields', () => {
|
|
130
|
+
const entries = explainConfig({
|
|
131
|
+
resolved: { debug: false, host: 'localhost', port: 3000 },
|
|
132
|
+
schema,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const host = entries.find((e) => e.path === 'host');
|
|
136
|
+
expect(host?.redacted).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z, globalRegistry } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { env, secret, deprecated } from '../extensions.js';
|
|
5
|
+
import type { ConfigFieldMeta } from '../extensions.js';
|
|
6
|
+
import { collectConfigMeta } from '../collect.js';
|
|
7
|
+
|
|
8
|
+
describe('env()', () => {
|
|
9
|
+
test('attaches env var name to schema metadata', () => {
|
|
10
|
+
const schema = env(z.string(), 'DATABASE_URL');
|
|
11
|
+
const meta = globalRegistry.get(schema) as ConfigFieldMeta | undefined;
|
|
12
|
+
|
|
13
|
+
expect(meta).toBeDefined();
|
|
14
|
+
expect(meta?.env).toBe('DATABASE_URL');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('secret()', () => {
|
|
19
|
+
test('attaches secret flag to schema metadata', () => {
|
|
20
|
+
const schema = secret(z.string());
|
|
21
|
+
const meta = globalRegistry.get(schema) as ConfigFieldMeta | undefined;
|
|
22
|
+
|
|
23
|
+
expect(meta).toBeDefined();
|
|
24
|
+
expect(meta?.secret).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('deprecated()', () => {
|
|
29
|
+
test('attaches deprecation message to schema metadata', () => {
|
|
30
|
+
const schema = deprecated(z.string(), 'Use NEW_VAR instead');
|
|
31
|
+
const raw = globalRegistry.get(schema) as
|
|
32
|
+
| Record<string, unknown>
|
|
33
|
+
| undefined;
|
|
34
|
+
|
|
35
|
+
expect(raw).toBeDefined();
|
|
36
|
+
// Zod 4 reserves `deprecated` as boolean, so the message is stored
|
|
37
|
+
// under `deprecationMessage` and the boolean flag is set.
|
|
38
|
+
expect(raw?.['deprecated']).toBe(true);
|
|
39
|
+
expect(raw?.['deprecationMessage']).toBe('Use NEW_VAR instead');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('wrapper ordering', () => {
|
|
44
|
+
test('metadata survives through .default() when applied BEFORE the transform', () => {
|
|
45
|
+
const schema = env(z.string(), 'HOST').default('localhost');
|
|
46
|
+
// Metadata lives on the inner type, not the wrapper
|
|
47
|
+
const innerMeta = globalRegistry.get(schema.def.innerType) as
|
|
48
|
+
| ConfigFieldMeta
|
|
49
|
+
| undefined;
|
|
50
|
+
|
|
51
|
+
expect(innerMeta).toBeDefined();
|
|
52
|
+
expect(innerMeta?.env).toBe('HOST');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('metadata is NOT on the wrapper when applied AFTER .default()', () => {
|
|
56
|
+
// Applying env() after .default() attaches metadata to the ZodDefault wrapper,
|
|
57
|
+
// but collectConfigMeta walks .def.innerType — so the inner string has no metadata.
|
|
58
|
+
const schema = env(z.string().default('localhost'), 'HOST');
|
|
59
|
+
// The wrapper itself has the metadata
|
|
60
|
+
const wrapperMeta = globalRegistry.get(schema) as
|
|
61
|
+
| ConfigFieldMeta
|
|
62
|
+
| undefined;
|
|
63
|
+
expect(wrapperMeta?.env).toBe('HOST');
|
|
64
|
+
|
|
65
|
+
// But the inner type does NOT
|
|
66
|
+
const innerMeta = globalRegistry.get(schema.def.innerType) as
|
|
67
|
+
| ConfigFieldMeta
|
|
68
|
+
| undefined;
|
|
69
|
+
expect(innerMeta?.env).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('composition', () => {
|
|
74
|
+
test('multiple wrappers compose: secret(env()) has both env and secret', () => {
|
|
75
|
+
const schema = secret(env(z.string(), 'DB_URL'));
|
|
76
|
+
const meta = globalRegistry.get(schema) as ConfigFieldMeta | undefined;
|
|
77
|
+
|
|
78
|
+
expect(meta).toBeDefined();
|
|
79
|
+
expect(meta?.env).toBe('DB_URL');
|
|
80
|
+
expect(meta?.secret).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('describe() preservation', () => {
|
|
85
|
+
test('.describe() text is preserved alongside custom metadata', () => {
|
|
86
|
+
const schema = env(z.string().describe('The database host'), 'DB_HOST');
|
|
87
|
+
const meta = globalRegistry.get(schema) as
|
|
88
|
+
| Record<string, unknown>
|
|
89
|
+
| undefined;
|
|
90
|
+
|
|
91
|
+
expect(meta).toBeDefined();
|
|
92
|
+
expect(meta?.env).toBe('DB_HOST');
|
|
93
|
+
expect(meta?.description).toBe('The database host');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('collectConfigMeta()', () => {
|
|
98
|
+
test('walks an object schema and returns all field metadata', () => {
|
|
99
|
+
const schema = z.object({
|
|
100
|
+
apiKey: secret(env(z.string(), 'API_KEY')),
|
|
101
|
+
host: env(z.string(), 'HOST').default('localhost'),
|
|
102
|
+
oldVar: deprecated(z.string(), 'Use newVar instead').optional(),
|
|
103
|
+
port: env(z.number(), 'PORT'),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const meta = collectConfigMeta(schema);
|
|
107
|
+
|
|
108
|
+
expect(meta.get('host')).toEqual({ env: 'HOST' });
|
|
109
|
+
expect(meta.get('port')).toEqual({ env: 'PORT' });
|
|
110
|
+
expect(meta.get('apiKey')).toEqual({ env: 'API_KEY', secret: true });
|
|
111
|
+
expect(meta.get('oldVar')).toEqual({ deprecated: 'Use newVar instead' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('handles nested objects with dot-separated paths', () => {
|
|
115
|
+
const schema = z.object({
|
|
116
|
+
cache: z.object({
|
|
117
|
+
ttl: env(z.number(), 'CACHE_TTL').default(3600),
|
|
118
|
+
}),
|
|
119
|
+
db: z.object({
|
|
120
|
+
host: env(z.string(), 'DB_HOST'),
|
|
121
|
+
password: secret(env(z.string(), 'DB_PASSWORD')),
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const meta = collectConfigMeta(schema);
|
|
126
|
+
|
|
127
|
+
expect(meta.get('db.host')).toEqual({ env: 'DB_HOST' });
|
|
128
|
+
expect(meta.get('db.password')).toEqual({
|
|
129
|
+
env: 'DB_PASSWORD',
|
|
130
|
+
secret: true,
|
|
131
|
+
});
|
|
132
|
+
expect(meta.get('cache.ttl')).toEqual({ env: 'CACHE_TTL' });
|
|
133
|
+
});
|
|
134
|
+
});
|