@gorlitzer-labs/comb 0.1.4 → 0.1.6
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 +30 -1
- package/package.json +1 -1
- package/src/backends/sops.mjs +51 -7
- package/src/cli.mjs +19 -0
- package/src/sync.mjs +166 -0
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ matters. Everything else is recoverable; that is not. Back it up somewhere real.
|
|
|
60
60
|
| `comb rotate <NAME>` | tell you where to go, then take the new value |
|
|
61
61
|
| `comb audit` | which secrets leaked into transcripts and shell history |
|
|
62
62
|
| `comb rm <NAME>` | forget one (does **not** revoke it at the provider) |
|
|
63
|
+
| `comb sync <host>` | merge the store with another realm's, both ways, over ssh |
|
|
63
64
|
|
|
64
65
|
Values are never printed, never passed as command-line arguments, never logged.
|
|
65
66
|
`comb get NAME --reveal` exists for when you genuinely need to read one, and
|
|
@@ -174,9 +175,37 @@ is actually compromised, revoke it here **and** rotate the secrets it held
|
|
|
174
175
|
(`comb rotate`), exactly as you would for any exposed credential. Revocation
|
|
175
176
|
stops future leaks; rotation closes the ones already out.
|
|
176
177
|
|
|
178
|
+
## Keeping realms in step — `comb sync`
|
|
179
|
+
|
|
180
|
+
Each realm holds its own copy of the store. `comb sync bankai` merges this
|
|
181
|
+
machine's copy with bankai's, both ways, and writes the one result to both:
|
|
182
|
+
|
|
183
|
+
- a secret on one side only is copied to the other
|
|
184
|
+
- on both sides, the newer one wins (a rotation propagates)
|
|
185
|
+
- a removal travels: `comb rm` leaves a marker (the name and when, no value),
|
|
186
|
+
so the next sync deletes it on the other side instead of copying it back
|
|
187
|
+
- a secret changed on **both** sides since the last sync is named in the
|
|
188
|
+
output; the newer is kept and the other value is gone
|
|
189
|
+
|
|
190
|
+
Only ciphertext crosses the wire. The other side's store is fetched over ssh,
|
|
191
|
+
decrypted here with this machine's key, merged, re-encrypted to the same
|
|
192
|
+
recipients and written back. The other machine needs only ssh. The sync refuses
|
|
193
|
+
and writes nothing if the two stores are encrypted to different recipients, so
|
|
194
|
+
syncing never adds or revokes a realm. Each sync keeps the timestamps it agreed
|
|
195
|
+
on in `~/.comb/sync/<host>.json`: names and times, never values.
|
|
196
|
+
|
|
197
|
+
**Upgrade every realm to comb 0.1.6 before relying on it.** An older comb does
|
|
198
|
+
not know the deletion marker: it would list a removed secret as though it were
|
|
199
|
+
still there, with no value.
|
|
200
|
+
|
|
201
|
+
Why not a private git repo: sops encrypts values, not names, so the repo would
|
|
202
|
+
show which services you use and when you rotated each one, and git keeps every
|
|
203
|
+
old copy of the ciphertext forever. Syncing directly between your own machines
|
|
204
|
+
keeps no history and puts no copy on anyone else's server.
|
|
205
|
+
|
|
177
206
|
## Requires
|
|
178
207
|
|
|
179
|
-
`sops` and `age` on PATH (`brew install sops age`, or your package manager).
|
|
208
|
+
`sops` 3.11+ and `age` on PATH (`brew install sops age`, or your package manager).
|
|
180
209
|
Node 20+.
|
|
181
210
|
|
|
182
211
|
MIT.
|
package/package.json
CHANGED
package/src/backends/sops.mjs
CHANGED
|
@@ -62,12 +62,33 @@ export function init(home = combHome()) {
|
|
|
62
62
|
return { home, recipient: ageRecipient(home) };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// `sops set --value-stdin` arrived in sops 3.11.0. Older sops would need the
|
|
66
|
+
// value as an argument, so refuse rather than fall back to the leak.
|
|
67
|
+
let valueStdinChecked = false;
|
|
68
|
+
export function sopsSupportsValueStdin(versionText) {
|
|
69
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(versionText || "");
|
|
70
|
+
if (!m) return false;
|
|
71
|
+
const [maj, min] = [Number(m[1]), Number(m[2])];
|
|
72
|
+
return maj > 3 || (maj === 3 && min >= 11);
|
|
73
|
+
}
|
|
74
|
+
function requireValueStdin() {
|
|
75
|
+
if (valueStdinChecked) return;
|
|
76
|
+
const v = spawnSync("sops", ["--version"], { encoding: "utf-8" });
|
|
77
|
+
if (!sopsSupportsValueStdin(v.stdout)) {
|
|
78
|
+
throw new Error(`sops 3.11 or newer is needed to store a value without exposing it (found: ${(v.stdout || "none").split("\n")[0].trim()}) — upgrade sops`);
|
|
79
|
+
}
|
|
80
|
+
valueStdinChecked = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
function sopsEnv(home) {
|
|
66
84
|
return { ...process.env, SOPS_AGE_KEY_FILE: keyPath(home) };
|
|
67
85
|
}
|
|
68
86
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The whole store as it is on disk, decrypted in memory, deletion markers
|
|
89
|
+
* included. Only `comb sync` needs those; everything else wants readAll.
|
|
90
|
+
*/
|
|
91
|
+
export function readRaw(home = combHome()) {
|
|
71
92
|
if (!initialised(home)) throw new Error("no store yet — run `comb init`");
|
|
72
93
|
const r = spawnSync("sops", ["--decrypt", "--output-type", "json", storePath(home)], {
|
|
73
94
|
encoding: "utf-8",
|
|
@@ -77,6 +98,11 @@ export function readAll(home = combHome()) {
|
|
|
77
98
|
return JSON.parse(r.stdout || "{}");
|
|
78
99
|
}
|
|
79
100
|
|
|
101
|
+
/** The live secrets, decrypted in memory. Never written anywhere. */
|
|
102
|
+
export function readAll(home = combHome()) {
|
|
103
|
+
return Object.fromEntries(Object.entries(readRaw(home)).filter(([, e]) => !e?.deleted));
|
|
104
|
+
}
|
|
105
|
+
|
|
80
106
|
/**
|
|
81
107
|
* Write one entry.
|
|
82
108
|
*
|
|
@@ -85,20 +111,38 @@ export function readAll(home = combHome()) {
|
|
|
85
111
|
*/
|
|
86
112
|
export function writeSecret(name, value, meta = {}, home = combHome()) {
|
|
87
113
|
const entry = { value, updated: new Date().toISOString(), ...meta };
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
114
|
+
requireValueStdin();
|
|
115
|
+
// Subcommand form (`sops set <file> <index>`), not the legacy --set flag:
|
|
116
|
+
// `unset` only exists as a subcommand, and having the pair disagree is how you
|
|
117
|
+
// end up with a delete that silently is not one.
|
|
118
|
+
//
|
|
119
|
+
// The value goes in on stdin, never as an argument. It used to be the last
|
|
120
|
+
// argument, which put every secret comb stored into the process table for as
|
|
121
|
+
// long as sops ran — readable by `ps` from any process on the machine, which
|
|
122
|
+
// is the exact leak comb exists to stop.
|
|
123
|
+
const r = spawnSync("sops", ["set", "--value-stdin", storePath(home), `["${name}"]`], {
|
|
92
124
|
encoding: "utf-8",
|
|
93
125
|
env: sopsEnv(home),
|
|
126
|
+
input: JSON.stringify(entry),
|
|
94
127
|
});
|
|
95
128
|
if (r.status !== 0) throw new Error(`could not write ${name}: ${r.stderr?.trim()}`);
|
|
96
129
|
}
|
|
97
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Forget one: the value is erased, and a marker — the name and when — stays.
|
|
133
|
+
*
|
|
134
|
+
* Unsetting the key outright used to be enough. With more than one machine it
|
|
135
|
+
* is not: the next `comb sync` would find the secret on the other side, take it
|
|
136
|
+
* for one this side had never seen, and copy it straight back. The marker is
|
|
137
|
+
* what lets a deletion travel. It holds no value, and readAll never shows it.
|
|
138
|
+
*/
|
|
98
139
|
export function deleteSecret(name, home = combHome()) {
|
|
99
|
-
|
|
140
|
+
if (!(name in readAll(home))) throw new Error(`no secret called ${name}`);
|
|
141
|
+
requireValueStdin();
|
|
142
|
+
const r = spawnSync("sops", ["set", "--value-stdin", storePath(home), `["${name}"]`], {
|
|
100
143
|
encoding: "utf-8",
|
|
101
144
|
env: sopsEnv(home),
|
|
145
|
+
input: JSON.stringify({ deleted: true, updated: new Date().toISOString() }),
|
|
102
146
|
});
|
|
103
147
|
if (r.status !== 0) throw new Error(`could not remove ${name}: ${r.stderr?.trim()}`);
|
|
104
148
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { spawnSync } from "node:child_process";
|
|
|
22
22
|
import { createInterface } from "node:readline";
|
|
23
23
|
import * as store from "./backends/sops.mjs";
|
|
24
24
|
import * as realms from "./realms.mjs";
|
|
25
|
+
import { syncWith } from "./sync.mjs";
|
|
25
26
|
import { scan, defaultTargets } from "./audit.mjs";
|
|
26
27
|
import { VERSION, checkAndNotify } from "./update-check.mjs";
|
|
27
28
|
|
|
@@ -72,6 +73,7 @@ function help() {
|
|
|
72
73
|
` ${B("comb realm add")} <name> <age1…> let a realm's key read the store (re-encrypts)`,
|
|
73
74
|
` ${B("comb realm rm")} <name> revoke a realm (re-encrypts without it)`,
|
|
74
75
|
` ${B("comb realm ls")} realms that can read the store`,
|
|
76
|
+
` ${B("comb sync")} <host> merge the store with another realm's, both ways (over ssh)`,
|
|
75
77
|
` ${B("comb pubkey")} print THIS machine's age public key`,
|
|
76
78
|
` ${B("comb rm")} <NAME> forget one`,
|
|
77
79
|
"",
|
|
@@ -172,6 +174,23 @@ try {
|
|
|
172
174
|
break;
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
case "sync": {
|
|
178
|
+
const peer = pos[0];
|
|
179
|
+
if (!peer) throw new Error("with which machine? `comb sync bankai` (any ssh destination)");
|
|
180
|
+
const r = syncWith(peer, typeof flags["remote-home"] === "string" ? { remoteHome: flags["remote-home"] } : {});
|
|
181
|
+
if (!r.changedHere && !r.changedThere) {
|
|
182
|
+
say(`${green("✓")} already in sync with ${B(peer)} ${dim(`(${r.live} secret(s))`)}`);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
say(`${green("✓")} synced with ${B(peer)} ${dim(`(${r.live} secret(s), only ciphertext crossed the wire)`)}`);
|
|
186
|
+
if (r.pulled.length) say(` ${dim("←")} from ${peer}: ${r.pulled.join(", ")}`);
|
|
187
|
+
if (r.pushed.length) say(` ${dim("→")} to ${peer}: ${r.pushed.join(", ")}`);
|
|
188
|
+
for (const c of r.conflicts) {
|
|
189
|
+
say(yellow(` ⚠ ${c.name} changed on both sides since the last sync — kept ${c.kept === "theirs" ? peer + "'s" : "this machine's"} (the newer); the other value is gone`));
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
175
194
|
case "pubkey": {
|
|
176
195
|
say(realms.localRecipient());
|
|
177
196
|
break;
|
package/src/sync.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* comb sync — one vault on two machines, kept the same both ways.
|
|
3
|
+
*
|
|
4
|
+
* A realm can read the store, but each machine still holds its own copy, and a
|
|
5
|
+
* secret set on one used to stay there until somebody copied the file across —
|
|
6
|
+
* which also threw away whatever the other side had added. This merges the two.
|
|
7
|
+
*
|
|
8
|
+
* What crosses the wire is ciphertext only. The other side's store is fetched
|
|
9
|
+
* over ssh (`cat`), decrypted HERE with this machine's key (both are recipients
|
|
10
|
+
* of the same store), merged, re-encrypted to the same recipients, and the one
|
|
11
|
+
* resulting file is written to both sides. The other machine needs nothing but
|
|
12
|
+
* ssh: no comb, no sops, no key in use.
|
|
13
|
+
*
|
|
14
|
+
* The merge, secret by secret:
|
|
15
|
+
* - on one side only → copied to the other
|
|
16
|
+
* - on both, different → the newer `updated` wins
|
|
17
|
+
* - a deletion marker → wins over an older value, loses to a newer one
|
|
18
|
+
* Without a record of the last sync, "different" cannot tell an ordinary
|
|
19
|
+
* rotation from a secret changed on BOTH sides since. So each sync leaves the
|
|
20
|
+
* timestamps it agreed on (names and times, never values) in sync/<peer>.json;
|
|
21
|
+
* the next one compares against it and names anything changed on both sides.
|
|
22
|
+
* The newer still wins — but you are told which one was overwritten.
|
|
23
|
+
*
|
|
24
|
+
* It refuses, and writes nothing, when the two stores are not encrypted to the
|
|
25
|
+
* same recipients: a sync must never be the way a realm is added or revoked.
|
|
26
|
+
* And it re-reads the other side just before writing, so a change made there
|
|
27
|
+
* mid-sync is not overwritten.
|
|
28
|
+
*
|
|
29
|
+
* Not a ledger, on purpose. A ledger keeps every old value; for secrets the
|
|
30
|
+
* point of rotating is that the old value is gone.
|
|
31
|
+
*/
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync, existsSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { tmpdir } from "node:os";
|
|
36
|
+
import { combHome, storePath, keyPath, readRaw } from "./backends/sops.mjs";
|
|
37
|
+
import { localRecipient } from "./realms.mjs";
|
|
38
|
+
|
|
39
|
+
const SAFE_PEER = /^[A-Za-z0-9_.@-]+$/;
|
|
40
|
+
const SAFE_PATH = /^[~A-Za-z0-9_./-]+$/;
|
|
41
|
+
|
|
42
|
+
/** The age recipients a store is encrypted to, read from its sops metadata. */
|
|
43
|
+
export function recipientsOf(ciphertext) {
|
|
44
|
+
return [...new Set([...(ciphertext || "").matchAll(/recipient:\s*(age1[a-z0-9]+)/g)].map((m) => m[1]))].sort();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Equal as data, whatever order the keys are in. A byte comparison of the JSON
|
|
48
|
+
// minds order, and the merge sorts by name — so two identical stores used to
|
|
49
|
+
// look changed and both files were rewritten on every sync.
|
|
50
|
+
const canonical = (x) => Array.isArray(x) ? x.map(canonical)
|
|
51
|
+
: x && typeof x === "object" ? Object.fromEntries(Object.keys(x).sort().map((k) => [k, canonical(x[k])])) : x;
|
|
52
|
+
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
|
53
|
+
const stamp = (e) => e?.updated ?? "";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge two decrypted stores. Pure: no I/O, so every rule is testable alone.
|
|
57
|
+
*
|
|
58
|
+
* `base` is the name → updated map the previous sync agreed on (may be empty).
|
|
59
|
+
* Returns the merged store and, by name only, what moved and what collided.
|
|
60
|
+
*/
|
|
61
|
+
export function mergeStores(local, remote, base = {}) {
|
|
62
|
+
const merged = {};
|
|
63
|
+
const report = { pulled: [], pushed: [], conflicts: [] };
|
|
64
|
+
const names = [...new Set([...Object.keys(local), ...Object.keys(remote)])].sort();
|
|
65
|
+
for (const n of names) {
|
|
66
|
+
const a = local[n], b = remote[n];
|
|
67
|
+
if (b === undefined) { merged[n] = a; report.pushed.push(n); continue; }
|
|
68
|
+
if (a === undefined) { merged[n] = b; report.pulled.push(n); continue; }
|
|
69
|
+
if (same(a, b)) { merged[n] = a; continue; }
|
|
70
|
+
// Newer wins; on an exact tie keep this side's, so the result is decided.
|
|
71
|
+
const remoteWins = stamp(b) > stamp(a);
|
|
72
|
+
merged[n] = remoteWins ? b : a;
|
|
73
|
+
(remoteWins ? report.pulled : report.pushed).push(n);
|
|
74
|
+
// Changed on both sides since the last agreed state: say so.
|
|
75
|
+
if (n in base && stamp(a) !== base[n] && stamp(b) !== base[n]) {
|
|
76
|
+
report.conflicts.push({ name: n, kept: remoteWins ? "theirs" : "ours" });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { merged, report };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ssh(peer, command, input) {
|
|
83
|
+
const bin = process.env.COMB_SSH || "ssh"; // overridable for tests
|
|
84
|
+
return spawnSync(bin, ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", peer, command],
|
|
85
|
+
{ encoding: "utf-8", input, maxBuffer: 64 * 1024 * 1024 });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function fetch(peer, remoteHome) {
|
|
89
|
+
const r = ssh(peer, `cat ${remoteHome}/secrets.yaml`);
|
|
90
|
+
if (r.status !== 0) throw new Error(`could not read ${peer}:${remoteHome}/secrets.yaml — ${r.stderr?.trim() || "ssh failed"}`);
|
|
91
|
+
return r.stdout;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function decrypt(ciphertext, home) {
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), "comb-sync-"));
|
|
96
|
+
try {
|
|
97
|
+
// Ciphertext only: it is safe on disk, and sops wants a file to read.
|
|
98
|
+
const f = join(dir, "peer.yaml");
|
|
99
|
+
writeFileSync(f, ciphertext, { mode: 0o600 });
|
|
100
|
+
const r = spawnSync("sops", ["--decrypt", "--output-type", "json", f],
|
|
101
|
+
{ encoding: "utf-8", env: { ...process.env, SOPS_AGE_KEY_FILE: keyPath(home) } });
|
|
102
|
+
if (r.status !== 0) throw new Error(`could not decrypt the other side's store with this machine's key: ${r.stderr?.trim()}`);
|
|
103
|
+
return JSON.parse(r.stdout || "{}");
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function encrypt(plain, recipients, home) {
|
|
110
|
+
// Same as realms.rekeyStore: sops reads a private temp file, never /dev/stdin
|
|
111
|
+
// (not openable in some non-interactive contexts), and it is wiped at once.
|
|
112
|
+
const dir = mkdtempSync(join(tmpdir(), "comb-sync-"));
|
|
113
|
+
try {
|
|
114
|
+
const f = join(dir, "store.json");
|
|
115
|
+
writeFileSync(f, JSON.stringify(plain), { mode: 0o600 });
|
|
116
|
+
const r = spawnSync("sops",
|
|
117
|
+
["--encrypt", "--age", recipients.join(","), "--input-type", "json", "--output-type", "yaml", f],
|
|
118
|
+
{ encoding: "utf-8", env: { ...process.env, SOPS_AGE_KEY_FILE: keyPath(home) } });
|
|
119
|
+
if (r.status !== 0) throw new Error(`could not encrypt the merged store: ${r.stderr?.trim()}`);
|
|
120
|
+
return r.stdout;
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const basePath = (peer, home) => join(home, "sync", `${peer}.json`);
|
|
127
|
+
const readBase = (peer, home) => existsSync(basePath(peer, home)) ? JSON.parse(readFileSync(basePath(peer, home), "utf-8")) : {};
|
|
128
|
+
function writeBase(peer, store, home) {
|
|
129
|
+
mkdirSync(join(home, "sync"), { recursive: true, mode: 0o700 });
|
|
130
|
+
// Names and timestamps only — never a value.
|
|
131
|
+
const stamps = Object.fromEntries(Object.entries(store).map(([n, e]) => [n, stamp(e)]));
|
|
132
|
+
writeFileSync(basePath(peer, home), JSON.stringify(stamps, null, 1) + "\n", { mode: 0o600 });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Sync this machine's store with `peer` (an ssh destination). */
|
|
136
|
+
export function syncWith(peer, { remoteHome = "~/.comb", home = combHome() } = {}) {
|
|
137
|
+
if (!SAFE_PEER.test(peer)) throw new Error(`not a host comb will pass to ssh: ${JSON.stringify(peer)}`);
|
|
138
|
+
if (!SAFE_PATH.test(remoteHome)) throw new Error(`not a path comb will pass to ssh: ${JSON.stringify(remoteHome)}`);
|
|
139
|
+
|
|
140
|
+
const theirsCipher = fetch(peer, remoteHome);
|
|
141
|
+
const oursCipher = readFileSync(storePath(home), "utf-8");
|
|
142
|
+
const ours = recipientsOf(oursCipher), theirs = recipientsOf(theirsCipher);
|
|
143
|
+
if (!ours.includes(localRecipient(home))) throw new Error("this machine's key is not a recipient of its own store — run `comb init`?");
|
|
144
|
+
if (!same(ours, theirs)) {
|
|
145
|
+
throw new Error(`the two stores are encrypted to different recipients (here ${ours.length}, ${peer} ${theirs.length}). ` +
|
|
146
|
+
"Add or revoke realms on both sides first — sync never changes who can read the store.");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const local = readRaw(home), remote = decrypt(theirsCipher, home);
|
|
150
|
+
const { merged, report } = mergeStores(local, remote, readBase(peer, home));
|
|
151
|
+
const changedHere = !same(merged, local), changedThere = !same(merged, remote);
|
|
152
|
+
|
|
153
|
+
if (changedHere || changedThere) {
|
|
154
|
+
const out = encrypt(merged, ours, home);
|
|
155
|
+
if (changedThere) {
|
|
156
|
+
if (fetch(peer, remoteHome) !== theirsCipher) throw new Error(`${peer} changed its store during the sync — nothing was written; run it again`);
|
|
157
|
+
const tmp = `${remoteHome}/secrets.yaml.comb-sync`;
|
|
158
|
+
const w = ssh(peer, `umask 077 && cat > ${tmp} && mv ${tmp} ${remoteHome}/secrets.yaml`, out);
|
|
159
|
+
if (w.status !== 0) throw new Error(`could not write ${peer}'s store — nothing changed here: ${w.stderr?.trim()}`);
|
|
160
|
+
}
|
|
161
|
+
if (changedHere) writeFileSync(storePath(home), out);
|
|
162
|
+
}
|
|
163
|
+
writeBase(peer, merged, home);
|
|
164
|
+
const live = Object.values(merged).filter((e) => !e?.deleted).length;
|
|
165
|
+
return { ...report, changedHere, changedThere, live };
|
|
166
|
+
}
|