@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 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.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 { z } from "zod";
2
- import type { SchemaEntry } from "./types";
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 typeToSchema = (entry: Record<string, unknown>): z.ZodType<unknown> => {
118
- const type = entry.type as string;
119
- let base: z.ZodType<unknown>;
120
-
121
- switch (type) {
122
- case "string":
123
- case "host":
124
- base = z.string();
125
- break;
126
- case "url":
127
- base = z.string().url();
128
- break;
129
- case "number":
130
- base = z.coerce.number();
131
- break;
132
- case "integer":
133
- base = z.coerce.number().int();
134
- break;
135
- case "boolean":
136
- base = z
137
- .string()
138
- .transform((v) => v === "true" || v === "1")
139
- .pipe(z.boolean());
140
- break;
141
- case "enum":
142
- base = z.enum((entry.values ?? []) as unknown as [string, ...string[]]);
143
- break;
144
- case "port":
145
- base = z.coerce.number().int().min(1).max(65535);
146
- break;
147
- case "email":
148
- base = z.string().email();
149
- break;
150
- default:
151
- base = z.string();
152
- }
153
-
154
- if (entry.default !== undefined) {
155
- base = base.default(entry.default as string | number | boolean);
156
- }
157
-
158
- return base;
159
- };
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 unknown as Record<string, unknown>);
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 (${raw.length > 25 ? raw.substring(0, 25) + "..." : raw})`,
178
+ message: `Set (${display.length > 25 ? display.substring(0, 25) + "..." : display})`,
213
179
  });
214
180
  } catch {
215
- results.push({ key, status: "invalid", message: `Invalid: "${raw}"` });
216
- errors.push({ key, message: `Invalid value: ${raw}` });
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
+ });