midsummer-sol 0.1.1
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/README.md +99 -0
- package/index.js +2035 -0
- package/package.json +39 -0
- package/sol-mcp.js +638 -0
- package/sol.js +3518 -0
package/index.js
ADDED
|
@@ -0,0 +1,2035 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var SEALED = "<<sealed>>";
|
|
3
|
+
// src/store.ts
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
function hashString(s) {
|
|
6
|
+
return "h_" + createHash("sha256").update(s, "utf8").digest("hex");
|
|
7
|
+
}
|
|
8
|
+
function hashNode(node) {
|
|
9
|
+
let canon;
|
|
10
|
+
if (node.kind === "blob") {
|
|
11
|
+
canon = node.encoding ? `blob\x00${node.encoding}\x00${node.content}` : `blob\x00${node.content}`;
|
|
12
|
+
} else if (node.kind === "sealed")
|
|
13
|
+
canon = `sealed\x00${node.box}`;
|
|
14
|
+
else
|
|
15
|
+
canon = "tree\x00" + Object.keys(node.entries).sort().map((k) => {
|
|
16
|
+
const e = node.entries[k];
|
|
17
|
+
return e.mode === undefined ? `${k}\x00${e.kind}\x00${e.hash}` : `${k}\x00${e.kind}\x00${e.hash}\x00${e.mode}`;
|
|
18
|
+
}).join(`
|
|
19
|
+
`);
|
|
20
|
+
return hashString(canon);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class Store {
|
|
24
|
+
objects = new Map;
|
|
25
|
+
put(node) {
|
|
26
|
+
const h = hashNode(node);
|
|
27
|
+
if (!this.objects.has(h))
|
|
28
|
+
this.objects.set(h, node);
|
|
29
|
+
return h;
|
|
30
|
+
}
|
|
31
|
+
get(h) {
|
|
32
|
+
return this.objects.get(h);
|
|
33
|
+
}
|
|
34
|
+
getTree(h) {
|
|
35
|
+
const n = this.get(h);
|
|
36
|
+
if (!n || n.kind !== "tree")
|
|
37
|
+
throw new Error(`not a tree: ${h}`);
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
has(h) {
|
|
41
|
+
return this.get(h) !== undefined;
|
|
42
|
+
}
|
|
43
|
+
size() {
|
|
44
|
+
return this.objects.size;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// src/tree.ts
|
|
48
|
+
var EMPTY_TREE = { kind: "tree", entries: {} };
|
|
49
|
+
function emptyRoot(store) {
|
|
50
|
+
return store.put(EMPTY_TREE);
|
|
51
|
+
}
|
|
52
|
+
function segments(path) {
|
|
53
|
+
return path.split("/").filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
function writeFile(store, root, path, content, opts = {}) {
|
|
56
|
+
const blob = opts.encoding ? { kind: "blob", content, encoding: opts.encoding } : { kind: "blob", content };
|
|
57
|
+
return writeInto(store, root, segments(path), store.put(blob), opts.mode);
|
|
58
|
+
}
|
|
59
|
+
function writeInto(store, treeHash, segs, blobHash, mode) {
|
|
60
|
+
const tree = store.getTree(treeHash);
|
|
61
|
+
const entries = { ...tree.entries };
|
|
62
|
+
const [head, ...rest] = segs;
|
|
63
|
+
if (rest.length === 0) {
|
|
64
|
+
entries[head] = mode === undefined ? { kind: "blob", hash: blobHash } : { kind: "blob", hash: blobHash, mode };
|
|
65
|
+
} else {
|
|
66
|
+
const child = entries[head];
|
|
67
|
+
const childHash = child?.kind === "tree" ? child.hash : emptyRoot(store);
|
|
68
|
+
entries[head] = { kind: "tree", hash: writeInto(store, childHash, rest, blobHash, mode) };
|
|
69
|
+
}
|
|
70
|
+
return store.put({ kind: "tree", entries });
|
|
71
|
+
}
|
|
72
|
+
function fileAt(store, root, path) {
|
|
73
|
+
const segs = segments(path);
|
|
74
|
+
let cur = root;
|
|
75
|
+
for (let i = 0;i < segs.length; i++) {
|
|
76
|
+
const entry = store.getTree(cur).entries[segs[i]];
|
|
77
|
+
if (!entry)
|
|
78
|
+
return;
|
|
79
|
+
if (i === segs.length - 1)
|
|
80
|
+
return entry.kind === "blob" ? store.get(entry.hash) : undefined;
|
|
81
|
+
if (entry.kind !== "tree")
|
|
82
|
+
return;
|
|
83
|
+
cur = entry.hash;
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
function modeAt(store, root, path) {
|
|
88
|
+
const segs = segments(path);
|
|
89
|
+
let cur = root;
|
|
90
|
+
for (let i = 0;i < segs.length; i++) {
|
|
91
|
+
const entry = store.getTree(cur).entries[segs[i]];
|
|
92
|
+
if (!entry)
|
|
93
|
+
return;
|
|
94
|
+
if (i === segs.length - 1)
|
|
95
|
+
return entry.mode;
|
|
96
|
+
if (entry.kind !== "tree")
|
|
97
|
+
return;
|
|
98
|
+
cur = entry.hash;
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
function deleteFile(store, root, path) {
|
|
103
|
+
return deleteInto(store, root, segments(path));
|
|
104
|
+
}
|
|
105
|
+
function deleteInto(store, treeHash, segs) {
|
|
106
|
+
const tree = store.getTree(treeHash);
|
|
107
|
+
const entries = { ...tree.entries };
|
|
108
|
+
const [head, ...rest] = segs;
|
|
109
|
+
if (!entries[head])
|
|
110
|
+
return treeHash;
|
|
111
|
+
if (rest.length === 0) {
|
|
112
|
+
delete entries[head];
|
|
113
|
+
} else if (entries[head].kind === "tree") {
|
|
114
|
+
entries[head] = { kind: "tree", hash: deleteInto(store, entries[head].hash, rest) };
|
|
115
|
+
}
|
|
116
|
+
return store.put({ kind: "tree", entries });
|
|
117
|
+
}
|
|
118
|
+
function readFile(store, root, path) {
|
|
119
|
+
const segs = segments(path);
|
|
120
|
+
let cur = root;
|
|
121
|
+
for (let i = 0;i < segs.length; i++) {
|
|
122
|
+
const entry = store.getTree(cur).entries[segs[i]];
|
|
123
|
+
if (!entry)
|
|
124
|
+
return;
|
|
125
|
+
if (i === segs.length - 1) {
|
|
126
|
+
return entry.kind === "blob" ? store.get(entry.hash).content : undefined;
|
|
127
|
+
}
|
|
128
|
+
if (entry.kind !== "tree")
|
|
129
|
+
return;
|
|
130
|
+
cur = entry.hash;
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
function listAll(store, root, prefix = "") {
|
|
135
|
+
const tree = store.getTree(root);
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const [name, entry] of Object.entries(tree.entries)) {
|
|
138
|
+
const p = prefix ? `${prefix}/${name}` : name;
|
|
139
|
+
if (entry.kind === "tree")
|
|
140
|
+
out.push(...listAll(store, entry.hash, p));
|
|
141
|
+
else
|
|
142
|
+
out.push(p);
|
|
143
|
+
}
|
|
144
|
+
return out.sort();
|
|
145
|
+
}
|
|
146
|
+
function entryKindAt(store, root, path) {
|
|
147
|
+
const segs = segments(path);
|
|
148
|
+
let cur = root;
|
|
149
|
+
for (let i = 0;i < segs.length; i++) {
|
|
150
|
+
const entry = store.getTree(cur).entries[segs[i]];
|
|
151
|
+
if (!entry)
|
|
152
|
+
return;
|
|
153
|
+
if (i === segs.length - 1)
|
|
154
|
+
return entry.kind;
|
|
155
|
+
if (entry.kind !== "tree")
|
|
156
|
+
return;
|
|
157
|
+
cur = entry.hash;
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
function blobHashAt(store, root, path) {
|
|
162
|
+
const segs = segments(path);
|
|
163
|
+
let cur = root;
|
|
164
|
+
for (let i = 0;i < segs.length; i++) {
|
|
165
|
+
const entry = store.getTree(cur).entries[segs[i]];
|
|
166
|
+
if (!entry)
|
|
167
|
+
return;
|
|
168
|
+
if (i === segs.length - 1)
|
|
169
|
+
return entry.kind === "blob" ? entry.hash : undefined;
|
|
170
|
+
if (entry.kind !== "tree")
|
|
171
|
+
return;
|
|
172
|
+
cur = entry.hash;
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// src/privacy.ts
|
|
177
|
+
class SealRegistry {
|
|
178
|
+
seals = [];
|
|
179
|
+
seal(prefix, recipients) {
|
|
180
|
+
this.seals = this.seals.filter((s) => s.prefix !== prefix);
|
|
181
|
+
this.seals.push({ prefix, recipients: new Set(recipients) });
|
|
182
|
+
}
|
|
183
|
+
unseal(prefix) {
|
|
184
|
+
this.seals = this.seals.filter((s) => s.prefix !== prefix);
|
|
185
|
+
}
|
|
186
|
+
isSealed(path) {
|
|
187
|
+
return this.seals.some((s) => covers(s.prefix, path));
|
|
188
|
+
}
|
|
189
|
+
canRead(path, actor) {
|
|
190
|
+
for (const s of this.seals) {
|
|
191
|
+
if (covers(s.prefix, path) && !s.recipients.has(actor))
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function covers(prefix, path) {
|
|
198
|
+
return path === prefix || path.startsWith(prefix + "/");
|
|
199
|
+
}
|
|
200
|
+
// src/crypto.ts
|
|
201
|
+
import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
202
|
+
var UNREADABLE = "<<unreadable>>";
|
|
203
|
+
|
|
204
|
+
class KeyRing {
|
|
205
|
+
keys = new Map;
|
|
206
|
+
ensure(actor) {
|
|
207
|
+
let k = this.keys.get(actor);
|
|
208
|
+
if (!k) {
|
|
209
|
+
k = randomBytes(32);
|
|
210
|
+
this.keys.set(actor, k);
|
|
211
|
+
}
|
|
212
|
+
return k;
|
|
213
|
+
}
|
|
214
|
+
key(actor) {
|
|
215
|
+
return this.keys.get(actor);
|
|
216
|
+
}
|
|
217
|
+
serialize() {
|
|
218
|
+
const out = {};
|
|
219
|
+
for (const [a, k] of this.keys)
|
|
220
|
+
out[a] = k.toString("base64");
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
load(data) {
|
|
224
|
+
for (const [a, k] of Object.entries(data))
|
|
225
|
+
this.keys.set(a, Buffer.from(k, "base64"));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function aeadSeal(key, plain) {
|
|
229
|
+
const iv = randomBytes(12);
|
|
230
|
+
const c = createCipheriv("aes-256-gcm", key, iv);
|
|
231
|
+
const ct = Buffer.concat([c.update(plain), c.final()]);
|
|
232
|
+
return { iv: iv.toString("base64"), ct: ct.toString("base64"), tag: c.getAuthTag().toString("base64") };
|
|
233
|
+
}
|
|
234
|
+
function aeadOpen(key, box) {
|
|
235
|
+
const d = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64"));
|
|
236
|
+
d.setAuthTag(Buffer.from(box.tag, "base64"));
|
|
237
|
+
return Buffer.concat([d.update(Buffer.from(box.ct, "base64")), d.final()]);
|
|
238
|
+
}
|
|
239
|
+
function sealContent(ring, content, recipients, epoch = 1) {
|
|
240
|
+
const cek = randomBytes(32);
|
|
241
|
+
const body = aeadSeal(cek, Buffer.from(content, "utf8"));
|
|
242
|
+
const lockboxes = {};
|
|
243
|
+
for (const r of recipients)
|
|
244
|
+
lockboxes[r] = aeadSeal(ring.ensure(r), cek);
|
|
245
|
+
return { body, lockboxes, epoch };
|
|
246
|
+
}
|
|
247
|
+
function openContent(ring, box, actor) {
|
|
248
|
+
const lb = box.lockboxes[actor];
|
|
249
|
+
const key = ring.key(actor);
|
|
250
|
+
if (!lb || !key)
|
|
251
|
+
return UNREADABLE;
|
|
252
|
+
try {
|
|
253
|
+
const cek = aeadOpen(key, lb);
|
|
254
|
+
return aeadOpen(cek, box.body).toString("utf8");
|
|
255
|
+
} catch {
|
|
256
|
+
return UNREADABLE;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function isRecipient(box, actor) {
|
|
260
|
+
return actor in box.lockboxes;
|
|
261
|
+
}
|
|
262
|
+
function ciphertextOf(box) {
|
|
263
|
+
return box.body.ct;
|
|
264
|
+
}
|
|
265
|
+
function rotate(ring, box, content, newRecipients) {
|
|
266
|
+
return sealContent(ring, content, newRecipients, box.epoch + 1);
|
|
267
|
+
}
|
|
268
|
+
function sameActor(a, b) {
|
|
269
|
+
const ab = Buffer.from(a);
|
|
270
|
+
const bb = Buffer.from(b);
|
|
271
|
+
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
|
272
|
+
}
|
|
273
|
+
var RECOVERY_INFO = Buffer.from("forge-vcs/recovery/v1\x00");
|
|
274
|
+
function deriveWrapKey(shared, epkB64, recipientPubB64) {
|
|
275
|
+
if (shared.every((b) => b === 0))
|
|
276
|
+
throw new Error("invalid ECDH shared secret");
|
|
277
|
+
const info = Buffer.concat([RECOVERY_INFO, Buffer.from(epkB64, "base64"), Buffer.from(recipientPubB64, "base64")]);
|
|
278
|
+
return Buffer.from(hkdfSync("sha256", shared, Buffer.alloc(0), info, 32));
|
|
279
|
+
}
|
|
280
|
+
function recoveryKeyPair() {
|
|
281
|
+
const { publicKey, privateKey } = generateKeyPairSync("x25519");
|
|
282
|
+
return { publicKey: publicKey.export({ type: "spki", format: "der" }).toString("base64"), privateKey };
|
|
283
|
+
}
|
|
284
|
+
function wrapCekTo(recipientPublicKeyB64, cek) {
|
|
285
|
+
const recipientPub = createPublicKey({ key: Buffer.from(recipientPublicKeyB64, "base64"), type: "spki", format: "der" });
|
|
286
|
+
const eph = generateKeyPairSync("x25519");
|
|
287
|
+
const epk = eph.publicKey.export({ type: "spki", format: "der" }).toString("base64");
|
|
288
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipientPub });
|
|
289
|
+
return { epk, box: aeadSeal(deriveWrapKey(shared, epk, recipientPublicKeyB64), cek) };
|
|
290
|
+
}
|
|
291
|
+
function unwrapCekWith(privateKey, lb) {
|
|
292
|
+
const recipientPubB64 = createPublicKey(privateKey).export({ type: "spki", format: "der" }).toString("base64");
|
|
293
|
+
const epk = createPublicKey({ key: Buffer.from(lb.epk, "base64"), type: "spki", format: "der" });
|
|
294
|
+
const shared = diffieHellman({ privateKey, publicKey: epk });
|
|
295
|
+
return aeadOpen(deriveWrapKey(shared, lb.epk, recipientPubB64), lb.box);
|
|
296
|
+
}
|
|
297
|
+
function sealWithRecovery(ring, content, recipients, recoveryPubKeys = {}, epoch = 1) {
|
|
298
|
+
const cek = randomBytes(32);
|
|
299
|
+
const body = aeadSeal(cek, Buffer.from(content, "utf8"));
|
|
300
|
+
const lockboxes = {};
|
|
301
|
+
for (const r of recipients)
|
|
302
|
+
lockboxes[r] = aeadSeal(ring.ensure(r), cek);
|
|
303
|
+
const recovery = {};
|
|
304
|
+
for (const [id, pub] of Object.entries(recoveryPubKeys))
|
|
305
|
+
recovery[id] = wrapCekTo(pub, cek);
|
|
306
|
+
return { body, lockboxes, recovery, epoch };
|
|
307
|
+
}
|
|
308
|
+
function openWithRecovery(privateKey, box, recoveryId) {
|
|
309
|
+
const lb = box.recovery?.[recoveryId];
|
|
310
|
+
if (!lb)
|
|
311
|
+
return UNREADABLE;
|
|
312
|
+
try {
|
|
313
|
+
const cek = unwrapCekWith(privateKey, lb);
|
|
314
|
+
return aeadOpen(cek, box.body).toString("utf8");
|
|
315
|
+
} catch {
|
|
316
|
+
return UNREADABLE;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// src/log.ts
|
|
320
|
+
function groupIntoUnits(ops) {
|
|
321
|
+
const units = [];
|
|
322
|
+
let cur = [];
|
|
323
|
+
for (const op of ops) {
|
|
324
|
+
if (op.type === "checkpoint") {
|
|
325
|
+
units.push({ label: op.message, by: op.by, at: op.at, head: op.rootAfter, ops: cur, open: false });
|
|
326
|
+
cur = [];
|
|
327
|
+
} else {
|
|
328
|
+
cur.push(op);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (cur.length > 0) {
|
|
332
|
+
const last = cur[cur.length - 1];
|
|
333
|
+
units.push({ by: last.by, at: last.at, head: last.rootAfter, ops: cur, open: true });
|
|
334
|
+
}
|
|
335
|
+
return units;
|
|
336
|
+
}
|
|
337
|
+
function underPrefix(path, prefix) {
|
|
338
|
+
if (prefix === "" || path === prefix)
|
|
339
|
+
return true;
|
|
340
|
+
const dir = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
341
|
+
return path.startsWith(dir);
|
|
342
|
+
}
|
|
343
|
+
function filterLog(ops, opts = {}) {
|
|
344
|
+
const { actor, pathPrefix, since } = opts;
|
|
345
|
+
return ops.filter((op) => {
|
|
346
|
+
if (actor !== undefined && op.by !== actor)
|
|
347
|
+
return false;
|
|
348
|
+
if (pathPrefix !== undefined && !underPrefix(op.path, pathPrefix))
|
|
349
|
+
return false;
|
|
350
|
+
if (since !== undefined && op.seq < since)
|
|
351
|
+
return false;
|
|
352
|
+
return true;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
function formatLog(ops) {
|
|
356
|
+
return ops.map((op) => {
|
|
357
|
+
const by = op.by ?? "?";
|
|
358
|
+
const head = `${op.seq} ${by} ${op.type} ${op.path}`;
|
|
359
|
+
return op.message ? `${head}: ${op.message}` : head;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// src/repo.ts
|
|
364
|
+
class Repo {
|
|
365
|
+
store = new Store;
|
|
366
|
+
seals = new SealRegistry;
|
|
367
|
+
view(actor = "anon", head) {
|
|
368
|
+
return new View(this, actor, head ?? emptyRoot(this.store));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
class View {
|
|
373
|
+
repo;
|
|
374
|
+
actor;
|
|
375
|
+
ancestry;
|
|
376
|
+
log = [];
|
|
377
|
+
seq = 0;
|
|
378
|
+
root;
|
|
379
|
+
constructor(repo, actor, head, ancestry = []) {
|
|
380
|
+
this.repo = repo;
|
|
381
|
+
this.actor = actor;
|
|
382
|
+
this.ancestry = ancestry;
|
|
383
|
+
this.root = head;
|
|
384
|
+
}
|
|
385
|
+
writeFile(path, content, opts = {}) {
|
|
386
|
+
this.root = writeFile(this.repo.store, this.root, path, content, opts);
|
|
387
|
+
return this.record("write", path);
|
|
388
|
+
}
|
|
389
|
+
writeBytes(path, data) {
|
|
390
|
+
this.root = writeFile(this.repo.store, this.root, path, Buffer.from(data).toString("base64"), { encoding: "base64" });
|
|
391
|
+
return this.record("write", path);
|
|
392
|
+
}
|
|
393
|
+
chmod(path, mode) {
|
|
394
|
+
const f = fileAt(this.repo.store, this.root, path);
|
|
395
|
+
if (!f)
|
|
396
|
+
return this;
|
|
397
|
+
this.root = writeFile(this.repo.store, this.root, path, f.content, { encoding: f.encoding, mode });
|
|
398
|
+
return this.record("write", path);
|
|
399
|
+
}
|
|
400
|
+
deleteFile(path) {
|
|
401
|
+
this.root = deleteFile(this.repo.store, this.root, path);
|
|
402
|
+
return this.record("delete", path);
|
|
403
|
+
}
|
|
404
|
+
seal(prefix, recipients) {
|
|
405
|
+
this.repo.seals.seal(prefix, recipients);
|
|
406
|
+
return this.record("seal", prefix);
|
|
407
|
+
}
|
|
408
|
+
readFile(path) {
|
|
409
|
+
if (!this.repo.seals.canRead(path, this.actor))
|
|
410
|
+
return SEALED;
|
|
411
|
+
return readFile(this.repo.store, this.root, path);
|
|
412
|
+
}
|
|
413
|
+
readBytes(path) {
|
|
414
|
+
if (!this.repo.seals.canRead(path, this.actor))
|
|
415
|
+
return SEALED;
|
|
416
|
+
const f = fileAt(this.repo.store, this.root, path);
|
|
417
|
+
if (!f)
|
|
418
|
+
return;
|
|
419
|
+
return new Uint8Array(Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8"));
|
|
420
|
+
}
|
|
421
|
+
list() {
|
|
422
|
+
return listAll(this.repo.store, this.root);
|
|
423
|
+
}
|
|
424
|
+
isSealed(path) {
|
|
425
|
+
return this.repo.seals.isSealed(path);
|
|
426
|
+
}
|
|
427
|
+
mode(path) {
|
|
428
|
+
return modeAt(this.repo.store, this.root, path);
|
|
429
|
+
}
|
|
430
|
+
head() {
|
|
431
|
+
return this.root;
|
|
432
|
+
}
|
|
433
|
+
history() {
|
|
434
|
+
return this.log;
|
|
435
|
+
}
|
|
436
|
+
fullHistory() {
|
|
437
|
+
return [...this.ancestry, ...this.log];
|
|
438
|
+
}
|
|
439
|
+
checkpoint(label) {
|
|
440
|
+
this.record("checkpoint", "", label);
|
|
441
|
+
return { label, head: this.root, at: this.log[this.log.length - 1].at };
|
|
442
|
+
}
|
|
443
|
+
units() {
|
|
444
|
+
return groupIntoUnits(this.log);
|
|
445
|
+
}
|
|
446
|
+
fork(actor = this.actor) {
|
|
447
|
+
return new View(this.repo, actor, this.root, [...this.ancestry, ...this.log]);
|
|
448
|
+
}
|
|
449
|
+
replay(actor = this.actor) {
|
|
450
|
+
const v = this.repo.view(actor);
|
|
451
|
+
for (const e of this.log) {
|
|
452
|
+
if (e.type === "write")
|
|
453
|
+
v.writeFile(e.path, this.blobContentFor(e));
|
|
454
|
+
else if (e.type === "delete")
|
|
455
|
+
v.deleteFile(e.path);
|
|
456
|
+
}
|
|
457
|
+
return v;
|
|
458
|
+
}
|
|
459
|
+
blobHashAt(path) {
|
|
460
|
+
return blobHashAt(this.repo.store, this.root, path);
|
|
461
|
+
}
|
|
462
|
+
record(type, path, message) {
|
|
463
|
+
this.log.push({ seq: this.seq++, type, path, rootAfter: this.root, at: Date.now(), by: this.actor, ...message !== undefined ? { message } : {} });
|
|
464
|
+
return this;
|
|
465
|
+
}
|
|
466
|
+
blobContentFor(e) {
|
|
467
|
+
const c = readFile(this.repo.store, e.rootAfter, e.path);
|
|
468
|
+
return c ?? "";
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// src/text-merge.ts
|
|
472
|
+
function lines(s) {
|
|
473
|
+
return s.split(`
|
|
474
|
+
`);
|
|
475
|
+
}
|
|
476
|
+
function eq(a, b) {
|
|
477
|
+
return a.length === b.length && a.every((x, i) => x === b[i]);
|
|
478
|
+
}
|
|
479
|
+
function lcsPairs(a, b) {
|
|
480
|
+
const n = a.length;
|
|
481
|
+
const m = b.length;
|
|
482
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
483
|
+
for (let i2 = n - 1;i2 >= 0; i2--) {
|
|
484
|
+
for (let j2 = m - 1;j2 >= 0; j2--) {
|
|
485
|
+
dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const pairs = [];
|
|
489
|
+
let i = 0;
|
|
490
|
+
let j = 0;
|
|
491
|
+
while (i < n && j < m) {
|
|
492
|
+
if (a[i] === b[j]) {
|
|
493
|
+
pairs.push([i, j]);
|
|
494
|
+
i++;
|
|
495
|
+
j++;
|
|
496
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1])
|
|
497
|
+
i++;
|
|
498
|
+
else
|
|
499
|
+
j++;
|
|
500
|
+
}
|
|
501
|
+
return pairs;
|
|
502
|
+
}
|
|
503
|
+
function hunks(base, other) {
|
|
504
|
+
const pairs = lcsPairs(base, other);
|
|
505
|
+
const out = [];
|
|
506
|
+
let pb = 0;
|
|
507
|
+
let po = 0;
|
|
508
|
+
const flush = (bEnd, oEnd) => {
|
|
509
|
+
if (bEnd > pb || oEnd > po)
|
|
510
|
+
out.push({ bs: pb, be: bEnd, lines: other.slice(po, oEnd) });
|
|
511
|
+
};
|
|
512
|
+
for (const [bi, oi] of pairs) {
|
|
513
|
+
flush(bi, oi);
|
|
514
|
+
pb = bi + 1;
|
|
515
|
+
po = oi + 1;
|
|
516
|
+
}
|
|
517
|
+
flush(base.length, other.length);
|
|
518
|
+
return out;
|
|
519
|
+
}
|
|
520
|
+
function sideContent(side, rs, re, base) {
|
|
521
|
+
const res = [];
|
|
522
|
+
let pos = rs;
|
|
523
|
+
for (const h of side) {
|
|
524
|
+
if (h.bs < rs || h.be > re)
|
|
525
|
+
continue;
|
|
526
|
+
for (let k = pos;k < h.bs; k++)
|
|
527
|
+
res.push(base[k]);
|
|
528
|
+
res.push(...h.lines);
|
|
529
|
+
pos = Math.max(pos, h.be);
|
|
530
|
+
}
|
|
531
|
+
for (let k = pos;k < re; k++)
|
|
532
|
+
res.push(base[k]);
|
|
533
|
+
return res;
|
|
534
|
+
}
|
|
535
|
+
function merge3(baseS, oursS, theirsS) {
|
|
536
|
+
const base = lines(baseS);
|
|
537
|
+
const ours = hunks(base, lines(oursS));
|
|
538
|
+
const theirs = hunks(base, lines(theirsS));
|
|
539
|
+
const all = [
|
|
540
|
+
...ours.map((h) => ({ ...h, side: "o" })),
|
|
541
|
+
...theirs.map((h) => ({ ...h, side: "t" }))
|
|
542
|
+
].sort((x, y) => x.bs - y.bs || x.be - y.be);
|
|
543
|
+
const regions = [];
|
|
544
|
+
for (const h of all) {
|
|
545
|
+
const last = regions[regions.length - 1];
|
|
546
|
+
if (last && h.bs < last.re) {
|
|
547
|
+
last.re = Math.max(last.re, h.be);
|
|
548
|
+
if (h.side === "o")
|
|
549
|
+
last.o = true;
|
|
550
|
+
else
|
|
551
|
+
last.t = true;
|
|
552
|
+
} else {
|
|
553
|
+
regions.push({ rs: h.bs, re: h.be, o: h.side === "o", t: h.side === "t" });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const out = [];
|
|
557
|
+
let conflicts = 0;
|
|
558
|
+
let pos = 0;
|
|
559
|
+
for (const reg of regions) {
|
|
560
|
+
for (let k = pos;k < reg.rs; k++)
|
|
561
|
+
out.push(base[k]);
|
|
562
|
+
const o = sideContent(ours, reg.rs, reg.re, base);
|
|
563
|
+
const t = sideContent(theirs, reg.rs, reg.re, base);
|
|
564
|
+
if (reg.o && reg.t) {
|
|
565
|
+
if (eq(o, t))
|
|
566
|
+
out.push(...o);
|
|
567
|
+
else {
|
|
568
|
+
conflicts++;
|
|
569
|
+
out.push("<<<<<<< ours", ...o, "=======", ...t, ">>>>>>> theirs");
|
|
570
|
+
}
|
|
571
|
+
} else if (reg.o)
|
|
572
|
+
out.push(...o);
|
|
573
|
+
else
|
|
574
|
+
out.push(...t);
|
|
575
|
+
pos = reg.re;
|
|
576
|
+
}
|
|
577
|
+
for (let k = pos;k < base.length; k++)
|
|
578
|
+
out.push(base[k]);
|
|
579
|
+
return { clean: conflicts === 0, text: out.join(`
|
|
580
|
+
`), conflicts };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/merge.ts
|
|
584
|
+
function resolvePath(base, ours, theirs) {
|
|
585
|
+
const oursChanged = ours !== base;
|
|
586
|
+
const theirsChanged = theirs !== base;
|
|
587
|
+
if (!oursChanged && !theirsChanged) {
|
|
588
|
+
return { content: base };
|
|
589
|
+
}
|
|
590
|
+
if (oursChanged && !theirsChanged) {
|
|
591
|
+
return { content: ours };
|
|
592
|
+
}
|
|
593
|
+
if (!oursChanged && theirsChanged) {
|
|
594
|
+
return { content: theirs };
|
|
595
|
+
}
|
|
596
|
+
if (ours === theirs) {
|
|
597
|
+
return { content: ours };
|
|
598
|
+
}
|
|
599
|
+
if (base !== undefined && ours !== undefined && theirs !== undefined) {
|
|
600
|
+
const m = merge3(base, ours, theirs);
|
|
601
|
+
if (m.clean)
|
|
602
|
+
return { content: m.text };
|
|
603
|
+
return { content: m.text, conflict: { path: "", ours, theirs } };
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
content: ours,
|
|
607
|
+
conflict: { path: "", ours, theirs }
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function merge(repo, baseHead, oursHead, theirsHead) {
|
|
611
|
+
const store = repo.store;
|
|
612
|
+
const paths = new Set([
|
|
613
|
+
...listAll(store, baseHead),
|
|
614
|
+
...listAll(store, oursHead),
|
|
615
|
+
...listAll(store, theirsHead)
|
|
616
|
+
]);
|
|
617
|
+
const conflicts = [];
|
|
618
|
+
let root = emptyRoot(store);
|
|
619
|
+
for (const path of [...paths].sort()) {
|
|
620
|
+
const base = readFile(store, baseHead, path);
|
|
621
|
+
const ours = readFile(store, oursHead, path);
|
|
622
|
+
const theirs = readFile(store, theirsHead, path);
|
|
623
|
+
const { content, conflict } = resolvePath(base, ours, theirs);
|
|
624
|
+
if (conflict)
|
|
625
|
+
conflicts.push({ ...conflict, path });
|
|
626
|
+
if (content !== undefined) {
|
|
627
|
+
root = writeFile(store, root, path, content);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return { head: root, conflicts };
|
|
631
|
+
}
|
|
632
|
+
// src/labels.ts
|
|
633
|
+
class Labels {
|
|
634
|
+
labels = new Map;
|
|
635
|
+
branch(name, head) {
|
|
636
|
+
this.create(name, "branch", head);
|
|
637
|
+
}
|
|
638
|
+
tag(name, head) {
|
|
639
|
+
this.create(name, "tag", head);
|
|
640
|
+
}
|
|
641
|
+
resolve(name) {
|
|
642
|
+
return this.labels.get(name)?.head;
|
|
643
|
+
}
|
|
644
|
+
update(name, head) {
|
|
645
|
+
const label = this.labels.get(name);
|
|
646
|
+
if (!label)
|
|
647
|
+
throw new Error(`unknown label: ${name}`);
|
|
648
|
+
if (label.kind === "tag")
|
|
649
|
+
throw new Error(`cannot move tag: ${name} (tags are immutable)`);
|
|
650
|
+
label.head = head;
|
|
651
|
+
}
|
|
652
|
+
remove(name) {
|
|
653
|
+
if (!this.labels.delete(name))
|
|
654
|
+
throw new Error(`unknown label: ${name}`);
|
|
655
|
+
}
|
|
656
|
+
list() {
|
|
657
|
+
return [...this.labels.values()].map((l) => ({ ...l })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
658
|
+
}
|
|
659
|
+
create(name, kind, head) {
|
|
660
|
+
if (this.labels.has(name))
|
|
661
|
+
throw new Error(`label already exists: ${name}`);
|
|
662
|
+
this.labels.set(name, { name, kind, head });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// src/git-bridge.ts
|
|
666
|
+
function importFiles(repo, files, actor = "anon") {
|
|
667
|
+
const view = repo.view(actor);
|
|
668
|
+
for (const path of Object.keys(files)) {
|
|
669
|
+
view.writeFile(path, files[path]);
|
|
670
|
+
}
|
|
671
|
+
return view;
|
|
672
|
+
}
|
|
673
|
+
function exportFiles(view) {
|
|
674
|
+
const out = {};
|
|
675
|
+
for (const path of view.list()) {
|
|
676
|
+
const content = view.readFile(path);
|
|
677
|
+
if (content === SEALED || content === undefined)
|
|
678
|
+
continue;
|
|
679
|
+
out[path] = content;
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
// src/cli.ts
|
|
684
|
+
var USAGE = [
|
|
685
|
+
"usage:",
|
|
686
|
+
" write <path> <content...> write content to a path",
|
|
687
|
+
" read <path> read a path (or <<sealed>> if withheld)",
|
|
688
|
+
" list list every file path",
|
|
689
|
+
" seal <prefix> <actor...> seal a prefix to a recipient set",
|
|
690
|
+
" log show the op-log",
|
|
691
|
+
" checkpoint <label> label the current head",
|
|
692
|
+
" fork note a clone-free branch at this head"
|
|
693
|
+
].join(`
|
|
694
|
+
`);
|
|
695
|
+
function runCommand(view, argv) {
|
|
696
|
+
const [cmd, ...rest] = argv;
|
|
697
|
+
switch (cmd) {
|
|
698
|
+
case "write":
|
|
699
|
+
return cmdWrite(view, rest);
|
|
700
|
+
case "read":
|
|
701
|
+
return cmdRead(view, rest);
|
|
702
|
+
case "list":
|
|
703
|
+
return cmdList(view);
|
|
704
|
+
case "seal":
|
|
705
|
+
return cmdSeal(view, rest);
|
|
706
|
+
case "log":
|
|
707
|
+
return cmdLog(view);
|
|
708
|
+
case "checkpoint":
|
|
709
|
+
return cmdCheckpoint(view, rest);
|
|
710
|
+
case "fork":
|
|
711
|
+
return cmdFork(view);
|
|
712
|
+
default:
|
|
713
|
+
return USAGE;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function cmdWrite(view, args) {
|
|
717
|
+
const [path, ...content] = args;
|
|
718
|
+
if (!path || content.length === 0)
|
|
719
|
+
return USAGE;
|
|
720
|
+
view.writeFile(path, content.join(" "));
|
|
721
|
+
return `wrote ${path} @ ${short(view.head())}`;
|
|
722
|
+
}
|
|
723
|
+
function cmdRead(view, args) {
|
|
724
|
+
const [path] = args;
|
|
725
|
+
if (!path)
|
|
726
|
+
return USAGE;
|
|
727
|
+
const content = view.readFile(path);
|
|
728
|
+
if (content === SEALED)
|
|
729
|
+
return SEALED;
|
|
730
|
+
if (content === undefined)
|
|
731
|
+
return `not found: ${path}`;
|
|
732
|
+
return content;
|
|
733
|
+
}
|
|
734
|
+
function cmdList(view) {
|
|
735
|
+
const files = view.list();
|
|
736
|
+
return files.length === 0 ? "(empty)" : files.join(`
|
|
737
|
+
`);
|
|
738
|
+
}
|
|
739
|
+
function cmdSeal(view, args) {
|
|
740
|
+
const [prefix, ...recipients] = args;
|
|
741
|
+
if (!prefix || recipients.length === 0)
|
|
742
|
+
return USAGE;
|
|
743
|
+
view.seal(prefix, recipients);
|
|
744
|
+
return `sealed ${prefix} -> ${recipients.join(", ")}`;
|
|
745
|
+
}
|
|
746
|
+
function cmdLog(view) {
|
|
747
|
+
const ops = view.history();
|
|
748
|
+
if (ops.length === 0)
|
|
749
|
+
return "(no ops)";
|
|
750
|
+
return ops.map((e) => `${e.seq} ${e.type} ${e.path} @ ${short(e.rootAfter)}`).join(`
|
|
751
|
+
`);
|
|
752
|
+
}
|
|
753
|
+
function cmdCheckpoint(view, args) {
|
|
754
|
+
const [label] = args;
|
|
755
|
+
if (!label)
|
|
756
|
+
return USAGE;
|
|
757
|
+
const cp = view.checkpoint(label);
|
|
758
|
+
return `checkpoint ${cp.label} @ ${short(cp.head)}`;
|
|
759
|
+
}
|
|
760
|
+
function cmdFork(view) {
|
|
761
|
+
return `fork: clone-free branch at ${short(view.head())}`;
|
|
762
|
+
}
|
|
763
|
+
function short(hash) {
|
|
764
|
+
return hash.slice(0, 12);
|
|
765
|
+
}
|
|
766
|
+
// src/async-store.ts
|
|
767
|
+
async function getTree(store, hash) {
|
|
768
|
+
const node = await store.get(hash);
|
|
769
|
+
if (!node || node.kind !== "tree")
|
|
770
|
+
throw new Error(`not a tree: ${hash}`);
|
|
771
|
+
return node;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
class MemoryAsyncStore {
|
|
775
|
+
nodes = new Map;
|
|
776
|
+
async put(node) {
|
|
777
|
+
const h = hashNode(node);
|
|
778
|
+
if (!this.nodes.has(h))
|
|
779
|
+
this.nodes.set(h, node);
|
|
780
|
+
return h;
|
|
781
|
+
}
|
|
782
|
+
async get(hash) {
|
|
783
|
+
return this.nodes.get(hash);
|
|
784
|
+
}
|
|
785
|
+
async has(hash) {
|
|
786
|
+
return this.nodes.has(hash);
|
|
787
|
+
}
|
|
788
|
+
get size() {
|
|
789
|
+
return this.nodes.size;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// src/attest.ts
|
|
793
|
+
import { createHmac, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
794
|
+
function canonicalOp(op) {
|
|
795
|
+
return JSON.stringify({
|
|
796
|
+
seq: op.seq,
|
|
797
|
+
type: op.type,
|
|
798
|
+
path: op.path,
|
|
799
|
+
rootAfter: op.rootAfter,
|
|
800
|
+
by: op.by ?? null,
|
|
801
|
+
message: op.message ?? null
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
function signOp(key, op) {
|
|
805
|
+
return createHmac("sha256", key).update(canonicalOp(op)).digest("base64");
|
|
806
|
+
}
|
|
807
|
+
function verifyOp(key, op, sig) {
|
|
808
|
+
const expected = Buffer.from(signOp(key, op), "base64");
|
|
809
|
+
let claimed;
|
|
810
|
+
try {
|
|
811
|
+
claimed = Buffer.from(sig, "base64");
|
|
812
|
+
} catch {
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
if (expected.length !== claimed.length)
|
|
816
|
+
return false;
|
|
817
|
+
return timingSafeEqual2(expected, claimed);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// src/errors.ts
|
|
821
|
+
class CorruptRepoError extends Error {
|
|
822
|
+
constructor(message) {
|
|
823
|
+
super(`corrupt repo: ${message}`);
|
|
824
|
+
this.name = "CorruptRepoError";
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/chain.ts
|
|
829
|
+
function hashEntry(prevHash, op) {
|
|
830
|
+
return hashString(canonicalOp(op) + "\x00" + (prevHash ?? ""));
|
|
831
|
+
}
|
|
832
|
+
function chainOp(prevHash, op) {
|
|
833
|
+
return { ...op, prevHash, entryHash: hashEntry(prevHash, op) };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// src/async-tree.ts
|
|
837
|
+
function segments2(path) {
|
|
838
|
+
return path.split("/").filter(Boolean);
|
|
839
|
+
}
|
|
840
|
+
async function emptyRoot2(store) {
|
|
841
|
+
return store.put({ kind: "tree", entries: {} });
|
|
842
|
+
}
|
|
843
|
+
async function writeFile2(store, root, path, content, opts = {}) {
|
|
844
|
+
const blob = opts.encoding ? { kind: "blob", content, encoding: opts.encoding } : { kind: "blob", content };
|
|
845
|
+
const hash = await store.put(blob);
|
|
846
|
+
return writeInto2(store, root, segments2(path), hash, "blob", opts.mode);
|
|
847
|
+
}
|
|
848
|
+
async function writeSealed(store, root, path, box) {
|
|
849
|
+
const hash = await store.put({ kind: "sealed", box });
|
|
850
|
+
return writeInto2(store, root, segments2(path), hash, "sealed");
|
|
851
|
+
}
|
|
852
|
+
async function writeInto2(store, treeHash, segs, hash, leafKind, mode) {
|
|
853
|
+
const tree = await getTree(store, treeHash);
|
|
854
|
+
const entries = { ...tree.entries };
|
|
855
|
+
const [head, ...rest] = segs;
|
|
856
|
+
if (rest.length === 0) {
|
|
857
|
+
entries[head] = mode === undefined ? { kind: leafKind, hash } : { kind: leafKind, hash, mode };
|
|
858
|
+
} else {
|
|
859
|
+
const child = entries[head];
|
|
860
|
+
const childHash = child?.kind === "tree" ? child.hash : await emptyRoot2(store);
|
|
861
|
+
entries[head] = { kind: "tree", hash: await writeInto2(store, childHash, rest, hash, leafKind, mode) };
|
|
862
|
+
}
|
|
863
|
+
return store.put({ kind: "tree", entries });
|
|
864
|
+
}
|
|
865
|
+
async function applyBatch(store, root, changes) {
|
|
866
|
+
if (changes.size === 0)
|
|
867
|
+
return root;
|
|
868
|
+
const tree = await getTree(store, root);
|
|
869
|
+
const entries = { ...tree.entries };
|
|
870
|
+
const subdirs = new Map;
|
|
871
|
+
for (const [path, leaf] of changes) {
|
|
872
|
+
const i = path.indexOf("/");
|
|
873
|
+
if (i === -1) {
|
|
874
|
+
if (leaf === null)
|
|
875
|
+
delete entries[path];
|
|
876
|
+
else
|
|
877
|
+
entries[path] = leaf.mode === undefined ? { kind: leaf.kind, hash: leaf.hash } : { kind: leaf.kind, hash: leaf.hash, mode: leaf.mode };
|
|
878
|
+
} else {
|
|
879
|
+
const head = path.slice(0, i);
|
|
880
|
+
let m = subdirs.get(head);
|
|
881
|
+
if (!m) {
|
|
882
|
+
m = new Map;
|
|
883
|
+
subdirs.set(head, m);
|
|
884
|
+
}
|
|
885
|
+
m.set(path.slice(i + 1), leaf);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
for (const [name, sub] of subdirs) {
|
|
889
|
+
const child = entries[name];
|
|
890
|
+
const childRoot = child && child.kind === "tree" ? child.hash : await emptyRoot2(store);
|
|
891
|
+
const built = await applyBatch(store, childRoot, sub);
|
|
892
|
+
const builtTree = await getTree(store, built);
|
|
893
|
+
if (Object.keys(builtTree.entries).length === 0)
|
|
894
|
+
delete entries[name];
|
|
895
|
+
else
|
|
896
|
+
entries[name] = { kind: "tree", hash: built };
|
|
897
|
+
}
|
|
898
|
+
return store.put({ kind: "tree", entries });
|
|
899
|
+
}
|
|
900
|
+
async function deleteFile2(store, root, path) {
|
|
901
|
+
return deleteInto2(store, root, segments2(path));
|
|
902
|
+
}
|
|
903
|
+
async function deleteInto2(store, treeHash, segs) {
|
|
904
|
+
const tree = await getTree(store, treeHash);
|
|
905
|
+
const entries = { ...tree.entries };
|
|
906
|
+
const [head, ...rest] = segs;
|
|
907
|
+
if (!entries[head])
|
|
908
|
+
return treeHash;
|
|
909
|
+
if (rest.length === 0) {
|
|
910
|
+
delete entries[head];
|
|
911
|
+
} else if (entries[head].kind === "tree") {
|
|
912
|
+
entries[head] = { kind: "tree", hash: await deleteInto2(store, entries[head].hash, rest) };
|
|
913
|
+
}
|
|
914
|
+
return store.put({ kind: "tree", entries });
|
|
915
|
+
}
|
|
916
|
+
async function nodeAt(store, root, path) {
|
|
917
|
+
const segs = segments2(path);
|
|
918
|
+
let cur = root;
|
|
919
|
+
for (let i = 0;i < segs.length; i++) {
|
|
920
|
+
const entry = (await getTree(store, cur)).entries[segs[i]];
|
|
921
|
+
if (!entry)
|
|
922
|
+
return;
|
|
923
|
+
if (i === segs.length - 1) {
|
|
924
|
+
if (entry.kind === "tree")
|
|
925
|
+
return;
|
|
926
|
+
const node = await store.get(entry.hash);
|
|
927
|
+
if (!node)
|
|
928
|
+
return;
|
|
929
|
+
if (node.kind === "blob")
|
|
930
|
+
return { kind: "blob", content: node.content };
|
|
931
|
+
if (node.kind === "sealed")
|
|
932
|
+
return { kind: "sealed", box: node.box };
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (entry.kind !== "tree")
|
|
936
|
+
return;
|
|
937
|
+
cur = entry.hash;
|
|
938
|
+
}
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
async function fileAt2(store, root, path) {
|
|
942
|
+
const segs = segments2(path);
|
|
943
|
+
let cur = root;
|
|
944
|
+
for (let i = 0;i < segs.length; i++) {
|
|
945
|
+
const entry = (await getTree(store, cur)).entries[segs[i]];
|
|
946
|
+
if (!entry)
|
|
947
|
+
return;
|
|
948
|
+
if (i === segs.length - 1) {
|
|
949
|
+
if (entry.kind !== "blob")
|
|
950
|
+
return;
|
|
951
|
+
const node = await store.get(entry.hash);
|
|
952
|
+
return node && node.kind === "blob" ? node : undefined;
|
|
953
|
+
}
|
|
954
|
+
if (entry.kind !== "tree")
|
|
955
|
+
return;
|
|
956
|
+
cur = entry.hash;
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
async function modeAt2(store, root, path) {
|
|
961
|
+
const segs = segments2(path);
|
|
962
|
+
let cur = root;
|
|
963
|
+
for (let i = 0;i < segs.length; i++) {
|
|
964
|
+
const entry = (await getTree(store, cur)).entries[segs[i]];
|
|
965
|
+
if (!entry)
|
|
966
|
+
return;
|
|
967
|
+
if (i === segs.length - 1)
|
|
968
|
+
return entry.mode;
|
|
969
|
+
if (entry.kind !== "tree")
|
|
970
|
+
return;
|
|
971
|
+
cur = entry.hash;
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
async function listAll2(store, root, prefix = "") {
|
|
976
|
+
const tree = await getTree(store, root);
|
|
977
|
+
const out = [];
|
|
978
|
+
for (const [name, entry] of Object.entries(tree.entries)) {
|
|
979
|
+
const p = prefix ? `${prefix}/${name}` : name;
|
|
980
|
+
if (entry.kind === "tree")
|
|
981
|
+
out.push(...await listAll2(store, entry.hash, p));
|
|
982
|
+
else
|
|
983
|
+
out.push(p);
|
|
984
|
+
}
|
|
985
|
+
return out.sort();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// src/async-repo.ts
|
|
989
|
+
function pageOps(ops, opts = {}) {
|
|
990
|
+
let out = opts.from !== undefined ? ops.filter((o) => o.seq >= opts.from) : ops.slice();
|
|
991
|
+
if (opts.limit !== undefined)
|
|
992
|
+
out = out.slice(0, opts.limit);
|
|
993
|
+
return out;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
class MemoryOpLog {
|
|
997
|
+
entries = [];
|
|
998
|
+
_head;
|
|
999
|
+
_logTip;
|
|
1000
|
+
async head() {
|
|
1001
|
+
return this._head;
|
|
1002
|
+
}
|
|
1003
|
+
async seq() {
|
|
1004
|
+
return this.entries.length ? this.entries[this.entries.length - 1].seq : 0;
|
|
1005
|
+
}
|
|
1006
|
+
async logTip() {
|
|
1007
|
+
return this._logTip;
|
|
1008
|
+
}
|
|
1009
|
+
async append(entry) {
|
|
1010
|
+
const chained = chainOp(this._logTip, entry);
|
|
1011
|
+
this.entries.push(chained);
|
|
1012
|
+
this._head = chained.rootAfter;
|
|
1013
|
+
this._logTip = chained.entryHash;
|
|
1014
|
+
}
|
|
1015
|
+
async history(opts) {
|
|
1016
|
+
return pageOps(this.entries, opts);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
class AsyncRepo {
|
|
1021
|
+
store;
|
|
1022
|
+
log;
|
|
1023
|
+
actor;
|
|
1024
|
+
constructor(store, log, actor = "anon") {
|
|
1025
|
+
this.store = store;
|
|
1026
|
+
this.log = log;
|
|
1027
|
+
this.actor = actor;
|
|
1028
|
+
}
|
|
1029
|
+
async currentRoot() {
|
|
1030
|
+
const head = await this.log.head();
|
|
1031
|
+
if (head === undefined) {
|
|
1032
|
+
const seq = await this.log.seq();
|
|
1033
|
+
if (seq > 0)
|
|
1034
|
+
throw new CorruptRepoError(`op-log has ${seq} op(s) but no head pointer — head is lost`);
|
|
1035
|
+
return emptyRoot2(this.store);
|
|
1036
|
+
}
|
|
1037
|
+
if (!await this.store.has(head)) {
|
|
1038
|
+
throw new CorruptRepoError(`head ${head} references a node missing from the store — dangling head`);
|
|
1039
|
+
}
|
|
1040
|
+
return head;
|
|
1041
|
+
}
|
|
1042
|
+
async writeFile(path, content, at = Date.now(), by = this.actor) {
|
|
1043
|
+
const rootAfter = await writeFile2(this.store, await this.currentRoot(), path, content);
|
|
1044
|
+
await this.log.append({ seq: await this.log.seq() + 1, type: "write", path, rootAfter, at, by });
|
|
1045
|
+
return rootAfter;
|
|
1046
|
+
}
|
|
1047
|
+
async applyBatch(changes) {
|
|
1048
|
+
const root = await this.currentRoot();
|
|
1049
|
+
const leaves = new Map;
|
|
1050
|
+
for (const c of changes) {
|
|
1051
|
+
if ("delete" in c) {
|
|
1052
|
+
leaves.set(c.path, null);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const blob = c.encoding ? { kind: "blob", content: c.content, encoding: c.encoding } : { kind: "blob", content: c.content };
|
|
1056
|
+
const hash = await this.store.put(blob);
|
|
1057
|
+
leaves.set(c.path, { kind: "blob", hash, mode: c.mode });
|
|
1058
|
+
}
|
|
1059
|
+
return { root: await applyBatch(this.store, root, leaves), changed: changes.length };
|
|
1060
|
+
}
|
|
1061
|
+
async writeBytes(path, data, at = Date.now(), by = this.actor) {
|
|
1062
|
+
const rootAfter = await writeFile2(this.store, await this.currentRoot(), path, Buffer.from(data).toString("base64"), { encoding: "base64" });
|
|
1063
|
+
await this.log.append({ seq: await this.log.seq() + 1, type: "write", path, rootAfter, at, by });
|
|
1064
|
+
return rootAfter;
|
|
1065
|
+
}
|
|
1066
|
+
async readBytes(path) {
|
|
1067
|
+
const leaf = await this.read(path);
|
|
1068
|
+
if (!leaf)
|
|
1069
|
+
return;
|
|
1070
|
+
if (leaf.kind === "sealed")
|
|
1071
|
+
return SEALED;
|
|
1072
|
+
const f = await fileAt2(this.store, await this.currentRoot(), path);
|
|
1073
|
+
if (!f)
|
|
1074
|
+
return;
|
|
1075
|
+
return new Uint8Array(Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8"));
|
|
1076
|
+
}
|
|
1077
|
+
async chmod(path, mode, at = Date.now()) {
|
|
1078
|
+
const f = await fileAt2(this.store, await this.currentRoot(), path);
|
|
1079
|
+
if (!f)
|
|
1080
|
+
return this.currentRoot();
|
|
1081
|
+
const rootAfter = await writeFile2(this.store, await this.currentRoot(), path, f.content, { encoding: f.encoding, mode });
|
|
1082
|
+
await this.log.append({ seq: await this.log.seq() + 1, type: "write", path, rootAfter, at, by: this.actor });
|
|
1083
|
+
return rootAfter;
|
|
1084
|
+
}
|
|
1085
|
+
async mode(path) {
|
|
1086
|
+
return modeAt2(this.store, await this.currentRoot(), path);
|
|
1087
|
+
}
|
|
1088
|
+
async writeSealed(path, box, at = Date.now()) {
|
|
1089
|
+
const rootAfter = await writeSealed(this.store, await this.currentRoot(), path, box);
|
|
1090
|
+
await this.log.append({ seq: await this.log.seq() + 1, type: "seal", path, rootAfter, at, by: this.actor });
|
|
1091
|
+
return rootAfter;
|
|
1092
|
+
}
|
|
1093
|
+
async read(path) {
|
|
1094
|
+
return nodeAt(this.store, await this.currentRoot(), path);
|
|
1095
|
+
}
|
|
1096
|
+
async deleteFile(path, at = Date.now(), by = this.actor) {
|
|
1097
|
+
const rootAfter = await deleteFile2(this.store, await this.currentRoot(), path);
|
|
1098
|
+
await this.log.append({ seq: await this.log.seq() + 1, type: "delete", path, rootAfter, at, by });
|
|
1099
|
+
return rootAfter;
|
|
1100
|
+
}
|
|
1101
|
+
async readFile(path) {
|
|
1102
|
+
const leaf = await this.read(path);
|
|
1103
|
+
if (!leaf)
|
|
1104
|
+
return;
|
|
1105
|
+
return leaf.kind === "sealed" ? SEALED : leaf.content;
|
|
1106
|
+
}
|
|
1107
|
+
async list() {
|
|
1108
|
+
return listAll2(this.store, await this.currentRoot());
|
|
1109
|
+
}
|
|
1110
|
+
async head() {
|
|
1111
|
+
return this.currentRoot();
|
|
1112
|
+
}
|
|
1113
|
+
async history(opts) {
|
|
1114
|
+
return this.log.history(opts);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
// src/workspace.ts
|
|
1118
|
+
function safePath(path) {
|
|
1119
|
+
const p = path.replace(/^\.[\\/]+/, "").trim();
|
|
1120
|
+
if (!p)
|
|
1121
|
+
throw new Error("path: empty");
|
|
1122
|
+
if (p.startsWith("/") || p.startsWith("\\") || /^[a-zA-Z]:[\\/]/.test(p))
|
|
1123
|
+
throw new Error(`path: absolute paths are not allowed: ${path}`);
|
|
1124
|
+
if (p.split(/[\\/]/).some((s) => s === ".."))
|
|
1125
|
+
throw new Error(`path: '..' escapes the workspace: ${path}`);
|
|
1126
|
+
return p;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
class SolWorkspace {
|
|
1130
|
+
store;
|
|
1131
|
+
log;
|
|
1132
|
+
actor;
|
|
1133
|
+
repo;
|
|
1134
|
+
constructor(store, log, actor = "agent") {
|
|
1135
|
+
this.store = store;
|
|
1136
|
+
this.log = log;
|
|
1137
|
+
this.actor = actor;
|
|
1138
|
+
this.repo = new AsyncRepo(store, log, actor);
|
|
1139
|
+
}
|
|
1140
|
+
async write(path, content) {
|
|
1141
|
+
return this.repo.writeFile(safePath(path), content);
|
|
1142
|
+
}
|
|
1143
|
+
async writeBytes(path, bytes) {
|
|
1144
|
+
return this.repo.writeBytes(safePath(path), bytes);
|
|
1145
|
+
}
|
|
1146
|
+
async read(path) {
|
|
1147
|
+
const c = await this.repo.readFile(path);
|
|
1148
|
+
return c === undefined || c === SEALED ? undefined : c;
|
|
1149
|
+
}
|
|
1150
|
+
async readBytes(path) {
|
|
1151
|
+
const b = await this.repo.readBytes(path);
|
|
1152
|
+
return b === undefined || b === SEALED ? undefined : b;
|
|
1153
|
+
}
|
|
1154
|
+
async edit(path, oldStr, newStr, opts = {}) {
|
|
1155
|
+
path = safePath(path);
|
|
1156
|
+
const cur = await this.repo.readFile(path);
|
|
1157
|
+
if (cur === undefined)
|
|
1158
|
+
throw new Error(`edit: no such file: ${path}`);
|
|
1159
|
+
if (cur === SEALED)
|
|
1160
|
+
throw new Error(`edit: cannot text-edit a sealed file: ${path}`);
|
|
1161
|
+
const occurrences = cur.split(oldStr).length - 1;
|
|
1162
|
+
if (occurrences === 0)
|
|
1163
|
+
throw new Error(`edit: text not found in ${path}`);
|
|
1164
|
+
if (occurrences > 1 && !opts.all)
|
|
1165
|
+
throw new Error(`edit: text appears ${occurrences}x in ${path} — add surrounding context or pass all:true`);
|
|
1166
|
+
await this.repo.writeFile(path, opts.all ? cur.split(oldStr).join(newStr) : cur.replace(oldStr, newStr));
|
|
1167
|
+
}
|
|
1168
|
+
async ls(prefix) {
|
|
1169
|
+
const all = await this.repo.list();
|
|
1170
|
+
if (!prefix)
|
|
1171
|
+
return all;
|
|
1172
|
+
const dir = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
1173
|
+
return all.filter((p) => p === prefix || p.startsWith(dir));
|
|
1174
|
+
}
|
|
1175
|
+
async exists(path) {
|
|
1176
|
+
return (await this.repo.list()).includes(path);
|
|
1177
|
+
}
|
|
1178
|
+
async isSealed(path) {
|
|
1179
|
+
return await this.repo.readFile(path) === SEALED;
|
|
1180
|
+
}
|
|
1181
|
+
async move(from, to) {
|
|
1182
|
+
const c = await this.repo.readFile(from);
|
|
1183
|
+
if (c === undefined)
|
|
1184
|
+
throw new Error(`move: no such file: ${from}`);
|
|
1185
|
+
if (c === SEALED)
|
|
1186
|
+
throw new Error(`move: cannot move a sealed file via the text API: ${from}`);
|
|
1187
|
+
await this.repo.writeFile(safePath(to), c);
|
|
1188
|
+
await this.repo.deleteFile(from);
|
|
1189
|
+
}
|
|
1190
|
+
remove(path) {
|
|
1191
|
+
return this.repo.deleteFile(path);
|
|
1192
|
+
}
|
|
1193
|
+
async commit(message) {
|
|
1194
|
+
const head = await this.repo.head();
|
|
1195
|
+
const hist = await this.log.history();
|
|
1196
|
+
let parent;
|
|
1197
|
+
for (let i = hist.length - 1;i >= 0; i--) {
|
|
1198
|
+
if (hist[i].type === "checkpoint") {
|
|
1199
|
+
parent = hist[i].rootAfter;
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
const entry = { seq: await this.log.seq() + 1, type: "checkpoint", path: "", rootAfter: head, at: Date.now(), by: this.actor, message, ...parent && parent !== head ? { parent } : {} };
|
|
1204
|
+
await this.log.append(entry);
|
|
1205
|
+
return head;
|
|
1206
|
+
}
|
|
1207
|
+
checkpoint(message) {
|
|
1208
|
+
return this.commit(message);
|
|
1209
|
+
}
|
|
1210
|
+
head() {
|
|
1211
|
+
return this.repo.head();
|
|
1212
|
+
}
|
|
1213
|
+
history(opts) {
|
|
1214
|
+
return this.repo.history(opts);
|
|
1215
|
+
}
|
|
1216
|
+
get raw() {
|
|
1217
|
+
return this.repo;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// src/sealed-client.ts
|
|
1221
|
+
class SealedClient {
|
|
1222
|
+
repo;
|
|
1223
|
+
ring;
|
|
1224
|
+
constructor(repo, ring) {
|
|
1225
|
+
this.repo = repo;
|
|
1226
|
+
this.ring = ring;
|
|
1227
|
+
}
|
|
1228
|
+
async write(path, content) {
|
|
1229
|
+
return this.repo.writeFile(path, content);
|
|
1230
|
+
}
|
|
1231
|
+
async seal(path, content, recipients) {
|
|
1232
|
+
const box = sealContent(this.ring, content, recipients);
|
|
1233
|
+
return this.repo.writeSealed(path, JSON.stringify(box));
|
|
1234
|
+
}
|
|
1235
|
+
async open(path, actor) {
|
|
1236
|
+
const leaf = await this.repo.read(path);
|
|
1237
|
+
if (!leaf)
|
|
1238
|
+
return;
|
|
1239
|
+
if (leaf.kind === "blob")
|
|
1240
|
+
return leaf.content;
|
|
1241
|
+
const box = JSON.parse(leaf.box);
|
|
1242
|
+
return openContent(this.ring, box, actor);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
// src/sync.ts
|
|
1246
|
+
async function missingFrom(source, target, root, seen, out) {
|
|
1247
|
+
if (seen.has(root) || await target.has(root))
|
|
1248
|
+
return;
|
|
1249
|
+
seen.add(root);
|
|
1250
|
+
const node = await source.get(root);
|
|
1251
|
+
if (!node)
|
|
1252
|
+
return;
|
|
1253
|
+
out.push(root);
|
|
1254
|
+
if (node.kind === "tree") {
|
|
1255
|
+
for (const e of Object.values(node.entries))
|
|
1256
|
+
await missingFrom(source, target, e.hash, seen, out);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function pull(target, source) {
|
|
1260
|
+
const sourceOps = await source.log.history();
|
|
1261
|
+
const targetOps = await target.log.history();
|
|
1262
|
+
const srcBySeq = new Map(sourceOps.map((o) => [o.seq, o]));
|
|
1263
|
+
const maxTargetSeq = targetOps.reduce((m, o) => Math.max(m, o.seq), 0);
|
|
1264
|
+
for (const t of targetOps) {
|
|
1265
|
+
const s = srcBySeq.get(t.seq);
|
|
1266
|
+
if (s && !(s.rootAfter === t.rootAfter && s.path === t.path && s.type === t.type)) {
|
|
1267
|
+
throw new Error("diverged: target has ops the source does not — use merge(), not pull()");
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const newOps = sourceOps.filter((o) => o.seq > maxTargetSeq).sort((a, b) => a.seq - b.seq);
|
|
1271
|
+
const seen = new Set;
|
|
1272
|
+
let nodes = 0;
|
|
1273
|
+
for (const op of newOps) {
|
|
1274
|
+
const out = [];
|
|
1275
|
+
await missingFrom(source.store, target.store, op.rootAfter, seen, out);
|
|
1276
|
+
for (const h of out) {
|
|
1277
|
+
const node = await source.store.get(h);
|
|
1278
|
+
if (node) {
|
|
1279
|
+
await target.store.put(node);
|
|
1280
|
+
nodes++;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
await target.log.append(op);
|
|
1284
|
+
}
|
|
1285
|
+
return { ops: newOps.length, nodes, head: await target.log.head() };
|
|
1286
|
+
}
|
|
1287
|
+
async function clone(source, target) {
|
|
1288
|
+
return pull(target, source);
|
|
1289
|
+
}
|
|
1290
|
+
async function reachable(store, root, into) {
|
|
1291
|
+
if (into.has(root))
|
|
1292
|
+
return;
|
|
1293
|
+
const node = await store.get(root);
|
|
1294
|
+
if (!node)
|
|
1295
|
+
return;
|
|
1296
|
+
into.add(root);
|
|
1297
|
+
if (node.kind === "tree")
|
|
1298
|
+
for (const e of Object.values(node.entries))
|
|
1299
|
+
await reachable(store, e.hash, into);
|
|
1300
|
+
}
|
|
1301
|
+
async function deltaNodes(store, root, have) {
|
|
1302
|
+
const out = [];
|
|
1303
|
+
const seen = new Set;
|
|
1304
|
+
const stack = [root];
|
|
1305
|
+
while (stack.length) {
|
|
1306
|
+
const h = stack.pop();
|
|
1307
|
+
if (have.has(h) || seen.has(h))
|
|
1308
|
+
continue;
|
|
1309
|
+
seen.add(h);
|
|
1310
|
+
const node = await store.get(h);
|
|
1311
|
+
if (!node)
|
|
1312
|
+
continue;
|
|
1313
|
+
out.push(h);
|
|
1314
|
+
if (node.kind === "tree")
|
|
1315
|
+
for (const e of Object.values(node.entries))
|
|
1316
|
+
stack.push(e.hash);
|
|
1317
|
+
}
|
|
1318
|
+
return out;
|
|
1319
|
+
}
|
|
1320
|
+
async function push(remote, local, opts = {}) {
|
|
1321
|
+
const { head: rHead, seq: rSeq } = await remote.headSeq();
|
|
1322
|
+
const localHead = await local.log.head();
|
|
1323
|
+
if (!localHead || localHead === rHead)
|
|
1324
|
+
return { ops: 0, nodes: 0, bytes: 0, head: rHead };
|
|
1325
|
+
if (rHead) {
|
|
1326
|
+
const localOps = await local.log.history();
|
|
1327
|
+
if (!localOps.some((o) => o.rootAfter === rHead)) {
|
|
1328
|
+
throw new Error("non-fast-forward: remote has moved — pull/merge first");
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const have = new Set;
|
|
1332
|
+
if (rHead)
|
|
1333
|
+
await reachable(local.store, rHead, have);
|
|
1334
|
+
const toSend = await deltaNodes(local.store, localHead, have);
|
|
1335
|
+
const nodes = [];
|
|
1336
|
+
let bytes = 0;
|
|
1337
|
+
for (const h of toSend) {
|
|
1338
|
+
const node = await local.store.get(h);
|
|
1339
|
+
if (node) {
|
|
1340
|
+
nodes.push(node);
|
|
1341
|
+
bytes += JSON.stringify(node).length;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const op = { seq: rSeq + 1, type: "write", path: "", rootAfter: localHead, at: opts.at ?? Date.now(), message: opts.message };
|
|
1345
|
+
const res = await remote.applyBatch(nodes, [op]);
|
|
1346
|
+
return { ops: 1, nodes: nodes.length, bytes, head: res.head };
|
|
1347
|
+
}
|
|
1348
|
+
function localTarget(r) {
|
|
1349
|
+
return {
|
|
1350
|
+
async headSeq() {
|
|
1351
|
+
return { head: await r.log.head(), seq: await r.log.seq() };
|
|
1352
|
+
},
|
|
1353
|
+
async applyBatch(nodes, ops) {
|
|
1354
|
+
for (const n of nodes)
|
|
1355
|
+
await r.store.put(n);
|
|
1356
|
+
for (const op of ops)
|
|
1357
|
+
await r.log.append(op);
|
|
1358
|
+
return { head: await r.log.head(), applied: ops.length };
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
// src/diff.ts
|
|
1363
|
+
function diffTrees(store, aHead, bHead) {
|
|
1364
|
+
const aPaths = new Set(listAll(store, aHead));
|
|
1365
|
+
const bPaths = new Set(listAll(store, bHead));
|
|
1366
|
+
const added = [];
|
|
1367
|
+
const removed = [];
|
|
1368
|
+
const modified = [];
|
|
1369
|
+
for (const p of bPaths)
|
|
1370
|
+
if (!aPaths.has(p))
|
|
1371
|
+
added.push(p);
|
|
1372
|
+
for (const p of aPaths)
|
|
1373
|
+
if (!bPaths.has(p))
|
|
1374
|
+
removed.push(p);
|
|
1375
|
+
for (const p of aPaths) {
|
|
1376
|
+
if (!bPaths.has(p))
|
|
1377
|
+
continue;
|
|
1378
|
+
const a = fileAt(store, aHead, p);
|
|
1379
|
+
const b = fileAt(store, bHead, p);
|
|
1380
|
+
if (!a || !b)
|
|
1381
|
+
continue;
|
|
1382
|
+
if (a.content === b.content && a.encoding === b.encoding)
|
|
1383
|
+
continue;
|
|
1384
|
+
const binary = a.encoding === "base64" || b.encoding === "base64";
|
|
1385
|
+
const hunks2 = binary ? "" : lineHunks(a.content, b.content);
|
|
1386
|
+
modified.push({ path: p, hunks: hunks2 });
|
|
1387
|
+
}
|
|
1388
|
+
added.sort();
|
|
1389
|
+
removed.sort();
|
|
1390
|
+
modified.sort((x, y) => x.path < y.path ? -1 : x.path > y.path ? 1 : 0);
|
|
1391
|
+
return { added, removed, modified };
|
|
1392
|
+
}
|
|
1393
|
+
function diffFile(store, aHead, bHead, path) {
|
|
1394
|
+
const a = readFile(store, aHead, path);
|
|
1395
|
+
const b = readFile(store, bHead, path);
|
|
1396
|
+
if (a === undefined || b === undefined || a === b)
|
|
1397
|
+
return "";
|
|
1398
|
+
return lineHunks(a, b);
|
|
1399
|
+
}
|
|
1400
|
+
function splitLines(s) {
|
|
1401
|
+
return s.split(`
|
|
1402
|
+
`);
|
|
1403
|
+
}
|
|
1404
|
+
function lcsSteps(a, b) {
|
|
1405
|
+
const n = a.length;
|
|
1406
|
+
const m = b.length;
|
|
1407
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
1408
|
+
for (let i2 = n - 1;i2 >= 0; i2--) {
|
|
1409
|
+
for (let j2 = m - 1;j2 >= 0; j2--) {
|
|
1410
|
+
dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
const steps = [];
|
|
1414
|
+
let i = 0;
|
|
1415
|
+
let j = 0;
|
|
1416
|
+
while (i < n && j < m) {
|
|
1417
|
+
if (a[i] === b[j]) {
|
|
1418
|
+
steps.push({ tag: " ", line: a[i] });
|
|
1419
|
+
i++;
|
|
1420
|
+
j++;
|
|
1421
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
1422
|
+
steps.push({ tag: "-", line: a[i] });
|
|
1423
|
+
i++;
|
|
1424
|
+
} else {
|
|
1425
|
+
steps.push({ tag: "+", line: b[j] });
|
|
1426
|
+
j++;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
while (i < n)
|
|
1430
|
+
steps.push({ tag: "-", line: a[i++] });
|
|
1431
|
+
while (j < m)
|
|
1432
|
+
steps.push({ tag: "+", line: b[j++] });
|
|
1433
|
+
return steps;
|
|
1434
|
+
}
|
|
1435
|
+
function lineHunks(aText, bText) {
|
|
1436
|
+
const steps = lcsSteps(splitLines(aText), splitLines(bText));
|
|
1437
|
+
const body = steps.map((s) => `${s.tag}${s.line}`).join(`
|
|
1438
|
+
`);
|
|
1439
|
+
return `@@ -1 +1 @@
|
|
1440
|
+
${body}`;
|
|
1441
|
+
}
|
|
1442
|
+
// src/blame.ts
|
|
1443
|
+
function blame(repo, view, path) {
|
|
1444
|
+
const writes = view.fullHistory().filter((op) => op.type === "write" && op.path === path);
|
|
1445
|
+
let prev = [];
|
|
1446
|
+
for (const op of writes) {
|
|
1447
|
+
const content = contentAt(repo, path, op.rootAfter);
|
|
1448
|
+
if (content === undefined) {
|
|
1449
|
+
prev = [];
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
const nextLines = splitLines2(content);
|
|
1453
|
+
prev = attribute(prev, nextLines, op.by, op.seq);
|
|
1454
|
+
}
|
|
1455
|
+
return prev.map((t) => ({ line: t.line, by: t.by, seq: t.seq }));
|
|
1456
|
+
}
|
|
1457
|
+
function contentAt(repo, path, root) {
|
|
1458
|
+
const c = repo.view("blame", root).readFile(path);
|
|
1459
|
+
if (c === undefined || c === SEALED)
|
|
1460
|
+
return;
|
|
1461
|
+
return c;
|
|
1462
|
+
}
|
|
1463
|
+
function attribute(old, next, by, seq) {
|
|
1464
|
+
const oldText = old.map((t) => t.line);
|
|
1465
|
+
const keep = lcsMatch(oldText, next);
|
|
1466
|
+
const out = [];
|
|
1467
|
+
for (let j = 0;j < next.length; j++) {
|
|
1468
|
+
const oi = keep[j];
|
|
1469
|
+
if (oi >= 0)
|
|
1470
|
+
out.push({ line: next[j], by: old[oi].by, seq: old[oi].seq });
|
|
1471
|
+
else
|
|
1472
|
+
out.push({ line: next[j], by, seq });
|
|
1473
|
+
}
|
|
1474
|
+
return out;
|
|
1475
|
+
}
|
|
1476
|
+
function lcsMatch(a, b) {
|
|
1477
|
+
const n = a.length;
|
|
1478
|
+
const m = b.length;
|
|
1479
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
1480
|
+
for (let i2 = n - 1;i2 >= 0; i2--) {
|
|
1481
|
+
for (let j2 = m - 1;j2 >= 0; j2--) {
|
|
1482
|
+
dp[i2][j2] = a[i2] === b[j2] ? dp[i2 + 1][j2 + 1] + 1 : Math.max(dp[i2 + 1][j2], dp[i2][j2 + 1]);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
const match = new Array(m).fill(-1);
|
|
1486
|
+
let i = 0;
|
|
1487
|
+
let j = 0;
|
|
1488
|
+
while (i < n && j < m) {
|
|
1489
|
+
if (a[i] === b[j]) {
|
|
1490
|
+
match[j] = i;
|
|
1491
|
+
i++;
|
|
1492
|
+
j++;
|
|
1493
|
+
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
|
1494
|
+
i++;
|
|
1495
|
+
} else {
|
|
1496
|
+
j++;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return match;
|
|
1500
|
+
}
|
|
1501
|
+
function splitLines2(content) {
|
|
1502
|
+
if (content === "")
|
|
1503
|
+
return [];
|
|
1504
|
+
const parts = content.split(`
|
|
1505
|
+
`);
|
|
1506
|
+
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
1507
|
+
parts.pop();
|
|
1508
|
+
return parts;
|
|
1509
|
+
}
|
|
1510
|
+
// src/gc.ts
|
|
1511
|
+
function reachableFrom(store, roots) {
|
|
1512
|
+
const live = new Set;
|
|
1513
|
+
const stack = [...roots];
|
|
1514
|
+
while (stack.length > 0) {
|
|
1515
|
+
const h = stack.pop();
|
|
1516
|
+
if (live.has(h))
|
|
1517
|
+
continue;
|
|
1518
|
+
const node = store.get(h);
|
|
1519
|
+
if (!node)
|
|
1520
|
+
continue;
|
|
1521
|
+
live.add(h);
|
|
1522
|
+
if (node.kind === "tree") {
|
|
1523
|
+
for (const entry of Object.values(node.entries)) {
|
|
1524
|
+
stack.push(entry.hash);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
return live;
|
|
1529
|
+
}
|
|
1530
|
+
function garbage(store, roots, allHashes) {
|
|
1531
|
+
const live = reachableFrom(store, roots);
|
|
1532
|
+
return allHashes.filter((h) => !live.has(h));
|
|
1533
|
+
}
|
|
1534
|
+
function compact(store, roots) {
|
|
1535
|
+
const live = reachableFrom(store, roots);
|
|
1536
|
+
const out = new Store;
|
|
1537
|
+
for (const h of live) {
|
|
1538
|
+
const node = store.get(h);
|
|
1539
|
+
if (node)
|
|
1540
|
+
out.put(node);
|
|
1541
|
+
}
|
|
1542
|
+
return out;
|
|
1543
|
+
}
|
|
1544
|
+
// src/publish.ts
|
|
1545
|
+
async function copyReachable(src, dst, root, seen) {
|
|
1546
|
+
if (seen.has(root))
|
|
1547
|
+
return;
|
|
1548
|
+
seen.add(root);
|
|
1549
|
+
const node = await src.get(root);
|
|
1550
|
+
if (!node)
|
|
1551
|
+
return;
|
|
1552
|
+
await dst.put(node);
|
|
1553
|
+
if (node.kind === "tree")
|
|
1554
|
+
for (const e of Object.values(node.entries))
|
|
1555
|
+
await copyReachable(src, dst, e.hash, seen);
|
|
1556
|
+
}
|
|
1557
|
+
async function overlayPath(store, base, local, localHead, path) {
|
|
1558
|
+
const leaf = await nodeAt(local.store, localHead, path);
|
|
1559
|
+
if (!leaf)
|
|
1560
|
+
return deleteFile2(store, base, path);
|
|
1561
|
+
if (leaf.kind === "sealed")
|
|
1562
|
+
return writeSealed(store, base, path, leaf.box);
|
|
1563
|
+
return writeFile2(store, base, path, leaf.content);
|
|
1564
|
+
}
|
|
1565
|
+
async function publishPaths(remote, local, paths) {
|
|
1566
|
+
const { head: rHead, seq: rSeq } = await remote.headSeq();
|
|
1567
|
+
const localHead = await local.log.head();
|
|
1568
|
+
if (!localHead)
|
|
1569
|
+
return { ops: 0, nodes: 0, bytes: 0, head: rHead };
|
|
1570
|
+
const store = new MemoryAsyncStore;
|
|
1571
|
+
const seen = new Set;
|
|
1572
|
+
await copyReachable(local.store, store, localHead, seen);
|
|
1573
|
+
if (rHead)
|
|
1574
|
+
await copyReachable(local.store, store, rHead, seen);
|
|
1575
|
+
const base = rHead && await store.has(rHead) ? rHead : await emptyRoot2(store);
|
|
1576
|
+
let root = base;
|
|
1577
|
+
for (const path of paths)
|
|
1578
|
+
root = await overlayPath(store, root, local, localHead, path);
|
|
1579
|
+
const log = new MemoryOpLog;
|
|
1580
|
+
if (rHead)
|
|
1581
|
+
await log.append({ seq: rSeq, type: "write", path: "", rootAfter: rHead, at: 0 });
|
|
1582
|
+
await log.append({ seq: rSeq + 1, type: "write", path: "", rootAfter: root, at: Date.now() });
|
|
1583
|
+
return push(remote, { store, log });
|
|
1584
|
+
}
|
|
1585
|
+
// src/sparse.ts
|
|
1586
|
+
function underPrefix2(path, prefix) {
|
|
1587
|
+
const p = prefix.replace(/\/+$/, "");
|
|
1588
|
+
if (!p)
|
|
1589
|
+
return true;
|
|
1590
|
+
return path === p || path.startsWith(`${p}/`);
|
|
1591
|
+
}
|
|
1592
|
+
async function leafAt(store, root, path) {
|
|
1593
|
+
const segs = path.split("/").filter(Boolean);
|
|
1594
|
+
let cur = root;
|
|
1595
|
+
for (let i = 0;i < segs.length; i++) {
|
|
1596
|
+
const entry = (await getTree(store, cur)).entries[segs[i]];
|
|
1597
|
+
if (!entry)
|
|
1598
|
+
return;
|
|
1599
|
+
if (i === segs.length - 1) {
|
|
1600
|
+
if (entry.kind === "tree")
|
|
1601
|
+
return;
|
|
1602
|
+
return { hash: entry.hash, kind: entry.kind };
|
|
1603
|
+
}
|
|
1604
|
+
if (entry.kind !== "tree")
|
|
1605
|
+
return;
|
|
1606
|
+
cur = entry.hash;
|
|
1607
|
+
}
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
async function writeLeafInto(store, treeHash, segs, leafHash, leafKind) {
|
|
1611
|
+
const tree = await getTree(store, treeHash);
|
|
1612
|
+
const entries = { ...tree.entries };
|
|
1613
|
+
const [head, ...rest] = segs;
|
|
1614
|
+
if (rest.length === 0) {
|
|
1615
|
+
entries[head] = { kind: leafKind, hash: leafHash };
|
|
1616
|
+
} else {
|
|
1617
|
+
const child = entries[head];
|
|
1618
|
+
const childHash = child?.kind === "tree" ? child.hash : await emptyRoot2(store);
|
|
1619
|
+
entries[head] = { kind: "tree", hash: await writeLeafInto(store, childHash, rest, leafHash, leafKind) };
|
|
1620
|
+
}
|
|
1621
|
+
return store.put({ kind: "tree", entries });
|
|
1622
|
+
}
|
|
1623
|
+
async function pullSubtree(target, source, prefix) {
|
|
1624
|
+
const sourceHead = await source.log.head();
|
|
1625
|
+
if (!sourceHead)
|
|
1626
|
+
return { paths: [], nodes: 0 };
|
|
1627
|
+
const all = await listAll2(source.store, sourceHead);
|
|
1628
|
+
const paths = all.filter((p) => underPrefix2(p, prefix)).sort();
|
|
1629
|
+
if (paths.length === 0)
|
|
1630
|
+
return { paths: [], nodes: 0 };
|
|
1631
|
+
let root = await target.log.head() ?? await emptyRoot2(target.store);
|
|
1632
|
+
let nodes = 0;
|
|
1633
|
+
for (const path of paths) {
|
|
1634
|
+
const leaf = await leafAt(source.store, sourceHead, path);
|
|
1635
|
+
if (!leaf)
|
|
1636
|
+
continue;
|
|
1637
|
+
if (!await target.store.has(leaf.hash)) {
|
|
1638
|
+
const node = await source.store.get(leaf.hash);
|
|
1639
|
+
if (node) {
|
|
1640
|
+
await target.store.put(node);
|
|
1641
|
+
nodes++;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
root = await writeLeafInto(target.store, root, path.split("/").filter(Boolean), leaf.hash, leaf.kind);
|
|
1645
|
+
}
|
|
1646
|
+
const op = {
|
|
1647
|
+
seq: await target.log.seq() + 1,
|
|
1648
|
+
type: "write",
|
|
1649
|
+
path: prefix.replace(/\/+$/, ""),
|
|
1650
|
+
rootAfter: root,
|
|
1651
|
+
at: Date.now(),
|
|
1652
|
+
message: `sparse: ${prefix}`
|
|
1653
|
+
};
|
|
1654
|
+
await target.log.append(op);
|
|
1655
|
+
return { paths, nodes };
|
|
1656
|
+
}
|
|
1657
|
+
// src/branch-acl.ts
|
|
1658
|
+
class RefAcls {
|
|
1659
|
+
policies = new Map;
|
|
1660
|
+
setVisibility(branch, who) {
|
|
1661
|
+
if (who === "public") {
|
|
1662
|
+
this.policies.delete(branch);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
this.policies.set(branch, new Set(who));
|
|
1666
|
+
}
|
|
1667
|
+
canSee(branch, actor) {
|
|
1668
|
+
const policy = this.policies.get(branch);
|
|
1669
|
+
if (!policy || policy === "public")
|
|
1670
|
+
return true;
|
|
1671
|
+
return policy.has(actor);
|
|
1672
|
+
}
|
|
1673
|
+
visibleBranches(all, actor) {
|
|
1674
|
+
return all.filter((branch) => this.canSee(branch, actor));
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
// src/channel.ts
|
|
1678
|
+
class Channel {
|
|
1679
|
+
listeners = new Set;
|
|
1680
|
+
subscribe(fn) {
|
|
1681
|
+
this.listeners.add(fn);
|
|
1682
|
+
return () => {
|
|
1683
|
+
this.listeners.delete(fn);
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
publish(op) {
|
|
1687
|
+
for (const fn of [...this.listeners]) {
|
|
1688
|
+
try {
|
|
1689
|
+
fn(op);
|
|
1690
|
+
} catch {}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
// src/semantic-merge.ts
|
|
1695
|
+
async function semanticMerge(repo, base, ours, theirs, checker) {
|
|
1696
|
+
const m = merge(repo, base, ours, theirs);
|
|
1697
|
+
if (m.conflicts.length > 0)
|
|
1698
|
+
return { head: m.head, lineConflicts: m.conflicts, clean: false };
|
|
1699
|
+
if (checker) {
|
|
1700
|
+
const r = await checker.run({ store: repo.store, head: m.head });
|
|
1701
|
+
if (r.verdict === "fail") {
|
|
1702
|
+
return { head: m.head, lineConflicts: [], semanticConflict: { issuer: checker.id, findings: r.findings ?? [] }, clean: false };
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
return { head: m.head, lineConflicts: [], clean: true };
|
|
1706
|
+
}
|
|
1707
|
+
var duplicateExportCheck = {
|
|
1708
|
+
id: "no-duplicate-exports",
|
|
1709
|
+
run({ store, head }) {
|
|
1710
|
+
const findings = [];
|
|
1711
|
+
for (const path of listAll(store, head)) {
|
|
1712
|
+
const content = readFile(store, head, path);
|
|
1713
|
+
if (typeof content !== "string")
|
|
1714
|
+
continue;
|
|
1715
|
+
const seen = new Map;
|
|
1716
|
+
content.split(`
|
|
1717
|
+
`).forEach((line, i) => {
|
|
1718
|
+
const match = line.match(/export\s+(?:const|let|var|function|class)\s+([A-Za-z0-9_$]+)/);
|
|
1719
|
+
if (!match)
|
|
1720
|
+
return;
|
|
1721
|
+
const name = match[1];
|
|
1722
|
+
if (seen.has(name))
|
|
1723
|
+
findings.push({ path, line: i + 1, message: `duplicate export '${name}' (also at line ${seen.get(name)})`, severity: "block" });
|
|
1724
|
+
else
|
|
1725
|
+
seen.set(name, i + 1);
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
return findings.length === 0 ? { verdict: "pass", summary: "no duplicate exports" } : { verdict: "fail", summary: `${findings.length} duplicate export(s)`, findings };
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
// src/change.ts
|
|
1733
|
+
var nextId = 1;
|
|
1734
|
+
|
|
1735
|
+
class Change {
|
|
1736
|
+
repo;
|
|
1737
|
+
title;
|
|
1738
|
+
author;
|
|
1739
|
+
baseHead;
|
|
1740
|
+
sourceHead;
|
|
1741
|
+
targetHead;
|
|
1742
|
+
message;
|
|
1743
|
+
ctx;
|
|
1744
|
+
id;
|
|
1745
|
+
status = "open";
|
|
1746
|
+
atts = [];
|
|
1747
|
+
constructor(repo, title, author, baseHead, sourceHead, targetHead, message, ctx) {
|
|
1748
|
+
this.repo = repo;
|
|
1749
|
+
this.title = title;
|
|
1750
|
+
this.author = author;
|
|
1751
|
+
this.baseHead = baseHead;
|
|
1752
|
+
this.sourceHead = sourceHead;
|
|
1753
|
+
this.targetHead = targetHead;
|
|
1754
|
+
this.message = message;
|
|
1755
|
+
this.ctx = ctx;
|
|
1756
|
+
this.id = "chg_" + nextId++;
|
|
1757
|
+
}
|
|
1758
|
+
get scope() {
|
|
1759
|
+
return this.ctx?.scope ?? "change:" + this.id;
|
|
1760
|
+
}
|
|
1761
|
+
attest(a) {
|
|
1762
|
+
const full = { ...a, at: a.at ?? Date.now() };
|
|
1763
|
+
this.atts.push(full);
|
|
1764
|
+
this.ctx?.ledger?.record(this.scope, full);
|
|
1765
|
+
this.ctx?.events?.publish({ type: "attestation.added", changeId: this.id, actor: full.issuer, at: full.at, data: full });
|
|
1766
|
+
return this;
|
|
1767
|
+
}
|
|
1768
|
+
attestations() {
|
|
1769
|
+
return this.atts;
|
|
1770
|
+
}
|
|
1771
|
+
evidence() {
|
|
1772
|
+
const diff = diffTrees(this.repo.store, this.targetHead, this.sourceHead);
|
|
1773
|
+
const checks = { pass: 0, fail: 0, pending: 0 };
|
|
1774
|
+
let approvals = 0;
|
|
1775
|
+
let blocking = 0;
|
|
1776
|
+
for (const a of this.atts) {
|
|
1777
|
+
if (a.kind === "check")
|
|
1778
|
+
checks[a.verdict === "pass" ? "pass" : a.verdict === "fail" ? "fail" : "pending"] += 1;
|
|
1779
|
+
if (a.kind === "approval" && a.verdict === "pass")
|
|
1780
|
+
approvals += 1;
|
|
1781
|
+
blocking += (a.findings ?? []).filter((f) => f.severity === "block").length;
|
|
1782
|
+
}
|
|
1783
|
+
return {
|
|
1784
|
+
changeId: this.id,
|
|
1785
|
+
title: this.title,
|
|
1786
|
+
author: this.author,
|
|
1787
|
+
status: this.status,
|
|
1788
|
+
diff,
|
|
1789
|
+
attestations: [...this.atts],
|
|
1790
|
+
summary: {
|
|
1791
|
+
files: { added: diff.added.length, removed: diff.removed.length, modified: diff.modified.length },
|
|
1792
|
+
checks,
|
|
1793
|
+
reviews: { approvals, blocking }
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
gate(policy = {}) {
|
|
1798
|
+
const ev = this.evidence();
|
|
1799
|
+
const reasons = [];
|
|
1800
|
+
if (ev.summary.checks.fail > 0)
|
|
1801
|
+
reasons.push(`${ev.summary.checks.fail} check(s) failing`);
|
|
1802
|
+
if (ev.summary.checks.pending > 0)
|
|
1803
|
+
reasons.push(`${ev.summary.checks.pending} check(s) pending`);
|
|
1804
|
+
for (const req of policy.requiredChecks ?? []) {
|
|
1805
|
+
const a = this.atts.find((x) => x.kind === "check" && (x.issuer === req || x.summary.toLowerCase().includes(req.toLowerCase())));
|
|
1806
|
+
if (!a || a.verdict !== "pass")
|
|
1807
|
+
reasons.push(`required check "${req}" not passing`);
|
|
1808
|
+
}
|
|
1809
|
+
if (policy.requiredApprovals && ev.summary.reviews.approvals < policy.requiredApprovals) {
|
|
1810
|
+
reasons.push(`needs ${policy.requiredApprovals} approval(s), has ${ev.summary.reviews.approvals}`);
|
|
1811
|
+
}
|
|
1812
|
+
if (policy.blockOnFindings && ev.summary.reviews.blocking > 0) {
|
|
1813
|
+
reasons.push(`${ev.summary.reviews.blocking} blocking finding(s)`);
|
|
1814
|
+
}
|
|
1815
|
+
const passed = reasons.length === 0;
|
|
1816
|
+
this.status = passed ? "approved" : "gated";
|
|
1817
|
+
this.ctx?.events?.publish({ type: passed ? "change.approved" : "change.gated", changeId: this.id, at: Date.now(), data: { passed, reasons } });
|
|
1818
|
+
return { passed, reasons };
|
|
1819
|
+
}
|
|
1820
|
+
promote(policy = {}, by) {
|
|
1821
|
+
if (this.ctx?.caps && by && !this.ctx.caps.can(by, this.scope, "promote")) {
|
|
1822
|
+
return { promoted: false, reason: `${by} lacks 'promote' capability on ${this.scope}` };
|
|
1823
|
+
}
|
|
1824
|
+
const g = this.gate(policy);
|
|
1825
|
+
if (!g.passed)
|
|
1826
|
+
return { promoted: false, reason: g.reasons.join("; ") };
|
|
1827
|
+
const m = merge(this.repo, this.baseHead, this.targetHead, this.sourceHead);
|
|
1828
|
+
if (m.conflicts.length > 0)
|
|
1829
|
+
return { promoted: false, conflicts: m.conflicts.length, reason: "merge conflicts" };
|
|
1830
|
+
this.targetHead = m.head;
|
|
1831
|
+
this.status = "promoted";
|
|
1832
|
+
this.ctx?.events?.publish({ type: "change.promoted", changeId: this.id, actor: by, at: Date.now(), data: { head: m.head } });
|
|
1833
|
+
return { promoted: true, newTargetHead: m.head, conflicts: 0 };
|
|
1834
|
+
}
|
|
1835
|
+
async promoteChecked(policy = {}, by, checker) {
|
|
1836
|
+
if (this.ctx?.caps && by && !this.ctx.caps.can(by, this.scope, "promote")) {
|
|
1837
|
+
return { promoted: false, reason: `${by} lacks 'promote' capability on ${this.scope}` };
|
|
1838
|
+
}
|
|
1839
|
+
const g = this.gate(policy);
|
|
1840
|
+
if (!g.passed)
|
|
1841
|
+
return { promoted: false, reason: g.reasons.join("; ") };
|
|
1842
|
+
const sm = await semanticMerge(this.repo, this.baseHead, this.targetHead, this.sourceHead, checker);
|
|
1843
|
+
if (sm.lineConflicts.length)
|
|
1844
|
+
return { promoted: false, conflicts: sm.lineConflicts.length, reason: "merge conflicts" };
|
|
1845
|
+
if (sm.semanticConflict)
|
|
1846
|
+
return { promoted: false, reason: `semantic conflict: ${sm.semanticConflict.findings[0]?.message ?? "structural break"}` };
|
|
1847
|
+
this.targetHead = sm.head;
|
|
1848
|
+
this.status = "promoted";
|
|
1849
|
+
this.ctx?.events?.publish({ type: "change.promoted", changeId: this.id, actor: by, at: Date.now(), data: { head: sm.head } });
|
|
1850
|
+
return { promoted: true, newTargetHead: sm.head, conflicts: 0 };
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
function openChange(repo, opts, ctx) {
|
|
1854
|
+
const c = new Change(repo, opts.title, opts.author, opts.baseHead, opts.sourceHead, opts.targetHead, opts.message, ctx);
|
|
1855
|
+
ctx?.events?.publish({ type: "change.opened", changeId: c.id, actor: c.author, at: Date.now(), data: c });
|
|
1856
|
+
return c;
|
|
1857
|
+
}
|
|
1858
|
+
// src/events.ts
|
|
1859
|
+
class EventBus {
|
|
1860
|
+
subs = new Set;
|
|
1861
|
+
subscribe(fn) {
|
|
1862
|
+
this.subs.add(fn);
|
|
1863
|
+
return () => {
|
|
1864
|
+
this.subs.delete(fn);
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
publish(e) {
|
|
1868
|
+
for (const fn of [...this.subs]) {
|
|
1869
|
+
try {
|
|
1870
|
+
fn(e);
|
|
1871
|
+
} catch {}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
// src/capability.ts
|
|
1876
|
+
function covers2(grant, want) {
|
|
1877
|
+
return grant === "*" || grant === want || want.startsWith(grant + "/");
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
class Capabilities {
|
|
1881
|
+
grants = [];
|
|
1882
|
+
grant(holder, scope, actions) {
|
|
1883
|
+
this.grants.push({ holder, scope, actions: new Set(actions) });
|
|
1884
|
+
return this;
|
|
1885
|
+
}
|
|
1886
|
+
can(holder, scope, action) {
|
|
1887
|
+
return this.grants.some((g) => g.holder === holder && covers2(g.scope, scope) && g.actions.has(action));
|
|
1888
|
+
}
|
|
1889
|
+
revoke(holder, scope) {
|
|
1890
|
+
this.grants = this.grants.filter((g) => !(g.holder === holder && g.scope === scope));
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
// src/attestation.ts
|
|
1894
|
+
import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual3 } from "node:crypto";
|
|
1895
|
+
function canonicalAttestation(a) {
|
|
1896
|
+
return JSON.stringify({
|
|
1897
|
+
kind: a.kind,
|
|
1898
|
+
issuer: a.issuer,
|
|
1899
|
+
verdict: a.verdict,
|
|
1900
|
+
summary: a.summary,
|
|
1901
|
+
findings: (a.findings ?? []).map((f) => ({ path: f.path, line: f.line ?? null, message: f.message, severity: f.severity ?? null })),
|
|
1902
|
+
at: a.at
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
function signAttestation(key, a) {
|
|
1906
|
+
return createHmac2("sha256", key).update(canonicalAttestation(a)).digest("base64");
|
|
1907
|
+
}
|
|
1908
|
+
function verifyAttestation(key, a, sig = a.signature) {
|
|
1909
|
+
if (!sig)
|
|
1910
|
+
return false;
|
|
1911
|
+
const expected = Buffer.from(signAttestation(key, a));
|
|
1912
|
+
const got = Buffer.from(sig);
|
|
1913
|
+
return expected.length === got.length && timingSafeEqual3(expected, got);
|
|
1914
|
+
}
|
|
1915
|
+
function attestSigned(change, key, a) {
|
|
1916
|
+
const full = { ...a, at: a.at ?? Date.now() };
|
|
1917
|
+
full.signature = signAttestation(key, full);
|
|
1918
|
+
change.attest(full);
|
|
1919
|
+
return full;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
class Ledger {
|
|
1923
|
+
bySubject = new Map;
|
|
1924
|
+
record(subject, a) {
|
|
1925
|
+
const list = this.bySubject.get(subject) ?? [];
|
|
1926
|
+
list.push(a);
|
|
1927
|
+
this.bySubject.set(subject, list);
|
|
1928
|
+
}
|
|
1929
|
+
provenance(subject) {
|
|
1930
|
+
return [...this.bySubject.get(subject) ?? []];
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
// src/check.ts
|
|
1934
|
+
class CheckCache {
|
|
1935
|
+
cache = new Map;
|
|
1936
|
+
_hits = 0;
|
|
1937
|
+
_misses = 0;
|
|
1938
|
+
async run(runner, req) {
|
|
1939
|
+
const key = runner.id + "@" + req.head;
|
|
1940
|
+
const hit = this.cache.get(key);
|
|
1941
|
+
if (hit) {
|
|
1942
|
+
this._hits += 1;
|
|
1943
|
+
return { result: hit, cached: true };
|
|
1944
|
+
}
|
|
1945
|
+
this._misses += 1;
|
|
1946
|
+
const result = await runner.run(req);
|
|
1947
|
+
this.cache.set(key, result);
|
|
1948
|
+
return { result, cached: false };
|
|
1949
|
+
}
|
|
1950
|
+
stats() {
|
|
1951
|
+
return { hits: this._hits, misses: this._misses };
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
function toAttestation(runner, r) {
|
|
1955
|
+
return { kind: "check", issuer: runner.id, verdict: r.verdict, summary: r.summary, findings: r.findings };
|
|
1956
|
+
}
|
|
1957
|
+
export {
|
|
1958
|
+
writeFile,
|
|
1959
|
+
wrapCekTo,
|
|
1960
|
+
verifyOp,
|
|
1961
|
+
verifyAttestation,
|
|
1962
|
+
unwrapCekWith,
|
|
1963
|
+
toAttestation,
|
|
1964
|
+
signOp,
|
|
1965
|
+
signAttestation,
|
|
1966
|
+
semanticMerge,
|
|
1967
|
+
sealWithRecovery,
|
|
1968
|
+
sealContent,
|
|
1969
|
+
sameActor,
|
|
1970
|
+
safePath,
|
|
1971
|
+
runCommand,
|
|
1972
|
+
rotate,
|
|
1973
|
+
recoveryKeyPair,
|
|
1974
|
+
readFile,
|
|
1975
|
+
reachableFrom,
|
|
1976
|
+
push,
|
|
1977
|
+
pullSubtree,
|
|
1978
|
+
pull,
|
|
1979
|
+
publishPaths,
|
|
1980
|
+
pageOps,
|
|
1981
|
+
openWithRecovery,
|
|
1982
|
+
openContent,
|
|
1983
|
+
openChange,
|
|
1984
|
+
modeAt,
|
|
1985
|
+
merge3,
|
|
1986
|
+
merge,
|
|
1987
|
+
localTarget,
|
|
1988
|
+
listAll,
|
|
1989
|
+
lineHunks,
|
|
1990
|
+
isRecipient,
|
|
1991
|
+
importFiles,
|
|
1992
|
+
hashString,
|
|
1993
|
+
hashNode,
|
|
1994
|
+
groupIntoUnits,
|
|
1995
|
+
getTree,
|
|
1996
|
+
garbage,
|
|
1997
|
+
formatLog,
|
|
1998
|
+
filterLog,
|
|
1999
|
+
fileAt,
|
|
2000
|
+
exportFiles,
|
|
2001
|
+
entryKindAt,
|
|
2002
|
+
emptyRoot,
|
|
2003
|
+
duplicateExportCheck,
|
|
2004
|
+
diffTrees,
|
|
2005
|
+
diffFile,
|
|
2006
|
+
deleteFile,
|
|
2007
|
+
compact,
|
|
2008
|
+
clone,
|
|
2009
|
+
ciphertextOf,
|
|
2010
|
+
canonicalOp,
|
|
2011
|
+
canonicalAttestation,
|
|
2012
|
+
blobHashAt,
|
|
2013
|
+
blame,
|
|
2014
|
+
attestSigned,
|
|
2015
|
+
View,
|
|
2016
|
+
UNREADABLE,
|
|
2017
|
+
Store,
|
|
2018
|
+
SolWorkspace,
|
|
2019
|
+
SealedClient,
|
|
2020
|
+
SealRegistry,
|
|
2021
|
+
SEALED,
|
|
2022
|
+
Repo,
|
|
2023
|
+
RefAcls,
|
|
2024
|
+
MemoryOpLog,
|
|
2025
|
+
MemoryAsyncStore,
|
|
2026
|
+
Ledger,
|
|
2027
|
+
Labels,
|
|
2028
|
+
KeyRing,
|
|
2029
|
+
EventBus,
|
|
2030
|
+
CheckCache,
|
|
2031
|
+
Channel,
|
|
2032
|
+
Change,
|
|
2033
|
+
Capabilities,
|
|
2034
|
+
AsyncRepo
|
|
2035
|
+
};
|