@joinremba/beacon 0.1.0 → 0.3.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 +1 -1
- package/src/cli-config.ts +6 -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 +86 -0
- package/src/index.ts +55 -1
- package/src/types.ts +10 -0
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
package/src/cli-config.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type { SchemaEntry } from "./types";
|
|
2
|
+
import type { FeatureGate, SchemaEntry } from "./types";
|
|
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) {
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { loadConfig, type BeaconConfigFile } from "./cli-config";
|
|
4
4
|
import { generateEnvExample } from "./cli-config";
|
|
5
5
|
import { runCheck } from "./cli-config";
|
|
6
|
+
import { encryptEnv, decryptEnv } from "./encryption";
|
|
6
7
|
import { color, icon, formatCheckResult, formatSummary, suggestKeys } from "./cli-format";
|
|
7
8
|
|
|
8
9
|
interface ParsedArgs {
|
|
@@ -10,6 +11,9 @@ interface ParsedArgs {
|
|
|
10
11
|
configPath: string;
|
|
11
12
|
output: string;
|
|
12
13
|
profile?: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
input?: string;
|
|
16
|
+
allProfiles: boolean;
|
|
13
17
|
wantsHelp: boolean;
|
|
14
18
|
helpTopic: string;
|
|
15
19
|
}
|
|
@@ -18,9 +22,10 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
18
22
|
const args: ParsedArgs = {
|
|
19
23
|
command: "",
|
|
20
24
|
configPath: "",
|
|
21
|
-
output: "
|
|
25
|
+
output: "",
|
|
22
26
|
wantsHelp: false,
|
|
23
27
|
helpTopic: "",
|
|
28
|
+
allProfiles: false,
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
let i = 0;
|
|
@@ -49,6 +54,14 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
49
54
|
} else if (arg === "--profile") {
|
|
50
55
|
i++;
|
|
51
56
|
args.profile = argv[i] ?? undefined;
|
|
57
|
+
} else if (arg === "--all-profiles") {
|
|
58
|
+
args.allProfiles = true;
|
|
59
|
+
} else if (arg === "--key") {
|
|
60
|
+
i++;
|
|
61
|
+
args.key = argv[i] ?? undefined;
|
|
62
|
+
} else if (arg === "-i" || arg === "--input") {
|
|
63
|
+
i++;
|
|
64
|
+
args.input = argv[i] ?? ".env";
|
|
52
65
|
} else if (arg === "--help" || arg === "-h") {
|
|
53
66
|
args.wantsHelp = true;
|
|
54
67
|
} else if (args.command === "" && !arg.startsWith("-") && !args.wantsHelp) {
|
|
@@ -71,6 +84,13 @@ async function main() {
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
if (args.command === "") {
|
|
87
|
+
if (args.configPath || args.profile || args.key || args.input || args.allProfiles) {
|
|
88
|
+
console.error(
|
|
89
|
+
` ${icon.fail} Missing command. Use ${color.cyan("beacon <command> [options]")}`
|
|
90
|
+
);
|
|
91
|
+
console.error(` Run ${color.cyan("beacon --help")} to see available commands`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
74
94
|
printHelp("");
|
|
75
95
|
return;
|
|
76
96
|
}
|
|
@@ -82,6 +102,21 @@ async function main() {
|
|
|
82
102
|
case "check":
|
|
83
103
|
await handleCheck(args);
|
|
84
104
|
break;
|
|
105
|
+
case "encrypt":
|
|
106
|
+
await handleEncrypt(args);
|
|
107
|
+
break;
|
|
108
|
+
case "decrypt":
|
|
109
|
+
await handleDecrypt(args);
|
|
110
|
+
break;
|
|
111
|
+
case "rotate":
|
|
112
|
+
handleRotate(args);
|
|
113
|
+
return;
|
|
114
|
+
case "drift":
|
|
115
|
+
await handleDrift(args);
|
|
116
|
+
break;
|
|
117
|
+
case "docker":
|
|
118
|
+
await handleDockerCheck(args);
|
|
119
|
+
break;
|
|
85
120
|
default:
|
|
86
121
|
console.error(` ${icon.fail} Unknown command: ${color.bold(args.command)}`);
|
|
87
122
|
printHelp("");
|
|
@@ -94,25 +129,61 @@ async function handleInit(args: ParsedArgs) {
|
|
|
94
129
|
|
|
95
130
|
try {
|
|
96
131
|
config = await loadConfig(args.configPath || undefined);
|
|
97
|
-
} catch {
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (args.configPath) {
|
|
134
|
+
console.error(` ${icon.fail} ${(err as Error).message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
98
137
|
config = { schema: {} };
|
|
138
|
+
console.warn(` ${icon.info} No config file found. Creating ${color.bold(".beaconrc.json")}...`);
|
|
139
|
+
const template = {
|
|
140
|
+
schema: {
|
|
141
|
+
DATABASE_URL: { type: "url", required: true, description: "PostgreSQL connection string" },
|
|
142
|
+
PORT: { type: "port", default: 3000, description: "HTTP server port" },
|
|
143
|
+
},
|
|
144
|
+
profiles: {
|
|
145
|
+
production: {
|
|
146
|
+
DB_HOST: { type: "host", required: true, description: "Production DB hostname" },
|
|
147
|
+
},
|
|
148
|
+
staging: {
|
|
149
|
+
DB_HOST: { type: "host", required: true, description: "Staging DB hostname" },
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
await Bun.write(".beaconrc.json", JSON.stringify(template, null, 2) + "\n");
|
|
154
|
+
console.log(` ${icon.pass} Created ${color.bold(".beaconrc.json")}`);
|
|
155
|
+
console.log(` Edit it to define your schema and profiles, then run:`);
|
|
156
|
+
console.log(
|
|
157
|
+
` ${color.cyan("bunx beacon init")} ${color.grey("# default profile")}`
|
|
158
|
+
);
|
|
159
|
+
console.log(
|
|
160
|
+
` ${color.cyan("bunx beacon init --profile production")} ${color.grey("# production")}`
|
|
161
|
+
);
|
|
162
|
+
console.log(
|
|
163
|
+
` ${color.cyan("bunx beacon init --all-profiles")} ${color.grey("# all profiles")}`
|
|
164
|
+
);
|
|
165
|
+
process.exit(0);
|
|
99
166
|
}
|
|
100
167
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
168
|
+
const profilesToGenerate = args.allProfiles
|
|
169
|
+
? [undefined, ...Object.keys(config.profiles ?? {})]
|
|
170
|
+
: [args.profile];
|
|
171
|
+
|
|
172
|
+
for (const profile of profilesToGenerate) {
|
|
173
|
+
const output = profile
|
|
174
|
+
? args.output || `.env.example.${profile}`
|
|
175
|
+
: args.output || ".env.example";
|
|
176
|
+
const example = generateEnvExample(config, profile);
|
|
177
|
+
await Bun.write(output, example);
|
|
178
|
+
console.log(` ${icon.pass} Generated ${color.bold(output)}`);
|
|
179
|
+
if (profile) {
|
|
180
|
+
console.log(` Profile: ${color.cyan(profile)}`);
|
|
181
|
+
}
|
|
107
182
|
}
|
|
108
183
|
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
: config.schema
|
|
113
|
-
).length;
|
|
114
|
-
if (count > 0) {
|
|
115
|
-
console.log(` ${count} variable(s) documented`);
|
|
184
|
+
const schemaKeys = Object.keys(config.schema).length;
|
|
185
|
+
if (schemaKeys > 0) {
|
|
186
|
+
console.log(` ${schemaKeys} variable(s) documented`);
|
|
116
187
|
}
|
|
117
188
|
process.exit(0);
|
|
118
189
|
}
|
|
@@ -159,6 +230,204 @@ async function handleCheck(args: ParsedArgs) {
|
|
|
159
230
|
process.exit(0);
|
|
160
231
|
}
|
|
161
232
|
|
|
233
|
+
async function handleEncrypt(args: ParsedArgs) {
|
|
234
|
+
const encryptionKey = args.key ?? process.env.BEACON_ENCRYPTION_KEY;
|
|
235
|
+
if (!encryptionKey) {
|
|
236
|
+
console.error(
|
|
237
|
+
` ${icon.fail} Encryption key required. Pass --key <key> or set BEACON_ENCRYPTION_KEY`
|
|
238
|
+
);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const inputPath = args.input ?? ".env";
|
|
243
|
+
const file = Bun.file(inputPath);
|
|
244
|
+
const exists = await file.exists();
|
|
245
|
+
if (!exists) {
|
|
246
|
+
console.error(` ${icon.fail} Input file not found: ${inputPath}`);
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const content = await file.text();
|
|
251
|
+
const encrypted = await encryptEnv(content, encryptionKey);
|
|
252
|
+
const outputPath = args.output;
|
|
253
|
+
await Bun.write(outputPath, encrypted);
|
|
254
|
+
console.log(` ${icon.pass} Encrypted ${color.bold(inputPath)} → ${color.bold(outputPath)}`);
|
|
255
|
+
process.exit(0);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function handleDecrypt(args: ParsedArgs) {
|
|
259
|
+
const encryptionKey = args.key ?? process.env.BEACON_ENCRYPTION_KEY;
|
|
260
|
+
if (!encryptionKey) {
|
|
261
|
+
console.error(
|
|
262
|
+
` ${icon.fail} Encryption key required. Pass --key <key> or set BEACON_ENCRYPTION_KEY`
|
|
263
|
+
);
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const inputPath = args.input ?? ".env.encrypted";
|
|
268
|
+
const file = Bun.file(inputPath);
|
|
269
|
+
const exists = await file.exists();
|
|
270
|
+
if (!exists) {
|
|
271
|
+
console.error(` ${icon.fail} Encrypted file not found: ${inputPath}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const encryptedBase64 = (await file.text()).trim();
|
|
276
|
+
const decrypted = await decryptEnv(encryptedBase64, encryptionKey);
|
|
277
|
+
const outputPath = args.output;
|
|
278
|
+
await Bun.write(outputPath, decrypted);
|
|
279
|
+
console.log(` ${icon.pass} Decrypted ${color.bold(inputPath)} → ${color.bold(outputPath)}`);
|
|
280
|
+
process.exit(0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function handleRotate(_args: ParsedArgs) {
|
|
284
|
+
console.log(`
|
|
285
|
+
${color.bold("Secret Rotation Checklist")}
|
|
286
|
+
|
|
287
|
+
Follow these steps to rotate a secret safely:
|
|
288
|
+
|
|
289
|
+
${color.bold("1.")} Generate a new secret value
|
|
290
|
+
Use: openssl rand -base64 32
|
|
291
|
+
|
|
292
|
+
${color.bold("2.")} Deploy the new secret alongside the old one
|
|
293
|
+
Add the new value to your env/secret store (e.g. DATABASE_URL_NEW)
|
|
294
|
+
Your app should accept both old and new values during the transition
|
|
295
|
+
|
|
296
|
+
${color.bold("3.")} Update all consumers to use the new secret
|
|
297
|
+
Deploy config changes pointing to the new secret
|
|
298
|
+
|
|
299
|
+
${color.bold("4.")} Verify everything works
|
|
300
|
+
Run: ${color.cyan("bunx beacon check")}
|
|
301
|
+
Check logs, metrics, and error rates
|
|
302
|
+
|
|
303
|
+
${color.bold("5.")} Revoke the old secret
|
|
304
|
+
Remove the old value from your env/secret store
|
|
305
|
+
Rotate any credentials in external services
|
|
306
|
+
|
|
307
|
+
${color.bold("6.")} Audit
|
|
308
|
+
Confirm no services still reference the old secret
|
|
309
|
+
Update docs and secrets inventory
|
|
310
|
+
|
|
311
|
+
${color.dim("Pro tip: Use beacon encrypt to store secrets safely in git.")}
|
|
312
|
+
${color.dim(" Set BEACON_ENCRYPTION_KEY in your deployment env.")}
|
|
313
|
+
`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function handleDrift(args: ParsedArgs) {
|
|
317
|
+
let config: BeaconConfigFile;
|
|
318
|
+
try {
|
|
319
|
+
config = await loadConfig(args.configPath || undefined);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.error(` ${icon.fail} ${(err as Error).message}`);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const profile = args.profile;
|
|
326
|
+
const mergedSchema = { ...config.schema };
|
|
327
|
+
if (profile && config.profiles?.[profile]) {
|
|
328
|
+
Object.assign(mergedSchema, config.profiles[profile]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const driftResults: Array<{ key: string; expected: string; actual: string }> = [];
|
|
332
|
+
|
|
333
|
+
for (const [key, entry] of Object.entries(mergedSchema)) {
|
|
334
|
+
const raw = process.env[key];
|
|
335
|
+
const isField = "type" in entry;
|
|
336
|
+
const required = isField ? (entry as { required?: boolean }).required !== false : true;
|
|
337
|
+
|
|
338
|
+
if (raw === undefined || raw === "") {
|
|
339
|
+
if (required) {
|
|
340
|
+
driftResults.push({ key, expected: "set", actual: "not set" });
|
|
341
|
+
}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (isField) {
|
|
346
|
+
const field = entry as { type?: string; values?: readonly string[] };
|
|
347
|
+
if (field.type === "enum" && field.values && !field.values.includes(raw)) {
|
|
348
|
+
driftResults.push({
|
|
349
|
+
key,
|
|
350
|
+
expected: `one of: ${field.values.join(" | ")}`,
|
|
351
|
+
actual: raw,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (driftResults.length === 0) {
|
|
358
|
+
console.log(` ${icon.pass} ${color.bold("No config drift detected")}`);
|
|
359
|
+
process.exit(0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
console.log(` ${icon.fail} ${color.bold("Config drift detected:")}\n`);
|
|
363
|
+
for (const d of driftResults) {
|
|
364
|
+
console.log(` ${color.yellow("⚠")} ${color.bold(d.key)}`);
|
|
365
|
+
console.log(` Expected: ${color.cyan(d.expected)}`);
|
|
366
|
+
console.log(` Actual: ${color.red(d.actual)}`);
|
|
367
|
+
console.log();
|
|
368
|
+
}
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function handleDockerCheck(args: ParsedArgs) {
|
|
373
|
+
const isDocker = await Bun.file("/.dockerenv")
|
|
374
|
+
.exists()
|
|
375
|
+
.catch(() => false);
|
|
376
|
+
const isK8s = process.env.KUBERNETES_SERVICE_HOST !== undefined;
|
|
377
|
+
|
|
378
|
+
console.log(` ${icon.info} ${color.bold("Environment Detection")}\n`);
|
|
379
|
+
|
|
380
|
+
if (isDocker) {
|
|
381
|
+
console.log(` ${icon.pass} Running inside Docker container`);
|
|
382
|
+
} else {
|
|
383
|
+
console.log(` ${color.dim("○")} Not detected as Docker`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (isK8s) {
|
|
387
|
+
console.log(` ${icon.pass} Running inside Kubernetes pod`);
|
|
388
|
+
console.log(
|
|
389
|
+
` KUBERNETES_SERVICE_HOST=${color.cyan(process.env.KUBERNETES_SERVICE_HOST ?? "")}`
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
console.log(` ${color.dim("○")} Not detected as Kubernetes`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log();
|
|
396
|
+
|
|
397
|
+
const dockerVars = ["DOCKER_HOST", "CONTAINER_NAME", "COMPOSE_PROJECT_NAME"];
|
|
398
|
+
const k8sVars = ["KUBERNETES_SERVICE_HOST", "KUBERNETES_SERVICE_PORT"];
|
|
399
|
+
|
|
400
|
+
if (isDocker || isK8s) {
|
|
401
|
+
console.log(` ${icon.info} ${color.bold("Container Env Vars Found")}\n`);
|
|
402
|
+
const relevant = [...(isDocker ? dockerVars : []), ...(isK8s ? k8sVars : [])];
|
|
403
|
+
for (const v of relevant) {
|
|
404
|
+
if (process.env[v]) {
|
|
405
|
+
console.log(` ${color.green("✓")} ${v}=${color.dim(process.env[v] ?? "")}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
console.log();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
let config: BeaconConfigFile | undefined;
|
|
412
|
+
try {
|
|
413
|
+
config = await loadConfig(args.configPath || undefined);
|
|
414
|
+
} catch {
|
|
415
|
+
// no config file, just show env info
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (config) {
|
|
419
|
+
console.log(` ${icon.info} ${color.bold("Running beacon check against schema")}\n`);
|
|
420
|
+
const result = await runCheck(config, args.profile);
|
|
421
|
+
process.stdout.write(formatCheckResult(result.results));
|
|
422
|
+
const passed = result.results.filter((r) => r.status === "ok").length;
|
|
423
|
+
const failed = result.results.filter((r) => r.status !== "ok").length;
|
|
424
|
+
process.stdout.write(formatSummary(passed, failed));
|
|
425
|
+
if (failed > 0) process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
process.exit(0);
|
|
429
|
+
}
|
|
430
|
+
|
|
162
431
|
function printHelp(command: string) {
|
|
163
432
|
if (command === "init") {
|
|
164
433
|
console.log(`
|
|
@@ -172,19 +441,130 @@ ${color.bold("DESCRIPTION")}
|
|
|
172
441
|
|
|
173
442
|
${color.bold("OPTIONS")}
|
|
174
443
|
-c, --config <path> Path to config file
|
|
175
|
-
|
|
444
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
176
445
|
-o, --output <path> Output file for init
|
|
177
|
-
|
|
178
|
-
--profile <name>
|
|
446
|
+
${color.dim("(default: .env.example or .env.example.<profile>)")}
|
|
447
|
+
--profile <name> Generate for a specific profile
|
|
448
|
+
--all-profiles Generate .env.example for every profile
|
|
179
449
|
|
|
180
450
|
${color.bold("EXAMPLES")}
|
|
181
451
|
beacon init ${color.grey("# generate .env.example")}
|
|
182
|
-
beacon init --profile production ${color.grey("#
|
|
452
|
+
beacon init --profile production ${color.grey("# generate .env.example.production")}
|
|
453
|
+
beacon init --all-profiles ${color.grey("# generate for all profiles")}
|
|
183
454
|
beacon init -c ./config/beacon.json ${color.grey("# custom config path")}
|
|
184
455
|
`);
|
|
185
456
|
return;
|
|
186
457
|
}
|
|
187
458
|
|
|
459
|
+
if (command === "rotate") {
|
|
460
|
+
console.log(`
|
|
461
|
+
${color.bold("USAGE")}
|
|
462
|
+
beacon rotate
|
|
463
|
+
|
|
464
|
+
${color.bold("DESCRIPTION")}
|
|
465
|
+
Print a step-by-step secret rotation checklist.
|
|
466
|
+
Follow it to rotate any secret (DB credentials, API keys, etc.).
|
|
467
|
+
|
|
468
|
+
${color.bold("EXAMPLES")}
|
|
469
|
+
beacon rotate
|
|
470
|
+
`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (command === "drift") {
|
|
475
|
+
console.log(`
|
|
476
|
+
${color.bold("USAGE")}
|
|
477
|
+
beacon drift [options]
|
|
478
|
+
|
|
479
|
+
${color.bold("DESCRIPTION")}
|
|
480
|
+
Detect config drift — compares your actual environment against
|
|
481
|
+
the schema defined in your beacon config file.
|
|
482
|
+
Reports missing variables, type mismatches, and unexpected values.
|
|
483
|
+
|
|
484
|
+
${color.bold("OPTIONS")}
|
|
485
|
+
-c, --config <path> Path to config file
|
|
486
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
487
|
+
--profile <name> Profile to merge (staging, production, etc.)
|
|
488
|
+
|
|
489
|
+
${color.bold("EXAMPLES")}
|
|
490
|
+
beacon drift
|
|
491
|
+
beacon drift --profile production
|
|
492
|
+
`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (command === "docker") {
|
|
497
|
+
console.log(`
|
|
498
|
+
${color.bold("USAGE")}
|
|
499
|
+
beacon docker [options]
|
|
500
|
+
|
|
501
|
+
${color.bold("DESCRIPTION")}
|
|
502
|
+
Validate the environment in Docker and Kubernetes contexts.
|
|
503
|
+
Detects the container runtime, checks common container env vars,
|
|
504
|
+
and runs a full beacon check against your schema.
|
|
505
|
+
|
|
506
|
+
${color.bold("OPTIONS")}
|
|
507
|
+
-c, --config <path> Path to config file
|
|
508
|
+
${color.dim("(default: .beaconrc.json or beacon.config.json)")}
|
|
509
|
+
--profile <name> Profile to merge
|
|
510
|
+
|
|
511
|
+
${color.bold("EXAMPLES")}
|
|
512
|
+
beacon docker
|
|
513
|
+
beacon docker --profile staging
|
|
514
|
+
`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (command === "encrypt") {
|
|
519
|
+
console.log(`
|
|
520
|
+
${color.bold("USAGE")}
|
|
521
|
+
beacon encrypt [options]
|
|
522
|
+
|
|
523
|
+
${color.bold("DESCRIPTION")}
|
|
524
|
+
Encrypt a .env file so secrets can be committed safely.
|
|
525
|
+
Requires a 256-bit encryption key (passed via --key or BEACON_ENCRYPTION_KEY).
|
|
526
|
+
|
|
527
|
+
${color.bold("OPTIONS")}
|
|
528
|
+
-i, --input <path> Input .env file
|
|
529
|
+
${color.dim("(default: .env)")}
|
|
530
|
+
-o, --output <path> Output encrypted file
|
|
531
|
+
${color.dim("(default: .env.encrypted)")}
|
|
532
|
+
--key <key> Encryption key
|
|
533
|
+
${color.dim("(or set BEACON_ENCRYPTION_KEY)")}
|
|
534
|
+
|
|
535
|
+
${color.bold("EXAMPLES")}
|
|
536
|
+
beacon encrypt ${color.grey("# encrypt .env → .env.encrypted")}
|
|
537
|
+
beacon encrypt -i .env.prod -o .env.prod.encrypted ${color.grey("# custom paths")}
|
|
538
|
+
BEACON_ENCRYPTION_KEY=... beacon encrypt ${color.grey("# key from env")}
|
|
539
|
+
`);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (command === "decrypt") {
|
|
544
|
+
console.log(`
|
|
545
|
+
${color.bold("USAGE")}
|
|
546
|
+
beacon decrypt [options]
|
|
547
|
+
|
|
548
|
+
${color.bold("DESCRIPTION")}
|
|
549
|
+
Decrypt a .env.encrypted file back to plaintext.
|
|
550
|
+
Requires the same key used during encryption.
|
|
551
|
+
|
|
552
|
+
${color.bold("OPTIONS")}
|
|
553
|
+
-i, --input <path> Input encrypted file
|
|
554
|
+
${color.dim("(default: .env.encrypted)")}
|
|
555
|
+
-o, --output <path> Output .env file
|
|
556
|
+
${color.dim("(default: .env)")}
|
|
557
|
+
--key <key> Encryption key
|
|
558
|
+
${color.dim("(or set BEACON_ENCRYPTION_KEY)")}
|
|
559
|
+
|
|
560
|
+
${color.bold("EXAMPLES")}
|
|
561
|
+
beacon decrypt ${color.grey("# decrypt .env.encrypted → .env")}
|
|
562
|
+
beacon decrypt -i .env.prod.encrypted -o .env.prod ${color.grey("# custom paths")}
|
|
563
|
+
BEACON_ENCRYPTION_KEY=... beacon decrypt ${color.grey("# key from env")}
|
|
564
|
+
`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
188
568
|
if (command === "check") {
|
|
189
569
|
console.log(`
|
|
190
570
|
${color.bold("USAGE")}
|
|
@@ -218,9 +598,14 @@ ${color.bold("USAGE")}
|
|
|
218
598
|
beacon <command> [options]
|
|
219
599
|
|
|
220
600
|
${color.bold("COMMANDS")}
|
|
221
|
-
init
|
|
222
|
-
check
|
|
223
|
-
|
|
601
|
+
init ${color.dim("Generate .env.example from your config")}
|
|
602
|
+
check ${color.dim("Validate current environment against your schema")}
|
|
603
|
+
encrypt ${color.dim("Encrypt .env file for safe committing")}
|
|
604
|
+
decrypt ${color.dim("Decrypt .env.encrypted back to plaintext")}
|
|
605
|
+
rotate ${color.dim("Print secret rotation checklist")}
|
|
606
|
+
drift ${color.dim("Detect config drift between schema and env")}
|
|
607
|
+
docker ${color.dim("Validate env in Docker/Kubernetes contexts")}
|
|
608
|
+
help ${color.dim("Show help for a specific command")}
|
|
224
609
|
|
|
225
610
|
${color.bold("OPTIONS")}
|
|
226
611
|
-c, --config <path> Path to config file (default: .beaconrc.json or beacon.config.json)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { encryptEnv, decryptEnv } from "./encryption";
|
|
3
|
+
|
|
4
|
+
test("encrypts and decrypts env content", async () => {
|
|
5
|
+
const key = "test-encryption-key-32bytes!";
|
|
6
|
+
const envContent = `DATABASE_URL=postgres://localhost/mydb
|
|
7
|
+
API_KEY=supersecret123
|
|
8
|
+
PORT=3000`;
|
|
9
|
+
|
|
10
|
+
const encrypted = await encryptEnv(envContent, key);
|
|
11
|
+
expect(typeof encrypted).toBe("string");
|
|
12
|
+
expect(encrypted.length).toBeGreaterThan(0);
|
|
13
|
+
|
|
14
|
+
const decrypted = await decryptEnv(encrypted, key);
|
|
15
|
+
expect(decrypted).toBe(envContent);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("rejects wrong key", async () => {
|
|
19
|
+
const key = "correct-key-32bytes!!!!!";
|
|
20
|
+
const envContent = "SECRET=value";
|
|
21
|
+
const encrypted = await encryptEnv(envContent, key);
|
|
22
|
+
const result = await decryptEnv(encrypted, "wrong-key-32bytes!!!!!!").catch(() => "error");
|
|
23
|
+
expect(result).toBe("error");
|
|
24
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const ALGORITHM = "AES-GCM";
|
|
2
|
+
const IV_LENGTH = 12;
|
|
3
|
+
const TAG_LENGTH = 128;
|
|
4
|
+
|
|
5
|
+
function keyToBuf(key: Uint8Array): ArrayBuffer {
|
|
6
|
+
const buf = new ArrayBuffer(key.byteLength);
|
|
7
|
+
new Uint8Array(buf).set(key);
|
|
8
|
+
return buf;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function deriveKey(password: string): Promise<CryptoKey> {
|
|
12
|
+
const encoded = new TextEncoder().encode(password);
|
|
13
|
+
const raw =
|
|
14
|
+
encoded.length >= 32
|
|
15
|
+
? encoded.slice(0, 32)
|
|
16
|
+
: (() => {
|
|
17
|
+
const p = new Uint8Array(32);
|
|
18
|
+
p.set(encoded);
|
|
19
|
+
return p;
|
|
20
|
+
})();
|
|
21
|
+
return crypto.subtle.importKey("raw", keyToBuf(raw), { name: ALGORITHM }, false, [
|
|
22
|
+
"encrypt",
|
|
23
|
+
"decrypt",
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function encryptEnv(envContent: string, key: string): Promise<string> {
|
|
28
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
29
|
+
const cryptoKey = await deriveKey(key);
|
|
30
|
+
const data = new TextEncoder().encode(envContent);
|
|
31
|
+
|
|
32
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
33
|
+
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
|
34
|
+
cryptoKey,
|
|
35
|
+
data
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const combined = new Uint8Array(IV_LENGTH + encrypted.byteLength);
|
|
39
|
+
combined.set(iv, 0);
|
|
40
|
+
combined.set(new Uint8Array(encrypted), IV_LENGTH);
|
|
41
|
+
|
|
42
|
+
return Buffer.from(combined).toString("base64");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function decryptEnv(encryptedBase64: string, key: string): Promise<string> {
|
|
46
|
+
const combined = Buffer.from(encryptedBase64, "base64");
|
|
47
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
48
|
+
const data = combined.slice(IV_LENGTH);
|
|
49
|
+
|
|
50
|
+
const cryptoKey = await deriveKey(key);
|
|
51
|
+
|
|
52
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
53
|
+
{ name: ALGORITHM, iv, tagLength: TAG_LENGTH },
|
|
54
|
+
cryptoKey,
|
|
55
|
+
data
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return new TextDecoder().decode(decrypted);
|
|
59
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -176,3 +176,89 @@ test("tracks secret keys", () => {
|
|
|
176
176
|
|
|
177
177
|
expect(config.secret).toEqual({ API_KEY: true });
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
test("isEnabled returns false for undefined feature", () => {
|
|
181
|
+
const config = createBeacon({ PORT: { type: "port", default: 3000 } });
|
|
182
|
+
expect(config.isEnabled("nonexistent")).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("isEnabled returns true for enabled feature", () => {
|
|
186
|
+
const config = createBeacon(
|
|
187
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
188
|
+
{ features: { newDashboard: { enabled: true } } }
|
|
189
|
+
);
|
|
190
|
+
expect(config.isEnabled("newDashboard")).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("isEnabled returns false for disabled feature", () => {
|
|
194
|
+
const config = createBeacon(
|
|
195
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
196
|
+
{ features: { darkMode: { enabled: false } } }
|
|
197
|
+
);
|
|
198
|
+
expect(config.isEnabled("darkMode")).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("isEnabled respects env override", () => {
|
|
202
|
+
const prev = process.env.FEATURE_NEW_DASHBOARD;
|
|
203
|
+
try {
|
|
204
|
+
process.env.FEATURE_NEW_DASHBOARD = "false";
|
|
205
|
+
const config = createBeacon(
|
|
206
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
207
|
+
{ features: { newDashboard: { enabled: true } } }
|
|
208
|
+
);
|
|
209
|
+
expect(config.isEnabled("newDashboard")).toBe(false);
|
|
210
|
+
} finally {
|
|
211
|
+
if (prev === undefined) {
|
|
212
|
+
delete process.env.FEATURE_NEW_DASHBOARD;
|
|
213
|
+
} else {
|
|
214
|
+
process.env.FEATURE_NEW_DASHBOARD = prev;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("isEnabled uses rollout when enabled is true", () => {
|
|
220
|
+
const config = createBeacon(
|
|
221
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
222
|
+
{ features: { gradual: { enabled: true, rollout: 1 } } }
|
|
223
|
+
);
|
|
224
|
+
expect(config.isEnabled("gradual")).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("isKilled returns false by default", () => {
|
|
228
|
+
const config = createBeacon({ PORT: { type: "port", default: 3000 } });
|
|
229
|
+
expect(config.isKilled("newDashboard")).toBe(false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("isKilled returns true when set in options", () => {
|
|
233
|
+
const config = createBeacon(
|
|
234
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
235
|
+
{ killSwitches: { newDashboard: true } }
|
|
236
|
+
);
|
|
237
|
+
expect(config.isKilled("newDashboard")).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("isKilled respects KILL_ env override", () => {
|
|
241
|
+
const prev = process.env.KILL_NEW_DASHBOARD;
|
|
242
|
+
try {
|
|
243
|
+
process.env.KILL_NEW_DASHBOARD = "true";
|
|
244
|
+
const config = createBeacon(
|
|
245
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
246
|
+
{ killSwitches: { newDashboard: false } }
|
|
247
|
+
);
|
|
248
|
+
expect(config.isKilled("newDashboard")).toBe(true);
|
|
249
|
+
} finally {
|
|
250
|
+
if (prev === undefined) delete process.env.KILL_NEW_DASHBOARD;
|
|
251
|
+
else process.env.KILL_NEW_DASHBOARD = prev;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("isEnabled returns false when feature is killed", () => {
|
|
256
|
+
const config = createBeacon(
|
|
257
|
+
{ PORT: { type: "port", default: 3000 } },
|
|
258
|
+
{
|
|
259
|
+
features: { newDashboard: { enabled: true } },
|
|
260
|
+
killSwitches: { newDashboard: true },
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
expect(config.isEnabled("newDashboard")).toBe(false);
|
|
264
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import type {
|
|
3
3
|
Beacon as BeaconInterface,
|
|
4
4
|
BeaconOptions,
|
|
5
|
+
FeatureGate,
|
|
5
6
|
FieldDefinition,
|
|
6
7
|
FieldDefinitionWithSchema,
|
|
7
8
|
SchemaEntry,
|
|
@@ -9,7 +10,14 @@ import type {
|
|
|
9
10
|
} from "./types";
|
|
10
11
|
import { ConfigError, ConfigValidationError } from "./errors";
|
|
11
12
|
|
|
12
|
-
export type {
|
|
13
|
+
export type {
|
|
14
|
+
BeaconOptions,
|
|
15
|
+
FeatureGate,
|
|
16
|
+
FieldDefinition,
|
|
17
|
+
FieldDefinitionWithSchema,
|
|
18
|
+
SchemaEntry,
|
|
19
|
+
FieldType,
|
|
20
|
+
};
|
|
13
21
|
export { ConfigError, ConfigValidationError };
|
|
14
22
|
export type { BeaconInterface as Beacon };
|
|
15
23
|
|
|
@@ -93,6 +101,22 @@ const resolveEntry = (
|
|
|
93
101
|
|
|
94
102
|
const SECRET_CENSOR = "[REDACTED]";
|
|
95
103
|
|
|
104
|
+
const featureEnvName = (name: string): string =>
|
|
105
|
+
`FEATURE_${name
|
|
106
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
107
|
+
.replace(/[^a-zA-Z0-9]/g, "_")
|
|
108
|
+
.toUpperCase()}`;
|
|
109
|
+
|
|
110
|
+
const parseEnvBoolean = (val: string): boolean => val === "true" || val === "1" || val === "yes";
|
|
111
|
+
|
|
112
|
+
const featureRollup = (name: string): number => {
|
|
113
|
+
let hash = 5381;
|
|
114
|
+
for (let i = 0; i < name.length; i++) {
|
|
115
|
+
hash = ((hash << 5) + hash + name.charCodeAt(i)) | 0;
|
|
116
|
+
}
|
|
117
|
+
return Math.abs(hash % 10000) / 10000;
|
|
118
|
+
};
|
|
119
|
+
|
|
96
120
|
export function createBeacon(
|
|
97
121
|
schema: Record<string, SchemaEntry>,
|
|
98
122
|
options?: BeaconOptions
|
|
@@ -111,6 +135,8 @@ export function createBeacon(
|
|
|
111
135
|
const secretKeys = new Set(resolved.filter((e) => e.secret).map((e) => e.key));
|
|
112
136
|
|
|
113
137
|
let validated: Record<string, unknown> | null = null;
|
|
138
|
+
const features: Record<string, FeatureGate> = options?.features ?? {};
|
|
139
|
+
const killSwitches: Record<string, boolean> = options?.killSwitches ?? {};
|
|
114
140
|
|
|
115
141
|
const beacon: BeaconInterface = {
|
|
116
142
|
ensure(): BeaconInterface {
|
|
@@ -189,6 +215,34 @@ export function createBeacon(
|
|
|
189
215
|
}
|
|
190
216
|
return map;
|
|
191
217
|
},
|
|
218
|
+
|
|
219
|
+
isKilled(feature: string): boolean {
|
|
220
|
+
const envName = `KILL_${feature
|
|
221
|
+
.replace(/([a-z])([A-Z])/g, "$1_$2")
|
|
222
|
+
.replace(/[^a-zA-Z0-9]/g, "_")
|
|
223
|
+
.toUpperCase()}`;
|
|
224
|
+
const envVal = process.env[envName];
|
|
225
|
+
if (envVal !== undefined && envVal !== "") {
|
|
226
|
+
return parseEnvBoolean(envVal);
|
|
227
|
+
}
|
|
228
|
+
return killSwitches[feature] ?? false;
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
isEnabled(feature: string): boolean {
|
|
232
|
+
if (this.isKilled(feature)) return false;
|
|
233
|
+
const envName = featureEnvName(feature);
|
|
234
|
+
const envVal = process.env[envName];
|
|
235
|
+
if (envVal !== undefined && envVal !== "") {
|
|
236
|
+
return parseEnvBoolean(envVal);
|
|
237
|
+
}
|
|
238
|
+
const gate = features[feature];
|
|
239
|
+
if (!gate) return false;
|
|
240
|
+
if (!gate.enabled) return false;
|
|
241
|
+
if (gate.rollout !== undefined) {
|
|
242
|
+
return featureRollup(feature) < gate.rollout;
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
192
246
|
};
|
|
193
247
|
|
|
194
248
|
return beacon;
|
package/src/types.ts
CHANGED
|
@@ -29,13 +29,23 @@ export interface FieldDefinitionWithSchema {
|
|
|
29
29
|
|
|
30
30
|
export type SchemaEntry = FieldDefinition | FieldDefinitionWithSchema;
|
|
31
31
|
|
|
32
|
+
export interface FeatureGate {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
rollout?: number;
|
|
35
|
+
description?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
export interface BeaconOptions {
|
|
33
39
|
profile?: string;
|
|
34
40
|
profiles?: Record<string, Record<string, SchemaEntry>>;
|
|
41
|
+
features?: Record<string, FeatureGate>;
|
|
42
|
+
killSwitches?: Record<string, boolean>;
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
export interface Beacon {
|
|
38
46
|
ensure(): Beacon;
|
|
39
47
|
get<T = unknown>(key: string): T;
|
|
40
48
|
readonly secret: Record<string, boolean>;
|
|
49
|
+
isEnabled(feature: string): boolean;
|
|
50
|
+
isKilled(feature: string): boolean;
|
|
41
51
|
}
|