dsh-claude-design 2.4.1 → 2.6.0

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.
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // claude-design: local editor server (prebuilt; do not edit).
3
3
 
4
- import fs5 from "node:fs";
5
- import path4 from "node:path";
4
+ import fs8 from "node:fs";
5
+ import path7 from "node:path";
6
6
 
7
- import { createHash, randomBytes, randomUUID } from "node:crypto";
8
- import fs4 from "node:fs";
7
+ import { createHash as createHash3, randomBytes as randomBytes2, randomUUID } from "node:crypto";
8
+ import fs7 from "node:fs";
9
9
  import fsp2 from "node:fs/promises";
10
10
  import http from "node:http";
11
- import path3 from "node:path";
11
+ import path6 from "node:path";
12
12
  import { fileURLToPath, pathToFileURL } from "node:url";
13
13
 
14
14
  import { spawn } from "node:child_process";
@@ -18,6 +18,9 @@ import os from "node:os";
18
18
  var COMMENTS_FILE = "comments.json";
19
19
  var COMMENTS_VERSION = 1;
20
20
  var LIMITS = { text: 1e4, label: 1e3, detail: 4e3, anchor: 2048, section: 200, name: 100 };
21
+ function sameAuthor(a, b) {
22
+ return a.kind === "user" && b.kind === "user" && (a.principal ?? "") === (b.principal ?? "");
23
+ }
21
24
  var CommentsError = class extends Error {
22
25
  code;
23
26
  status;
@@ -88,7 +91,10 @@ function parseDoc(text) {
88
91
  const threads = data.threads.filter(
89
92
  (t) => isRecord(t) && typeof t.id === "string" && typeof t.anchor === "string" && typeof t.label === "string" && (t.status === "open" || t.status === "resolved") && Array.isArray(t.messages) && t.messages.every((m) => isRecord(m) && typeof m.text === "string" && isRecord(m.author) && (m.author.kind === "user" || m.author.kind === "agent"))
90
93
  );
91
- for (const t of threads) if (!isRecord(t.location)) t.location = {};
94
+ for (const t of threads) {
95
+ if (!isRecord(t.location)) t.location = {};
96
+ for (const m of t.messages) if (m.author.principal !== void 0 && typeof m.author.principal !== "string") delete m.author.principal;
97
+ }
92
98
  return { version: COMMENTS_VERSION, threads };
93
99
  }
94
100
  var formatDoc = (doc) => `${JSON.stringify(doc, null, 2)}
@@ -127,10 +133,10 @@ function addMessage(thread, author, text, now) {
127
133
  }
128
134
  return message;
129
135
  }
130
- function editMessage(thread, messageId, text, now) {
136
+ function editMessage(thread, messageId, text, now, by) {
131
137
  const message = thread.messages.find((m) => m.id === messageId);
132
138
  if (!message) throw new CommentsError("MESSAGE_NOT_FOUND", `No message ${messageId} in ${thread.id}.`, 404);
133
- if (message.author.kind !== "user") throw new CommentsError("MESSAGE_NOT_EDITABLE", "Only your own messages can be edited.", 403);
139
+ if (message.author.kind !== "user" || by && !sameAuthor(message.author, by)) throw new CommentsError("MESSAGE_NOT_EDITABLE", "Only your own messages can be edited.", 403);
134
140
  if (message.text !== text) {
135
141
  message.text = text;
136
142
  message.editedAt = now;
@@ -275,6 +281,7 @@ async function updateComments(root, change) {
275
281
  var AGENT_GRACE_MS = 6e4;
276
282
  var AGENT_BUSY_MS = 30 * 6e4;
277
283
  var REQUEST_TTL_MS = 30 * 6e4;
284
+ var REQUESTS_KEPT = 32;
278
285
  var WAIT_DEFAULT_S = 300;
279
286
  var WAIT_MAX_S = 3600;
280
287
  var PRESENCE_CHECK_MS = 5e3;
@@ -332,12 +339,14 @@ function defaultUserName() {
332
339
  function createCommentsService(options) {
333
340
  const { root, log, agentHook } = options;
334
341
  const userName = (options.userName ?? defaultUserName()).slice(0, 100);
335
- const user = { kind: "user", name: userName };
342
+ const owner = { author: { kind: "user", name: userName }, canComment: true, canSend: true, admin: true };
343
+ const viewerOf = options.viewer ?? (() => owner);
336
344
  const allowWrite = options.allowWrite ?? (() => true);
337
- const clients = /* @__PURE__ */ new Set();
345
+ const clients = /* @__PURE__ */ new Map();
338
346
  const waiters = [];
339
347
  const hookRunning = /* @__PURE__ */ new Set();
340
348
  const requests = [];
349
+ const requestFates = /* @__PURE__ */ new Map();
341
350
  let lastDoc = emptyDoc();
342
351
  let lastWaitEnded = 0;
343
352
  let lastSent = "";
@@ -350,13 +359,23 @@ function createCommentsService(options) {
350
359
  const listening = waiters.length > 0;
351
360
  const busy = lastDoc.threads.some(
352
361
  (t) => t.status === "open" && t.agent?.status === "delivered" && at - Date.parse(t.agent.deliveredAt ?? t.agent.sentAt) < AGENT_BUSY_MS
353
- );
362
+ ) || [...requestFates.values()].some((f) => f.state === "working" && at - f.at < AGENT_BUSY_MS);
354
363
  const hook = Boolean(agentHook);
355
364
  return { available: listening || busy || hook || at - lastWaitEnded < AGENT_GRACE_MS, listening, busy, hook };
356
365
  }
357
- async function snapshot() {
366
+ const meOf = (viewer) => ({
367
+ name: viewer.author.name,
368
+ ...viewer.author.principal ? { principal: viewer.author.principal } : {},
369
+ canComment: viewer.canComment,
370
+ canSend: viewer.canSend,
371
+ admin: viewer.admin
372
+ });
373
+ async function shared() {
358
374
  lastDoc = await readComments(root);
359
- return { threads: lastDoc.threads, agent: presence(), me: { name: userName } };
375
+ return { threads: lastDoc.threads, agent: presence() };
376
+ }
377
+ async function snapshot(viewer) {
378
+ return { ...await shared(), me: meOf(viewer) };
360
379
  }
361
380
  const frame = (snap) => `event: comments
362
381
  data: ${JSON.stringify(snap)}
@@ -365,18 +384,18 @@ data: ${JSON.stringify(snap)}
365
384
  function broadcast() {
366
385
  queue = queue.then(async () => {
367
386
  if (closed) return;
368
- let snap;
387
+ let base;
369
388
  try {
370
- snap = await snapshot();
389
+ base = await shared();
371
390
  } catch (error) {
372
391
  log(`comments: reading ${COMMENTS_FILE} failed: ${error.message}`);
373
392
  return;
374
393
  }
375
- const text = frame(snap);
376
- lastPresence = JSON.stringify(snap.agent);
394
+ const text = JSON.stringify(base);
395
+ lastPresence = JSON.stringify(base.agent);
377
396
  if (text === lastSent) return;
378
397
  lastSent = text;
379
- for (const client of clients) client.write(text);
398
+ for (const [client, viewer] of clients) client.write(frame({ ...base, me: meOf(viewer) }));
380
399
  });
381
400
  return queue;
382
401
  }
@@ -389,10 +408,16 @@ data: ${JSON.stringify(snap)}
389
408
  if (!waiter.response.writableEnded) sendJson(waiter.response, 200, body);
390
409
  lastWaitEnded = Date.now();
391
410
  }
411
+ function setRequestState(id, state) {
412
+ requestFates.delete(id);
413
+ requestFates.set(id, { state, at: Date.now() });
414
+ while (requestFates.size > REQUESTS_KEPT) requestFates.delete(requestFates.keys().next().value);
415
+ }
392
416
  function dropStaleRequests() {
393
417
  const oldest = Date.now() - REQUEST_TTL_MS;
394
418
  while (requests.length > 0 && Date.parse(requests[0].sentAt) < oldest) {
395
419
  const dropped = requests.shift();
420
+ setRequestState(dropped.id, "over");
396
421
  log(`comments: dropped request ${dropped.id}: no agent took it in ${REQUEST_TTL_MS / 6e4} minutes`);
397
422
  }
398
423
  }
@@ -419,6 +444,7 @@ data: ${JSON.stringify(snap)}
419
444
  return;
420
445
  }
421
446
  finishWaiter(waiter, { threads: views, ...handed.length > 0 ? { requests: handed } : {} });
447
+ for (const request of handed) setRequestState(request.id, "working");
422
448
  log(`comments: sent to the agent (wait): ${[...views, ...handed].map((v) => v.id).join(", ")}`);
423
449
  await broadcast();
424
450
  return;
@@ -466,6 +492,7 @@ data: ${JSON.stringify(snap)}
466
492
  }
