@joinremba/beacon 0.1.0 → 0.4.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/README.md +121 -12
- package/package.json +2 -1
- package/src/cli-config.ts +15 -49
- package/src/cli.test.ts +51 -1
- package/src/cli.ts +407 -22
- package/src/encryption.test.ts +24 -0
- package/src/encryption.ts +59 -0
- package/src/index.test.ts +190 -30
- package/src/index.ts +95 -71
- package/src/schema.ts +56 -0
- package/src/types.ts +19 -1
package/README.md
CHANGED
|
@@ -162,6 +162,7 @@ The default export. Accepts an env schema and optional configuration.
|
|
|
162
162
|
| `schema` | `Record<string, SchemaEntry>` | Map of environment variable names to field definitions. |
|
|
163
163
|
| `profile` | `string` | Active profile name. Merges matching entry from `profiles`. |
|
|
164
164
|
| `profiles` | `Record<string, Record<string, SchemaEntry>>` | Named profile overrides. |
|
|
165
|
+
| `features` | `Record<string, FeatureGate>` | Feature gates for runtime toggles. |
|
|
165
166
|
|
|
166
167
|
**SchemaEntry** can be either:
|
|
167
168
|
|
|
@@ -201,11 +202,12 @@ The default export. Accepts an env schema and optional configuration.
|
|
|
201
202
|
|
|
202
203
|
A config instance with:
|
|
203
204
|
|
|
204
|
-
| Method / Property
|
|
205
|
-
|
|
|
206
|
-
| `ensure()`
|
|
207
|
-
| `get<T>(key): T`
|
|
208
|
-
| `secret`
|
|
205
|
+
| Method / Property | Description |
|
|
206
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
207
|
+
| `ensure()` | Validates all env vars. Throws `ConfigValidationError` on failure. Returns the config instance for chaining. |
|
|
208
|
+
| `get<T>(key): T` | Returns the validated value for the given key. Throws if called before `ensure()`. |
|
|
209
|
+
| `secret` | Returns a `Record<string, boolean>` of which keys are marked as secrets. |
|
|
210
|
+
| `isEnabled(feature): boolean` | Checks if a feature gate is enabled. Respects env overrides (`FEATURE_<NAME>`). |
|
|
209
211
|
|
|
210
212
|
### TypeScript Types
|
|
211
213
|
|
|
@@ -216,6 +218,7 @@ import type {
|
|
|
216
218
|
SchemaEntry,
|
|
217
219
|
FieldDefinition,
|
|
218
220
|
FieldType,
|
|
221
|
+
FeatureGate,
|
|
219
222
|
ConfigError,
|
|
220
223
|
ConfigValidationError,
|
|
221
224
|
} from "@joinremba/beacon";
|
|
@@ -245,6 +248,10 @@ Used by the CLI for `init` and `check` commands:
|
|
|
245
248
|
"production": {
|
|
246
249
|
"DB_HOST": { "type": "host", "required": true, "description": "Production DB hostname" }
|
|
247
250
|
}
|
|
251
|
+
},
|
|
252
|
+
"features": {
|
|
253
|
+
"newDashboard": { "enabled": true, "description": "New dashboard UI" },
|
|
254
|
+
"darkMode": { "enabled": false }
|
|
248
255
|
}
|
|
249
256
|
}
|
|
250
257
|
```
|
|
@@ -320,6 +327,108 @@ const config = createBeacon(
|
|
|
320
327
|
);
|
|
321
328
|
```
|
|
322
329
|
|
|
330
|
+
### Feature Gates
|
|
331
|
+
|
|
332
|
+
Toggle features on/off without redeploying. Define gates in the `features` option and check them with `isEnabled()`:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
const config = createBeacon(
|
|
336
|
+
{ DATABASE_URL: { type: "url" } },
|
|
337
|
+
{
|
|
338
|
+
features: {
|
|
339
|
+
newDashboard: { enabled: true },
|
|
340
|
+
darkMode: { enabled: false },
|
|
341
|
+
gradualRollout: { enabled: true, rollout: 0.5 },
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
config.isEnabled("newDashboard"); // true
|
|
347
|
+
config.isEnabled("darkMode"); // false
|
|
348
|
+
config.isEnabled("gradualRollout"); // true for ~50% of deployments (deterministic hash)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**Env overrides** — Any feature can be toggled at runtime via `FEATURE_<NAME>`:
|
|
352
|
+
|
|
353
|
+
```sh
|
|
354
|
+
FEATURE_DARK_MODE=true bun start
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
CamelCase feature names map to `FEATURE_` prefixed uppercase with underscores (`newDashboard` → `FEATURE_NEW_DASHBOARD`). Accepted truthy values: `true`, `1`, `yes`. Everything else is `false`.
|
|
358
|
+
|
|
359
|
+
| Option | Type | Default | Description |
|
|
360
|
+
| ------------- | --------- | ------- | --------------------------------------------------------- |
|
|
361
|
+
| `enabled` | `boolean` | `false` | Whether the gate is on by default. |
|
|
362
|
+
| `rollout` | `number` | — | Percentage of deployments (0–1). Uses deterministic hash. |
|
|
363
|
+
| `description` | `string` | — | Human-readable description. |
|
|
364
|
+
|
|
365
|
+
### Kill-Switch Flags
|
|
366
|
+
|
|
367
|
+
Force-disable a feature at runtime — overrides any feature gate. Define kill switches in the `killSwitches` option or set `KILL_<NAME>` env vars:
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
const config = createBeacon(
|
|
371
|
+
{ DATABASE_URL: { type: "url" } },
|
|
372
|
+
{
|
|
373
|
+
features: { newDashboard: { enabled: true } },
|
|
374
|
+
killSwitches: { newDashboard: true },
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
config.isEnabled("newDashboard"); // false — killed
|
|
379
|
+
config.isKilled("newDashboard"); // true
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**Env override** — `KILL_NEW_DASHBOARD=true` overrides the config. Accepted truthy values: `true`, `1`, `yes`.
|
|
383
|
+
|
|
384
|
+
When a feature is killed, `isEnabled()` returns `false` regardless of the feature gate configuration.
|
|
385
|
+
|
|
386
|
+
### Encrypted .env (`beacon encrypt` / `beacon decrypt`)
|
|
387
|
+
|
|
388
|
+
Commit `.env` files safely using AES-256-GCM encryption. Requires an encryption key passed via `--key` or `BEACON_ENCRYPTION_KEY`.
|
|
389
|
+
|
|
390
|
+
```sh
|
|
391
|
+
# Encrypt .env → .env.encrypted
|
|
392
|
+
BEACON_ENCRYPTION_KEY=your-256-bit-key beacon encrypt
|
|
393
|
+
|
|
394
|
+
# Decrypt back to plaintext
|
|
395
|
+
BEACON_ENCRYPTION_KEY=your-256-bit-key beacon decrypt
|
|
396
|
+
|
|
397
|
+
# Custom paths
|
|
398
|
+
beacon encrypt -i .env.prod -o .env.prod.encrypted --key "your-key"
|
|
399
|
+
beacon decrypt -i .env.prod.encrypted -o .env.prod --key "your-key"
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Secret Rotation Checklist (`beacon rotate`)
|
|
403
|
+
|
|
404
|
+
Prints a step-by-step checklist for rotating secrets (DB credentials, API keys, etc.):
|
|
405
|
+
|
|
406
|
+
```sh
|
|
407
|
+
beacon rotate
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Follows the generate → deploy alongside → update consumers → verify → revoke → audit workflow.
|
|
411
|
+
|
|
412
|
+
### Config Drift Detection (`beacon drift`)
|
|
413
|
+
|
|
414
|
+
Detects when your actual environment differs from the schema defined in your config:
|
|
415
|
+
|
|
416
|
+
```sh
|
|
417
|
+
beacon drift
|
|
418
|
+
beacon drift --profile production
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Reports missing required variables, type mismatches, and unexpected enum values.
|
|
422
|
+
|
|
423
|
+
### Docker/Kubernetes Checks (`beacon docker`)
|
|
424
|
+
|
|
425
|
+
Validates your environment in container contexts — detects Docker and Kubernetes runtimes, checks common container env vars, and runs a full schema validation:
|
|
426
|
+
|
|
427
|
+
```sh
|
|
428
|
+
beacon docker
|
|
429
|
+
beacon docker --profile staging
|
|
430
|
+
```
|
|
431
|
+
|
|
323
432
|
---
|
|
324
433
|
|
|
325
434
|
## Roadmap
|
|
@@ -334,15 +443,15 @@ const config = createBeacon(
|
|
|
334
443
|
- `beacon check` CLI command
|
|
335
444
|
- Coloured CLI output with suggestions
|
|
336
445
|
|
|
337
|
-
**V1**
|
|
446
|
+
**V1** ✅
|
|
338
447
|
|
|
339
448
|
- 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
|
|
449
|
+
- Kill-switch flags (`KILL_<NAME>` env vars, `config.isKilled()`)
|
|
450
|
+
- Encrypted `.env` support (`beacon encrypt` / `beacon decrypt`)
|
|
451
|
+
- Secret rotation checklist (`beacon rotate`)
|
|
452
|
+
- CI validation action (`beacon check` in CI workflows)
|
|
453
|
+
- Docker/Kubernetes env checks (`beacon docker`)
|
|
454
|
+
- Config drift detection (`beacon drift`)
|
|
346
455
|
|
|
347
456
|
**V2**
|
|
348
457
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joinremba/beacon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Validate environment variables, config, secrets, and runtime feature gates before production breaks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"bun": ">=1.3.1"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
+
"@joinremba/core": "^0.4.0",
|
|
71
72
|
"zod": "^4.4.2"
|
|
72
73
|
},
|
|
73
74
|
"devDependencies": {
|
package/src/cli-config.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
1
|
+
import type { FeatureGate, SchemaEntry } from "./types";
|
|
2
|
+
import { typeToSchema, type SchemaField } from "./schema";
|
|
3
3
|
|
|
4
4
|
export interface BeaconConfigFile {
|
|
5
5
|
schema: Record<string, SchemaEntry>;
|
|
6
6
|
profile?: string;
|
|
7
7
|
profiles?: Record<string, Record<string, SchemaEntry>>;
|
|
8
|
+
features?: Record<string, FeatureGate>;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export async function loadConfig(path?: string): Promise<BeaconConfigFile> {
|
|
@@ -28,6 +29,10 @@ export async function loadConfig(path?: string): Promise<BeaconConfigFile> {
|
|
|
28
29
|
config.profiles = parsed.profiles;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
if (parsed.features) {
|
|
33
|
+
config.features = parsed.features;
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
return config;
|
|
32
37
|
} catch (err) {
|
|
33
38
|
if (err instanceof SyntaxError) {
|
|
@@ -114,49 +119,7 @@ export function generateEnvExample(config: BeaconConfigFile, activeProfile?: str
|
|
|
114
119
|
return lines.join("\n");
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
const
|
|
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
|
-
};
|
|
122
|
+
const SECRET_CENSOR = "[REDACTED]";
|
|
160
123
|
|
|
161
124
|
export async function runCheck(
|
|
162
125
|
config: BeaconConfigFile,
|
|
@@ -201,19 +164,22 @@ export async function runCheck(
|
|
|
201
164
|
continue;
|
|
202
165
|
}
|
|
203
166
|
|
|
167
|
+
const isSecret = isField ? (entry as { secret?: boolean }).secret : false;
|
|
168
|
+
const display = isSecret ? SECRET_CENSOR : raw;
|
|
169
|
+
|
|
204
170
|
try {
|
|
205
171
|
if (isField) {
|
|
206
|
-
const schema = typeToSchema(entry as
|
|
172
|
+
const schema = typeToSchema(entry as SchemaField);
|
|
207
173
|
schema.parse(raw);
|
|
208
174
|
}
|
|
209
175
|
results.push({
|
|
210
176
|
key,
|
|
211
177
|
status: "ok",
|
|
212
|
-
message: `Set (${
|
|
178
|
+
message: `Set (${display.length > 25 ? display.substring(0, 25) + "..." : display})`,
|
|
213
179
|
});
|
|
214
180
|
} catch {
|
|
215
|
-
results.push({ key, status: "invalid", message: `Invalid: "${
|
|
216
|
-
errors.push({ key, message: `Invalid value: ${
|
|
181
|
+
results.push({ key, status: "invalid", message: `Invalid: "${display}"` });
|
|
182
|
+
errors.push({ key, message: `Invalid value: ${display}` });
|
|
217
183
|
}
|
|
218
184
|
}
|
|
219
185
|
|
package/src/cli.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test, describe } from "bun:test";
|
|
2
|
-
import { generateEnvExample } from "./cli-config";
|
|
2
|
+
import { generateEnvExample, runCheck } from "./cli-config";
|
|
3
3
|
import type { BeaconConfigFile } from "./cli-config";
|
|
4
4
|
|
|
5
5
|
describe("generateEnvExample", () => {
|
|
@@ -51,3 +51,53 @@ describe("generateEnvExample", () => {
|
|
|
51
51
|
expect(result).toContain("Secret: yes");
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
describe("runCheck", () => {
|
|
56
|
+
test("returns ok for present and valid env vars", async () => {
|
|
57
|
+
const prev = process.env.TEST_DB_URL;
|
|
58
|
+
process.env.TEST_DB_URL = "https://example.com/db";
|
|
59
|
+
try {
|
|
60
|
+
const config: BeaconConfigFile = {
|
|
61
|
+
schema: {
|
|
62
|
+
TEST_DB_URL: { type: "url", required: true },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const result = await runCheck(config);
|
|
66
|
+
expect(result.results).toHaveLength(1);
|
|
67
|
+
expect(result.results[0]?.status).toBe("ok");
|
|
68
|
+
} finally {
|
|
69
|
+
if (prev === undefined) delete process.env.TEST_DB_URL;
|
|
70
|
+
else process.env.TEST_DB_URL = prev;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("returns missing for absent required var", async () => {
|
|
75
|
+
delete process.env.TEST_MISSING;
|
|
76
|
+
const config: BeaconConfigFile = {
|
|
77
|
+
schema: {
|
|
78
|
+
TEST_MISSING: { type: "string", required: true },
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const result = await runCheck(config);
|
|
82
|
+
expect(result.results[0]?.status).toBe("missing");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("redacts secret values on invalid", async () => {
|
|
86
|
+
const prev = process.env.TEST_SECRET;
|
|
87
|
+
process.env.TEST_SECRET = "my-secret-value-that-should-not-leak";
|
|
88
|
+
try {
|
|
89
|
+
const config: BeaconConfigFile = {
|
|
90
|
+
schema: {
|
|
91
|
+
TEST_SECRET: { type: "integer", secret: true },
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const result = await runCheck(config);
|
|
95
|
+
expect(result.results[0]?.status).toBe("invalid");
|
|
96
|
+
expect(result.results[0]?.message).not.toContain("my-secret-value");
|
|
97
|
+
expect(result.errors[0]?.message).not.toContain("my-secret-value");
|
|
98
|
+
} finally {
|
|
99
|
+
if (prev === undefined) delete process.env.TEST_SECRET;
|
|
100
|
+
else process.env.TEST_SECRET = prev;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|