@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Remba
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<img alt="@joinremba/beacon" src="./assets/logo.svg" width="80">
|
|
4
|
+
</picture>
|
|
5
|
+
<br>
|
|
6
|
+
<strong>@joinremba/beacon</strong>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/@joinremba/beacon"><img src="https://img.shields.io/npm/v/@joinremba/beacon.svg" alt="npm version"></a>
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/@joinremba/beacon.svg" alt="Licence"></a>
|
|
12
|
+
<a href="https://github.com/joinremba/beacon/actions/workflows/ci.yml"><img src="https://github.com/joinremba/beacon/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/Bun-%3E%3D1.3.1-black?logo=bun" alt="Bun">
|
|
14
|
+
<img src="https://img.shields.io/badge/TypeScript-6-blue" alt="TypeScript">
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
Beacon helps TypeScript teams boot applications safely by validating environment variables, config, secrets, and runtime feature gates before production breaks.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
bun add @joinremba/beacon
|
|
21
|
+
bunx beacon init
|
|
22
|
+
bunx beacon check
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { createBeacon } from "@joinremba/beacon";
|
|
31
|
+
|
|
32
|
+
const config = createBeacon({
|
|
33
|
+
DATABASE_URL: { type: "url", required: true },
|
|
34
|
+
REDIS_URL: { type: "url", required: true },
|
|
35
|
+
NODE_ENV: {
|
|
36
|
+
type: "enum",
|
|
37
|
+
values: ["development", "test", "staging", "production"],
|
|
38
|
+
default: "development",
|
|
39
|
+
},
|
|
40
|
+
PORT: { type: "port", default: 3000 },
|
|
41
|
+
API_KEY: { type: "string", required: true, secret: true },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
config.ensure();
|
|
45
|
+
|
|
46
|
+
const dbUrl = config.get<string>("DATABASE_URL");
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If any variable is missing or invalid, `ensure()` throws a `ConfigValidationError` with **all issues collected at once** — so you fix everything in a single pass, not iteratively.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Why Beacon?
|
|
54
|
+
|
|
55
|
+
Most backend projects start with a scattered collection of helper functions for reading env vars, checking types, and remembering which vars are required. This works until:
|
|
56
|
+
|
|
57
|
+
- A new developer joins and doesn't know which env vars exist
|
|
58
|
+
- A staging environment crashes because a required var was renamed but not documented
|
|
59
|
+
- A secret leaks into an error log because nobody added redaction
|
|
60
|
+
- Your CI pipeline passes locally but fails in production due to config drift
|
|
61
|
+
|
|
62
|
+
Beacon solves these by giving you a **single source of truth** for your env schema, with built-in validation, secrets redaction, profile support, and CLI tools for generating `.env.example` and checking environments.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- **Schema-based validation** — Define your env schema with simple string types (`"url"`, `"port"`, `"enum"`, etc.) or raw Zod schemas.
|
|
69
|
+
- **Missing variable detection** — All errors are collected and reported together, not one at a time.
|
|
70
|
+
- **Secrets redaction** — Keep secrets out of logs and error messages automatically. Values are replaced with `[REDACTED]`.
|
|
71
|
+
- **Local/staging/production profiles** — Define different schemas per environment.
|
|
72
|
+
- **`.env.example` generation** — Generate a documented `.env.example` from your schema via the CLI.
|
|
73
|
+
- **CLI** — `beacon init` scaffolds config, `beacon check` validates before deploying.
|
|
74
|
+
- **Zero runtime overhead** — Validations run once at boot. After that, access is plain property reads.
|
|
75
|
+
- **Framework-agnostic** — Works with Bun, Node.js, Express, Hono, Fastify, Next.js, Elysia.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## CLI
|
|
80
|
+
|
|
81
|
+
Beacon ships with a CLI for development and CI workflows.
|
|
82
|
+
|
|
83
|
+
### `beacon init`
|
|
84
|
+
|
|
85
|
+
Generate a documented `.env.example` from your beacon config:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
bunx beacon init
|
|
89
|
+
|
|
90
|
+
# With a production profile:
|
|
91
|
+
bunx beacon init --profile production
|
|
92
|
+
|
|
93
|
+
# Custom config path:
|
|
94
|
+
bunx beacon init -c ./config/beacon.json -o .env.example.prod
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Output includes types, defaults, descriptions, and secret markers for every variable:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
# PostgreSQL connection string
|
|
101
|
+
# Type: url
|
|
102
|
+
# Required: yes
|
|
103
|
+
# DATABASE_URL=
|
|
104
|
+
|
|
105
|
+
# HTTP server port
|
|
106
|
+
# Type: port
|
|
107
|
+
# Default: 3000
|
|
108
|
+
PORT=3000
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### `beacon check`
|
|
112
|
+
|
|
113
|
+
Validate your current environment against your schema:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
bunx beacon check
|
|
117
|
+
|
|
118
|
+
# With a specific profile:
|
|
119
|
+
bunx beacon check --profile staging
|
|
120
|
+
|
|
121
|
+
# Custom config:
|
|
122
|
+
bunx beacon check -c ./config/production.json
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Output is a colour-coded table:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
KEY STATUS VALUE
|
|
129
|
+
──────────── ──────── ────────────────────
|
|
130
|
+
DATABASE_URL pass postgres://localhost...
|
|
131
|
+
PORT pass Using default value
|
|
132
|
+
NODE_ENV pass Using default value
|
|
133
|
+
API_KEY MISSING Not set
|
|
134
|
+
LOG_LEVEL pass Optional, not set
|
|
135
|
+
DB_HOST MISSING Not set
|
|
136
|
+
Did you mean DB_HOSTNAME?
|
|
137
|
+
|
|
138
|
+
2 issue(s), 3 pass
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Exit codes: `0` if all pass, `1` if any issues found.
|
|
142
|
+
|
|
143
|
+
### Per-command help
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
beacon help init
|
|
147
|
+
beacon check --help
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## API Reference
|
|
153
|
+
|
|
154
|
+
### `createBeacon(schema, options?)`
|
|
155
|
+
|
|
156
|
+
The default export. Accepts an env schema and optional configuration.
|
|
157
|
+
|
|
158
|
+
**Parameters**
|
|
159
|
+
|
|
160
|
+
| Option | Type | Description |
|
|
161
|
+
| ---------- | --------------------------------------------- | ----------------------------------------------------------- |
|
|
162
|
+
| `schema` | `Record<string, SchemaEntry>` | Map of environment variable names to field definitions. |
|
|
163
|
+
| `profile` | `string` | Active profile name. Merges matching entry from `profiles`. |
|
|
164
|
+
| `profiles` | `Record<string, Record<string, SchemaEntry>>` | Named profile overrides. |
|
|
165
|
+
|
|
166
|
+
**SchemaEntry** can be either:
|
|
167
|
+
|
|
168
|
+
**1. String-based** — Simple type names for everyday use:
|
|
169
|
+
|
|
170
|
+
| Field | Type | Default | Description |
|
|
171
|
+
| ------------- | ----------- | ------- | ------------------------------------ |
|
|
172
|
+
| `type` | `FieldType` | — | The type to validate against. |
|
|
173
|
+
| `required` | `boolean` | `true` | Whether the variable must be set. |
|
|
174
|
+
| `default` | `unknown` | — | Default value if not set. |
|
|
175
|
+
| `secret` | `boolean` | `false` | Redact value from errors and logs. |
|
|
176
|
+
| `values` | `string[]` | — | Allowed values (only for `"enum"`). |
|
|
177
|
+
| `description` | `string` | — | Used when generating `.env.example`. |
|
|
178
|
+
|
|
179
|
+
| Type | Zod equivalent |
|
|
180
|
+
| --------- | -------------------------------------------- |
|
|
181
|
+
| `string` | `z.string()` |
|
|
182
|
+
| `url` | `z.string().url()` |
|
|
183
|
+
| `number` | `z.coerce.number()` |
|
|
184
|
+
| `integer` | `z.coerce.number().int()` |
|
|
185
|
+
| `boolean` | `"true"` / `"false"` / `"1"` / `"0"` coerced |
|
|
186
|
+
| `port` | integer 1–65535 |
|
|
187
|
+
| `enum` | requires `values[]` |
|
|
188
|
+
| `email` | `z.string().email()` |
|
|
189
|
+
| `host` | `z.string()` |
|
|
190
|
+
|
|
191
|
+
**2. Zod schema** — Advanced users can pass Zod schemas directly:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
{
|
|
195
|
+
PORT: { schema: z.coerce.number().positive().max(9999) },
|
|
196
|
+
WHITELIST: { schema: z.string().regex(/^[\d,]+$/) },
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Returns**
|
|
201
|
+
|
|
202
|
+
A config instance with:
|
|
203
|
+
|
|
204
|
+
| Method / Property | Description |
|
|
205
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
206
|
+
| `ensure()` | Validates all env vars. Throws `ConfigValidationError` on failure. Returns the config instance for chaining. |
|
|
207
|
+
| `get<T>(key): T` | Returns the validated value for the given key. Throws if called before `ensure()`. |
|
|
208
|
+
| `secret` | Returns a `Record<string, boolean>` of which keys are marked as secrets. |
|
|
209
|
+
|
|
210
|
+
### TypeScript Types
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import type {
|
|
214
|
+
BeaconOptions,
|
|
215
|
+
Beacon,
|
|
216
|
+
SchemaEntry,
|
|
217
|
+
FieldDefinition,
|
|
218
|
+
FieldType,
|
|
219
|
+
ConfigError,
|
|
220
|
+
ConfigValidationError,
|
|
221
|
+
} from "@joinremba/beacon";
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Config file (`.beaconrc.json`)
|
|
225
|
+
|
|
226
|
+
Used by the CLI for `init` and `check` commands:
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{
|
|
230
|
+
"schema": {
|
|
231
|
+
"DATABASE_URL": {
|
|
232
|
+
"type": "url",
|
|
233
|
+
"required": true,
|
|
234
|
+
"description": "PostgreSQL connection string"
|
|
235
|
+
},
|
|
236
|
+
"PORT": { "type": "port", "default": 3000, "description": "HTTP server port" },
|
|
237
|
+
"NODE_ENV": {
|
|
238
|
+
"type": "enum",
|
|
239
|
+
"values": ["development", "production"],
|
|
240
|
+
"default": "development"
|
|
241
|
+
},
|
|
242
|
+
"API_KEY": { "type": "string", "required": true, "secret": true }
|
|
243
|
+
},
|
|
244
|
+
"profiles": {
|
|
245
|
+
"production": {
|
|
246
|
+
"DB_HOST": { "type": "host", "required": true, "description": "Production DB hostname" }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Examples
|
|
255
|
+
|
|
256
|
+
### Basic env validation
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import { createBeacon } from "@joinremba/beacon";
|
|
260
|
+
|
|
261
|
+
const config = createBeacon({
|
|
262
|
+
NODE_ENV: {
|
|
263
|
+
type: "enum",
|
|
264
|
+
values: ["development", "production", "test"],
|
|
265
|
+
default: "development",
|
|
266
|
+
},
|
|
267
|
+
PORT: { type: "port", default: 3000 },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
config.ensure();
|
|
271
|
+
console.log(config.get("PORT"));
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### With secrets redaction
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
const config = createBeacon({
|
|
278
|
+
API_KEY: { type: "string", secret: true },
|
|
279
|
+
DATABASE_URL: { type: "url", secret: true },
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
config.ensure();
|
|
283
|
+
// Error messages never show API_KEY or DATABASE_URL values
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Custom error handling
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
import { ConfigValidationError } from "@joinremba/beacon";
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
config.ensure();
|
|
293
|
+
} catch (err) {
|
|
294
|
+
if (err instanceof ConfigValidationError) {
|
|
295
|
+
for (const issue of err.errors) {
|
|
296
|
+
console.error(`[${issue.key}] ${issue.message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Production profile
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const config = createBeacon(
|
|
307
|
+
{
|
|
308
|
+
DB_HOST: { type: "string", default: "localhost" },
|
|
309
|
+
DB_PORT: { type: "port", default: 5432 },
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
profile: "production",
|
|
313
|
+
profiles: {
|
|
314
|
+
production: {
|
|
315
|
+
DB_HOST: { type: "host", required: true },
|
|
316
|
+
DB_PORT: { type: "port", required: true },
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Roadmap
|
|
326
|
+
|
|
327
|
+
**MVP** (current)
|
|
328
|
+
|
|
329
|
+
- Typed env validation with string-based types and Zod
|
|
330
|
+
- Missing variable detection (aggregated errors)
|
|
331
|
+
- Secrets redaction in errors and logs
|
|
332
|
+
- Local/staging/production profiles
|
|
333
|
+
- `.env.example` generation via CLI
|
|
334
|
+
- `beacon check` CLI command
|
|
335
|
+
- Coloured CLI output with suggestions
|
|
336
|
+
|
|
337
|
+
**V1**
|
|
338
|
+
|
|
339
|
+
- Feature gates from local config
|
|
340
|
+
- Kill-switch flags
|
|
341
|
+
- Encrypted `.env` support
|
|
342
|
+
- Secret rotation checklist
|
|
343
|
+
- CI validation action
|
|
344
|
+
- Docker/Kubernetes env checks
|
|
345
|
+
- Config drift detection
|
|
346
|
+
|
|
347
|
+
**V2**
|
|
348
|
+
|
|
349
|
+
- Hosted team secret sync
|
|
350
|
+
- Audit trail for config changes
|
|
351
|
+
- Deployment provider integrations
|
|
352
|
+
- GitHub Actions integration
|
|
353
|
+
- Remba Cloud dashboard
|
|
354
|
+
|
|
355
|
+
---
|
|
356
|
+
|
|
357
|
+
## Related Packages
|
|
358
|
+
|
|
359
|
+
- [@joinremba/catalog](https://github.com/joinremba/catalog) — Production-ready logging and error event layer built on Pino.
|
|
360
|
+
- [@joinremba/gate](https://github.com/joinremba/gate) — API safety layer: validation, responses, idempotency, rate limiting, and API keys.
|
|
361
|
+
|
|
362
|
+
## Social Preview
|
|
363
|
+
|
|
364
|
+
For sharing on X (Twitter) and other social platforms, use `assets/og-image.svg` as the repository's social preview image in GitHub repo settings:
|
|
365
|
+
|
|
366
|
+
1. Go to your repo **Settings** → **Social preview** → **Upload image**
|
|
367
|
+
2. Select `assets/og-image.svg`
|
|
368
|
+
3. Save
|
|
369
|
+
|
|
370
|
+
This will be used whenever your repo link is shared on social media, Slack, or Discord.
|
|
371
|
+
|
|
372
|
+
## Contributing
|
|
373
|
+
|
|
374
|
+
Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, development process, and how to submit pull requests.
|
|
375
|
+
|
|
376
|
+
## License
|
|
377
|
+
|
|
378
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@joinremba/beacon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Validate environment variables, config, secrets, and runtime feature gates before production breaks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"beacon": "src/cli.ts"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"import": "./src/index.ts",
|
|
15
|
+
"default": "./src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"./cli": {
|
|
18
|
+
"types": "./src/cli.ts",
|
|
19
|
+
"import": "./src/cli.ts",
|
|
20
|
+
"default": "./src/cli.ts"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun --format esm",
|
|
30
|
+
"dev": "bun --watch ./src/index.ts",
|
|
31
|
+
"format": "prettier --write .",
|
|
32
|
+
"format:check": "prettier --check .",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"lint:fix": "eslint . --fix",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "bun test",
|
|
37
|
+
"test:watch": "bun test --watch",
|
|
38
|
+
"prepublishOnly": "bun run build",
|
|
39
|
+
"check": "bun lint && bun format:check && bun typecheck && bun test"
|
|
40
|
+
},
|
|
41
|
+
"author": {
|
|
42
|
+
"name": "Benson Isaac",
|
|
43
|
+
"email": "bensxnisaac@gmail.com"
|
|
44
|
+
},
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/joinremba/beacon.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/joinremba/beacon/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/joinremba/beacon#readme",
|
|
54
|
+
"keywords": [
|
|
55
|
+
"env",
|
|
56
|
+
"environment",
|
|
57
|
+
"config",
|
|
58
|
+
"validation",
|
|
59
|
+
"feature-gates",
|
|
60
|
+
"secrets",
|
|
61
|
+
"type-safe",
|
|
62
|
+
"cli"
|
|
63
|
+
],
|
|
64
|
+
"publishConfig": {
|
|
65
|
+
"access": "public"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"bun": ">=1.3.1"
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"zod": "^4.4.2"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@types/bun": "latest",
|
|
75
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
76
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
77
|
+
"eslint": "^8.57.1",
|
|
78
|
+
"eslint-config-prettier": "^10.1.8",
|
|
79
|
+
"prettier": "^3.8.3",
|
|
80
|
+
"typescript": "^6.0.3"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { SchemaEntry } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface BeaconConfigFile {
|
|
5
|
+
schema: Record<string, SchemaEntry>;
|
|
6
|
+
profile?: string;
|
|
7
|
+
profiles?: Record<string, Record<string, SchemaEntry>>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function loadConfig(path?: string): Promise<BeaconConfigFile> {
|
|
11
|
+
const searchPaths = path ? [path] : [".beaconrc.json", "beacon.config.json"];
|
|
12
|
+
|
|
13
|
+
for (const searchPath of searchPaths) {
|
|
14
|
+
const file = Bun.file(searchPath);
|
|
15
|
+
const exists = await file.exists();
|
|
16
|
+
if (exists) {
|
|
17
|
+
const text = await file.text();
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(text);
|
|
20
|
+
|
|
21
|
+
if (!parsed.schema || typeof parsed.schema !== "object") {
|
|
22
|
+
throw new Error(`Config file "${searchPath}" must have a "schema" field`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const config: BeaconConfigFile = { schema: parsed.schema };
|
|
26
|
+
|
|
27
|
+
if (parsed.profiles) {
|
|
28
|
+
config.profiles = parsed.profiles;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return config;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err instanceof SyntaxError) {
|
|
34
|
+
throw new Error(`Invalid JSON in config file "${searchPath}": ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error(
|
|
42
|
+
path
|
|
43
|
+
? `Config file not found: ${path}`
|
|
44
|
+
: "No config file found. Create a .beaconrc.json or pass --config <path>"
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function generateEnvExample(config: BeaconConfigFile, activeProfile?: string): string {
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
lines.push("# Environment Variables");
|
|
51
|
+
lines.push(`# Generated by @joinremba/beacon`);
|
|
52
|
+
lines.push(`# Profile: ${activeProfile ?? "default"}`);
|
|
53
|
+
lines.push("");
|
|
54
|
+
|
|
55
|
+
const mergedSchema = { ...config.schema };
|
|
56
|
+
|
|
57
|
+
if (activeProfile && config.profiles?.[activeProfile]) {
|
|
58
|
+
Object.assign(mergedSchema, config.profiles[activeProfile]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [key, entry] of Object.entries(mergedSchema)) {
|
|
62
|
+
if (entry.description) {
|
|
63
|
+
lines.push(`# ${entry.description}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isField = "type" in entry;
|
|
67
|
+
const isSchema = "schema" in entry;
|
|
68
|
+
|
|
69
|
+
if (isField) {
|
|
70
|
+
const field = entry as {
|
|
71
|
+
type: string;
|
|
72
|
+
required?: boolean;
|
|
73
|
+
default?: unknown;
|
|
74
|
+
values?: readonly string[];
|
|
75
|
+
secret?: boolean;
|
|
76
|
+
};
|
|
77
|
+
const typeInfo = field.values ? `${field.type} (${field.values.join(" | ")})` : field.type;
|
|
78
|
+
lines.push(`# Type: ${typeInfo}`);
|
|
79
|
+
if (field.required !== false) {
|
|
80
|
+
lines.push(`# Required: yes`);
|
|
81
|
+
} else {
|
|
82
|
+
lines.push(`# Required: no`);
|
|
83
|
+
}
|
|
84
|
+
if (field.default !== undefined) {
|
|
85
|
+
lines.push(`# Default: ${field.default}`);
|
|
86
|
+
}
|
|
87
|
+
if (field.secret) {
|
|
88
|
+
lines.push(`# Secret: yes`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (field.default !== undefined) {
|
|
92
|
+
lines.push(`${key}=${field.default}`);
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(`# ${key}=`);
|
|
95
|
+
}
|
|
96
|
+
} else if (isSchema) {
|
|
97
|
+
const sEntry = entry as { required?: boolean; secret?: boolean; description?: string };
|
|
98
|
+
if (sEntry.required !== false) {
|
|
99
|
+
lines.push(`# Required: yes`);
|
|
100
|
+
} else {
|
|
101
|
+
lines.push(`# Required: no`);
|
|
102
|
+
}
|
|
103
|
+
if (sEntry.secret) {
|
|
104
|
+
lines.push(`# Secret: yes`);
|
|
105
|
+
}
|
|
106
|
+
lines.push(`# ${key}=`);
|
|
107
|
+
} else {
|
|
108
|
+
lines.push(`# ${key}=`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
lines.push("");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const typeToSchema = (entry: Record<string, unknown>): z.ZodType<unknown> => {
|
|
118
|
+
const type = entry.type as string;
|
|
119
|
+
let base: z.ZodType<unknown>;
|
|
120
|
+
|
|
121
|
+
switch (type) {
|
|
122
|
+
case "string":
|
|
123
|
+
case "host":
|
|
124
|
+
base = z.string();
|
|
125
|
+
break;
|
|
126
|
+
case "url":
|
|
127
|
+
base = z.string().url();
|
|
128
|
+
break;
|
|
129
|
+
case "number":
|
|
130
|
+
base = z.coerce.number();
|
|
131
|
+
break;
|
|
132
|
+
case "integer":
|
|
133
|
+
base = z.coerce.number().int();
|
|
134
|
+
break;
|
|
135
|
+
case "boolean":
|
|
136
|
+
base = z
|
|
137
|
+
.string()
|
|
138
|
+
.transform((v) => v === "true" || v === "1")
|
|
139
|
+
.pipe(z.boolean());
|
|
140
|
+
break;
|
|
141
|
+
case "enum":
|
|
142
|
+
base = z.enum((entry.values ?? []) as unknown as [string, ...string[]]);
|
|
143
|
+
break;
|
|
144
|
+
case "port":
|
|
145
|
+
base = z.coerce.number().int().min(1).max(65535);
|
|
146
|
+
break;
|
|
147
|
+
case "email":
|
|
148
|
+
base = z.string().email();
|
|
149
|
+
break;
|
|
150
|
+
default:
|
|
151
|
+
base = z.string();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (entry.default !== undefined) {
|
|
155
|
+
base = base.default(entry.default as string | number | boolean);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return base;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export async function runCheck(
|
|
162
|
+
config: BeaconConfigFile,
|
|
163
|
+
activeProfile?: string
|
|
164
|
+
): Promise<{
|
|
165
|
+
results: Array<{ key: string; status: "ok" | "missing" | "invalid"; message: string }>;
|
|
166
|
+
errors: Array<{ key: string; message: string }>;
|
|
167
|
+
}> {
|
|
168
|
+
const results: Array<{ key: string; status: "ok" | "missing" | "invalid"; message: string }> = [];
|
|
169
|
+
const errors: Array<{ key: string; message: string }> = [];
|
|
170
|
+
|
|
171
|
+
const mergedSchema = { ...config.schema };
|
|
172
|
+
if (activeProfile && config.profiles?.[activeProfile]) {
|
|
173
|
+
Object.assign(mergedSchema, config.profiles[activeProfile]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [key, entry] of Object.entries(mergedSchema)) {
|
|
177
|
+
const raw = process.env[key];
|
|
178
|
+
const isField = "type" in entry;
|
|
179
|
+
|
|
180
|
+
let required = true;
|
|
181
|
+
let hasDefault = false;
|
|
182
|
+
|
|
183
|
+
if (isField) {
|
|
184
|
+
const field = entry as { required?: boolean; default?: unknown };
|
|
185
|
+
required = field.required !== false;
|
|
186
|
+
hasDefault = field.default !== undefined;
|
|
187
|
+
} else {
|
|
188
|
+
const f = entry as { required?: boolean };
|
|
189
|
+
required = f.required !== false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (raw === undefined || raw === "") {
|
|
193
|
+
if (hasDefault) {
|
|
194
|
+
results.push({ key, status: "ok", message: "Using default value" });
|
|
195
|
+
} else if (!required) {
|
|
196
|
+
results.push({ key, status: "ok", message: "Optional, not set" });
|
|
197
|
+
} else {
|
|
198
|
+
results.push({ key, status: "missing", message: "Not set" });
|
|
199
|
+
errors.push({ key, message: "Missing required environment variable" });
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
if (isField) {
|
|
206
|
+
const schema = typeToSchema(entry as unknown as Record<string, unknown>);
|
|
207
|
+
schema.parse(raw);
|
|
208
|
+
}
|
|
209
|
+
results.push({
|
|
210
|
+
key,
|
|
211
|
+
status: "ok",
|
|
212
|
+
message: `Set (${raw.length > 25 ? raw.substring(0, 25) + "..." : raw})`,
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
results.push({ key, status: "invalid", message: `Invalid: "${raw}"` });
|
|
216
|
+
errors.push({ key, message: `Invalid value: ${raw}` });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { results, errors };
|
|
221
|
+
}
|