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
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The gate a client goes through before it sends a hosted EVÐA anything: a record, a circle key, a
|
|
2
|
+
// token. (HQ-2083, Spec 08 section 8: "If it doesn't match, your phone won't send anything.")
|
|
3
|
+
//
|
|
4
|
+
// lib/tdx-attestation.mjs answers "does this quote prove this VM?". This file is what makes that answer
|
|
5
|
+
// decide whether bytes leave the device. A client gets a payload to a hosted EVÐA only through
|
|
6
|
+
// `gate.open(...)` and then `session.send(...)`, and every path that is not a verified quote under a
|
|
7
|
+
// pinned measurement ends in a refusal with nothing sent.
|
|
8
|
+
//
|
|
9
|
+
// The protocol, version 1. Over TLS to the host, on one connection:
|
|
10
|
+
// client -> host POST /.well-known/evda-attestation {"v":1,"nonce":"<base64, 32 bytes>"}
|
|
11
|
+
// host -> client {"v":1,"platform":"tdx","quote":"<base64>","collateral":{tcbInfo,
|
|
12
|
+
// tcbInfoIssuerChain,qeIdentity,qeIdentityIssuerChain}}
|
|
13
|
+
// The VM puts reportDataFor(nonce, its TLS public key) in the quote's REPORTDATA. The client takes the
|
|
14
|
+
// TLS key from the connection it is holding, never from the response body, so a host cannot name a key
|
|
15
|
+
// other than the one it is actually serving with. Delivery then goes over a connection pinned to that
|
|
16
|
+
// key (SHA-256 of its SPKI DER); a connection presenting any other key is refused before anything is
|
|
17
|
+
// written to it.
|
|
18
|
+
//
|
|
19
|
+
// The pinned measurement is the client's own configuration, never something a host supplies. With no
|
|
20
|
+
// pinned measurement the gate refuses: an unconfigured client is not a client that trusts everyone.
|
|
21
|
+
//
|
|
22
|
+
// Not here yet: tying the pinned measurement to EVÐA's Sigstore log (lib/measurement-log.mjs checks a
|
|
23
|
+
// logged digest; the statement that carries MRTD and RTMRs is a later slice), SEV-SNP, and the Swift
|
|
24
|
+
// port of this gate in iris-mobile, which follows this file and its test vectors.
|
|
25
|
+
|
|
26
|
+
import { createHash, createPublicKey, KeyObject, timingSafeEqual, X509Certificate } from "node:crypto";
|
|
27
|
+
import { request as httpsRequest } from "node:https";
|
|
28
|
+
import { createNonceBook, MEASUREMENT_NAMES, TDX_TRUST, verifyTdxAttestation, parseTdxQuote } from "./tdx-attestation.mjs";
|
|
29
|
+
|
|
30
|
+
/** Where a hosted EVÐA answers the attestation challenge. */
|
|
31
|
+
export const ATTESTATION_PATH = "/.well-known/evda-attestation";
|
|
32
|
+
|
|
33
|
+
/** The protocol version this client speaks. A response naming another version is refused. */
|
|
34
|
+
export const ATTESTATION_PROTOCOL_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/** Why the gate refused, as a stable code. `message` carries the same thing for a person. */
|
|
37
|
+
export const REFUSAL = Object.freeze({
|
|
38
|
+
NO_POLICY: "no-policy",
|
|
39
|
+
UNREACHABLE: "unreachable",
|
|
40
|
+
NO_QUOTE: "no-quote",
|
|
41
|
+
UNREADABLE_QUOTE: "unreadable-quote",
|
|
42
|
+
WRONG_MEASUREMENT: "wrong-measurement",
|
|
43
|
+
NOT_FRESH: "not-fresh",
|
|
44
|
+
NOT_BOUND: "not-bound",
|
|
45
|
+
NOT_PROVEN: "not-proven",
|
|
46
|
+
WRONG_CONNECTION: "wrong-connection",
|
|
47
|
+
NOT_DELIVERED: "not-delivered",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// What a person reads. Each says that nothing was sent and why, in words that need no TDX.
|
|
51
|
+
const SAID = {
|
|
52
|
+
[REFUSAL.NO_POLICY]: "this app has no published EVÐA code to compare the server against, so it cannot tell whether the server is safe",
|
|
53
|
+
[REFUSAL.UNREACHABLE]: "the server could not be reached to ask it for proof",
|
|
54
|
+
[REFUSAL.NO_QUOTE]: "the server did not send any proof of what code it is running",
|
|
55
|
+
[REFUSAL.UNREADABLE_QUOTE]: "the server's proof could not be read",
|
|
56
|
+
[REFUSAL.WRONG_MEASUREMENT]: "the server is not running the code EVÐA publishes",
|
|
57
|
+
[REFUSAL.NOT_FRESH]: "the server's proof answered a request this phone did not just make, so it could be an old proof played back",
|
|
58
|
+
[REFUSAL.NOT_BOUND]: "the server's proof was not made for this request on this connection, so it could be an old proof played back or someone in the middle",
|
|
59
|
+
[REFUSAL.NOT_PROVEN]: "the server could not prove it is a locked, up to date part of the chip",
|
|
60
|
+
[REFUSAL.WRONG_CONNECTION]: "the connection is not the one the server proved, so someone else could be in the middle",
|
|
61
|
+
[REFUSAL.NOT_DELIVERED]: "the server was proven, and sending to it then failed",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const sha256Hex = (b) => createHash("sha256").update(b).digest("hex");
|
|
65
|
+
|
|
66
|
+
/** A refusal: nothing was sent. `reason` is the detail for a log, `message` the sentence for a person. */
|
|
67
|
+
function refuse(code, reason) {
|
|
68
|
+
return { ok: false, sent: false, code, reason, message: `Nothing was sent: ${SAID[code]}.` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const spkiDer = (key) => {
|
|
72
|
+
if (Buffer.isBuffer(key) || key instanceof Uint8Array) return createPublicKey({ key: Buffer.from(key), format: "der", type: "spki" }).export({ type: "spki", format: "der" });
|
|
73
|
+
if (key instanceof KeyObject && key.type === "public") return key.export({ type: "spki", format: "der" });
|
|
74
|
+
return createPublicKey(key).export({ type: "spki", format: "der" });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The pin for a TLS public key: SHA-256 of its SPKI DER, lowercase hex. The same value the verifier returns. */
|
|
78
|
+
export function tlsKeyPin(tlsPublicKey) {
|
|
79
|
+
return sha256Hex(spkiDer(tlsPublicKey));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether `tlsPublicKey` is the key `pin` names. False for anything unreadable. */
|
|
83
|
+
export function pinMatches(pin, tlsPublicKey) {
|
|
84
|
+
try {
|
|
85
|
+
const have = Buffer.from(tlsKeyPin(tlsPublicKey), "hex");
|
|
86
|
+
const want = Buffer.from(String(pin ?? ""), "hex");
|
|
87
|
+
return want.length === 32 && timingSafeEqual(have, want);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Why `policy` cannot gate anything, in words, or null when it can.
|
|
95
|
+
* policy: { platform: "tdx", measurements: { mrtd, rtmr0, rtmr1, rtmr2, rtmr3 }, minTcb: { tcbEvaluationDataNumber, statuses? } }
|
|
96
|
+
*/
|
|
97
|
+
export function hostPolicyProblem(policy) {
|
|
98
|
+
if (!policy) return "no pinned measurement is configured";
|
|
99
|
+
if (policy.platform !== "tdx") return `platform ${JSON.stringify(policy.platform ?? null)} is not one this client verifies (only tdx)`;
|
|
100
|
+
const m = policy.measurements;
|
|
101
|
+
const missing = MEASUREMENT_NAMES.filter((n) => !/^[0-9a-f]{96}$/.test(m?.[n] ?? ""));
|
|
102
|
+
if (missing.length) return `the pinned measurement does not name ${missing.join(", ")} as 96 lowercase hex characters each`;
|
|
103
|
+
if (!Number.isSafeInteger(policy.minTcb?.tcbEvaluationDataNumber)) return "the policy names no minimum TCB evaluation number";
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The JSON body a client sends to ATTESTATION_PATH for `nonce` (32 bytes). */
|
|
108
|
+
export function attestationRequest(nonce) {
|
|
109
|
+
const n = Buffer.from(nonce);
|
|
110
|
+
if (n.length !== 32) throw new Error("the nonce must be 32 bytes");
|
|
111
|
+
return { v: ATTESTATION_PROTOCOL_VERSION, nonce: n.toString("base64") };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads a host's answer. Returns { quote, collateral } or { error } in words; never throws. */
|
|
115
|
+
export function parseAttestationResponse(body) {
|
|
116
|
+
let r = body;
|
|
117
|
+
if (typeof r === "string" || Buffer.isBuffer(r) || r instanceof Uint8Array) {
|
|
118
|
+
try { r = JSON.parse(Buffer.from(r).toString("utf8")); } catch { return { error: "the response is not JSON" }; }
|
|
119
|
+
}
|
|
120
|
+
if (!r || typeof r !== "object") return { error: "the response is empty" };
|
|
121
|
+
if (r.v !== ATTESTATION_PROTOCOL_VERSION) return { error: `the response speaks protocol ${JSON.stringify(r.v ?? null)}, not ${ATTESTATION_PROTOCOL_VERSION}` };
|
|
122
|
+
if (r.platform !== "tdx") return { error: `the response is for platform ${JSON.stringify(r.platform ?? null)}, not tdx` };
|
|
123
|
+
if (typeof r.quote !== "string" || r.quote.length === 0) return { error: "the response carries no quote", missing: true };
|
|
124
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(r.quote)) return { error: "the quote is not base64" };
|
|
125
|
+
return { quote: Buffer.from(r.quote, "base64"), collateral: r.collateral ?? null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The verifier's reasons, sorted into what a person needs to know. The tests hold one case per code,
|
|
129
|
+
// so a reworded reason in tdx-attestation.mjs that stops matching fails there, not silently here.
|
|
130
|
+
function classify(reason) {
|
|
131
|
+
if (/^(MRTD|RTMR\d) is /.test(reason)) return REFUSAL.WRONG_MEASUREMENT;
|
|
132
|
+
if (/^REPORTDATA /.test(reason)) return REFUSAL.NOT_BOUND;
|
|
133
|
+
if (/nonce/.test(reason)) return REFUSAL.NOT_FRESH;
|
|
134
|
+
if (/could not be read|truncated|too short|shorter than|not understood/.test(reason)) return REFUSAL.UNREADABLE_QUOTE;
|
|
135
|
+
return REFUSAL.NOT_PROVEN;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The gate. `policy` is the client's pinned measurement (see hostPolicyProblem); `clock` returns the
|
|
140
|
+
* time certificates, collateral and the nonce are judged at.
|
|
141
|
+
*
|
|
142
|
+
* gate.open({ challenge }) asks the host to prove itself. `challenge(requestBody)` is the transport: it
|
|
143
|
+
* POSTs the body to ATTESTATION_PATH and resolves { body, tlsPublicKey }, the host's answer and the TLS
|
|
144
|
+
* public key of the connection that answer came over. It resolves to an attested session, or a refusal.
|
|
145
|
+
*
|
|
146
|
+
* session.send(payload, { deliver }) hands `payload` to `deliver(payload, { pin })` once. `deliver` must
|
|
147
|
+
* send only over a connection whose TLS key matches `pin` (httpsHostTransport does this for Node) and
|
|
148
|
+
* resolve { tlsPublicKey, result } naming the key it actually used; a key other than the attested one is
|
|
149
|
+
* reported, and a transport that refuses the wrong key throws an error with code EVDA_PIN_MISMATCH. `payload` may be a function, called only after the host is proven, so key
|
|
150
|
+
* material can be produced only for a host that passed.
|
|
151
|
+
*/
|
|
152
|
+
export function createAttestedHostGate({ policy, nonces = createNonceBook(), clock = () => Date.now(), trust = TDX_TRUST } = {}) {
|
|
153
|
+
return {
|
|
154
|
+
async open({ challenge } = {}) {
|
|
155
|
+
const problem = hostPolicyProblem(policy);
|
|
156
|
+
if (problem) return refuse(REFUSAL.NO_POLICY, problem);
|
|
157
|
+
if (typeof challenge !== "function") return refuse(REFUSAL.UNREACHABLE, "no transport was given to reach the host");
|
|
158
|
+
|
|
159
|
+
const nonce = nonces.issue(clock());
|
|
160
|
+
let answer;
|
|
161
|
+
try {
|
|
162
|
+
answer = await challenge(attestationRequest(nonce));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return refuse(REFUSAL.UNREACHABLE, `the attestation request failed: ${err?.message ?? err}`);
|
|
165
|
+
}
|
|
166
|
+
const parsed = parseAttestationResponse(answer?.body);
|
|
167
|
+
if (parsed.error) return refuse(parsed.missing || answer?.body == null ? REFUSAL.NO_QUOTE : REFUSAL.UNREADABLE_QUOTE, parsed.error);
|
|
168
|
+
try {
|
|
169
|
+
parseTdxQuote(parsed.quote);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
return refuse(REFUSAL.UNREADABLE_QUOTE, `the quote could not be read: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
if (!answer.tlsPublicKey) return refuse(REFUSAL.WRONG_CONNECTION, "the transport did not report the TLS key of the connection the quote came over");
|
|
174
|
+
|
|
175
|
+
const v = verifyTdxAttestation({
|
|
176
|
+
quote: parsed.quote, nonce, nonces, tlsPublicKey: answer.tlsPublicKey,
|
|
177
|
+
expected: policy.measurements, minTcb: policy.minTcb, collateral: parsed.collateral, now: clock(), trust,
|
|
178
|
+
});
|
|
179
|
+
if (!v.ok) return refuse(classify(v.reason), v.reason);
|
|
180
|
+
return session({ pin: v.tlsPublicKeySha256, measurements: v.measurements, tcb: v.tcb });
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function session({ pin, measurements, tcb }) {
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
pin,
|
|
189
|
+
measurements,
|
|
190
|
+
tcb,
|
|
191
|
+
async send(payload, { deliver } = {}) {
|
|
192
|
+
if (typeof deliver !== "function") return refuse(REFUSAL.NOT_DELIVERED, "no delivery transport was given");
|
|
193
|
+
let bytes;
|
|
194
|
+
try {
|
|
195
|
+
bytes = typeof payload === "function" ? await payload({ pin, measurements }) : payload;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return refuse(REFUSAL.NOT_DELIVERED, `the payload could not be prepared: ${err?.message ?? err}`);
|
|
198
|
+
}
|
|
199
|
+
let out;
|
|
200
|
+
try {
|
|
201
|
+
out = await deliver(bytes, { pin });
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err?.code === "EVDA_PIN_MISMATCH") return refuse(REFUSAL.WRONG_CONNECTION, err.message);
|
|
204
|
+
return refuse(REFUSAL.NOT_DELIVERED, `delivery failed: ${err?.message ?? err}`);
|
|
205
|
+
}
|
|
206
|
+
// A transport that pins as it must never reaches this; one that does not has already sent, and says so.
|
|
207
|
+
if (!pinMatches(pin, out?.tlsPublicKey)) return { ok: false, sent: true, code: REFUSAL.WRONG_CONNECTION, reason: "the delivery went over a TLS key other than the attested one", message: `Sent over the wrong connection: ${SAID[REFUSAL.WRONG_CONNECTION]}. Treat what was sent as exposed.` };
|
|
208
|
+
return { ok: true, sent: true, pin, result: out.result };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── the Node transport (the Mac client; the phone's is Swift) ──────────────────────────────────
|
|
214
|
+
|
|
215
|
+
const keyOfCert = (cert) => (cert?.raw ? new X509Certificate(cert.raw).publicKey : null);
|
|
216
|
+
|
|
217
|
+
function postJson(url, body, { pin } = {}) {
|
|
218
|
+
return new Promise((resolve, reject) => {
|
|
219
|
+
const u = new URL(url);
|
|
220
|
+
if (u.protocol !== "https:") return reject(new Error(`${u.origin} is not https`));
|
|
221
|
+
const data = Buffer.from(JSON.stringify(body));
|
|
222
|
+
const req = httpsRequest(u, { method: "POST", rejectUnauthorized: false, agent: false, headers: { "content-type": "application/json", "content-length": data.length } }, (res) => {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
res.on("data", (c) => chunks.push(c));
|
|
225
|
+
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks), tlsPublicKey }));
|
|
226
|
+
res.on("error", reject);
|
|
227
|
+
});
|
|
228
|
+
let tlsPublicKey = null;
|
|
229
|
+
req.on("error", reject);
|
|
230
|
+
// Nothing is written until the handshake is done and the key is read, so a pinned request to the
|
|
231
|
+
// wrong key leaves with no header and no body.
|
|
232
|
+
req.on("socket", (socket) => {
|
|
233
|
+
socket.once("secureConnect", () => {
|
|
234
|
+
try { tlsPublicKey = keyOfCert(socket.getPeerCertificate(true)); } catch { tlsPublicKey = null; }
|
|
235
|
+
if (pin !== undefined && !pinMatches(pin, tlsPublicKey)) {
|
|
236
|
+
const err = Object.assign(new Error("the server's TLS key is not the attested one"), { code: "EVDA_PIN_MISMATCH" });
|
|
237
|
+
req.destroy(err);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
req.end(data);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The Node transport for one hosted EVÐA at `origin` (https). challenge() for gate.open, and
|
|
248
|
+
* deliver(path) for session.send, which posts the payload (JSON-serializable) over a connection pinned
|
|
249
|
+
* to the attested key: a server presenting any other key is dropped after the handshake, before any
|
|
250
|
+
* header or body is written. The VM's certificate is self-signed inside the VM, so the CA check is
|
|
251
|
+
* replaced by the attestation and the pin, not by nothing.
|
|
252
|
+
*/
|
|
253
|
+
export function httpsHostTransport(origin) {
|
|
254
|
+
return {
|
|
255
|
+
async challenge(requestBody) {
|
|
256
|
+
const r = await postJson(new URL(ATTESTATION_PATH, origin), requestBody);
|
|
257
|
+
if (r.status !== 200) throw new Error(`the host answered ${r.status}`);
|
|
258
|
+
return { body: r.body, tlsPublicKey: r.tlsPublicKey };
|
|
259
|
+
},
|
|
260
|
+
deliver(path) {
|
|
261
|
+
return async (payload, { pin }) => {
|
|
262
|
+
if (!pin) throw new Error("no pin was given, so delivery cannot be bound to the attested key");
|
|
263
|
+
const r = await postJson(new URL(path, origin), payload, { pin });
|
|
264
|
+
if (r.status < 200 || r.status >= 300) throw new Error(`the host answered ${r.status}`);
|
|
265
|
+
return { tlsPublicKey: r.tlsPublicKey, result: r.body };
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
package/lib/browse.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// The whole record as a folder tree, for the owner's own app (HQ-2038).
|
|
2
|
+
//
|
|
3
|
+
// The EVÐA Mac app shows every folder of the record, not only the record roles, one folder at a
|
|
4
|
+
// time so a record of tens of thousands of files opens fast. Three rules decide what it lists, and
|
|
5
|
+
// each is the rule another reader already uses, never a copy of it:
|
|
6
|
+
//
|
|
7
|
+
// what the scan reads sync-rules `scanSkip` with the plan's excludes and the sub-record
|
|
8
|
+
// boundaries, the same predicate the shadow scan and doctor walk with, so
|
|
9
|
+
// the app and the sync agree on what is in the record. Dot-named entries
|
|
10
|
+
// (.obsidian, .claude) are left out of the view on top of that.
|
|
11
|
+
// which circle circles `homesOf` through readers `readersFor`, the resolver doctor and
|
|
12
|
+
// the MCP server use: a file shows the circle that homes it.
|
|
13
|
+
// what stays closed `noted: true` and `sealed: true` entries (readers `isNotedFile`,
|
|
14
|
+
// `isSealedFile`), and a part whose read_by says no AI reads it (`none`)
|
|
15
|
+
// or does not parse: the app hosts the owner's AI connection, so it keeps
|
|
16
|
+
// those closed as the phone keeps a noted entry closed.
|
|
17
|
+
//
|
|
18
|
+
// `openEntry` opens exactly what `browse` lists as text: it walks the same rules down the path, so
|
|
19
|
+
// a path the listing would not show is refused however it is spelled.
|
|
20
|
+
//
|
|
21
|
+
// Listing never reads a file iCloud holds only in the cloud (a dataless file): reading it would
|
|
22
|
+
// download it. Such a file is listed with `cloud: true` and its frontmatter unread; opening it reads
|
|
23
|
+
// it, and a noted or sealed entry is refused then.
|
|
24
|
+
|
|
25
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { scanSkip, datalessAmong, isTextPath } from "./sync-rules.mjs";
|
|
28
|
+
import { planBeside } from "./circle-plan.mjs";
|
|
29
|
+
import { boundaries } from "./records.mjs";
|
|
30
|
+
import { resolveRole } from "./roles.mjs";
|
|
31
|
+
import { readContext, readersFor, isNotedFile, isSealedFile, aiView } from "./readers.mjs";
|
|
32
|
+
import { EvdaError } from "./errors.mjs";
|
|
33
|
+
import { FENCE_OPEN } from "./agents.mjs";
|
|
34
|
+
import { write as writeToday, todayPath } from "./tools.mjs";
|
|
35
|
+
|
|
36
|
+
// The default preset: the roles pinned above the tree, in this order, when the mapping gives each a
|
|
37
|
+
// folder. The journal first. `work` is a live source (harlequin:), not a folder, so it is never pinned.
|
|
38
|
+
export const DEFAULT_PINS = ["record/daily", "record/captures", "record/people", "collections", "collections/areas", "knowledge"];
|
|
39
|
+
|
|
40
|
+
// The record roles keep the names the phone gives them; any other pin is named by its folder.
|
|
41
|
+
const ROLE_TITLES = { "record/daily": "Journal", "record/captures": "Captures", "record/people": "People" };
|
|
42
|
+
|
|
43
|
+
// The largest file the app opens as text. Bigger text is listed and stays closed.
|
|
44
|
+
export const MAX_OPEN_BYTES = 4 * 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
const DATED = /^\d{4}-\d{2}-\d{2}/;
|
|
47
|
+
|
|
48
|
+
// Tooling the view leaves out, by kind (HQ-2038, round 2): a package manifest or lockfile, a shell
|
|
49
|
+
// script, a license, a backup copy, an empty file. It stays in the record and in the sync; the app
|
|
50
|
+
// does not list or open it, and each folder says how many it left out. Dot-named entries are left
|
|
51
|
+
// out the same way.
|
|
52
|
+
const LOCKFILE = /(^package(-lock)?\.json$)|(-lock\.json$)|(^pnpm-lock\.yaml$)|(^yarn\.lock$)/i;
|
|
53
|
+
export function isTooling(name, size) {
|
|
54
|
+
if (name.startsWith(".")) return true;
|
|
55
|
+
if (size === 0) return true;
|
|
56
|
+
if (LOCKFILE.test(name)) return true;
|
|
57
|
+
if (/\.(sh|zsh|bash)$/i.test(name)) return true;
|
|
58
|
+
if (/^(LICENSE|LICENCE|COPYING)(\.(md|txt))?$/i.test(name)) return true;
|
|
59
|
+
if (/\.bak($|[.-])|~$/i.test(name)) return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The files that say how the record and its agent work: the manifest, the operating layer the
|
|
64
|
+
// mapping names, the agent pointer files create-evda writes, and any root file carrying the
|
|
65
|
+
// generated core fence (his IRIS.md). Only at the record's root. The app groups them under one
|
|
66
|
+
// entry instead of mixing them with notes.
|
|
67
|
+
const AGENT_FILES = ["evda.json", "AGENTS.md", "CLAUDE.md", "GEMINI.md"];
|
|
68
|
+
function isOperating(bctx, name) {
|
|
69
|
+
if (AGENT_FILES.includes(name)) return true;
|
|
70
|
+
const agents = bctx.evda.manifest?.mapping?.agents;
|
|
71
|
+
if (typeof agents === "string" && agents.replace(/^\.\//, "") === name) return true;
|
|
72
|
+
if (!/\.md$/i.test(name)) return false;
|
|
73
|
+
if (datalessAmong(bctx.evda.dir, [name]).size) return false;
|
|
74
|
+
try {
|
|
75
|
+
return readFileSync(join(bctx.evda.dir, name), "utf8").includes(FENCE_OPEN);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// "01_Projects" -> "Projects", "03_Resources/Wiki" -> "Wiki".
|
|
82
|
+
export function folderTitle(rel) {
|
|
83
|
+
const last = rel.split("/").pop() ?? rel;
|
|
84
|
+
const t = last.replace(/^\d+[\s_.-]+/, "").replace(/[_-]+/g, " ").trim();
|
|
85
|
+
return t ? t[0].toUpperCase() + t.slice(1) : last;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Everything one listing needs, read once: the scan's skip rule, the circles and the readers.
|
|
89
|
+
export function browseContext(evda) {
|
|
90
|
+
let extra = [];
|
|
91
|
+
try { extra = planBeside(evda)?.plan?.exclude ?? []; } catch { extra = []; }
|
|
92
|
+
const skip = scanSkip([...extra, ...boundaries(evda)]);
|
|
93
|
+
return { evda, skip, readers: readContext(evda) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Why the app keeps a file closed, or null when it opens. `cloud` skips the frontmatter read.
|
|
97
|
+
export function closedBecause(bctx, abs, { cloud = false } = {}) {
|
|
98
|
+
if (!cloud && isSealedFile(abs)) return "sealed";
|
|
99
|
+
if (!cloud && isNotedFile(abs)) return "noted";
|
|
100
|
+
const r = readersFor(bctx.readers, abs);
|
|
101
|
+
if (r.broken) return "reader";
|
|
102
|
+
if (Array.isArray(r.readers) && r.readers.length > 0 && r.readers.every((x) => x.kind === "none")) return "reader";
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const circleOf = (bctx, abs) => readersFor(bctx.readers, abs).circle;
|
|
107
|
+
|
|
108
|
+
const shown = (name) => !name.startsWith(".");
|
|
109
|
+
|
|
110
|
+
// A relative folder or file path, refused unless every segment is a plain, visible name.
|
|
111
|
+
function cleanRel(rel, what) {
|
|
112
|
+
const r = String(rel ?? "").replace(/^\/+|\/+$/g, "");
|
|
113
|
+
if (r === "") return "";
|
|
114
|
+
const parts = r.split("/");
|
|
115
|
+
if (parts.some((p) => p === "" || p === "." || p === ".." || !shown(p))) throw new EvdaError(`${rel} is not ${what} in your record.`, { code: "OUTSIDE", exit: 2 });
|
|
116
|
+
return parts.join("/");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Walk the rules down `rel` from the root: every segment visible, not skipped by the scan, and no
|
|
120
|
+
// symlink on the way. Returns the lstat of the last segment, or null when the path is not one the
|
|
121
|
+
// listing shows (missing included).
|
|
122
|
+
function reachable(bctx, rel) {
|
|
123
|
+
const parts = rel.split("/");
|
|
124
|
+
let st = null;
|
|
125
|
+
for (let i = 0; i < parts.length; i++) {
|
|
126
|
+
const sub = parts.slice(0, i + 1).join("/");
|
|
127
|
+
try { st = lstatSync(join(bctx.evda.dir, sub)); } catch { return null; }
|
|
128
|
+
if (st.isSymbolicLink() || bctx.skip(sub, st)) return null;
|
|
129
|
+
if (i < parts.length - 1 && !st.isDirectory()) return null;
|
|
130
|
+
}
|
|
131
|
+
return st;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The pinned folders: each DEFAULT_PINS role the mapping gives a folder, with whether it is there.
|
|
135
|
+
// A role the mapping leaves out is not pinned, except the record roles, which read from
|
|
136
|
+
// `record/<x>` as everywhere else.
|
|
137
|
+
export function pins(evda, { bctx = browseContext(evda) } = {}) {
|
|
138
|
+
const out = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
for (const role of DEFAULT_PINS) {
|
|
141
|
+
let r;
|
|
142
|
+
try { r = resolveRole(evda, role); } catch { continue; }
|
|
143
|
+
if (r.kind === "source") continue;
|
|
144
|
+
if (!r.mapped && !role.startsWith("record/")) continue;
|
|
145
|
+
let folder;
|
|
146
|
+
try { folder = cleanRel(r.mapped ? String(r.rel).replace(/^(\.\/)+/, "") : role, "a folder"); } catch { continue; }
|
|
147
|
+
if (folder === "" || seen.has(folder)) continue;
|
|
148
|
+
seen.add(folder);
|
|
149
|
+
const st = reachable(bctx, folder);
|
|
150
|
+
out.push({ role, title: ROLE_TITLES[role] ?? folderTitle(folder), folder, exists: Boolean(st?.isDirectory()) });
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
156
|
+
|
|
157
|
+
// Dated names newest first, ahead of the rest; the rest by name.
|
|
158
|
+
function fileOrder(a, b) {
|
|
159
|
+
const da = DATED.test(a.name), db = DATED.test(b.name);
|
|
160
|
+
if (da && db) return -byName(a, b);
|
|
161
|
+
if (da !== db) return da ? -1 : 1;
|
|
162
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// One folder's children: its folders and its files, each file with the circle that homes it.
|
|
166
|
+
// `hidden` counts the files kept closed (noted, sealed, a part no AI reads), never their names.
|
|
167
|
+
// `noted` gives each noted entry by its date alone (HQ-2049), newest first: the date its name
|
|
168
|
+
// starts with, else the day it was last written. Never a name, a title or a word of it.
|
|
169
|
+
// `tooling` counts what the view leaves out by kind (isTooling). At the root, the files that say
|
|
170
|
+
// how the record works are left to `operating` instead.
|
|
171
|
+
export function browse(evda, folder = "", { bctx = browseContext(evda) } = {}) {
|
|
172
|
+
const rel = cleanRel(folder, "a folder");
|
|
173
|
+
if (rel !== "") {
|
|
174
|
+
const st = reachable(bctx, rel);
|
|
175
|
+
if (!st || !st.isDirectory()) throw new EvdaError(`${rel} is not a folder in your record.`, { code: "NOT_FOUND", exit: 2 });
|
|
176
|
+
}
|
|
177
|
+
const abs = rel === "" ? evda.dir : join(evda.dir, rel);
|
|
178
|
+
const dirs = [];
|
|
179
|
+
const files = [];
|
|
180
|
+
let hidden = 0;
|
|
181
|
+
let tooling = 0;
|
|
182
|
+
const noted = [];
|
|
183
|
+
const candidates = [];
|
|
184
|
+
for (const name of readdirSync(abs)) {
|
|
185
|
+
const child = rel === "" ? name : `${rel}/${name}`;
|
|
186
|
+
let st;
|
|
187
|
+
try { st = lstatSync(join(evda.dir, child)); } catch { continue; }
|
|
188
|
+
if (st.isSymbolicLink() || bctx.skip(child, st)) continue;
|
|
189
|
+
if (!shown(name)) { tooling++; continue; }
|
|
190
|
+
if (st.isDirectory()) dirs.push({ path: child, name });
|
|
191
|
+
else if (!st.isFile()) continue;
|
|
192
|
+
else if (rel === "" && isOperating(bctx, name)) continue;
|
|
193
|
+
else if (isTooling(name, st.size)) tooling++;
|
|
194
|
+
else candidates.push({ child, name, size: st.size, mtime: st.mtimeMs });
|
|
195
|
+
}
|
|
196
|
+
// Every file, not only notes: a PDF or an image iCloud holds only in the cloud is listed as such,
|
|
197
|
+
// so the app says so and fetches it on a click instead of downloading it by opening (HQ-2071).
|
|
198
|
+
const cloud = datalessAmong(evda.dir, candidates.map((c) => c.child));
|
|
199
|
+
for (const c of candidates) {
|
|
200
|
+
const fabs = join(evda.dir, c.child);
|
|
201
|
+
const inCloud = cloud.has(c.child);
|
|
202
|
+
const why = closedBecause(bctx, fabs, { cloud: inCloud });
|
|
203
|
+
if (why) {
|
|
204
|
+
hidden++;
|
|
205
|
+
if (why === "noted") noted.push(notedDate(c.name, c.mtime));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
files.push({ path: c.child, name: c.name, size: c.size, circle: circleOf(bctx, fabs), text: isTextPath(c.child) && c.size <= MAX_OPEN_BYTES, cloud: inCloud });
|
|
209
|
+
}
|
|
210
|
+
dirs.sort(byName);
|
|
211
|
+
files.sort(fileOrder);
|
|
212
|
+
noted.sort().reverse();
|
|
213
|
+
return { folder: rel, dirs, files, hidden, noted, tooling };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// A noted entry's date and nothing else: the YYYY-MM-DD its name starts with, else the local day
|
|
217
|
+
// it was last written.
|
|
218
|
+
export function notedDate(name, mtimeMs) {
|
|
219
|
+
const m = DATED.exec(name);
|
|
220
|
+
if (m) return m[0];
|
|
221
|
+
const d = new Date(mtimeMs);
|
|
222
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
223
|
+
return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The files that say how the record and its agent work, from the root (isOperating), each with its
|
|
227
|
+
// circle, by name. Closed ones are left out as anywhere else.
|
|
228
|
+
export function operating(evda, { bctx = browseContext(evda) } = {}) {
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const name of readdirSync(evda.dir).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }))) {
|
|
231
|
+
let st;
|
|
232
|
+
try { st = lstatSync(join(evda.dir, name)); } catch { continue; }
|
|
233
|
+
if (!st.isFile() || st.isSymbolicLink() || bctx.skip(name, st) || !shown(name) || !isOperating(bctx, name)) continue;
|
|
234
|
+
const abs = join(evda.dir, name);
|
|
235
|
+
if (closedBecause(bctx, abs)) continue;
|
|
236
|
+
out.push({ path: name, name, size: st.size, circle: circleOf(bctx, abs), text: isTextPath(name) && st.size <= MAX_OPEN_BYTES, cloud: false });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Append the owner's words to today's daily note, verbatim, under a new timestamped section: the
|
|
242
|
+
// same write evda_write makes (lib/tools.mjs write, lib/day-file.mjs appendToDay), so nothing is
|
|
243
|
+
// ever replaced. Returns { path, bytes, section }.
|
|
244
|
+
export const CAPTURE_CONTEXT = "Typed into the capture box in the EVÐA app.";
|
|
245
|
+
export function capture(evda, words, { now = new Date() } = {}) {
|
|
246
|
+
return writeToday(evda, { words, context: CAPTURE_CONTEXT }, { now });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The circle a file at the record's root would land in: the record's default home. The app tags a
|
|
250
|
+
// file with its circle only when it is not this one, so the usual circle is not on every row.
|
|
251
|
+
export function defaultCircle(evda, { bctx = browseContext(evda) } = {}) {
|
|
252
|
+
return circleOf(bctx, join(evda.dir, "evda-default-home.md"));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Today's Journal (HQ-2038): today's daily note, always there for the app even before it is
|
|
256
|
+
// written. `exists` false means an empty page; the first capture creates it through `write`.
|
|
257
|
+
// `path` is null only when the record maps no daily role.
|
|
258
|
+
export function today(evda, { now = new Date(), bctx = browseContext(evda) } = {}) {
|
|
259
|
+
const home = defaultCircle(evda, { bctx });
|
|
260
|
+
let path;
|
|
261
|
+
try { path = todayPath(evda, { now }); } catch { return { path: null, exists: false, text: "", circle: home, home }; }
|
|
262
|
+
const st = reachable(bctx, path);
|
|
263
|
+
if (!st?.isFile()) return { path, exists: false, text: "", circle: home, home };
|
|
264
|
+
const opened = openEntry(evda, path, { bctx });
|
|
265
|
+
return { path, exists: true, text: opened.text, circle: opened.circle, home };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Every text file the listing shows, the whole record walked one `browse` at a time, plus the
|
|
269
|
+
// operating files at the root (HQ-2049): the app's quick open and its link resolution read this,
|
|
270
|
+
// so a name the tree would not show (noted, sealed, a part no AI reads, tooling, excluded) is never
|
|
271
|
+
// offered or resolved. Paths only, in walk order; nothing of any file's words.
|
|
272
|
+
export function index(evda, { bctx = browseContext(evda) } = {}) {
|
|
273
|
+
const out = operating(evda, { bctx }).filter((f) => f.text).map((f) => f.path);
|
|
274
|
+
const walk = (folder) => {
|
|
275
|
+
const f = browse(evda, folder, { bctx });
|
|
276
|
+
for (const file of f.files) if (file.text) out.push(file.path);
|
|
277
|
+
for (const d of f.dirs) walk(d.path);
|
|
278
|
+
};
|
|
279
|
+
walk("");
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// One listed text file, opened: its words with any sealed section replaced by one line, and its
|
|
284
|
+
// circle. Anything `browse` would not list as text is refused, with nothing of it returned.
|
|
285
|
+
export function openEntry(evda, path, { bctx = browseContext(evda) } = {}) {
|
|
286
|
+
const rel = cleanRel(path, "a file");
|
|
287
|
+
const refused = () => new EvdaError(`${path} is not in your record.`, { code: "OUTSIDE", exit: 2 });
|
|
288
|
+
if (rel === "") throw refused();
|
|
289
|
+
const st = reachable(bctx, rel);
|
|
290
|
+
if (!st) throw refused();
|
|
291
|
+
if (!st.isFile()) throw refused();
|
|
292
|
+
const name = rel.split("/").pop();
|
|
293
|
+
if (isTooling(name, st.size) && !(rel === name && isOperating(bctx, name))) throw refused();
|
|
294
|
+
const abs = join(evda.dir, rel);
|
|
295
|
+
// A hard path check after the walk: the resolved file is inside the resolved record.
|
|
296
|
+
const root = realpathSync(evda.dir);
|
|
297
|
+
if (!realpathSync(abs).startsWith(root + "/")) throw refused();
|
|
298
|
+
if (!isTextPath(rel)) throw new EvdaError(`${rel} is not text, so it opens in Finder, not here.`, { code: "NOT_TEXT", exit: 2 });
|
|
299
|
+
if (st.size > MAX_OPEN_BYTES) throw new EvdaError(`${rel} is too large to open here.`, { code: "TOO_LARGE", exit: 2 });
|
|
300
|
+
if (closedBecause(bctx, abs)) throw refused();
|
|
301
|
+
const text = readFileSync(abs, "utf8");
|
|
302
|
+
return { path: rel, text: aiView(text).text, circle: circleOf(bctx, abs) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Attachments: PDFs, images and every other file the tree lists (HQ-2071) ───────────────────
|
|
306
|
+
//
|
|
307
|
+
// The app shows a PDF or an image itself and hands any other attachment to the Mac's default app.
|
|
308
|
+
// What it may show is what `browse` lists and `openEntry` would not open as text: the same walk,
|
|
309
|
+
// the same closed rules, decided from names and stat alone, so asking never downloads a file iCloud
|
|
310
|
+
// holds only in the cloud. The app serves the bytes itself, only for a path this answered.
|
|
311
|
+
|
|
312
|
+
const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "heic", "heif", "gif", "webp"]);
|
|
313
|
+
|
|
314
|
+
// "pdf", "image", or "other", by the name alone.
|
|
315
|
+
export function attachmentKind(rel) {
|
|
316
|
+
const name = String(rel ?? "").split("/").pop() ?? "";
|
|
317
|
+
const dot = name.lastIndexOf(".");
|
|
318
|
+
const ext = dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
319
|
+
if (ext === "pdf") return "pdf";
|
|
320
|
+
if (IMAGE_EXTENSIONS.has(ext)) return "image";
|
|
321
|
+
return "other";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// One listed file that is not text: its kind, size, circle, and whether its bytes are only in
|
|
325
|
+
// iCloud. Refused, with nothing of it returned, whenever `browse` would not list it.
|
|
326
|
+
export function attachEntry(evda, path, { bctx = browseContext(evda) } = {}) {
|
|
327
|
+
const rel = cleanRel(path, "a file");
|
|
328
|
+
const refused = () => new EvdaError(`${path} is not in your record.`, { code: "OUTSIDE", exit: 2 });
|
|
329
|
+
if (rel === "") throw refused();
|
|
330
|
+
const st = reachable(bctx, rel);
|
|
331
|
+
if (!st || !st.isFile()) throw refused();
|
|
332
|
+
const name = rel.split("/").pop();
|
|
333
|
+
if (isTooling(name, st.size)) throw refused();
|
|
334
|
+
const abs = join(evda.dir, rel);
|
|
335
|
+
const root = realpathSync(evda.dir);
|
|
336
|
+
if (!realpathSync(abs).startsWith(root + "/")) throw refused();
|
|
337
|
+
if (isTextPath(rel)) throw new EvdaError(`${rel} is text, so it opens as a note.`, { code: "TEXT", exit: 2 });
|
|
338
|
+
const cloud = datalessAmong(evda.dir, [rel]).has(rel);
|
|
339
|
+
if (closedBecause(bctx, abs, { cloud })) throw refused();
|
|
340
|
+
return { path: rel, kind: attachmentKind(rel), size: st.size, circle: circleOf(bctx, abs), cloud };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Every listed file that is not text, by path, the whole tree walked as `index` walks it: what a
|
|
344
|
+
// link or an embed to a PDF or an image resolves against. Paths only.
|
|
345
|
+
export function attachments(evda, { bctx = browseContext(evda) } = {}) {
|
|
346
|
+
const out = [];
|
|
347
|
+
const walk = (folder) => {
|
|
348
|
+
const f = browse(evda, folder, { bctx });
|
|
349
|
+
for (const file of f.files) if (!file.text && !isTextPath(file.path)) out.push(file.path);
|
|
350
|
+
for (const d of f.dirs) walk(d.path);
|
|
351
|
+
};
|
|
352
|
+
walk("");
|
|
353
|
+
return out;
|
|
354
|
+
}
|
package/lib/circles.mjs
CHANGED
|
@@ -552,7 +552,11 @@ export function markdownFiles(root) {
|
|
|
552
552
|
for (const name of readdirSync(abs).sort()) {
|
|
553
553
|
if (name.startsWith(".") || name === "node_modules") continue;
|
|
554
554
|
const p = join(abs, name);
|
|
555
|
-
|
|
555
|
+
// A dangling symlink (target lives only on another machine, e.g. raw media kept
|
|
556
|
+
// outside the synced vault) has nothing to stat. It is not markdown content either
|
|
557
|
+
// way, so skip it rather than let the whole walk crash on one broken pointer.
|
|
558
|
+
let st;
|
|
559
|
+
try { st = statSync(p); } catch { continue; }
|
|
556
560
|
if (st.isDirectory()) walk(p);
|
|
557
561
|
else if (st.isFile() && /\.md$/i.test(name)) out.push(p);
|
|
558
562
|
}
|