configre 2.1.0 → 2.1.2

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
@@ -224,9 +224,11 @@ If this server is not authorized yet, Configre automatically:
224
224
  1. Creates its local identity, if needed.
225
225
  2. Writes `demo/secrets/config/recipients/truco.pub`.
226
226
  3. Creates and pushes a commit containing only that public-key file.
227
- 4. Stops startup with a message explaining that the administrator must publish an updated encrypted file.
227
+ 4. Logs a warning and continues with public configuration only until the administrator publishes an updated encrypted file.
228
228
 
229
- This first stop is expected: publishing a public key does not let the server decrypt the existing ciphertext. Repeating the command does not publish another registration commit for the same key.
229
+ Publishing a public key does not let the server decrypt the existing ciphertext. While authorization is pending, `cfg` preserves the public defaults and selected public profile; fields that exist only in the encrypted file are absent. Repeating the command does not publish another registration commit for the same key. If Git registration fails, Configre logs a warning and still returns public settings; the next Configre load retries registration.
230
+
231
+ Configre uses `log.info` when it creates identity files, writes Git exclusions, creates the recipients directory or a public-key file, updates the encrypted file, and successfully pushes the public key. Messages contain operation names and file paths, never secret values or key contents. These messages use the existing `Configre` LemonLog namespace; use `DEBUG=Configre:*` to display them. Unchanged files and previously published public keys do not generate another creation or publication log.
230
232
 
231
233
  ### 4. Include the server in the encrypted file
232
234
 
@@ -281,7 +283,7 @@ To revoke this server, remove `demo/secrets/config/recipients/truco.pub`, run th
281
283
  - 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.
282
284
  - 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.
283
285
  - 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.
