configre 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,10 +119,10 @@ import Configre from "../../index.js";
119
119
  import { join } from "node:path";
120
120
 
121
121
  const configPath = join(import.meta.dirname, "config");
122
- const cfg = Configre(configPath, { secrets: true });
122
+ const cfg = Configre(configPath);
123
123
  ```
124
124
 
125
- `{ secrets: true }` enables this workflow. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
125
+ Secrets activate automatically when the base configuration or selected profile has a corresponding `.secret.cjs` file, or when `secrets.enc.json` already exists. No options are needed. Loading remains synchronous, and your application reads the result through ordinary properties such as `cfg.api.key`.
126
126
 
127
127
  > The demo reports `API key configured:` without printing the key. Avoid logging `cfg` in your application: it contains the decrypted secrets.
128
128
 
@@ -138,18 +138,24 @@ module.exports = {
138
138
  };
139
139
  ```
140
140
 
141
+ Create `demo/secrets/config/index.secret.cjs` yourself to enable secrets, initially with:
142
+
143
+ ```javascript
144
+ module.exports = {};
145
+ ```
146
+
141
147
  Then run:
142
148
 
143
149
  ```bash
144
150
  node demo/secrets/demo.js
145
151
  ```
146
152
 
147
- For a new setup, Configre creates the following files and directory:
153
+ For a new setup, Configre generates the encrypted file, recipients directory and Git exclusions alongside your existing configuration files:
148
154
 
149
155
  ```text
150
156
  demo/secrets/config/
151
157
  index.cjs # Public configuration
152
- index.secret.cjs # Editable secrets; starts with module.exports = {};
158
+ index.secret.cjs # Editable secrets you create yourself
153
159
  recipients/ # Public keys of servers that register
154
160
  secrets.enc.json # Generated encrypted values
155
161
  .gitignore # Keeps .secret.cjs files out of Git
@@ -163,7 +169,7 @@ With the public placeholder empty and no secrets added, look for:
163
169
  API key configured: false
164
170
  ```
165
171
 
166
- **`index.secret.cjs` starts empty on purpose.** Configre does not copy values from `index.cjs` into it. It contains only the fields you choose to override with secrets; public settings continue to come from `index.cjs`.
172
+ **Configre never creates `.secret.cjs` files.** Without a secret counterpart for the base configuration or selected profile, and without an encrypted file, it loads only public settings and creates no identity or secret artifacts. A secret module contains only the fields you choose to override; public settings continue to come from `index.cjs`.
167
173
 
168
174
  These initialization steps describe a new setup. If `secrets.enc.json` already exists, Configre uses that encrypted file instead of resetting it. A checkout containing the encrypted file but no `.secret.cjs` files is treated as a server checkout.
169
175
 
@@ -254,7 +260,7 @@ node demo/secrets/demo.js
254
260
 
255
261
  Commit and push the updated `secrets.enc.json`, then pull and restart the demo on the server. **You do not need to copy or register `truco.pub` again.** Its existing authorization also covers new keys you add later.
256
262
 
257
- For settings specific to `truco`, add `demo/secrets/config/truco.cjs`. The next administrator run creates an empty `truco.secret.cjs` counterpart, which you can fill with that profile's secrets. All profile secret files, including inactive ones, are encrypted together when the administrator runs the demo.
263
+ For settings specific to `truco`, add `demo/secrets/config/truco.cjs` and create `truco.secret.cjs` yourself with that profile's secrets. The secret file activates secrets when `truco` is selected, even without `index.secret.cjs`. A public profile alone never creates a secret counterpart. Once secrets are active, all profile secret files, including inactive ones, are encrypted together when the administrator runs the demo.
258
264
 
259
265
  For `--config=truco`, the merge order is:
260
266
 
@@ -271,7 +277,7 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
271
277
 
