create-evda 0.1.8 → 0.1.10
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 +109 -1
- package/README.md +20 -7
- package/bin/evda.mjs +277 -7
- package/create-evda.mjs +17 -4
- package/dist/pairing.mjs +2932 -1684
- package/hooks/evda-day-writer-guard.mjs +7 -0
- package/lib/acknowledge.mjs +187 -0
- package/lib/adopt.mjs +488 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +269 -0
- package/lib/browse.mjs +354 -0
- package/lib/circles.mjs +5 -1
- package/lib/clients.mjs +29 -2
- package/lib/connect.mjs +9 -2
- package/lib/connecting-apps.mjs +56 -0
- package/lib/connector-tools.mjs +85 -0
- package/lib/corrections.mjs +34 -0
- package/lib/credentials-migrate.mjs +120 -0
- package/lib/day-file.mjs +173 -15
- package/lib/day-writer.mjs +110 -0
- package/lib/doctor.mjs +45 -3
- package/lib/every-chat.mjs +128 -0
- package/lib/filechanges.mjs +203 -68
- package/lib/gather-apply.mjs +176 -26
- package/lib/gather.mjs +119 -17
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +61 -8
- package/lib/keychain.mjs +181 -0
- package/lib/manifest.mjs +5 -1
- package/lib/mcp-auth.mjs +41 -1
- package/lib/mcp-http.mjs +42 -4
- package/lib/mcp.mjs +1198 -76
- package/lib/measurement-log.mjs +249 -0
- package/lib/messages.mjs +5 -2
- package/lib/migrate-people.mjs +54 -20
- package/lib/pairing.mjs +243 -5
- package/lib/parity.mjs +47 -13
- package/lib/person-folders.mjs +89 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +26 -5
- package/lib/publish.mjs +55 -10
- package/lib/qr.mjs +317 -0
- package/lib/record.mjs +74 -0
- package/lib/routines.mjs +54 -4
- 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/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +412 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +1040 -31
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/watch.mjs +160 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/skills/evda/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -10
- package/templates/evda-digest.md +5 -1
package/lib/gather-apply.mjs
CHANGED
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
//
|
|
28
28
|
// Revert restores the record and the sources to their bytes before the apply: moved files go back
|
|
29
29
|
// with their mode and modification time, written files are removed, changed manifests get their
|
|
30
|
-
// old bytes and times back, and directories the apply made are removed when empty.
|
|
30
|
+
// old bytes and times back, and directories the apply made are removed when empty. Every folder that
|
|
31
|
+
// existed before the apply and that it wrote into or moved a file out of (in the pile or the record)
|
|
32
|
+
// gets its modification time back (HQ-1368); one removed since is named and left gone. A written file
|
|
31
33
|
// edited since the apply is named and the revert refused, unless --force.
|
|
32
34
|
//
|
|
33
35
|
// EVDA_GATHER_FALSIFY=<mode,...>, for the tests (APPLY_FALSIFY_MODES):
|
|
@@ -36,27 +38,35 @@
|
|
|
36
38
|
// reapply-resorts nothing in the record counts as present, so a second gather places it all again
|
|
37
39
|
// move-without-flag apply moves the sources when --move was not given
|
|
38
40
|
// revert-ignores-edits revert removes a placed file the owner edited, without --force
|
|
41
|
+
// revert-dir-times revert leaves the folders' modification times as its own writes made them
|
|
42
|
+
// two-schemes a create-evda record grows the preset's Title Case folders beside record/
|
|
39
43
|
|
|
40
44
|
import { createHash } from "node:crypto";
|
|
41
45
|
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
42
46
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
43
47
|
import { tmpdir } from "node:os";
|
|
44
|
-
import { openEvda, readManifest, findRoot } from "./manifest.mjs";
|
|
48
|
+
import { openEvda, readManifest, findRoot, holderOf } from "./manifest.mjs";
|
|
45
49
|
import { fourRoles, roleForPath, fourRoleOf } from "./roles.mjs";
|
|
46
50
|
import { subRecords } from "./records.mjs";
|
|
47
51
|
import { folderManifests, folderFor, FOLDER_MANIFEST } from "./folders.mjs";
|
|
48
52
|
import { loadPreset, applyPreset, manifestFor } from "./presets.mjs";
|
|
49
53
|
import { reference } from "./references.mjs";
|
|
50
|
-
import { proposeCircle, onlyKeys, refuse } from "./tools.mjs";
|
|
54
|
+
import { proposeCircle, onlyKeys, refuse, PROPOSALS } from "./tools.mjs";
|
|
51
55
|
import { doctor } from "./doctor.mjs";
|
|
52
56
|
import { gatherDir, loadProposal, checkOut, presentIndex, presentAt, GATHERED, chatPlan, dayDirOf } from "./gather.mjs";
|
|
53
57
|
import { dayAppend } from "./chat-export.mjs";
|
|
58
|
+
import { sealForKeychain, removeSecret, SERVICE } from "./keychain.mjs";
|
|
54
59
|
import { EvdaError } from "./errors.mjs";
|
|
55
60
|
import { carries, NOTED, SEALED } from "./circles.mjs";
|
|
61
|
+
import { planProfile } from "./migrate-people.mjs";
|
|
62
|
+
import { README_NAME } from "./person-folders.mjs";
|
|
63
|
+
import { localDate } from "./day-file.mjs";
|
|
64
|
+
import { htmlNoteToMarkdown } from "./html-notes.mjs";
|
|
65
|
+
import { captionOf } from "./takeout.mjs";
|
|
56
66
|
import { parseArgs, scaffold } from "../create-evda.mjs";
|
|
57
67
|
|
|
58
68
|
export const APPLY_VERSION = 1;
|
|
59
|
-
export const APPLY_FALSIFY_MODES = ["apply-writes-share", "revert-not-exact", "reapply-resorts", "move-without-flag", "revert-ignores-edits"];
|
|
69
|
+
export const APPLY_FALSIFY_MODES = ["apply-writes-share", "revert-not-exact", "reapply-resorts", "move-without-flag", "revert-ignores-edits", "revert-dir-times", "two-schemes"];
|
|
60
70
|
const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
61
71
|
const posix = (p) => p.split(sep).join("/");
|
|
62
72
|
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
@@ -67,20 +77,27 @@ const within = (abs, root) => abs === root || abs.startsWith(root + sep);
|
|
|
67
77
|
|
|
68
78
|
// Every write the apply makes goes through here, so the reversal manifest is complete by
|
|
69
79
|
// construction: a file made, a file changed (its old bytes and times kept), a directory made.
|
|
70
|
-
function tracker(root) {
|
|
71
|
-
const t = { root, created: [], modified: [], dirs: [], moved: [], stash: new Map() };
|
|
80
|
+
function tracker(root, times = new Map(), made = []) {
|
|
81
|
+
const t = { root, created: [], modified: [], dirs: [], moved: [], stash: new Map(), times };
|
|
72
82
|
const abs = (rel) => join(root, rel);
|
|
83
|
+
// A folder that was there before the apply is about to gain or lose an entry: its times are kept
|
|
84
|
+
// first, once, so revert can put them back. One the apply made (or made the record with) is removed
|
|
85
|
+
// by revert instead. `times` is shared with the apply, which notes the pile's folders the same way.
|
|
86
|
+
const ours = new Set(made.map((d) => abs(d)));
|
|
87
|
+
t.noteDir = (dirAbs) => { if (!ours.has(dirAbs) && !t.dirs.some((d) => abs(d) === dirAbs)) noteDirTimes(times, dirAbs); };
|
|
73
88
|
t.mkdirp = (rel) => {
|
|
74
89
|
if (!rel || rel === ".") return;
|
|
75
90
|
const segs = rel.split("/");
|
|
76
91
|
for (let i = 1; i <= segs.length; i++) {
|
|
77
92
|
const sub = segs.slice(0, i).join("/");
|
|
78
93
|
if (existsSync(abs(sub))) continue;
|
|
94
|
+
t.noteDir(dirname(abs(sub)));
|
|
79
95
|
mkdirSync(abs(sub));
|
|
80
96
|
t.dirs.push(sub);
|
|
81
97
|
}
|
|
82
98
|
};
|
|
83
99
|
t.writeNew = (rel, buf, times = null) => {
|
|
100
|
+
t.noteDir(dirname(abs(rel)));
|
|
84
101
|
t.mkdirp(posix(dirname(rel)));
|
|
85
102
|
writeFileSync(abs(rel), buf, { flag: "wx" });
|
|
86
103
|
if (times) utimesSync(abs(rel), times.atime, times.mtime);
|
|
@@ -94,6 +111,7 @@ function tracker(root) {
|
|
|
94
111
|
t.stash.set(rel, { path: rel, before: readFileSync(abs(rel)).toString("base64"), mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
|
|
95
112
|
} else t.stash.set(rel, { path: rel, before: null });
|
|
96
113
|
}
|
|
114
|
+
t.noteDir(dirname(abs(rel)));
|
|
97
115
|
t.mkdirp(posix(dirname(rel)));
|
|
98
116
|
writeFileSync(abs(rel), buf);
|
|
99
117
|
};
|
|
@@ -105,6 +123,13 @@ function tracker(root) {
|
|
|
105
123
|
return t;
|
|
106
124
|
}
|
|
107
125
|
|
|
126
|
+
// Keep a folder's times before the apply changes what is in it: { path, atimeMs, mtimeMs }, by path.
|
|
127
|
+
function noteDirTimes(times, dirAbs) {
|
|
128
|
+
if (times.has(dirAbs) || !existsSync(dirAbs)) return;
|
|
129
|
+
const st = statSync(dirAbs);
|
|
130
|
+
if (st.isDirectory()) times.set(dirAbs, { path: dirAbs, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
|
|
131
|
+
}
|
|
132
|
+
|
|
108
133
|
// Every file and directory under `root`, relative, hidden ones included; symlinks are listed as files.
|
|
109
134
|
function tree(root) {
|
|
110
135
|
const files = [];
|
|
@@ -171,13 +196,37 @@ function ensureRecord(target, p, { owner = "", dry = false } = {}) {
|
|
|
171
196
|
return { evda: openEvda(dir), made, existed: false, scratch: dry ? dirname(dir) : null };
|
|
172
197
|
}
|
|
173
198
|
|
|
199
|
+
// A new record is create-evda's own layout and nothing more (HQ-1368, naming slice): the preset's
|
|
200
|
+
// Title Case folders are not made, so the folders the apply needs are made in the record's own
|
|
201
|
+
// lowercase scheme under record/, beside record/daily, and only those the items need.
|
|
174
202
|
function createRecord(dir, presetId, owner) {
|
|
175
|
-
const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : [])
|
|
203
|
+
const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : [])]);
|
|
176
204
|
scaffold(opts);
|
|
177
|
-
|
|
178
|
-
return { preset:
|
|
205
|
+
if (falsified("two-schemes")) applyPreset(dir, presetId);
|
|
206
|
+
return { preset: presetId };
|
|
179
207
|
}
|
|
180
208
|
|
|
209
|
+
// Folders other code finds by their name, kept as they are in every layout.
|
|
210
|
+
const NAMED_FOLDERS = new Set(["Credentials"]);
|
|
211
|
+
|
|
212
|
+
// True when the record is laid out the way create-evda makes one: its record layer under record/
|
|
213
|
+
// and no typed folder of a preset beside it. A record a preset grew (Journal/, 01_Projects/) keeps
|
|
214
|
+
// its own scheme. (HQ-1368, naming slice; Phronesis 2026-10-21.)
|
|
215
|
+
export function ownLayout(evda) {
|
|
216
|
+
if (falsified("two-schemes")) return false;
|
|
217
|
+
if (evda.manifest.mapping?.["record/daily"] !== "record/daily") return false;
|
|
218
|
+
const subs = subRecords(evda.dir);
|
|
219
|
+
return folderManifests(evda).every((m) => {
|
|
220
|
+
const rel = posix(relative(evda.dir, m.dir));
|
|
221
|
+
return !m.type || rel === "" || rel === "record" || rel.startsWith("record/") || rel === "messages" || rel.startsWith("messages/") || NAMED_FOLDERS.has(rel.split("/")[0]) || subs.some((s) => rel === s || rel.startsWith(`${s}/`));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// A proposal folder's name in a create-evda record: lowercase under record/, any number prefix
|
|
226
|
+
// dropped, so 01_Projects is record/projects and Events is record/events. Only the proposal's own
|
|
227
|
+
// folder is renamed; a subject below it ("02_Areas/COMP 318") keeps its spaces and case.
|
|
228
|
+
export const ownName = (path) => (NAMED_FOLDERS.has(path.split("/")[0]) ? path : `record/${path.split("/").map((s) => s.replace(/^\d+_/, "").toLowerCase()).join("/")}`);
|
|
229
|
+
|
|
181
230
|
// ---- the plan ----
|
|
182
231
|
|
|
183
232
|
// The folder a proposal folder lands in, in this record.
|
|
@@ -188,11 +237,13 @@ function folderResolver(evda, p) {
|
|
|
188
237
|
const inSub = (rel) => subs.some((s) => rel === s || rel.startsWith(`${s}/`));
|
|
189
238
|
const manifests = folderManifests(evda).filter((m) => m.type && !inSub(posix(relative(evda.dir, m.dir))));
|
|
190
239
|
const cache = new Map();
|
|
240
|
+
const own = ownLayout(evda);
|
|
191
241
|
return (f) => {
|
|
192
242
|
if (cache.has(f.path)) return cache.get(f.path);
|
|
193
243
|
let r;
|
|
194
|
-
const
|
|
195
|
-
|
|
244
|
+
const name = own ? ownName(f.path) : f.path;
|
|
245
|
+
const abs = join(evda.dir, name);
|
|
246
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) r = { path: name, how: "exists" };
|
|
196
247
|
else if (!falsified("reapply-resorts") && f.type) {
|
|
197
248
|
const same = manifests
|
|
198
249
|
.map((m) => ({ m, rel: posix(relative(evda.dir, m.dir)) }))
|
|
@@ -202,7 +253,7 @@ function folderResolver(evda, p) {
|
|
|
202
253
|
}
|
|
203
254
|
if (!r) {
|
|
204
255
|
const pf = preset.folders.find((x) => x.path === f.path);
|
|
205
|
-
r = { path:
|
|
256
|
+
r = { path: name, how: "create", type: f.type ?? null, role: f.role ?? null, connectors: pf?.connectors ?? [] };
|
|
206
257
|
}
|
|
207
258
|
cache.set(f.path, r);
|
|
208
259
|
return r;
|
|
@@ -212,17 +263,26 @@ function folderResolver(evda, p) {
|
|
|
212
263
|
// The proposal folder a destination sits under: the longest one at or above it.
|
|
213
264
|
const topOf = (p, to) => p.folders.filter((f) => to === f.path || to.startsWith(`${f.path}/`)).sort((a, b) => b.path.length - a.path.length)[0] ?? null;
|
|
214
265
|
|
|
266
|
+
// The caption a photo's Takeout sidecar holds, read at apply because the proposal quotes none; null
|
|
267
|
+
// when the sidecar is gone or changed since the gather.
|
|
268
|
+
function sidecarCaption(p, item) {
|
|
269
|
+
const abs = sourceAbs(p, { ...item, path: item.takeout.sidecar });
|
|
270
|
+
let buf;
|
|
271
|
+
try { buf = readFileSync(abs); } catch { return null; }
|
|
272
|
+
return sha256(buf) === item.takeout.sha256 ? captionOf(buf) : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
215
275
|
function sourceAbs(p, item) {
|
|
216
276
|
const s = p.sources.find((x) => x.id === item.source);
|
|
217
277
|
return existsSync(s.path) && statSync(s.path).isFile() ? s.path : join(s.path, item.path);
|
|
218
278
|
}
|
|
219
279
|
|
|
220
280
|
// The whole apply as a list of intended writes, before any is made. Pure over the record's state.
|
|
221
|
-
function plan(evda, p, { mode }) {
|
|
281
|
+
function plan(evda, p, { mode, now = new Date() }) {
|
|
222
282
|
const resolveFolder = folderResolver(evda, p);
|
|
223
283
|
const index = presentIndex(evda.dir);
|
|
224
284
|
const items = new Map(p.items.map((i) => [i.id, i]));
|
|
225
|
-
const out = { writes: [], present: [], changed: [], left: [], refused: [], referenceOnly: [], folders: new Map() };
|
|
285
|
+
const out = { writes: [], present: [], changed: [], left: [], refused: [], duplicates: [], referenceOnly: [], folders: new Map() };
|
|
226
286
|
const taken = new Set();
|
|
227
287
|
const claim = (to) => {
|
|
228
288
|
let t = to;
|
|
@@ -255,6 +315,7 @@ function plan(evda, p, { mode }) {
|
|
|
255
315
|
const i = items.get(pl.item);
|
|
256
316
|
if (pl.status === "refused") { out.refused.push({ item: pl.item, why: pl.why }); continue; }
|
|
257
317
|
if (pl.status === "unplaced") { out.left.push({ item: pl.item, why: pl.why }); continue; }
|
|
318
|
+
if (pl.status === "duplicate") { out.duplicates.push({ item: pl.item, of: pl.of }); continue; }
|
|
258
319
|
if (pl.status === "present") { out.present.push({ item: pl.item, at: pl.at }); continue; }
|
|
259
320
|
if (pl.status === "reference" && !pl.to) { out.referenceOnly.push({ item: pl.item, refs: (i.cards ?? []).map((c) => c.ref) }); continue; }
|
|
260
321
|
if (pl.status === "create") {
|
|
@@ -290,17 +351,32 @@ function plan(evda, p, { mode }) {
|
|
|
290
351
|
// A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
|
|
291
352
|
// evda_read refuse it to every caller without naming it, and no tool releases it.
|
|
292
353
|
if (md && pl.sealed && !i.sealed) add.sealed = `${SEALED}: true`;
|
|
354
|
+
// A Keep note's Takeout sidecar gives its created and edited times (HQ-1368, lib/takeout.mjs).
|
|
355
|
+
if (md && i.takeout?.created) add.created = `created: ${q(i.takeout.created)}`;
|
|
356
|
+
if (md && i.takeout?.edited) add.edited = `edited: ${q(i.takeout.edited)}`;
|
|
293
357
|
// The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
|
|
294
358
|
// record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
|
|
295
|
-
|
|
359
|
+
// A note-shaped HTML file becomes the markdown the proposal named (HQ-1368); revert puts the .html back.
|
|
360
|
+
let bytes = withFrontmatter(pl.convert === "html-to-markdown" ? Buffer.from(htmlNoteToMarkdown(buf.toString("utf8")).markdown, "utf8") : buf, add);
|
|
296
361
|
// A note that already said `sealed: false` (or anything but true) is sealed all the same.
|
|
297
362
|
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
363
|
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
364
|
const st = statSync(src);
|
|
365
|
+
// A photo's date taken, from its Takeout sidecar, is its copy's modification time, to the second:
|
|
366
|
+
// a photo carries no frontmatter, and the time Takeout wrote the file says nothing of the moment.
|
|
367
|
+
// The caption is read again from the sidecar, unchanged since the gather, for the gathered list.
|
|
368
|
+
const taken = !md && Number.isInteger(i.takeout?.takenEpoch) ? i.takeout.takenEpoch : null;
|
|
369
|
+
const takeout = i.takeout ? { ...(i.takeout.taken ? { taken: i.takeout.taken } : {}), ...(i.takeout.created ? { created: i.takeout.created } : {}), ...(i.takeout.caption ? { caption: sidecarCaption(p, i) } : {}) } : null;
|
|
370
|
+
if (takeout && takeout.caption === null) delete takeout.caption;
|
|
371
|
+
// A note gather caught as holding a credential, newly sealed here (HQ-1916): its value(s) are
|
|
372
|
+
// moved into the Keychain at apply, not left in the file. A file that already carried its own
|
|
373
|
+
// seal (re-gathered, or hand-marked) is not a fresh credential detection and is left as it is.
|
|
300
374
|
out.writes.push({
|
|
301
375
|
kind: mode === "move" ? "move" : "copy", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes, original: buf,
|
|
376
|
+
credential: !!(md && pl.sealed && i.credential && !i.sealed),
|
|
302
377
|
schemes: md && add.references ? pl.refs.map((r) => r.split(":")[0]) : [],
|
|
303
|
-
times: { atime: st.atimeMs / 1000, mtime: st.mtimeMs / 1000 }, mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs,
|
|
378
|
+
times: { atime: st.atimeMs / 1000, mtime: taken ?? st.mtimeMs / 1000 }, mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs,
|
|
379
|
+
...(takeout && Object.keys(takeout).length ? { takeout } : {}),
|
|
304
380
|
});
|
|
305
381
|
}
|
|
306
382
|
// A finished project with no README placed gets one, holding only its status and its name.
|
|
@@ -309,6 +385,20 @@ function plan(evda, p, { mode }) {
|
|
|
309
385
|
const { to, folder } = land(`${f.dir}/README.md`);
|
|
310
386
|
out.writes.push({ kind: "page", item: `status:${f.name}`, to: claim(to), folder, bytes: Buffer.from(`---\nstatus: ${f.status}\n---\n# ${f.name}\n`, "utf8"), schemes: [] });
|
|
311
387
|
}
|
|
388
|
+
// A page that lands directly in a person folder is that person's profile (HQ-2024), so it opens
|
|
389
|
+
// with person-profile frontmatter, planned the way `evda migrate-people` plans one: keys the page
|
|
390
|
+
// already has stay, a dated heading becomes an observation, and a page with none gets one dated
|
|
391
|
+
// the day of the apply. A sealed or just-noted page is left as it is, and doctor names it.
|
|
392
|
+
const typeAt = new Map(folderManifests(evda).map((m) => [posix(relative(evda.dir, m.dir)), m.type]));
|
|
393
|
+
const owner = (() => { const h = holderOf(evda.manifest); return h.id ?? h.name ?? "owner"; })();
|
|
394
|
+
for (const w of out.writes) {
|
|
395
|
+
const f = out.folders.get(w.folder);
|
|
396
|
+
if (!f || !/\.md$/i.test(w.to) || basename(w.to) === README_NAME || posix(dirname(w.to)) !== f.path) continue;
|
|
397
|
+
if ((typeAt.has(f.path) ? typeAt.get(f.path) : f.proposal.type) !== "person") continue;
|
|
398
|
+
const prof = planProfile({ fileName: basename(w.to), text: w.bytes.toString("utf8"), owner, date: localDate(now) });
|
|
399
|
+
if (prof.skip || prof.problems.length) continue;
|
|
400
|
+
w.bytes = Buffer.concat([Buffer.from(prof.frontmatter, "utf8"), w.bytes.subarray(prof.bodyAt)]);
|
|
401
|
+
}
|
|
312
402
|
// The folders the writes land in: made, with their manifest and role, or the record's own.
|
|
313
403
|
out.roles = [];
|
|
314
404
|
const mapping = evda.manifest.mapping;
|
|
@@ -317,7 +407,8 @@ function plan(evda, p, { mode }) {
|
|
|
317
407
|
for (const f of [...out.folders.values()].filter((x) => x.how === "create" && out.writes.some((w) => w.folder === x.proposal.path))) {
|
|
318
408
|
if (roleForPath(four, f.path)) continue;
|
|
319
409
|
// The proposal's own key for this folder, or for the nearest folder above it.
|
|
320
|
-
|
|
410
|
+
// A folder renamed into the record's own scheme takes the folder's own role under its new name.
|
|
411
|
+
const candidates = f.path !== f.proposal.path ? [] : Object.entries(p.mapping ?? {}).filter(([, v]) => f.path === v || f.path.startsWith(`${v}/`)).sort((a, b) => b[1].length - a[1].length);
|
|
321
412
|
const [key0, rel] = candidates[0] ?? [`${f.role ?? "collections"}/${slug(basename(f.path))}`, f.path];
|
|
322
413
|
if (out.roles.some((r) => r.rel === rel) || Object.values(mapping).includes(rel)) continue;
|
|
323
414
|
let key = key0;
|
|
@@ -349,9 +440,12 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
349
440
|
const manifestAbs = dry ? null : checkOut(manifestOut ?? join(gatherDir(env), `${p.id}.apply-${now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "")}.json`), p);
|
|
350
441
|
if (manifestAbs && existsSync(manifestAbs)) throw refuse(`a reversal manifest is already at ${manifestAbs}. Name another with --manifest.`, "MANIFEST_EXISTS");
|
|
351
442
|
|
|
443
|
+
// The folders whose times revert puts back: the target's own, when it is there before the apply.
|
|
444
|
+
const dirTimes = new Map();
|
|
445
|
+
if (!dry) noteDirTimes(dirTimes, target);
|
|
352
446
|
const rec = ensureRecord(target, p, { owner, dry });
|
|
353
447
|
const evda = rec.evda;
|
|
354
|
-
const pl = plan(evda, p, { mode });
|
|
448
|
+
const pl = plan(evda, p, { mode, now });
|
|
355
449
|
pl.chats = chatWrites(evda, p);
|
|
356
450
|
const result = { dry, mode, target, made: rec.made, plan: pl, manifest: manifestAbs, proposal: p.id };
|
|
357
451
|
result.counts = counts(pl, p);
|
|
@@ -361,7 +455,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
361
455
|
return result;
|
|
362
456
|
}
|
|
363
457
|
|
|
364
|
-
const t = tracker(evda.dir);
|
|
458
|
+
const t = tracker(evda.dir, dirTimes, rec.made?.dirs ?? []);
|
|
365
459
|
// 1. Folders the writes need that the record does not have, each with its manifest.
|
|
366
460
|
const needSchemes = new Map();
|
|
367
461
|
for (const w of pl.writes) for (const s of w.schemes) {
|
|
@@ -385,13 +479,35 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
385
479
|
}
|
|
386
480
|
// 3. The files.
|
|
387
481
|
const ledger = [];
|
|
482
|
+
// HQ-1916: a credential's value moves into the Keychain here, before its file ever touches disk;
|
|
483
|
+
// the tracker only ever sees the redacted bytes. Not macOS, or the write fails: the value stays in
|
|
484
|
+
// the sealed file exactly as HQ-1368 left it, and the fallback says why in one plain line.
|
|
485
|
+
const keychainPath = env.EVDA_KEYCHAIN_PATH || null;
|
|
486
|
+
const keychainAccounts = [];
|
|
487
|
+
const keychainLines = [];
|
|
488
|
+
let keychainFilesMoved = 0;
|
|
489
|
+
let keychainFilesKept = 0;
|
|
388
490
|
for (const w of pl.writes) {
|
|
389
|
-
|
|
390
|
-
if (w.
|
|
491
|
+
let bytes = w.bytes;
|
|
492
|
+
if (w.credential) {
|
|
493
|
+
const sealed = sealForKeychain(bytes.toString("utf8"), basename(w.to), { keychainPath });
|
|
494
|
+
if (sealed.ok) {
|
|
495
|
+
bytes = Buffer.from(sealed.text, "utf8");
|
|
496
|
+
keychainAccounts.push(...sealed.accounts);
|
|
497
|
+
keychainFilesMoved += 1;
|
|
498
|
+
keychainLines.push(` kept ${w.to}: ${sealed.accounts.length} value(s) moved to the Keychain (${SERVICE}); the file holds a pointer, not the value`);
|
|
499
|
+
} else {
|
|
500
|
+
keychainFilesKept += 1;
|
|
501
|
+
keychainLines.push(` kept ${w.to}: the value stays in the sealed file (${sealed.why})`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
t.writeNew(w.to, bytes, w.times ?? null);
|
|
505
|
+
if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString(), ...(w.takeout ? { takeout: w.takeout } : {}) });
|
|
391
506
|
if (w.kind === "move") {
|
|
507
|
+
t.noteDir(dirname(w.source));
|
|
392
508
|
unlinkSync(w.source);
|
|
393
509
|
// 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(
|
|
510
|
+
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
511
|
}
|
|
396
512
|
}
|
|
397
513
|
// 3b. The owner's chat messages no agent filed (HQ-1385), appended to their days.
|
|
@@ -429,6 +545,8 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
429
545
|
const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
|
|
430
546
|
if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
|
|
431
547
|
const before = tree(evda.dir);
|
|
548
|
+
t.noteDir(evda.dir);
|
|
549
|
+
t.noteDir(join(evda.dir, PROPOSALS));
|
|
432
550
|
const r = proposeCircle(evda, { name: c.name, who: c.readBy === "none" ? "Only the owner, on their own devices. No AI reads it (read_by: none), and no agent host holds it." : "Only the people the owner grants it to, by name.", what: `${covers}/`, why: `The owner said yes to its own circle for these notes while gathering a pile (proposal ${p.id}).` }, { now });
|
|
433
551
|
const after = tree(evda.dir);
|
|
434
552
|
for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
|
|
@@ -451,9 +569,15 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
451
569
|
modified: t.modified.filter((m) => !rec.made?.files.includes(m.path)),
|
|
452
570
|
dirs: t.dirs,
|
|
453
571
|
moved: t.moved,
|
|
572
|
+
// The times of every folder that was there before the apply and that it changed (HQ-1368).
|
|
573
|
+
dirTimes: [...dirTimes.values()],
|
|
454
574
|
placed: pl.writes.map((w) => ({ item: w.item, kind: w.kind, from: w.source ?? null, to: w.to })),
|
|
455
575
|
chats: [...pl.chats.byFile.keys()],
|
|
456
576
|
counts: result.counts,
|
|
577
|
+
// HQ-1916: the Keychain items this apply added, and the keychain they went into (null is the
|
|
578
|
+
// login keychain), so revert can remove exactly these and nothing else.
|
|
579
|
+
keychain: keychainAccounts,
|
|
580
|
+
keychainPath,
|
|
457
581
|
};
|
|
458
582
|
mkdirSync(dirname(manifestAbs), { recursive: true });
|
|
459
583
|
writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" });
|
|
@@ -461,6 +585,9 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
461
585
|
const lines = [];
|
|
462
586
|
const d = doctor(evda.dir, { out: (l) => lines.push(l), via: "gather apply" });
|
|
463
587
|
result.doctor = { exit: d.exit, lines, failures: d.failures, warnings: d.warnings.length };
|
|
588
|
+
result.keychainLines = keychainLines;
|
|
589
|
+
result.keychainMoved = keychainFilesMoved;
|
|
590
|
+
result.keychainKept = keychainFilesKept;
|
|
464
591
|
result.lines = applyLines(result, p);
|
|
465
592
|
return result;
|
|
466
593
|
}
|
|
@@ -493,7 +620,7 @@ function counts(pl, p) {
|
|
|
493
620
|
const placed = by("copy") + by("move") + by("reference") + by("page");
|
|
494
621
|
return {
|
|
495
622
|
placed, copied: by("copy"), moved: by("move"), references: by("reference"), pages: by("page"),
|
|
496
|
-
asked: (p.questions ?? []).length, left: pl.left.length, refused: pl.refused.length, present: pl.present.length,
|
|
623
|
+
asked: (p.questions ?? []).length, left: pl.left.length, refused: pl.refused.length, present: pl.present.length, duplicates: pl.duplicates.length,
|
|
497
624
|
changed: pl.changed.length, referenceOnly: pl.referenceOnly.length,
|
|
498
625
|
chatAdded: pl.chats?.added ?? 0, chatFiled: pl.chats?.filed ?? 0, chatSecret: pl.chats?.secret ?? 0, chatDays: pl.chats?.byFile.size ?? 0,
|
|
499
626
|
};
|
|
@@ -507,7 +634,7 @@ function applyLines(r, p) {
|
|
|
507
634
|
const c = r.counts;
|
|
508
635
|
const out = [];
|
|
509
636
|
const verb = r.dry ? "would" : "did";
|
|
510
|
-
if (r.made) out.push(`${r.dry ? "Would make" : "Made"} a new EVDA at ${r.target}
|
|
637
|
+
if (r.made) out.push(`${r.dry ? "Would make" : "Made"} a new EVDA at ${r.target} in create-evda's own layout, its new folders under record/ (${r.made.files.length} file(s)).`);
|
|
511
638
|
else out.push(`${r.dry ? "Would grow" : "Grew"} the EVDA at ${r.target}; nothing already in it was moved.`);
|
|
512
639
|
for (const f of r.plan.folders.values()) {
|
|
513
640
|
if (f.how === "adopted") out.push(` adopted ${f.proposal.path}/ as the record's own ${f.path}/ (type ${f.proposal.type}, ${f.proposal.role})`);
|
|
@@ -519,12 +646,14 @@ function applyLines(r, p) {
|
|
|
519
646
|
if (c.pages) out.push(` ${verb === "did" ? "wrote " : "write "} ${c.pages} page(s) the answers asked for`);
|
|
520
647
|
if (c.referenceOnly) out.push(` kept ${c.referenceOnly} export(s) as references only, never copied`);
|
|
521
648
|
if (c.present) out.push(` present ${c.present} item(s) already in the record, left where they are`);
|
|
649
|
+
if (r.keychainLines?.length) out.push(...r.keychainLines);
|
|
522
650
|
for (const x of r.plan.changed) out.push(` changed ${x.item} ${x.why}`);
|
|
523
651
|
if ((p.chats ?? []).length) {
|
|
524
652
|
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` : ""}`);
|
|
525
653
|
for (const why of r.plan.chats.changed) out.push(` changed ${why}`);
|
|
526
654
|
}
|
|
527
655
|
for (const x of r.plan.left) out.push(` left ${x.item}: ${x.why}`);
|
|
656
|
+
for (const x of r.plan.duplicates) out.push(` duplicate ${x.item}: the same bytes as ${x.of}, placed once; this copy is left in the pile`);
|
|
528
657
|
for (const x of r.plan.refused) out.push(` refused ${x.item}: left where it is`);
|
|
529
658
|
for (const x of r.proposed ?? []) out.push(` proposed ${x} (a proposal file; no circle was created, and no file was marked for sharing)`);
|
|
530
659
|
if (!r.dry) {
|
|
@@ -535,7 +664,8 @@ function applyLines(r, p) {
|
|
|
535
664
|
} else out.push("Nothing was written.");
|
|
536
665
|
out.push(checkedLine(c));
|
|
537
666
|
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
|
-
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`);
|
|
667
|
+
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${c.duplicates ? `, ${c.duplicates} duplicate(s) left in the pile` : ""}`);
|
|
668
|
+
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
669
|
return out;
|
|
540
670
|
}
|
|
541
671
|
|
|
@@ -587,6 +717,10 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
|
|
|
587
717
|
}
|
|
588
718
|
let removed = 0;
|
|
589
719
|
for (const f of files) { const a = join(root, f.path); if (existsSync(a)) { unlinkSync(a); removed += 1; } }
|
|
720
|
+
// HQ-1916: every Keychain item this apply added, removed again. Never blocks the revert: a value
|
|
721
|
+
// already gone from the Keychain (the owner deleted it by hand) is simply not there to remove.
|
|
722
|
+
let keychainRemoved = 0;
|
|
723
|
+
for (const account of m.keychain ?? []) if (removeSecret({ account, keychainPath: m.keychainPath ?? null })) keychainRemoved += 1;
|
|
590
724
|
let put = 0;
|
|
591
725
|
if (!falsified("revert-not-exact")) {
|
|
592
726
|
for (const f of m.modified) {
|
|
@@ -608,9 +742,21 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
|
|
|
608
742
|
rmdirSync(a);
|
|
609
743
|
}
|
|
610
744
|
if (m.made?.root && existsSync(root) && readdirSync(root).length === 0) rmdirSync(root);
|
|
745
|
+
// Last, once nothing more is written: every folder the apply changed gets its times back, deepest
|
|
746
|
+
// first. One the owner removed since is named and not made again for its time.
|
|
747
|
+
const dirTimes = Array.isArray(m.dirTimes) ? m.dirTimes : null;
|
|
748
|
+
const timesSet = [];
|
|
749
|
+
const dirsGone = [];
|
|
750
|
+
if (dirTimes && !falsified("revert-dir-times")) {
|
|
751
|
+
for (const d of [...dirTimes].sort((a, b) => b.path.split(sep).length - a.path.split(sep).length)) {
|
|
752
|
+
if (!existsSync(d.path) || !statSync(d.path).isDirectory()) { dirsGone.push(d.path); continue; }
|
|
753
|
+
utimesSync(d.path, d.atimeMs / 1000, d.mtimeMs / 1000);
|
|
754
|
+
timesSet.push(d.path);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
611
757
|
m.reverted = now.toISOString();
|
|
612
758
|
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 };
|
|
759
|
+
return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0, keychainRemoved, dirTimes: dirTimes ? { set: timesSet.length, gone: dirsGone } : null };
|
|
614
760
|
}
|
|
615
761
|
|
|
616
762
|
export function revertLines(r) {
|
|
@@ -621,7 +767,11 @@ export function revertLines(r) {
|
|
|
621
767
|
if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the apply were removed: ${r.edited.join(", ")}`);
|
|
622
768
|
for (const d of r.kept) out.push(` kept ${d}/, which holds files the apply did not write`);
|
|
623
769
|
if (r.gone.length) out.push(` gone ${r.gone.length} file(s) were already removed`);
|
|
770
|
+
if (r.dirTimes?.gone.length) out.push(` gone ${r.dirTimes.gone.length} folder(s) removed since the apply, left as they are: ${r.dirTimes.gone.map((d) => (d.startsWith(r.target + sep) ? posix(relative(r.target, d)) : d)).join(", ")}`);
|
|
771
|
+
if (r.keychainRemoved) out.push(` removed ${r.keychainRemoved} Keychain item(s) the apply added`);
|
|
624
772
|
out.push(`CHECKED ${r.removed + r.restored + r.put} file(s) reverted ${r.kept.length} folder(s) kept`);
|
|
773
|
+
if (r.keychainRemoved) out.push(`CHECKED ${r.keychainRemoved} Keychain item(s) removed`);
|
|
774
|
+
out.push(r.dirTimes ? `CHECKED ${r.dirTimes.set} folder time(s) restored, ${r.dirTimes.gone.length} folder(s) gone` : "SKIPPED folder times: this reversal manifest is older than HQ-1368's folder times, so none were kept");
|
|
625
775
|
return out;
|
|
626
776
|
}
|
|
627
777
|
|