284
- - Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`, Configre stops without overwriting it; use a distinct profile or resolve the key replacement explicitly.
286
+ - Registration names must start with a letter or digit and contain only letters, digits, dots, underscores or hyphens. If another key already occupies `truco.pub`, registration stops without overwriting it, and configuration loading continues with a warning and public settings only; use a distinct profile or resolve the key replacement explicitly.
285
287
  - Configre appends Git exclusions without removing existing rules and refuses to proceed on the administrator if editable secret modules are already tracked. It does not untrack files or rewrite history. It also prepares `.gitignore` when no repository exists yet.
286
288
  - The administrator's `.secret.cjs` files are the source of truth for values, and `recipients/` is the source of truth for authorization. Removing all recipient files revokes them on the next administrator reload; old ciphertext does not restore them. Recover accidentally deleted public-key files from Git before reloading. Missing Git exclusions and ciphertext are regenerated on the administrator, and existing secret modules are never overwritten.
287
289
  - Back up the administrator's editable secrets and private identity securely. Never share or commit `identity.pem`. On POSIX, its directory must have owner-only permissions (`0700`) and the private file must have owner-only permissions (`0600`); on Windows, protection depends on the user profile's filesystem permissions. Private identities and secret input files cannot be symlinks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "configre",
3
- "version": "2.1.0",
3
+ "version": "2.1.2",
4
4
  "description": "🔧 Effortlessly Tailor Your Settings",
5
5
  "type": "module",
6
6
  "engines": {
@@ -3,12 +3,15 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { generatePrivateKey, parsePrivateKey, parsePublicKey } from "./crypto.js";
5
5
  import { stat, readText, withLock } from "./files.js";
6
+ import log from "./log.js";
6
7
 
7
8
  function loadIdentity() {
8
9
  const directory = path.join(os.homedir(), ".config", "configre");
9
10
  const privatePath = path.join(directory, "identity.pem");
10
11
  const publicPath = path.join(directory, "identity.pub");
11
- fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
12
+ if (fs.mkdirSync(directory, { recursive: true, mode: 0o700 })) {
13
+ log.info("Created identity directory", directory);
14
+ }
12
15
  const info = stat(directory);
13
16
  if (!info.isDirectory() || (process.platform !== "win32" && (info.mode & 0o077) !== 0)) {
14
17
  throw new Error(`Configre secrets: identity directory must be private and not a symlink at ${directory}`);
@@ -32,10 +35,12 @@ function loadIdentity() {
32
35
  throw new Error("Configre secrets: private identity is missing; restore it instead of replacing it");
33
36
  }
34
37
  fs.writeFileSync(privatePath, generatePrivateKey(), { flag: "wx", mode: 0o600 });
38
+ log.info("Created private identity file", privatePath);
35
39
  }
36
40
  const identity = parsePrivateKey(readText(privatePath, true));
37
41
  if (!stat(publicPath)) {
38
42
  fs.writeFileSync(publicPath, identity.publicKey, { flag: "wx", mode: 0o644 });
43
+ log.info("Created public identity file", publicPath);
39
44
  }
40
45
  return readIdentity();
41
46
  });
package/secrets/index.js CHANGED
@@ -5,6 +5,7 @@ import { isDeepStrictEqual } from "node:util";
5
5
  import { createRequire } from "node:module";
6
6
  import loadIdentity from "./identity.js";
7
7
  import registerRecipient from "./register.js";
8
+ import log from "./log.js";
8
9
  import { parsePublicKey, validateSecrets, encrypt, decrypt } from "./crypto.js";
9
10
  import { stat, readText, readJSON, withLock, writeEncrypted } from "./files.js";
10
11
 
@@ -63,11 +64,15 @@ function prepareIgnore(paths) {
63
64
  const lines = existing.split(/\r?\n/).filter(line => line && !line.startsWith("#"));
64
65
  if (!isDeepStrictEqual(lines.slice(-rules.length), rules)) {
65
66
  fs.appendFileSync(ignorePath, (existing && !existing.endsWith("\n") ? "\n" : "") + rules.join("\n") + "\n");
67
+ log.info("Wrote Git exclusions for secret files", ignorePath);
66
68
  }
67
69
  }
68
70
 
69
71
  function loadRecipients(directory, identity) {
70
- if (!stat(directory)) fs.mkdirSync(directory, { mode: 0o700 });
72
+ if (!stat(directory)) {
73
+ fs.mkdirSync(directory, { mode: 0o700 });
74
+ log.info("Created recipients directory", directory);
75
+ }
71
76
  if (!stat(directory).isDirectory()) {
72
77
  throw new Error("Configre secrets: recipients must be a directory, not a symlink");
73
78
  }
@@ -129,36 +134,45 @@ function loadSecrets(configPath, configFile, profile) {
129
134
  if (!hasLocal && !hasEncrypted) return [];
130
135
 
131
136
  const identity = loadIdentity();
132
- if (names.length === 0 && hasEncrypted) {
133
- try {
137
+ try {
138
+ if (names.length === 0 && hasEncrypted) {
134
139
  return selectSecrets(validateBundle(decrypt(readJSON(paths.encrypted), identity)), paths, profile);
135
- } catch (error) {
136
- if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
137
- registerRecipient(paths, identity, profile);
138
- throw new Error("Configre secrets: not authorized yet; public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and restart this machine");
139
140
  }
140
- }
141
141
 
142
- const bundle = withLock(paths.encrypted, () => {
143
- const names = localFiles(paths);
144
- const hasEncrypted = !!stat(paths.encrypted);
145
- const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
146
- const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
147
- if (names.length === 0 && hasEncrypted) return previous;
142
+ const bundle = withLock(paths.encrypted, () => {
143
+ const names = localFiles(paths);
144
+ const hasEncrypted = !!stat(paths.encrypted);
145
+ const envelope = hasEncrypted ? readJSON(paths.encrypted) : null;
146
+ const previous = hasEncrypted ? validateBundle(decrypt(envelope, identity)) : null;
147
+ if (names.length === 0 && hasEncrypted) return previous;
148
148
 
149
- prepareIgnore(paths);
150
- const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
151
- const recipients = loadRecipients(paths.recipients, identity);
152
- if (hasEncrypted) {
153
- const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
154
- if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
155
- return settings;
149
+ prepareIgnore(paths);
150
+ const settings = { files: Object.fromEntries(names.map(name => [name, readSecret(path.join(paths.parent, name))])) };
151
+ const recipients = loadRecipients(paths.recipients, identity);
152
+ if (hasEncrypted) {
153
+ const previousRecipients = envelope.recipients.map(({ fingerprint, publicKey }) => ({ fingerprint, publicKey }));
154
+ if (isDeepStrictEqual(settings, previous) && isDeepStrictEqual(recipients, previousRecipients)) {
155
+ return settings;
156
+ }
156
157
  }
158
+ writeEncrypted(paths.encrypted, encrypt(settings, recipients));
159
+ log.info(hasEncrypted ? "Updated encrypted secrets file" : "Created encrypted secrets file", paths.encrypted);
160
+ return settings;
161
+ });
162
+ return selectSecrets(bundle, paths, profile);
163
+ } catch (error) {
164
+ if (error.code !== "CONFIGRE_NOT_AUTHORIZED") throw error;
165
+ let registration = "Public key is published in Git. The administrator must pull, reload Configre and publish secrets.enc.json; then pull the updated encrypted file and reload Configre.";
166
+ try {
167
+ registerRecipient(paths, identity, profile);
168
+ } catch (registrationError) {
169
+ registration = registrationError.code === "CONFIGRE_REGISTRATION_FAILED"
170
+ ? registrationError.message
171
+ : "Automatic public-key registration failed; check Git and file permissions, then reload Configre to retry.";
157
172
  }
158
- writeEncrypted(paths.encrypted, encrypt(settings, recipients));
159
- return settings;
160
- });
161
- return selectSecrets(bundle, paths, profile);
173
+ log.warn("Secrets not authorized; continuing with public settings only.", registration, "Public key:", identity.publicPath);
174
+ return [];
175
+ }
162
176
  }
163
177
 
164
178
  export default loadSecrets;
package/secrets/log.js ADDED
@@ -0,0 +1,3 @@
1
+ import lemonlog from "lemonlog";
2
+
3
+ export default lemonlog("Configre");
@@ -2,11 +2,14 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import config from "./config.js";
5
+ import log from "./log.js";
5
6
  import { parsePublicKey } from "./crypto.js";
6
7
  import { stat, readText, withLock } from "./files.js";
7
8
 
8
9
  function registrationError(message) {
9
- return new Error(`Configre secrets: not authorized; automatic registration ${message}`);
10
+ const error = new Error(`Configre secrets: not authorized; automatic registration ${message}`);
11
+ error.code = "CONFIGRE_REGISTRATION_FAILED";
12
+ return error;
10
13
  }
11
14
 
12
15
  function git(directory, args, operation, options = {}) {
@@ -84,9 +87,15 @@ function registerRecipient(paths, identity, profile) {
84
87
  git(root, ["update-index", "--add", "--cacheinfo", "100644", blob, relative], "adding the public key to the isolated index", { env });
85
88
  const tree = git(root, ["write-tree"], "building the registration tree", { env }).trim();
86
89
  const commit = git(root, ["commit-tree", tree, "-p", head, "-m", `Configre: register ${profile}`], "creating the public-key commit (Git author identity must be configured)").trim();
87
- fs.mkdirSync(directory, { recursive: true });
88
- if (!stat(filename)) fs.writeFileSync(filename, publicKey, { flag: "wx", mode: 0o644 });
90
+ if (fs.mkdirSync(directory, { recursive: true })) {
91
+ log.info("Created recipients directory", directory);
92
+ }
93
+ if (!stat(filename)) {
94
+ fs.writeFileSync(filename, publicKey, { flag: "wx", mode: 0o644 });
95
+ log.info("Created public-key registration file", filename);
96
+ }
89
97
  git(root, ["push", "--no-verify", "--no-follow-tags", "--", remote, `${commit}:${remoteBranch}`], "pushing the public-key commit");
98
+ log.info("Published public key to Git", filename);
90
99
  if (git(root, ["symbolic-ref", "--quiet", "HEAD"], "checking the current branch").trim() !== branch) {
91
100
  throw registrationError("published the public key, but the local branch changed concurrently; synchronize the checkout");
92
101
  }
@@ -7,6 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { pathToFileURL } from "node:url";
8
8
  import test from "node:test";
9
9
  import Configre from "../index.js";
10
+ import log from "../secrets/log.js";
10
11
  import { generatePrivateKey, parsePrivateKey, encrypt } from "../secrets/crypto.js";
11
12
 
12
13
  const sentinel = "synthetic-secret-for-configre-tests";
@@ -17,6 +18,9 @@ test.before(() => {
17
18
  });
18
19
 
19
20
  function fixture(t, { seed = true } = {}) {
21
+ const logs = { info: [], warn: [] };
22
+ t.mock.method(log, "info", (...args) => logs.info.push(args));
23
+ t.mock.method(log, "warn", (...args) => logs.warn.push(args));
20
24
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "configre-secrets-"));
21
25
  const homes = [0, 1, 2].map(index => path.join(root, `home-${index}`));
22
26
  let home = homes[0];
@@ -36,7 +40,7 @@ function fixture(t, { seed = true } = {}) {
36
40
  }
37
41
  t.after(() => fs.rmSync(root, { recursive: true, force: true }));
38
42
  return {
39
- root, config, homes,
43
+ root, config, homes, logs,
40
44
  local: path.join(config, "index.secret.cjs"),
41
45
  encrypted: path.join(config, "secrets.enc.json"),
42
46
  recipients: path.join(config, "recipients"),
@@ -54,6 +58,15 @@ function load(f) {
54
58
  return Configre(f.config);
55
59
  }
56
60
 
61
+ function assertPublicOnly(f, logs, warning) {
62
+ const count = logs.warn.length;
63
+ const config = load(f);
64
+ assert.equal(config.api.key, "");
65
+ assert.equal(logs.warn.length, count + 1);
66
+ assert.match(logs.warn.at(-1).join(" "), warning);
67
+ return config;
68
+ }
69
+
57
70
  function envelope(f) {
58
71
  return JSON.parse(fs.readFileSync(f.encrypted, "utf8"));
59
72
  }
@@ -110,6 +123,45 @@ test("missing secret counterparts leave configuration and identity untouched", t
110
123
  assert.equal(new Configre(f.config).get().api.key, "");
111
124
  assert.deepEqual(fs.readdirSync(f.config), files);
112
125
  assert.equal(fs.existsSync(f.homes[0]), false);
126
+ assert.deepEqual(f.logs, { info: [], warn: [] });
127
+ });
128
+
129
+ test("an unauthorized machine keeps public settings and omits every encrypted field", t => {
130
+ const f = fixture(t);
131
+ writeSettings(f, { api: { key: sentinel }, encryptedOnly: { token: sentinel } });
132
+ load(f);
133
+ const consumer = consumerCopy(f, "unauthorized-consumer");
134
+ f.useHome(1);
135
+ const config = assertPublicOnly(consumer, f.logs, /not authorized.*public settings/i);
136
+ assert.deepEqual(config, { api: { key: "", host: "profile" }, list: [1, 2] });
137
+ assert.equal(Object.hasOwn(config, "encryptedOnly"), false);
138
+ assert.deepEqual(new Configre(consumer.config).get(), config);
139
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
140
+ const result = spawnSync(process.execPath, ["-e", `
141
+ const assert = require('node:assert/strict');
142
+ const os = require('node:os');
143
+ os.homedir = () => process.env.CONFIGRE_TEST_HOME;
144
+ const Configre = require(process.env.CONFIGRE_TEST_MODULE);
145
+ const cfg = Configre(process.env.CONFIGRE_TEST_PATH);
146
+ assert.equal(cfg.api.key, '');
147
+ assert.equal(Object.hasOwn(cfg, 'encryptedOnly'), false);
148
+ console.info('Application started');
149
+ `], {
150
+ encoding: "utf8",
151
+ env: {
152
+ ...process.env, DEBUG: "Configre:*",
153
+ CONFIGRE_TEST_HOME: f.homes[1],
154
+ CONFIGRE_TEST_MODULE: path.join(import.meta.dirname, "..", "index.js"),
155
+ CONFIGRE_TEST_PATH: consumer.config
156
+ }
157
+ });
158
+ assert.equal(result.status, 0, result.stderr);
159
+ const output = result.stdout + result.stderr;
160
+ assert.match(output, /Secrets not authorized; continuing with public settings only/);
161
+ assert.match(output, /Application started/);
162
+ assert.equal(output.includes(sentinel), false);
163
+ assert.equal(output.includes("BEGIN PRIVATE KEY"), false);
164
+ assert.equal(output.includes("BEGIN PUBLIC KEY"), false);
113
165
  });
114
166
 
115
167
  for (const profile of ["testhost", "testhost.dev", "forced"]) {
@@ -135,7 +187,12 @@ test("an existing empty secret module initializes secrets and reuses generated f
135
187
  const ignore = path.join(f.config, ".gitignore");
136
188
  fs.writeFileSync(ignore, "# existing rules\n*.log");
137
189
  const config = load(f);
190
+ const initialLogs = f.logs.info.length;
138
191
  assert.deepEqual(config, Configre(f.config));
192
+ assert.equal(f.logs.info.length, initialLogs);
193
+ for (const filename of [path.dirname(f.privatePath(0)), f.privatePath(0), f.publicPath(0), ignore, f.recipients, f.encrypted]) {
194
+ assert.ok(f.logs.info.some(([message, target]) => /Created|Wrote/.test(message) && target === filename));
195
+ }
139
196
  const original = fs.readFileSync(f.privatePath(0));
140
197
  const published = fs.readFileSync(f.publicPath(0), "utf8");
141
198
  assert.equal(parsePrivateKey(original).publicKey, published);
@@ -156,6 +213,9 @@ test("an existing empty secret module initializes secrets and reuses generated f
156
213
  assert.equal(fs.existsSync(f.encrypted + ".lock"), false);
157
214
  writeSettings(f);
158
215
  assert.equal(load(f).api.key, sentinel);
216
+ assert.deepEqual(f.logs.info.at(-1), ["Updated encrypted secrets file", f.encrypted]);
217
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
218
+ assert.equal(JSON.stringify(f.logs).includes("BEGIN PRIVATE KEY"), false);
159
219
  });
160
220
 
161
221
  test("missing administrator files are recreated without losing values or authorized recipients", t => {
@@ -198,7 +258,7 @@ test("removing every recipient revokes access without restoring keys from old ci
198
258
  assert.notDeepEqual(fs.readFileSync(f.encrypted), encrypted);
199
259
  const consumer = consumerCopy(f, "revoked-consumer");
200
260
  f.useHome(1);
201
- assert.throws(() => load(consumer), /not authorized/);
261
+ assertPublicOnly(consumer, f.logs, /not authorized/);
202
262
  });
203
263
 
204
264
  test("secrets merge last with profiles, arrays, JSON values and the constructor API", t => {
@@ -337,7 +397,7 @@ test("grant and revoke work across isolated machines without consumer project wr
337
397
  const after = fs.readdirSync(consumer.config).map(name => [name, fs.readFileSync(path.join(consumer.config, name))]);
338
398
  assert.deepEqual(after, before);
339
399
  f.useHome(2);
340
- assert.throws(() => load(consumer), /not authorized/);
400
+ assertPublicOnly(consumer, f.logs, /not authorized/);
341
401
  f.useHome(0);
342
402
  fs.unlinkSync(path.join(f.recipients, "developer.pub"));
343
403
  load(f);
@@ -346,9 +406,9 @@ test("grant and revoke work across isolated machines without consumer project wr
346
406
  assert.notEqual(revoked.iv, shared.iv);
347
407
  fs.copyFileSync(f.encrypted, path.join(consumer.config, "secrets.enc.json"));
348
408
  f.useHome(1);
349
- assert.throws(() => load(consumer), /not authorized/);
409
+ assertPublicOnly(consumer, f.logs, /not authorized/);
350
410
  writeSettings({ local: path.join(consumer.config, "index.secret.cjs") }, { api: { key: "replacement" } });
351
- assert.throws(() => load(consumer), /not authorized/);
411
+ assertPublicOnly(consumer, f.logs, /not authorized/);
352
412
  assert.deepEqual(JSON.parse(fs.readFileSync(path.join(consumer.config, "secrets.enc.json"))), revoked);
353
413
  fs.writeFileSync(path.join(consumer.config, "secrets.enc.json"), JSON.stringify(shared));
354
414
  fs.unlinkSync(path.join(consumer.config, "index.secret.cjs"));
@@ -633,7 +693,11 @@ test("a server publishes only its public key once and receives automatic authori
633
693
  fs.writeFileSync(untracked, sentinel);
634
694
  const index = git(f.checkout, ["ls-files", "--stage", "-z"]);
635
695
  f.useHome(1);
636
- assert.throws(() => load(f.consumer), /public key is published in Git/);
696
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
697
+ const registeredPath = fs.realpathSync(f.registration);
698
+ assert.ok(f.logs.info.some(([message, filename]) => message === "Created public-key registration file" && filename === registeredPath));
699
+ assert.deepEqual(f.logs.info.at(-1), ["Published public key to Git", registeredPath]);
700
+ const registrationLogs = f.logs.info.length;
637
701
  const published = git(f.remote, ["rev-parse", "main"]).trim();
638
702
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]).trim(), published);
639
703
  assert.equal(git(f.remote, ["diff-tree", "--no-commit-id", "--name-only", "-r", "main"]).trim(), "config/recipients/testhost.pub");
@@ -645,7 +709,8 @@ test("a server publishes only its public key once and receives automatic authori
645
709
  assert.equal(fs.readFileSync(untracked, "utf8"), sentinel);
646
710
  assert.equal(git(f.remote, ["show", "main:tracked.txt"]), "initial\n");
647
711
  assert.equal(git(f.remote, ["tag", "--list"]), "");
648
- assert.throws(() => load(f.consumer), /public key is published in Git/);
712
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
713
+ assert.equal(f.logs.info.length, registrationLogs);
649
714
  assert.equal(git(f.remote, ["rev-parse", "main"]).trim(), published);
650
715
 
651
716
  f.useHome(0);
@@ -657,7 +722,9 @@ test("a server publishes only its public key once and receives automatic authori
657
722
  git(f.root, ["push", "--quiet"]);
658
723
  git(f.checkout, ["pull", "--quiet", "--ff-only"]);
659
724
  f.useHome(1);
725
+ const warnings = f.logs.warn.length;
660
726
  assert.equal(load(f.consumer).api.key, sentinel);
727
+ assert.equal(f.logs.warn.length, warnings);
661
728
  f.useHome(0);
662
729
  writeSettings(f, { api: { key: sentinel + "-updated" } });
663
730
  load(f);
@@ -679,18 +746,16 @@ test("a rejected registration push preserves the branch and staging area and can
679
746
  const head = git(f.checkout, ["rev-parse", "HEAD"]);
680
747
  const index = fs.readFileSync(path.join(f.checkout, ".git", "index"));
681
748
  f.useHome(1);
682
- assert.throws(() => load(f.consumer), error => {
683
- assert.match(error.message, /failed while pushing the public-key commit/);
684
- assert.equal(error.stack.includes(sentinel), false);
685
- return true;
686
- });
749
+ assertPublicOnly(f.consumer, f.logs, /failed while pushing the public-key commit/);
750
+ assert.equal(JSON.stringify(f.logs).includes(sentinel), false);
751
+ assert.equal(f.logs.info.some(([message]) => message === "Published public key to Git"), false);
687
752
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), head);
688
753
  assert.equal(git(f.remote, ["rev-parse", "main"]), head);
689
754
  assert.deepEqual(fs.readFileSync(path.join(f.checkout, ".git", "index")), index);
690
755
  assert.equal(fs.existsSync(path.join(f.checkout, ".git", "configre-registration.lock")), false);
691
756
  assert.equal(fs.readdirSync(path.join(f.checkout, ".git")).some(name => name.startsWith("configre-registration-")), false);
692
757
  fs.unlinkSync(hook);
693
- assert.throws(() => load(f.consumer), /public key is published in Git/);
758
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
694
759
  assert.equal(git(f.remote, ["rev-list", "--count", "main"]).trim(), "2");
695
760
  });
696
761
 
@@ -706,9 +771,9 @@ test("registration recovers when publication succeeded before the local branch a
706
771
  git(interrupted, ["add", "tracked.txt"]);
707
772
  fs.writeFileSync(tracked, "unstaged work\n");
708
773
  f.useHome(1);
709
- assert.throws(() => load(f.consumer), /public key is published in Git/);
774
+ assertPublicOnly(f.consumer, f.logs, /public key is published in Git/i);
710
775
  const published = git(f.remote, ["rev-parse", "main"]);
711
- assert.throws(() => load({ config: path.join(interrupted, "config") }), /public key is published in Git/);
776
+ assertPublicOnly({ config: path.join(interrupted, "config") }, f.logs, /public key is published in Git/i);
712
777
  git(interrupted, ["pull", "--quiet", "--ff-only"]);
713
778
  assert.equal(git(interrupted, ["rev-parse", "HEAD"]), published);
714
779
  assert.equal(git(f.remote, ["rev-parse", "main"]), published);
@@ -721,16 +786,16 @@ test("registration refuses to publish unrelated local commits or guess a branch"
721
786
  const remoteHead = git(f.remote, ["rev-parse", "main"]);
722
787
  git(f.checkout, ["checkout", "--quiet", "--detach"]);
723
788
  f.useHome(1);
724
- assert.throws(() => load(f.consumer), /detached HEAD/);
789
+ assertPublicOnly(f.consumer, f.logs, /detached HEAD/);
725
790
  git(f.checkout, ["checkout", "--quiet", "main"]);
726
791
  git(f.checkout, ["branch", "--unset-upstream"]);
727
- assert.throws(() => load(f.consumer), /upstream remote/);
792
+ assertPublicOnly(f.consumer, f.logs, /upstream remote/);
728
793
  git(f.checkout, ["branch", "--set-upstream-to=origin/main"]);
729
794
  fs.writeFileSync(path.join(f.checkout, "tracked.txt"), "unpublished work\n");
730
795
  git(f.checkout, ["add", "tracked.txt"]);
731
796
  git(f.checkout, ["commit", "--quiet", "-m", "Unpublished work"]);
732
797
  const localHead = git(f.checkout, ["rev-parse", "HEAD"]);
733
- assert.throws(() => load(f.consumer), /local branch to match its upstream/);
798
+ assertPublicOnly(f.consumer, f.logs, /local branch to match its upstream/);
734
799
  assert.equal(git(f.checkout, ["rev-parse", "HEAD"]), localHead);
735
800
  assert.equal(git(f.remote, ["rev-parse", "main"]), remoteHead);
736
801
  assert.equal(fs.existsSync(f.registration), false);
@@ -742,11 +807,11 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
742
807
  const previousArgs = process.argv;
743
808
  process.argv = [...previousArgs.filter(arg => !arg.startsWith("--config=")), "--config=../escape"];
744
809
  t.after(() => { process.argv = previousArgs; });
745
- assert.throws(() => load(f.consumer), /requires a profile/);
810
+ assertPublicOnly(f.consumer, f.logs, /requires a profile/);
746
811
  process.argv = previousArgs;
747
812
  fs.mkdirSync(path.dirname(f.registration));
748
813
  fs.copyFileSync(f.publicPath(2), f.registration);
749
- assert.throws(() => load(f.consumer), /different key for this profile/);
814
+ assertPublicOnly(f.consumer, f.logs, /different key for this profile/);
750
815
  assert.equal(fs.readFileSync(f.registration, "utf8"), fs.readFileSync(f.publicPath(2), "utf8"));
751
816
  fs.unlinkSync(f.registration);
752
817
  fs.copyFileSync(f.publicPath(2), path.join(f.recipients, "testhost.pub"));
@@ -754,7 +819,7 @@ test("registration rejects profile collisions, unsafe names and damaged encrypte
754
819
  git(f.root, ["commit", "--quiet", "-m", "Existing server identity"]);
755
820
  git(f.root, ["push", "--quiet"]);
756
821
  const remoteHead = git(f.remote, ["rev-parse", "main"]);
757
- assert.throws(() => load(f.consumer), /different published key/);
822
+ assertPublicOnly(f.consumer, f.logs, /different published key/);
758
823
  const damaged = envelope(f);
759
824
  damaged.extra = sentinel;
760
825
  fs.writeFileSync(path.join(f.consumer.config, "secrets.enc.json"), JSON.stringify(damaged));