@joinremba/beacon 0.1.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/LICENSE +21 -0
- package/README.md +378 -0
- package/package.json +82 -0
- package/src/cli-config.ts +221 -0
- package/src/cli-format.ts +100 -0
- package/src/cli.test.ts +53 -0
- package/src/cli.ts +243 -0
- package/src/errors.ts +18 -0
- package/src/index.test.ts +178 -0
- package/src/index.ts +197 -0
- package/src/types.ts +41 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const ESC = "\x1b";
|
|
2
|
+
|
|
3
|
+
export const color = {
|
|
4
|
+
green: (s: string) => `${ESC}[32m${s}${ESC}[39m`,
|
|
5
|
+
red: (s: string) => `${ESC}[31m${s}${ESC}[39m`,
|
|
6
|
+
yellow: (s: string) => `${ESC}[33m${s}${ESC}[39m`,
|
|
7
|
+
dim: (s: string) => `${ESC}[2m${s}${ESC}[22m`,
|
|
8
|
+
bold: (s: string) => `${ESC}[1m${s}${ESC}[22m`,
|
|
9
|
+
cyan: (s: string) => `${ESC}[36m${s}${ESC}[39m`,
|
|
10
|
+
grey: (s: string) => `${ESC}[90m${s}${ESC}[39m`,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const icon = {
|
|
14
|
+
pass: color.green("\u2713"),
|
|
15
|
+
fail: color.red("\u2717"),
|
|
16
|
+
warn: color.yellow("!"),
|
|
17
|
+
info: color.cyan("i"),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function formatCheckResult(
|
|
21
|
+
results: Array<{ key: string; status: "ok" | "missing" | "invalid"; message: string }>
|
|
22
|
+
): string {
|
|
23
|
+
if (results.length === 0) return color.dim(" No variables defined in schema.\n");
|
|
24
|
+
|
|
25
|
+
const keyWidth = Math.min(Math.max(...results.map((r) => r.key.length), 4), 50);
|
|
26
|
+
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
lines.push(
|
|
29
|
+
` ${color.dim("KEY".padEnd(keyWidth))} ${color.dim("STATUS")} ${color.dim("VALUE")}`
|
|
30
|
+
);
|
|
31
|
+
lines.push(
|
|
32
|
+
` ${color.dim("\u2500".repeat(keyWidth))} ${color.dim("\u2500".repeat(6))} ${color.dim("\u2500".repeat(20))}`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
for (const item of results) {
|
|
36
|
+
const key = item.key.padEnd(keyWidth);
|
|
37
|
+
|
|
38
|
+
let statusText: string;
|
|
39
|
+
let valueText: string;
|
|
40
|
+
|
|
41
|
+
switch (item.status) {
|
|
42
|
+
case "ok":
|
|
43
|
+
statusText = color.green("pass");
|
|
44
|
+
valueText = color.dim(item.message);
|
|
45
|
+
break;
|
|
46
|
+
case "missing":
|
|
47
|
+
statusText = color.red("MISSING");
|
|
48
|
+
valueText = item.message;
|
|
49
|
+
break;
|
|
50
|
+
case "invalid":
|
|
51
|
+
statusText = color.yellow("INVALID");
|
|
52
|
+
valueText = item.message;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(` ${key} ${statusText} ${valueText}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines.join("\n") + "\n";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatSummary(passed: number, failed: number): string {
|
|
63
|
+
if (failed === 0) {
|
|
64
|
+
return color.green(`\u2713 All ${passed} variable(s) pass.\n`);
|
|
65
|
+
}
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
if (failed > 0) parts.push(color.red(`${failed} issue(s)`));
|
|
68
|
+
if (passed > 0) parts.push(color.green(`${passed} pass`));
|
|
69
|
+
return ` ${parts.join(", ")}\n`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function levenshtein(a: string, b: string): number {
|
|
73
|
+
const m = a.length;
|
|
74
|
+
const n = b.length;
|
|
75
|
+
const dp: number[][] = [];
|
|
76
|
+
for (let i = 0; i <= m; i++) {
|
|
77
|
+
dp.push([]);
|
|
78
|
+
for (let j = 0; j <= n; j++) {
|
|
79
|
+
dp[i]!.push(0);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i <= m; i++) dp[i]![0] = i;
|
|
83
|
+
for (let j = 0; j <= n; j++) dp[0]![j] = j;
|
|
84
|
+
for (let i = 1; i <= m; i++) {
|
|
85
|
+
for (let j = 1; j <= n; j++) {
|
|
86
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
87
|
+
dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + cost);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return dp[m]![n]!;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function suggestKeys(missingKey: string, allKeys: string[], maxSuggestions = 3): string[] {
|
|
94
|
+
const scored = allKeys
|
|
95
|
+
.map((k) => ({ key: k, score: levenshtein(missingKey.toLowerCase(), k.toLowerCase()) }))
|
|
96
|
+
.filter((s) => s.score <= 4 && s.score > 0)
|
|
97
|
+
.sort((a, b) => a.score - b.score);
|
|
98
|
+
|
|
99
|
+
return scored.slice(0, maxSuggestions).map((s) => s.key);
|
|
100
|
+
}
|
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { generateEnvExample } from "./cli-config";
|
|
3
|
+
import type { BeaconConfigFile } from "./cli-config";
|
|
4
|
+
|
|
5
|
+
describe("generateEnvExample", () => {
|
|
6
|
+
test("generates basic env example", () => {
|
|
7
|
+
const config: BeaconConfigFile = {
|
|
8
|
+
schema: {
|
|
9
|
+
DATABASE_URL: { type: "url", required: true, description: "PostgreSQL connection string" },
|
|
10
|
+
PORT: { type: "port", default: 3000, description: "HTTP server port" },
|
|
11
|
+
NODE_ENV: {
|
|
12
|
+
type: "enum",
|
|
13
|
+
values: ["development", "production"],
|
|
14
|
+
default: "development",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const result = generateEnvExample(config);
|
|
20
|
+
expect(result).toContain("DATABASE_URL");
|
|
21
|
+
expect(result).toContain("PostgreSQL connection string");
|
|
22
|
+
expect(result).toContain("PORT=3000");
|
|
23
|
+
expect(result).toContain("development | production");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("includes profile override when activeProfile is set", () => {
|
|
27
|
+
const config: BeaconConfigFile = {
|
|
28
|
+
schema: {
|
|
29
|
+
DB_HOST: { type: "string", default: "localhost" },
|
|
30
|
+
},
|
|
31
|
+
profiles: {
|
|
32
|
+
production: {
|
|
33
|
+
DB_HOST: { type: "host", required: true, description: "Production DB host" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const result = generateEnvExample(config, "production");
|
|
39
|
+
expect(result).toContain("Profile: production");
|
|
40
|
+
expect(result).toContain("Production DB host");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("marks secret vars", () => {
|
|
44
|
+
const config: BeaconConfigFile = {
|
|
45
|
+
schema: {
|
|
46
|
+
API_KEY: { type: "string", secret: true, description: "API secret key" },
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const result = generateEnvExample(config);
|
|
51
|
+
expect(result).toContain("Secret: yes");
|
|
52
|
+
});
|
|
53
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { loadConfig, type BeaconConfigFile } from "./cli-config";
|
|
4
|
+
import { generateEnvExample } from "./cli-config";
|
|
5
|
+
import { runCheck } from "./cli-config";
|
|
6
|
+
import { color, icon, formatCheckResult, formatSummary, suggestKeys } from "./cli-format";
|
|
7
|
+
|
|
8
|
+
interface ParsedArgs {
|
|
9
|
+
command: string;
|
|
10
|
+
configPath: string;
|
|
11
|
+
output: string;
|
|
12
|
+
profile?: string;
|
|
13
|
+
wantsHelp: boolean;
|
|
14
|
+
helpTopic: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
18
|
+
const args: ParsedArgs = {
|
|
19
|
+
command: "",
|
|
20
|
+
configPath: "",
|
|
21
|
+
output: ".env.example",
|
|
22
|
+
wantsHelp: false,
|
|
23
|
+
helpTopic: "",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let i = 0;
|
|
27
|
+
|
|
28
|
+
const first = argv[i];
|
|
29
|
+
if (first !== undefined && !first.startsWith("-")) {
|
|
30
|
+
const val = first;
|
|
31
|
+
if (val === "help") {
|
|
32
|
+
args.command = "help";
|
|
33
|
+
i++;
|
|
34
|
+
args.helpTopic = argv[i] ?? "";
|
|
35
|
+
return args;
|
|
36
|
+
}
|
|
37
|
+
args.command = val;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
while (i < argv.length) {
|
|
42
|
+
const arg = argv[i] as string;
|
|
43
|
+
if (arg === "-c" || arg === "--config") {
|
|
44
|
+
i++;
|
|
45
|
+
args.configPath = argv[i] ?? "";
|
|
46
|
+
} else if (arg === "-o" || arg === "--output") {
|
|
47
|
+
i++;
|
|
48
|
+
args.output = argv[i] ?? ".env.example";
|
|
49
|
+
} else if (arg === "--profile") {
|
|
50
|
+
i++;
|
|
51
|
+
args.profile = argv[i] ?? undefined;
|
|
52
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
53
|
+
args.wantsHelp = true;
|
|
54
|
+
} else if (args.command === "" && !arg.startsWith("-") && !args.wantsHelp) {
|
|
55
|
+
args.command = arg;
|
|
56
|
+
}
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return args;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const argv = process.argv.slice(2);
|
|
65
|
+
const args = parseArgs(argv);
|
|
66
|
+
|
|
67
|
+
if (args.wantsHelp || args.command === "help") {
|
|
68
|
+
const topic = args.command === "help" ? args.helpTopic : args.command;
|
|
69
|
+
printHelp(topic);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (args.command === "") {
|
|
74
|
+
printHelp("");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
switch (args.command) {
|
|
79
|
+
case "init":
|
|
80
|
+
await handleInit(args);
|
|
81
|
+
break;
|
|
82
|
+
case "check":
|
|
83
|
+
await handleCheck(args);
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
console.error(` ${icon.fail} Unknown command: ${color.bold(args.command)}`);
|
|
87
|
+
printHelp("");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleInit(args: ParsedArgs) {
|
|
93
|
+
let config: BeaconConfigFile;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
config = await loadConfig(args.configPath || undefined);
|
|
97
|
+
} catch {
|
|
98
|
+
config = { schema: {} };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const profile = args.profile;
|
|
102
|
+
const example = generateEnvExample(config, profile);
|
|
103
|
+
await Bun.write(args.output, example);
|
|
104
|
+
console.log(` ${icon.pass} Generated ${color.bold(args.output)}`);
|
|
105
|
+
if (profile) {
|
|
106
|
+
console.log(` Profile: ${color.cyan(profile)}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const count = Object.keys(
|
|
110
|
+
profile && config.profiles?.[profile]
|
|
111
|
+
? { ...config.schema, ...config.profiles[profile] }
|
|
112
|
+
: config.schema
|
|
113
|
+
).length;
|
|
114
|
+
if (count > 0) {
|
|
115
|
+
console.log(` ${count} variable(s) documented`);
|
|
116
|
+
}
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleCheck(args: ParsedArgs) {
|
|
121
|
+
let config: BeaconConfigFile;
|
|
122
|
+
try {
|
|
123
|
+
config = await loadConfig(args.configPath || undefined);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(` ${icon.fail} ${(err as Error).message}`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const profile = args.profile;
|
|
130
|
+
if (profile) {
|
|
131
|
+
console.log(` ${icon.info} Profile: ${color.cyan(profile)}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result = await runCheck(config, profile);
|
|
135
|
+
|
|
136
|
+
process.stdout.write(formatCheckResult(result.results));
|
|
137
|
+
|
|
138
|
+
if (result.errors.length > 0) {
|
|
139
|
+
for (const err of result.errors) {
|
|
140
|
+
const suggestions = suggestKeys(
|
|
141
|
+
err.key,
|
|
142
|
+
Object.keys(config.schema).filter((k) => k !== err.key)
|
|
143
|
+
);
|
|
144
|
+
if (suggestions.length > 0) {
|
|
145
|
+
process.stdout.write(
|
|
146
|
+
` ${color.dim(`Did you mean ${suggestions.map((s) => color.cyan(s)).join(" or ")}?`)}\n`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const passed = result.results.filter((r) => r.status === "ok").length;
|
|
153
|
+
const failed = result.results.filter((r) => r.status !== "ok").length;
|
|
154
|
+
process.stdout.write(formatSummary(passed, failed));
|
|
155
|
+
|
|
156
|
+
if (failed > 0) {
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function printHelp(command: string) {
|
|
163
|
+
if (command === "init") {
|
|
164
|
+
console.log(`
|
|
165
|
+
${color.bold("USAGE")}
|
|
166
|
+
beacon init [options]
|
|
167
|
+
|
|
168
|
+
${color.bold("DESCRIPTION")}
|
|
169
|
+
Generate a .env.example file from your beacon config file.
|
|
170
|
+
Reads your schema and creates a documented template with
|
|
171
|
+
types, defaults, and descriptions for every variable.
|
|
172
|
+
|
|
173
|
+
${color.bold("OPTIONS")}
|
|
174
|
+
-c, --config <path> Path to config file
|
|
175
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
176
|
+
-o, --output <path> Output file for init
|
|
177
|
+
${color.dim("(default: .env.example)")}
|
|
178
|
+
--profile <name> Profile to merge (staging, production, etc.)
|
|
179
|
+
|
|
180
|
+
${color.bold("EXAMPLES")}
|
|
181
|
+
beacon init ${color.grey("# generate .env.example")}
|
|
182
|
+
beacon init --profile production ${color.grey("# merge production profile")}
|
|
183
|
+
beacon init -c ./config/beacon.json ${color.grey("# custom config path")}
|
|
184
|
+
`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (command === "check") {
|
|
189
|
+
console.log(`
|
|
190
|
+
${color.bold("USAGE")}
|
|
191
|
+
beacon check [options]
|
|
192
|
+
|
|
193
|
+
${color.bold("DESCRIPTION")}
|
|
194
|
+
Validate the current process.env against your schema.
|
|
195
|
+
Reports missing, invalid, and optional variables.
|
|
196
|
+
|
|
197
|
+
${color.bold("OPTIONS")}
|
|
198
|
+
-c, --config <path> Path to config file
|
|
199
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
200
|
+
--profile <name> Profile to merge (staging, production, etc.)
|
|
201
|
+
|
|
202
|
+
${color.bold("EXIT CODES")}
|
|
203
|
+
0 All variables pass validation
|
|
204
|
+
1 One or more variables are missing or invalid
|
|
205
|
+
|
|
206
|
+
${color.bold("EXAMPLES")}
|
|
207
|
+
beacon check ${color.grey("# validate env")}
|
|
208
|
+
beacon check --profile production ${color.grey("# validate with profile")}
|
|
209
|
+
beacon check -c ./config/beacon.json ${color.grey("# custom config path")}
|
|
210
|
+
`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
console.log(`
|
|
215
|
+
${color.bold("beacon")} ${color.dim("- validate env vars, config, secrets, and feature gates")}
|
|
216
|
+
|
|
217
|
+
${color.bold("USAGE")}
|
|
218
|
+
beacon <command> [options]
|
|
219
|
+
|
|
220
|
+
${color.bold("COMMANDS")}
|
|
221
|
+
init ${color.dim("Generate .env.example from your config")}
|
|
222
|
+
check ${color.dim("Validate current environment against your schema")}
|
|
223
|
+
help ${color.dim("Show help for a specific command")}
|
|
224
|
+
|
|
225
|
+
${color.bold("OPTIONS")}
|
|
226
|
+
-c, --config <path> Path to config file (default: .beaconrc.json or beacon.config.json)
|
|
227
|
+
-o, --output <path> Output file for init (default: .env.example)
|
|
228
|
+
--profile <name> Profile name to use (e.g. staging, production)
|
|
229
|
+
|
|
230
|
+
${color.bold("EXIT CODES")}
|
|
231
|
+
0 Success
|
|
232
|
+
1 Validation failure or error
|
|
233
|
+
|
|
234
|
+
${color.bold("EXAMPLES")}
|
|
235
|
+
beacon init
|
|
236
|
+
beacon init --profile production
|
|
237
|
+
beacon check
|
|
238
|
+
beacon check -c ./config/beacon.json --profile staging
|
|
239
|
+
beacon help init
|
|
240
|
+
`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await main();
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class ConfigError extends Error {
|
|
2
|
+
readonly key: string;
|
|
3
|
+
readonly redacted: boolean;
|
|
4
|
+
|
|
5
|
+
constructor(key: string, message: string, redacted = false) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "ConfigError";
|
|
8
|
+
this.key = key;
|
|
9
|
+
this.redacted = redacted;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ConfigValidationError extends AggregateError {
|
|
14
|
+
constructor(errors: ConfigError[]) {
|
|
15
|
+
super(errors, "Configuration validation failed");
|
|
16
|
+
this.name = "ConfigValidationError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { expect, test, beforeAll } from "bun:test";
|
|
2
|
+
import { createBeacon, ConfigValidationError } from "./index";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const zodStringMin3 = z.string().min(3);
|
|
6
|
+
|
|
7
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
process.env = { ...ORIGINAL_ENV };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("returns typed config values after ensure()", () => {
|
|
14
|
+
process.env.DATABASE_URL = "https://example.com/db";
|
|
15
|
+
process.env.REDIS_URL = "https://example.com/redis";
|
|
16
|
+
|
|
17
|
+
const config = createBeacon({
|
|
18
|
+
DATABASE_URL: { type: "url", required: true },
|
|
19
|
+
REDIS_URL: { type: "url", required: true },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
config.ensure();
|
|
23
|
+
expect(config.get<string>("DATABASE_URL")).toBe("https://example.com/db");
|
|
24
|
+
expect(config.get<string>("REDIS_URL")).toBe("https://example.com/redis");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("throws ValidationError for missing required vars", () => {
|
|
28
|
+
delete process.env.MISSING_VAR;
|
|
29
|
+
|
|
30
|
+
const config = createBeacon({
|
|
31
|
+
MISSING_VAR: { type: "string", required: true },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(() => config.ensure()).toThrow(ConfigValidationError);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("does not throw for optional vars with defaults", () => {
|
|
38
|
+
delete process.env.MY_PORT;
|
|
39
|
+
|
|
40
|
+
const config = createBeacon({
|
|
41
|
+
MY_PORT: { type: "port", default: 3000 },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
config.ensure();
|
|
45
|
+
expect(config.get<number>("MY_PORT")).toBe(3000);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("coerces number types", () => {
|
|
49
|
+
process.env.MY_NUMBER = "42";
|
|
50
|
+
|
|
51
|
+
const config = createBeacon({
|
|
52
|
+
MY_NUMBER: { type: "number" },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
config.ensure();
|
|
56
|
+
expect(config.get<number>("MY_NUMBER")).toBe(42);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("coerces boolean types", () => {
|
|
60
|
+
process.env.FEATURE_X = "true";
|
|
61
|
+
process.env.FEATURE_Y = "false";
|
|
62
|
+
process.env.FEATURE_Z = "1";
|
|
63
|
+
|
|
64
|
+
const config = createBeacon({
|
|
65
|
+
FEATURE_X: { type: "boolean" },
|
|
66
|
+
FEATURE_Y: { type: "boolean" },
|
|
67
|
+
FEATURE_Z: { type: "boolean" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
config.ensure();
|
|
71
|
+
expect(config.get<boolean>("FEATURE_X")).toBe(true);
|
|
72
|
+
expect(config.get<boolean>("FEATURE_Y")).toBe(false);
|
|
73
|
+
expect(config.get<boolean>("FEATURE_Z")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("validates enum values", () => {
|
|
77
|
+
process.env.NODE_ENV = "production";
|
|
78
|
+
|
|
79
|
+
const config = createBeacon({
|
|
80
|
+
NODE_ENV: {
|
|
81
|
+
type: "enum",
|
|
82
|
+
values: ["development", "staging", "production"],
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
config.ensure();
|
|
87
|
+
expect(config.get<string>("NODE_ENV")).toBe("production");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("throws for invalid enum values", () => {
|
|
91
|
+
process.env.NODE_ENV = "invalid";
|
|
92
|
+
|
|
93
|
+
const config = createBeacon({
|
|
94
|
+
NODE_ENV: {
|
|
95
|
+
type: "enum",
|
|
96
|
+
values: ["development", "production"],
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(() => config.ensure()).toThrow();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("validates port range", () => {
|
|
104
|
+
process.env.PORT = "99999";
|
|
105
|
+
|
|
106
|
+
const config = createBeacon({
|
|
107
|
+
PORT: { type: "port" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(() => config.ensure()).toThrow();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("throws when accessing before ensure()", () => {
|
|
114
|
+
const config = createBeacon({
|
|
115
|
+
DB_URL: { type: "url", required: true },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(() => config.get("DB_URL")).toThrow("Call beacon.ensure()");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("uses profile overrides when profile is set", () => {
|
|
122
|
+
process.env.DB_HOST = "prod.example.com";
|
|
123
|
+
|
|
124
|
+
const config = createBeacon(
|
|
125
|
+
{
|
|
126
|
+
DB_HOST: { type: "string" },
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
profile: "production",
|
|
130
|
+
profiles: {
|
|
131
|
+
production: {
|
|
132
|
+
DB_HOST: { type: "host" },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
config.ensure();
|
|
139
|
+
expect(config.get<string>("DB_HOST")).toBe("prod.example.com");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("accepts Zod schemas directly", () => {
|
|
143
|
+
process.env.ZOD_VAR = "hello";
|
|
144
|
+
|
|
145
|
+
const config = createBeacon({
|
|
146
|
+
ZOD_VAR: { schema: zodStringMin3 },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
config.ensure();
|
|
150
|
+
expect(config.get<string>("ZOD_VAR")).toBe("hello");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("collects all errors before throwing", () => {
|
|
154
|
+
delete process.env.VAR_A;
|
|
155
|
+
delete process.env.VAR_B;
|
|
156
|
+
|
|
157
|
+
const config = createBeacon({
|
|
158
|
+
VAR_A: { type: "string", required: true },
|
|
159
|
+
VAR_B: { type: "number", required: true },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
config.ensure();
|
|
164
|
+
expect.unreachable();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
expect(err).toBeInstanceOf(ConfigValidationError);
|
|
167
|
+
expect((err as ConfigValidationError).errors).toHaveLength(2);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("tracks secret keys", () => {
|
|
172
|
+
const config = createBeacon({
|
|
173
|
+
API_KEY: { type: "string", secret: true },
|
|
174
|
+
DB_URL: { type: "url" },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(config.secret).toEqual({ API_KEY: true });
|
|
178
|
+
});
|