467
493
  for (const request of requests.splice(0)) {
468
494
  hookRunning.add(request.id);
495
+ setRequestState(request.id, "working");
469
496
  const child = spawn(command, {
470
497
  shell: true,
471
498
  cwd: root,
@@ -486,6 +513,7 @@ data: ${JSON.stringify(snap)}
486
513
  settled = true;
487
514
  hookRunning.delete(request.id);
488
515
  requests.unshift(request);
516
+ setRequestState(request.id, "queued");
489
517
  log(`comments: the agent hook ${why} for ${request.id}; the request stays waiting${stderr ? `: ${stderr.trim()}` : ""}`);
490
518
  };
491
519
  child.on("error", (error) => failed(`failed to start (${error.message})`));
@@ -532,31 +560,46 @@ data: ${JSON.stringify(snap)}
532
560
  await broadcast();
533
561
  if (sent) await deliver();
534
562
  }
535
- async function create(request, response) {
563
+ function need(allowed, message) {
564
+ if (!allowed) throw new CommentsError("NOT_ALLOWED", message, 403);
565
+ }
566
+ const ONLY_OWNER_SENDS = "Only the owner can send comments to the agent.";
567
+ async function create(request, response, viewer) {
536
568
  const input = readNewThread(await readJson(request));
537
569
  if (!input) throw new CommentsError("INVALID_COMMENT", "expected {anchor, label, text} with a non-empty text");
570
+ need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
538
571
  let location = {};
539
572
  try {
540
573
  location = await options.locate?.(input) ?? {};
541
574
  } catch (error) {
542
575
  log(`comments: could not resolve where a comment points: ${error.message}`);
543
576
  }
544
- const created = await updateComments(root, (doc) => addThread(doc, input, location, user, now()));
577
+ const created = await updateComments(root, (doc) => addThread(doc, input, location, viewer.author, now()));
545
578
  log(`comments: ${created.id} added on ${created.label}${location.file ? ` (${location.file})` : ""}${input.send ? ", sent to the agent" : ""}`);
546
579
  sendJson(response, 201, { thread: created });
547
580
  await afterChange(Boolean(input.send));
548
581
  }
549
- async function ask(request, response) {
550
- const input = readAgentRequest(await readJson(request));
551
- if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
582
+ function enqueue(input) {
552
583
  if (!presence().available) throw new CommentsError("AGENT_UNAVAILABLE", "no agent is waiting: copy the request for the chat instead", 409);
553
584
  const queued = { id: newId("r"), ...input, sentAt: now() };
554
585
  requests.push(queued);
586
+ setRequestState(queued.id, "queued");
555
587
  log(`comments: request ${queued.id}${input.label ? ` (${input.label})` : ""} for the agent`);
588
+ return queued;
589
+ }
590
+ async function ask(request, response) {
591
+ const input = readAgentRequest(await readJson(request));
592
+ if (!input) throw new CommentsError("INVALID_REQUEST", "expected {text} with a non-empty text");
593
+ const queued = enqueue(input);
556
594
  sendJson(response, 202, { request: queued });
557
595
  await deliver();
558
596
  }
559
- async function events(request, response) {
597
+ async function sendRequest(input) {
598
+ const queued = enqueue(input);
599
+ await deliver();
600
+ return queued;
601
+ }
602
+ async function events(request, response, viewer) {
560
603
  response.writeHead(200, {
561
604
  "Content-Type": "text/event-stream; charset=utf-8",
562
605
  "Cache-Control": "no-store",
@@ -564,8 +607,8 @@ data: ${JSON.stringify(snap)}
564
607
  "X-Accel-Buffering": "no"
565
608
  });
566
609
  response.write(": connected\n\n");
567
- response.write(frame(await snapshot()));
568
- clients.add(response);
610
+ response.write(frame(await snapshot(viewer)));
611
+ clients.set(response, viewer);
569
612
  const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS);
570
613
  keepAlive.unref?.();
571
614
  response.on("close", () => {
@@ -578,6 +621,7 @@ data: ${JSON.stringify(snap)}
578
621
  const seconds = Number.isFinite(asked) && asked > 0 ? Math.min(asked, WAIT_MAX_S) : WAIT_DEFAULT_S;
579
622
  const waiter = { response, done: false };
580
623
  waiters.push(waiter);
624
+ for (const [id, fate] of requestFates) if (fate.state === "working") setRequestState(id, "over");
581
625
  waiter.timer = setTimeout(() => {
582
626
  finishWaiter(waiter, { threads: [] });
583
627
  void broadcast();
@@ -601,14 +645,26 @@ data: ${JSON.stringify(snap)}
601
645
  request.resume();
602
646
  throw new CommentsError("NOT_ALLOWED", "cross-origin request", 403);
603
647
  }
648
+ const viewer = viewerOf(request);
649
+ if (!viewer) {
650
+ request.resume();
651
+ throw new CommentsError("NOT_ALLOWED", "not shared with you", 403);
652
+ }
653
+ const commenting = !reading && parts[0] !== "agent" && parts[1] !== "send";
654
+ if (commenting && !viewer.canComment) {
655
+ request.resume();
656
+ throw new CommentsError("NOT_ALLOWED", "You can read the comments here, not add or change them.", 403);
657
+ }
604
658
  if (parts.length === 0) {
605
- if (reading) return sendJson(response, 200, await snapshot());
606
- if (method === "POST") return await create(request, response);
659
+ if (reading) return sendJson(response, 200, await snapshot(viewer));
660
+ if (method === "POST") return await create(request, response, viewer);
607
661
  } else if (parts.length === 1 && parts[0] === "events") {
608
- if (reading) return await events(request, response);
662
+ if (reading) return await events(request, response, viewer);
609
663
  } else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "wait") {
664
+ need(viewer.canSend, "Only the owner's agent waits for comments.");
610
665
  if (reading) return await wait(request, response, url);
611
666
  } else if (parts.length === 2 && parts[0] === "agent" && parts[1] === "requests") {
667
+ need(viewer.canSend, ONLY_OWNER_SENDS);
612
668
  if (method === "POST") return await ask(request, response);
613
669
  } else if (parts.length === 1) {
614
670
  const id = parts[0];
@@ -627,6 +683,8 @@ data: ${JSON.stringify(snap)}
627
683
  if (method === "DELETE") {
628
684
  await updateComments(root, (doc) => {
629
685
  const found = findThread(doc, id);
686
+ const first = found.messages[0];
687
+ need(viewer.admin || first !== void 0 && sameAuthor(first.author, viewer.author), "Only the person who started this comment, or the owner, can delete it.");
630
688
  doc.threads.splice(doc.threads.indexOf(found), 1);
631
689
  });
632
690
  log(`comments: ${id} deleted`);
@@ -638,9 +696,10 @@ data: ${JSON.stringify(snap)}
638
696
  const id = parts[0];
639
697
  const input = readReply(await readJson(request));
640
698
  if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
699
+ need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
641
700
  const t = await updateComments(root, (doc) => {
642
701
  const found = findThread(doc, id);
643
- addMessage(found, user, input.text, now());
702
+ addMessage(found, viewer.author, input.text, now());
644
703
  if (input.send) sendToAgent(found, now());
645
704
  return found;
646
705
  });
@@ -654,9 +713,10 @@ data: ${JSON.stringify(snap)}
654
713
  const messageId = parts[2];
655
714
  const input = readEdit(await readJson(request));
656
715
  if (!input) throw new CommentsError("INVALID_COMMENT", "expected {text} with a non-empty text");
716
+ need(!input.send || viewer.canSend, ONLY_OWNER_SENDS);
657
717
  const t = await updateComments(root, (doc) => {
658
718
  const found = findThread(doc, id);
659
- editMessage(found, messageId, input.text, now());
719
+ editMessage(found, messageId, input.text, now(), viewer.author);
660
720
  if (input.send && canSendToAgent(found)) sendToAgent(found, now());
661
721
  return found;
662
722
  });
@@ -668,6 +728,7 @@ data: ${JSON.stringify(snap)}
668
728
  if (method === "POST") {
669
729
  const id = parts[0];
670
730
  request.resume();
731
+ need(viewer.canSend, ONLY_OWNER_SENDS);
671
732
  const t = await updateComments(root, (doc) => {
672
733
  const found = findThread(doc, id);
673
734
  if (!canSendToAgent(found)) throw new CommentsError("NOTHING_TO_SEND", "the agent already has everything in this comment", 409);
@@ -696,13 +757,20 @@ data: ${JSON.stringify(snap)}
696
757
  clearTimeout(debounce);
697
758
  watcher?.close();
698
759
  for (const waiter of [...waiters]) finishWaiter(waiter, { threads: [], closed: true });
699
- for (const client of clients) client.end();
760
+ for (const client of clients.keys()) client.end();
700
761
  clients.clear();
701
762
  }
702
- return { handle, presence, deliver, close };
763
+ function requestStates() {
764
+ const states = {};
765
+ for (const [id, fate] of requestFates) if (fate.state !== "over") states[id] = fate.state;
766
+ return states;
767
+ }
768
+ return { handle, presence, deliver, sendRequest, requestStates, close };
703
769
  }
704
770
 
705
771
  import fs3 from "node:fs";
772
+ import net from "node:net";
773
+ import os2 from "node:os";
706
774
  import path2 from "node:path";
707
775
  var STATE_FILE = ".artifact-server.json";
708
776
  function readState(root) {
@@ -749,6 +817,78 @@ async function listen(server2, port, host) {
749
817
  const address = server2.address();
750
818
  return typeof address === "object" && address ? address.port : port;
751
819
  }
820
+ var DEFAULT_PORTS = { slides: 4710, design: 4720, "design-system": 4730, dashboard: 4740, docs: 4750, motion: 4760 };
821
+ var PORT_TRIES = 100;
822
+ var PortsTaken = class extends Error {
823
+ code = "PORT_IN_USE";
824
+ from;
825
+ to;
826
+ constructor(from, to) {
827
+ super(`${from === to ? `Port ${from} is` : `Ports ${from} to ${to} are all`} in use; pick another --port (0 takes any free one).`);
828
+ this.from = from;
829
+ this.to = to;
830
+ }
831
+ };
832
+ var isBenchmarkRange = (address) => /^198\.1[89]\./.test(address);
833
+ function lanAddresses() {
834
+ const out = [];
835
+ for (const list of Object.values(os2.networkInterfaces())) {
836
+ for (const entry of list ?? []) {
837
+ if (entry.family !== "IPv4" || entry.internal || entry.address.startsWith("169.254.") || isBenchmarkRange(entry.address)) continue;
838
+ out.push(entry.address);
839
+ }
840
+ }
841
+ return [...new Set(out)];
842
+ }
843
+ function answers(host, port) {
844
+ return new Promise((resolve) => {
845
+ const socket = net.connect({ host, port });
846
+ const done = (taken) => {
847
+ socket.destroy();
848
+ resolve(taken);
849
+ };
850
+ socket.setTimeout(300, () => done(false));
851
+ socket.once("connect", () => done(true));
852
+ socket.once("error", (error) => done(!["ECONNREFUSED", "EADDRNOTAVAIL", "EAFNOSUPPORT", "ENETUNREACH", "EHOSTUNREACH"].includes(error.code ?? "")));
853
+ });
854
+ }
855
+ async function answersAnyPort(address) {
856
+ const server2 = net.createServer();
857
+ let port;
858
+ try {
859
+ port = await new Promise((resolve, reject) => {
860
+ server2.once("error", reject);
861
+ server2.listen(0, address, () => {
862
+ const bound = server2.address();
863
+ resolve(typeof bound === "object" && bound ? bound.port : 0);
864
+ });
865
+ });
866
+ } catch {
867
+ return false;
868
+ } finally {
869
+ await new Promise((resolve) => server2.close(() => resolve()));
870
+ }
871
+ return port > 0 && answers(address, port);
872
+ }
873
+ async function listenFree(server2, from, host, first) {
874
+ if (from === 0) return listen(server2, 0, host);
875
+ const to = Math.min(from + PORT_TRIES - 1, 65535);
876
+ const range = Array.from({ length: to - from + 1 }, (_, i) => from + i);
877
+ const ports = first ? [first, ...range.filter((port) => port !== first)] : range;
878
+ const wildcard = host === "0.0.0.0" || host === "::";
879
+ const lan = lanAddresses().filter((address) => address !== host);
880
+ const answering = await Promise.all(lan.map(answersAnyPort));
881
+ const probes = [.../* @__PURE__ */ new Set([...wildcard ? [] : [host], "127.0.0.1", "::1", ...lan.filter((_, i) => !answering[i])])];
882
+ for (const port of ports) {
883
+ if ((await Promise.all(probes.map((address) => answers(address, port)))).some(Boolean)) continue;
884
+ try {
885
+ return await listen(server2, port, host);
886
+ } catch (error) {
887
+ if (error.code !== "EADDRINUSE") throw error;
888
+ }
889
+ }
890
+ throw new PortsTaken(from, to);
891
+ }
752
892
  function serverOrigin(host, port) {
753
893
  const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
754
894
  return `http://${shown}:${port}`;
@@ -813,6 +953,1453 @@ function sendFile(request, response, file, headers) {
813
953
  }
814
954
  var errorText = (error) => error instanceof Error ? error.message : String(error);
815
955
 
956
+ var MAX_EDIT = 2e3;
957
+ function editScript(a, b) {
958
+ const n = a.length;
959
+ const m = b.length;
960
+ const limit = Math.min(n + m, MAX_EDIT);
961
+ const offset = limit + 1;
962
+ const v = new Int32Array(2 * limit + 3);
963
+ const trace = [];
964
+ let found = -1;
965
+ for (let d = 0; d <= limit; d++) {
966
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
967
+ for (let k = -d; k <= d; k += 2) {
968
+ let x2 = k === -d || k !== d && v[offset + k - 1] < v[offset + k + 1] ? v[offset + k + 1] : v[offset + k - 1] + 1;
969
+ let y2 = x2 - k;
970
+ while (x2 < n && y2 < m && a[x2] === b[y2]) {
971
+ x2++;
972
+ y2++;
973
+ }
974
+ v[offset + k] = x2;
975
+ if (x2 >= n && y2 >= m) {
976
+ found = d;
977
+ break;
978
+ }
979
+ }
980
+ if (found >= 0) break;
981
+ }
982
+ if (found < 0) return null;
983
+ const ops = [];
984
+ let x = n;
985
+ let y = m;
986
+ for (let d = found; d > 0; d--) {
987
+ const prev = trace[d];
988
+ const at = (k2) => prev[k2 + d + 1];
989
+ const k = x - y;
990
+ const prevK = k === -d || k !== d && at(k - 1) < at(k + 1) ? k + 1 : k - 1;
991
+ const prevX = at(prevK);
992
+ const prevY = prevX - prevK;
993
+ while (x > prevX && y > prevY) {
994
+ ops.push("=");
995
+ x--;
996
+ y--;
997
+ }
998
+ ops.push(x === prevX ? "+" : "-");
999
+ x = prevX;
1000
+ y = prevY;
1001
+ }
1002
+ while (x > 0 && y > 0) {
1003
+ ops.push("=");
1004
+ x--;
1005
+ y--;
1006
+ }
1007
+ return ops.reverse();
1008
+ }
1009
+ function hunksOf(base, side, mine) {
1010
+ const ops = editScript(base, side);
1011
+ if (!ops) return null;
1012
+ const out = [];
1013
+ let open = null;
1014
+ let i = 0;
1015
+ let j = 0;
1016
+ for (const op of ops) {
1017
+ if (op === "=") {
1018
+ open = null;
1019
+ i++;
1020
+ j++;
1021
+ continue;
1022
+ }
1023
+ if (!open) out.push(open = { start: i, end: i, lines: [], mine });
1024
+ if (op === "-") open.end = ++i;
1025
+ else open.lines.push(side[j++]);
1026
+ }
1027
+ return out.flatMap(
1028
+ (h) => h.end - h.start > 1 && h.lines.length === h.end - h.start ? h.lines.flatMap((line, k) => {
1029
+ const at = h.start + k;
1030
+ return line === base[at] ? [] : [{ start: at, end: at + 1, lines: [line], mine }];
1031
+ }) : [h]
1032
+ );
1033
+ }
1034
+ function applyHunks(base, start, end, hunks) {
1035
+ const out = [];
1036
+ let at = start;
1037
+ for (const h of hunks) {
1038
+ out.push(...base.slice(at, h.start), ...h.lines);
1039
+ at = h.end;
1040
+ }
1041
+ out.push(...base.slice(at, end));
1042
+ return out;
1043
+ }
1044
+ function mergeText(base, mine, current, at, conflicts) {
1045
+ const lines = base.split("\n");
1046
+ const ours = hunksOf(lines, mine.split("\n"), true);
1047
+ const theirs = ours && hunksOf(lines, current.split("\n"), false);
1048
+ if (!ours || !theirs) return null;
1049
+ const all = [...ours, ...theirs].sort((x, y) => x.start - y.start || x.end - y.end);
1050
+ const out = [];
1051
+ const lost = [];
1052
+ let done = 0;
1053
+ for (let i = 0; i < all.length; ) {
1054
+ const region = [all[i++]];
1055
+ const start = region[0].start;
1056
+ let end = region[0].end;
1057
+ for (; i < all.length; i++) {
1058
+ const h = all[i];
1059
+ if (!(h.start < end || h.start === h.end && start === end && h.start === start)) break;
1060
+ region.push(h);
1061
+ end = Math.max(end, h.end);
1062
+ }
1063
+ out.push(...lines.slice(done, start));
1064
+ const mineLines = applyHunks(lines, start, end, region.filter((h) => h.mine));
1065
+ if (region.every((h) => h.mine === region[0].mine)) out.push(...applyHunks(lines, start, end, region));
1066
+ else {
1067
+ const theirLines = applyHunks(lines, start, end, region.filter((h) => !h.mine));
1068
+ if (mineLines.join("\n") !== theirLines.join("\n")) lost.push(`${at}@${start + 1}`);
1069
+ out.push(...mineLines);
1070
+ }
1071
+ done = end;
1072
+ }
1073
+ out.push(...lines.slice(done));
1074
+ conflicts.push(...lost);
1075
+ return out.join("\n");
1076
+ }
1077
+
1078
+ var utf8 = new TextDecoder("utf-8", { fatal: true });
1079
+ function mergeTextBytes(base, mine, current) {
1080
+ const text = (bytes) => bytes.includes(0) ? null : utf8.decode(bytes);
1081
+ try {
1082
+ const [b, m, c] = [text(base), text(mine), text(current)];
1083
+ if (b === null || m === null || c === null) return null;
1084
+ const merged = mergeText(b, m, c, ".", []);
1085
+ return merged === null ? null : Buffer.from(merged, "utf8");
1086
+ } catch {
1087
+ return null;
1088
+ }
1089
+ }
1090
+
1091
+ import { createHash, randomBytes } from "node:crypto";
1092
+ import fs4 from "node:fs";
1093
+ import path3 from "node:path";
1094
+ var HISTORY_DIR = "history";
1095
+ var INDEX = "index.json";
1096
+ var OBJECTS = "objects";
1097
+ var IDLE_MS = 3e4;
1098
+ var SPAN_MS = 5 * 6e4;
1099
+ var MAX_VERSIONS = 500;
1100
+ var MAX_NOTICES = 50;
1101
+ var NOTICE_DAYS = 7;
1102
+ var AGENT = { by: "agent", user: { id: "agent", name: "Agent" } };
1103
+ var SYSTEM = { by: "system", user: { id: "system", name: "" } };
1104
+ var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
1105
+ var SHA = /^[0-9a-f]{64}$/;
1106
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1107
+ function writeAtomic2(file, data) {
1108
+ fs4.mkdirSync(path3.dirname(file), { recursive: true });
1109
+ const tmp = path3.join(path3.dirname(file), `.${path3.basename(file)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
1110
+ try {
1111
+ fs4.writeFileSync(tmp, data);
1112
+ fs4.renameSync(tmp, file);
1113
+ } catch (error) {
1114
+ fs4.rmSync(tmp, { force: true });
1115
+ throw error;
1116
+ }
1117
+ }
1118
+ var emptyIndex = () => ({ format: 1, next: 1, base: {}, versions: [], notices: [] });
1119
+ function readUser(value) {
1120
+ return isRecord2(value) && typeof value.id === "string" && typeof value.name === "string" ? { id: value.id, name: value.name } : null;
1121
+ }
1122
+ var isAuthor = (value) => value === "human" || value === "agent" || value === "system";
1123
+ function parseIndex(raw) {
1124
+ const index = emptyIndex();
1125
+ if (!isRecord2(raw)) return index;
1126
+ if (isRecord2(raw.base)) {
1127
+ for (const [p, s] of Object.entries(raw.base)) if (typeof s === "string" && SHA.test(s)) index.base[p] = s;
1128
+ }
1129
+ if (Array.isArray(raw.versions)) {
1130
+ for (const v of raw.versions) {
1131
+ if (!isRecord2(v) || typeof v.n !== "number" || !isAuthor(v.by) || typeof v.at !== "string" || !isRecord2(v.changes)) continue;
1132
+ const user = readUser(v.user);
1133
+ if (!user) continue;
1134
+ const changes = {};
1135
+ for (const [p, s] of Object.entries(v.changes)) if (s === null || typeof s === "string" && SHA.test(s)) changes[p] = s;
1136
+ const version = { n: v.n, first: typeof v.first === "string" ? v.first : v.at, at: v.at, by: v.by, user, changes };
1137
+ if (typeof v.role === "string") version.role = v.role;
1138
+ if (typeof v.restoredFrom === "number") version.restoredFrom = v.restoredFrom;
1139
+ if (version.restoredFrom !== void 0 && v.restoredPart === true) version.restoredPart = true;
1140
+ if (Array.isArray(v.overwrote)) {
1141
+ const list = [];
1142
+ for (const o of v.overwrote) {
1143
+ const who = isRecord2(o) ? readUser(o.user) : null;
1144
+ if (!isRecord2(o) || !who || typeof o.path !== "string" || o.lost !== null && typeof o.lost !== "string" || typeof o.n !== "number" || !isAuthor(o.by)) continue;
1145
+ list.push({
1146
+ path: o.path,
1147
+ lost: o.lost,
1148
+ n: o.n,
1149
+ by: o.by,
1150
+ user: who,
1151
+ ...o.soft === true ? { soft: true } : {},
1152
+ ...o.chosen === true ? { chosen: true } : {},
1153
+ ...typeof o.base === "string" && SHA.test(o.base) ? { base: o.base } : {}
1154
+ });
1155
+ }
1156
+ if (list.length) version.overwrote = list;
1157
+ }
1158
+ index.versions.push(version);
1159
+ }
1160
+ }
1161
+ index.versions.sort((a, b) => a.n - b.n);
1162
+ const last = index.versions.at(-1);
1163
+ index.next = Math.max(typeof raw.next === "number" ? raw.next : 1, (last?.n ?? 0) + 1);
1164
+ if (Array.isArray(raw.notices)) {
1165
+ for (const n of raw.notices) {
1166
+ if (!isRecord2(n) || typeof n.id !== "string" || typeof n.to !== "string" || typeof n.path !== "string" || typeof n.n !== "number") continue;
1167
+ if (n.kind !== "overwritten" && n.kind !== "overwrote") continue;
1168
+ const by = readUser(n.by);
1169
+ const lost = isRecord2(n.lost) ? n.lost : null;
1170
+ const lostUser = lost ? readUser(lost.user) : null;
1171
+ if (!by || !lost || !lostUser || lost.sha !== null && typeof lost.sha !== "string" || typeof lost.n !== "number" || !isAuthor(lost.by)) continue;
1172
+ index.notices.push({
1173
+ id: n.id,
1174
+ to: n.to,
1175
+ at: typeof n.at === "string" ? n.at : (/* @__PURE__ */ new Date(0)).toISOString(),
1176
+ kind: n.kind,
1177
+ path: n.path,
1178
+ n: n.n,
1179
+ by,
1180
+ byAuthor: isAuthor(n.byAuthor) ? n.byAuthor : "human",
1181
+ lost: { sha: lost.sha, n: lost.n, by: lost.by, user: lostUser },
1182
+ ...n.soft === true ? { soft: true } : {},
1183
+ ...typeof n.base === "string" && SHA.test(n.base) ? { base: n.base } : {}
1184
+ });
1185
+ }
1186
+ }
1187
+ return index;
1188
+ }
1189
+ var History = class {
1190
+ dir;
1191
+ index;
1192
+ /** path → sha: what the files hold after the newest version. */
1193
+ headState;
1194
+ constructor(root) {
1195
+ this.dir = path3.join(root, HISTORY_DIR);
1196
+ let raw = null;
1197
+ try {
1198
+ raw = JSON.parse(fs4.readFileSync(path3.join(this.dir, INDEX), "utf8"));
1199
+ } catch {
1200
+ }
1201
+ this.index = parseIndex(raw);
1202
+ this.headState = this.fold(Infinity);
1203
+ this.index.notices = this.index.notices.filter((n) => (this.headState.get(n.path) ?? null) !== n.lost.sha);
1204
+ }
1205
+ get empty() {
1206
+ return this.index.versions.length === 0 && Object.keys(this.index.base).length === 0;
1207
+ }
1208
+ /** What the files hold now, as far as the history knows (path → sha). */
1209
+ head() {
1210
+ return this.headState;
1211
+ }
1212
+ versions() {
1213
+ return this.index.versions;
1214
+ }
1215
+ version(n) {
1216
+ return this.index.versions.find((v) => v.n === n);
1217
+ }
1218
+ /** The files after the versions numbered up to `upto` (the base and every version at or below it). */
1219
+ fold(upto) {
1220
+ const state = new Map(Object.entries(this.index.base));
1221
+ for (const version of this.index.versions) {
1222
+ if (version.n > upto) break;
1223
+ for (const [p, s] of Object.entries(version.changes)) {
1224
+ if (s === null) state.delete(p);
1225
+ else state.set(p, s);
1226
+ }
1227
+ }
1228
+ return state;
1229
+ }
1230
+ /** The files right after version `n`; null when it is not kept. */
1231
+ stateAt(n) {
1232
+ return this.version(n) ? this.fold(n) : null;
1233
+ }
1234
+ /** The files right before version `n`. */
1235
+ stateBefore(n) {
1236
+ return this.fold(n - 1);
1237
+ }
1238
+ /** The content with this sha, from the objects. */
1239
+ read(sha) {
1240
+ if (!SHA.test(sha)) return null;
1241
+ try {
1242
+ return fs4.readFileSync(path3.join(this.dir, OBJECTS, sha));
1243
+ } catch {
1244
+ return null;
1245
+ }
1246
+ }
1247
+ /** Keeps a content that no version holds (an overwrite's `base`); its sha. */
1248
+ put(bytes) {
1249
+ const sha = sha256(bytes);
1250
+ this.putObject(sha, bytes);
1251
+ return sha;
1252
+ }
1253
+ size(sha) {
1254
+ if (!SHA.test(sha)) return null;
1255
+ try {
1256
+ return fs4.statSync(path3.join(this.dir, OBJECTS, sha)).size;
1257
+ } catch {
1258
+ return null;
1259
+ }
1260
+ }
1261
+ /** The newest version that changed `path`, with what it left there. */
1262
+ lastChange(p) {
1263
+ for (let i = this.index.versions.length - 1; i >= 0; i--) {
1264
+ const version = this.index.versions[i];
1265
+ if (p in version.changes) return { version, sha: version.changes[p] };
1266
+ }
1267
+ return null;
1268
+ }
1269
+ /**
1270
+ * Records changes: path → its content now (null: deleted). Those equal to the head are left out; nothing left, no
1271
+ * version (null). The rest join the open version of the same person, or start a new one.
1272
+ */
1273
+ record(changes, who, extra = {}, now = /* @__PURE__ */ new Date()) {
1274
+ const changed = {};
1275
+ for (const [p, bytes] of changes) {
1276
+ const sha = bytes === null ? null : sha256(bytes);
1277
+ if ((this.headState.get(p) ?? null) === sha) continue;
1278
+ if (bytes !== null && sha !== null) this.putObject(sha, bytes);
1279
+ changed[p] = sha;
1280
+ }
1281
+ if (Object.keys(changed).length === 0) return null;
1282
+ const iso = now.toISOString();
1283
+ const last = this.index.versions.at(-1);
1284
+ const joins = !!last && who.by !== "system" && last.by === who.by && last.user.id === who.user.id && last.restoredFrom === void 0 && extra.restoredFrom === void 0 && now.getTime() - Date.parse(last.at) < IDLE_MS && now.getTime() - Date.parse(last.first) < SPAN_MS;
1285
+ let version;
1286
+ if (joins) {
1287
+ version = last;
1288
+ version.at = iso;
1289
+ Object.assign(version.changes, changed);
1290
+ if (who.user.name) version.user = { ...who.user };
1291
+ } else {
1292
+ version = { n: this.index.next++, first: iso, at: iso, by: who.by, user: { ...who.user }, changes: changed };
1293
+ if (who.role) version.role = who.role;
1294
+ if (extra.restoredFrom !== void 0) version.restoredFrom = extra.restoredFrom;
1295
+ if (extra.restoredFrom !== void 0 && extra.restoredPart) version.restoredPart = true;
1296
+ this.index.versions.push(version);
1297
+ }
1298
+ for (const [p, s] of Object.entries(changed)) {
1299
+ if (s === null) this.headState.delete(p);
1300
+ else this.headState.set(p, s);
1301
+ }
1302
+ this.index.notices = this.index.notices.filter(
1303
+ (n) => !(n.path in changed && (changed[n.path] === n.lost.sha || extra.restoredFrom !== void 0 && n.to === who.user.id))
1304
+ );
1305
+ if (extra.overwrote?.length) {
1306
+ version.overwrote = [...(version.overwrote ?? []).filter((o) => !extra.overwrote.some((x) => x.path === o.path)), ...extra.overwrote];
1307
+ this.notify(version, who, extra.overwrote, now);
1308
+ }
1309
+ this.prune(now);
1310
+ this.save();
1311
+ return version;
1312
+ }
1313
+ /** Notices for an overwrite: to whoever lost their change (the agent's go to the owner), and a hard one's saver unless they chose it. */
1314
+ notify(version, who, overwrote, now) {
1315
+ const at = now.toISOString();
1316
+ for (const o of overwrote) {
1317
+ const lost = { sha: o.lost, n: o.n, by: o.by, user: o.user };
1318
+ const victim = o.by === "agent" ? "local" : o.user.id;
1319
+ const fields = {
1320
+ at,
1321
+ path: o.path,
1322
+ n: version.n,
1323
+ by: { ...who.user },
1324
+ byAuthor: who.by,
1325
+ lost,
1326
+ ...o.soft ? { soft: true } : {},
1327
+ ...o.base ? { base: o.base } : {}
1328
+ };
1329
+ const same = (n) => n.path === o.path && n.n === version.n;
1330
+ this.index.notices = this.index.notices.filter((n) => !same(n));
1331
+ if (victim !== who.user.id) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: victim, kind: "overwritten", ...fields });
1332
+ if (!o.soft && !o.chosen) this.index.notices.push({ id: randomBytes(6).toString("hex"), to: who.user.id, kind: "overwrote", ...fields });
1333
+ }
1334
+ }
1335
+ /** The notices for this person, newest first. */
1336
+ noticesFor(userId) {
1337
+ return this.index.notices.filter((n) => n.to === userId).reverse();
1338
+ }
1339
+ notice(id) {
1340
+ return this.index.notices.find((n) => n.id === id);
1341
+ }
1342
+ /** Dismisses one of this person's notices; false when they have none by that id. */
1343
+ dismiss(userId, id) {
1344
+ const before = this.index.notices.length;
1345
+ this.index.notices = this.index.notices.filter((n) => !(n.id === id && n.to === userId));
1346
+ if (this.index.notices.length === before) return false;
1347
+ this.save();
1348
+ return true;
1349
+ }
1350
+ /**
1351
+ * What to write to bring version `n` back: path → content (null: delete), for `paths` (default: every file it or the
1352
+ * head holds), only where the head differs. null when the version is not kept or a content is missing.
1353
+ */
1354
+ restoreTo(n, paths, tracked = () => true) {
1355
+ const target = this.stateAt(n);
1356
+ if (!target) return null;
1357
+ const out = /* @__PURE__ */ new Map();
1358
+ const all = paths ?? [.../* @__PURE__ */ new Set([...target.keys(), ...this.headState.keys()])];
1359
+ for (const p of all) {
1360
+ if (!tracked(p)) continue;
1361
+ const want = target.get(p) ?? null;
1362
+ if ((this.headState.get(p) ?? null) === want) continue;
1363
+ if (want === null) {
1364
+ out.set(p, null);
1365
+ continue;
1366
+ }
1367
+ const bytes = this.read(want);
1368
+ if (!bytes) return null;
1369
+ out.set(p, bytes);
1370
+ }
1371
+ return out;
1372
+ }
1373
+ /** Each version's changes as added, edited or deleted (what the file held before it decides). */
1374
+ fileChanges() {
1375
+ const out = /* @__PURE__ */ new Map();
1376
+ const state = new Map(Object.entries(this.index.base));
1377
+ for (const version of this.index.versions) {
1378
+ const list = [];
1379
+ for (const [p, s] of Object.entries(version.changes)) {
1380
+ const had = state.has(p);
1381
+ list.push({ path: p, op: s === null ? "deleted" : had ? "edited" : "added" });
1382
+ if (s === null) state.delete(p);
1383
+ else state.set(p, s);
1384
+ }
1385
+ out.set(version.n, list);
1386
+ }
1387
+ return out;
1388
+ }
1389
+ putObject(sha, bytes) {
1390
+ const file = path3.join(this.dir, OBJECTS, sha);
1391
+ if (fs4.existsSync(file)) return;
1392
+ writeAtomic2(file, bytes);
1393
+ }
1394
+ /** Past MAX_VERSIONS the oldest fold into the base; old notices go; contents no one holds are removed. */
1395
+ prune(now) {
1396
+ let folded = false;
1397
+ while (this.index.versions.length > MAX_VERSIONS) {
1398
+ const old = this.index.versions.shift();
1399
+ for (const [p, s] of Object.entries(old.changes)) {
1400
+ if (s === null) delete this.index.base[p];
1401
+ else this.index.base[p] = s;
1402
+ }
1403
+ folded = true;
1404
+ }
1405
+ const oldest = now.getTime() - NOTICE_DAYS * 864e5;
1406
+ const kept = this.index.versions[0]?.n ?? Infinity;
1407
+ const perPerson = /* @__PURE__ */ new Map();
1408
+ this.index.notices = this.index.notices.filter((n) => Date.parse(n.at) >= oldest && n.lost.n >= kept).reverse().filter((n) => {
1409
+ const count = (perPerson.get(n.to) ?? 0) + 1;
1410
+ perPerson.set(n.to, count);
1411
+ return count <= MAX_NOTICES;
1412
+ }).reverse();
1413
+ if (folded) this.collect();
1414
+ }
1415
+ /** Removes the objects that neither the base, a version nor a notice holds (with the base an overwrite was made on). */
1416
+ collect() {
1417
+ const keep = new Set(Object.values(this.index.base));
1418
+ for (const version of this.index.versions) {
1419
+ for (const s of Object.values(version.changes)) if (s) keep.add(s);
1420
+ for (const o of version.overwrote ?? []) if (o.base) keep.add(o.base);
1421
+ }
1422
+ for (const notice of this.index.notices) {
1423
+ if (notice.lost.sha) keep.add(notice.lost.sha);
1424
+ if (notice.base) keep.add(notice.base);
1425
+ }
1426
+ let names;
1427
+ try {
1428
+ names = fs4.readdirSync(path3.join(this.dir, OBJECTS));
1429
+ } catch {
1430
+ return;
1431
+ }
1432
+ for (const name of names) if (SHA.test(name) && !keep.has(name)) fs4.rmSync(path3.join(this.dir, OBJECTS, name), { force: true });
1433
+ }
1434
+ save() {
1435
+ writeAtomic2(path3.join(this.dir, INDEX), `${JSON.stringify(this.index)}
1436
+ `);
1437
+ }
1438
+ };
1439
+ var MAX_DIFF_BYTES = 1024 * 1024;
1440
+ var CONTEXT = 3;
1441
+ var MAX_DIFF_LINES = 3e3;
1442
+ var MAX_LINE = 2e3;
1443
+ var decoder = new TextDecoder("utf-8", { fatal: true });
1444
+ function textOf(bytes) {
1445
+ if (bytes === null) return null;
1446
+ if (bytes.includes(0)) return void 0;
1447
+ try {
1448
+ return decoder.decode(bytes);
1449
+ } catch {
1450
+ return void 0;
1451
+ }
1452
+ }
1453
+ var splitLines = (text) => text === null || text === "" ? [] : text.replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n");
1454
+ function diffContents(before, after) {
1455
+ if ((before?.length ?? 0) > MAX_DIFF_BYTES || (after?.length ?? 0) > MAX_DIFF_BYTES) return { tooLarge: true, added: 0, removed: 0, lines: [] };
1456
+ const a = textOf(before);
1457
+ const b = textOf(after);
1458
+ if (a === void 0 || b === void 0) return { binary: true, added: 0, removed: 0, lines: [] };
1459
+ const left = splitLines(a);
1460
+ const right = splitLines(b);
1461
+ let head = 0;
1462
+ while (head < left.length && head < right.length && left[head] === right[head]) head++;
1463
+ let tail = 0;
1464
+ while (tail < left.length - head && tail < right.length - head && left[left.length - 1 - tail] === right[right.length - 1 - tail]) tail++;
1465
+ const middle = editScript(left.slice(head, left.length - tail), right.slice(head, right.length - tail));
1466
+ if (!middle) return { tooLarge: true, added: 0, removed: 0, lines: [] };
1467
+ const ops = [...Array(head).fill("="), ...middle, ...Array(tail).fill("=")];
1468
+ const all = [];
1469
+ let i = 0;
1470
+ let j = 0;
1471
+ let added = 0;
1472
+ let removed = 0;
1473
+ const cut = (s) => s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}\u2026` : s;
1474
+ for (const op of ops) {
1475
+ if (op === "=") all.push([" ", cut(right[j++])]), i++;
1476
+ else if (op === "-") all.push(["-", cut(left[i++])]), removed++;
1477
+ else all.push(["+", cut(right[j++])]), added++;
1478
+ }
1479
+ const keep = new Uint8Array(all.length);
1480
+ all.forEach(([kind], at) => {
1481
+ if (kind === " ") return;
1482
+ for (let x = Math.max(0, at - CONTEXT); x <= Math.min(all.length - 1, at + CONTEXT); x++) keep[x] = 1;
1483
+ });
1484
+ const lines = [];
1485
+ let skipped = 0;
1486
+ let truncated = false;
1487
+ for (let at = 0; at < all.length; at++) {
1488
+ if (!keep[at]) {
1489
+ skipped++;
1490
+ continue;
1491
+ }
1492
+ if (skipped) lines.push(["~", String(skipped)]);
1493
+ skipped = 0;
1494
+ if (lines.length >= MAX_DIFF_LINES) {
1495
+ truncated = true;
1496
+ break;
1497
+ }
1498
+ lines.push(all[at]);
1499
+ }
1500
+ if (skipped && !truncated) lines.push(["~", String(skipped)]);
1501
+ return { added, removed, lines, ...truncated ? { truncated: true } : {} };
1502
+ }
1503
+
1504
+ import { createHash as createHash2 } from "node:crypto";
1505
+ import fs6 from "node:fs";
1506
+ import os3 from "node:os";
1507
+ import path5 from "node:path";
1508
+
1509
+ import crypto from "node:crypto";
1510
+ import fs5 from "node:fs";
1511
+ import path4 from "node:path";
1512
+ var GUEST_ROLES = ["editor", "commenter", "viewer"];
1513
+ var isGuestRole = (value) => typeof value === "string" && GUEST_ROLES.includes(value);
1514
+ var SHARING_FILE = ".sharing.json";
1515
+ var MAX_GUESTS = 200;
1516
+ var MAX_NAME = 40;
1517
+ var token = (bytes = 18) => crypto.randomBytes(bytes).toString("base64url");
1518
+ var isRecord3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1519
+ function fresh() {
1520
+ return { lan: false, links: { editor: { token: token(), on: true }, commenter: { token: token(), on: true }, viewer: { token: token(), on: true } }, guests: [] };
1521
+ }
1522
+ function cleanName(raw) {
1523
+ const name = typeof raw === "string" ? raw.replace(/[\u0000-\u001f\u007f@<>]/g, "").replace(/\s+/g, " ").trim() : "";
1524
+ return [...name].slice(0, MAX_NAME).join("");
1525
+ }
1526
+ function guestUser(guest) {
1527
+ return { id: `guest-${guest.id}`, name: guest.name, email: `${guest.name}@${guest.id}.guest` };
1528
+ }
1529
+ function isLoopback(address) {
1530
+ if (!address) return false;
1531
+ const a = address.startsWith("::ffff:") ? address.slice(7) : address;
1532
+ return a === "::1" || a.startsWith("127.");
1533
+ }
1534
+ var Sharing = class _Sharing {
1535
+ file;
1536
+ state;
1537
+ constructor(root) {
1538
+ this.file = path4.join(root, SHARING_FILE);
1539
+ this.state = _Sharing.read(this.file);
1540
+ }
1541
+ static read(file) {
1542
+ let raw;
1543
+ try {
1544
+ raw = JSON.parse(fs5.readFileSync(file, "utf8"));
1545
+ } catch {
1546
+ return fresh();
1547
+ }
1548
+ const state = fresh();
1549
+ if (!isRecord3(raw)) return state;
1550
+ state.lan = raw.lan === true;
1551
+ if (typeof raw.port === "number" && Number.isInteger(raw.port) && raw.port > 0 && raw.port < 65536) state.port = raw.port;
1552
+ if (isRecord3(raw.links)) {
1553
+ for (const role of GUEST_ROLES) {
1554
+ const link = raw.links[role];
1555
+ if (isRecord3(link) && typeof link.token === "string" && link.token.length >= 16) state.links[role] = { token: link.token, on: link.on !== false };
1556
+ }
1557
+ }
1558
+ if (Array.isArray(raw.guests)) {
1559
+ for (const g of raw.guests) {
1560
+ if (!isRecord3(g) || typeof g.id !== "string" || typeof g.secret !== "string" || !isGuestRole(g.role) || typeof g.link !== "string") continue;
1561
+ state.guests.push({
1562
+ id: g.id,
1563
+ secret: g.secret,
1564
+ name: cleanName(g.name) || "Guest",
1565
+ role: g.role,
1566
+ link: g.link,
1567
+ joined: typeof g.joined === "string" ? g.joined : (/* @__PURE__ */ new Date(0)).toISOString(),
1568
+ ...typeof g.seen === "string" ? { seen: g.seen } : {}
1569
+ });
1570
+ }
1571
+ }
1572
+ return state;
1573
+ }
1574
+ save() {
1575
+ const tmp = `${this.file}.${process.pid}.tmp`;
1576
+ fs5.writeFileSync(tmp, `${JSON.stringify(this.state, null, 2)}
1577
+ `, { mode: 384 });
1578
+ fs5.renameSync(tmp, this.file);
1579
+ }
1580
+ /** The role whose link has this token, while that link is on. */
1581
+ linkRole(tokenGiven) {
1582
+ for (const role of GUEST_ROLES) {
1583
+ const link = this.state.links[role];
1584
+ if (link.on && tokenGiven.length === link.token.length && crypto.timingSafeEqual(Buffer.from(tokenGiven), Buffer.from(link.token))) return role;
1585
+ }
1586
+ return null;
1587
+ }
1588
+ /** The guest a cookie names, if they still have access (their link on and not reset). */
1589
+ guest(secret) {
1590
+ if (!secret) return null;
1591
+ const guest = this.state.guests.find((g) => g.secret === secret);
1592
+ return guest && this.valid(guest) ? guest : null;
1593
+ }
1594
+ valid(guest) {
1595
+ const link = this.state.links[guest.role];
1596
+ return link.on && link.token === guest.link && this.state.guests.includes(guest);
1597
+ }
1598
+ join(role, name) {
1599
+ const guest = { id: token(4), secret: token(24), name: cleanName(name) || "Guest", role, link: this.state.links[role].token, joined: (/* @__PURE__ */ new Date()).toISOString() };
1600
+ this.state.guests.push(guest);
1601
+ if (this.state.guests.length > MAX_GUESTS) this.state.guests.splice(0, this.state.guests.length - MAX_GUESTS);
1602
+ this.save();
1603
+ return guest;
1604
+ }
1605
+ /** Marks a guest seen now (saved at most once a minute per guest). */
1606
+ seen(guest) {
1607
+ const now = /* @__PURE__ */ new Date();
1608
+ if (guest.seen && now.getTime() - Date.parse(guest.seen) < 6e4) return;
1609
+ guest.seen = now.toISOString();
1610
+ this.save();
1611
+ }
1612
+ setLan(on, port) {
1613
+ this.state.lan = on;
1614
+ if (on && port) this.state.port = port;
1615
+ this.save();
1616
+ }
1617
+ setLink(role, on) {
1618
+ this.state.links[role].on = on;
1619
+ this.save();
1620
+ }
1621
+ /** A new token for the link: the old link stops working and the guests who came through it are dropped. */
1622
+ resetLink(role) {
1623
+ const old = this.state.links[role].token;
1624
+ this.state.links[role] = { token: token(), on: this.state.links[role].on };
1625
+ this.state.guests = this.state.guests.filter((g) => g.link !== old);
1626
+ this.save();
1627
+ }
1628
+ /**
1629
+ * Gives a guest another role. They now hang on that role's link (turning it off or resetting it drops them), so a role
1630
+ * whose link is off would shut them out: false then, as for a guest who isn't there.
1631
+ */
1632
+ setRole(id, role) {
1633
+ const guest = this.state.guests.find((g) => g.id === id);
1634
+ if (!guest || !this.state.links[role].on) return false;
1635
+ guest.role = role;
1636
+ guest.link = this.state.links[role].token;
1637
+ this.save();
1638
+ return true;
1639
+ }
1640
+ removeGuest(id) {
1641
+ const before = this.state.guests.length;
1642
+ this.state.guests = this.state.guests.filter((g) => g.id !== id);
1643
+ if (this.state.guests.length === before) return false;
1644
+ this.save();
1645
+ return true;
1646
+ }
1647
+ };
1648
+
1649
+ var NOUNS = {
1650
+ docs: { en: "doc", zh: "\u6587\u6863", zhThis: "\u8FD9\u7BC7\u6587\u6863" },
1651
+ slides: { en: "deck", zh: "\u6F14\u793A\u6587\u7A3F", zhThis: "\u8FD9\u4EFD\u6F14\u793A\u6587\u7A3F" },
1652
+ design: { en: "design", zh: "\u8BBE\u8BA1\u7A3F", zhThis: "\u8FD9\u4EFD\u8BBE\u8BA1\u7A3F" },
1653
+ "design-system": { en: "design system", zh: "\u8BBE\u8BA1\u7CFB\u7EDF", zhThis: "\u8FD9\u4E2A\u8BBE\u8BA1\u7CFB\u7EDF" },
1654
+ dashboard: { en: "dashboard", zh: "\u4EEA\u8868\u76D8", zhThis: "\u8FD9\u4E2A\u4EEA\u8868\u76D8" },
1655
+ motion: { en: "animation", zh: "\u52A8\u753B", zhThis: "\u8FD9\u4E2A\u52A8\u753B" }
1656
+ };
1657
+ var ShareError = class extends Error {
1658
+ };
1659
+ var canEditAs = (role) => role === "owner" || role === "editor";
1660
+ function commentsViewer(viewer) {
1661
+ return {
1662
+ author: viewer.guest ? { kind: "user", name: viewer.user.name, principal: viewer.user.email } : { kind: "user", name: viewer.user.name },
1663
+ canComment: viewer.role !== "viewer",
1664
+ canSend: viewer.role === "owner",
1665
+ admin: viewer.role === "owner"
1666
+ };
1667
+ }
1668
+ var isRecord4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1669
+ var esc = (text) => text.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1670
+ var script = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
1671
+ function ownHosts(host, port) {
1672
+ const named = host === "0.0.0.0" || host === "::" ? [] : [host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`];
1673
+ return [.../* @__PURE__ */ new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, ...named])];
1674
+ }
1675
+ function localOwner() {
1676
+ let name = "You";
1677
+ try {
1678
+ name = os3.userInfo().username.trim() || name;
1679
+ } catch {
1680
+ }
1681
+ return { id: "local", name, email: `${name}@localhost` };
1682
+ }
1683
+ var guestCookieName = (kind, root) => `${kind.replace(/-/g, "_")}_guest_${createHash2("sha256").update(path5.resolve(root)).digest("hex").slice(0, 12)}`;
1684
+ var ShareHost = class {
1685
+ sharing;
1686
+ options;
1687
+ /** The port the server listens on (set by `listening`); the LAN listeners take the same one. */
1688
+ port = 0;
1689
+ hosts = /* @__PURE__ */ new Set();
1690
+ /** One listener per LAN address while the artifact is open to the network. */
1691
+ lanServers = /* @__PURE__ */ new Map();
1692
+ live = /* @__PURE__ */ new Set();
1693
+ constructor(options) {
1694
+ this.options = options;
1695
+ this.sharing = new Sharing(options.root);
1696
+ }
1697
+ /**
1698
+ * Starts `server` on the first free port from `port` (a shared artifact tries the port its links were given out with
1699
+ * first, so they keep working, unless the port was given), shares it on the network with `lan` (or as it was left)
1700
+ * and answers the port taken. If this throws, the caller closes what it started.
1701
+ */
1702
+ async listen(server2, { port, host, lan = false, portGiven = false }) {
1703
+ const { state } = this.sharing;
1704
+ const linksPort = !portGiven && (lan || state.lan) && state.port ? state.port : void 0;
1705
+ const taken = await listenFree(server2, port, host, linksPort);
1706
+ if (linksPort && taken !== linksPort) this.options.log(`port ${linksPort} (the shared links') is in use; took ${taken}: give the new links out again`);
1707
+ else if (!linksPort && port && taken !== port) this.options.log(`port ${port} is in use; took ${taken}`);
1708
+ if (lan && !this.sharing.state.lan) this.sharing.setLan(true, taken);
1709
+ await this.listening(taken, ownHosts(host, taken));
1710
+ return taken;
1711
+ }
1712
+ /** The artifact at each LAN address it is shared on ([] while it is not). */
1713
+ lanUrls() {
1714
+ return [...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`);
1715
+ }
1716
+ /** The main listener is up on `port` (answering `hosts`): open to the network now if the artifact was left shared. */
1717
+ async listening(port, hosts) {
1718
+ this.port = port;
1719
+ this.hosts = new Set(hosts);
1720
+ if (this.sharing.state.lan) await this.setLan(true);
1721
+ }
1722
+ /** Open to the local network now. */
1723
+ get shared() {
1724
+ return this.lanServers.size > 0;
1725
+ }
1726
+ /** A Host header this server answers: its own names, and while shared, its LAN addresses and machine name. */
1727
+ hostAllowed(request) {
1728
+ const host = request.headers.host ?? "";
1729
+ if (this.hosts.has(host)) return true;
1730
+ if (!this.lanServers.size) return false;
1731
+ const suffix = `:${this.port}`;
1732
+ if (!host.endsWith(suffix)) return false;
1733
+ const name = host.slice(0, -suffix.length).toLowerCase();
1734
+ const machine = os3.hostname().toLowerCase();
1735
+ return this.lanServers.has(name) || name === machine || name === `${machine.replace(/\.local$/, "")}.local`;
1736
+ }
1737
+ /**
1738
+ * Opens the artifact to the local network (a listener on each LAN address, on the server's port) or closes it.
1739
+ * Returns the addresses it listens on. Guests already in keep their place for when it opens again.
1740
+ */
1741
+ async setLan(on) {
1742
+ if (!on) {
1743
+ this.closeLan();
1744
+ for (const live of [...this.live]) if (live.viewer.guest) live.close("unshared");
1745
+ if (this.sharing.state.lan) this.sharing.setLan(false);
1746
+ return [];
1747
+ }
1748
+ for (const address of lanAddresses()) {
1749
+ if (this.lanServers.has(address)) continue;
1750
+ const server2 = this.options.createServer();
1751
+ try {
1752
+ await listen(server2, this.port, address);
1753
+ this.lanServers.set(address, server2);
1754
+ } catch (error) {
1755
+ server2.close();
1756
+ this.options.log(`not listening on ${address}:${this.port}: ${error.message}`);
1757
+ }
1758
+ }
1759
+ if (!this.sharing.state.lan || this.sharing.state.port !== this.port) this.sharing.setLan(true, this.port);
1760
+ this.options.log(`shared on the local network: ${[...this.lanServers.keys()].map((a) => `http://${a}:${this.port}/`).join(", ") || "(no LAN address)"}`);
1761
+ return [...this.lanServers.keys()];
1762
+ }
1763
+ closeLan() {
1764
+ for (const server2 of this.lanServers.values()) {
1765
+ server2.close();
1766
+ server2.closeAllConnections?.();
1767
+ }
1768
+ this.lanServers.clear();
1769
+ }
1770
+ /** Stops the LAN listeners (the server closes its own). */
1771
+ close() {
1772
+ this.closeLan();
1773
+ }
1774
+ // ---- who is asking ---------------------------------------------------------------------------------------------
1775
+ cookie(request) {
1776
+ for (const part of (request.headers.cookie ?? "").split(";")) {
1777
+ const eq = part.indexOf("=");
1778
+ if (eq > 0 && part.slice(0, eq).trim() === this.options.cookieName) return part.slice(eq + 1).trim();
1779
+ }
1780
+ return void 0;
1781
+ }
1782
+ /**
1783
+ * Who a request is: the owner over loopback, a guest by their cookie (while their link is on and the artifact is
1784
+ * shared), else nobody. Loopback is always the owner, so trying a link on this computer can't lock the owner out of
1785
+ * their own artifact; a guest comes over a LAN address (this computer's own included: that is how the owner sees
1786
+ * what a role sees).
1787
+ */
1788
+ viewerOf(request) {
1789
+ if (isLoopback(request.socket.remoteAddress)) return { role: "owner", user: this.options.owner };
1790
+ const guest = this.lanServers.size ? this.sharing.guest(this.cookie(request)) : null;
1791
+ if (!guest) return null;
1792
+ this.sharing.seen(guest);
1793
+ return { role: guest.role, user: guestUser(guest), guest };
1794
+ }
1795
+ /** Keeps track of a stream or socket `viewer` holds open; call the returned function when it closes. */
1796
+ attach(viewer, close) {
1797
+ const live = { viewer, close };
1798
+ this.live.add(live);
1799
+ return () => this.live.delete(live);
1800
+ }
1801
+ /** `attach` for an HTTP stream (server-sent events): it ends when its person loses access. */
1802
+ hold(viewer, response) {
1803
+ const detach = this.attach(viewer, () => response.end());
1804
+ response.on("close", detach);
1805
+ }
1806
+ /** Still allowed in: the owner, or a guest whose link is on and who was not removed. */
1807
+ stillIn(viewer) {
1808
+ return !viewer.guest || this.shared && this.sharing.valid(viewer.guest);
1809
+ }
1810
+ /** Ends the streams of guests who lost access (a link turned off or reset, a guest removed). */
1811
+ dropRevoked() {
1812
+ for (const live of [...this.live]) if (live.viewer.guest && !this.sharing.valid(live.viewer.guest)) live.close("revoked");
1813
+ }
1814
+ me(viewer) {
1815
+ return { role: viewer.role, id: viewer.user.id, name: viewer.user.name, email: viewer.user.email };
1816
+ }
1817
+ // ---- routes ----------------------------------------------------------------------------------------------------
1818
+ /** Answers the share link and /__share paths; false for any other path. */
1819
+ async route(request, response) {
1820
+ const p = new URL(request.url ?? "/", "http://local").pathname;
1821
+ const method = request.method ?? "GET";
1822
+ const link = /^\/s\/([A-Za-z0-9_-]{16,64})$/.exec(p);
1823
+ const ours = link || p === "/__share" || p.startsWith("/__share/");
1824
+ if (!ours) return false;
1825
+ if (method === "POST" && !sameOrigin(request)) {
1826
+ request.resume();
1827
+ sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
1828
+ return true;
1829
+ }
1830
+ if (link) {
1831
+ if (method === "GET" || method === "POST") await this.join(request, response, method, link[1]);
1832
+ else response.writeHead(405, { Allow: "GET, POST" }).end();
1833
+ return true;
1834
+ }
1835
+ const viewer = this.viewerOf(request);
1836
+ if (!viewer) {
1837
+ this.denied(request, response);
1838
+ return true;
1839
+ }
1840
+ if (method === "GET" && p === "/__share/me") return sendJson2(response, 200, this.me(viewer)), true;
1841
+ if (method === "GET" && p === "/__share/boot.js") {
1842
+ const body = this.bootScript(viewer, new URL(request.url ?? "/", "http://local").searchParams);
1843
+ response.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": Buffer.byteLength(body), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" }).end(body);
1844
+ return true;
1845
+ }
1846
+ if (method === "GET" && p === "/__share/invite") return sendJson2(response, 200, this.invites(viewer, request.headers.host ?? "")), true;
1847
+ if (method === "GET" && p === "/__share/ui.js") {
1848
+ fs6.stat(this.options.uiFile, (error, stat) => {
1849
+ if (error || !stat.isFile()) return sendJson2(response, 404, { code: "not_found", message: "The Share panel is missing." });
1850
+ sendFile(request, response, this.options.uiFile, { "Content-Type": "text/javascript; charset=utf-8", "Content-Length": stat.size, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
1851
+ });
1852
+ return true;
1853
+ }
1854
+ if (viewer.role !== "owner") return sendJson2(response, 403, { code: "not_granted", message: "Only the owner can do this." }), true;
1855
+ if (p === "/__share") return await this.shareApi(request, response, method), true;
1856
+ sendJson2(response, 404, { code: "not_found", message: "Not found" });
1857
+ return true;
1858
+ }
1859
+ /**
1860
+ * The tags a page puts first in its <head>: the boot script and the Share panel (the owner's, or a guest's). `query`
1861
+ * ("?…") goes to the boot script, for `globals`.
1862
+ */
1863
+ headTags(_viewer, query = "") {
1864
+ let tags = `<script src="/__share/boot.js${query.replace(/[<>"']/g, "").replace(/&/g, "&amp;")}"></script>`;
1865
+ if (fs6.existsSync(this.options.uiFile)) tags += '<script type="module" src="/__share/ui.js"></script>';
1866
+ return tags;
1867
+ }
1868
+ /** `html` with the head tags first in its <head>; null when it has none. */
1869
+ injectHead(html, viewer, query = "") {
1870
+ const at = html.search(/<head(\s[^>]*)?>/i);
1871
+ if (at < 0) return null;
1872
+ const end = html.indexOf(">", at) + 1;
1873
+ return html.slice(0, end) + this.headTags(viewer, query) + html.slice(end);
1874
+ }
1875
+ /** What the page runs before anything else (see SECURE_CONTEXT_SHIM and GUEST_WATCH). */
1876
+ bootScript(viewer, query = new URLSearchParams()) {
1877
+ const share = { ...this.me(viewer), noun: this.options.noun, title: this.options.title(), ...this.options.reactions ? { reactions: true } : {} };
1878
+ let js = `${SECURE_CONTEXT_SHIM}window.__baoyuShare=${script(share)};`;
1879
+ for (const [name, value] of Object.entries(this.options.globals?.(viewer, query) ?? {})) js += `window[${script(name)}]=${script(value)};`;
1880
+ if (viewer.guest) js += GUEST_WATCH;
1881
+ return js;
1882
+ }
1883
+ /** Nobody's request: a page for a browser's navigation, JSON for anything else. */
1884
+ denied(request, response) {
1885
+ request.resume();
1886
+ const navigation = (request.method === "GET" || request.method === "HEAD") && /\btext\/html\b/.test(request.headers.accept ?? "");
1887
+ if (!navigation) return sendJson2(response, 403, { code: "not_granted", message: `This ${this.options.noun.en} is not shared with you.` });
1888
+ const t = this.words(request);
1889
+ this.sendPage(response, 403, t.noAccessTitle, `<h1>${esc(t.noAccessTitle)}</h1><p>${esc(t.noAccess)}</p>${this.ownerHint(request, t)}`);
1890
+ }
1891
+ /** A small page of our own (the join form, the refusal), in the reader's language. */
1892
+ sendPage(response, status, title, body, headers = {}) {
1893
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>${PAGE_CSS}</style></head><body><main>${body}</main></body></html>`;
1894
+ response.writeHead(status, {
1895
+ "Content-Type": "text/html; charset=utf-8",
1896
+ "Cache-Control": "no-store",
1897
+ "X-Content-Type-Options": "nosniff",
1898
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
1899
+ ...headers
1900
+ }).end(html);
1901
+ }
1902
+ /** On the computer that shares the artifact, over its LAN address: the way back to the owner's page. */
1903
+ ownerHint(request, t) {
1904
+ const from = (request.socket.remoteAddress ?? "").replace(/^::ffff:/, "");
1905
+ if (!this.lanServers.has(from)) return "";
1906
+ const url = `http://127.0.0.1:${this.port}/`;
1907
+ return `<p class="note">${esc(t.ownerHere)} <a href="${esc(url)}">${esc(url)}</a></p>`;
1908
+ }
1909
+ /** GET /s/<token>: the name form (or straight in, for a guest already in through this link); POST: join. */
1910
+ async join(request, response, method, token2) {
1911
+ const t = this.words(request);
1912
+ const role = this.sharing.linkRole(token2);
1913
+ const local = isLoopback(request.socket.remoteAddress);
1914
+ const form = method === "POST" ? await readBody(request, 8 << 10).then((body) => body.toString("utf8"), () => "") : "";
1915
+ const title = this.options.title();
1916
+ if (!role || !local && !this.lanServers.size) return this.sendPage(response, 404, t.linkGoneTitle, `<h1>${esc(t.linkGoneTitle)}</h1><p>${esc(t.linkGone)}</p>`);
1917
+ if (local) {
1918
+ const lan = [...this.lanServers.keys()][0];
1919
+ const there = lan ? `http://${lan}:${this.port}/s/${token2}` : "";
1920
+ const tryIt = there ? `<p>${esc(t.tryRole(t.roles[role]))} <a href="${esc(there)}">${esc(there)}</a></p>` : "";
1921
+ return this.sendPage(response, 200, title, `<h1>${esc(title)}</h1><p>${esc(t.youOwn)} <a href="/">${esc(t.openAsOwner)}</a></p>${tryIt}`);
1922
+ }
1923
+ const current = this.sharing.guest(this.cookie(request));
1924
+ if (method === "GET" && current && current.link === this.sharing.state.links[role].token) {
1925
+ response.writeHead(303, { Location: "/", "Cache-Control": "no-store" }).end();
1926
+ return;
1927
+ }
1928
+ if (method === "GET") {
1929
+ return this.sendPage(
1930
+ response,
1931
+ 200,
1932
+ title,
1933
+ `<h1>${esc(title)}</h1><p>${esc(t.invited(t.roles[role]))}</p><form method="post"><label for="n">${esc(t.yourName)}</label><input id="n" name="name" maxlength="${MAX_NAME}" value="${esc(current?.name ?? "")}" required autofocus autocomplete="name"><button type="submit">${esc(t.open)}</button></form><p class="note">${esc(t.nameNote)}</p>`
1934
+ );
1935
+ }
1936
+ const name = new URLSearchParams(form).get("name") ?? "";
1937
+ const guest = this.sharing.join(role, name);
1938
+ this.options.log(`share: ${guest.name} joined as ${role} (${request.socket.remoteAddress})`);
1939
+ const cookie = `${this.options.cookieName}=${guest.secret}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000`;
1940
+ response.writeHead(303, { Location: "/", "Set-Cookie": cookie, "Cache-Control": "no-store" }).end();
1941
+ }
1942
+ /**
1943
+ * The links this person may pass on: their own role's and the ones below it that are on (the owner: every one on), at
1944
+ * the address they came by (`host`, a Host header hostAllowed let in). A guest could forward the link they joined
1945
+ * with anyway; a lower one gives no more than that. Who joins through one shows in the owner's panel like anyone else.
1946
+ */
1947
+ invites(viewer, host) {
1948
+ const { links } = this.sharing.state;
1949
+ const from = viewer.role === "owner" ? 0 : GUEST_ROLES.indexOf(viewer.role);
1950
+ return { links: GUEST_ROLES.slice(from).filter((role) => links[role].on).map((role) => ({ role, url: `http://${host}/s/${links[role].token}` })) };
1951
+ }
1952
+ /** Links, addresses and people, for the share panel and `artifact.mjs share`. */
1953
+ shareView() {
1954
+ const addresses = [...this.lanServers.keys()];
1955
+ const base = addresses.length ? `http://${addresses[0]}:${this.port}` : `http://127.0.0.1:${this.port}`;
1956
+ const online = /* @__PURE__ */ new Set();
1957
+ for (const live of this.live) if (live.viewer.guest) online.add(live.viewer.guest.id);
1958
+ const { links, guests } = this.sharing.state;
1959
+ return {
1960
+ title: this.options.title(),
1961
+ lan: this.lanServers.size > 0,
1962
+ port: this.port,
1963
+ addresses,
1964
+ local: `http://127.0.0.1:${this.port}/`,
1965
+ links: Object.fromEntries(GUEST_ROLES.map((role) => [role, { on: links[role].on, url: `${base}/s/${links[role].token}` }])),
1966
+ guests: guests.filter((g) => this.sharing.valid(g) || links[g.role].token === g.link).map((g) => ({ id: g.id, name: g.name, role: g.role, joined: g.joined, ...g.seen ? { seen: g.seen } : {}, online: online.has(g.id), active: this.sharing.valid(g) }))
1967
+ };
1968
+ }
1969
+ /**
1970
+ * The owner's sharing calls: `lan` {on}, `link` {role, on}, `reset` {role} (a new link: the old one and the guests
1971
+ * who came through it stop working), `role` {guest, role} (a guest's role, while that role's link is on), `remove`
1972
+ * {guest}. Answers the view after.
1973
+ */
1974
+ async share(action) {
1975
+ const role = action.role;
1976
+ switch (action.action) {
1977
+ case "lan": {
1978
+ const addresses = await this.setLan(action.on === true);
1979
+ if (action.on === true && !addresses.length) throw new ShareError("This computer has no local network address to share on.");
1980
+ break;
1981
+ }
1982
+ case "link":
1983
+ if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1984
+ this.sharing.setLink(role, action.on === true);
1985
+ break;
1986
+ case "reset":
1987
+ if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1988
+ this.sharing.resetLink(role);
1989
+ break;
1990
+ case "role": {
1991
+ if (!isGuestRole(role)) throw new ShareError("role: editor, commenter or viewer");
1992
+ if (typeof action.guest !== "string" || !this.sharing.state.guests.some((g) => g.id === action.guest)) throw new ShareError("no such guest");
1993
+ if (!this.sharing.setRole(action.guest, role)) throw new ShareError(`The ${role} link is off: turn it on first.`);
1994
+ for (const live of [...this.live]) if (live.viewer.guest?.id === action.guest) live.close("revoked");
1995
+ break;
1996
+ }
1997
+ case "remove":
1998
+ if (typeof action.guest !== "string" || !this.sharing.removeGuest(action.guest)) throw new ShareError("no such guest");
1999
+ break;
2000
+ default:
2001
+ throw new ShareError("action: lan, link, reset, role or remove");
2002
+ }
2003
+ this.options.log(`share: ${JSON.stringify(action)}`);
2004
+ this.dropRevoked();
2005
+ return this.shareView();
2006
+ }
2007
+ async shareApi(request, response, method) {
2008
+ if (method === "GET") return sendJson2(response, 200, this.shareView());
2009
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: `${method} is not allowed here.` });
2010
+ let action;
2011
+ try {
2012
+ action = JSON.parse((await readBody(request, 64 << 10)).toString("utf8"));
2013
+ } catch {
2014
+ return sendJson2(response, 400, { code: "invalid_argument", message: "The call is not JSON." });
2015
+ }
2016
+ if (!isRecord4(action)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {action, ...}." });
2017
+ try {
2018
+ return sendJson2(response, 200, await this.share(action));
2019
+ } catch (error) {
2020
+ if (error instanceof ShareError) return sendJson2(response, 400, { code: "invalid_argument", message: error.message });
2021
+ throw error;
2022
+ }
2023
+ }
2024
+ words(request) {
2025
+ const zh = /^zh\b/i.test(request.headers["accept-language"] ?? "");
2026
+ return (zh ? ZH : EN)(this.options.noun);
2027
+ }
2028
+ };
2029
+ var SECURE_CONTEXT_SHIM = "if(window.crypto&&!crypto.randomUUID)crypto.randomUUID=function(){var b=crypto.getRandomValues(new Uint8Array(16)),h='',i;b[6]=b[6]&15|64;b[8]=b[8]&63|128;for(i=0;i<16;i++){h+=(b[i]+256).toString(16).slice(1);if(i==3||i==5||i==7||i==9)h+='-'}return h};";
2030
+ var GUEST_WATCH = "setInterval(function(){fetch('/__share/me',{cache:'no-store'}).then(function(r){if(r.status===403)return location.reload();return r.json().then(function(m){if(m&&m.role&&m.role!==window.__baoyuShare.role)location.reload()})},function(){})},5000);";
2031
+ var PAGE_CSS = `
2032
+ :root { color-scheme: light dark; --bg: #f7f6f3; --card: #fff; --fg: #1f1e1d; --muted: #6b6a66; --line: #e3e1dc; --accent: #c96442; }
2033
+ @media (prefers-color-scheme: dark) { :root { --bg: #1f1e1d; --card: #2a2927; --fg: #f2f1ee; --muted: #a8a6a0; --line: #3a3936; } }
2034
+ * { box-sizing: border-box; }
2035
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; padding: 16px; }
2036
+ main { width: min(420px, 100%); background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 28px; }
2037
+ h1 { font-size: 20px; margin: 0 0 8px; word-break: break-word; }
2038
+ p { margin: 0 0 16px; color: var(--muted); }
2039
+ label { display: block; font-size: 13px; margin: 0 0 6px; color: var(--muted); }
2040
+ input { width: 100%; font: inherit; padding: 9px 11px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: inherit; margin: 0 0 14px; }
2041
+ button { width: 100%; font: inherit; font-weight: 600; padding: 10px; border: 0; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
2042
+ .note { font-size: 12px; margin: 14px 0 0; }
2043
+ a { color: var(--accent); overflow-wrap: anywhere; }
2044
+ `;
2045
+ var EN = (noun) => ({
2046
+ noAccessTitle: "No access",
2047
+ noAccess: `This ${noun.en} is not shared with you. Ask its owner for a link.`,
2048
+ linkGoneTitle: "This link does not work",
2049
+ linkGone: `The link was turned off or replaced, or the ${noun.en} is no longer shared. Ask its owner for a new one.`,
2050
+ invited: (what) => `You were invited to ${what} this ${noun.en}.`,
2051
+ yourName: "Your name (others see it on your comments and edits)",
2052
+ open: `Open the ${noun.en}`,
2053
+ nameNote: "This browser keeps your place; the owner can remove it at any time.",
2054
+ roles: { editor: "edit", commenter: "comment on", viewer: "view" },
2055
+ youOwn: `This is your ${noun.en}: on this computer you are its owner.`,
2056
+ openAsOwner: "Open it",
2057
+ tryRole: (what) => `To see it as someone invited to ${what} it does, open the link at the network address, here or on another device:`,
2058
+ ownerHere: `You are on the computer that shares this ${noun.en}. Open it as the owner at`
2059
+ });
2060
+ var ZH = (noun) => ({
2061
+ noAccessTitle: "\u65E0\u6743\u8BBF\u95EE",
2062
+ noAccess: `${noun.zhThis}\u6CA1\u6709\u5206\u4EAB\u7ED9\u4F60\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u94FE\u63A5\u3002`,
2063
+ linkGoneTitle: "\u94FE\u63A5\u5DF2\u5931\u6548",
2064
+ linkGone: `\u8FD9\u4E2A\u94FE\u63A5\u5DF2\u88AB\u5173\u95ED\u6216\u91CD\u7F6E\uFF0C\u6216\u8005${noun.zhThis}\u5DF2\u505C\u6B62\u5206\u4EAB\u3002\u8BF7\u5411\u6240\u6709\u8005\u8981\u4E00\u4E2A\u65B0\u94FE\u63A5\u3002`,
2065
+ invited: (what) => `\u4F60\u88AB\u9080\u8BF7${what}${noun.zhThis}\u3002`,
2066
+ yourName: "\u4F60\u7684\u540D\u5B57\uFF08\u522B\u4EBA\u4F1A\u5728\u4F60\u7684\u8BC4\u8BBA\u548C\u7F16\u8F91\u4E0A\u770B\u5230\u5B83\uFF09",
2067
+ open: `\u6253\u5F00${noun.zh}`,
2068
+ nameNote: "\u8FD9\u4E2A\u6D4F\u89C8\u5668\u4F1A\u8BB0\u4F4F\u4F60\u7684\u8EAB\u4EFD\uFF1B\u6240\u6709\u8005\u53EF\u4EE5\u968F\u65F6\u79FB\u9664\u3002",
2069
+ roles: { editor: "\u7F16\u8F91", commenter: "\u8BC4\u8BBA", viewer: "\u67E5\u770B" },
2070
+ youOwn: `\u8FD9\u662F\u4F60\u7684${noun.zh}\uFF1A\u5728\u8FD9\u53F0\u7535\u8111\u4E0A\u4F60\u662F\u6240\u6709\u8005\u3002`,
2071
+ openAsOwner: `\u6253\u5F00${noun.zh}`,
2072
+ tryRole: (what) => `\u60F3\u770B\u770B\u53D7\u9080${what}\u7684\u4EBA\u770B\u5230\u4EC0\u4E48\uFF0C\u7528\u5C40\u57DF\u7F51\u5730\u5740\u6253\u5F00\u8FD9\u4E2A\u94FE\u63A5\uFF08\u5728\u8FD9\u53F0\u7535\u8111\u6216\u522B\u7684\u8BBE\u5907\u4E0A\u90FD\u884C\uFF09\uFF1A`,
2073
+ ownerHere: `\u4F60\u5728\u5206\u4EAB${noun.zhThis}\u7684\u7535\u8111\u4E0A\u3002\u4EE5\u6240\u6709\u8005\u8EAB\u4EFD\u6253\u5F00\uFF1A`
2074
+ });
2075
+
2076
+ var whoOf = (viewer) => ({ by: "human", user: { id: viewer.user.id, name: viewer.user.name }, role: viewer.role });
2077
+ var MAX_BODY = 64 << 10;
2078
+ var HistoryHost = class {
2079
+ history;
2080
+ options;
2081
+ constructor(options) {
2082
+ this.options = options;
2083
+ this.history = options.history;
2084
+ }
2085
+ /** What the page's boot script tells the panel (window.__baoyuHistory); `preview` on a version's preview page. */
2086
+ bootGlobal(viewer, preview) {
2087
+ const global = { base: this.options.base, canRestore: canEditAs(viewer.role), me: viewer.user.id };
2088
+ const version = preview === void 0 ? void 0 : this.history.version(preview);
2089
+ if (version) {
2090
+ const newest = this.history.versions().at(-1);
2091
+ global.preview = { n: version.n, at: version.at, by: version.by, name: version.user.name, current: version === newest, current_url: this.options.base };
2092
+ }
2093
+ return global;
2094
+ }
2095
+ notices(viewer, label) {
2096
+ const head = this.history.head();
2097
+ const gone = (n) => !head.has(n.path) && this.history.version(n.n)?.changes[n.path] === null;
2098
+ return this.history.noticesFor(viewer.user.id).map((n) => noticeView(n, label, this.history.version(n.lost.n)?.at, gone(n)));
2099
+ }
2100
+ list(viewer) {
2101
+ const label = this.options.labels();
2102
+ const versions = this.history.versions();
2103
+ const changes = this.history.fileChanges();
2104
+ const newest = versions.at(-1);
2105
+ const rows = [...versions].reverse().map((v) => ({
2106
+ n: v.n,
2107
+ first: v.first,
2108
+ at: v.at,
2109
+ by: v.by,
2110
+ name: v.user.name,
2111
+ userId: v.user.id,
2112
+ ...v.role ? { role: v.role } : {},
2113
+ ...v.restoredFrom !== void 0 ? { restoredFrom: v.restoredFrom } : {},
2114
+ ...v.restoredPart ? { restoredPart: true } : {},
2115
+ ...v === newest ? { current: true } : {},
2116
+ preview: this.options.previewUrl(v.n),
2117
+ files: (changes.get(v.n) ?? []).map((c) => ({ path: c.path, op: c.op, label: label(c.path) })),
2118
+ ...v.overwrote?.length ? { overwrote: v.overwrote.map((o) => ({ path: o.path, label: label(o.path), name: o.user.name, by: o.by, n: o.n, ...o.soft ? { soft: true } : {} })) } : {}
2119
+ }));
2120
+ return { me: viewer.user.id, canRestore: canEditAs(viewer.role), rows, notices: this.notices(viewer, label) };
2121
+ }
2122
+ /** Answers `__history…` (`sub` is what follows it); false for a path it doesn't know. */
2123
+ async handle(request, response, sub, viewer) {
2124
+ const method = request.method ?? "GET";
2125
+ const url = new URL(request.url ?? "/", "http://local");
2126
+ if (method === "POST" && !sameOrigin(request)) {
2127
+ request.resume();
2128
+ sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
2129
+ return true;
2130
+ }
2131
+ if (method === "GET" && (sub === "" || sub === "/")) return sendJson2(response, 200, this.list(viewer)), true;
2132
+ if (sub === "/notices") {
2133
+ if (method === "GET") return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
2134
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" }), true;
2135
+ const body = await this.body(request, response);
2136
+ if (!body) return true;
2137
+ if (typeof body.dismiss !== "string") return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {dismiss: id}." }), true;
2138
+ this.history.dismiss(viewer.user.id, body.dismiss);
2139
+ return sendJson2(response, 200, { notices: this.notices(viewer, this.options.labels()) }), true;
2140
+ }
2141
+ const versionRoute = /^\/(\d+)\/(diff|restore)$/.exec(sub);
2142
+ if (versionRoute) {
2143
+ const n = Number(versionRoute[1]);
2144
+ const version = this.history.version(n);
2145
+ if (!version) return sendJson2(response, 404, { code: "not_found", message: `There is no version ${n} (it may be too old to keep).` }), true;
2146
+ if (versionRoute[2] === "diff") {
2147
+ if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
2148
+ const p = url.searchParams.get("path") ?? "";
2149
+ if (!(p in version.changes)) return sendJson2(response, 404, { code: "not_found", message: `Version ${n} did not change ${p}.` }), true;
2150
+ const before = this.history.stateBefore(n).get(p) ?? null;
2151
+ const after = version.changes[p] ?? null;
2152
+ return sendJson2(response, 200, this.diff(p, before, after)), true;
2153
+ }
2154
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
2155
+ if (!canEditAs(viewer.role)) return sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can bring a version back." }), true;
2156
+ const body = await this.body(request, response);
2157
+ if (!body) return true;
2158
+ const paths = Array.isArray(body.paths) ? body.paths.filter((p) => typeof p === "string") : void 0;
2159
+ const changes = this.history.restoreTo(n, paths, this.options.tracked);
2160
+ if (!changes) return sendJson2(response, 409, { code: "unavailable", message: `Version ${n} can't be brought back: a file it holds is missing from the history.` }), true;
2161
+ const made = await this.options.apply(changes, whoOf(viewer), { restoredFrom: n });
2162
+ this.options.log(`history: ${viewer.user.name} brought back version ${n}${paths ? ` (${paths.join(", ")})` : ""}${made ? ` as version ${made.n}` : " (nothing to change)"}`);
2163
+ return sendJson2(response, 200, { version: made?.n ?? null, changed: [...changes.keys()] }), true;
2164
+ }
2165
+ const noticeRoute = /^\/notice\/([0-9a-f]{6,32})\/(diff|recover)$/.exec(sub);
2166
+ if (noticeRoute) {
2167
+ const notice = this.history.notice(noticeRoute[1]);
2168
+ if (!notice || notice.to !== viewer.user.id) return sendJson2(response, 404, { code: "not_found", message: "No such notice." }), true;
2169
+ if (noticeRoute[2] === "diff") {
2170
+ if (method !== "GET") return sendJson2(response, 405, { code: "method_not_allowed", message: "GET" }), true;
2171
+ const now2 = this.history.head().get(notice.path) ?? null;
2172
+ const nowBytes = now2 === null ? null : this.history.read(now2);
2173
+ const back2 = this.recovered(notice, nowBytes);
2174
+ if (now2 && !nowBytes || !back2) return sendJson2(response, 200, { path: notice.path, label: this.options.labels()(notice.path), missing: true, added: 0, removed: 0, lines: [], now: now2 }), true;
2175
+ return sendJson2(response, 200, { ...this.diffBytes(notice.path, nowBytes, back2.bytes), now: now2 }), true;
2176
+ }
2177
+ if (method !== "POST") return sendJson2(response, 405, { code: "method_not_allowed", message: "POST" }), true;
2178
+ if (!canEditAs(viewer.role)) return request.resume(), sendJson2(response, 403, { code: "not_writer", message: "Only the owner and editors can change the files." }), true;
2179
+ const body = await this.body(request, response);
2180
+ if (!body) return true;
2181
+ const now = this.history.head().get(notice.path) ?? null;
2182
+ if ("now" in body && body.now !== now) {
2183
+ return sendJson2(response, 409, { code: "changed", message: "This changed while you were looking. Compare again." }), true;
2184
+ }
2185
+ const back = this.recovered(notice, now === null ? null : this.history.read(now));
2186
+ if (!back) return sendJson2(response, 409, { code: "unavailable", message: "That content is no longer in the history." }), true;
2187
+ const made = await this.options.apply(/* @__PURE__ */ new Map([[notice.path, back.bytes]]), whoOf(viewer), { restoredFrom: notice.lost.n, ...back.part ? { restoredPart: true } : {} });
2188
+ this.history.dismiss(viewer.user.id, notice.id);
2189
+ this.options.log(`history: ${viewer.user.name} brought back ${notice.lost.user.name}'s ${back.bytes === null ? "deletion of " : ""}${notice.path} (version ${notice.lost.n}${back.part ? ", what was replaced" : ""})`);
2190
+ return sendJson2(response, 200, { version: made?.n ?? null }), true;
2191
+ }
2192
+ return false;
2193
+ }
2194
+ /** Before → after of one file, with the versions' names for the panel's header. */
2195
+ diff(p, before, after) {
2196
+ const read = (sha) => sha === null ? null : this.history.read(sha);
2197
+ const a = read(before);
2198
+ const b = read(after);
2199
+ if (before && !a || after && !b) return { path: p, label: this.options.labels()(p), missing: true, added: 0, removed: 0, lines: [] };
2200
+ return this.diffBytes(p, a, b);
2201
+ }
2202
+ diffBytes(p, a, b) {
2203
+ const [x, y] = this.options.diffSides ? this.options.diffSides(p, a, b) : [a, b];
2204
+ return { path: p, label: this.options.labels()(p), ...diffContents(x, y) };
2205
+ }
2206
+ /**
2207
+ * What bringing a notice's lost content back writes to its file (`now`: what the file holds): the lost content merged
2208
+ * into it when the notice has a base and the server can merge (`part` when that is not the whole lost content), else
2209
+ * the whole lost content; a deletion deletes it again (bytes null). null: not in the history.
2210
+ */
2211
+ recovered(notice, now) {
2212
+ if (notice.lost.sha === null) return { bytes: null, part: false };
2213
+ const lost = this.history.read(notice.lost.sha);
2214
+ if (!lost) return null;
2215
+ const base = notice.base ? this.history.read(notice.base) : null;
2216
+ const merged = base && this.options.recover ? this.options.recover(notice.path, base, lost, now) : null;
2217
+ return merged ? { bytes: merged, part: !merged.equals(lost) } : { bytes: lost, part: false };
2218
+ }
2219
+ async body(request, response) {
2220
+ try {
2221
+ const raw = (await readBody(request, MAX_BODY)).toString("utf8");
2222
+ const body = raw.trim() ? JSON.parse(raw) : {};
2223
+ if (typeof body === "object" && body !== null && !Array.isArray(body)) return body;
2224
+ } catch {
2225
+ }
2226
+ sendJson2(response, 400, { code: "invalid_argument", message: "Expected a JSON object." });
2227
+ return null;
2228
+ }
2229
+ };
2230
+ function noticeView(n, label, lostAt, gone) {
2231
+ return {
2232
+ ...lostAt ? { lostAt } : {},
2233
+ id: n.id,
2234
+ kind: n.kind,
2235
+ at: n.at,
2236
+ path: n.path,
2237
+ label: label(n.path),
2238
+ n: n.n,
2239
+ name: n.by.name,
2240
+ by: n.byAuthor,
2241
+ lostName: n.lost.user.name,
2242
+ lostBy: n.lost.by,
2243
+ lostN: n.lost.n,
2244
+ ...n.soft ? { soft: true } : {},
2245
+ // Bringing it back puts back only what was replaced (a write with a base); into a deleted file, all of it.
2246
+ ...n.base && !gone ? { part: true } : {},
2247
+ ...gone ? { removed: true } : {},
2248
+ // What was replaced is a deletion: bringing it back deletes the file again.
2249
+ ...n.lost.sha === null ? { deleted: true } : {}
2250
+ };
2251
+ }
2252
+ var plain = (text) => ({ en: text, zh: text });
2253
+
2254
+ var PEER_ID = /^[A-Za-z0-9_-]{8,64}$/;
2255
+ function agentWhere(main2, count, label) {
2256
+ const where = main2 ? label(main2) : null;
2257
+ if (!where || count <= 1) return where;
2258
+ return { en: `${where.en} and ${count - 1} more`, zh: `${where.zh} \u7B49 ${count} \u5904` };
2259
+ }
2260
+ var AGENT_COLOR = "#d25f25";
2261
+ var MAX_PEERS = 64;
2262
+ var MAX_PATCH_BYTES = 16 << 10;
2263
+ var MAX_FIELDS = 12;
2264
+ var MAX_PRESENCE_BYTES = 6 << 10;
2265
+ var BROADCAST_MS = 50;
2266
+ var LEAVE_GRACE_MS = 4e3;
2267
+ var AGENT_LINGER_MS = 2e4;
2268
+ var AGENT_PEER = "agent-claude";
2269
+ var PresenceHub = class {
2270
+ peers = /* @__PURE__ */ new Map();
2271
+ listeners = /* @__PURE__ */ new Set();
2272
+ agent = null;
2273
+ timer = null;
2274
+ /** Adds a page's stream; `send` writes one SSE frame to it. Returns what to call when the stream closes. */
2275
+ join(peer, viewer, send) {
2276
+ let entry = this.peers.get(peer);
2277
+ if (entry && entry.viewer.user.id !== viewer.user.id) {
2278
+ entry = void 0;
2279
+ peer = "";
2280
+ }
2281
+ if (peer && !entry && this.peers.size < MAX_PEERS) {
2282
+ entry = { viewer, presence: {}, updatedAt: Date.now(), streams: 0 };
2283
+ this.peers.set(peer, entry);
2284
+ }
2285
+ if (entry) {
2286
+ entry.streams += 1;
2287
+ if (entry.leaving) clearTimeout(entry.leaving);
2288
+ entry.leaving = void 0;
2289
+ }
2290
+ this.listeners.add(send);
2291
+ send(this.frame());
2292
+ this.schedule();
2293
+ let open = true;
2294
+ return () => {
2295
+ if (!open) return;
2296
+ open = false;
2297
+ this.listeners.delete(send);
2298
+ if (!entry) return;
2299
+ entry.streams -= 1;
2300
+ if (entry.streams > 0) return;
2301
+ const leaving = entry;
2302
+ leaving.leaving = setTimeout(() => {
2303
+ if (this.peers.get(peer) === leaving && leaving.streams === 0) {
2304
+ this.peers.delete(peer);
2305
+ this.schedule();
2306
+ }
2307
+ }, LEAVE_GRACE_MS);
2308
+ leaving.leaving.unref?.();
2309
+ };
2310
+ }
2311
+ /** Merges a page's patch into its presence; false when the peer isn't this person's open page or the patch is bad. */
2312
+ update(peer, viewer, patch) {
2313
+ const entry = this.peers.get(peer);
2314
+ if (!entry || entry.viewer.user.id !== viewer.user.id) return false;
2315
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) return false;
2316
+ const next = { ...entry.presence };
2317
+ for (const [key, value] of Object.entries(patch)) {
2318
+ if (value === null || value === void 0) delete next[key];
2319
+ else next[key] = value;
2320
+ }
2321
+ if (Object.keys(next).length > MAX_FIELDS || Buffer.byteLength(JSON.stringify(next)) > MAX_PRESENCE_BYTES) return false;
2322
+ entry.presence = next;
2323
+ entry.updatedAt = Date.now();
2324
+ this.schedule();
2325
+ return true;
2326
+ }
2327
+ /** The agent changed the artifact: it shows on the list (with `context`, where it worked) for a while. */
2328
+ agentActive(agent = {}) {
2329
+ if (this.agent) clearTimeout(this.agent.timer);
2330
+ const presence = { who: { n: agent.name ?? "Agent", c: AGENT_COLOR } };
2331
+ if (agent.context) presence.context = agent.context;
2332
+ if (agent.where) presence.where = agent.where;
2333
+ const timer = setTimeout(() => {
2334
+ this.agent = null;
2335
+ this.schedule();
2336
+ }, AGENT_LINGER_MS);
2337
+ timer.unref?.();
2338
+ this.agent = { presence, updatedAt: Date.now(), timer };
2339
+ this.schedule();
2340
+ }
2341
+ /** Everyone on the list now. */
2342
+ list() {
2343
+ const views = [];
2344
+ for (const [peer, entry] of this.peers) {
2345
+ const who = entry.presence.who;
2346
+ const presence = { ...entry.presence, who: { ...typeof who === "object" && who !== null && !Array.isArray(who) ? who : {}, n: entry.viewer.user.name } };
2347
+ views.push({ peer, by: entry.viewer.user.id, name: entry.viewer.user.name, role: entry.viewer.role, presence, updatedAt: entry.updatedAt });
2348
+ }
2349
+ if (this.agent) {
2350
+ const name = String(this.agent.presence.who.n ?? "Agent");
2351
+ views.push({ peer: AGENT_PEER, by: "agent", name, kind: "agent", presence: this.agent.presence, updatedAt: this.agent.updatedAt });
2352
+ }
2353
+ return views;
2354
+ }
2355
+ /** Answers POST `__presence` ({peer, patch}); GET gives the list. */
2356
+ async handle(request, response, viewer) {
2357
+ const method = request.method ?? "GET";
2358
+ if (method === "GET") return sendJson2(response, 200, { peers: this.list() });
2359
+ if (method !== "POST") {
2360
+ request.resume();
2361
+ return sendJson2(response, 405, { code: "method_not_allowed", message: "GET or POST" });
2362
+ }
2363
+ if (!sameOrigin(request)) {
2364
+ request.resume();
2365
+ return sendJson2(response, 403, { code: "not_granted", message: "Cross-origin write refused." });
2366
+ }
2367
+ let body;
2368
+ try {
2369
+ body = JSON.parse((await readBody(request, MAX_PATCH_BYTES)).toString("utf8"));
2370
+ } catch {
2371
+ return sendJson2(response, 400, { code: "invalid_argument", message: `Expected {peer, patch} under ${MAX_PATCH_BYTES} bytes.` });
2372
+ }
2373
+ const { peer, patch } = typeof body === "object" && body !== null ? body : {};
2374
+ if (typeof peer !== "string" || !PEER_ID.test(peer)) return sendJson2(response, 400, { code: "invalid_argument", message: "Expected {peer, patch}." });
2375
+ if (!this.update(peer, viewer, patch)) return sendJson2(response, 409, { code: "not_joined", message: "That page is not open here (its event stream is closed)." });
2376
+ response.writeHead(204, { "Cache-Control": "no-store" }).end();
2377
+ }
2378
+ /** Ends the timers (the server is closing). */
2379
+ close() {
2380
+ if (this.timer) clearTimeout(this.timer);
2381
+ if (this.agent) clearTimeout(this.agent.timer);
2382
+ for (const entry of this.peers.values()) if (entry.leaving) clearTimeout(entry.leaving);
2383
+ this.peers.clear();
2384
+ this.listeners.clear();
2385
+ }
2386
+ frame() {
2387
+ return `event: peers
2388
+ data: ${JSON.stringify({ peers: this.list() })}
2389
+
2390
+ `;
2391
+ }
2392
+ schedule() {
2393
+ if (this.timer) return;
2394
+ this.timer = setTimeout(() => {
2395
+ this.timer = null;
2396
+ const frame = this.frame();
2397
+ for (const send of this.listeners) send(frame);
2398
+ }, BROADCAST_MS);
2399
+ this.timer.unref?.();
2400
+ }
2401
+ };
2402
+
816
2403
  var KIND = "";
817
2404
  var NOUN = "";
818
2405
  var INDEX_FILE = "";
@@ -820,6 +2407,8 @@ var LAYOUT;
820
2407
  var pageScriptSrc;
821
2408
  var sessionExtras;
822
2409
  var commentLocation;
2410
+ var kindLabels;
2411
+ var kindAgentContext;
823
2412
  var SCRIPT = "";
824
2413
  var EDITOR_DIR = "";
825
2414
  var MIME = {
@@ -893,6 +2482,9 @@ var ROUTES = {
893
2482
  session: "__deck/session"
894
2483
  };
895
2484
  var COMMENTS_ROUTE = "__comments";
2485
+ var HISTORY_ROUTE = "__history";
2486
+ var PRESENCE_ROUTE = "__presence";
2487
+ var PREVIEW_PATH = /^v\/(\d{1,9})(\/.*)?$/;
896
2488
  var ASSET_INDEX = "index.json";
897
2489
  var BLOB_ID = /^[0-9a-f]{32}$/;
898
2490
  var BLOB_PATH = /^_blob\/([0-9a-f]{32})$/;
@@ -903,7 +2495,11 @@ var SSE_KEEPALIVE_MS2 = 25e3;
903
2495
  var WATCH_DEBOUNCE_MS2 = 300;
904
2496
  var POLL_MS2 = 2e3;
905
2497
  var CHANGE_LOG_SIZE = 64;
906
- var sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
2498
+ var OVERWRITE_WINDOW_MS = 6e4;
2499
+ var filesOf = (listing, paths) => Object.fromEntries(paths.map((p) => [p, listing.files[p]?.sha256 ?? null]));
2500
+ var authorOf = (who) => ({ id: who.user.id, name: who.user.name, ...who.by === "agent" ? { agent: true } : {} });
2501
+ var CLIENT_ID = /^[A-Za-z0-9_-]{8,64}$/;
2502
+ var sha2562 = (bytes) => createHash3("sha256").update(bytes).digest("hex");
907
2503
  function contentTypeOf(file) {
908
2504
  const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
909
2505
  return MIME[ext] ?? "application/octet-stream";
@@ -913,6 +2509,7 @@ function isProjectPath(p) {
913
2509
  if (typeof p !== "string" || !p.startsWith("project/") || p.includes("\\") || p.includes("\0")) return false;
914
2510
  return p.split("/").every((part) => part !== "" && part !== "." && part !== "..");
915
2511
  }
2512
+ var SHA256 = /^[0-9a-f]{64}$/;
916
2513
  var isWriteEntry = (entry) => entry !== null && !("delete" in entry);
917
2514
  function isWireEntry(value) {
918
2515
  if (value === null) return true;
@@ -940,7 +2537,7 @@ function pageCsp(scriptSrc) {
940
2537
  ].join("; ");
941
2538
  }
942
2539
  function versionOf(files) {
943
- const digest = sha256(
2540
+ const digest = sha2562(
944
2541
  Object.entries(files).map(([p, f]) => `${p}:${f.sha256}`).join("\n")
945
2542
  );
946
2543
  return "local-" + digest.slice(0, 16);
@@ -951,7 +2548,7 @@ async function collectFiles(root, limit) {
951
2548
  const seen = /* @__PURE__ */ new Set();
952
2549
  let truncated = false;
953
2550
  const visit = async (relDir, descend) => {
954
- const dir = relDir === "" ? root : path3.join(root, ...relDir.split("/"));
2551
+ const dir = relDir === "" ? root : path6.join(root, ...relDir.split("/"));
955
2552
  let entries;
956
2553
  try {
957
2554
  entries = await fsp2.readdir(dir, { withFileTypes: true });
@@ -982,8 +2579,8 @@ async function collectFiles(root, limit) {
982
2579
  return { rels, truncated };
983
2580
  }
984
2581
  async function writeFileAtomic(file, bytes) {
985
- await fsp2.mkdir(path3.dirname(file), { recursive: true });
986
- const tmp = path3.join(path3.dirname(file), `.${path3.basename(file)}.${randomUUID()}.tmp`);
2582
+ await fsp2.mkdir(path6.dirname(file), { recursive: true });
2583
+ const tmp = path6.join(path6.dirname(file), `.${path6.basename(file)}.${randomUUID()}.tmp`);
987
2584
  try {
988
2585
  await fsp2.writeFile(tmp, bytes);
989
2586
  await fsp2.rename(tmp, file);
@@ -995,19 +2592,27 @@ async function writeFileAtomic(file, bytes) {
995
2592
  }
996
2593
  var Store = class {
997
2594
  root;
2595
+ history;
998
2596
  log;
999
2597
  /** project/<path> → sha256 of the page's last save not yet seen by the watcher ('' for a delete). */
1000
2598
  ownWrites;
2599
+ /** "<user id>\0project/<path>" → when the server last refused that person's save of the file as a conflict. */
2600
+ refused;
1001
2601
  queue;
1002
2602
  seq;
1003
2603
  truncationReported;
1004
- constructor(root, log) {
2604
+ /** The last listing left files out (more than the format's limit): its missing files are not deletes. */
2605
+ truncated;
2606
+ constructor(root, history, log) {
1005
2607
  this.root = root;
2608
+ this.history = history;
1006
2609
  this.log = log;
1007
2610
  this.ownWrites = /* @__PURE__ */ new Map();
2611
+ this.refused = /* @__PURE__ */ new Map();
1008
2612
  this.queue = Promise.resolve();
1009
2613
  this.seq = 0;
1010
2614
  this.truncationReported = false;
2615
+ this.truncated = false;
1011
2616
  }
1012
2617
  /** Run `work` after every save, upload and change check before it, and before the next. */
1013
2618
  exclusive(work) {
@@ -1025,11 +2630,11 @@ var Store = class {
1025
2630
  async diskPath(rel) {
1026
2631
  const realRoot = await fsp2.realpath(this.root).catch(() => null);
1027
2632
  if (realRoot === null) return null;
1028
- const file = path3.join(realRoot, ...rel.split("/"));
1029
- for (let dir = path3.dirname(file); ; dir = path3.dirname(dir)) {
2633
+ const file = path6.join(realRoot, ...rel.split("/"));
2634
+ for (let dir = path6.dirname(file); ; dir = path6.dirname(dir)) {
1030
2635
  const real = await fsp2.realpath(dir).catch(() => null);
1031
2636
  if (real === null) {
1032
- if (path3.dirname(dir) === dir) return null;
2637
+ if (path6.dirname(dir) === dir) return null;
1033
2638
  continue;
1034
2639
  }
1035
2640
  if (real !== dir) return null;
@@ -1041,6 +2646,7 @@ var Store = class {
1041
2646
  async listing() {
1042
2647
  const limit = LAYOUT.maxFiles;
1043
2648
  const { rels, truncated } = await collectFiles(this.root, limit);
2649
+ this.truncated = truncated;
1044
2650
  if (truncated && !this.truncationReported) {
1045
2651
  this.truncationReported = true;
1046
2652
  this.log(`The ${NOUN} folder has more than ${limit} files; the editor lists only the first ${limit}: ${this.root}`);
@@ -1051,7 +2657,7 @@ var Store = class {
1051
2657
  if (file === null) continue;
1052
2658
  const bytes = await fsp2.readFile(file).catch(() => null);
1053
2659
  if (bytes === null) continue;
1054
- files[`project/${rel}`] = { sha256: sha256(bytes), size: bytes.length, contentType: contentTypeOf(rel) };
2660
+ files[`project/${rel}`] = { sha256: sha2562(bytes), size: bytes.length, contentType: contentTypeOf(rel) };
1055
2661
  }
1056
2662
  return { ver: versionOf(files), files };
1057
2663
  }
@@ -1061,11 +2667,14 @@ var Store = class {
1061
2667
  if (file === null) return null;
1062
2668
  return fsp2.readFile(file).catch(() => null);
1063
2669
  }
1064
- /** A save from the page: all entries or none (ifMatch is checked for all before anything is written). */
1065
- publish(batch) {
1066
- return this.exclusive(() => this.publishNow(batch));
2670
+ /**
2671
+ * A save from the page by `who`: all entries or none (ifMatch is checked for all before anything is written). It is
2672
+ * a version in the history under their name; what changed on disk before it is one of its own, Claude's.
2673
+ */
2674
+ publish(batch, who, over = [], merged = []) {
2675
+ return this.exclusive(() => this.publishNow(batch, who, over, merged));
1067
2676
  }
1068
- async publishNow(batch) {
2677
+ async publishNow(batch, who, over, merged) {
1069
2678
  const files = /* @__PURE__ */ new Map();
1070
2679
  let total = 0;
1071
2680
  for (const [p, entry] of Object.entries(batch)) {
@@ -1084,6 +2693,7 @@ var Store = class {
1084
2693
  return { status: 413, body: { code: "too_large", message: `a publish can write at most ${LAYOUT.maxPublishBytes} bytes` } };
1085
2694
  }
1086
2695
  const before = await this.listing();
2696
+ await this.syncDisk(before);
1087
2697
  const conflicts = [];
1088
2698
  for (const [p, entry] of Object.entries(batch)) {
1089
2699
  const expected = entry?.ifMatch;
@@ -1091,24 +2701,121 @@ var Store = class {
1091
2701
  const actual = before.files[p]?.sha256 ?? null;
1092
2702
  if (actual !== expected) conflicts.push({ path: p, actual, expected });
1093
2703
  }
1094
- if (conflicts.length > 0) return { status: 409, body: { code: "conflict", live: before.ver, paths: conflicts } };
2704
+ if (conflicts.length > 0) {
2705
+ const now = Date.now();
2706
+ for (const c of conflicts) this.refused.set(`${who.user.id}\0${c.path}`, now);
2707
+ return { status: 409, body: { code: "conflict", live: before.ver, paths: conflicts } };
2708
+ }
2709
+ for (const m of merged) {
2710
+ if (batch[m.path] !== void 0 && before.files[m.path]?.sha256 === m.sha) this.refused.delete(`${who.user.id}\0${m.path}`);
2711
+ }
2712
+ const chosen = /* @__PURE__ */ new Map();
2713
+ for (const o of over) {
2714
+ if (batch[o.path] === void 0 || before.files[o.path]?.sha256 !== o.sha) continue;
2715
+ this.refused.set(`${who.user.id}\0${o.path}`, Date.now());
2716
+ chosen.set(o.path, o.base);
2717
+ }
2718
+ const overwrote = this.overwrites(batch, before, who, chosen);
1095
2719
  const shas = {};
2720
+ const written = /* @__PURE__ */ new Map();
1096
2721
  for (const [p, entry] of Object.entries(batch)) {
1097
2722
  const file = files.get(p);
1098
2723
  if (!isWriteEntry(entry)) {
1099
2724
  this.ownWrites.set(p, "");
1100
2725
  await fsp2.rm(file, { force: true });
2726
+ written.set(p, null);
1101
2727
  continue;
1102
2728
  }
1103
2729
  const bytes = Buffer.from(entry.base64, "base64");
1104
- const sha = sha256(bytes);
2730
+ const sha = sha2562(bytes);
1105
2731
  this.ownWrites.set(p, sha);
1106
2732
  await writeFileAtomic(file, bytes);
1107
2733
  shas[p] = sha;
2734
+ written.set(p, bytes);
1108
2735
  }
2736
+ const version = this.history.record(written, who, overwrote.length ? { overwrote } : {});
1109
2737
  const after = await this.listing();
1110
2738
  this.seq += 1;
1111
- return { status: 200, body: { version: after.ver, seq: this.seq, shas } };
2739
+ return { status: 200, body: { version: after.ver, seq: this.seq, shas }, version };
2740
+ }
2741
+ /**
2742
+ * The files this save replaces without its page having seen them: the server refused this person's save of the file
2743
+ * as a conflict moments ago (someone else had saved it since their page loaded it), and now the page saves on top of
2744
+ * what that someone saved. (The editors take the newer file and save their own over it.) Their own other tab's save,
2745
+ * and the content the history began with, are no one else's. `chosen`: the files the page says its person saw and
2746
+ * chose to save over (marked as refused above), whose overwrite is `chosen`, with the version the edits started
2747
+ * from as its `base` when the history holds that content (and it is not what was lost, which would bring back nothing).
2748
+ * A page that names no base (Keep mine from a bar or a banner, a save on top of a refused one) gets the file as it was
2749
+ * right before the lost version: what both started from when one saved while the other was editing, so Bring back
2750
+ * returns what that version changed and leaves the saver's other changes.
2751
+ */
2752
+ overwrites(batch, before, who, chosen) {
2753
+ const now = Date.now();
2754
+ const out = [];
2755
+ for (const [p, entry] of Object.entries(batch)) {
2756
+ const key = `${who.user.id}\0${p}`;
2757
+ const at = this.refused.get(key);
2758
+ if (at === void 0) continue;
2759
+ this.refused.delete(key);
2760
+ const lost = before.files[p]?.sha256;
2761
+ if (now - at > OVERWRITE_WINDOW_MS || !lost) continue;
2762
+ if ((isWriteEntry(entry) ? sha2562(Buffer.from(entry.base64, "base64")) : null) === lost) continue;
2763
+ const last = this.history.lastChange(p);
2764
+ if (!last || last.sha !== lost || last.version.by === "system" || last.version.user.id === who.user.id) continue;
2765
+ const base = chosen.get(p) ?? this.history.stateBefore(last.version.n).get(p);
2766
+ out.push({
2767
+ path: p,
2768
+ lost,
2769
+ n: last.version.n,
2770
+ by: last.version.by,
2771
+ user: { ...last.version.user },
2772
+ ...chosen.has(p) ? { chosen: true } : {},
2773
+ ...base && base !== lost && this.history.size(base) !== null ? { base } : {}
2774
+ });
2775
+ }
2776
+ for (const [key, at] of this.refused) if (now - at > OVERWRITE_WINDOW_MS) this.refused.delete(key);
2777
+ return out;
2778
+ }
2779
+ /**
2780
+ * Records what changed on disk since the history last looked, as Claude's (the agent's, or anything else on this
2781
+ * computer; the files as they were when the history began are a version of no one's). Runs inside `exclusive`.
2782
+ */
2783
+ async syncDisk(listing) {
2784
+ const head = this.history.head();
2785
+ const changes = /* @__PURE__ */ new Map();
2786
+ for (const [p, f] of Object.entries(listing.files)) {
2787
+ if (head.get(p) === f.sha256) continue;
2788
+ const bytes = await this.readProjectFile(p);
2789
+ if (bytes !== null) changes.set(p, bytes);
2790
+ }
2791
+ if (!this.truncated) {
2792
+ for (const p of head.keys()) if (!(p in listing.files) && this.fileOf(p) !== null) changes.set(p, null);
2793
+ }
2794
+ if (changes.size === 0) return null;
2795
+ const version = this.history.record(changes, this.history.empty ? SYSTEM : AGENT);
2796
+ if (version) this.log(`history: version ${version.n}${version.by === "agent" ? " (changed on disk)" : " (the files as they were)"}: ${[...changes.keys()].map((p) => p.replace(/^project\//, "")).join(", ")}`);
2797
+ return version;
2798
+ }
2799
+ /** Writes contents from the history (a restore) and records them as `who`'s version. */
2800
+ applyFromHistory(changes, who, extra) {
2801
+ return this.exclusive(async () => {
2802
+ await this.syncDisk(await this.listing());
2803
+ const written = /* @__PURE__ */ new Map();
2804
+ for (const [p, bytes] of changes) {
2805
+ const rel = this.fileOf(p);
2806
+ const file = rel === null ? null : await this.diskPath(rel);
2807
+ if (file === null) continue;
2808
+ if (bytes === null) {
2809
+ this.ownWrites.set(p, "");
2810
+ await fsp2.rm(file, { force: true });
2811
+ } else {
2812
+ this.ownWrites.set(p, sha2562(bytes));
2813
+ await writeFileAtomic(file, bytes);
2814
+ }
2815
+ written.set(p, bytes);
2816
+ }
2817
+ return { version: this.history.record(written, who, extra), paths: [...written.keys()] };
2818
+ });
1112
2819
  }
1113
2820
  async assetIndex() {
1114
2821
  try {
@@ -1125,7 +2832,7 @@ var Store = class {
1125
2832
  return this.exclusive(() => this.putBlobNow(bytes, contentType));
1126
2833
  }
1127
2834
  async putBlobNow(bytes, contentType) {
1128
- const id = sha256(bytes).slice(0, 32);
2835
+ const id = sha2562(bytes).slice(0, 32);
1129
2836
  const type = (contentType.split(";")[0] ?? "").trim().toLowerCase() || "application/octet-stream";
1130
2837
  if (await this.findBlobFile(id) === null) {
1131
2838
  const file = await this.diskPath(`${LAYOUT.blobDir}/${id}.${assetExtensionOf(type)}`);
@@ -1142,7 +2849,7 @@ var Store = class {
1142
2849
  }
1143
2850
  async findBlobFile(id) {
1144
2851
  try {
1145
- const names = await fsp2.readdir(path3.join(this.root, LAYOUT.blobDir));
2852
+ const names = await fsp2.readdir(path6.join(this.root, LAYOUT.blobDir));
1146
2853
  const name = names.find((n) => n.startsWith(`${id}.`) && !n.endsWith(".tmp"));
1147
2854
  if (name === void 0) return null;
1148
2855
  const file = await this.diskPath(`${LAYOUT.blobDir}/${name}`);
@@ -1169,7 +2876,7 @@ var Store = class {
1169
2876
  let sha = "";
1170
2877
  try {
1171
2878
  const file = await this.diskPath(rel);
1172
- if (file !== null && (await fsp2.lstat(file)).isFile()) sha = sha256(await fsp2.readFile(file));
2879
+ if (file !== null && (await fsp2.lstat(file)).isFile()) sha = sha2562(await fsp2.readFile(file));
1173
2880
  } catch {
1174
2881
  sha = "";
1175
2882
  }
@@ -1197,13 +2904,13 @@ function watchArtifact(store, onChange, onError) {
1197
2904
  const directories = /* @__PURE__ */ new Map();
1198
2905
  const visit = (rel, descend) => {
1199
2906
  if (directories.size >= maxDirectories) return;
1200
- const dir = rel === "" ? store.root : path3.join(store.root, ...rel.split("/"));
1201
- if (directories.has(dir) || !fs4.existsSync(dir)) return;
1202
- const info = fs4.lstatSync(dir);
2907
+ const dir = rel === "" ? store.root : path6.join(store.root, ...rel.split("/"));
2908
+ if (directories.has(dir) || !fs7.existsSync(dir)) return;
2909
+ const info = fs7.lstatSync(dir);
1203
2910
  if (!info.isDirectory()) return;
1204
2911
  directories.set(dir, `${info.dev}:${info.ino}`);
1205
2912
  if (!descend) return;
1206
- for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
2913
+ for (const entry of fs7.readdirSync(dir, { withFileTypes: true })) {
1207
2914
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
1208
2915
  const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
1209
2916
  if (LAYOUT.descendInto(child)) visit(child, true);
@@ -1220,19 +2927,19 @@ function watchArtifact(store, onChange, onError) {
1220
2927
  if (watchers.has(dir)) continue;
1221
2928
  let watcher;
1222
2929
  try {
1223
- watcher = fs4.watch(dir, (event, name) => {
2930
+ watcher = fs7.watch(dir, (event, name) => {
1224
2931
  if (stopped) return;
1225
2932
  if (name) {
1226
- const changed = path3.join(dir, String(name));
2933
+ const changed = path6.join(dir, String(name));
1227
2934
  if (event === "rename" && watchers.has(changed)) {
1228
2935
  for (const [watchedDir, item] of watchers) {
1229
- if (watchedDir === changed || watchedDir.startsWith(changed + path3.sep)) {
2936
+ if (watchedDir === changed || watchedDir.startsWith(changed + path6.sep)) {
1230
2937
  item.watcher.close();
1231
2938
  watchers.delete(watchedDir);
1232
2939
  }
1233
2940
  }
1234
2941
  }
1235
- const rel = path3.relative(store.root, changed).split(path3.sep).join("/");
2942
+ const rel = path6.relative(store.root, changed).split(path6.sep).join("/");
1236
2943
  if (isIgnoredPath(rel) || !LAYOUT.watches(rel)) return;
1237
2944
  }
1238
2945
  schedule();
@@ -1250,6 +2957,7 @@ function watchArtifact(store, onChange, onError) {
1250
2957
  try {
1251
2958
  refresh();
1252
2959
  const current = await store.listing();
2960
+ await store.syncDisk(current);
1253
2961
  if (seen === null) {
1254
2962
  seen = new Map(Object.entries(current.files).map(([p, f]) => [p, f.sha256]));
1255
2963
  pollSignature = await signature();
@@ -1257,7 +2965,8 @@ function watchArtifact(store, onChange, onError) {
1257
2965
  }
1258
2966
  const external = await store.externalChanges(/* @__PURE__ */ new Set([...seen.keys(), ...Object.keys(current.files)]), seen);
1259
2967
  if (stopped || external.length === 0) return;
1260
- onChange({ type: "external-change", paths: external, ver: (await store.listing()).ver });
2968
+ const after = await store.listing();
2969
+ onChange({ type: "external-change", paths: external, ver: after.ver, files: filesOf(after, external) });
1261
2970
  } catch (error) {
1262
2971
  onError(error);
1263
2972
  }
@@ -1266,7 +2975,7 @@ function watchArtifact(store, onChange, onError) {
1266
2975
  const { rels } = await collectFiles(store.root, LAYOUT.maxFiles);
1267
2976
  const parts = [];
1268
2977
  for (const rel of rels) {
1269
- const info = await fsp2.lstat(path3.join(store.root, ...rel.split("/"))).catch(() => null);
2978
+ const info = await fsp2.lstat(path6.join(store.root, ...rel.split("/"))).catch(() => null);
1270
2979
  parts.push(info ? `${rel}\0${info.size}\0${info.mtimeMs}\0${info.ino}` : `${rel}\0-`);
1271
2980
  }
1272
2981
  return parts.join("\n");
@@ -1295,23 +3004,46 @@ function watchArtifact(store, onChange, onError) {
1295
3004
  };
1296
3005
  }
1297
3006
  var publishError = (response, status, code, message) => sendJson2(response, status, { code, message });
1298
- async function startServer({ root, port = 0, host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
1299
- `), agentHook }) {
1300
- const project = path3.join(root, "project");
3007
+ async function startServer({ root, port = DEFAULT_PORTS[KIND], host = "127.0.0.1", log = (line) => process.stderr.write(`${line}
3008
+ `), agentHook, lan = false, portGiven = false }) {
3009
+ const project = path6.join(root, "project");
1301
3010
  const stamp = (line) => log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}`);
1302
3011
  const projectDir = await fsp2.realpath(project);
1303
- const store = new Store(projectDir, stamp);
3012
+ const history = new History(root);
3013
+ const store = new Store(projectDir, history, stamp);
3014
+ const owner = localOwner();
3015
+ const share = new ShareHost({
3016
+ root,
3017
+ cookieName: guestCookieName(KIND, root),
3018
+ owner,
3019
+ noun: NOUNS[KIND] ?? { en: NOUN, zh: NOUN, zhThis: NOUN },
3020
+ title: () => artifactTitle(root, projectDir),
3021
+ log: stamp,
3022
+ createServer: () => http.createServer(handle),
3023
+ uiFile: path6.join(EDITOR_DIR, "share-ui.js"),
3024
+ // The History panel's address and what this person may do there; on a version's page (?at=n), which version.
3025
+ globals: (viewer, query) => {
3026
+ const at = Number(query.get("at") ?? "");
3027
+ return { __baoyuHistory: historyHost.bootGlobal(viewer, query.has("at") && Number.isInteger(at) ? at : void 0) };
3028
+ }
3029
+ });
1304
3030
  const comments = createCommentsService({
1305
3031
  root,
1306
3032
  log: stamp,
1307
3033
  locate: commentLocation ? (input) => commentLocation(input, projectDir) : void 0,
1308
3034
  agentHook,
1309
- allowWrite: sameOrigin
3035
+ userName: owner.name,
3036
+ allowWrite: sameOrigin,
3037
+ viewer: (request) => {
3038
+ const viewer = share.viewerOf(request);
3039
+ return viewer ? commentsViewer(viewer) : null;
3040
+ }
1310
3041
  });
1311
- const grant = randomBytes(24).toString("base64url");
3042
+ const grant = randomBytes2(24).toString("base64url");
1312
3043
  const editorPath = `/${KIND}/${grant}/`;
1313
- const boot = randomBytes(6).toString("hex");
1314
- const clients = /* @__PURE__ */ new Set();
3044
+ const boot = randomBytes2(6).toString("hex");
3045
+ const clients = /* @__PURE__ */ new Map();
3046
+ const presence = new PresenceHub();
1315
3047
  const changes = [];
1316
3048
  let changeSeq = 0;
1317
3049
  const pageCache = /* @__PURE__ */ new Map();
@@ -1321,18 +3053,66 @@ event: ${event.type}
1321
3053
  data: ${JSON.stringify(event)}
1322
3054
 
1323
3055
  `;
3056
+ const changed = (event, from) => {
3057
+ changeSeq += 1;
3058
+ changes.push({ seq: changeSeq, paths: event.paths, ...from ? { from } : {}, ...event.by ? { by: event.by } : {} });
3059
+ if (changes.length > CHANGE_LOG_SIZE) changes.shift();
3060
+ for (const [client, id] of clients) if (!from || id !== from) client.write(frame(event));
3061
+ };
3062
+ const labels2 = () => {
3063
+ let named;
3064
+ try {
3065
+ named = kindLabels?.(projectDir);
3066
+ } catch {
3067
+ named = void 0;
3068
+ }
3069
+ return (p) => {
3070
+ const rel = p.replace(/^project\//, "");
3071
+ return named?.(rel) ?? plain(rel);
3072
+ };
3073
+ };
3074
+ const agentWorked = (paths) => {
3075
+ const rels = paths.map((p) => p.replace(/^project\//, ""));
3076
+ const main2 = [...paths].reverse().find((p) => !p.endsWith(`/${INDEX_FILE}`) && p !== INDEX_FILE) ?? paths[0];
3077
+ let context = null;
3078
+ try {
3079
+ context = kindAgentContext?.(projectDir, rels) ?? null;
3080
+ } catch {
3081
+ context = null;
3082
+ }
3083
+ presence.agentActive({ context, where: agentWhere(main2, paths.length, labels2()) });
3084
+ };
3085
+ const historyHost = new HistoryHost({
3086
+ history,
3087
+ labels: labels2,
3088
+ base: editorPath,
3089
+ previewUrl: (n) => `${editorPath}v/${n}/`,
3090
+ apply: async (files, who, extra) => {
3091
+ const { version, paths } = await store.applyFromHistory(files, who, extra);
3092
+ if (paths.length) {
3093
+ const listing = await store.listing();
3094
+ changed({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), by: authorOf(who) });
3095
+ }
3096
+ return version;
3097
+ },
3098
+ tracked: (p) => store.fileOf(p) !== null,
3099
+ // A notice whose overwrite named the version its saver's edits started from: what was lost goes back by line into
3100
+ // the file as it is now, the lost lines winning where both changed them; a deleted file gets all of it back.
3101
+ recover: (_path, base, lost, now) => now === null ? lost : mergeTextBytes(base, lost, now),
3102
+ log: stamp
3103
+ });
1324
3104
  const stopWatch = watchArtifact(
1325
3105
  store,
1326
3106
  (event) => {
1327
- changeSeq += 1;
1328
- changes.push({ seq: changeSeq, paths: event.paths });
1329
- if (changes.length > CHANGE_LOG_SIZE) changes.shift();
1330
3107
  stamp(`changed on disk: ${event.paths.join(", ")}${clients.size ? "" : " (no page open)"}`);
1331
- for (const client of clients) client.write(frame(event));
3108
+ changed({ ...event, by: authorOf(AGENT) });
3109
+ agentWorked(event.paths);
1332
3110
  },
1333
3111
  (error) => stamp(`watching failed: ${errorText(error)}`)
1334
3112
  );
1335
- const events = async (request, response) => {
3113
+ const events = async (request, response, viewer) => {
3114
+ const given = new URL(request.url ?? "/", "http://local").searchParams.get("client") ?? "";
3115
+ const client = CLIENT_ID.test(given) ? given : "";
1336
3116
  response.writeHead(200, {
1337
3117
  "Content-Type": "text/event-stream; charset=utf-8",
1338
3118
  "Cache-Control": "no-store",
@@ -1342,21 +3122,27 @@ data: ${JSON.stringify(event)}
1342
3122
  response.write(": connected\n\n");
1343
3123
  const last = /^([0-9a-f]+)\.(\d+)$/.exec(String(request.headers["last-event-id"] ?? ""));
1344
3124
  const missedSince = last && last[1] === boot ? Number(last[2]) : null;
1345
- if (missedSince !== null && missedSince < changeSeq) {
1346
- const missed = changes.filter((c) => c.seq > missedSince);
1347
- const paths = [...new Set((missed.length ? missed : changes).flatMap((c) => c.paths))];
1348
- response.write(frame({ type: "external-change", paths, ver: (await store.listing()).ver }));
3125
+ const truncated = missedSince !== null && changes.length > 0 && changes[0].seq > missedSince + 1;
3126
+ const missed = missedSince === null ? [] : truncated ? changes : changes.filter((c) => c.seq > missedSince && (!client || c.from !== client));
3127
+ if (missed.length) {
3128
+ const paths = [...new Set(missed.flatMap((c) => c.paths))];
3129
+ const listing = await store.listing();
3130
+ const by = missed.every((c) => c.by?.id === missed[0].by?.id) ? missed[0].by : void 0;
3131
+ response.write(frame({ type: "external-change", paths, ver: listing.ver, files: filesOf(listing, paths), ...by ? { by } : {} }));
1349
3132
  } else {
1350
3133
  response.write(`id: ${eventId()}
1351
3134
 
1352
3135
  `);
1353
3136
  }
1354
- clients.add(response);
3137
+ clients.set(response, client);
3138
+ share.hold(viewer, response);
3139
+ const leave = presence.join(client, viewer, (text) => response.write(text));
1355
3140
  const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
1356
3141
  keepAlive.unref?.();
1357
3142
  const done = () => {
1358
3143
  clearInterval(keepAlive);
1359
3144
  clients.delete(response);
3145
+ leave();
1360
3146
  };
1361
3147
  request.on("close", done);
1362
3148
  response.on("close", done);
@@ -1381,11 +3167,11 @@ data: ${JSON.stringify(event)}
1381
3167
  "Content-Security-Policy": DATA_CSP
1382
3168
  });
1383
3169
  };
1384
- const serveEditor = async (request, response, rest) => {
3170
+ const serveEditor = async (request, response, rest, viewer, query = "") => {
1385
3171
  const parts = rest === "" ? ["index.html"] : rest.split("/");
1386
3172
  const invalid = parts.some((p) => p === "" || p === "." || p === ".." || p.startsWith(".") || p.includes("\\")) || parts[0] === "node";
1387
3173
  const realDist = invalid ? null : await fsp2.realpath(EDITOR_DIR).catch(() => null);
1388
- const file = realDist === null ? null : path3.join(realDist, ...parts);
3174
+ const file = realDist === null ? null : path6.join(realDist, ...parts);
1389
3175
  const real = file === null ? null : await fsp2.realpath(file).catch(() => null);
1390
3176
  const info = real === null ? null : await fsp2.lstat(real).catch(() => null);
1391
3177
  if (file === null || real !== file || !info?.isFile()) {
@@ -1396,25 +3182,26 @@ data: ${JSON.stringify(event)}
1396
3182
  response.writeHead(404, { "Cache-Control": "no-store" }).end();
1397
3183
  return;
1398
3184
  }
1399
- const ext = path3.extname(file).toLowerCase();
3185
+ const ext = path6.extname(file).toLowerCase();
1400
3186
  const type = STATIC_TYPES[ext] ?? "application/octet-stream";
1401
3187
  if (ext === ".html") {
1402
3188
  const key = parts.join("/");
1403
3189
  let page = pageCache.get(key);
1404
3190
  if (!page || page.mtimeMs !== info.mtimeMs) {
1405
3191
  const html = await fsp2.readFile(file, "utf8");
1406
- page = { mtimeMs: info.mtimeMs, body: Buffer.from(html), csp: pageCsp(pageScriptSrc(html)) };
3192
+ page = { mtimeMs: info.mtimeMs, html, csp: pageCsp(pageScriptSrc(html)) };
1407
3193
  pageCache.set(key, page);
1408
3194
  }
3195
+ const body = Buffer.from(share.injectHead(page.html, viewer, query) ?? page.html);
1409
3196
  response.writeHead(200, {
1410
3197
  "Content-Type": type,
1411
- "Content-Length": page.body.length,
3198
+ "Content-Length": body.length,
1412
3199
  "Cache-Control": "no-cache",
1413
3200
  "X-Content-Type-Options": "nosniff",
1414
3201
  "Referrer-Policy": "no-referrer",
1415
3202
  "Content-Security-Policy": page.csp
1416
3203
  });
1417
- response.end(request.method === "HEAD" ? void 0 : page.body);
3204
+ response.end(request.method === "HEAD" ? void 0 : body);
1418
3205
  return;
1419
3206
  }
1420
3207
  sendFile(request, response, file, {
@@ -1425,26 +3212,41 @@ data: ${JSON.stringify(event)}
1425
3212
  "X-Content-Type-Options": "nosniff"
1426
3213
  });
1427
3214
  };
1428
- const publish = async (request, response) => {
3215
+ const publish = async (request, response, viewer) => {
1429
3216
  if (!/^application\/json\b/i.test(request.headers["content-type"] ?? "")) {
1430
3217
  publishError(response, 415, "invalid_content", "expected application/json");
1431
3218
  return;
1432
3219
  }
1433
3220
  const raw = await readBody(request, MAX_PUBLISH_BYTES);
1434
3221
  let files;
3222
+ let over = [];
3223
+ let merged = [];
3224
+ const named = (list) => Array.isArray(list) ? list.filter((o) => typeof o?.path === "string" && typeof o.sha === "string" && SHA256.test(o.sha)).map((o) => ({ path: o.path, sha: o.sha, ...typeof o.base === "string" && SHA256.test(o.base) ? { base: o.base } : {} })) : [];
1435
3225
  try {
1436
3226
  const body = JSON.parse(raw.toString("utf8"));
1437
3227
  if (typeof body?.files !== "object" || body.files === null || Array.isArray(body.files)) throw new Error("files");
1438
3228
  if (!Object.values(body.files).every(isWireEntry)) throw new Error("entry");
1439
3229
  files = body.files;
3230
+ over = named(body.over);
3231
+ merged = named(body.merged);
1440
3232
  } catch {
1441
3233
  publishError(response, 400, "invalid_content", "malformed publish request");
1442
3234
  return;
1443
3235
  }
1444
- const outcome = await store.publish(files);
3236
+ const outcome = await store.publish(files, whoOf(viewer), over, merged);
1445
3237
  const names = Object.keys(files).map((p) => p.replace(/^project\//, ""));
1446
- stamp(`saved from the page (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""}): ${names.join(", ")}`);
3238
+ const made = outcome.version ? ` as version ${outcome.version.n}` : "";
3239
+ stamp(`saved from the page by ${viewer.user.name} (${outcome.status}${outcome.body.code ? ` ${outcome.body.code}` : ""})${made}: ${names.join(", ")}`);
1447
3240
  sendJson2(response, outcome.status, outcome.body);
3241
+ if (outcome.status === 200 && clients.size > 0) {
3242
+ const from = String(request.headers["x-deck-client"] ?? "");
3243
+ const shas = outcome.body.shas ?? {};
3244
+ const paths = Object.keys(files);
3245
+ changed(
3246
+ { type: "external-change", paths, ver: String(outcome.body.version ?? ""), files: Object.fromEntries(paths.map((p) => [p, shas[p] ?? null])), by: authorOf(whoOf(viewer)) },
3247
+ CLIENT_ID.test(from) ? from : void 0
3248
+ );
3249
+ }
1448
3250
  };
1449
3251
  const upload = async (request, response) => {
1450
3252
  const contentType = request.headers["content-type"] ?? "application/octet-stream";
@@ -1458,9 +3260,97 @@ data: ${JSON.stringify(event)}
1458
3260
  stamp(`uploaded from the page: ${result.url} (${contentType}, ${bytes.length} bytes)`);
1459
3261
  sendJson2(response, 200, result);
1460
3262
  };
1461
- const route = async (request, response, rest) => {
3263
+ const session = (viewer) => {
3264
+ const extras = sessionExtras();
3265
+ if (viewer.guest && "userName" in extras) extras.userName = viewer.user.name;
3266
+ return { canEdit: canEditAs(viewer.role), ...extras, role: viewer.role };
3267
+ };
3268
+ const previewRoute = async (request, response, n, rest, viewer) => {
3269
+ const method = request.method ?? "GET";
3270
+ const state = history.stateAt(n);
3271
+ if (!state) {
3272
+ request.resume();
3273
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end(`There is no version ${n} (it may be too old to keep).`);
3274
+ return;
3275
+ }
3276
+ if (rest === ROUTES.publish || rest === ROUTES.assets) {
3277
+ request.resume();
3278
+ publishError(response, 403, "not_writer", "This is an earlier version: open the current one to edit.");
3279
+ return;
3280
+ }
3281
+ if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
3282
+ if (method !== "GET") {
3283
+ request.resume();
3284
+ publishError(response, 403, "not_writer", "This is an earlier version: comment on the current one.");
3285
+ return;
3286
+ }
3287
+ return comments.handle(request, response, rest.slice(COMMENTS_ROUTE.length));
3288
+ }
3289
+ if (method !== "GET" && method !== "HEAD") {
3290
+ response.writeHead(405, { Allow: "GET, HEAD" }).end();
3291
+ return;
3292
+ }
3293
+ if (rest === ROUTES.listing) {
3294
+ const files = {};
3295
+ for (const [p, sha] of [...state].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
3296
+ if (store.fileOf(p) === null) continue;
3297
+ files[p] = { sha256: sha, size: history.size(sha) ?? 0, contentType: contentTypeOf(p) };
3298
+ }
3299
+ return sendJson2(response, 200, { ver: `history-${n}`, files });
3300
+ }
3301
+ if (rest === ROUTES.session) return sendJson2(response, 200, { ...session(viewer), canEdit: false });
3302
+ if (rest === ROUTES.events) {
3303
+ response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-store", Connection: "keep-alive", "X-Accel-Buffering": "no" });
3304
+ response.write(": connected\n\n");
3305
+ const keepAlive = setInterval(() => response.write(": ping\n\n"), SSE_KEEPALIVE_MS2);
3306
+ keepAlive.unref?.();
3307
+ request.on("close", () => clearInterval(keepAlive));
3308
+ return;
3309
+ }
3310
+ if (rest.startsWith("project/")) {
3311
+ const sha = store.fileOf(rest) === null ? void 0 : state.get(rest);
3312
+ const bytes = sha === void 0 ? null : history.read(sha);
3313
+ if (bytes === null) {
3314
+ response.writeHead(404, { "Cache-Control": "no-store" }).end();
3315
+ return;
3316
+ }
3317
+ response.writeHead(200, {
3318
+ "Content-Type": contentTypeOf(rest),
3319
+ "Content-Length": bytes.byteLength,
3320
+ "Cache-Control": "no-store",
3321
+ "X-Content-Type-Options": "nosniff",
3322
+ "Content-Security-Policy": DATA_CSP
3323
+ });
3324
+ response.end(method === "HEAD" ? void 0 : bytes);
3325
+ return;
3326
+ }
3327
+ const blob = BLOB_PATH.exec(rest);
3328
+ if (blob) return serveBlob(request, response, blob[1]);
3329
+ return serveEditor(request, response, rest, viewer, `?at=${n}`);
3330
+ };
3331
+ const route = async (request, response, rest, viewer) => {
1462
3332
  const method = request.method ?? "GET";
1463
- if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) return comments.handle(request, response, rest.slice(COMMENTS_ROUTE.length));
3333
+ const preview = PREVIEW_PATH.exec(rest);
3334
+ if (preview) {
3335
+ if (preview[2] === void 0) {
3336
+ request.resume();
3337
+ response.writeHead(308, { Location: `${editorPath}${rest}/`, "Cache-Control": "no-store" }).end();
3338
+ return;
3339
+ }
3340
+ return previewRoute(request, response, Number(preview[1]), preview[2].slice(1), viewer);
3341
+ }
3342
+ if (rest === PRESENCE_ROUTE) return presence.handle(request, response, viewer);
3343
+ if (rest === HISTORY_ROUTE || rest.startsWith(`${HISTORY_ROUTE}/`)) {
3344
+ if (await historyHost.handle(request, response, rest.slice(HISTORY_ROUTE.length), viewer)) return;
3345
+ request.resume();
3346
+ response.writeHead(404, { "Cache-Control": "no-store" }).end();
3347
+ return;
3348
+ }
3349
+ if (rest === COMMENTS_ROUTE || rest.startsWith(`${COMMENTS_ROUTE}/`)) {
3350
+ const sub = rest.slice(COMMENTS_ROUTE.length);
3351
+ if (sub === "/events") share.hold(viewer, response);
3352
+ return comments.handle(request, response, sub);
3353
+ }
1464
3354
  if (rest === ROUTES.publish || rest === ROUTES.assets) {
1465
3355
  if (method !== "POST") {
1466
3356
  response.writeHead(405, { Allow: "POST" }).end();
@@ -1471,7 +3361,12 @@ data: ${JSON.stringify(event)}
1471
3361
  publishError(response, 403, "not_writer", "cross-origin request");
1472
3362
  return;
1473
3363
  }
1474
- if (rest === ROUTES.publish) await publish(request, response);
3364
+ if (!canEditAs(viewer.role)) {
3365
+ request.resume();
3366
+ publishError(response, 403, "not_writer", `You can view this ${NOUN} but not edit it.`);
3367
+ return;
3368
+ }
3369
+ if (rest === ROUTES.publish) await publish(request, response, viewer);
1475
3370
  else await upload(request, response);
1476
3371
  return;
1477
3372
  }
@@ -1480,8 +3375,8 @@ data: ${JSON.stringify(event)}
1480
3375
  return;
1481
3376
  }
1482
3377
  if (rest === ROUTES.listing) return sendJson2(response, 200, await store.listing());
1483
- if (rest === ROUTES.session) return sendJson2(response, 200, { canEdit: true, ...sessionExtras() });
1484
- if (rest === ROUTES.events) return events(request, response);
3378
+ if (rest === ROUTES.session) return sendJson2(response, 200, session(viewer));
3379
+ if (rest === ROUTES.events) return events(request, response, viewer);
1485
3380
  if (rest.startsWith("project/")) {
1486
3381
  const bytes = await store.readProjectFile(rest);
1487
3382
  if (bytes === null) {
@@ -1500,7 +3395,7 @@ data: ${JSON.stringify(event)}
1500
3395
  }
1501
3396
  const blob = BLOB_PATH.exec(rest);
1502
3397
  if (blob) return serveBlob(request, response, blob[1]);
1503
- return serveEditor(request, response, rest);
3398
+ return serveEditor(request, response, rest, viewer);
1504
3399
  };
1505
3400
  const guard = async (response, work) => {
1506
3401
  try {
@@ -1515,17 +3410,25 @@ data: ${JSON.stringify(event)}
1515
3410
  else response.destroy();
1516
3411
  }
1517
3412
  };
1518
- const server2 = http.createServer((request, response) => {
1519
- const url2 = new URL(request.url ?? "/", "http://localhost");
1520
- const pathname = url2.pathname;
1521
- if (pathname === "/" || pathname === "/index.html") {
1522
- response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
3413
+ async function handle(request, response) {
3414
+ if (!share.hostAllowed(request)) {
3415
+ request.resume();
3416
+ response.writeHead(421, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" }).end("Misdirected request");
1523
3417
  return;
1524
3418
  }
3419
+ const url2 = new URL(request.url ?? "/", "http://localhost");
3420
+ const pathname = url2.pathname;
1525
3421
  if (pathname === "/favicon.ico") {
1526
3422
  response.writeHead(204).end();
1527
3423
  return;
1528
3424
  }
3425
+ if (await share.route(request, response)) return;
3426
+ const viewer = share.viewerOf(request);
3427
+ if (!viewer) return share.denied(request, response);
3428
+ if (pathname === "/" || pathname === "/index.html") {
3429
+ response.writeHead(302, { Location: editorPath, "Cache-Control": "no-store" }).end();
3430
+ return;
3431
+ }
1529
3432
  const rootBlob = BLOB_PATH.exec(pathname.slice(1));
1530
3433
  if (rootBlob) {
1531
3434
  void guard(response, () => serveBlob(request, response, rootBlob[1]));
@@ -1547,30 +3450,42 @@ data: ${JSON.stringify(event)}
1547
3450
  response.writeHead(400).end();
1548
3451
  return;
1549
3452
  }
1550
- void guard(response, () => route(request, response, rest));
1551
- });
3453
+ void guard(response, () => route(request, response, rest, viewer));
3454
+ }
3455
+ const server2 = http.createServer((request, response) => void guard(response, () => handle(request, response)));
1552
3456
  const close = () => {
1553
3457
  stopWatch();
1554
3458
  comments.close();
1555
- for (const client of clients) client.end();
3459
+ share.close();
3460
+ presence.close();
3461
+ for (const client of clients.keys()) client.end();
1556
3462
  clients.clear();
1557
3463
  server2.close();
1558
3464
  server2.closeAllConnections?.();
1559
3465
  };
1560
- const actualPort = await listen(server2, port, host).catch((error) => {
3466
+ const actualPort = await share.listen(server2, { port, host, lan, portGiven }).catch((error) => {
1561
3467
  close();
1562
3468
  throw error;
1563
3469
  });
1564
3470
  const url = `${serverOrigin(host, actualPort)}${editorPath}`;
1565
3471
  stamp(`serving ${project} at ${url}`);
1566
- return { url, port: actualPort, grant, close };
3472
+ const lanUrls = share.lanUrls();
3473
+ return { url, port: actualPort, grant, close, ...lanUrls.length ? { lan: lanUrls } : {} };
3474
+ }
3475
+ function artifactTitle(root, projectDir) {
3476
+ try {
3477
+ const index = JSON.parse(fs7.readFileSync(path6.join(projectDir, INDEX_FILE), "utf8"));
3478
+ for (const value of [index.title, index.name]) if (typeof value === "string" && value.trim() && value.trim() !== "project") return value.trim();
3479
+ } catch {
3480
+ }
3481
+ return path6.basename(root);
1567
3482
  }
1568
3483
  var runningServer2 = (root) => runningServer(root, KIND, ROUTES.session);
1569
- async function serveForeground({ root, port, host, agentHook }) {
1570
- const running = await startServer({ root, port, host, agentHook });
3484
+ async function serveForeground({ root, port, host, agentHook, lan, portGiven }) {
3485
+ const running = await startServer({ root, port, host, agentHook, lan, portGiven });
1571
3486
  const state = { pid: process.pid, url: running.url, port: running.port, root, kind: KIND, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
1572
3487
  recordServer(state, running.close);
1573
- return state;
3488
+ return { ...state, ...running.lan ? { lan: running.lan } : {} };
1574
3489
  }
1575
3490
  async function main(argv) {
1576
3491
  const positional = [];
@@ -1579,26 +3494,27 @@ async function main(argv) {
1579
3494
  const arg = argv[i];
1580
3495
  if (!arg.startsWith("--")) positional.push(arg);
1581
3496
  else if (arg.includes("=")) flags.set(arg.slice(2, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1));
3497
+ else if (arg === "--lan") flags.set("lan", "true");
1582
3498
  else flags.set(arg.slice(2), argv[++i] ?? "");
1583
3499
  }
1584
3500
  if (positional.length !== 1 || flags.has("help")) {
1585
3501
  process.stderr.write(
1586
- `Usage: node ${path3.basename(SCRIPT)} <root> [--port n] [--host h] [--agent-hook "<command>"]
3502
+ `Usage: node ${path6.basename(SCRIPT)} <root> [--port n] [--host h] [--lan] [--agent-hook "<command>"]
1587
3503
  <root> holds project/${INDEX_FILE}.
1588
3504
  `
1589
3505
  );
1590
3506
  return 2;
1591
3507
  }
1592
- let root = path3.resolve(positional[0]);
1593
- if (path3.basename(root) === "project" && fs4.existsSync(path3.join(root, INDEX_FILE)) && !fs4.existsSync(path3.join(root, "project"))) {
1594
- root = path3.dirname(root);
3508
+ let root = path6.resolve(positional[0]);
3509
+ if (path6.basename(root) === "project" && fs7.existsSync(path6.join(root, INDEX_FILE)) && !fs7.existsSync(path6.join(root, "project"))) {
3510
+ root = path6.dirname(root);
1595
3511
  }
1596
- if (!fs4.statSync(path3.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
1597
- process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path3.join(root, "project", INDEX_FILE)} does not exist.` })}
3512
+ if (!fs7.statSync(path6.join(root, "project", INDEX_FILE), { throwIfNoEntry: false })?.isFile()) {
3513
+ process.stderr.write(`${JSON.stringify({ error: "NOT_FOUND", message: `${path6.join(root, "project", INDEX_FILE)} does not exist.` })}
1598
3514
  `);
1599
3515
  return 2;
1600
3516
  }
1601
- const port = Number(flags.get("port") ?? 0);
3517
+ const port = flags.has("port") ? Number(flags.get("port")) : DEFAULT_PORTS[KIND];
1602
3518
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
1603
3519
  process.stderr.write(`${JSON.stringify({ error: "INVALID_PORT", message: "--port is a number from 0 to 65535." })}
1604
3520
  `);
@@ -1611,14 +3527,14 @@ async function main(argv) {
1611
3527
  return 0;
1612
3528
  }
1613
3529
  const agentHook = flags.get("agent-hook") || void 0;
1614
- const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook });
1615
- process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid }, null, 2)}
3530
+ const state = await serveForeground({ root, port, host: flags.get("host") ?? "127.0.0.1", agentHook, lan: flags.has("lan"), portGiven: flags.has("port") });
3531
+ process.stdout.write(`${JSON.stringify({ url: state.url, root, pid: state.pid, ...state.lan ? { lan: state.lan } : {} }, null, 2)}
1616
3532
  `);
1617
3533
  return -1;
1618
3534
  }
1619
3535
  function isMain(moduleUrl) {
1620
3536
  try {
1621
- return Boolean(process.argv[1]) && pathToFileURL(fs4.realpathSync(process.argv[1])).href === moduleUrl;
3537
+ return Boolean(process.argv[1]) && pathToFileURL(fs7.realpathSync(process.argv[1])).href === moduleUrl;
1622
3538
  } catch {
1623
3539
  return false;
1624
3540
  }
@@ -1631,8 +3547,10 @@ function createArtifactServer(kind, moduleUrl) {
1631
3547
  pageScriptSrc = kind.pageScriptSrc;
1632
3548
  sessionExtras = kind.sessionExtras;
1633
3549
  commentLocation = kind.commentLocation;
3550
+ kindLabels = kind.labels;
3551
+ kindAgentContext = kind.agentContext;
1634
3552
  SCRIPT = fileURLToPath(moduleUrl);
1635
- EDITOR_DIR = path3.join(path3.dirname(SCRIPT), "editor");
3553
+ EDITOR_DIR = path6.join(path6.dirname(SCRIPT), "editor");
1636
3554
  const runIfMain = () => {
1637
3555
  if (!isMain(moduleUrl)) return;
1638
3556
  main(process.argv.slice(2)).then(
@@ -1640,7 +3558,8 @@ function createArtifactServer(kind, moduleUrl) {
1640
3558
  if (code >= 0) process.exitCode = code;
1641
3559
  },
1642
3560
  (error) => {
1643
- process.stderr.write(`${JSON.stringify({ error: "SERVER_FAILED", message: errorText(error) })}
3561
+ const code = error.code === "PORT_IN_USE" ? "PORT_IN_USE" : "SERVER_FAILED";
3562
+ process.stderr.write(`${JSON.stringify({ error: code, message: errorText(error) })}
1644
3563
  `);
1645
3564
  process.exitCode = 2;
1646
3565
  }
@@ -1681,7 +3600,7 @@ function commentLocation2(input, projectDir) {
1681
3600
  });
1682
3601
  let boards = [];
1683
3602
  try {
1684
- const listed = JSON.parse(fs5.readFileSync(path4.join(projectDir, INDEX_FILE2), "utf8")).boards;
3603
+ const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).boards;
1685
3604
  if (listed && typeof listed === "object") boards = Object.keys(listed);
1686
3605
  } catch {
1687
3606
  }
@@ -1689,6 +3608,28 @@ function commentLocation2(input, projectDir) {
1689
3608
  const file = projectFile(board);
1690
3609
  return board && file ? { file, board } : { file: "project/canvas.json" };
1691
3610
  }
3611
+ function labels(projectDir) {
3612
+ const titles = /* @__PURE__ */ new Map();
3613
+ try {
3614
+ const listed = JSON.parse(fs8.readFileSync(path7.join(projectDir, INDEX_FILE2), "utf8")).boards;
3615
+ if (listed && typeof listed === "object") {
3616
+ for (const [file, board] of Object.entries(listed)) {
3617
+ const title = board?.title;
3618
+ if (typeof title === "string" && title.trim()) titles.set(file, title.trim());
3619
+ }
3620
+ }
3621
+ } catch {
3622
+ }
3623
+ return (rel) => {
3624
+ if (rel === INDEX_FILE2) return { en: "Canvas layout", zh: "\u753B\u5E03\u5E03\u5C40" };
3625
+ if (rel.endsWith(".dc.html")) {
3626
+ const name = titles.get(rel) ?? rel.slice(rel.lastIndexOf("/") + 1).replace(/\.dc\.html$/, "");
3627
+ return { en: `Board \xB7 ${name}`, zh: `\u753B\u677F \xB7 ${name}` };
3628
+ }
3629
+ if (rel.startsWith("ds/")) return { en: `Design system \xB7 ${rel.slice(3)}`, zh: `\u8BBE\u8BA1\u7CFB\u7EDF \xB7 ${rel.slice(3)}` };
3630
+ return null;
3631
+ };
3632
+ }
1692
3633
  var server = createArtifactServer(
1693
3634
  {
1694
3635
  kind: KIND2,
@@ -1698,7 +3639,8 @@ var server = createArtifactServer(
1698
3639
  /** Boards run inline scripts, compile JSX with Babel (eval) and load from blob:. */
1699
3640
  pageScriptSrc: () => "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:",
1700
3641
  sessionExtras: () => ({}),
1701
- commentLocation: commentLocation2
3642
+ commentLocation: commentLocation2,
3643
+ labels
1702
3644
  },
1703
3645
  import.meta.url
1704
3646
  );