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/qr.mjs
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// A QR code for the pairing link, drawn here (HQ-2040), so asking Claude to pair a phone can hand
|
|
2
|
+
// the person something to scan. No network QR service sees the link (the code in it is a key until
|
|
3
|
+
// it is used), and the package keeps its zero runtime dependencies (test/deps.test.mjs).
|
|
4
|
+
//
|
|
5
|
+
// Byte mode, versions 1 to 40, error correction L, M, Q or H, following ISO/IEC 18004 the way
|
|
6
|
+
// Project Nayuki's reference encoder lays it out. test/qr.test.mjs holds the matrices against a
|
|
7
|
+
// second, independent encoder's output, module for module.
|
|
8
|
+
//
|
|
9
|
+
// Out: the module matrix, a PNG (8-bit grey, deflated by node:zlib), and the same code as text in
|
|
10
|
+
// half blocks, which a chat can show in a code block when it cannot show an image.
|
|
11
|
+
|
|
12
|
+
import { deflateSync } from "node:zlib";
|
|
13
|
+
|
|
14
|
+
const ECL = {
|
|
15
|
+
L: { bits: 1, i: 0 },
|
|
16
|
+
M: { bits: 0, i: 1 },
|
|
17
|
+
Q: { bits: 3, i: 2 },
|
|
18
|
+
H: { bits: 2, i: 3 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Error correction codewords per block, and the number of blocks, by level then version (index 0 unused).
|
|
22
|
+
const ECC_PER_BLOCK = [
|
|
23
|
+
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
|
24
|
+
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
|
|
25
|
+
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
|
26
|
+
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
|
|
27
|
+
];
|
|
28
|
+
const NUM_BLOCKS = [
|
|
29
|
+
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
|
|
30
|
+
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
|
|
31
|
+
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
|
|
32
|
+
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const bit = (x, i) => ((x >>> i) & 1) !== 0;
|
|
36
|
+
|
|
37
|
+
function rawDataModules(ver) {
|
|
38
|
+
let n = (16 * ver + 128) * ver + 64;
|
|
39
|
+
if (ver >= 2) {
|
|
40
|
+
const align = Math.floor(ver / 7) + 2;
|
|
41
|
+
n -= (25 * align - 10) * align - 55;
|
|
42
|
+
if (ver >= 7) n -= 36;
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dataCodewords = (ver, e) => Math.floor(rawDataModules(ver) / 8) - ECC_PER_BLOCK[e.i][ver] * NUM_BLOCKS[e.i][ver];
|
|
48
|
+
|
|
49
|
+
function gfMul(x, y) {
|
|
50
|
+
let z = 0;
|
|
51
|
+
for (let i = 7; i >= 0; i--) {
|
|
52
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
53
|
+
z ^= ((y >>> i) & 1) * x;
|
|
54
|
+
}
|
|
55
|
+
return z;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rsDivisor(degree) {
|
|
59
|
+
const r = new Array(degree).fill(0);
|
|
60
|
+
r[degree - 1] = 1;
|
|
61
|
+
let root = 1;
|
|
62
|
+
for (let i = 0; i < degree; i++) {
|
|
63
|
+
for (let j = 0; j < r.length; j++) {
|
|
64
|
+
r[j] = gfMul(r[j], root);
|
|
65
|
+
if (j + 1 < r.length) r[j] ^= r[j + 1];
|
|
66
|
+
}
|
|
67
|
+
root = gfMul(root, 0x02);
|
|
68
|
+
}
|
|
69
|
+
return r;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rsRemainder(data, divisor) {
|
|
73
|
+
const r = divisor.map(() => 0);
|
|
74
|
+
for (const b of data) {
|
|
75
|
+
const factor = b ^ r.shift();
|
|
76
|
+
r.push(0);
|
|
77
|
+
divisor.forEach((coef, i) => { r[i] ^= gfMul(coef, factor); });
|
|
78
|
+
}
|
|
79
|
+
return r;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function alignmentPositions(ver) {
|
|
83
|
+
if (ver === 1) return [];
|
|
84
|
+
const size = ver * 4 + 17;
|
|
85
|
+
const n = Math.floor(ver / 7) + 2;
|
|
86
|
+
const step = Math.floor((ver * 8 + n * 3 + 5) / (n * 4 - 4)) * 2;
|
|
87
|
+
const out = [6];
|
|
88
|
+
for (let pos = size - 7; out.length < n; pos -= step) out.splice(1, 0, pos);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const MASKS = [
|
|
93
|
+
(x, y) => (x + y) % 2 === 0,
|
|
94
|
+
(x, y) => y % 2 === 0,
|
|
95
|
+
(x) => x % 3 === 0,
|
|
96
|
+
(x, y) => (x + y) % 3 === 0,
|
|
97
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
98
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
99
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
100
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Encode `text` (UTF-8, byte mode) as a QR code. Returns `{ version, ecl, mask, size, modules }`,
|
|
105
|
+
* with `modules[y][x]` true for a dark module. Picks the smallest version that fits; picks the mask
|
|
106
|
+
* by the standard's penalty unless `mask` (0 to 7) is given. Throws when the text does not fit.
|
|
107
|
+
*/
|
|
108
|
+
export function encodeQr(text, { ecl = "M", mask = null, minVersion = 1 } = {}) {
|
|
109
|
+
const e = ECL[ecl];
|
|
110
|
+
if (!e) throw new Error(`QR error correction is L, M, Q or H, not ${ecl}.`);
|
|
111
|
+
if (mask !== null && !(Number.isInteger(mask) && mask >= 0 && mask <= 7)) throw new Error("A QR mask is 0 to 7.");
|
|
112
|
+
const bytes = [...Buffer.from(String(text), "utf8")];
|
|
113
|
+
let ver = Math.max(1, minVersion);
|
|
114
|
+
for (; ver <= 40; ver++) {
|
|
115
|
+
const countBits = ver <= 9 ? 8 : 16;
|
|
116
|
+
if (bytes.length < 2 ** countBits && 4 + countBits + bytes.length * 8 <= dataCodewords(ver, e) * 8) break;
|
|
117
|
+
}
|
|
118
|
+
if (ver > 40) throw new Error("That is too long for a QR code.");
|
|
119
|
+
|
|
120
|
+
// The data bits: mode, length, bytes, terminator, then pad to the capacity.
|
|
121
|
+
const bits = [];
|
|
122
|
+
const put = (val, len) => { for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
|
|
123
|
+
put(0b0100, 4);
|
|
124
|
+
put(bytes.length, ver <= 9 ? 8 : 16);
|
|
125
|
+
for (const b of bytes) put(b, 8);
|
|
126
|
+
const capacity = dataCodewords(ver, e) * 8;
|
|
127
|
+
put(0, Math.min(4, capacity - bits.length));
|
|
128
|
+
put(0, (8 - (bits.length % 8)) % 8);
|
|
129
|
+
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) put(pad, 8);
|
|
130
|
+
const data = [];
|
|
131
|
+
for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
|
|
132
|
+
|
|
133
|
+
// Split into blocks, add error correction to each, interleave.
|
|
134
|
+
const numBlocks = NUM_BLOCKS[e.i][ver];
|
|
135
|
+
const eccLen = ECC_PER_BLOCK[e.i][ver];
|
|
136
|
+
const rawCodewords = Math.floor(rawDataModules(ver) / 8);
|
|
137
|
+
const numShort = numBlocks - (rawCodewords % numBlocks);
|
|
138
|
+
const shortLen = Math.floor(rawCodewords / numBlocks);
|
|
139
|
+
const divisor = rsDivisor(eccLen);
|
|
140
|
+
const blocks = [];
|
|
141
|
+
for (let i = 0, k = 0; i < numBlocks; i++) {
|
|
142
|
+
const dat = data.slice(k, k + shortLen - eccLen + (i < numShort ? 0 : 1));
|
|
143
|
+
k += dat.length;
|
|
144
|
+
const ecc = rsRemainder(dat, divisor);
|
|
145
|
+
if (i < numShort) dat.push(0);
|
|
146
|
+
blocks.push(dat.concat(ecc));
|
|
147
|
+
}
|
|
148
|
+
const codewords = [];
|
|
149
|
+
for (let i = 0; i < blocks[0].length; i++) {
|
|
150
|
+
blocks.forEach((b, j) => {
|
|
151
|
+
if (i !== shortLen - eccLen || j >= numShort) codewords.push(b[i]);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const size = ver * 4 + 17;
|
|
156
|
+
const modules = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
157
|
+
const fn = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
158
|
+
const set = (x, y, dark) => { modules[y][x] = dark; fn[y][x] = true; };
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
|
161
|
+
for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
|
|
162
|
+
for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
|
|
163
|
+
const d = Math.max(Math.abs(dx), Math.abs(dy));
|
|
164
|
+
const x = cx + dx;
|
|
165
|
+
const y = cy + dy;
|
|
166
|
+
if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const al = alignmentPositions(ver);
|
|
170
|
+
for (let i = 0; i < al.length; i++) for (let j = 0; j < al.length; j++) {
|
|
171
|
+
if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) continue;
|
|
172
|
+
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(al[i] + dx, al[j] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
173
|
+
}
|
|
174
|
+
const drawFormat = (m) => {
|
|
175
|
+
const d = (e.bits << 3) | m;
|
|
176
|
+
let rem = d;
|
|
177
|
+
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
178
|
+
const b = ((d << 10) | rem) ^ 0x5412;
|
|
179
|
+
for (let i = 0; i <= 5; i++) set(8, i, bit(b, i));
|
|
180
|
+
set(8, 7, bit(b, 6));
|
|
181
|
+
set(8, 8, bit(b, 7));
|
|
182
|
+
set(7, 8, bit(b, 8));
|
|
183
|
+
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(b, i));
|
|
184
|
+
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(b, i));
|
|
185
|
+
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(b, i));
|
|
186
|
+
set(8, size - 8, true);
|
|
187
|
+
};
|
|
188
|
+
drawFormat(0);
|
|
189
|
+
if (ver >= 7) {
|
|
190
|
+
let rem = ver;
|
|
191
|
+
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
|
|
192
|
+
const b = (ver << 12) | rem;
|
|
193
|
+
for (let i = 0; i < 18; i++) {
|
|
194
|
+
const a = size - 11 + (i % 3);
|
|
195
|
+
const c = Math.floor(i / 3);
|
|
196
|
+
set(a, c, bit(b, i));
|
|
197
|
+
set(c, a, bit(b, i));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The codewords, in the zigzag.
|
|
202
|
+
let n = 0;
|
|
203
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
204
|
+
if (right === 6) right = 5;
|
|
205
|
+
for (let v = 0; v < size; v++) for (let j = 0; j < 2; j++) {
|
|
206
|
+
const x = right - j;
|
|
207
|
+
const y = ((right + 1) & 2) === 0 ? size - 1 - v : v;
|
|
208
|
+
if (!fn[y][x] && n < codewords.length * 8) {
|
|
209
|
+
modules[y][x] = bit(codewords[n >>> 3], 7 - (n & 7));
|
|
210
|
+
n++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const applyMask = (m) => {
|
|
216
|
+
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && MASKS[m](x, y)) modules[y][x] = !modules[y][x];
|
|
217
|
+
};
|
|
218
|
+
let chosen = mask;
|
|
219
|
+
if (chosen === null) {
|
|
220
|
+
let best = Infinity;
|
|
221
|
+
for (let m = 0; m < 8; m++) {
|
|
222
|
+
applyMask(m);
|
|
223
|
+
drawFormat(m);
|
|
224
|
+
const p = penalty(modules);
|
|
225
|
+
if (p < best) { best = p; chosen = m; }
|
|
226
|
+
applyMask(m);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
applyMask(chosen);
|
|
230
|
+
drawFormat(chosen);
|
|
231
|
+
return { version: ver, ecl, mask: chosen, size, modules };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The standard's four penalty rules; the lowest total picks the mask.
|
|
235
|
+
function penalty(m) {
|
|
236
|
+
const size = m.length;
|
|
237
|
+
let score = 0;
|
|
238
|
+
const lines = [];
|
|
239
|
+
for (let y = 0; y < size; y++) lines.push(m[y]);
|
|
240
|
+
for (let x = 0; x < size; x++) lines.push(m.map((row) => row[x]));
|
|
241
|
+
const finder = [true, false, true, true, true, false, true];
|
|
242
|
+
for (const line of lines) {
|
|
243
|
+
for (let i = 0, run = 1; i < size; i++) {
|
|
244
|
+
if (i + 1 < size && line[i + 1] === line[i]) { run++; continue; }
|
|
245
|
+
if (run >= 5) score += 3 + (run - 5);
|
|
246
|
+
run = 1;
|
|
247
|
+
}
|
|
248
|
+
for (let i = 0; i + 7 <= size; i++) {
|
|
249
|
+
if (!finder.every((v, k) => line[i + k] === v)) continue;
|
|
250
|
+
const lightBefore = i >= 4 && [1, 2, 3, 4].every((k) => !line[i - k]);
|
|
251
|
+
const lightAfter = i + 11 <= size && [0, 1, 2, 3].every((k) => !line[i + 7 + k]);
|
|
252
|
+
if (lightBefore || lightAfter) score += 40;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (let y = 0; y + 1 < size; y++) for (let x = 0; x + 1 < size; x++) {
|
|
256
|
+
const c = m[y][x];
|
|
257
|
+
if (c === m[y][x + 1] && c === m[y + 1][x] && c === m[y + 1][x + 1]) score += 3;
|
|
258
|
+
}
|
|
259
|
+
const dark = m.reduce((a, row) => a + row.filter(Boolean).length, 0);
|
|
260
|
+
score += Math.floor(Math.abs(dark * 20 - size * size * 10) / (size * size)) * 10;
|
|
261
|
+
return score;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
265
|
+
let c = n;
|
|
266
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
267
|
+
return c >>> 0;
|
|
268
|
+
});
|
|
269
|
+
function crc32(buf) {
|
|
270
|
+
let c = 0xffffffff;
|
|
271
|
+
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
272
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
273
|
+
}
|
|
274
|
+
function chunk(type, data) {
|
|
275
|
+
const len = Buffer.alloc(4);
|
|
276
|
+
len.writeUInt32BE(data.length);
|
|
277
|
+
const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
278
|
+
const crc = Buffer.alloc(4);
|
|
279
|
+
crc.writeUInt32BE(crc32(td));
|
|
280
|
+
return Buffer.concat([len, td, crc]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The code as a PNG: black on white, `scale` pixels a module, a quiet zone of `border` modules. */
|
|
284
|
+
export function qrPng(qr, { scale = 8, border = 4 } = {}) {
|
|
285
|
+
const px = (qr.size + border * 2) * scale;
|
|
286
|
+
const raw = Buffer.alloc((px + 1) * px, 0xff);
|
|
287
|
+
for (let y = 0; y < px; y++) {
|
|
288
|
+
raw[y * (px + 1)] = 0; // filter: none
|
|
289
|
+
const my = Math.floor(y / scale) - border;
|
|
290
|
+
for (let x = 0; x < px; x++) {
|
|
291
|
+
const mx = Math.floor(x / scale) - border;
|
|
292
|
+
if (my >= 0 && my < qr.size && mx >= 0 && mx < qr.size && qr.modules[my][mx]) raw[y * (px + 1) + 1 + x] = 0;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const ihdr = Buffer.alloc(13);
|
|
296
|
+
ihdr.writeUInt32BE(px, 0);
|
|
297
|
+
ihdr.writeUInt32BE(px, 4);
|
|
298
|
+
ihdr[8] = 8; // bit depth
|
|
299
|
+
ihdr[9] = 0; // greyscale
|
|
300
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The code as text, two rows a line in half blocks, dark modules drawn, with a quiet zone of `border`. */
|
|
304
|
+
export function qrText(qr, { border = 2 } = {}) {
|
|
305
|
+
const at = (x, y) => x >= 0 && y >= 0 && x < qr.size && y < qr.size && qr.modules[y][x];
|
|
306
|
+
const lines = [];
|
|
307
|
+
for (let y = -border; y < qr.size + border; y += 2) {
|
|
308
|
+
let line = "";
|
|
309
|
+
for (let x = -border; x < qr.size + border; x++) {
|
|
310
|
+
const top = at(x, y);
|
|
311
|
+
const bottom = at(x, y + 1);
|
|
312
|
+
line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
|
|
313
|
+
}
|
|
314
|
+
lines.push(line);
|
|
315
|
+
}
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
}
|
package/lib/record.mjs
CHANGED
|
@@ -47,6 +47,26 @@ export function headings(text) {
|
|
|
47
47
|
return text.split("\n").filter((l) => /^#{1,6} /.test(l)).map((l) => l.replace(/^#+ /, "").trim());
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// The first line of the file's most recent section: past any frontmatter block, past heading
|
|
51
|
+
// lines (those are already in `headings()`), and past every heading but the last. A day file
|
|
52
|
+
// accumulates one heading per entry through the day, so the last heading is the newest entry --
|
|
53
|
+
// a "recent" preview wants that one, not the file's opening paragraph, which for a fresh day file
|
|
54
|
+
// is create-evda's own scaffold text and was never the owner's. A file with no heading at all uses
|
|
55
|
+
// its first non-blank line, same as before.
|
|
56
|
+
export function firstLine(text) {
|
|
57
|
+
let body = text;
|
|
58
|
+
if (body.startsWith("---")) {
|
|
59
|
+
const end = body.indexOf("\n---", 3);
|
|
60
|
+
if (end !== -1) body = body.slice(end + 4);
|
|
61
|
+
}
|
|
62
|
+
const lines = body.split("\n");
|
|
63
|
+
let lastHeadingAt = -1;
|
|
64
|
+
for (let i = 0; i < lines.length; i++) if (/^#{1,6} /.test(lines[i])) lastHeadingAt = i;
|
|
65
|
+
const after = lastHeadingAt === -1 ? lines : lines.slice(lastHeadingAt + 1);
|
|
66
|
+
const line = after.find((l) => l.trim() !== "" && !/^#{1,6} /.test(l));
|
|
67
|
+
return line ? line.trim() : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
export function moment(evda, { from, to = from } = {}) {
|
|
51
71
|
if (!DATE.test(from ?? "") || !DATE.test(to ?? "")) {
|
|
52
72
|
throw new EvdaError(`moment needs YYYY-MM-DD dates, got ${from}..${to}`);
|
|
@@ -78,3 +98,57 @@ export function moment(evda, { from, to = from } = {}) {
|
|
|
78
98
|
out.entries.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : a.date.localeCompare(b.date)));
|
|
79
99
|
return out;
|
|
80
100
|
}
|
|
101
|
+
|
|
102
|
+
// What a reader needs from each section of a file to know what it was about without opening it
|
|
103
|
+
// (HQ-2324): its heading, its context line (the one line the filing agent wrote above the owner's
|
|
104
|
+
// words, `Context, not their words:` or `*Context (Iris, ...): ...*`), and the opening of the
|
|
105
|
+
// owner's words, cut at a word. A day file's headings are mostly times (`## 20:13`), so a heading
|
|
106
|
+
// alone told a chat app nothing about his last days. Text before the first heading is a section
|
|
107
|
+
// with no heading. Frontmatter, fences and the one-line marker a sealed part leaves are skipped.
|
|
108
|
+
export const OPENING_MAX = 280;
|
|
109
|
+
const CONTEXT_MAX = 240;
|
|
110
|
+
const CONTEXT_LINE = /^\*{0,2}_?\s*Context\b[^:]*:/i;
|
|
111
|
+
|
|
112
|
+
function cutAt(text, max) {
|
|
113
|
+
if (text.length <= max) return text;
|
|
114
|
+
const cut = text.slice(0, max);
|
|
115
|
+
const at = cut.lastIndexOf(" ");
|
|
116
|
+
return `${at > max / 2 ? cut.slice(0, at) : cut}...`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function sectionDigest(text, { openingMax = OPENING_MAX } = {}) {
|
|
120
|
+
let body = text;
|
|
121
|
+
if (body.startsWith("---")) {
|
|
122
|
+
const end = body.indexOf("\n---", 3);
|
|
123
|
+
if (end !== -1) body = body.slice(end + 4);
|
|
124
|
+
}
|
|
125
|
+
const out = [];
|
|
126
|
+
let cur = { heading: null, lines: [] };
|
|
127
|
+
let fence = false;
|
|
128
|
+
const flush = () => {
|
|
129
|
+
const lines = cur.lines.map((l) => l.trim()).filter((l) => l && !/^\[sealed[:\]]/i.test(l));
|
|
130
|
+
const ctxAt = lines.findIndex((l) => CONTEXT_LINE.test(l));
|
|
131
|
+
const context = ctxAt === -1 ? null : cutAt(lines[ctxAt].replace(/^[*_\s]+|[*_\s]+$/g, ""), CONTEXT_MAX);
|
|
132
|
+
const words = lines.filter((_, i) => i !== ctxAt)
|
|
133
|
+
.map((l) => l.replace(/^>\s?/, "").replace(/^>\s?/, "").trim())
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(" ")
|
|
136
|
+
.replace(/\s+/g, " ")
|
|
137
|
+
.trim();
|
|
138
|
+
if (cur.heading !== null || context || words) out.push({ heading: cur.heading, context, opening: words ? cutAt(words, openingMax) : null });
|
|
139
|
+
};
|
|
140
|
+
for (const line of body.split("\n")) {
|
|
141
|
+
if (/^\s*(`{3,}|~{3,})/.test(line)) { fence = !fence; continue; }
|
|
142
|
+
if (fence) continue;
|
|
143
|
+
const h = /^#{1,6} (.*)$/.exec(line);
|
|
144
|
+
if (h) { flush(); cur = { heading: h[1].trim(), lines: [] }; continue; }
|
|
145
|
+
cur.lines.push(line);
|
|
146
|
+
}
|
|
147
|
+
flush();
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The date a heading names (`## 2026-10-02, morning: ...`, `## October 4 (added 2026-10-04)`), or null.
|
|
152
|
+
export function headingDate(heading) {
|
|
153
|
+
return /\b(\d{4}-\d{2}-\d{2})\b/.exec(heading ?? "")?.[1] ?? null;
|
|
154
|
+
}
|
package/lib/routines.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { mkdirSync, writeFileSync, existsSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
15
15
|
import { delimiter, join, resolve, sep } from "node:path";
|
|
16
|
-
import { homedir } from "node:os";
|
|
16
|
+
import { homedir, tmpdir, userInfo } from "node:os";
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { openEvda, holderOf } from "./manifest.mjs";
|
|
19
19
|
import { serverCommand } from "./connect.mjs";
|
|
@@ -165,10 +165,42 @@ function bootout(target, env, launchctl) {
|
|
|
165
165
|
// loaded job (113 when there is none).
|
|
166
166
|
const isLoaded = (target, launchctl) => launchctl(["print", target]).status === 0;
|
|
167
167
|
|
|
168
|
+
// Is this record under a temp folder? Such a folder goes away, and a job for it runs daily on
|
|
169
|
+
// nothing (HQ-2006: the Mac app's Rust tests left two loaded in his launchd). Both the path as given
|
|
170
|
+
// and its realpath are checked, since /var and /tmp are symlinks into /private on a Mac.
|
|
171
|
+
const TEMP_ROOTS = ["/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
|
|
172
|
+
export function underTempDir(dir) {
|
|
173
|
+
const paths = [resolve(dir)];
|
|
174
|
+
try { paths.push(realpathSync(dir)); } catch {}
|
|
175
|
+
const roots = [...TEMP_ROOTS, resolve(tmpdir())];
|
|
176
|
+
try { roots.push(realpathSync(tmpdir())); } catch {}
|
|
177
|
+
return paths.some((p) => roots.some((r) => p === r || p.startsWith(r.endsWith(sep) ? r : r + sep)));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The owner's real home, from the account database rather than $HOME, which a test or a spawned
|
|
181
|
+
// process can point anywhere.
|
|
182
|
+
function accountHome() {
|
|
183
|
+
try { return userInfo().homedir; } catch { return homedir(); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const samePath = (a, b) => {
|
|
187
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
188
|
+
return resolve(a) === resolve(b) || real(a) === real(b);
|
|
189
|
+
};
|
|
190
|
+
|
|
168
191
|
// Write the launchd job and load it. `agentsDir`, `load` and `launchctl` exist so tests never touch
|
|
169
192
|
// the real ~/Library/LaunchAgents or launchctl. A job already loaded under this label is unloaded
|
|
170
193
|
// first, so a second install replaces it rather than failing to load a duplicate.
|
|
171
|
-
|
|
194
|
+
//
|
|
195
|
+
// A record under a temp folder is refused before anything is written when the job would land in the
|
|
196
|
+
// real ~/Library/LaunchAgents or be loaded by the real launchctl, whoever the caller is. `realHome`
|
|
197
|
+
// is the seam that lets a test name a stand-in for the real home.
|
|
198
|
+
export function installLaunchd(key, dir, { env = process.env, home = env.HOME ?? homedir(), realHome = accountHome(), agentsDir = env.EVDA_LAUNCH_AGENTS ?? process.env.EVDA_LAUNCH_AGENTS ?? join(home, "Library", "LaunchAgents"), load = !noLaunchctl(env), claude = findClaude(env), launchctl } = {}) {
|
|
199
|
+
const realLaunchctl = !launchctl && !env.EVDA_LAUNCHCTL;
|
|
200
|
+
if (underTempDir(dir) && (samePath(agentsDir, join(realHome, "Library", "LaunchAgents")) || (load && realLaunchctl))) {
|
|
201
|
+
throw new EvdaError(`REFUSED: ${resolve(dir)} is in a temp folder, so no launchd job is scheduled for it (it would run daily on a folder that goes away). Nothing was written.`, { code: "TEMP_RECORD", exit: 2 });
|
|
202
|
+
}
|
|
203
|
+
launchctl ??= (a) => runLaunchctl(a, env);
|
|
172
204
|
if (process.platform !== "darwin" && load && !env.EVDA_LAUNCHCTL) throw new EvdaError("REFUSED: launchd is macOS only. Use the Claude desktop routine instead. Nothing was written.", { exit: 2 });
|
|
173
205
|
const evda = openEvda(dir);
|
|
174
206
|
const plist = launchdPlist(key, dir, { home, claude });
|
|
@@ -211,13 +243,31 @@ export function uninstallLaunchd(key, dir, { env = process.env, home = env.HOME
|
|
|
211
243
|
// What setup does with the routines it was given (HQ-1246). On a Mac each is installed as a launchd
|
|
212
244
|
// job; elsewhere nothing can be scheduled from here, so it prints the Claude desktop steps and says
|
|
213
245
|
// so. Returns the lines to print.
|
|
214
|
-
|
|
246
|
+
//
|
|
247
|
+
// `say` prints a line at once, before anything is installed (HQ-1368): a run with --yes asks nothing,
|
|
248
|
+
// so the line that a launchd job is about to be loaded into this Mac is said before launchctl runs,
|
|
249
|
+
// not only in the summary after it. `launchctl` is the tests' stand-in, handed through to
|
|
250
|
+
// installLaunchd with loading on, so a test can watch the order without the real launchctl.
|
|
251
|
+
export function setupNotice(key, dir) {
|
|
252
|
+
const r = ROUTINES[key];
|
|
253
|
+
return `Installing a launchd job on this Mac: ${r.title}, ${r.when.toLowerCase()}, runs ${r.command.join(" ")} on ${resolve(dir)} with your own Claude. Not wanted: create-evda --no-routines, or afterwards evda routine ${key} ${resolve(dir)} --uninstall`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function installAtSetup(keys, dir, { env = process.env, platform = process.platform, say = null, launchctl = null } = {}) {
|
|
215
257
|
const lines = [];
|
|
216
258
|
for (const key of keys) {
|
|
217
259
|
const r = ROUTINES[key];
|
|
218
260
|
lines.push("");
|
|
219
261
|
if (platform === "darwin") {
|
|
220
|
-
|
|
262
|
+
let done;
|
|
263
|
+
try {
|
|
264
|
+
if (say && !falsified(env, "silent-install")) say(setupNotice(key, dir));
|
|
265
|
+
done = installLaunchd(key, dir, launchctl ? { env, launchctl, load: true } : { env });
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (e.code !== "TEMP_RECORD") throw e;
|
|
268
|
+
lines.push(e.message);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
221
271
|
const lead = key === "morning" ? "Organizing is on" : `${r.title} is on`;
|
|
222
272
|
lines.push(`${lead}: ${r.when.toLowerCase()} your own Claude ${key === "morning" ? "files your notes into their day and writes what belongs where" : "reads your week back to you"}, in reports/. Turn it off: evda routine ${key} ${resolve(dir)} --uninstall`);
|
|
223
273
|
lines.push(` launchd job ${done.path}: ${done.loaded}. It runs while this computer is on; asleep at that hour, it runs when it wakes.`);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "https://raw.githubusercontent.com/sigstore/root-signing/main/targets/trusted_root.json (Sigstore public-good instance), fetched 2026-10-04",
|
|
3
|
+
"tlogs": [
|
|
4
|
+
{
|
|
5
|
+
"baseUrl": "https://rekor.sigstore.dev",
|
|
6
|
+
"logId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0=",
|
|
7
|
+
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==",
|
|
8
|
+
"keyDetails": "PKIX_ECDSA_P256_SHA_256",
|
|
9
|
+
"validFrom": "2021-01-12T11:53:27Z"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"fulcio": {
|
|
13
|
+
"baseUrl": "https://fulcio.sigstore.dev",
|
|
14
|
+
"validFrom": "2022-04-13T20:06:15Z",
|
|
15
|
+
"chain": [
|
|
16
|
+
"MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow=",
|
|
17
|
+
"MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
package/lib/skills.mjs
CHANGED
|
@@ -226,7 +226,7 @@ export function writeSkill(evda, args = {}, { now = new Date(), env = process.en
|
|
|
226
226
|
// Tools that are how a session starts or keeps itself, never a step of a process the owner asked
|
|
227
227
|
// for. And the plain read-and-file loop: search, read, write is the record's ordinary use, not a
|
|
228
228
|
// process worth a skill, so a run made only of those is never pointed at.
|
|
229
|
-
const META = new Set(["evda_operating_layer", "evda_skills", "evda_write_skill", "evda_check_update"]);
|
|
229
|
+
const META = new Set(["evda_operating_layer", "evda_skills", "evda_write_skill", "evda_check_update", "evda_acknowledge", "evda_acknowledgements"]);
|
|
230
230
|
const ORDINARY = new Set(["evda_recent", "evda_search", "evda_read", "evda_write", "evda_note", "evda_circles", "evda_presets"]);
|
|
231
231
|
export const MIN_STEPS = 3;
|
|
232
232
|
export const MAX_STEPS = 8;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// One sync at a time per record, across processes (HQ-1925).
|
|
2
|
+
//
|
|
3
|
+
// The Mac app syncs on its own, a launchd job or a person at a terminal can run `evda sync`, and the
|
|
4
|
+
// MCP's evda_sync_devices syncs too. Every one of them reaches the relay through lib/pairing.mjs, so
|
|
5
|
+
// the lock is taken there and nowhere else.
|
|
6
|
+
//
|
|
7
|
+
// The lock is `<record>/.evda/sync.lock`: the record's derived state, which sync-rules never sends
|
|
8
|
+
// to a device, never under record/. It holds the holder's pid and that process's start time. A lock
|
|
9
|
+
// is stale only when its holder is gone: the pid runs no process, or the process running under that
|
|
10
|
+
// pid started at a different time (the pid was reused). Its age never counts, because a sync over a
|
|
11
|
+
// slow network is still a live sync.
|
|
12
|
+
//
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { EvdaError } from "./errors.mjs";
|
|
19
|
+
|
|
20
|
+
export const SYNC_LOCK = join(".evda", "sync.lock");
|
|
21
|
+
export const SYNC_BUSY = "A sync of this EVÐA is already running. Try again in a moment.";
|
|
22
|
+
export const SYNC_WAIT_MS = 20_000;
|
|
23
|
+
|
|
24
|
+
/** When the process with this pid started, as the system reports it, or null when no process has that pid. */
|
|
25
|
+
export function processStart(pid) {
|
|
26
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
27
|
+
try {
|
|
28
|
+
process.kill(pid, 0);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== "EPERM") return null; // EPERM: it runs, as someone else
|
|
31
|
+
}
|
|
32
|
+
if (process.platform === "win32") return ""; // no start time to read; the pid alone decides
|
|
33
|
+
try {
|
|
34
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
35
|
+
env: { PATH: "/bin:/usr/bin", LC_ALL: "C" },
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
38
|
+
}).trim();
|
|
39
|
+
return out || null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null; // ps exits 1 when the pid is gone between the two checks
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Is the holder named in a lock file still the process that wrote it? */
|
|
46
|
+
export function holderAlive(holder, { startOf = processStart } = {}) {
|
|
47
|
+
if (!holder || !Number.isInteger(holder.pid)) return false;
|
|
48
|
+
const now = startOf(holder.pid);
|
|
49
|
+
if (now === null) return false;
|
|
50
|
+
if (now === "" || !holder.start) return true; // no start time on this system: the pid decides
|
|
51
|
+
return now === holder.start;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readHolder(raw) {
|
|
55
|
+
try {
|
|
56
|
+
const h = JSON.parse(raw);
|
|
57
|
+
return h && typeof h === "object" ? h : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRaw(path) {
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync(path, "utf8");
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error?.code === "ENOENT") return null;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const SYNC_GUARD = join(".evda", "sync.guard");
|
|
73
|
+
|
|
74
|
+
// Judging a lock stale and taking it must be one step, or two processes that both read the same
|
|
75
|
+
// stale lock can each take it. That step runs inside an exclusive SQLite transaction on
|
|
76
|
+
// .evda/sync.guard: SQLite holds it with an OS file lock, which the OS drops when a process dies, so
|
|
77
|
+
// the guard itself can never be left stale. It is held only for the few file operations below,
|
|
78
|
+
// never for a sync. node:sqlite is loaded here and not at the top, because pairing (the only caller)
|
|
79
|
+
// already needs it and every other evda command must still run on a Node without it.
|
|
80
|
+
async function guarded(dir, fn) {
|
|
81
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
82
|
+
const db = new DatabaseSync(join(dir, SYNC_GUARD));
|
|
83
|
+
try {
|
|
84
|
+
db.exec("PRAGMA busy_timeout = 10000");
|
|
85
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
86
|
+
try {
|
|
87
|
+
return fn();
|
|
88
|
+
} finally {
|
|
89
|
+
db.exec("COMMIT");
|
|
90
|
+
}
|
|
91
|
+
} finally {
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pause = (ms) => new Promise((ok) => setTimeout(ok, ms));
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Take the record's sync lock, waiting up to `waitMs` for a running sync to finish. Returns a
|
|
100
|
+
* release function. When the wait runs out, throws SYNC_BUSY (exit 2: nothing was written).
|
|
101
|
+
* `beforeReclaim` is for tests: it runs, synchronously, between judging a lock stale and taking it.
|
|
102
|
+
*/
|
|
103
|
+
export async function acquireSyncLock(dir, { waitMs, pollMs = 100, startOf = processStart, env = process.env, beforeReclaim } = {}) {
|
|
104
|
+
const fromEnv = env.EVDA_SYNC_WAIT_MS ? Number(env.EVDA_SYNC_WAIT_MS) : NaN;
|
|
105
|
+
const wait = waitMs ?? (Number.isFinite(fromEnv) && fromEnv >= 0 ? fromEnv : SYNC_WAIT_MS);
|
|
106
|
+
const path = join(dir, SYNC_LOCK);
|
|
107
|
+
mkdirSync(join(dir, ".evda"), { recursive: true });
|
|
108
|
+
const mine = JSON.stringify({ pid: process.pid, start: startOf(process.pid) || null, n: randomBytes(4).toString("hex") });
|
|
109
|
+
const deadline = Date.now() + wait;
|
|
110
|
+
for (;;) {
|
|
111
|
+
const took = await guarded(dir, () => {
|
|
112
|
+
const raw = readRaw(path);
|
|
113
|
+
if (raw !== null) {
|
|
114
|
+
if (holderAlive(readHolder(raw), { startOf })) return false;
|
|
115
|
+
if (beforeReclaim) beforeReclaim();
|
|
116
|
+
unlinkSync(path);
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(path, mine, { flag: "wx" });
|
|
119
|
+
return true;
|
|
120
|
+
});
|
|
121
|
+
if (took) break;
|
|
122
|
+
if (Date.now() >= deadline) throw new EvdaError(SYNC_BUSY, { code: "SYNC_BUSY", exit: 2 });
|
|
123
|
+
await pause(pollMs);
|
|
124
|
+
}
|
|
125
|
+
let released = false;
|
|
126
|
+
return async function release() {
|
|
127
|
+
if (released) return;
|
|
128
|
+
released = true;
|
|
129
|
+
await guarded(dir, () => {
|
|
130
|
+
if (readRaw(path) === mine) unlinkSync(path);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Run `fn` holding the record's sync lock; the lock is released however `fn` ends. */
|
|
136
|
+
export async function withSyncLock(dir, fn, opts = {}) {
|
|
137
|
+
const release = await acquireSyncLock(dir, opts);
|
|
138
|
+
try {
|
|
139
|
+
return await fn();
|
|
140
|
+
} finally {
|
|
141
|
+
await release();
|
|
142
|
+
}
|
|
143
|
+
}
|