@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
@@ -0,0 +1,269 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { z } from 'zod';
3
+
4
+ import { deprecated, env, secret } from '../extensions.js';
5
+ import {
6
+ generateEnvExample,
7
+ generateExample,
8
+ generateJsonSchema,
9
+ } from '../generate/index.js';
10
+
11
+ /** Shared test schema used across generator tests. */
12
+ const testSchema = z.object({
13
+ host: env(z.string().describe('The server hostname'), 'HOST').default(
14
+ 'localhost'
15
+ ),
16
+ port: env(z.number().describe('The server port'), 'PORT').default(3000),
17
+ verbose: z.boolean().describe('Enable verbose logging').default(false),
18
+ });
19
+
20
+ /** Schema with deprecated and secret fields. */
21
+ const annotatedSchema = z.object({
22
+ apiKey: secret(env(z.string().describe('API authentication key'), 'API_KEY')),
23
+ oldEndpoint: deprecated(
24
+ env(z.string().describe('Legacy API endpoint'), 'OLD_ENDPOINT'),
25
+ 'Use newEndpoint instead'
26
+ ),
27
+ });
28
+
29
+ /** Schema with nested objects. */
30
+ const nestedSchema = z.object({
31
+ db: z.object({
32
+ host: env(z.string().describe('Database host'), 'DB_HOST').default(
33
+ 'localhost'
34
+ ),
35
+ port: env(z.number().describe('Database port'), 'DB_PORT').default(5432),
36
+ }),
37
+ server: z.object({
38
+ name: z.string().describe('Server name').default('app'),
39
+ }),
40
+ });
41
+
42
+ describe('generateExample()', () => {
43
+ describe('TOML format', () => {
44
+ test('produces valid TOML with comments for descriptions', () => {
45
+ const result = generateExample(testSchema, 'toml');
46
+
47
+ expect(result).toContain('# The server hostname');
48
+ expect(result).toContain('host = "localhost"');
49
+ expect(result).toContain('# The server port');
50
+ expect(result).toContain('port = 3000');
51
+ expect(result).toContain('# Enable verbose logging');
52
+ expect(result).toContain('verbose = false');
53
+ });
54
+
55
+ test('annotates deprecated fields in TOML', () => {
56
+ const result = generateExample(annotatedSchema, 'toml');
57
+
58
+ expect(result).toContain('# DEPRECATED: Use newEndpoint instead');
59
+ });
60
+
61
+ test('handles nested objects as TOML sections', () => {
62
+ const result = generateExample(nestedSchema, 'toml');
63
+
64
+ expect(result).toContain('[db]');
65
+ expect(result).toContain('[server]');
66
+ expect(result).toContain('host = "localhost"');
67
+ expect(result).toContain('port = 5432');
68
+ });
69
+ });
70
+
71
+ describe('JSON format', () => {
72
+ test('produces valid JSON without comments', () => {
73
+ const result = generateExample(testSchema, 'json');
74
+ const parsed = JSON.parse(result);
75
+
76
+ expect(parsed).toEqual({
77
+ host: 'localhost',
78
+ port: 3000,
79
+ verbose: false,
80
+ });
81
+ });
82
+
83
+ test('handles nested objects', () => {
84
+ const result = generateExample(nestedSchema, 'json');
85
+ const parsed = JSON.parse(result);
86
+
87
+ expect(parsed).toHaveProperty('db');
88
+ expect(parsed).toHaveProperty('server');
89
+ expect(parsed.db.host).toBe('localhost');
90
+ });
91
+ });
92
+
93
+ describe('JSONC format', () => {
94
+ test('produces JSON with // comments for descriptions', () => {
95
+ const result = generateExample(testSchema, 'jsonc');
96
+
97
+ expect(result).toContain('// The server hostname');
98
+ expect(result).toContain('"host"');
99
+ expect(result).toContain('"localhost"');
100
+ });
101
+
102
+ test('annotates deprecated fields in JSONC', () => {
103
+ const result = generateExample(annotatedSchema, 'jsonc');
104
+
105
+ expect(result).toContain('// DEPRECATED: Use newEndpoint instead');
106
+ });
107
+
108
+ test('handles nested objects', () => {
109
+ const result = generateExample(nestedSchema, 'jsonc');
110
+
111
+ expect(result).toContain('"db"');
112
+ expect(result).toContain('"host"');
113
+ expect(result).toContain('"localhost"');
114
+ expect(result).not.toContain('"db": ""');
115
+ });
116
+ });
117
+
118
+ describe('YAML format', () => {
119
+ test('produces valid YAML with comments for descriptions', () => {
120
+ const result = generateExample(testSchema, 'yaml');
121
+
122
+ expect(result).toContain('# The server hostname');
123
+ expect(result).toContain('host: "localhost"');
124
+ expect(result).toContain('# The server port');
125
+ expect(result).toContain('port: 3000');
126
+ expect(result).toContain('# Enable verbose logging');
127
+ expect(result).toContain('verbose: false');
128
+ });
129
+
130
+ test('annotates deprecated fields in YAML', () => {
131
+ const result = generateExample(annotatedSchema, 'yaml');
132
+
133
+ expect(result).toContain('# DEPRECATED: Use newEndpoint instead');
134
+ });
135
+
136
+ test('handles nested objects', () => {
137
+ const result = generateExample(nestedSchema, 'yaml');
138
+
139
+ expect(result).toContain('db:');
140
+ expect(result).toContain(' host: "localhost"');
141
+ expect(result).toContain('server:');
142
+ });
143
+ });
144
+ });
145
+
146
+ describe('generateJsonSchema()', () => {
147
+ test('produces valid JSON Schema with $schema, type, and properties', () => {
148
+ const result = generateJsonSchema(testSchema);
149
+
150
+ expect(result.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
151
+ expect(result.type).toBe('object');
152
+ expect(result.properties).toBeDefined();
153
+ });
154
+
155
+ test('includes title and description from options', () => {
156
+ const result = generateJsonSchema(testSchema, {
157
+ description: 'Server configuration',
158
+ title: 'ServerConfig',
159
+ });
160
+
161
+ expect(result.title).toBe('ServerConfig');
162
+ expect(result.description).toBe('Server configuration');
163
+ });
164
+
165
+ test('includes descriptions from .describe()', () => {
166
+ const result = generateJsonSchema(testSchema);
167
+ const props = result.properties as Record<string, Record<string, unknown>>;
168
+
169
+ expect(props['host']?.description).toBe('The server hostname');
170
+ expect(props['port']?.description).toBe('The server port');
171
+ });
172
+
173
+ test('includes defaults', () => {
174
+ const result = generateJsonSchema(testSchema);
175
+ const props = result.properties as Record<string, Record<string, unknown>>;
176
+
177
+ expect(props['host']?.default).toBe('localhost');
178
+ expect(props['port']?.default).toBe(3000);
179
+ expect(props['verbose']?.default).toBe(false);
180
+ });
181
+
182
+ test('maps string, number, boolean, and enum types correctly', () => {
183
+ const enumSchema = z.object({
184
+ color: z.enum(['red', 'green', 'blue']).describe('The color'),
185
+ count: z.number().describe('A count'),
186
+ enabled: z.boolean().describe('Toggle'),
187
+ name: z.string().describe('A name'),
188
+ });
189
+
190
+ const result = generateJsonSchema(enumSchema);
191
+ const props = result.properties as Record<string, Record<string, unknown>>;
192
+
193
+ expect(props['name']?.type).toBe('string');
194
+ expect(props['count']?.type).toBe('number');
195
+ expect(props['enabled']?.type).toBe('boolean');
196
+ expect(props['color']?.enum).toEqual(['red', 'green', 'blue']);
197
+ });
198
+
199
+ test('marks deprecated fields', () => {
200
+ const result = generateJsonSchema(annotatedSchema);
201
+ const props = result.properties as Record<string, Record<string, unknown>>;
202
+
203
+ expect(props['oldEndpoint']?.deprecated).toBe(true);
204
+ });
205
+
206
+ test('lists required fields (those without defaults or optional)', () => {
207
+ const result = generateJsonSchema(annotatedSchema);
208
+
209
+ expect(result.required).toContain('apiKey');
210
+ expect(result.required).toContain('oldEndpoint');
211
+ });
212
+
213
+ test('recurses into nested object fields', () => {
214
+ const result = generateJsonSchema(nestedSchema);
215
+ const props = result.properties as Record<string, Record<string, unknown>>;
216
+
217
+ expect(props['db']?.type).toBe('object');
218
+ const dbProps = props['db']?.properties as Record<
219
+ string,
220
+ Record<string, unknown>
221
+ >;
222
+ expect(dbProps['host']?.type).toBe('string');
223
+ expect(dbProps['host']?.description).toBe('Database host');
224
+ expect(dbProps['port']?.type).toBe('number');
225
+
226
+ expect(props['server']?.type).toBe('object');
227
+ const serverProps = props['server']?.properties as Record<
228
+ string,
229
+ Record<string, unknown>
230
+ >;
231
+ expect(serverProps['name']?.type).toBe('string');
232
+ });
233
+ });
234
+
235
+ describe('generateEnvExample()', () => {
236
+ test('lists env vars with type info', () => {
237
+ const result = generateEnvExample(testSchema);
238
+
239
+ expect(result).toContain('HOST=');
240
+ expect(result).toContain('PORT=');
241
+ expect(result).toContain('string');
242
+ expect(result).toContain('number');
243
+ });
244
+
245
+ test('annotates secrets', () => {
246
+ const result = generateEnvExample(annotatedSchema);
247
+
248
+ expect(result).toContain('API_KEY=');
249
+ expect(result).toContain('secret');
250
+ });
251
+
252
+ test('shows defaults as comments', () => {
253
+ const result = generateEnvExample(testSchema);
254
+
255
+ expect(result).toContain('default: "localhost"');
256
+ expect(result).toContain('default: 3000');
257
+ });
258
+
259
+ test('returns empty string when no env vars are present', () => {
260
+ const noEnvSchema = z.object({
261
+ name: z.string(),
262
+ verbose: z.boolean().default(false),
263
+ });
264
+
265
+ const result = generateEnvExample(noEnvSchema);
266
+
267
+ expect(result).toBe('');
268
+ });
269
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { configRef, isConfigRef } from '../ref.js';
4
+
5
+ describe('configRef', () => {
6
+ test('creates a marker object with __configRef flag', () => {
7
+ const ref = configRef('db.host');
8
+ expect(ref.__configRef).toBe(true);
9
+ expect(ref.path).toBe('db.host');
10
+ });
11
+
12
+ test('creates distinct refs for different paths', () => {
13
+ const ref1 = configRef('db.host');
14
+ const ref2 = configRef('db.port');
15
+ expect(ref1.path).not.toBe(ref2.path);
16
+ });
17
+ });
18
+
19
+ describe('isConfigRef', () => {
20
+ test('returns true for configRef markers', () => {
21
+ const ref = configRef('db.host');
22
+ expect(isConfigRef(ref)).toBe(true);
23
+ });
24
+
25
+ test('returns false for plain objects', () => {
26
+ expect(isConfigRef({ path: 'db.host' })).toBe(false);
27
+ });
28
+
29
+ test('returns false for non-objects', () => {
30
+ expect(isConfigRef('db.host')).toBe(false);
31
+ expect(isConfigRef(42)).toBe(false);
32
+ expect(isConfigRef(null)).toBe(false);
33
+ expect(isConfigRef()).toBe(false);
34
+ });
35
+ });
@@ -0,0 +1,246 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { z } from 'zod';
3
+
4
+ import { env } from '../extensions.js';
5
+ import { resolveConfig } from '../resolve.js';
6
+
7
+ const baseSchema = z.object({
8
+ debug: z.boolean().default(false),
9
+ host: z.string().default('localhost'),
10
+ port: z.number().default(3000),
11
+ });
12
+
13
+ describe('resolveConfig', () => {
14
+ describe('schema defaults', () => {
15
+ test('applies schema defaults when no other source provides values', () => {
16
+ const result = resolveConfig({ schema: baseSchema });
17
+
18
+ expect(result.isOk()).toBe(true);
19
+ expect(result.unwrap()).toEqual({
20
+ debug: false,
21
+ host: 'localhost',
22
+ port: 3000,
23
+ });
24
+ });
25
+ });
26
+
27
+ describe('base config', () => {
28
+ test('overrides schema defaults', () => {
29
+ const result = resolveConfig({
30
+ base: { host: 'example.com', port: 8080 },
31
+ schema: baseSchema,
32
+ });
33
+
34
+ expect(result.isOk()).toBe(true);
35
+ const value = result.unwrap();
36
+ expect(value.host).toBe('example.com');
37
+ expect(value.port).toBe(8080);
38
+ // Schema default preserved for unspecified fields
39
+ expect(value.debug).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('loadouts', () => {
44
+ test('overrides base config for matching loadout', () => {
45
+ const result = resolveConfig({
46
+ base: { host: 'example.com', port: 8080 },
47
+ loadout: 'production',
48
+ loadouts: {
49
+ production: { host: 'prod.example.com', port: 443 },
50
+ },
51
+ schema: baseSchema,
52
+ });
53
+
54
+ expect(result.isOk()).toBe(true);
55
+ const value = result.unwrap();
56
+ expect(value.host).toBe('prod.example.com');
57
+ expect(value.port).toBe(443);
58
+ expect(value.debug).toBe(false);
59
+ });
60
+
61
+ test('silently ignores unrecognized loadout (base only)', () => {
62
+ const result = resolveConfig({
63
+ base: { host: 'example.com' },
64
+ loadout: 'staging',
65
+ loadouts: {
66
+ production: { host: 'prod.example.com' },
67
+ },
68
+ schema: baseSchema,
69
+ });
70
+
71
+ expect(result.isOk()).toBe(true);
72
+ expect(result.unwrap().host).toBe('example.com');
73
+ });
74
+ });
75
+
76
+ describe('local overrides', () => {
77
+ test('deep-merge on top of loadout', () => {
78
+ const nestedSchema = z.object({
79
+ db: z
80
+ .object({
81
+ host: z.string().default('localhost'),
82
+ port: z.number().default(5432),
83
+ })
84
+ .default({}),
85
+ });
86
+
87
+ const result = resolveConfig({
88
+ base: { db: { host: 'db.example.com', port: 5432 } },
89
+ loadout: 'production',
90
+ loadouts: {
91
+ production: { db: { host: 'prod-db.example.com' } },
92
+ },
93
+ localOverrides: { db: { port: 9999 } },
94
+ schema: nestedSchema,
95
+ });
96
+
97
+ expect(result.isOk()).toBe(true);
98
+ const value = result.unwrap();
99
+ expect(value.db.host).toBe('prod-db.example.com');
100
+ expect(value.db.port).toBe(9999);
101
+ });
102
+ });
103
+
104
+ describe('env var overrides', () => {
105
+ test('overrides all other sources', () => {
106
+ const schema = z.object({
107
+ host: env(z.string(), 'APP_HOST').default('localhost'),
108
+ port: env(z.number(), 'APP_PORT').default(3000),
109
+ });
110
+
111
+ const result = resolveConfig({
112
+ base: { host: 'example.com', port: 8080 },
113
+ env: { APP_HOST: 'env-host.example.com', APP_PORT: '9090' },
114
+ schema,
115
+ });
116
+
117
+ expect(result.isOk()).toBe(true);
118
+ const value = result.unwrap();
119
+ expect(value.host).toBe('env-host.example.com');
120
+ expect(value.port).toBe(9090);
121
+ });
122
+
123
+ test('coerces string to number', () => {
124
+ const schema = z.object({
125
+ port: env(z.number(), 'PORT').default(3000),
126
+ });
127
+
128
+ const result = resolveConfig({
129
+ env: { PORT: '8080' },
130
+ schema,
131
+ });
132
+
133
+ expect(result.isOk()).toBe(true);
134
+ expect(result.unwrap().port).toBe(8080);
135
+ });
136
+
137
+ test('rejects non-numeric string with Zod error instead of NaN', () => {
138
+ const schema = z.object({
139
+ port: env(z.number(), 'PORT').default(3000),
140
+ });
141
+
142
+ const result = resolveConfig({
143
+ env: { PORT: 'abc' },
144
+ schema,
145
+ });
146
+
147
+ expect(result.isErr()).toBe(true);
148
+ });
149
+
150
+ test('coerces string to boolean', () => {
151
+ const schema = z.object({
152
+ debug: env(z.boolean(), 'DEBUG').default(false),
153
+ verbose: env(z.boolean(), 'VERBOSE').default(false),
154
+ });
155
+
156
+ const trueValues = resolveConfig({
157
+ env: { DEBUG: 'true', VERBOSE: '1' },
158
+ schema,
159
+ });
160
+ expect(trueValues.isOk()).toBe(true);
161
+ expect(trueValues.unwrap().debug).toBe(true);
162
+ expect(trueValues.unwrap().verbose).toBe(true);
163
+
164
+ const falseValues = resolveConfig({
165
+ env: { DEBUG: 'false', VERBOSE: '0' },
166
+ schema,
167
+ });
168
+ expect(falseValues.isOk()).toBe(true);
169
+ expect(falseValues.unwrap().debug).toBe(false);
170
+ expect(falseValues.unwrap().verbose).toBe(false);
171
+ });
172
+ });
173
+
174
+ describe('validation', () => {
175
+ test('missing required field returns Result.err', () => {
176
+ const schema = z.object({
177
+ required: z.string(),
178
+ });
179
+
180
+ const result = resolveConfig({ schema });
181
+
182
+ expect(result.isErr()).toBe(true);
183
+ });
184
+ });
185
+
186
+ describe('mutation safety', () => {
187
+ test('repeated resolve() calls do not mutate the original base', () => {
188
+ const schema = z.object({
189
+ host: env(z.string(), 'APP_HOST').default('localhost'),
190
+ });
191
+ const base = { host: 'dev-host' };
192
+
193
+ const first = resolveConfig({
194
+ base,
195
+ env: { APP_HOST: 'env-host' },
196
+ schema,
197
+ });
198
+ expect(first.isOk()).toBe(true);
199
+ expect(first.unwrap().host).toBe('env-host');
200
+
201
+ const second = resolveConfig({ base, schema });
202
+ expect(second.isOk()).toBe(true);
203
+ expect(second.unwrap().host).toBe('dev-host');
204
+ expect(base.host).toBe('dev-host');
205
+ });
206
+ });
207
+
208
+ describe('full stack', () => {
209
+ test('all 5 sources compose correctly', () => {
210
+ const schema = z.object({
211
+ apiUrl: env(z.string(), 'API_URL').default('http://localhost'),
212
+ debug: env(z.boolean(), 'DEBUG').default(false),
213
+ local: z.string().default('default-local'),
214
+ name: z.string().default('app'),
215
+ port: z.number().default(3000),
216
+ });
217
+
218
+ const result = resolveConfig({
219
+ // base overrides name
220
+ base: { name: 'my-app', port: 8080 },
221
+ // env overrides debug and apiUrl
222
+ env: { API_URL: 'https://api.prod.com', DEBUG: 'true' },
223
+ // loadout overrides port
224
+ loadout: 'production',
225
+ loadouts: { production: { port: 443 } },
226
+ // local overrides local
227
+ localOverrides: { local: 'my-local-value' },
228
+ schema,
229
+ });
230
+
231
+ expect(result.isOk()).toBe(true);
232
+ const value = result.unwrap();
233
+ // Schema default
234
+ // (nothing left at just default — name was overridden by base)
235
+ // Base
236
+ expect(value.name).toBe('my-app');
237
+ // Loadout overrides base port
238
+ expect(value.port).toBe(443);
239
+ // Local overrides
240
+ expect(value.local).toBe('my-local-value');
241
+ // Env overrides
242
+ expect(value.apiUrl).toBe('https://api.prod.com');
243
+ expect(value.debug).toBe(true);
244
+ });
245
+ });
246
+ });
@@ -0,0 +1,64 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ import { ensureWorkspace } from '../workspace.js';
7
+
8
+ describe('ensureWorkspace', () => {
9
+ let root: string;
10
+
11
+ beforeEach(async () => {
12
+ root = await mkdtemp(join(tmpdir(), 'trails-ws-'));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(root, { force: true, recursive: true });
17
+ });
18
+
19
+ describe('directory creation', () => {
20
+ test('creates .trails/ directory', async () => {
21
+ await ensureWorkspace(root);
22
+ const entries = await readdir(join(root, '.trails'));
23
+ expect(entries).toContain('config');
24
+ expect(entries).toContain('dev');
25
+ expect(entries).toContain('generated');
26
+ });
27
+
28
+ test('creates all expected subdirectories', async () => {
29
+ await ensureWorkspace(root);
30
+ const config = await readdir(join(root, '.trails', 'config'));
31
+ expect(config).toBeDefined();
32
+ const dev = await readdir(join(root, '.trails', 'dev'));
33
+ expect(dev).toBeDefined();
34
+ const generated = await readdir(join(root, '.trails', 'generated'));
35
+ expect(generated).toBeDefined();
36
+ });
37
+ });
38
+
39
+ describe('.gitignore', () => {
40
+ test('writes .gitignore on first run', async () => {
41
+ await ensureWorkspace(root);
42
+ const content = await readFile(
43
+ join(root, '.trails', '.gitignore'),
44
+ 'utf8'
45
+ );
46
+ expect(content).toContain('config/');
47
+ expect(content).toContain('dev/');
48
+ expect(content).toContain('generated/');
49
+ });
50
+
51
+ test('does not overwrite existing .gitignore', async () => {
52
+ await ensureWorkspace(root);
53
+ const customContent = '# custom\n*\n';
54
+ await Bun.write(join(root, '.trails', '.gitignore'), customContent);
55
+
56
+ await ensureWorkspace(root);
57
+ const content = await readFile(
58
+ join(root, '.trails', '.gitignore'),
59
+ 'utf8'
60
+ );
61
+ expect(content).toBe(customContent);
62
+ });
63
+ });
64
+ });