create-evda 0.1.8 → 0.1.9
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/CHANGELOG.md +37 -1
- package/README.md +8 -2
- package/bin/evda.mjs +157 -4
- package/create-evda.mjs +6 -1
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +18 -2
- package/lib/connect.mjs +8 -1
- package/lib/day-file.mjs +58 -9
- package/lib/day-writer.mjs +103 -0
- package/lib/doctor.mjs +13 -0
- package/lib/every-chat.mjs +132 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +44 -3
- package/lib/gather.mjs +3 -1
- package/lib/keychain.mjs +144 -0
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +24 -4
- package/lib/mcp.mjs +893 -73
- package/lib/measurement-log.mjs +246 -0
- package/lib/messages.mjs +5 -2
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +20 -0
- package/lib/routines.mjs +42 -3
- package/lib/sigstore-trust.json +20 -0
- package/lib/skills.mjs +1 -1
- package/lib/sync-lock.mjs +143 -0
- package/lib/sync-rules.mjs +154 -1
- package/lib/tools.mjs +423 -24
- package/lib/watch.mjs +160 -0
- package/package.json +1 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +25 -4
- package/templates/evda-digest.md +3 -1
package/lib/filechanges.mjs
CHANGED
|
@@ -36,6 +36,10 @@ import { admitDevice, devicesOf, readDevices, recordOf, retireDevice } from "./d
|
|
|
36
36
|
import { resolveRole } from "./roles.mjs";
|
|
37
37
|
import { boundaries, insideAny } from "./records.mjs";
|
|
38
38
|
import { EvdaError } from "./errors.mjs";
|
|
39
|
+
import { carryList, datalessAmong, excluder, fileClass, walk as walkRules } from "./sync-rules.mjs";
|
|
40
|
+
// The day role, re-exported so pairing reads it through the four person libs the phone bundles.
|
|
41
|
+
export { dayRole } from "./day-file.mjs";
|
|
42
|
+
import { dayRole, localDate } from "./day-file.mjs";
|
|
39
43
|
|
|
40
44
|
export const FILE_SET = "file.set";
|
|
41
45
|
export const FILE_REMOVE = "file.remove";
|
|
@@ -365,6 +369,49 @@ export function grantView(evda) {
|
|
|
365
369
|
};
|
|
366
370
|
}
|
|
367
371
|
|
|
372
|
+
// The id this record's computer speaks as (HQ-2005). A record create-evda made grants its own id
|
|
373
|
+
// owner, and the computer is the record id. A record whose parts were declared by hand may grant
|
|
374
|
+
// owner to a named device instead (his: `laptop.atlas` owner of every part, `hestia.atlas` an
|
|
375
|
+
// editor, the record id nothing); then the computer is the one device that owns every private part
|
|
376
|
+
// this record homes. None, or more than one, is refused in one line naming what was found, so a
|
|
377
|
+
// caller can refuse before it writes anything. A record with no private parts is its own id.
|
|
378
|
+
//
|
|
379
|
+
// A computer that pins a device as a PEER in its own state is not that device (HQ-2040): Hestia
|
|
380
|
+
// holds the laptop as a peer, so it never speaks as laptop.atlas. `notMe` lists those pins. With
|
|
381
|
+
// them set aside, the one owner left is this computer even when it owns only some parts (his
|
|
382
|
+
// Hestia owns core, where a phone pairs); none left is the same refusal, so a computer with no
|
|
383
|
+
// grant of its own writes nothing rather than minting a second identity for another device's id.
|
|
384
|
+
export const NOT_AN_OWNER_LEAD = "This record has private parts";
|
|
385
|
+
export async function ownerDevice(evda, { notMe = [] } = {}) {
|
|
386
|
+
const own = typeof evda.manifest?.id === "string" ? evda.manifest.id : "";
|
|
387
|
+
const view = grantView(evda);
|
|
388
|
+
const parts = view.index.filter((c) => !c.public && !c.mount);
|
|
389
|
+
if (!own || parts.length === 0) return own;
|
|
390
|
+
const owns = async (id) => {
|
|
391
|
+
const out = [];
|
|
392
|
+
for (const c of parts) if (await view.mayShareKey(id, c.id)) out.push(c.name);
|
|
393
|
+
return out;
|
|
394
|
+
};
|
|
395
|
+
if ((await owns(own)).length > 0) return own;
|
|
396
|
+
const grants = (readGrants(evda) ?? []).filter((g) => g.role === "owner" && g.principalId && g.problems.every((p) => p.level !== "fail"));
|
|
397
|
+
const devices = readDevices(evda) ?? { lists: new Map(), problems: [] };
|
|
398
|
+
const names = new Map();
|
|
399
|
+
for (const g of grants) for (const d of devicesOf(devices, g.principalId)) if (!names.has(d)) names.set(d, g.holder?.name ?? d);
|
|
400
|
+
const found = [];
|
|
401
|
+
const peers = new Set(notMe);
|
|
402
|
+
for (const id of [...names.keys()].sort()) if (id !== own && !peers.has(id)) found.push({ id, name: names.get(id), parts: await owns(id) });
|
|
403
|
+
const full = found.filter((f) => f.parts.length === parts.length);
|
|
404
|
+
if (full.length === 1) return full[0].id;
|
|
405
|
+
const some = found.filter((f) => f.parts.length > 0);
|
|
406
|
+
if (peers.size > 0 && full.length === 0 && some.length === 1) return some[0].id;
|
|
407
|
+
const list = parts.map((c) => c.name).join(", ");
|
|
408
|
+
if (full.length > 1) {
|
|
409
|
+
throw new EvdaError(`${NOT_AN_OWNER_LEAD} (${list}), and ${full.length} devices own every one of them (${full.map((f) => f.name).join(", ")}), so this computer cannot tell which of them it is. Nothing was written.`, { code: "NOT_AN_OWNER", exit: 2 });
|
|
410
|
+
}
|
|
411
|
+
const who = found.filter((f) => f.parts.length > 0).map((f) => `${f.name} owns ${f.parts.join(", ")}`);
|
|
412
|
+
throw new EvdaError(`${NOT_AN_OWNER_LEAD} (${list}), but this computer is not an owner of any of them, and no one device owns them all${who.length ? `: ${who.join("; ")}` : ": no grant names an owner"}. Nothing was written.`, { code: "NOT_AN_OWNER", exit: 2 });
|
|
413
|
+
}
|
|
414
|
+
|
|
368
415
|
// Paths under an append-only role, where two devices' concurrent appends union instead of
|
|
369
416
|
// conflicting: the daily notes and the ledger (Spec 08, section 3).
|
|
370
417
|
export function unionPaths(evda) {
|
|
@@ -415,10 +462,16 @@ export function circleCrypto(crypto) {
|
|
|
415
462
|
// has no mapping for the home record, still unions where the home does.
|
|
416
463
|
// onMinted optional ({ change, circle, payload }) -> after the Change is appended, once per
|
|
417
464
|
// circle the file reaches
|
|
418
|
-
// blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a file
|
|
419
|
-
// BLOB_THRESHOLD bytes
|
|
420
|
-
// bytes are sealed once under a fresh content key and kept
|
|
421
|
-
// carries { hash, contentKey } in place of the text. Absent,
|
|
465
|
+
// blobs optional { seal(bytes) -> { hash, contentKey }, link(changeId, hash) }: a text file
|
|
466
|
+
// above BLOB_THRESHOLD bytes or not UTF-8, and a file the manifest carries, rides the
|
|
467
|
+
// blob lane (Spec 08 §1). Its bytes are sealed once under a fresh content key and kept
|
|
468
|
+
// by the device; the payload carries { hash, contentKey } in place of the text. Absent,
|
|
469
|
+
// such a file is minted as text.
|
|
470
|
+
// carry optional, the manifest's `sync.carry` list (sync-rules `carryList`). Every file that
|
|
471
|
+
// is neither text nor carried is a REFERENCE (HQ-2017): its payload is { path, sha256,
|
|
472
|
+
// baseSha256, ref: { name, size } }, with no text and no blob, and nothing is sealed.
|
|
473
|
+
// The scan passes `how.reference` for such a file and no bytes; a file that reaches
|
|
474
|
+
// mint with bytes is classified here again, so no caller can seal media by mistake.
|
|
422
475
|
//
|
|
423
476
|
// Throwing is how a callback says "not recorded": the scan leaves the path for the next run.
|
|
424
477
|
export const BLOB_THRESHOLD = 256 * 1024;
|
|
@@ -431,8 +484,17 @@ const utf8OrNull = (bytes) => {
|
|
|
431
484
|
}
|
|
432
485
|
};
|
|
433
486
|
|
|
434
|
-
|
|
487
|
+
// The reference payload for a file whose bytes do not travel (HQ-2017).
|
|
488
|
+
const refPayload = (address, sha256, baseSha256, size) => ({
|
|
489
|
+
path: address,
|
|
490
|
+
sha256,
|
|
491
|
+
baseSha256,
|
|
492
|
+
ref: { name: address.slice(address.lastIndexOf("/") + 1), size },
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
export function fileMinter({ log, index, seal, notifyFor, previousSha, changesetId, originUser = null, onMinted, blobs, unionPath = null, readText = null, carry = [] }) {
|
|
435
496
|
const at = addressing(index);
|
|
497
|
+
const classOf = fileClass(carry);
|
|
436
498
|
const circleOf = (path) => {
|
|
437
499
|
const c = circleForPath(index, path);
|
|
438
500
|
if (!c) throw new EvdaError(`${path} is in no circle, so it has no one to sync to.`, { code: "NO_CIRCLE" });
|
|
@@ -461,10 +523,11 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
|
|
|
461
523
|
return first;
|
|
462
524
|
};
|
|
463
525
|
return {
|
|
464
|
-
async mint(item, bytes) {
|
|
526
|
+
async mint(item, bytes, how = {}) {
|
|
465
527
|
const home = circleOf(item.path);
|
|
466
528
|
const address = at.address(home, item.path);
|
|
467
|
-
const
|
|
529
|
+
const kind = how.reference ? "reference" : classOf(item.path);
|
|
530
|
+
const text = kind === "reference" ? null : utf8OrNull(bytes);
|
|
468
531
|
const circles = home.mount ? [home] : circlesFor(index, item.path, text, readText);
|
|
469
532
|
const union = unionPath?.(item.path) === true ? { union: true } : {};
|
|
470
533
|
// A circle the file reached before and reaches no longer (its mark was taken off, or its home
|
|
@@ -474,7 +537,13 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
|
|
|
474
537
|
const withdraw = async () => {
|
|
475
538
|
if (withdrawn.length > 0) await append(FILE_REMOVE, item.path, { path: address, sha256: null, baseSha256: await previousSha(item.path) }, withdrawn);
|
|
476
539
|
};
|
|
477
|
-
if (
|
|
540
|
+
if (kind === "reference") {
|
|
541
|
+
const size = how.reference?.size ?? bytes.length;
|
|
542
|
+
await append(FILE_SET, item.path, refPayload(address, item.sha256, await previousSha(item.path), size), circles);
|
|
543
|
+
await withdraw();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (blobs && (kind === "carried" || text === null || bytes.length > BLOB_THRESHOLD)) {
|
|
478
547
|
const sealed = await blobs.seal(bytes);
|
|
479
548
|
const blob = { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") };
|
|
480
549
|
await append(FILE_SET, item.path, { path: address, sha256: item.sha256, baseSha256: await previousSha(item.path), blob }, circles, sealed.hash);
|
|
@@ -528,28 +597,35 @@ export function fileMinter({ log, index, seal, notifyFor, previousSha, changeset
|
|
|
528
597
|
// `ancestors`: every sha this device logged for the address before the current one, from any circle
|
|
529
598
|
// it can open. A receiver holding any of them fast-forwards; a receiver holding a version nobody
|
|
530
599
|
// here has seen keeps it and gets the current text beside it, by the ordinary merge table.
|
|
531
|
-
|
|
600
|
+
//
|
|
601
|
+
// `stream(circleId, { skip })` yields the same Changes one at a time, minting each as it is asked
|
|
602
|
+
// for, today's day file first and then newest first (HQ-2068); `snapshot` is the stream collected.
|
|
603
|
+
//
|
|
604
|
+
// `onFileSet(rel, sha256)`, when given, is awaited once per file.set this mints (HQ-2109): the
|
|
605
|
+
// catch-up reads and hashes the file itself, so it is the one place that can tell the scan index the
|
|
606
|
+
// file is already accounted for, at no extra read. Without it, a device's next ordinary scan finds no
|
|
607
|
+
// row for a file the catch-up just sent, reads it as never-before-seen, and mints a second Change for
|
|
608
|
+
// bytes the peer already has.
|
|
609
|
+
export function catchUp({ evda, view, log, seal, open, changesetId, originUser = null, unionPath = null, blobs = null, exclude = null, onFileSet = null }) {
|
|
610
|
+
// What travels, by the same rule as the scan (sync-rules, HQ-2017): a reference is hashed from a
|
|
611
|
+
// local file and carries no bytes; a dataless file is not opened at all, and is left out of the
|
|
612
|
+
// catch-up (it is neither sent nor withdrawn) until its bytes are on this disk.
|
|
613
|
+
const classOf = fileClass(carryList(evda.manifest));
|
|
532
614
|
const readText = (rel) => {
|
|
533
615
|
const abs = join(evda.dir, rel);
|
|
534
616
|
return existsSync(abs) && statSync(abs).isFile() ? readFileSync(abs, "utf8") : null;
|
|
535
617
|
};
|
|
618
|
+
// The files a circle member holds, by the sync rules' own walk (HQ-2017): the same exclusions the
|
|
619
|
+
// scan lists by (node_modules, .git, .evda, the plan's paths), no symlink, no iCloud placeholder,
|
|
620
|
+
// nothing over the blob lane. The catch-up once walked by its own rule (every dot name skipped,
|
|
621
|
+
// node_modules kept), so what it sent and withdrew disagreed with what the scan minted.
|
|
622
|
+
const skip = exclude ?? excluder();
|
|
536
623
|
const walk = (prefix) => {
|
|
537
|
-
const out = [];
|
|
538
624
|
const start = join(evda.dir, prefix);
|
|
539
|
-
if (!existsSync(start)) return
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
const dir = stack.pop();
|
|
544
|
-
for (const name of readdirSync(join(evda.dir, dir)).sort()) {
|
|
545
|
-
if (name.startsWith(".")) continue;
|
|
546
|
-
const rel = dir === "" ? name : `${dir}/${name}`;
|
|
547
|
-
const st = statSync(join(evda.dir, rel));
|
|
548
|
-
if (st.isDirectory()) stack.push(rel);
|
|
549
|
-
else if (st.isFile()) out.push(rel);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
return out;
|
|
625
|
+
if (!existsSync(start)) return [];
|
|
626
|
+
const from = prefix.replace(/\/+$/, "");
|
|
627
|
+
if (statSync(start).isFile()) return skip(from) ? [] : [from];
|
|
628
|
+
return [...walkRules(evda.dir, { exclude: skip, from })].filter((e) => e.kind === "file").map((e) => e.rel);
|
|
553
629
|
};
|
|
554
630
|
const shaOf = async (bytes) => Buffer.from(await crypto.subtle.digest("SHA-256", bytes)).toString("hex");
|
|
555
631
|
|
|
@@ -568,55 +644,114 @@ export function catchUp({ evda, view, log, seal, open, changesetId, originUser =
|
|
|
568
644
|
return out;
|
|
569
645
|
};
|
|
570
646
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
647
|
+
const dayDir = dayFolderOf(evda.manifest);
|
|
648
|
+
|
|
649
|
+
// One Change per file the circle reaches now, newest first (HQ-2068), then one removal per address
|
|
650
|
+
// it no longer reaches. Each is minted only when the caller asks for the next, so a caller that
|
|
651
|
+
// posts as it goes has the first batch on the relay before the rest is read. `skip` holds
|
|
652
|
+
// addresses already sent by an earlier, interrupted run: neither minted again nor withdrawn.
|
|
653
|
+
async function* stream(circleId, { skip = new Set(), now = new Date() } = {}) {
|
|
654
|
+
const index = view().index;
|
|
655
|
+
const target = circleById(index, circleId);
|
|
656
|
+
if (!target) throw new EvdaError(`no circle ${circleId} in this record.`, { code: "NO_CIRCLE" });
|
|
657
|
+
if (target.mount) throw new EvdaError(`circle "${target.name}" is held from ${target.mount.holder}; only its home catches a device up.`, { code: "NOT_OWNER" });
|
|
658
|
+
const at = addressing(index);
|
|
659
|
+
const listed = new Set();
|
|
660
|
+
const skipped = new Set();
|
|
661
|
+
const files = [];
|
|
662
|
+
for (const { prefix, match } of scanPrefixes(index, (c) => !c.mount && !c.frozen)) {
|
|
663
|
+
const rels = walk(prefix).filter((rel) => match(rel) && !listed.has(rel));
|
|
664
|
+
const dataless = datalessAmong(evda.dir, rels);
|
|
665
|
+
for (const rel of rels) {
|
|
666
|
+
if (listed.has(rel)) continue;
|
|
667
|
+
listed.add(rel);
|
|
668
|
+
if (dataless.has(rel)) { skipped.add(rel); continue; }
|
|
669
|
+
let mtimeMs = 0;
|
|
670
|
+
try { mtimeMs = statSync(join(evda.dir, rel)).mtimeMs; } catch { continue; }
|
|
671
|
+
files.push({ rel, mtimeMs });
|
|
587
672
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
673
|
+
}
|
|
674
|
+
files.sort(catchUpOrder(dayDir, localDate(now)));
|
|
675
|
+
const mint = async (kind, payload, blobHash = null) => {
|
|
676
|
+
const id = await changesetId();
|
|
677
|
+
const change = await log.append({ changesetId: id, eco: target.id, originUser, kind, target: payload.path, payload: await seal(target, payload), notify: [] });
|
|
678
|
+
if (blobHash !== null) await blobs.link(change.id, blobHash);
|
|
679
|
+
return change;
|
|
680
|
+
};
|
|
681
|
+
const addresses = new Set();
|
|
682
|
+
for (const { rel } of files) {
|
|
683
|
+
const kind = classOf(rel);
|
|
684
|
+
let bytes = null;
|
|
685
|
+
let text = null;
|
|
686
|
+
let size = 0;
|
|
687
|
+
let known = null;
|
|
688
|
+
if (kind === "reference") {
|
|
689
|
+
// Only its home circle: a mark to release is frontmatter, and a reference has no text.
|
|
690
|
+
if (circleForPath(index, rel) !== target) continue;
|
|
691
|
+
const raw = readFileSync(join(evda.dir, rel));
|
|
692
|
+
size = raw.length;
|
|
693
|
+
known = await shaOf(raw);
|
|
694
|
+
} else {
|
|
695
|
+
bytes = readFileSync(join(evda.dir, rel));
|
|
696
|
+
text = utf8OrNull(bytes);
|
|
697
|
+
if (!circlesFor(index, rel, text, readText).includes(target)) continue;
|
|
608
698
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
699
|
+
const address = at.address(circleForPath(index, rel), rel);
|
|
700
|
+
addresses.add(address);
|
|
701
|
+
if (skip.has(address)) continue;
|
|
702
|
+
const sha256 = known ?? (await shaOf(bytes));
|
|
703
|
+
// Told as soon as the hash is known, whichever branch below mints: a file.set is coming for
|
|
704
|
+
// this path either way, and the scan index should already agree with it by the time it lands.
|
|
705
|
+
if (onFileSet) await onFileSet(rel, sha256);
|
|
706
|
+
const ancestors = await ancestorsOf(address, sha256);
|
|
707
|
+
const base = { path: address, sha256, baseSha256: ancestors.at(-1) ?? null, ancestors };
|
|
708
|
+
if (kind === "reference") {
|
|
709
|
+
yield await mint(FILE_SET, { ...refPayload(address, sha256, base.baseSha256, size), ancestors });
|
|
710
|
+
continue;
|
|
613
711
|
}
|
|
614
|
-
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
712
|
+
if (blobs && (kind === "carried" || text === null || bytes.length > BLOB_THRESHOLD)) {
|
|
713
|
+
const sealed = await blobs.seal(bytes);
|
|
714
|
+
yield await mint(FILE_SET, { ...base, blob: { hash: sealed.hash, contentKey: Buffer.from(sealed.contentKey).toString("base64") } }, sealed.hash);
|
|
715
|
+
continue;
|
|
618
716
|
}
|
|
717
|
+
yield await mint(FILE_SET, { ...base, text: text ?? new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes), ...(unionPath?.(rel) === true ? { union: true } : {}) });
|
|
718
|
+
}
|
|
719
|
+
// Addresses the circle held before and does not now: removed, or no longer released into it.
|
|
720
|
+
const gone = new Set();
|
|
721
|
+
const skippedAddresses = new Set([...skipped].map((rel) => { try { return at.address(circleForPath(index, rel), rel); } catch { return rel; } }));
|
|
722
|
+
for (const change of await log.forEco(target.id)) {
|
|
723
|
+
if (change.kind === FILE_SET && !addresses.has(change.target) && !skippedAddresses.has(change.target) && !skip.has(change.target)) gone.add(change.target);
|
|
724
|
+
}
|
|
725
|
+
for (const address of [...gone].sort()) {
|
|
726
|
+
const ancestors = await ancestorsOf(address, null);
|
|
727
|
+
if (ancestors.length === 0) continue;
|
|
728
|
+
yield await mint(FILE_REMOVE, { path: address, sha256: null, baseSha256: ancestors.at(-1), ancestors });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
stream,
|
|
734
|
+
async snapshot(circleId, o = {}) {
|
|
735
|
+
const minted = [];
|
|
736
|
+
for await (const change of stream(circleId, o)) minted.push(change);
|
|
619
737
|
return minted;
|
|
620
738
|
},
|
|
621
739
|
};
|
|
622
740
|
}
|
|
741
|
+
|
|
742
|
+
// The order a catch-up sends a circle's files in (HQ-2068): today's day file first, then the most
|
|
743
|
+
// recently modified, then by path, so a phone joining a large record shows what was written last
|
|
744
|
+
// within seconds instead of after the alphabet.
|
|
745
|
+
export function catchUpOrder(dayDir, today) {
|
|
746
|
+
const dayPrefix = `${String(dayDir ?? "").replace(/\/+$/, "")}/`;
|
|
747
|
+
const rank = (rel) => (rel.startsWith(dayPrefix) && rel.slice(dayPrefix.length).startsWith(today) ? 0 : 1);
|
|
748
|
+
return (a, b) => rank(a.rel) - rank(b.rel) || b.mtimeMs - a.mtimeMs || (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// The day folder a record's mapping names, else create-evda's default (as pairing.ts reads it).
|
|
752
|
+
function dayFolderOf(manifest) {
|
|
753
|
+
const mapping = manifest?.mapping;
|
|
754
|
+
const role = dayRole(mapping);
|
|
755
|
+
const rel = role ? mapping?.[role] : undefined;
|
|
756
|
+
return typeof rel === "string" && rel.trim() !== "" ? rel.replace(/\/+$/, "") : "record/daily";
|
|
757
|
+
}
|
package/lib/gather-apply.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { proposeCircle, onlyKeys, refuse } from "./tools.mjs";
|
|
|
51
51
|
import { doctor } from "./doctor.mjs";
|
|
52
52
|
import { gatherDir, loadProposal, checkOut, presentIndex, presentAt, GATHERED, chatPlan, dayDirOf } from "./gather.mjs";
|
|
53
53
|
import { dayAppend } from "./chat-export.mjs";
|
|
54
|
+
import { sealForKeychain, removeSecret, SERVICE } from "./keychain.mjs";
|
|
54
55
|
import { EvdaError } from "./errors.mjs";
|
|
55
56
|
import { carries, NOTED, SEALED } from "./circles.mjs";
|
|
56
57
|
import { parseArgs, scaffold } from "../create-evda.mjs";
|
|
@@ -297,8 +298,12 @@ function plan(evda, p, { mode }) {
|
|
|
297
298
|
if (add.sealed && !/^sealed:[ \t]*true[ \t]*$/m.test(bytes.toString("utf8").split(/\n---/)[0])) bytes = Buffer.from(bytes.toString("utf8").replace(/^sealed:.*$/m, `${SEALED}: true`), "utf8");
|
|
298
299
|
for (const flag of [NOTED, SEALED]) if (md && !carries("gather-apply", flag)) bytes = Buffer.from(bytes.toString("utf8").replace(new RegExp(`^${flag}:[ \\t]*true[ \\t]*\\n`, "m"), ""), "utf8");
|
|
299
300
|
const st = statSync(src);
|
|
301
|
+
// A note gather caught as holding a credential, newly sealed here (HQ-1916): its value(s) are
|
|
302
|
+
// moved into the Keychain at apply, not left in the file. A file that already carried its own
|
|
303
|
+
// seal (re-gathered, or hand-marked) is not a fresh credential detection and is left as it is.
|
|
300
304
|
out.writes.push({
|
|
301
305
|
kind: mode === "move" ? "move" : "copy", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes, original: buf,
|
|
306
|
+
credential: !!(md && pl.sealed && i.credential && !i.sealed),
|
|
302
307
|
schemes: md && add.references ? pl.refs.map((r) => r.split(":")[0]) : [],
|
|
303
308
|
times: { atime: st.atimeMs / 1000, mtime: st.mtimeMs / 1000 }, mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs,
|
|
304
309
|
});
|
|
@@ -385,13 +390,34 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
385
390
|
}
|
|
386
391
|
// 3. The files.
|
|
387
392
|
const ledger = [];
|
|
393
|
+
// HQ-1916: a credential's value moves into the Keychain here, before its file ever touches disk;
|
|
394
|
+
// the tracker only ever sees the redacted bytes. Not macOS, or the write fails: the value stays in
|
|
395
|
+
// the sealed file exactly as HQ-1368 left it, and the fallback says why in one plain line.
|
|
396
|
+
const keychainPath = env.EVDA_KEYCHAIN_PATH || null;
|
|
397
|
+
const keychainAccounts = [];
|
|
398
|
+
const keychainLines = [];
|
|
399
|
+
let keychainFilesMoved = 0;
|
|
400
|
+
let keychainFilesKept = 0;
|
|
388
401
|
for (const w of pl.writes) {
|
|
389
|
-
|
|
402
|
+
let bytes = w.bytes;
|
|
403
|
+
if (w.credential) {
|
|
404
|
+
const sealed = sealForKeychain(bytes.toString("utf8"), basename(w.to), { keychainPath });
|
|
405
|
+
if (sealed.ok) {
|
|
406
|
+
bytes = Buffer.from(sealed.text, "utf8");
|
|
407
|
+
keychainAccounts.push(...sealed.accounts);
|
|
408
|
+
keychainFilesMoved += 1;
|
|
409
|
+
keychainLines.push(` kept ${w.to}: ${sealed.accounts.length} value(s) moved to the Keychain (${SERVICE}); the file holds a pointer, not the value`);
|
|
410
|
+
} else {
|
|
411
|
+
keychainFilesKept += 1;
|
|
412
|
+
keychainLines.push(` kept ${w.to}: the value stays in the sealed file (${sealed.why})`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
t.writeNew(w.to, bytes, w.times ?? null);
|
|
390
416
|
if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString() });
|
|
391
417
|
if (w.kind === "move") {
|
|
392
418
|
unlinkSync(w.source);
|
|
393
419
|
// A file the apply changed on the way in keeps its source bytes here, so revert can put them back.
|
|
394
|
-
t.moved.push({ source: w.source, to: w.to, sha256: w.sourceSha, mode: w.mode, atimeMs: w.atimeMs, mtimeMs: w.mtimeMs, ...(sha256(
|
|
420
|
+
t.moved.push({ source: w.source, to: w.to, sha256: w.sourceSha, mode: w.mode, atimeMs: w.atimeMs, mtimeMs: w.mtimeMs, ...(sha256(bytes) !== w.sourceSha ? { bytes: w.original.toString("base64") } : {}) });
|
|
395
421
|
}
|
|
396
422
|
}
|
|
397
423
|
// 3b. The owner's chat messages no agent filed (HQ-1385), appended to their days.
|
|
@@ -454,6 +480,10 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
454
480
|
placed: pl.writes.map((w) => ({ item: w.item, kind: w.kind, from: w.source ?? null, to: w.to })),
|
|
455
481
|
chats: [...pl.chats.byFile.keys()],
|
|
456
482
|
counts: result.counts,
|
|
483
|
+
// HQ-1916: the Keychain items this apply added, and the keychain they went into (null is the
|
|
484
|
+
// login keychain), so revert can remove exactly these and nothing else.
|
|
485
|
+
keychain: keychainAccounts,
|
|
486
|
+
keychainPath,
|
|
457
487
|
};
|
|
458
488
|
mkdirSync(dirname(manifestAbs), { recursive: true });
|
|
459
489
|
writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" });
|
|
@@ -461,6 +491,9 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
461
491
|
const lines = [];
|
|
462
492
|
const d = doctor(evda.dir, { out: (l) => lines.push(l), via: "gather apply" });
|
|
463
493
|
result.doctor = { exit: d.exit, lines, failures: d.failures, warnings: d.warnings.length };
|
|
494
|
+
result.keychainLines = keychainLines;
|
|
495
|
+
result.keychainMoved = keychainFilesMoved;
|
|
496
|
+
result.keychainKept = keychainFilesKept;
|
|
464
497
|
result.lines = applyLines(result, p);
|
|
465
498
|
return result;
|
|
466
499
|
}
|
|
@@ -519,6 +552,7 @@ function applyLines(r, p) {
|
|
|
519
552
|
if (c.pages) out.push(` ${verb === "did" ? "wrote " : "write "} ${c.pages} page(s) the answers asked for`);
|
|
520
553
|
if (c.referenceOnly) out.push(` kept ${c.referenceOnly} export(s) as references only, never copied`);
|
|
521
554
|
if (c.present) out.push(` present ${c.present} item(s) already in the record, left where they are`);
|
|
555
|
+
if (r.keychainLines?.length) out.push(...r.keychainLines);
|
|
522
556
|
for (const x of r.plan.changed) out.push(` changed ${x.item} ${x.why}`);
|
|
523
557
|
if ((p.chats ?? []).length) {
|
|
524
558
|
out.push(` ${verb === "did" ? "added " : "add "} ${c.chatAdded} message(s) of the owner's from ${p.chats.length} chat export(s) to ${c.chatDays} day file(s), each conversation under its date and time; ${c.chatFiled} already in the record${c.chatSecret ? `, ${c.chatSecret} left out for holding something shaped like a secret` : ""}`);
|
|
@@ -536,6 +570,7 @@ function applyLines(r, p) {
|
|
|
536
570
|
out.push(checkedLine(c));
|
|
537
571
|
if ((p.chats ?? []).length) out.push(`CHECKED ${c.chatAdded + c.chatFiled + c.chatSecret} chat message(s) of the owner's ${c.chatAdded} added, ${c.chatFiled} already filed, ${c.chatSecret} secret-shaped`);
|
|
538
572
|
out.push(`CHECKED ${c.copied + c.moved} file(s) ${r.mode === "move" ? "moved" : "copied"}, ${c.references} reference entr${c.references === 1 ? "y" : "ies"}, ${c.pages} page(s), ${c.present} already in the record, ${c.refused} refused, ${c.changed} changed since the gather`);
|
|
573
|
+
if (r.keychainLines?.length) out.push(`CHECKED ${r.keychainMoved + r.keychainKept} credential file(s) ${r.keychainMoved} with their value(s) moved to the Keychain, ${r.keychainKept} kept in the sealed file`);
|
|
539
574
|
return out;
|
|
540
575
|
}
|
|
541
576
|
|
|
@@ -587,6 +622,10 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
|
|
|
587
622
|
}
|
|
588
623
|
let removed = 0;
|
|
589
624
|
for (const f of files) { const a = join(root, f.path); if (existsSync(a)) { unlinkSync(a); removed += 1; } }
|
|
625
|
+
// HQ-1916: every Keychain item this apply added, removed again. Never blocks the revert: a value
|
|
626
|
+
// already gone from the Keychain (the owner deleted it by hand) is simply not there to remove.
|
|
627
|
+
let keychainRemoved = 0;
|
|
628
|
+
for (const account of m.keychain ?? []) if (removeSecret({ account, keychainPath: m.keychainPath ?? null })) keychainRemoved += 1;
|
|
590
629
|
let put = 0;
|
|
591
630
|
if (!falsified("revert-not-exact")) {
|
|
592
631
|
for (const f of m.modified) {
|
|
@@ -610,7 +649,7 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
|
|
|
610
649
|
if (m.made?.root && existsSync(root) && readdirSync(root).length === 0) rmdirSync(root);
|
|
611
650
|
m.reverted = now.toISOString();
|
|
612
651
|
writeFileSync(abs, JSON.stringify(m, null, 2) + "\n");
|
|
613
|
-
return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0 };
|
|
652
|
+
return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0, keychainRemoved };
|
|
614
653
|
}
|
|
615
654
|
|
|
616
655
|
export function revertLines(r) {
|
|
@@ -621,7 +660,9 @@ export function revertLines(r) {
|
|
|
621
660
|
if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the apply were removed: ${r.edited.join(", ")}`);
|
|
622
661
|
for (const d of r.kept) out.push(` kept ${d}/, which holds files the apply did not write`);
|
|
623
662
|
if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
|
|
663
|
+
if (r.keychainRemoved) out.push(` removed ${r.keychainRemoved} Keychain item(s) the apply added`);
|
|
624
664
|
out.push(`CHECKED ${r.removed + r.restored + r.put} file(s) reverted ${r.kept.length} folder(s) kept`);
|
|
665
|
+
if (r.keychainRemoved) out.push(`CHECKED ${r.keychainRemoved} Keychain item(s) removed`);
|
|
625
666
|
return out;
|
|
626
667
|
}
|
|
627
668
|
|
package/lib/gather.mjs
CHANGED
|
@@ -75,7 +75,9 @@ export const PEOPLE_MIN_FILES = 3;
|
|
|
75
75
|
const CRED_QUALIFIER = String.raw`(?:(?:wi-?fi|home|network|router|guest|email|e-mail|bank|account|admin|root|master|login|app|door|alarm|phone|sim|card|atm|debit|garage)\s+)*`;
|
|
76
76
|
const CRED_SHORT = String.raw`pass(?:word|wd|code|phrase)?|pwd|pw|pin(?:\s*code)?|api[\s_-]*key|access[\s_-]*key|secret(?:[\s_-]*key)?|(?:auth[\s_-]*|access[\s_-]*|api[\s_-]*)?token|private[\s_-]*key|login`;
|
|
77
77
|
const CRED_LONG = String.raw`(?:recovery|backup|2fa|mfa|one[\s-]*time)[\s_-]*(?:codes?|keys?|phrase)|seed[\s_-]*(?:phrase|words)|mnemonic(?:[\s_-]*phrase)?|secret[\s_-]*phrase`;
|
|
78
|
-
|
|
78
|
+
// Exported for lib/keychain.mjs (HQ-1916): the same line shape that types a note as holding a
|
|
79
|
+
// credential is used again at apply, to find each value on its own line and move it off disk.
|
|
80
|
+
export const CRED_LINE = new RegExp(String.raw`^\s*(?:[-*+•>]\s+|\d+[.)]\s+)?[*_\x60]*${CRED_QUALIFIER}(?:(${CRED_LONG})|(${CRED_SHORT}))[*_\x60]*\s*(?:[:=]|\s[-\u2013]\s)[*_\x60]*\s*(.*)$`, "i");
|
|
79
81
|
export function credentialLine(lines) {
|
|
80
82
|
const all = Array.isArray(lines) ? lines : String(lines ?? "").split("\n");
|
|
81
83
|
for (let n = 0; n < all.length; n++) {
|
package/lib/keychain.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// macOS Keychain storage for the credential lines gather finds (HQ-1916, builds on HQ-1368).
|
|
2
|
+
// First version, his own words: "it just needs to be super initial versioning" -- the smallest thing
|
|
3
|
+
// that takes the password off disk.
|
|
4
|
+
//
|
|
5
|
+
// At `evda gather apply`, a note gather caught as holding a credential (lib/gather.mjs CRED_LINE, or
|
|
6
|
+
// a bare token line shaped like a secret) is still sealed into Credentials/ as it is today, but each
|
|
7
|
+
// value found in it is written into the login Keychain (service EVÐA) instead of staying in the
|
|
8
|
+
// file. The line keeps its own label; only the value becomes a plain pointer:
|
|
9
|
+
// Password: in your Keychain (EVÐA, wifi.md, password)
|
|
10
|
+
// Not macOS, or the Keychain write fails: the value stays in the file, unchanged, as it did before
|
|
11
|
+
// this ticket, and gather-apply says so in one line. Nothing here calls any MCP tool, and nothing
|
|
12
|
+
// here is registered as one: the owner reads a secret with `evda secret <file>`, on their own Mac,
|
|
13
|
+
// printed to their own terminal only (Spec 07, the sealed folder's own guarantee).
|
|
14
|
+
//
|
|
15
|
+
// EVDA_KEYCHAIN_FALSIFY=<mode,...>, for the tests:
|
|
16
|
+
// no-keychain keychainAvailable() always says no, so a credential file is never redacted
|
|
17
|
+
// write-fails every "security add-generic-password" fails, so the value stays in the file
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from "node:child_process";
|
|
20
|
+
import { CRED_LINE } from "./gather.mjs";
|
|
21
|
+
import { secretShape } from "./references.mjs";
|
|
22
|
+
|
|
23
|
+
export const SERVICE = "EVÐA";
|
|
24
|
+
export const KEYCHAIN_FALSIFY_MODES = ["no-keychain", "write-fails"];
|
|
25
|
+
const falsified = (mode) => (process.env.EVDA_KEYCHAIN_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
26
|
+
|
|
27
|
+
// The account a credential's Keychain item is filed under: the record file it came from, and its
|
|
28
|
+
// label, so `evda secret <file>` can find every value a file's pointers name.
|
|
29
|
+
export const accountFor = (fileName, label) => `${fileName} :: ${label}`;
|
|
30
|
+
|
|
31
|
+
// Whether this machine can hold a secret in the Keychain at all. Never throws.
|
|
32
|
+
export function keychainAvailable() {
|
|
33
|
+
if (falsified("no-keychain")) return false;
|
|
34
|
+
if (process.platform !== "darwin") return false;
|
|
35
|
+
try {
|
|
36
|
+
execFileSync("security", ["list-keychains"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const keychainArgs = (keychainPath) => (keychainPath ? [keychainPath] : []);
|
|
44
|
+
|
|
45
|
+
// `security add-generic-password`, overwriting an item already at this account (a second gather of
|
|
46
|
+
// the same file lands on the same value). Throws on failure; the caller decides what that means.
|
|
47
|
+
export function addSecret({ account, value, keychainPath = null } = {}) {
|
|
48
|
+
if (falsified("write-fails")) throw new Error("EVDA_KEYCHAIN_FALSIFY=write-fails");
|
|
49
|
+
execFileSync("security", ["add-generic-password", "-a", account, "-s", SERVICE, "-w", value, "-U", ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "pipe"] });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Never throws: revert removes what it can and says what it removed.
|
|
53
|
+
export function removeSecret({ account, keychainPath = null } = {}) {
|
|
54
|
+
try {
|
|
55
|
+
execFileSync("security", ["delete-generic-password", "-a", account, "-s", SERVICE, ...keychainArgs(keychainPath)], { stdio: ["ignore", "ignore", "ignore"] });
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The value at an account, or throws (no item, or the Keychain refused to read it).
|
|
63
|
+
export function readSecret({ account, keychainPath = null } = {}) {
|
|
64
|
+
return execFileSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w", ...keychainArgs(keychainPath)], { stdio: ["ignore", "pipe", "ignore"] }).toString("utf8").replace(/\n$/, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Every credential value on its own line in `text`: a labeled line (CRED_LINE, the same shape that
|
|
68
|
+
// typed the note in the first place) keeps its label and the text before the value, so only the
|
|
69
|
+
// value itself is later replaced; a bare line shaped like a secret (a token with no "label:") has no
|
|
70
|
+
// label of its own, so it is numbered "secret", "secret 2", ... in the order it appears. A labeled
|
|
71
|
+
// line whose value is empty (the code sits on the next line instead) is left alone: multi-line values
|
|
72
|
+
// are out of this first version's scope.
|
|
73
|
+
export function extractSecrets(text) {
|
|
74
|
+
const lines = text.split("\n");
|
|
75
|
+
const out = [];
|
|
76
|
+
let bare = 0;
|
|
77
|
+
for (let i = 0; i < lines.length; i++) {
|
|
78
|
+
const line = lines[i];
|
|
79
|
+
const m = CRED_LINE.exec(line);
|
|
80
|
+
if (m) {
|
|
81
|
+
const label = (m[1] ?? m[2]).toLowerCase().replace(/[\s_-]+/g, " ");
|
|
82
|
+
const value = m[3].replace(/[*_\x60]+$/g, "").trim();
|
|
83
|
+
if (!value) continue;
|
|
84
|
+
out.push({ index: i, label, prefix: line.slice(0, line.length - m[3].length), value, whole: false });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (secretShape(line.trim())) {
|
|
88
|
+
bare += 1;
|
|
89
|
+
out.push({ index: i, label: bare === 1 ? "secret" : `secret ${bare}`, prefix: "", value: line, whole: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A pointer line reads back its account: "in your Keychain (EVÐA, wifi.md, password)".
|
|
96
|
+
const POINTER = /in your Keychain \(([^,]+), ([^,]+), ([^)]+)\)/;
|
|
97
|
+
export const pointerFor = (fileName, label) => `in your Keychain (${SERVICE}, ${fileName}, ${label})`;
|
|
98
|
+
|
|
99
|
+
// Move every credential value in `text` into the Keychain, one item per line, and return the text
|
|
100
|
+
// with each replaced by its pointer. All-or-nothing per file: if any value fails to write, every
|
|
101
|
+
// item this call added is removed again and the original text comes back unchanged, so a file is
|
|
102
|
+
// never left half in the Keychain and half redacted.
|
|
103
|
+
// Returns { ok: true, text, accounts } or { ok: false, why }.
|
|
104
|
+
export function sealForKeychain(text, fileName, { keychainPath = null } = {}) {
|
|
105
|
+
if (!keychainAvailable()) return { ok: false, why: "not on macOS, or the Keychain is unavailable" };
|
|
106
|
+
const secrets = extractSecrets(text);
|
|
107
|
+
if (!secrets.length) return { ok: false, why: "no credential value was found on its own line" };
|
|
108
|
+
const added = [];
|
|
109
|
+
try {
|
|
110
|
+
for (const s of secrets) {
|
|
111
|
+
const account = accountFor(fileName, s.label);
|
|
112
|
+
addSecret({ account, value: s.value, keychainPath });
|
|
113
|
+
added.push(account);
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
for (const account of added) removeSecret({ account, keychainPath });
|
|
117
|
+
return { ok: false, why: `the Keychain write failed (${String(e.message ?? e).split("\n")[0]})` };
|
|
118
|
+
}
|
|
119
|
+
const lines = text.split("\n");
|
|
120
|
+
for (const s of secrets) lines[s.index] = s.whole ? pointerFor(fileName, s.label) : `${s.prefix}${pointerFor(fileName, s.label)}`;
|
|
121
|
+
return { ok: true, text: lines.join("\n"), accounts: added };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The owner's own read: every pointer in `text` resolved back to its value, for `evda secret <file>`
|
|
125
|
+
// only. No MCP tool calls this.
|
|
126
|
+
export function resolvePointers(text, { keychainPath = null } = {}) {
|
|
127
|
+
const lines = text.split("\n");
|
|
128
|
+
let resolved = 0;
|
|
129
|
+
let failed = 0;
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
const m = POINTER.exec(lines[i]);
|
|
132
|
+
if (!m) continue;
|
|
133
|
+
const account = accountFor(m[2], m[3]);
|
|
134
|
+
try {
|
|
135
|
+
const value = readSecret({ account, keychainPath });
|
|
136
|
+
lines[i] = lines[i].slice(0, m.index) + value + lines[i].slice(m.index + m[0].length);
|
|
137
|
+
resolved += 1;
|
|
138
|
+
} catch {
|
|
139
|
+
lines[i] = `${lines[i]} (could not read this from the Keychain)`;
|
|
140
|
+
failed += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { text: lines.join("\n"), resolved, failed };
|
|
144
|
+
}
|
package/lib/manifest.mjs
CHANGED
|
@@ -16,7 +16,9 @@ export const SCHEMA_VERSION = 1;
|
|
|
16
16
|
export const KNOWN_LAYERS = ["record", "ledger", "circles"];
|
|
17
17
|
// `filing` (HQ-1385) is the owner's answer to the one setup question, "everything" or "asked";
|
|
18
18
|
// lib/day-file.mjs filingOf reads it for every agent and hook.
|
|
19
|
-
|
|
19
|
+
// `every_chat` (HQ-2205) is, per chat app, whether the owner put the every-chat line in that app's
|
|
20
|
+
// settings ("set") or does not want it ("declined"); lib/every-chat.mjs reads it, evda_every_chat writes it.
|
|
21
|
+
export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping", "subrecords", "read_by", "filing", "every_chat", "sync"];
|
|
20
22
|
|
|
21
23
|
// Whether a record's circles stop at its sub-records (HQ-1371). A sub-record is a directory below
|
|
22
24
|
// the root holding its own evda.json. "bounded": the parent's circles, coverage, scan and release
|