272
278
  - Secret `.cjs` modules execute on the administrator and are reloaded on each Configre call. They must export plain objects containing JSON-compatible objects, arrays, strings, finite numbers, booleans and `null`. Functions, `undefined`, accessors, symbols, custom objects, circular references, and properties named `__proto__`, `constructor` or `prototype` are rejected. Consumers decrypt data without executing these modules.
273
279
  - Empty strings stay empty strings. Configre does not fill them from environment variables or populate `process.env`. The existing deep-merge behavior, including array merging, also applies to secrets.
274
- - Without `{ secrets: true }`, Configre does not access identities or secret files. The option also works with `new Configre(configPath, { secrets: true }).get()` and CommonJS consumers.
280
+ - Activation depends only on the existing files; there is no `secrets` option. The same behavior applies to `new Configre(configPath).get()` and CommonJS consumers.
275
281
  - Each OS user has one identity reused across projects; authorization is per project. A service running under another OS user needs its own registration. All authorized identities can decrypt all profiles in the project's encrypted file.
276
282
  - Public recipient files must contain a single RSA-3072 public key in PEM format, with exponent 65537, as generated by Configre. Other file extensions are ignored, and duplicate keys do not add recipients. The administrator is always included.
277
283
  - Registration uses an isolated Git index and publishes only the public-key file. It preserves unrelated staged and unstaged changes, runs no commit or pre-push hooks, and does not push local tags, merge, rebase or force-push. Failed registrations can be retried at the next startup; each Git command has a 30-second timeout. An authorized server loads secrets without Git commands or project writes.
@@ -2,7 +2,7 @@ import Configre from "../../index.js";
2
2
  import { join } from "node:path";
3
3
 
4
4
  const configPath = join(import.meta.dirname, "config");
5
- const cfg = Configre(configPath, { secrets: true });
5
+ const cfg = Configre(configPath);
6
6
  console.info("API key configured:", Boolean(cfg.api.key));
7
7
  console.info(`Set api.key in ${join(configPath, "index.secret.cjs")} and run this demo again.`);
8
8
  console.info(`New servers automatically publish their public key in ${join(configPath, "recipients")}. Pull and rerun here to authorize them.`);
package/index.js CHANGED
@@ -10,15 +10,10 @@ const requireConfig = createRequire(import.meta.url);
10
10
  const log = lemonlog("Configre");
11
11
 
