@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,210 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createServiceLookup } from '@ontrails/core';
|
|
6
|
+
import type { TrailContext } from '@ontrails/core';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
|
|
9
|
+
import { env } from '../extensions.js';
|
|
10
|
+
import type { ConfigState } from '../registry.js';
|
|
11
|
+
import { configInit } from '../trails/config-init.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build a TrailContext with configService resolved in extensions.
|
|
15
|
+
*/
|
|
16
|
+
const buildCtx = (state: ConfigState): TrailContext => {
|
|
17
|
+
const extensions = { config: state };
|
|
18
|
+
const ctx: TrailContext = {
|
|
19
|
+
cwd: '/tmp',
|
|
20
|
+
env: {},
|
|
21
|
+
extensions,
|
|
22
|
+
requestId: 'test',
|
|
23
|
+
service: undefined as unknown as TrailContext['service'],
|
|
24
|
+
signal: AbortSignal.timeout(5000),
|
|
25
|
+
workspaceRoot: '/tmp',
|
|
26
|
+
};
|
|
27
|
+
const withLookup = {
|
|
28
|
+
...ctx,
|
|
29
|
+
service: createServiceLookup(() => withLookup),
|
|
30
|
+
};
|
|
31
|
+
return withLookup;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const testSchema = z.object({
|
|
35
|
+
host: z.string().default('localhost'),
|
|
36
|
+
port: z.number().default(3000),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const testState: ConfigState = {
|
|
40
|
+
resolved: { host: 'localhost', port: 3000 },
|
|
41
|
+
schema: testSchema,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('config.init trail', () => {
|
|
45
|
+
describe('identity', () => {
|
|
46
|
+
test('has id "config.init"', () => {
|
|
47
|
+
expect(configInit.id).toBe('config.init');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('has kind "trail"', () => {
|
|
51
|
+
expect(configInit.kind).toBe('trail');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('has intent "write"', () => {
|
|
55
|
+
expect(configInit.intent).toBe('write');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('has infrastructure metadata', () => {
|
|
59
|
+
expect(configInit.metadata).toEqual({ category: 'infrastructure' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('has output schema', () => {
|
|
63
|
+
expect(configInit.output).toBeDefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('declares configService dependency', () => {
|
|
67
|
+
expect(configInit.services).toBeDefined();
|
|
68
|
+
expect(configInit.services?.length).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('examples', () => {
|
|
73
|
+
test('has at least one example', () => {
|
|
74
|
+
expect(configInit.examples?.length).toBeGreaterThanOrEqual(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('wired behavior', () => {
|
|
79
|
+
test('generates TOML output by default', async () => {
|
|
80
|
+
const ctx = buildCtx(testState);
|
|
81
|
+
const result = await configInit.run({ format: 'toml' }, ctx);
|
|
82
|
+
|
|
83
|
+
expect(result.isOk()).toBe(true);
|
|
84
|
+
const value = result.unwrap();
|
|
85
|
+
expect(value.format).toBe('toml');
|
|
86
|
+
expect(value.content).toContain('host');
|
|
87
|
+
expect(value.content).toContain('port');
|
|
88
|
+
expect(value.content.length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('generates JSON output when requested', async () => {
|
|
92
|
+
const ctx = buildCtx(testState);
|
|
93
|
+
const result = await configInit.run({ format: 'json' }, ctx);
|
|
94
|
+
|
|
95
|
+
expect(result.isOk()).toBe(true);
|
|
96
|
+
const value = result.unwrap();
|
|
97
|
+
expect(value.format).toBe('json');
|
|
98
|
+
expect(value.content).toContain('"host"');
|
|
99
|
+
expect(value.content).toContain('"port"');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('generates YAML output when requested', async () => {
|
|
103
|
+
const ctx = buildCtx(testState);
|
|
104
|
+
const result = await configInit.run({ format: 'yaml' }, ctx);
|
|
105
|
+
|
|
106
|
+
expect(result.isOk()).toBe(true);
|
|
107
|
+
const value = result.unwrap();
|
|
108
|
+
expect(value.format).toBe('yaml');
|
|
109
|
+
expect(value.content).toContain('host:');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('generates JSONC output when requested', async () => {
|
|
113
|
+
const ctx = buildCtx(testState);
|
|
114
|
+
const result = await configInit.run({ format: 'jsonc' }, ctx);
|
|
115
|
+
|
|
116
|
+
expect(result.isOk()).toBe(true);
|
|
117
|
+
const value = result.unwrap();
|
|
118
|
+
expect(value.format).toBe('jsonc');
|
|
119
|
+
expect(value.content).toContain('"host"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('output content is non-empty for schema with fields', async () => {
|
|
123
|
+
const ctx = buildCtx(testState);
|
|
124
|
+
const result = await configInit.run({ format: 'toml' }, ctx);
|
|
125
|
+
|
|
126
|
+
expect(result.isOk()).toBe(true);
|
|
127
|
+
expect(result.unwrap().content.trim().length).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('returns content without writtenFiles when dir is not provided', async () => {
|
|
131
|
+
const ctx = buildCtx(testState);
|
|
132
|
+
const result = await configInit.run({ format: 'toml' }, ctx);
|
|
133
|
+
|
|
134
|
+
expect(result.isOk()).toBe(true);
|
|
135
|
+
expect(result.unwrap().writtenFiles).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('artifact generation', () => {
|
|
140
|
+
let tempDir: string;
|
|
141
|
+
|
|
142
|
+
const envSchema = z.object({
|
|
143
|
+
host: env(z.string(), 'APP_HOST').default('localhost'),
|
|
144
|
+
port: z.number().default(3000),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const envState: ConfigState = {
|
|
148
|
+
resolved: { host: 'localhost', port: 3000 },
|
|
149
|
+
schema: envSchema,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
beforeEach(async () => {
|
|
153
|
+
tempDir = await mkdtemp(join(tmpdir(), 'trails-config-init-'));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(async () => {
|
|
157
|
+
await rm(tempDir, { force: true, recursive: true });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('writes .schema.json when dir is provided', async () => {
|
|
161
|
+
const ctx = buildCtx(envState);
|
|
162
|
+
const result = await configInit.run(
|
|
163
|
+
{ dir: tempDir, format: 'toml' },
|
|
164
|
+
ctx
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(result.isOk()).toBe(true);
|
|
168
|
+
const value = result.unwrap();
|
|
169
|
+
expect(value.writtenFiles).toBeDefined();
|
|
170
|
+
expect(value.writtenFiles).toContainEqual(join(tempDir, '.schema.json'));
|
|
171
|
+
|
|
172
|
+
const schemaContent = await readFile(
|
|
173
|
+
join(tempDir, '.schema.json'),
|
|
174
|
+
'utf8'
|
|
175
|
+
);
|
|
176
|
+
const parsed = JSON.parse(schemaContent);
|
|
177
|
+
expect(parsed['$schema']).toBe(
|
|
178
|
+
'https://json-schema.org/draft/2020-12/schema'
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('writes .env.example when schema has env bindings', async () => {
|
|
183
|
+
const ctx = buildCtx(envState);
|
|
184
|
+
const result = await configInit.run(
|
|
185
|
+
{ dir: tempDir, format: 'toml' },
|
|
186
|
+
ctx
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(result.isOk()).toBe(true);
|
|
190
|
+
const value = result.unwrap();
|
|
191
|
+
expect(value.writtenFiles).toContainEqual(join(tempDir, '.env.example'));
|
|
192
|
+
|
|
193
|
+
const envContent = await readFile(join(tempDir, '.env.example'), 'utf8');
|
|
194
|
+
expect(envContent).toContain('APP_HOST');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('still returns content alongside written files', async () => {
|
|
198
|
+
const ctx = buildCtx(envState);
|
|
199
|
+
const result = await configInit.run(
|
|
200
|
+
{ dir: tempDir, format: 'json' },
|
|
201
|
+
ctx
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result.isOk()).toBe(true);
|
|
205
|
+
const value = result.unwrap();
|
|
206
|
+
expect(value.content.length).toBeGreaterThan(0);
|
|
207
|
+
expect(value.format).toBe('json');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Result } from '@ontrails/core';
|
|
3
|
+
import type { TrailContext } from '@ontrails/core';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import { configLayer } from '../config-layer.js';
|
|
7
|
+
|
|
8
|
+
const stubTrail = {
|
|
9
|
+
description: undefined,
|
|
10
|
+
detours: undefined,
|
|
11
|
+
examples: undefined,
|
|
12
|
+
fields: undefined,
|
|
13
|
+
follow: [],
|
|
14
|
+
id: 'test.stub',
|
|
15
|
+
idempotent: undefined,
|
|
16
|
+
input: z.object({}),
|
|
17
|
+
intent: 'read' as const,
|
|
18
|
+
kind: 'trail' as const,
|
|
19
|
+
metadata: undefined,
|
|
20
|
+
output: undefined,
|
|
21
|
+
run: (_input: unknown, _ctx: TrailContext) => Result.ok({}),
|
|
22
|
+
services: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('configLayer', () => {
|
|
26
|
+
describe('identity', () => {
|
|
27
|
+
test('has name "config"', () => {
|
|
28
|
+
expect(configLayer.name).toBe('config');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('has a description', () => {
|
|
32
|
+
expect(configLayer.description).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('wrap', () => {
|
|
37
|
+
test('passes through to the base implementation', async () => {
|
|
38
|
+
const impl = (_input: unknown, _ctx: TrailContext) =>
|
|
39
|
+
Result.ok({ called: true });
|
|
40
|
+
|
|
41
|
+
const wrapped = configLayer.wrap(stubTrail, impl);
|
|
42
|
+
const ctx = {
|
|
43
|
+
cwd: '/tmp',
|
|
44
|
+
env: {},
|
|
45
|
+
workspaceRoot: '/tmp',
|
|
46
|
+
} as TrailContext;
|
|
47
|
+
const result = await wrapped({}, ctx);
|
|
48
|
+
|
|
49
|
+
expect(result.isOk()).toBe(true);
|
|
50
|
+
expect(result.unwrap()).toEqual({ called: true });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import { configService } from '../config-service.js';
|
|
5
|
+
import type { ConfigState } from '../registry.js';
|
|
6
|
+
import { clearConfigState, registerConfigState } from '../registry.js';
|
|
7
|
+
|
|
8
|
+
/** Stub ServiceContext for create calls. */
|
|
9
|
+
const stubSvcCtx = {
|
|
10
|
+
config: undefined,
|
|
11
|
+
cwd: '/tmp',
|
|
12
|
+
env: {},
|
|
13
|
+
workspaceRoot: '/tmp',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('configService', () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
clearConfigState();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('identity', () => {
|
|
22
|
+
test('has id "config"', () => {
|
|
23
|
+
expect(configService.id).toBe('config');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('has kind "service"', () => {
|
|
27
|
+
expect(configService.kind).toBe('service');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('has infrastructure metadata', () => {
|
|
31
|
+
expect(configService.metadata).toEqual({ category: 'infrastructure' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('has description', () => {
|
|
35
|
+
expect(configService.description).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('mock', () => {
|
|
40
|
+
test('returns a ConfigState with empty schema and resolved', () => {
|
|
41
|
+
const value = configService.mock?.() as ConfigState;
|
|
42
|
+
expect(value).toBeDefined();
|
|
43
|
+
expect(value.resolved).toEqual({});
|
|
44
|
+
expect(value.schema).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('create', () => {
|
|
49
|
+
test('returns Result.ok with registered ConfigState', async () => {
|
|
50
|
+
const schema = z.object({ port: z.number().default(3000) });
|
|
51
|
+
const state: ConfigState = { resolved: { port: 3000 }, schema };
|
|
52
|
+
registerConfigState(state);
|
|
53
|
+
|
|
54
|
+
const result = await configService.create(stubSvcCtx);
|
|
55
|
+
|
|
56
|
+
expect(result.isOk()).toBe(true);
|
|
57
|
+
const value = result.unwrap() as ConfigState;
|
|
58
|
+
expect(value.resolved).toEqual({ port: 3000 });
|
|
59
|
+
expect(value.schema).toBe(schema);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('includes optional layer data when present', async () => {
|
|
63
|
+
const schema = z.object({ port: z.number() });
|
|
64
|
+
const state: ConfigState = {
|
|
65
|
+
base: { port: 8080 },
|
|
66
|
+
local: { port: 3000 },
|
|
67
|
+
resolved: { port: 3000 },
|
|
68
|
+
schema,
|
|
69
|
+
};
|
|
70
|
+
registerConfigState(state);
|
|
71
|
+
|
|
72
|
+
const result = await configService.create(stubSvcCtx);
|
|
73
|
+
|
|
74
|
+
expect(result.isOk()).toBe(true);
|
|
75
|
+
const value = result.unwrap() as ConfigState;
|
|
76
|
+
expect(value.base).toEqual({ port: 8080 });
|
|
77
|
+
expect(value.local).toEqual({ port: 3000 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('returns Result.err when no state is registered', async () => {
|
|
81
|
+
const result = await configService.create(stubSvcCtx);
|
|
82
|
+
|
|
83
|
+
expect(result.isErr()).toBe(true);
|
|
84
|
+
expect(result.error.message).toContain('Config state not registered');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
|
|
7
|
+
import { defineConfig } from '../define-config.js';
|
|
8
|
+
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
debug: z.boolean().default(false),
|
|
11
|
+
host: z.string().default('localhost'),
|
|
12
|
+
port: z.number().default(3000),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
type EnvKey = 'NODE_ENV' | 'TRAILS_ENV';
|
|
16
|
+
|
|
17
|
+
interface EnvSnapshot {
|
|
18
|
+
readonly NODE_ENV: string | undefined;
|
|
19
|
+
readonly TRAILS_ENV: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const readEnvSnapshot = (): EnvSnapshot => ({
|
|
23
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
24
|
+
TRAILS_ENV: process.env.TRAILS_ENV,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const setEnvVar = (key: EnvKey, value: string | undefined): void => {
|
|
28
|
+
if (value === undefined) {
|
|
29
|
+
if (key === 'NODE_ENV') {
|
|
30
|
+
delete process.env.NODE_ENV;
|
|
31
|
+
} else {
|
|
32
|
+
delete process.env.TRAILS_ENV;
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (key === 'NODE_ENV') {
|
|
37
|
+
process.env.NODE_ENV = value;
|
|
38
|
+
} else {
|
|
39
|
+
process.env.TRAILS_ENV = value;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const restoreEnv = (snapshot: EnvSnapshot): void => {
|
|
44
|
+
setEnvVar('NODE_ENV', snapshot.NODE_ENV);
|
|
45
|
+
setEnvVar('TRAILS_ENV', snapshot.TRAILS_ENV);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe('defineConfig', () => {
|
|
49
|
+
test('returns an object with schema, base, and loadouts', () => {
|
|
50
|
+
const base = { host: 'example.com' };
|
|
51
|
+
const loadouts = { production: { port: 443 } };
|
|
52
|
+
|
|
53
|
+
const config = defineConfig({ base, loadouts, schema });
|
|
54
|
+
|
|
55
|
+
expect(config.schema).toBe(schema);
|
|
56
|
+
expect(config.base).toBe(base);
|
|
57
|
+
expect(config.loadouts).toBe(loadouts);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resolve() uses TRAILS_ENV to select loadout', async () => {
|
|
61
|
+
const config = defineConfig({
|
|
62
|
+
base: { host: 'example.com' },
|
|
63
|
+
loadouts: {
|
|
64
|
+
production: { host: 'prod.example.com', port: 443 },
|
|
65
|
+
test: { host: 'test.example.com', port: 9999 },
|
|
66
|
+
},
|
|
67
|
+
schema,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = await config.resolve({
|
|
71
|
+
env: { TRAILS_ENV: 'production' },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.isOk()).toBe(true);
|
|
75
|
+
expect(result.unwrap().host).toBe('prod.example.com');
|
|
76
|
+
expect(result.unwrap().port).toBe(443);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('resolve() with explicit loadout option overrides TRAILS_ENV', async () => {
|
|
80
|
+
const config = defineConfig({
|
|
81
|
+
base: { host: 'example.com' },
|
|
82
|
+
loadouts: {
|
|
83
|
+
production: { host: 'prod.example.com' },
|
|
84
|
+
test: { host: 'test.example.com' },
|
|
85
|
+
},
|
|
86
|
+
schema,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await config.resolve({
|
|
90
|
+
env: { TRAILS_ENV: 'production' },
|
|
91
|
+
loadout: 'test',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.isOk()).toBe(true);
|
|
95
|
+
expect(result.unwrap().host).toBe('test.example.com');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('envFromNodeEnv maps NODE_ENV to TRAILS_ENV when unset', async () => {
|
|
99
|
+
const config = defineConfig({
|
|
100
|
+
base: { host: 'example.com' },
|
|
101
|
+
envFromNodeEnv: true,
|
|
102
|
+
loadouts: {
|
|
103
|
+
production: { host: 'prod.example.com', port: 443 },
|
|
104
|
+
},
|
|
105
|
+
schema,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await config.resolve({
|
|
109
|
+
env: { NODE_ENV: 'production' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.isOk()).toBe(true);
|
|
113
|
+
expect(result.unwrap().host).toBe('prod.example.com');
|
|
114
|
+
expect(result.unwrap().port).toBe(443);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('envFromNodeEnv does not mutate process.env', async () => {
|
|
118
|
+
const snapshot = readEnvSnapshot();
|
|
119
|
+
setEnvVar('TRAILS_ENV', undefined);
|
|
120
|
+
setEnvVar('NODE_ENV', 'production');
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const config = defineConfig({
|
|
124
|
+
base: { host: 'example.com' },
|
|
125
|
+
envFromNodeEnv: true,
|
|
126
|
+
loadouts: {
|
|
127
|
+
production: { host: 'prod.example.com', port: 443 },
|
|
128
|
+
},
|
|
129
|
+
schema,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const result = await config.resolve();
|
|
133
|
+
|
|
134
|
+
expect(result.isOk()).toBe(true);
|
|
135
|
+
expect(result.unwrap().host).toBe('prod.example.com');
|
|
136
|
+
expect(process.env.TRAILS_ENV).toBeUndefined();
|
|
137
|
+
} finally {
|
|
138
|
+
restoreEnv(snapshot);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('envFromNodeEnv does not override explicit TRAILS_ENV', async () => {
|
|
143
|
+
const config = defineConfig({
|
|
144
|
+
base: { host: 'example.com' },
|
|
145
|
+
envFromNodeEnv: true,
|
|
146
|
+
loadouts: {
|
|
147
|
+
production: { host: 'prod.example.com' },
|
|
148
|
+
test: { host: 'test.example.com' },
|
|
149
|
+
},
|
|
150
|
+
schema,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await config.resolve({
|
|
154
|
+
env: { NODE_ENV: 'production', TRAILS_ENV: 'test' },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.isOk()).toBe(true);
|
|
158
|
+
expect(result.unwrap().host).toBe('test.example.com');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('test loadout works when TRAILS_ENV=test', async () => {
|
|
162
|
+
const config = defineConfig({
|
|
163
|
+
base: { port: 8080 },
|
|
164
|
+
loadouts: {
|
|
165
|
+
test: { debug: true, port: 0 },
|
|
166
|
+
},
|
|
167
|
+
schema,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await config.resolve({
|
|
171
|
+
env: { TRAILS_ENV: 'test' },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.isOk()).toBe(true);
|
|
175
|
+
const value = result.unwrap();
|
|
176
|
+
expect(value.debug).toBe(true);
|
|
177
|
+
expect(value.port).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('local overrides', () => {
|
|
181
|
+
let tempDir: string;
|
|
182
|
+
|
|
183
|
+
beforeEach(async () => {
|
|
184
|
+
tempDir = await mkdtemp(join(tmpdir(), 'trails-define-config-'));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
afterEach(async () => {
|
|
188
|
+
await rm(tempDir, { force: true, recursive: true });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('applies local overrides from .trails/config/local.ts', async () => {
|
|
192
|
+
const configDir = join(tempDir, '.trails', 'config');
|
|
193
|
+
await mkdir(configDir, { recursive: true });
|
|
194
|
+
await Bun.write(
|
|
195
|
+
join(configDir, 'local.ts'),
|
|
196
|
+
'export default { port: 4444 };'
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const config = defineConfig({
|
|
200
|
+
base: { host: 'example.com' },
|
|
201
|
+
schema,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const result = await config.resolve({
|
|
205
|
+
cwd: tempDir,
|
|
206
|
+
env: {},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.isOk()).toBe(true);
|
|
210
|
+
expect(result.unwrap().port).toBe(4444);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('local overrides applied between loadout and env', async () => {
|
|
214
|
+
const configDir = join(tempDir, '.trails', 'config');
|
|
215
|
+
await mkdir(configDir, { recursive: true });
|
|
216
|
+
await Bun.write(
|
|
217
|
+
join(configDir, 'local.js'),
|
|
218
|
+
'export default { port: 5555 };'
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const config = defineConfig({
|
|
222
|
+
base: { port: 8080 },
|
|
223
|
+
loadouts: {
|
|
224
|
+
dev: { port: 9090 },
|
|
225
|
+
},
|
|
226
|
+
schema,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Local overrides should win over loadout (9090)
|
|
230
|
+
const result = await config.resolve({
|
|
231
|
+
cwd: tempDir,
|
|
232
|
+
env: { TRAILS_ENV: 'dev' },
|
|
233
|
+
loadout: 'dev',
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.isOk()).toBe(true);
|
|
237
|
+
expect(result.unwrap().port).toBe(5555);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('skips local overrides when TRAILS_ENV=test', async () => {
|
|
241
|
+
const configDir = join(tempDir, '.trails', 'config');
|
|
242
|
+
await mkdir(configDir, { recursive: true });
|
|
243
|
+
await Bun.write(
|
|
244
|
+
join(configDir, 'local.ts'),
|
|
245
|
+
'export default { port: 4444 };'
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const config = defineConfig({
|
|
249
|
+
base: { port: 8080 },
|
|
250
|
+
schema,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const result = await config.resolve({
|
|
254
|
+
cwd: tempDir,
|
|
255
|
+
env: { TRAILS_ENV: 'test' },
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(result.isOk()).toBe(true);
|
|
259
|
+
// Should NOT apply local overrides, so port stays at base 8080
|
|
260
|
+
expect(result.unwrap().port).toBe(8080);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|