@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 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 | 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. |
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.1.0",
3
+ "version": "0.3.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",
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: ".env.example",
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 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)}`);
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 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`);
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
- ${color.dim("(default: .beaconrc.json or beacon.config.json)")}
444
+ ${color.dim("(default: .beaconrc.json or beacon.config.json)")}
176
445
  -o, --output <path> Output file for init
177
- ${color.dim("(default: .env.example)")}
178
- --profile <name> Profile to merge (staging, production, etc.)
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("# merge production profile")}
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 ${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")}
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 { BeaconOptions, FieldDefinition, FieldDefinitionWithSchema, SchemaEntry, FieldType };
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
  }