12
12
  class ConfigreClass {
13
- constructor(pathOrDir, options = {}) {
13
+ constructor(pathOrDir) {
14
14
  if (typeof pathOrDir !== "string" || pathOrDir.length === 0) {
15
15
  throw new TypeError("Configre path must be a non-empty string");
16
16
  }
17
- if (options === null || typeof options !== "object" || Array.isArray(options) ||
18
- (options.secrets !== undefined && typeof options.secrets !== "boolean")) {
19
- throw new TypeError("Configre options must be an object with an optional boolean secrets property");
20
- }
21
-
22
17
  const dir = path.join(pathOrDir);
23
18
  const isNested = (ConfigreClass._nesting || 0) > 0;
24
19
  ConfigreClass._nesting = (ConfigreClass._nesting || 0) + 1;
@@ -35,9 +30,7 @@ class ConfigreClass {
35
30
  const configArg = process.argv.find(arg => arg.startsWith('--config='));
36
31
  this.profile = configArg ? configArg.slice('--config='.length) : os.hostname();
37
32
  this.profileSettings = this.loadProfileSettings();
38
- this.secretSettings = options.secrets === true
39
- ? loadSecrets(pathOrDir, this.configFile, this.profile)
40
- : [];
33
+ this.secretSettings = loadSecrets(pathOrDir, this.configFile, this.profile);
41
34
  } finally {
42
35
  ConfigreClass._nesting -= 1;
43
36
  }
@@ -93,11 +86,11 @@ class ConfigreClass {
93
86
  }
94
87
 
95
88
  // Wrapper function to support both constructor and function usage
96
- function Configre(path, options) {
89
+ function Configre(path) {
97
90
  if (this instanceof Configre) {
98
- return new ConfigreClass(path, options);
91
+ return new ConfigreClass(path);
99
92
  } else {
100
- return new ConfigreClass(path, options).get();
93
+ return new ConfigreClass(path).get();
101
94
  }
102
95
  }
103
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configre",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "🔧 Effortlessly Tailor Your Settings",
5
5
  "type": "module",
6
6
  "engines": {
package/secrets/index.js CHANGED
@@ -122,8 +122,14 @@ function selectSecrets(bundle, paths, profile) {
122
122
 
123
123
  function loadSecrets(configPath, configFile, profile) {
124
124
  const paths = secretPaths(configPath, configFile);
125
+ const names = localFiles(paths);
126
+ const hasEncrypted = !!stat(paths.encrypted);
127
+ const hasLocal = names.includes(path.basename(paths.local)) || (paths.directory &&
128
+ (names.includes(profile + ".dev.secret.cjs") || names.includes(profile + ".secret.cjs")));
129
+ if (!hasLocal && !hasEncrypted) return [];
130
+
125
131
  const identity = loadIdentity();
126
- if (localFiles(paths).length === 0 && stat(paths.encrypted)) {
132
+ if (names.length === 0 && hasEncrypted) {
127
133
  try {
128
134
  return selectSecrets(validateBundle(decrypt(readJSON(paths.encrypted), identity)), paths, profile);
129
135
  } catch (error) {
@@ -143,20 +149,6 @@ function loadSecrets(configPath, configFile, profile) {
143
149
  prepareIgnore(paths);
144
150
  const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
145
151
  const recipients = loadRecipients(paths.recipients, identity);
146
- const templates = new Set([path.basename(paths.local)]);
147
- if (paths.directory) {
148
- for (const name of fs.readdirSync(paths.parent)) {
149
- if (name.endsWith(".cjs") && !name.endsWith(".secret.cjs") && stat(path.join(paths.parent, name)).isFile()) {
150
- templates.add(name.slice(0, -4) + ".secret.cjs");
151
- }
152
- }
153
- }
154
- for (const name of templates) {
155
- if (!Object.hasOwn(settings.files, name)) {
156
- fs.writeFileSync(path.join(paths.parent, name), "module.exports = {};\n", { flag: "wx", mode: 0o600 });
157
- settings.files[name] = {};
158
- }
159
- }
160
152
  if (hasEncrypted) {
161
153
  const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
162
154
  if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
@@ -51,7 +51,7 @@ function writeSettings(f, settings = { api: { key: sentinel } }) {
51
51
  }
52
52
 
53
53
  function load(f) {
54
- return Configre(f.config, { secrets: true });
54
+ return Configre(f.config);
55
55
  }
56
56
 
57
57
  function envelope(f) {
@@ -98,23 +98,40 @@ function gitFixture(t) {
98
98
  };
99
99
  }
100
100
 
101
- test("secrets stay opt-in and invalid options fail before creating an identity", t => {
101
+ test("missing secret counterparts leave configuration and identity untouched", t => {
102
102
  const f = fixture(t, { seed: false });
103
- fs.writeFileSync(f.local, "invalid-json");
103
+ const publicFiles = fs.readdirSync(f.config);
104
104
  t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
105
105
  assert.equal(Configre(f.config).api.key, "");
106
- assert.equal(Configre(f.config, { secrets: false }).api.key, "");
106
+ assert.deepEqual(fs.readdirSync(f.config), publicFiles);
107
+ fs.writeFileSync(path.join(f.config, "other.secret.cjs"), "invalid-json");
108
+ const files = fs.readdirSync(f.config);
109
+ assert.equal(Configre(f.config).api.key, "");
107
110
  assert.equal(new Configre(f.config).get().api.key, "");
108
- for (const options of [null, true, [], { secrets: "true" }]) {
109
- assert.throws(() => Configre(f.config, options), /options must be an object/);
110
- }
111
- assert.equal(fs.existsSync(f.encrypted), false);
112
- assert.equal(fs.existsSync(path.join(f.config, ".gitignore")), false);
111
+ assert.deepEqual(fs.readdirSync(f.config), files);
113
112
  assert.equal(fs.existsSync(f.homes[0]), false);
114
113
  });
115
114
 
116
- test("first use initializes empty secrets and reuses all generated files", t => {
115
+ for (const profile of ["testhost", "testhost.dev", "forced"]) {
116
+ test(`a ${profile} secret activates secrets without a base secret module`, t => {
117
+ const f = fixture(t);
118
+ if (profile === "forced") {
119
+ const previousArgs = process.argv;
120
+ process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=forced"];
121
+ t.after(() => { process.argv = previousArgs; });
122
+ }
123
+ const local = path.join(f.config, profile + ".secret.cjs");
124
+ writeSettings({ local });
125
+ assert.equal(load(f).api.key, sentinel);
126
+ assert.equal(fs.existsSync(f.local), false);
127
+ assert.deepEqual(fs.readdirSync(f.config).filter(name => name.endsWith(".secret.cjs")), [path.basename(local)]);
128
+ assert.equal(load(consumerCopy(f, "profile-only-consumer")).api.key, sentinel);
129
+ });
130
+ }
131
+
132
+ test("an existing empty secret module initializes secrets and reuses generated files", t => {
117
133
  const f = fixture(t, { seed: false });
134
+ fs.writeFileSync(f.local, "module.exports = {};\n", { mode: 0o600 });
118
135
  const ignore = path.join(f.config, ".gitignore");
119
136
  fs.writeFileSync(ignore, "# existing rules\n*.log");
120
137
  const config = load(f);
@@ -198,7 +215,7 @@ test("secrets merge last with profiles, arrays, JSON values and the constructor
198
215
  assert.deepEqual(config.api, settings.api);
199
216
  assert.deepEqual(config.list, [9, 2]);
200
217
  assert.deepEqual(config.json, settings.json);
201
- assert.deepEqual(new Configre(f.config, { secrets: true }).get(), config);
218
+ assert.deepEqual(new Configre(f.config).get(), config);
202
219
  assert.equal(fs.existsSync(f.recipients), true);
203
220
  assert.equal(fs.readFileSync(f.encrypted, "utf8").includes(sentinel), false);
204
221
  assert.equal(envelope(f).recipients.length, 1);
@@ -277,19 +294,19 @@ test("base and host secrets select dev and forced profiles identically on consum
277
294
  assert.deepEqual(fs.readdirSync(consumer.config), consumerFiles);
278
295
  });
279
296
 
280
- test("administrator scaffolds empty counterparts without overwriting existing host secrets", t => {
297
+ test("administrator leaves missing counterparts absent and preserves existing host secrets", t => {
281
298
  const f = fixture(t);
282
299
  writeSettings(f);
283
300
  load(f);
284
301
  const host = { local: path.join(f.config, "testhost.secret.cjs") };
285
- assert.equal(fs.readFileSync(host.local, "utf8"), "module.exports = {};\n");
302
+ assert.equal(fs.existsSync(host.local), false);
286
303
  writeSettings(host, { api: { key: "host-secret" } });
287
304
  const original = fs.readFileSync(host.local);
288
305
  fs.writeFileSync(path.join(f.config, "newhost.cjs"), "module.exports = { public: true };\n");
289
306
  load(f);
290
307
  assert.deepEqual(fs.readFileSync(host.local), original);
291
- assert.equal(fs.readFileSync(path.join(f.config, "newhost.secret.cjs"), "utf8"), "module.exports = {};\n");
292
- const consumer = consumerCopy(f, "scaffold-consumer");
308
+ assert.equal(fs.existsSync(path.join(f.config, "newhost.secret.cjs")), false);
309
+ const consumer = consumerCopy(f, "optional-profile-consumer");
293
310
  fs.writeFileSync(path.join(consumer.config, "newhost.cjs"), "module.exports = {};\n");
294
311
  assert.equal(load(consumer).api.key, "host-secret");
295
312
  assert.equal(fs.existsSync(path.join(consumer.config, "newhost.secret.cjs")), false);
@@ -467,20 +484,33 @@ test("private permissions and symlinks are rejected", { skip: process.platform =
467
484
  assert.throws(() => load(f), /regular file/);
468
485
  });
469
486
 
487
+ test("explicit config files without their own secret sidecar leave identities and files untouched", t => {
488
+ const f = fixture(t, { seed: false });
489
+ const filename = path.join(f.root, "settings.cjs");
490
+ fs.writeFileSync(filename, 'module.exports = { public: true };');
491
+ fs.writeFileSync(path.join(f.root, "other.secret.cjs"), "invalid-json");
492
+ const files = fs.readdirSync(f.root);
493
+ t.mock.method(os, "homedir", () => { throw new Error("must not access the identity"); });
494
+ assert.deepEqual(Configre(filename), { public: true });
495
+ assert.deepEqual(Configre(filename.slice(0, -4)), { public: true });
496
+ assert.deepEqual(fs.readdirSync(f.root), files);
497
+ assert.equal(fs.existsSync(f.homes[0]), false);
498
+ });
499
+
470
500
  test("explicit config files and extensionless paths use sidecars beside the resolved file", t => {
471
501
  const f = fixture(t);
472
502
  const filename = path.join(f.root, "settings.cjs");
473
503
  fs.writeFileSync(filename, 'module.exports = { api: { key: "", host: "file" } };');
474
504
  const local = filename.slice(0, -4) + ".secret.cjs";
475
505
  writeSettings({ local });
476
- assert.equal(Configre(filename, { secrets: true }).api.key, sentinel);
477
- assert.equal(Configre(filename.slice(0, -4), { secrets: true }).api.key, sentinel);
506
+ assert.equal(Configre(filename).api.key, sentinel);
507
+ assert.equal(Configre(filename.slice(0, -4)).api.key, sentinel);
478
508
  assert.equal(fs.existsSync(filename + ".secrets.enc.json"), true);
479
509
  assert.equal(fs.existsSync(filename + ".recipients"), true);
480
510
  assert.equal(fs.existsSync(path.join(f.root, "secrets.enc.json")), false);
481
511
  const relative = path.relative(process.cwd(), f.config);
482
512
  writeSettings(f);
483
- assert.equal(Configre(relative, { secrets: true }).api.key, sentinel);
513
+ assert.equal(Configre(relative).api.key, sentinel);
484
514
  });
485
515
 
486
516
  test("ESM callers can use the same synchronous API", t => {
@@ -494,7 +524,7 @@ test("ESM callers can use the same synchronous API", t => {
494
524
  import os from 'node:os';
495
525
  import Configre from ${JSON.stringify(moduleURL)};
496
526
  os.homedir = () => process.env.CONFIGRE_TEST_HOME;
497
- const cfg = Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true });
527
+ const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
498
528
  assert.equal(typeof cfg.then, 'undefined');
499
529
  assert.equal(cfg.api.key, ${JSON.stringify(sentinel)});
500
530
  `);
@@ -511,7 +541,7 @@ test("symlinked configuration directories keep directory sidecar names", { skip:
511
541
  writeSettings(f);
512
542
  const linked = path.join(f.root, "linked-config");
513
543
  fs.symlinkSync(f.config, linked);
514
- assert.equal(Configre(linked, { secrets: true }).api.key, sentinel);
544
+ assert.equal(Configre(linked).api.key, sentinel);
515
545
  assert.equal(fs.existsSync(f.encrypted), true);
516
546
  assert.equal(fs.existsSync(path.join(f.config, "index.cjs.secrets.enc.json")), false);
517
547
  });
@@ -520,13 +550,12 @@ test("error output never includes local secret values or private key contents",
520
550
  const f = fixture(t);
521
551
  fs.writeFileSync(f.local, `module.exports = {secret: "${sentinel}", bad`);
522
552
  assert.throws(() => load(f), /invalid secret module/);
523
- assert.equal(new Configre(f.config)._isNested, false);
524
553
  const script = `
525
554
  const os = require('node:os');
526
555
  os.homedir = () => process.env.CONFIGRE_TEST_HOME;
527
556
  const Configre = require(process.env.CONFIGRE_TEST_MODULE);
528
557
  try {
529
- Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true });
558
+ Configre(process.env.CONFIGRE_TEST_PATH);
530
559
  } catch (error) {
531
560
  console.error(error.stack);
532
561
  process.exitCode = 1;
@@ -545,6 +574,8 @@ test("error output never includes local secret values or private key contents",
545
574
  assert.match(result.stderr, /invalid secret module/);
546
575
  assert.equal((result.stdout + result.stderr).includes(sentinel), false);
547
576
  assert.equal((result.stdout + result.stderr).includes("BEGIN PRIVATE KEY"), false);
577
+ writeSettings(f);
578
+ assert.equal(new Configre(f.config)._isNested, false);
548
579
  });
549
580
 
550
581
  test("Git exclusions preserve existing rules and never hide already tracked local files", t => {
@@ -568,6 +599,7 @@ test("Git exclusions preserve existing rules and never hide already tracked loca
568
599
  assert.deepEqual(fs.readFileSync(f.encrypted), original);
569
600
  assert.ok(git(f.root, ["ls-files"]).includes("config/index.secret.cjs"));
570
601
  git(f.root, ["rm", "--cached", "config/index.secret.cjs"]);
602
+ writeSettings({ local: path.join(f.config, "testhost.secret.cjs") }, {});
571
603
  git(f.root, ["add", "-f", "config/testhost.secret.cjs"]);
572
604
  assert.throws(() => load(f), /tracked by Git/);
573
605
  });
@@ -579,13 +611,13 @@ test("recipient files can be tracked and sidecar names are escaped literally in
579
611
  fs.writeFileSync(filename, "module.exports = {};");
580
612
  const local = filename.slice(0, -4) + ".secret.cjs";
581
613
  writeSettings({ local });
582
- Configre(filename, { secrets: true });
614
+ Configre(filename);
583
615
  assert.ok(git(f.root, ["check-ignore", path.basename(local)]).includes(path.basename(local)));
584
616
  const recipient = filename + ".recipients/developer.pub";
585
617
  fs.copyFileSync(f.publicPath(1), recipient);
586
618
  git(f.root, ["add", "-f", path.relative(f.root, recipient)]);
587
619
  const original = fs.readFileSync(filename + ".secrets.enc.json");
588
- assert.equal(Configre(filename, { secrets: true }).api.key, sentinel);
620
+ assert.equal(Configre(filename).api.key, sentinel);
589
621
  assert.notDeepEqual(fs.readFileSync(filename + ".secrets.enc.json"), original);
590
622
  });
591
623
 
@@ -773,7 +805,7 @@ test("a second process cannot write while an encrypted replacement is pending",
773
805
  const os = require('node:os');
774
806
  os.homedir = () => process.env.CONFIGRE_TEST_HOME;
775
807
  const Configre = require(process.env.CONFIGRE_TEST_MODULE);
776
- assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH, { secrets: true }), /another writer/);
808
+ assert.throws(() => Configre(process.env.CONFIGRE_TEST_PATH), /another writer/);
777
809
  `], {
778
810
  encoding: "utf8",
779
811
  env: {
@@ -792,6 +824,5 @@ test("a second process cannot write while an encrypted replacement is pending",
792
824
  assert.equal(load(f).api.key, sentinel + "-new");
793
825
  assert.equal(contested, true);
794
826
  fs.unlinkSync(f.local);
795
- fs.unlinkSync(path.join(f.config, "testhost.secret.cjs"));
796
827
  assert.equal(load(f).api.key, sentinel + "-new");
797
828
  });