create-evda 0.1.9 → 0.1.11
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 +95 -0
- package/README.md +12 -5
- package/bin/evda.mjs +129 -6
- package/create-evda.mjs +14 -11
- package/lib/adopt.mjs +516 -0
- package/lib/agents.mjs +1 -1
- package/lib/attested-host.mjs +287 -0
- package/lib/attested-wrap.mjs +147 -0
- package/lib/checkout-freshness.mjs +88 -0
- package/lib/circles.mjs +4 -2
- package/lib/clients.mjs +11 -0
- package/lib/connect.mjs +1 -1
- 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 +121 -12
- package/lib/day-writer.mjs +39 -5
- package/lib/doctor.mjs +60 -5
- package/lib/every-chat.mjs +7 -11
- package/lib/folders.mjs +2 -1
- package/lib/gather-apply.mjs +133 -24
- package/lib/gather.mjs +120 -19
- package/lib/html-notes.mjs +124 -0
- package/lib/keep-hook.mjs +84 -12
- package/lib/keychain.mjs +38 -1
- package/lib/manifest.mjs +3 -1
- package/lib/mcp-http.mjs +19 -1
- package/lib/mcp.mjs +358 -56
- package/lib/measurement-log.mjs +3 -0
- package/lib/migrate-people.mjs +54 -20
- package/lib/person-folders.mjs +90 -0
- package/lib/plugin-hook.mjs +109 -0
- package/lib/plugin.mjs +208 -0
- package/lib/presets.mjs +28 -6
- package/lib/prompt-hook.mjs +115 -4
- package/lib/publish.mjs +55 -10
- package/lib/readme.mjs +145 -0
- package/lib/record-walk.mjs +9 -0
- package/lib/record.mjs +54 -0
- package/lib/routines.mjs +13 -2
- package/lib/takeout.mjs +169 -0
- package/lib/tdx-attestation.mjs +432 -0
- package/lib/tdx-trust.json +10 -0
- package/lib/tools.mjs +669 -59
- package/lib/touches.mjs +427 -0
- package/lib/update-check.mjs +30 -0
- package/lib/yaml.mjs +8 -4
- package/package.json +2 -1
- package/templates/AGENTS.md +56 -6
- package/templates/evda-digest.md +2 -0
package/lib/every-chat.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// here, so no surface can carry a different copy. No node: imports, because lib/clients.mjs is
|
|
13
13
|
// rendered in the EVDA app's webview and reads this file.
|
|
14
14
|
|
|
15
|
+
import { appLike } from "./connecting-apps.mjs";
|
|
16
|
+
|
|
15
17
|
export const EVERY_CHAT_KEY = "every_chat";
|
|
16
18
|
|
|
17
19
|
// Bumped when the line's meaning changes. An owner who set an older version is asked once more,
|
|
@@ -66,18 +68,12 @@ export const CHAT_APPS = {
|
|
|
66
68
|
};
|
|
67
69
|
export const GENERIC_WHERE = "Add it to your app's every-chat instructions: the setting that applies to all of your conversations, not just this one.";
|
|
68
70
|
|
|
69
|
-
// Which app is calling, from initialize's clientInfo.name
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// ChatGPT send are not yet measured (HQ-2207 measures them), and a name that matches nothing is
|
|
73
|
-
// "other", which is asked with GENERIC_WHERE rather than skipped, so a new app still gets the line.
|
|
74
|
-
const CODING = /claude[-_ ]?code|codex|cursor|copilot|vscode|windsurf|zed|opencode|cline|aider|gemini[-_ ]?cli|goose|mcp-inspector|inspector/i;
|
|
71
|
+
// Which chat app is calling, from initialize's clientInfo.name: the every-chat key of the first row
|
|
72
|
+
// in lib/connecting-apps.mjs (HQ-2218) whose pattern matches it. A coding agent's row has none
|
|
73
|
+
// (null), and a name no row matches is "other", asked with GENERIC_WHERE rather than skipped.
|
|
75
74
|
export function chatAppOf(clientName) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
if (/claude|anthropic/i.test(n)) return "claude";
|
|
79
|
-
if (/openai|chatgpt/i.test(n)) return "chatgpt";
|
|
80
|
-
return "other";
|
|
75
|
+
const row = appLike(clientName);
|
|
76
|
+
return row ? row.chat : "other";
|
|
81
77
|
}
|
|
82
78
|
|
|
83
79
|
// What evda.json says for one app: { state: "set" | "declined", form, version, date } or null.
|
package/lib/folders.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { join, relative, resolve, sep } from "node:path";
|
|
|
25
25
|
import { resolveAll } from "./roles.mjs";
|
|
26
26
|
import { SCHEMES } from "./references.mjs";
|
|
27
27
|
import { EvdaError } from "./errors.mjs";
|
|
28
|
+
import { outsideRecord } from "./record-walk.mjs";
|
|
28
29
|
|
|
29
30
|
export const FOLDER_MANIFEST = "folder.evda.json";
|
|
30
31
|
export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis", "message"];
|
|
@@ -138,7 +139,7 @@ export function folderManifests(evda) {
|
|
|
138
139
|
}
|
|
139
140
|
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
140
141
|
for (const e of entries) {
|
|
141
|
-
if (e.name.startsWith(".") || e.name
|
|
142
|
+
if (e.name.startsWith(".") || outsideRecord(e.name)) continue;
|
|
142
143
|
if (e.isDirectory()) walk(join(dir, e.name));
|
|
143
144
|
}
|
|
144
145
|
};
|
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,28 +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";
|
|
54
58
|
import { sealForKeychain, removeSecret, SERVICE } from "./keychain.mjs";
|
|
55
59
|
import { EvdaError } from "./errors.mjs";
|
|
56
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";
|
|
57
66
|
import { parseArgs, scaffold } from "../create-evda.mjs";
|
|
58
67
|
|
|
59
68
|
export const APPLY_VERSION = 1;
|
|
60
|
-
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"];
|
|
61
70
|
const falsified = (mode) => (process.env.EVDA_GATHER_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
62
71
|
const posix = (p) => p.split(sep).join("/");
|
|
63
72
|
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
@@ -68,20 +77,27 @@ const within = (abs, root) => abs === root || abs.startsWith(root + sep);
|
|
|
68
77
|
|
|
69
78
|
// Every write the apply makes goes through here, so the reversal manifest is complete by
|
|
70
79
|
// construction: a file made, a file changed (its old bytes and times kept), a directory made.
|
|
71
|
-
function tracker(root) {
|
|
72
|
-
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 };
|
|
73
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); };
|
|
74
88
|
t.mkdirp = (rel) => {
|
|
75
89
|
if (!rel || rel === ".") return;
|
|
76
90
|
const segs = rel.split("/");
|
|
77
91
|
for (let i = 1; i <= segs.length; i++) {
|
|
78
92
|
const sub = segs.slice(0, i).join("/");
|
|
79
93
|
if (existsSync(abs(sub))) continue;
|
|
94
|
+
t.noteDir(dirname(abs(sub)));
|
|
80
95
|
mkdirSync(abs(sub));
|
|
81
96
|
t.dirs.push(sub);
|
|
82
97
|
}
|
|
83
98
|
};
|
|
84
99
|
t.writeNew = (rel, buf, times = null) => {
|
|
100
|
+
t.noteDir(dirname(abs(rel)));
|
|
85
101
|
t.mkdirp(posix(dirname(rel)));
|
|
86
102
|
writeFileSync(abs(rel), buf, { flag: "wx" });
|
|
87
103
|
if (times) utimesSync(abs(rel), times.atime, times.mtime);
|
|
@@ -95,6 +111,7 @@ function tracker(root) {
|
|
|
95
111
|
t.stash.set(rel, { path: rel, before: readFileSync(abs(rel)).toString("base64"), mode: st.mode & 0o7777, atimeMs: st.atimeMs, mtimeMs: st.mtimeMs });
|
|
96
112
|
} else t.stash.set(rel, { path: rel, before: null });
|
|
97
113
|
}
|
|
114
|
+
t.noteDir(dirname(abs(rel)));
|
|
98
115
|
t.mkdirp(posix(dirname(rel)));
|
|
99
116
|
writeFileSync(abs(rel), buf);
|
|
100
117
|
};
|
|
@@ -106,6 +123,13 @@ function tracker(root) {
|
|
|
106
123
|
return t;
|
|
107
124
|
}
|
|
108
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
|
+
|
|
109
133
|
// Every file and directory under `root`, relative, hidden ones included; symlinks are listed as files.
|
|
110
134
|
function tree(root) {
|
|
111
135
|
const files = [];
|
|
@@ -172,13 +196,37 @@ function ensureRecord(target, p, { owner = "", dry = false } = {}) {
|
|
|
172
196
|
return { evda: openEvda(dir), made, existed: false, scratch: dry ? dirname(dir) : null };
|
|
173
197
|
}
|
|
174
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.
|
|
175
202
|
function createRecord(dir, presetId, owner) {
|
|
176
|
-
const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : [])
|
|
203
|
+
const opts = parseArgs([dir, "--yes", "--no-pointer", ...(owner ? ["--owner", owner] : [])]);
|
|
177
204
|
scaffold(opts);
|
|
178
|
-
|
|
179
|
-
return { preset:
|
|
205
|
+
if (falsified("two-schemes")) applyPreset(dir, presetId);
|
|
206
|
+
return { preset: presetId };
|
|
207
|
+
}
|
|
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
|
+
});
|
|
180
223
|
}
|
|
181
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
|
+
|
|
182
230
|
// ---- the plan ----
|
|
183
231
|
|
|
184
232
|
// The folder a proposal folder lands in, in this record.
|
|
@@ -189,11 +237,13 @@ function folderResolver(evda, p) {
|
|
|
189
237
|
const inSub = (rel) => subs.some((s) => rel === s || rel.startsWith(`${s}/`));
|
|
190
238
|
const manifests = folderManifests(evda).filter((m) => m.type && !inSub(posix(relative(evda.dir, m.dir))));
|
|
191
239
|
const cache = new Map();
|
|
240
|
+
const own = ownLayout(evda);
|
|
192
241
|
return (f) => {
|
|
193
242
|
if (cache.has(f.path)) return cache.get(f.path);
|
|
194
243
|
let r;
|
|
195
|
-
const
|
|
196
|
-
|
|
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" };
|
|
197
247
|
else if (!falsified("reapply-resorts") && f.type) {
|
|
198
248
|
const same = manifests
|
|
199
249
|
.map((m) => ({ m, rel: posix(relative(evda.dir, m.dir)) }))
|
|
@@ -203,7 +253,7 @@ function folderResolver(evda, p) {
|
|
|
203
253
|
}
|
|
204
254
|
if (!r) {
|
|
205
255
|
const pf = preset.folders.find((x) => x.path === f.path);
|
|
206
|
-
r = { path:
|
|
256
|
+
r = { path: name, how: "create", type: f.type ?? null, role: f.role ?? null, connectors: pf?.connectors ?? [] };
|
|
207
257
|
}
|
|
208
258
|
cache.set(f.path, r);
|
|
209
259
|
return r;
|
|
@@ -213,17 +263,26 @@ function folderResolver(evda, p) {
|
|
|
213
263
|
// The proposal folder a destination sits under: the longest one at or above it.
|
|
214
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;
|
|
215
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
|
+
|
|
216
275
|
function sourceAbs(p, item) {
|
|
217
276
|
const s = p.sources.find((x) => x.id === item.source);
|
|
218
277
|
return existsSync(s.path) && statSync(s.path).isFile() ? s.path : join(s.path, item.path);
|
|
219
278
|
}
|
|
220
279
|
|
|
221
280
|
// The whole apply as a list of intended writes, before any is made. Pure over the record's state.
|
|
222
|
-
function plan(evda, p, { mode }) {
|
|
281
|
+
function plan(evda, p, { mode, now = new Date() }) {
|
|
223
282
|
const resolveFolder = folderResolver(evda, p);
|
|
224
283
|
const index = presentIndex(evda.dir);
|
|
225
284
|
const items = new Map(p.items.map((i) => [i.id, i]));
|
|
226
|
-
const out = { writes: [], present: [], changed: [], left: [], refused: [], referenceOnly: [], folders: new Map() };
|
|
285
|
+
const out = { writes: [], present: [], changed: [], left: [], refused: [], duplicates: [], referenceOnly: [], folders: new Map() };
|
|
227
286
|
const taken = new Set();
|
|
228
287
|
const claim = (to) => {
|
|
229
288
|
let t = to;
|
|
@@ -256,6 +315,7 @@ function plan(evda, p, { mode }) {
|
|
|
256
315
|
const i = items.get(pl.item);
|
|
257
316
|
if (pl.status === "refused") { out.refused.push({ item: pl.item, why: pl.why }); continue; }
|
|
258
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; }
|
|
259
319
|
if (pl.status === "present") { out.present.push({ item: pl.item, at: pl.at }); continue; }
|
|
260
320
|
if (pl.status === "reference" && !pl.to) { out.referenceOnly.push({ item: pl.item, refs: (i.cards ?? []).map((c) => c.ref) }); continue; }
|
|
261
321
|
if (pl.status === "create") {
|
|
@@ -291,13 +351,23 @@ function plan(evda, p, { mode }) {
|
|
|
291
351
|
// A credential kept in the Credentials folder is sealed (HQ-1368): evda_search, evda_recent and
|
|
292
352
|
// evda_read refuse it to every caller without naming it, and no tool releases it.
|
|
293
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)}`;
|
|
294
357
|
// The item's own bytes, frontmatter included, so `noted: true` and `sealed: true` ride into the
|
|
295
358
|
// record with it (HQ-1414). EVDA_CARRY_FALSIFY=gather-apply:<flag> drops that line on the way in.
|
|
296
|
-
|
|
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);
|
|
297
361
|
// A note that already said `sealed: false` (or anything but true) is sealed all the same.
|
|
298
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");
|
|
299
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");
|
|
300
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;
|
|
301
371
|
// A note gather caught as holding a credential, newly sealed here (HQ-1916): its value(s) are
|
|
302
372
|
// moved into the Keychain at apply, not left in the file. A file that already carried its own
|
|
303
373
|
// seal (re-gathered, or hand-marked) is not a fresh credential detection and is left as it is.
|
|
@@ -305,7 +375,8 @@ function plan(evda, p, { mode }) {
|
|
|
305
375
|
kind: mode === "move" ? "move" : "copy", item: pl.item, source: src, sourceSha: i.sha256, to: claim(to), folder, bytes, original: buf,
|
|
306
376
|
credential: !!(md && pl.sealed && i.credential && !i.sealed),
|
|
307
377
|
schemes: md && add.references ? pl.refs.map((r) => r.split(":")[0]) : [],
|
|
308
|
-
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 } : {}),
|
|
309
380
|
});
|
|
310
381
|
}
|
|
311
382
|
// A finished project with no README placed gets one, holding only its status and its name.
|
|
@@ -314,6 +385,20 @@ function plan(evda, p, { mode }) {
|
|
|
314
385
|
const { to, folder } = land(`${f.dir}/README.md`);
|
|
315
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: [] });
|
|
316
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
|
+
}
|
|
317
402
|
// The folders the writes land in: made, with their manifest and role, or the record's own.
|
|
318
403
|
out.roles = [];
|
|
319
404
|
const mapping = evda.manifest.mapping;
|
|
@@ -322,7 +407,8 @@ function plan(evda, p, { mode }) {
|
|
|
322
407
|
for (const f of [...out.folders.values()].filter((x) => x.how === "create" && out.writes.some((w) => w.folder === x.proposal.path))) {
|
|
323
408
|
if (roleForPath(four, f.path)) continue;
|
|
324
409
|
// The proposal's own key for this folder, or for the nearest folder above it.
|
|
325
|
-
|
|
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);
|
|
326
412
|
const [key0, rel] = candidates[0] ?? [`${f.role ?? "collections"}/${slug(basename(f.path))}`, f.path];
|
|
327
413
|
if (out.roles.some((r) => r.rel === rel) || Object.values(mapping).includes(rel)) continue;
|
|
328
414
|
let key = key0;
|
|
@@ -354,9 +440,12 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
354
440
|
const manifestAbs = dry ? null : checkOut(manifestOut ?? join(gatherDir(env), `${p.id}.apply-${now.toISOString().replace(/[-:]/g, "").replace(/\..*$/, "")}.json`), p);
|
|
355
441
|
if (manifestAbs && existsSync(manifestAbs)) throw refuse(`a reversal manifest is already at ${manifestAbs}. Name another with --manifest.`, "MANIFEST_EXISTS");
|
|
356
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);
|
|
357
446
|
const rec = ensureRecord(target, p, { owner, dry });
|
|
358
447
|
const evda = rec.evda;
|
|
359
|
-
const pl = plan(evda, p, { mode });
|
|
448
|
+
const pl = plan(evda, p, { mode, now });
|
|
360
449
|
pl.chats = chatWrites(evda, p);
|
|
361
450
|
const result = { dry, mode, target, made: rec.made, plan: pl, manifest: manifestAbs, proposal: p.id };
|
|
362
451
|
result.counts = counts(pl, p);
|
|
@@ -366,7 +455,7 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
366
455
|
return result;
|
|
367
456
|
}
|
|
368
457
|
|
|
369
|
-
const t = tracker(evda.dir);
|
|
458
|
+
const t = tracker(evda.dir, dirTimes, rec.made?.dirs ?? []);
|
|
370
459
|
// 1. Folders the writes need that the record does not have, each with its manifest.
|
|
371
460
|
const needSchemes = new Map();
|
|
372
461
|
for (const w of pl.writes) for (const s of w.schemes) {
|
|
@@ -413,8 +502,9 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
413
502
|
}
|
|
414
503
|
}
|
|
415
504
|
t.writeNew(w.to, bytes, w.times ?? null);
|
|
416
|
-
if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString() });
|
|
505
|
+
if (w.source) ledger.push({ source: w.sourceSha, to: w.to, proposal: p.id, at: now.toISOString(), ...(w.takeout ? { takeout: w.takeout } : {}) });
|
|
417
506
|
if (w.kind === "move") {
|
|
507
|
+
t.noteDir(dirname(w.source));
|
|
418
508
|
unlinkSync(w.source);
|
|
419
509
|
// A file the apply changed on the way in keeps its source bytes here, so revert can put them back.
|
|
420
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") } : {}) });
|
|
@@ -455,6 +545,8 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
455
545
|
const covers = f ? f.path + c.covers.slice(f.proposal.path.length) : c.covers;
|
|
456
546
|
if (!pl.writes.some((w) => w.to.startsWith(`${covers}/`))) continue;
|
|
457
547
|
const before = tree(evda.dir);
|
|
548
|
+
t.noteDir(evda.dir);
|
|
549
|
+
t.noteDir(join(evda.dir, PROPOSALS));
|
|
458
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 });
|
|
459
551
|
const after = tree(evda.dir);
|
|
460
552
|
for (const d of after.dirs.filter((x) => !before.dirs.includes(x))) t.dirs.push(d);
|
|
@@ -477,6 +569,8 @@ export async function applyGather(p, into, { mode = "copy", dry = false, now = n
|
|
|
477
569
|
modified: t.modified.filter((m) => !rec.made?.files.includes(m.path)),
|
|
478
570
|
dirs: t.dirs,
|
|
479
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()],
|
|
480
574
|
placed: pl.writes.map((w) => ({ item: w.item, kind: w.kind, from: w.source ?? null, to: w.to })),
|
|
481
575
|
chats: [...pl.chats.byFile.keys()],
|
|
482
576
|
counts: result.counts,
|
|
@@ -526,7 +620,7 @@ function counts(pl, p) {
|
|
|
526
620
|
const placed = by("copy") + by("move") + by("reference") + by("page");
|
|
527
621
|
return {
|
|
528
622
|
placed, copied: by("copy"), moved: by("move"), references: by("reference"), pages: by("page"),
|
|
529
|
-
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,
|
|
530
624
|
changed: pl.changed.length, referenceOnly: pl.referenceOnly.length,
|
|
531
625
|
chatAdded: pl.chats?.added ?? 0, chatFiled: pl.chats?.filed ?? 0, chatSecret: pl.chats?.secret ?? 0, chatDays: pl.chats?.byFile.size ?? 0,
|
|
532
626
|
};
|
|
@@ -540,7 +634,7 @@ function applyLines(r, p) {
|
|
|
540
634
|
const c = r.counts;
|
|
541
635
|
const out = [];
|
|
542
636
|
const verb = r.dry ? "would" : "did";
|
|
543
|
-
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)).`);
|
|
544
638
|
else out.push(`${r.dry ? "Would grow" : "Grew"} the EVDA at ${r.target}; nothing already in it was moved.`);
|
|
545
639
|
for (const f of r.plan.folders.values()) {
|
|
546
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})`);
|
|
@@ -559,6 +653,7 @@ function applyLines(r, p) {
|
|
|
559
653
|
for (const why of r.plan.chats.changed) out.push(` changed ${why}`);
|
|
560
654
|
}
|
|
561
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`);
|
|
562
657
|
for (const x of r.plan.refused) out.push(` refused ${x.item}: left where it is`);
|
|
563
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)`);
|
|
564
659
|
if (!r.dry) {
|
|
@@ -569,7 +664,7 @@ function applyLines(r, p) {
|
|
|
569
664
|
} else out.push("Nothing was written.");
|
|
570
665
|
out.push(checkedLine(c));
|
|
571
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`);
|
|
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`);
|
|
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` : ""}`);
|
|
573
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`);
|
|
574
669
|
return out;
|
|
575
670
|
}
|
|
@@ -647,9 +742,21 @@ export function revertGather(path, { force = false, now = new Date() } = {}) {
|
|
|
647
742
|
rmdirSync(a);
|
|
648
743
|
}
|
|
649
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
|
+
}
|
|
650
757
|
m.reverted = now.toISOString();
|
|
651
758
|
writeFileSync(abs, JSON.stringify(m, null, 2) + "\n");
|
|
652
|
-
return { manifest: abs, target: root, restored, removed, put, kept, gone, edited, forced: force && edited.length > 0, keychainRemoved };
|
|
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 };
|
|
653
760
|
}
|
|
654
761
|
|
|
655
762
|
export function revertLines(r) {
|
|
@@ -660,9 +767,11 @@ export function revertLines(r) {
|
|
|
660
767
|
if (r.forced) out.push(` forced ${r.edited.length} file(s) edited since the apply were removed: ${r.edited.join(", ")}`);
|
|
661
768
|
for (const d of r.kept) out.push(` kept ${d}/, which holds files the apply did not write`);
|
|
662
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(", ")}`);
|
|
663
771
|
if (r.keychainRemoved) out.push(` removed ${r.keychainRemoved} Keychain item(s) the apply added`);
|
|
664
772
|
out.push(`CHECKED ${r.removed + r.restored + r.put} file(s) reverted ${r.kept.length} folder(s) kept`);
|
|
665
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");
|
|
666
775
|
return out;
|
|
667
776
|
}
|
|
668
777
|
|