@appsoftwareltd/etherpk-mcp 0.14.1 → 0.16.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.
package/dist/main.js CHANGED
@@ -627,8 +627,8 @@ var ASSET_CHUNK_PLAINTEXT_BYTES = 4194304;
627
627
  function assetChunkCount(size) {
628
628
  return Math.max(1, Math.ceil(size / ASSET_CHUNK_PLAINTEXT_BYTES));
629
629
  }
630
- var DAY = 864e5;
631
- 365 * DAY, 30 * DAY, 7 * DAY;
630
+ var DAY$1 = 864e5;
631
+ 365 * DAY$1, 30 * DAY$1, 7 * DAY$1;
632
632
  /** Whether `text` is a short link a Sync Server opens: one of Google's share links. */
633
633
  function isShortMapLink(text) {
634
634
  let url;
@@ -717,7 +717,7 @@ function looksLikeAgentSetupCode(value) {
717
717
  }
718
718
  var package_default = {
719
719
  name: "@appsoftwareltd/etherpk-mcp",
720
- version: "0.14.1",
720
+ version: "0.16.0",
721
721
  license: "Elastic-2.0",
722
722
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
723
723
  type: "module",
@@ -863,7 +863,7 @@ function fromBase64Url(text) {
863
863
  * is indistinguishable from random, so the server can tell two assets in *one* graph are
864
864
  * identical and nothing more; the secret is per graph, so nothing compares across graphs.
865
865
  */
866
- var INFO = "etherpk/asset-dedup/v1";
866
+ var INFO$1 = "etherpk/asset-dedup/v1";
867
867
  /** The graph's dedup secret (32 bytes) for its newest epoch. Derive once per epoch. */
868
868
  async function deriveAssetDedupSecret(keyring) {
869
869
  const newest = keyring.epochs.at(-1);
@@ -873,7 +873,7 @@ async function deriveAssetDedupSecret(keyring) {
873
873
  name: "HKDF",
874
874
  hash: "SHA-256",
875
875
  salt: utf8(keyring.graphId),
876
- info: utf8(INFO)
876
+ info: utf8(INFO$1)
877
877
  }, ikm, 256);
878
878
  return new Uint8Array(bits);
879
879
  }
@@ -1542,6 +1542,50 @@ async function openVault(envelope, key) {
1542
1542
  throw new EnvelopeError(`unexpected vault envelope kind ${envelope[1] ?? "none"}`);
1543
1543
  }
1544
1544
  contextAad("settings-vault", "v1");
1545
+ //#endregion
1546
+ //#region ../client/src/lib/crypto/journal-doc-id.ts
1547
+ /**
1548
+ * The **journal document id** (ADR 0157): a [[Journal Entry]]'s document id, derived from its day,
1549
+ * so two devices that start the same day before they hear of each other write into one document
1550
+ * and the CRDT merges their text, instead of leaving two documents behind one name.
1551
+ *
1552
+ * secret = HKDF-SHA-256(ikm = epoch 1 key, salt = graph id, info = 'etherpk/journal-doc-id/v1')
1553
+ * id = HMAC-SHA-256(secret, 'YYYY-MM-DD'), first 128 bits, as a version 8 UUID
1554
+ *
1555
+ * The **epoch 1** key is the input, never the newest (unlike the asset dedup token): the id must
1556
+ * not change when the graph moves to a new Graph Key epoch (ADR 0127), or a day started before a
1557
+ * rotation and the same day started after it would get two documents. Every member holds every
1558
+ * epoch, so every member derives the same id. The server sees document ids already, and without
1559
+ * the key an id is indistinguishable from a random one, so it learns no day from it.
1560
+ */
1561
+ var INFO = "etherpk/journal-doc-id/v1";
1562
+ var DAY = /^\d{4}-\d{2}-\d{2}$/;
1563
+ /** The graph's journal id secret (32 bytes). It never changes, so derive it once per session. */
1564
+ async function deriveJournalIdSecret(keyring) {
1565
+ const first = keyForEpoch(keyring, 1);
1566
+ if (!first) throw new Error("deriveJournalIdSecret: keyring has no epoch 1 key");
1567
+ const ikm = await crypto.subtle.importKey("raw", first, "HKDF", false, ["deriveBits"]);
1568
+ const bits = await crypto.subtle.deriveBits({
1569
+ name: "HKDF",
1570
+ hash: "SHA-256",
1571
+ salt: utf8(keyring.graphId),
1572
+ info: utf8(INFO)
1573
+ }, ikm, 256);
1574
+ return new Uint8Array(bits);
1575
+ }
1576
+ /** The document id of the journal entry for `day` (`YYYY-MM-DD`), under the graph's secret. */
1577
+ async function journalDocId(secret, day) {
1578
+ if (!DAY.test(day)) throw new Error(`journalDocId: "${day}" is not a calendar day`);
1579
+ const key = await crypto.subtle.importKey("raw", secret, {
1580
+ name: "HMAC",
1581
+ hash: "SHA-256"
1582
+ }, false, ["sign"]);
1583
+ const bytes = new Uint8Array(await crypto.subtle.sign("HMAC", key, utf8(day))).slice(0, 16);
1584
+ bytes[6] = bytes[6] & 15 | 128;
1585
+ bytes[8] = bytes[8] & 63 | 128;
1586
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
1587
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1588
+ }
1545
1589
  var PAD_TO = 64;
1546
1590
  var LENGTH_PREFIX = 2;
1547
1591
  function graphNameAad(graphId) {
@@ -4201,8 +4245,8 @@ function parseBlocks(markdown) {
4201
4245
  * does.
4202
4246
  */
4203
4247
  /** An extension's id or a name under it: lowercase letters, digits and hyphens, from a letter. */
4204
- var NAME = "[a-z][a-z0-9-]*";
4205
- var EXTENSION_FENCE_INFO = new RegExp(`^${NAME}\\.${NAME}$`);
4248
+ var NAME$1 = "[a-z][a-z0-9-]*";
4249
+ var EXTENSION_FENCE_INFO = new RegExp(`^${NAME$1}\\.${NAME$1}$`);
4206
4250
  /** Whether an info word names an Extension Fence: an extension's id, a dot and a name. */
4207
4251
  function isExtensionFenceInfo(info) {
4208
4252
  return EXTENSION_FENCE_INFO.test(info);
@@ -7910,6 +7954,12 @@ var REMOTE = Symbol("etherpk-remote");
7910
7954
  var CACHE_SEED = Symbol("etherpk-cache-seed");
7911
7955
  /** Structural lifecycle changes which must never be mistaken for a user's edit. */
7912
7956
  var SUPPRESSED = Symbol("etherpk-suppressed");
7957
+ /**
7958
+ * A created document's text written again as this device's own insert, so it can be sent whole
7959
+ * to a generation that never held the structs it was first written with (ADR 0157). Local, so it
7960
+ * is sent, but no undo manager tracks it.
7961
+ */
7962
+ var RECREATED = Symbol("etherpk-recreated");
7913
7963
  /** Health that says the bytes are not this document's, so neither text nor emptiness is real. */
7914
7964
  function contentBlocked(health) {
7915
7965
  return health === "key-unavailable" || health === "ciphertext-corrupt";
@@ -8002,6 +8052,14 @@ function createDocSync(deps) {
8002
8052
  let pendingSnapshot;
8003
8053
  /** The newest epoch an update needed that the keyring does not hold (ADR 0127). */
8004
8054
  let missingEpoch;
8055
+ /** Set by `claimCreation` until the relay acknowledges this device's first text. */
8056
+ let createdHere = false;
8057
+ /**
8058
+ * Set when the relay answered `stale_generation` to text this device created or resurrected:
8059
+ * the next catch-up page tells which generation the relay holds, and that text is then
8060
+ * written again and sent whole into it rather than cleared.
8061
+ */
8062
+ let recreating = false;
8005
8063
  /** Outbox operations the relay refused as sealed under an older epoch: sealed again on `keysChanged`. */
8006
8064
  const refusedAsStale = /* @__PURE__ */ new Set();
8007
8065
  /** A read-back failed: the next idle uploads a fresh snapshot whatever the tail size. */
@@ -8031,6 +8089,44 @@ function createDocSync(deps) {
8031
8089
  catchupActive = false;
8032
8090
  catchupCompletion.resolve();
8033
8091
  }
8092
+ /**
8093
+ * The relay holds this document at a generation that the text this device created was not
8094
+ * written in (ADR 0157). Move to that generation, and write the text again as one insert of
8095
+ * this device's own, so it depends on no struct the generation never held (a deterministic
8096
+ * Journal Template's among them). The next send carries the whole state: a `resurrect` into a
8097
+ * deleted generation, an append into one that another device started first.
8098
+ */
8099
+ function recreateInto(nextGeneration, state) {
8100
+ recreating = false;
8101
+ documentGeneration = nextGeneration;
8102
+ lifecycleState = state;
8103
+ lastSeq = 0;
8104
+ syncHealth = "healthy";
8105
+ const content = doc.getText("content");
8106
+ const text = content.toString();
8107
+ if (text === "") return;
8108
+ doc.transact(() => {
8109
+ content.delete(0, text.length);
8110
+ content.insert(0, text);
8111
+ }, RECREATED);
8112
+ }
8113
+ /**
8114
+ * A catch-up has reached a newer active generation than this device's copy (ADR 0157): the
8115
+ * document was deleted, and started again, while this device was away. The copy holds the
8116
+ * deleted generation's text, so it is cleared, never merged into the new one. The new
8117
+ * generation never held the structs that remain, so the next send carries the whole state.
8118
+ */
8119
+ function discardOlderGeneration() {
8120
+ const content = doc.getText("content");
8121
+ seeding = true;
8122
+ try {
8123
+ if (content.length > 0) doc.transact(() => content.delete(0, content.length), SUPPRESSED);
8124
+ } finally {
8125
+ seeding = false;
8126
+ }
8127
+ lastSyncedStateVector = Y.encodeStateVector(new Y.Doc());
8128
+ lastSyncedSnapshot = Y.snapshot(doc);
8129
+ }
8034
8130
  function failCatchup(error) {
8035
8131
  const reported = asError(error);
8036
8132
  firstCatchupPage.reject(reported);
@@ -8138,7 +8234,8 @@ function createDocSync(deps) {
8138
8234
  epochId: epoch.epochId,
8139
8235
  envelope: toBase64Url(await encrypt(plaintext)),
8140
8236
  stateVector: operationStateVector,
8141
- createdAt: now()
8237
+ createdAt: now(),
8238
+ ...createdHere ? { creates: true } : {}
8142
8239
  };
8143
8240
  const state = stateSnapshot();
8144
8241
  await persistTask(() => persist.enqueue(operation, state, { settledDirtyTokens }));
@@ -8506,6 +8603,7 @@ function createDocSync(deps) {
8506
8603
  }
8507
8604
  lastSyncedStateVector = mergeStateVectors(lastSyncedStateVector, operation.stateVector);
8508
8605
  lifecycleState = "active";
8606
+ createdHere = false;
8509
8607
  updatesSinceSnapshot += 1;
8510
8608
  bytesSinceSnapshot += fromBase64Url(operation.envelope).byteLength;
8511
8609
  scheduleAutomaticCompaction();
@@ -8529,6 +8627,21 @@ function createDocSync(deps) {
8529
8627
  try {
8530
8628
  const beforeBatch = indexMutationVersion;
8531
8629
  if (message.generation < documentGeneration) break;
8630
+ if (recreating) {
8631
+ recreateInto(message.generation, message.state);
8632
+ if (message.state === "deleted") {
8633
+ await saveCurrent({ indexContentChanged: true });
8634
+ futureUpdates.clear();
8635
+ completeCatchup();
8636
+ break;
8637
+ }
8638
+ } else if (message.state === "active" && message.generation > documentGeneration && lifecycleState === "active") discardOlderGeneration();
8639
+ if (message.state === "deleted" && message.generation === documentGeneration && (lifecycleState === "deleted" || lifecycleState === "resurrecting")) {
8640
+ lastSeq = 0;
8641
+ futureUpdates.clear();
8642
+ completeCatchup();
8643
+ break;
8644
+ }
8532
8645
  if (message.state === "deleted") {
8533
8646
  documentGeneration = message.generation;
8534
8647
  lifecycleState = "deleted";
@@ -8635,10 +8748,21 @@ function createDocSync(deps) {
8635
8748
  await transmit(durableQueue[0]);
8636
8749
  },
8637
8750
  lifecycle: () => lifecycleState,
8751
+ generation: () => documentGeneration,
8752
+ claimCreation() {
8753
+ createdHere = true;
8754
+ },
8638
8755
  health: () => syncHealth,
8639
8756
  async staleGeneration(currentGeneration) {
8640
8757
  syncHealth = "generation-stale";
8641
- for (const operation of durableQueue.splice(0)) await persist.discard(operation.outboxId);
8758
+ const discarded = durableQueue.splice(0);
8759
+ if (createdHere || discarded.some((operation) => operation.kind === "resurrect" || operation.kind === "append" && operation.creates)) {
8760
+ recreating = true;
8761
+ createdHere = true;
8762
+ lastSyncedStateVector = Y.encodeStateVector(new Y.Doc());
8763
+ lastSyncedSnapshot = void 0;
8764
+ } else if (currentGeneration > documentGeneration && lifecycleState === "active") discardOlderGeneration();
8765
+ for (const operation of discarded) await persist.discard(operation.outboxId);
8642
8766
  queueChanged();
8643
8767
  boundarySnapshots.clear();
8644
8768
  documentGeneration = currentGeneration;
@@ -9720,6 +9844,14 @@ function createGraphSync(deps) {
9720
9844
  const foregroundCatchups = [];
9721
9845
  const backgroundCatchups = [];
9722
9846
  let catchupInFlight;
9847
+ /**
9848
+ * The request the in-flight one's document asked for again while it was in flight. The relay
9849
+ * may have composed the in-flight answer before whatever prompted the second request: an ack
9850
+ * naming a sequence that another device's concurrent first write took, for one. Dropped as a
9851
+ * duplicate, that document stayed behind for good, so it runs once more if the answer leaves
9852
+ * the document with a gap.
9853
+ */
9854
+ let catchupRepeat;
9723
9855
  let resolveFirstOpen;
9724
9856
  const firstOpen = new Promise((resolve) => {
9725
9857
  resolveFirstOpen = resolve;
@@ -9812,7 +9944,10 @@ function createGraphSync(deps) {
9812
9944
  }
9813
9945
  function queueCatchup(message) {
9814
9946
  if (!open) return;
9815
- if (catchupInFlight?.docId === message.docId && catchupInFlight.generation === message.generation && catchupInFlight.afterSeq === message.afterSeq) return;
9947
+ if (catchupInFlight?.docId === message.docId && catchupInFlight.generation === message.generation && catchupInFlight.afterSeq === message.afterSeq) {
9948
+ catchupRepeat = message;
9949
+ return;
9950
+ }
9816
9951
  const removeQueued = (queue) => {
9817
9952
  const index = queue.findIndex((candidate) => candidate.docId === message.docId);
9818
9953
  if (index >= 0) queue.splice(index, 1);
@@ -9826,6 +9961,9 @@ function createGraphSync(deps) {
9826
9961
  if (!requestId || catchupInFlight?.requestId !== requestId) return;
9827
9962
  const completedDocId = catchupInFlight.docId;
9828
9963
  catchupInFlight = void 0;
9964
+ const repeat = catchupRepeat?.docId === completedDocId ? catchupRepeat : void 0;
9965
+ catchupRepeat = void 0;
9966
+ if (repeat && engines.get(completedDocId)?.health() === "sequence-gap") queueCatchup(repeat);
9829
9967
  pumpCatchups();
9830
9968
  retireEngineIfIdle(completedDocId);
9831
9969
  }
@@ -10125,7 +10263,10 @@ function createGraphSync(deps) {
10125
10263
  return;
10126
10264
  }
10127
10265
  if (message.code === "stale_generation" && message.docId && message.currentGeneration !== void 0) {
10128
- if (catchupInFlight?.docId === message.docId) catchupInFlight = void 0;
10266
+ if (catchupInFlight?.docId === message.docId) {
10267
+ catchupInFlight = void 0;
10268
+ catchupRepeat = void 0;
10269
+ }
10129
10270
  operationAnswered(message.docId);
10130
10271
  engine(message.docId).staleGeneration(message.currentGeneration);
10131
10272
  pumpCatchups();
@@ -10183,6 +10324,7 @@ function createGraphSync(deps) {
10183
10324
  operationsInFlight.clear();
10184
10325
  operationsWaiting.clear();
10185
10326
  catchupInFlight = void 0;
10327
+ catchupRepeat = void 0;
10186
10328
  foregroundCatchups.length = 0;
10187
10329
  backgroundCatchups.length = 0;
10188
10330
  for (const pending of watermarkRequests.values()) {
@@ -10311,6 +10453,7 @@ function createGraphSync(deps) {
10311
10453
  });
10312
10454
  }
10313
10455
  connect();
10456
+ let journalIdSecret;
10314
10457
  return {
10315
10458
  rootDoc: root.doc,
10316
10459
  docSync: (docId) => engine(docId),
@@ -10367,6 +10510,10 @@ function createGraphSync(deps) {
10367
10510
  compactDoc: (docId) => deps.cache.docCache(docId).compact(),
10368
10511
  uploadSnapshot: (docId) => engine(docId).compact(),
10369
10512
  registry: () => registryMap,
10513
+ journalDocId(date) {
10514
+ journalIdSecret ??= deriveJournalIdSecret(keyring());
10515
+ return journalIdSecret.then((secret) => journalDocId(secret, date));
10516
+ },
10370
10517
  getMeta() {
10371
10518
  const name = metaMap.get("name");
10372
10519
  const settings = metaMap.get("settings");
@@ -10623,6 +10770,7 @@ function createGraphSync(deps) {
10623
10770
  foregroundCatchups.length = 0;
10624
10771
  backgroundCatchups.length = 0;
10625
10772
  catchupInFlight = void 0;
10773
+ catchupRepeat = void 0;
10626
10774
  for (const pending of watermarkRequests.values()) {
10627
10775
  clearTimeout(pending.timer);
10628
10776
  pending.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
@@ -11258,8 +11406,10 @@ async function hostRequest(endpoint, request, options = {}) {
11258
11406
  /**
11259
11407
  * The settings a publish reads, which the command line decides even when the graph's host runs
11260
11408
  * the publish: where the publish folders are remembered, which browser draws diagrams and maps,
11261
- * and which style a map's picture is drawn over. The host was started from another environment
11262
- * (an agent's), and a scheduled publish may name its own browser or its own map host.
11409
+ * which style a map's picture is drawn over, and the GitHub token and API a [[Publish
11410
+ * Repository]] is committed with (ADR 0152). The host was started from another environment (an
11411
+ * agent's), and a scheduled publish may name its own browser, map host or token. The token goes
11412
+ * no further than this request: the host uses it for this publish and keeps nothing of it.
11263
11413
  */
11264
11414
  var PUBLISH_SETTINGS = [
11265
11415
  "ETHERPK_MCP_PUBLISH_CONFIG",
@@ -11267,7 +11417,9 @@ var PUBLISH_SETTINGS = [
11267
11417
  "ETHERPK_CHROMIUM",
11268
11418
  "PLAYWRIGHT_BROWSERS_PATH",
11269
11419
  "PLAYWRIGHT_CHROMIUM_EXECUTABLE",
11270
- "ETHERPK_MAP_STYLE_URL"
11420
+ "ETHERPK_MAP_STYLE_URL",
11421
+ "ETHERPK_GITHUB_TOKEN",
11422
+ "ETHERPK_GITHUB_API_URL"
11271
11423
  ];
11272
11424
  /** The publish settings in `env`, for a `publish` request. */
11273
11425
  function publishSettings(env) {
@@ -11625,6 +11777,13 @@ function sameBytes(a, b) {
11625
11777
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
11626
11778
  return true;
11627
11779
  }
11780
+ /** Whether the folder already holds `content` at `path`: compared where it can, read back where it cannot. */
11781
+ async function holds$1(folder, path, content) {
11782
+ if (folder.sameContent) return folder.sameContent(path, content);
11783
+ if (typeof content === "string") return await folder.readText(path) === content;
11784
+ const existing = await folder.readBytes(path);
11785
+ return existing !== null && sameBytes(existing, content);
11786
+ }
11628
11787
  /** Write a site into a folder under the rules above, whatever stands behind the folder. */
11629
11788
  async function writeSite(folder, bundle, options) {
11630
11789
  const result = {
@@ -11636,17 +11795,9 @@ async function writeSite(folder, bundle, options) {
11636
11795
  let done = 0;
11637
11796
  for (const [path, content] of bundle) {
11638
11797
  options.onProgress?.(done++, total);
11639
- if (typeof content === "string") {
11640
- if (await folder.readText(path) === content) {
11641
- result.unchanged++;
11642
- continue;
11643
- }
11644
- } else {
11645
- const existing = await folder.readBytes(path);
11646
- if (existing && sameBytes(existing, content)) {
11647
- result.unchanged++;
11648
- continue;
11649
- }
11798
+ if (await holds$1(folder, path, content)) {
11799
+ result.unchanged++;
11800
+ continue;
11650
11801
  }
11651
11802
  await folder.writeFile(path, content);
11652
11803
  result.written++;
@@ -11672,6 +11823,570 @@ async function writeSite(folder, bundle, options) {
11672
11823
  options.onProgress?.(total, total);
11673
11824
  return result;
11674
11825
  }
11826
+ var GitHubError = class extends Error {
11827
+ code;
11828
+ status;
11829
+ detail;
11830
+ constructor(code, message, status, detail) {
11831
+ super(message);
11832
+ this.code = code;
11833
+ this.status = status;
11834
+ this.detail = detail;
11835
+ this.name = "GitHubError";
11836
+ }
11837
+ };
11838
+ /** Writes GitHub asks to be spaced (its "best practices for using the REST API"). */
11839
+ var WRITE_SPACING_MS = 1e3;
11840
+ /** How many times a request GitHub failed on (5xx) is tried again. Creating git objects is idempotent. */
11841
+ var SERVER_ERROR_RETRIES = 2;
11842
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11843
+ /** True for a 403 or 429 that GitHub sends as a rate limit, rather than a refusal. */
11844
+ function isRateLimit(status, headers, message) {
11845
+ if (status === 429) return true;
11846
+ if (status !== 403) return false;
11847
+ return headers.has("retry-after") || headers.get("x-ratelimit-remaining") === "0" || /rate limit/i.test(message);
11848
+ }
11849
+ /** How long to wait before trying again, in GitHub's order: retry-after, the reset time, a doubling minute. */
11850
+ function waitFor(headers, attempt, now) {
11851
+ const retryAfter = Number(headers.get("retry-after"));
11852
+ if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1e3;
11853
+ const reset = Number(headers.get("x-ratelimit-reset"));
11854
+ if (headers.get("x-ratelimit-remaining") === "0" && Number.isFinite(reset) && reset * 1e3 > now) return reset * 1e3 - now;
11855
+ return 6e4 * 2 ** attempt;
11856
+ }
11857
+ /** The code for a refusal GitHub sent, from its status and message. */
11858
+ function refusalCode(status, message) {
11859
+ if (status === 401) return "bad-token";
11860
+ if (/not a fast.?forward/i.test(message)) return "branch-moved";
11861
+ if (/protected branch|pull request|GH006|signed/i.test(message)) return "branch-protected";
11862
+ if (status === 409 && /empty/i.test(message)) return "empty-repository";
11863
+ if (status === 403) return "read-only";
11864
+ if (status === 404) return "no-access";
11865
+ return "unexpected";
11866
+ }
11867
+ function createGitHubApi(options) {
11868
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
11869
+ const baseUrl = (options.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
11870
+ const sleep = options.sleep ?? realSleep;
11871
+ const now = options.now ?? Date.now;
11872
+ let lastWrite = Number.NEGATIVE_INFINITY;
11873
+ let writeQueue = Promise.resolve();
11874
+ async function send(method, path, body) {
11875
+ try {
11876
+ return await doFetch(`${baseUrl}${path}`, {
11877
+ method,
11878
+ headers: {
11879
+ Accept: "application/vnd.github+json",
11880
+ Authorization: `Bearer ${options.token}`,
11881
+ "X-GitHub-Api-Version": "2022-11-28",
11882
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
11883
+ },
11884
+ body: body === void 0 ? void 0 : JSON.stringify(body),
11885
+ credentials: "omit"
11886
+ });
11887
+ } catch (error) {
11888
+ throw new GitHubError("offline", `GitHub could not be reached: ${error instanceof Error ? error.message : String(error)}`);
11889
+ }
11890
+ }
11891
+ async function attempt(method, path, body, write) {
11892
+ let rateLimited = 0;
11893
+ let failed = 0;
11894
+ for (;;) {
11895
+ if (write) {
11896
+ const wait = lastWrite + WRITE_SPACING_MS - now();
11897
+ if (wait > 0) await sleep(wait);
11898
+ lastWrite = now();
11899
+ }
11900
+ const response = await send(method, path, body);
11901
+ if (response.ok) {
11902
+ const text = await response.text();
11903
+ return text === "" ? null : JSON.parse(text);
11904
+ }
11905
+ const message = await response.json().then((json) => typeof json?.message === "string" ? json.message : "").catch(() => "");
11906
+ if (isRateLimit(response.status, response.headers, message)) {
11907
+ const wait = waitFor(response.headers, rateLimited, now());
11908
+ if (rateLimited >= 4) throw new GitHubError("rate-limited", `GitHub asked for a wait: ${message}`, response.status, { retryAt: now() + wait });
11909
+ rateLimited++;
11910
+ options.onWait?.(now() + wait);
11911
+ await sleep(wait);
11912
+ continue;
11913
+ }
11914
+ if (response.status >= 500 && failed < SERVER_ERROR_RETRIES) {
11915
+ failed++;
11916
+ await sleep(2e3 * failed);
11917
+ continue;
11918
+ }
11919
+ throw new GitHubError(refusalCode(response.status, message), message || `${response.status} ${response.statusText}`, response.status);
11920
+ }
11921
+ }
11922
+ return { request(method, path, body) {
11923
+ if (method === "GET") return attempt(method, path, body, false);
11924
+ const run = writeQueue.then(() => attempt(method, path, body, true));
11925
+ writeQueue = run.catch(() => void 0);
11926
+ return run;
11927
+ } };
11928
+ }
11929
+ /** `/repos/{owner}/{name}`, each part escaped. */
11930
+ function repoPath(repo) {
11931
+ return `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`;
11932
+ }
11933
+ /** A branch name in a ref path: a branch may hold `/`, which stays a separator. */
11934
+ function branchPath(branch) {
11935
+ return branch.split("/").map(encodeURIComponent).join("/");
11936
+ }
11937
+ /** Standard base64, as GitHub's blob and contents endpoints take and give it. */
11938
+ function bytesToBase64(bytes) {
11939
+ let binary = "";
11940
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
11941
+ return btoa(binary);
11942
+ }
11943
+ /** GitHub's base64 wraps lines; the line breaks are not part of it. */
11944
+ function base64ToBytes(text) {
11945
+ const binary = atob(text.replace(/\s+/g, ""));
11946
+ const bytes = new Uint8Array(binary.length);
11947
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
11948
+ return bytes;
11949
+ }
11950
+ //#endregion
11951
+ //#region ../client/src/lib/document/publish/host/github-repository.ts
11952
+ /**
11953
+ * A [[Publish Repository]] before any publish: reading what a person typed as a repository,
11954
+ * checking that their GitHub token can reach it, and starting the branch when they ask (ADR 0152).
11955
+ *
11956
+ * The check runs when the repository is saved in the Publish tab and before the first publish to
11957
+ * one that was never checked, rather than behind a button of its own. Write access is proven by
11958
+ * writing: a fine-grained token's own permissions are not in any answer GitHub gives (a
11959
+ * repository's `permissions` are the person's role), so the check uploads a tiny blob that no
11960
+ * commit refers to, which GitHub discards.
11961
+ *
11962
+ * A publish never creates a branch: a branch name mistyped would publish where no host looks.
11963
+ * The person asks for it (`createBranch`), after the check has said the branch is not there.
11964
+ */
11965
+ var OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
11966
+ var NAME = /^[A-Za-z0-9._-]{1,100}$/;
11967
+ /**
11968
+ * `owner/name` from what a person typed or pasted: `owner/name`, a github.com address, a clone
11969
+ * url or an ssh address. Null when it is none of those.
11970
+ */
11971
+ function parseRepository(input) {
11972
+ const parts = input.trim().replace(/^git@github\.com:/i, "").replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, "").replace(/\/+$/, "").replace(/\.git$/i, "").split("/");
11973
+ if (parts.length !== 2) return null;
11974
+ const [owner, name] = parts;
11975
+ if (!OWNER.test(owner) || !NAME.test(name) || name === "." || name === "..") return null;
11976
+ return {
11977
+ owner,
11978
+ name
11979
+ };
11980
+ }
11981
+ /** Whether git takes `name` as a branch name (the rules of `git check-ref-format` a person can break by typing). */
11982
+ function isBranchName(name) {
11983
+ if (name === "" || name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.endsWith(".lock")) return false;
11984
+ if (name.includes("..") || name.includes("//") || name.includes("@{")) return false;
11985
+ if ([...name].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return false;
11986
+ return !/[\s~^:?*[\\]/.test(name);
11987
+ }
11988
+ function megabytes(bytes) {
11989
+ return `${Math.round(bytes / 1048576)} MB`;
11990
+ }
11991
+ function clock(ms) {
11992
+ return new Date(ms).toLocaleTimeString([], {
11993
+ hour: "2-digit",
11994
+ minute: "2-digit"
11995
+ });
11996
+ }
11997
+ /**
11998
+ * An error from a check or a publish, worded for the person: what happened, why, and what to do.
11999
+ * `replaceToken` says where a new token goes: the Publish tab's GitHub section by default, an
12000
+ * environment variable at the Headless Client's command line.
12001
+ */
12002
+ function describeGitHubError(error, repo, options = {}) {
12003
+ if (!(error instanceof GitHubError)) return error instanceof Error ? error.message : String(error);
12004
+ const where = `${repo.owner}/${repo.name}`;
12005
+ switch (error.code) {
12006
+ case "offline": return "GitHub could not be reached. Check the connection and try again.";
12007
+ case "bad-token": return `GitHub refused the token. It has expired, been revoked or was mistyped. ${options.replaceToken ?? "Make a new one and replace it under GitHub above."}`;
12008
+ case "no-access": return `GitHub found no repository ${where} for this token. Check the name, and that the token's Repository access includes it.`;
12009
+ case "read-only": return `The token can read ${where} but not write to it. On GitHub, give the token the Contents permission with Read and write.`;
12010
+ case "branch-missing": return `${where} has no branch "${repo.branch}". Check the name, or create the branch.`;
12011
+ case "empty-repository": return `${where} is empty. Create the branch to make its first commit.`;
12012
+ case "branch-moved": return `The branch "${repo.branch}" changed twice while EtherPK was publishing to it. Publish again.`;
12013
+ case "branch-protected": return `A protection rule on "${repo.branch}" refused the commit, as it asks for a pull request or a signed commit. Publish to a branch of its own, such as gh-pages, and serve the site from that branch.`;
12014
+ case "rate-limited": return `GitHub asked EtherPK to wait for longer than it will. Nothing was committed. Publish again after ${clock(error.detail?.retryAt ?? Date.now())}.`;
12015
+ case "too-large": return `${error.detail?.path ?? "A file"} is ${megabytes(error.detail?.size ?? 0)}, and GitHub takes files up to 100 MB. Nothing was committed. Make the file smaller or link to it from elsewhere.`;
12016
+ case "truncated": return `"${repo.branch}" holds more files than GitHub lists at once, so EtherPK cannot see which old files to remove. Publish to a branch of its own.`;
12017
+ case "unexpected": return `GitHub refused the request${error.status ? ` (${error.status})` : ""}: ${error.message}`;
12018
+ }
12019
+ }
12020
+ //#endregion
12021
+ //#region ../client/src/lib/document/publish/seeded.ts
12022
+ var PAGES_WORKFLOW_PATH = ".github/workflows/pages.yaml";
12023
+ var WRANGLER_PATH = "wrangler.jsonc";
12024
+ var ASSETS_IGNORE_PATH = ".assetsignore";
12025
+ var README_PATH = "README.md";
12026
+ var GITIGNORE_PATH = ".gitignore";
12027
+ /**
12028
+ * Files written when absent and left alone afterwards. A Publish Repository gets no Pages
12029
+ * workflow: GitHub refuses a commit that adds one unless the token may also change workflows,
12030
+ * and Pages serves a branch as it is without one ("Deploy from a branch"), so its README says
12031
+ * that instead.
12032
+ */
12033
+ function seededFiles(publication, destination = "folder") {
12034
+ const files = /* @__PURE__ */ new Map([
12035
+ [PAGES_WORKFLOW_PATH, `# Publishes this folder to GitHub Pages as it is: the site is already built by EtherPK,
12036
+ # so there is no build step. In the repository's Settings → Pages, set the source to
12037
+ # "GitHub Actions" once - every push to main then deploys.
12038
+ name: Deploy to GitHub Pages
12039
+ on:
12040
+ push:
12041
+ branches: [main]
12042
+ workflow_dispatch:
12043
+ permissions:
12044
+ contents: read
12045
+ pages: write
12046
+ id-token: write
12047
+ concurrency:
12048
+ group: pages
12049
+ cancel-in-progress: false
12050
+ jobs:
12051
+ deploy:
12052
+ runs-on: ubuntu-latest
12053
+ environment:
12054
+ name: github-pages
12055
+ url: \${{ steps.deployment.outputs.page_url }}
12056
+ steps:
12057
+ - uses: actions/checkout@v4
12058
+ - uses: actions/configure-pages@v5
12059
+ - uses: actions/upload-pages-artifact@v3
12060
+ with:
12061
+ path: .
12062
+ - id: deployment
12063
+ uses: actions/deploy-pages@v4
12064
+ `],
12065
+ [WRANGLER_PATH, `// Cloudflare Workers with static assets (Cloudflare's successor to Pages for new sites).
12066
+ // Deploy with \`npx wrangler deploy\`, or connect the repository in the dashboard under
12067
+ // Workers → Create → Import a repository and leave the build command empty. The Worker is
12068
+ // named after the publication - Cloudflare warns when this name and the Worker's differ.
12069
+ {
12070
+ "name": "${publication.id}",
12071
+ "compatibility_date": "2026-01-01",
12072
+ "assets": {
12073
+ "directory": ".",
12074
+ "html_handling": "auto-trailing-slash",
12075
+ "not_found_handling": "404-page"
12076
+ }
12077
+ }
12078
+ `],
12079
+ [README_PATH, `# ${publication.name}
12080
+
12081
+ This folder is a website published by [EtherPK](https://etherpk.com) from the publication
12082
+ \`${publication.id}\`. Every file in it except \`CNAME\` and \`robots.txt\` is regenerated on each
12083
+ publish, so edit the notes, the publication page, its include pages and its theme in EtherPK,
12084
+ not here.
12085
+
12086
+ - Preview: open \`index.html\` in a browser - search works from disk too.
12087
+ ${destination === "repository" ? `- GitHub Pages: in the repository's Settings → Pages, set Source to "Deploy from a branch" and
12088
+ choose this branch and \`/ (root)\`. Every publish then deploys.` : `- GitHub Pages: push this folder to a repository and set Settings → Pages → Source to
12089
+ "GitHub Actions" - \`.github/workflows/pages.yaml\` deploys every push to \`main\`.`}
12090
+ - Cloudflare: \`npx wrangler deploy\` uses \`wrangler.jsonc\`, or connect the repository in the
12091
+ Workers dashboard with no build command.
12092
+ - Netlify, GitLab Pages, Vercel, S3 or your own server: the folder is plain files, so any of
12093
+ them serves it as it is. Step by step for each: https://docs.etherpk.com/hosting-a-published-site
12094
+ `],
12095
+ [GITIGNORE_PATH, `.DS_Store\nThumbs.db\n`],
12096
+ [ASSETS_IGNORE_PATH, `# Files in this folder that are not part of the website, kept out of the Worker's assets.
12097
+ # Written once by EtherPK - yours to edit.
12098
+ .git
12099
+ .github
12100
+ .gitignore
12101
+ .assetsignore
12102
+ .nojekyll
12103
+ wrangler.jsonc
12104
+ AGENTS.md
12105
+ README.md
12106
+ `]
12107
+ ]);
12108
+ if (destination === "repository") files.delete(PAGES_WORKFLOW_PATH);
12109
+ return files;
12110
+ }
12111
+ var AGENTS_START = "<!-- etherpk:start -->";
12112
+ var AGENTS_END = "<!-- etherpk:end -->";
12113
+ function managedSection(publication) {
12114
+ return `${AGENTS_START}
12115
+ ## EtherPK published site
12116
+
12117
+ This folder is a website generated by [EtherPK](https://etherpk.com) from the publication
12118
+ \`${publication.id}\` ("${publication.name}"). It is **output**: every file except \`CNAME\`,
12119
+ \`robots.txt\` and the text outside this section of \`AGENTS.md\` is rewritten on each publish
12120
+ and files that are no longer produced are deleted.
12121
+
12122
+ - Do not edit the HTML, the assets or \`theme/\` here - the change is lost on the next publish.
12123
+ - Content changes are made to the documents in EtherPK. A document is published when its
12124
+ frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
12125
+ (unless the publication takes every public document).
12126
+ - The navigation is the outline of the publication page in EtherPK - the theme and the include
12127
+ slots (head, favicon, logo, header, footer, before-content, after-content, scripts, styles) are chosen
12128
+ and edited there too, in the Publish settings and the Theme editor.
12129
+ - EtherPK publishes this site into a folder, or straight to its repository on GitHub, from any
12130
+ device. A change made here to a file it writes is lost on the next publish, wherever that runs.
12131
+ - \`index.html\` opens from disk for a preview. On GitHub Pages the site deploys through
12132
+ \`.github/workflows/pages.yaml\` where there is one, or with Pages set to "Deploy from a branch".
12133
+ \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it as it is
12134
+ (https://docs.etherpk.com/hosting-a-published-site).
12135
+
12136
+ This section is rewritten by EtherPK on every publish. Put your own notes above or below it.
12137
+ ${AGENTS_END}
12138
+ `;
12139
+ }
12140
+ /**
12141
+ * The publication whose site a folder's AGENTS.md says the folder holds, or null when it carries
12142
+ * no published-site section. A first publish to a [[Publish Repository]] asks before it replaces
12143
+ * files on a branch that does not say it holds this publication's site.
12144
+ */
12145
+ function publishedPublicationOf(agentsMd) {
12146
+ if (agentsMd === null) return null;
12147
+ const start = agentsMd.indexOf(AGENTS_START);
12148
+ const end = agentsMd.indexOf(AGENTS_END);
12149
+ if (start === -1 || end <= start) return null;
12150
+ const section = agentsMd.slice(start, end);
12151
+ if (!section.includes("## EtherPK published site")) return null;
12152
+ return /from the publication\s+`([a-z0-9-]+)`/.exec(section)?.[1] ?? null;
12153
+ }
12154
+ /** The folder's AGENTS.md with the managed section replaced or added, the user's text kept. */
12155
+ function agentsMdWithManagedSection(existing, publication) {
12156
+ const section = managedSection(publication);
12157
+ if (existing === null || existing.trim() === "") return section;
12158
+ const start = existing.indexOf(AGENTS_START);
12159
+ const end = existing.indexOf(AGENTS_END);
12160
+ if (start !== -1 && end !== -1 && end > start) return existing.slice(0, start) + section.trimEnd() + existing.slice(end + 20);
12161
+ return `${existing.trimEnd()}\n\n${section}`;
12162
+ }
12163
+ //#endregion
12164
+ //#region ../client/src/lib/document/publish/host/git-blob-sha.ts
12165
+ /**
12166
+ * The name git gives a file's content: the SHA-1 of `blob <byte length>\0` followed by the bytes,
12167
+ * as `git hash-object` prints it. A [[Publish Repository]] compares it with the names in the
12168
+ * branch's tree to find what a publish changed without downloading a single file, and an
12169
+ * unchanged image keeps its name, so a later publish never sends it again.
12170
+ *
12171
+ * WebCrypto, which the browser and Node both have, so the Client and the Headless Client share it.
12172
+ * SHA-1 here is git's object name, not a security measure.
12173
+ */
12174
+ var encoder$1 = new TextEncoder();
12175
+ async function gitBlobSha(content) {
12176
+ const bytes = typeof content === "string" ? encoder$1.encode(content) : content;
12177
+ const header = encoder$1.encode(`blob ${bytes.byteLength}\0`);
12178
+ const object = new Uint8Array(header.byteLength + bytes.byteLength);
12179
+ object.set(header, 0);
12180
+ object.set(bytes, header.byteLength);
12181
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-1", object));
12182
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
12183
+ }
12184
+ /** Tree entries sent in one request, and inline text in one request, before the next batch starts. */
12185
+ var TREE_BATCH_ENTRIES = 300;
12186
+ var TREE_BATCH_BYTES = 4194304;
12187
+ var decoder = new TextDecoder();
12188
+ var encoder = new TextEncoder();
12189
+ /** Read the branch: its head commit and every file in its tree. */
12190
+ async function openRepositoryBranch(api, repo) {
12191
+ const base = repoPath(repo);
12192
+ let head;
12193
+ try {
12194
+ head = (await api.request("GET", `${base}/git/ref/heads/${branchPath(repo.branch)}`)).object.sha;
12195
+ } catch (error) {
12196
+ if (error instanceof GitHubError && error.code === "no-access") {
12197
+ await api.request("GET", base);
12198
+ throw new GitHubError("branch-missing", error.message, error.status);
12199
+ }
12200
+ throw error;
12201
+ }
12202
+ const treeSha = (await api.request("GET", `${base}/git/commits/${head}`)).tree.sha;
12203
+ const listing = await api.request("GET", `${base}/git/trees/${treeSha}?recursive=1`);
12204
+ if (listing.truncated) throw new GitHubError("truncated", "GitHub listed the branch only in part.");
12205
+ const tree = /* @__PURE__ */ new Map();
12206
+ for (const entry of listing.tree) if (entry.type === "blob") tree.set(entry.path, {
12207
+ mode: entry.mode,
12208
+ sha: entry.sha
12209
+ });
12210
+ /** Path → new content, or null for a removal. */
12211
+ const staged = /* @__PURE__ */ new Map();
12212
+ const downloaded = /* @__PURE__ */ new Map();
12213
+ async function bytesAt(path) {
12214
+ const change = staged.get(path);
12215
+ if (change !== void 0) return change === null ? null : typeof change === "string" ? encoder.encode(change) : change;
12216
+ const entry = tree.get(path);
12217
+ if (!entry) return null;
12218
+ let bytes = downloaded.get(entry.sha);
12219
+ if (!bytes) {
12220
+ bytes = base64ToBytes((await api.request("GET", `${base}/git/blobs/${entry.sha}`)).content);
12221
+ downloaded.set(entry.sha, bytes);
12222
+ }
12223
+ return bytes;
12224
+ }
12225
+ return {
12226
+ head,
12227
+ async readText(path) {
12228
+ const bytes = await bytesAt(path);
12229
+ return bytes === null ? null : decoder.decode(bytes);
12230
+ },
12231
+ readBytes: bytesAt,
12232
+ async sameContent(path, content) {
12233
+ const entry = tree.get(path);
12234
+ return entry !== void 0 && entry.sha === await gitBlobSha(content);
12235
+ },
12236
+ async writeFile(path, content) {
12237
+ staged.set(path, content);
12238
+ },
12239
+ async remove(path) {
12240
+ if (tree.has(path)) staged.set(path, null);
12241
+ },
12242
+ async listFiles() {
12243
+ return [...tree.keys()];
12244
+ },
12245
+ replaced() {
12246
+ return [...staged.keys()].filter((path) => tree.has(path) && path !== "AGENTS.md").sort();
12247
+ },
12248
+ pendingChanges() {
12249
+ return staged.size;
12250
+ },
12251
+ async commit(message, options = {}) {
12252
+ if (staged.size === 0) return null;
12253
+ const report = options.onProgress ?? (() => {});
12254
+ const maxFileBytes = options.maxFileBytes ?? 104857600;
12255
+ for (const [path, content] of staged) {
12256
+ if (content === null) continue;
12257
+ const size = typeof content === "string" ? encoder.encode(content).byteLength : content.byteLength;
12258
+ if (size > maxFileBytes) throw new GitHubError("too-large", `${path} is over GitHub's file size limit.`, void 0, {
12259
+ path,
12260
+ size
12261
+ });
12262
+ }
12263
+ const known = new Set([...tree.values()].map((entry) => entry.sha));
12264
+ const binaries = [...staged].filter((change) => change[1] instanceof Uint8Array);
12265
+ const blobShas = /* @__PURE__ */ new Map();
12266
+ report("uploading", 0, binaries.length);
12267
+ for (const [index, [path, bytes]] of binaries.entries()) {
12268
+ let sha = await gitBlobSha(bytes);
12269
+ if (!known.has(sha)) {
12270
+ sha = (await api.request("POST", `${base}/git/blobs`, {
12271
+ content: bytesToBase64(bytes),
12272
+ encoding: "base64"
12273
+ })).sha;
12274
+ known.add(sha);
12275
+ }
12276
+ blobShas.set(path, sha);
12277
+ report("uploading", index + 1, binaries.length);
12278
+ }
12279
+ const batches = batch([...staged].map(([path, content]) => {
12280
+ const mode = tree.get(path)?.mode ?? "100644";
12281
+ if (content === null) return {
12282
+ path,
12283
+ mode,
12284
+ type: "blob",
12285
+ sha: null
12286
+ };
12287
+ if (typeof content === "string") return {
12288
+ path,
12289
+ mode,
12290
+ type: "blob",
12291
+ content
12292
+ };
12293
+ return {
12294
+ path,
12295
+ mode,
12296
+ type: "blob",
12297
+ sha: blobShas.get(path)
12298
+ };
12299
+ }), options.treeBatch ?? TREE_BATCH_ENTRIES);
12300
+ let root = treeSha;
12301
+ for (const [index, entriesOfBatch] of batches.entries()) {
12302
+ report("committing", index, batches.length + 2);
12303
+ root = (await api.request("POST", `${base}/git/trees`, {
12304
+ base_tree: root,
12305
+ tree: entriesOfBatch
12306
+ })).sha;
12307
+ }
12308
+ report("committing", batches.length, batches.length + 2);
12309
+ const commit = await api.request("POST", `${base}/git/commits`, {
12310
+ message,
12311
+ tree: root,
12312
+ parents: [head]
12313
+ });
12314
+ report("committing", batches.length + 1, batches.length + 2);
12315
+ await api.request("PATCH", `${base}/git/refs/heads/${branchPath(repo.branch)}`, {
12316
+ sha: commit.sha,
12317
+ force: false
12318
+ });
12319
+ report("committing", batches.length + 2, batches.length + 2);
12320
+ return {
12321
+ sha: commit.sha,
12322
+ url: commit.html_url
12323
+ };
12324
+ }
12325
+ };
12326
+ }
12327
+ /** Tree entries in batches small enough for one request: by count, and by the text they carry. */
12328
+ function batch(entries, size) {
12329
+ const out = [];
12330
+ let current = [];
12331
+ let bytes = 0;
12332
+ for (const entry of entries) {
12333
+ const weight = entry.content?.length ?? 0;
12334
+ if (current.length > 0 && (current.length >= size || bytes + weight > TREE_BATCH_BYTES)) {
12335
+ out.push(current);
12336
+ current = [];
12337
+ bytes = 0;
12338
+ }
12339
+ current.push(entry);
12340
+ bytes += weight;
12341
+ }
12342
+ if (current.length > 0) out.push(current);
12343
+ return out;
12344
+ }
12345
+ /** The graph's own AGENTS.md section (`storage/fs/agents-md-section.md`). */
12346
+ var GRAPH_AGENTS_HEADING = /^## EtherPK knowledge graph\s*$/m;
12347
+ /** True when the branch looks like a [[Graph Folder]], which a publish would gut. */
12348
+ async function holdsGraph(branch) {
12349
+ const files = await branch.listFiles();
12350
+ if (files.some((path) => path.startsWith("pages/")) && files.some((path) => path.startsWith("journals/"))) return true;
12351
+ const agents = files.includes("AGENTS.md") ? await branch.readText("AGENTS.md") : null;
12352
+ return agents !== null && GRAPH_AGENTS_HEADING.test(agents);
12353
+ }
12354
+ /** Publish a rendered site to the branch as one commit, under the rules at the top of this module. */
12355
+ async function publishToRepository(api, repo, input, options = {}) {
12356
+ for (let attempt = 0;; attempt++) {
12357
+ options.onProgress?.("reading", 0, 0);
12358
+ const branch = await openRepositoryBranch(api, repo);
12359
+ if (await holdsGraph(branch)) throw new Error(`${repo.owner}/${repo.name} holds a knowledge graph on "${repo.branch}", and a publish there would delete its files. Choose a repository or a branch of its own for the site.`);
12360
+ const publishedHere = publishedPublicationOf(await branch.readText("AGENTS.md")) === input.publicationId;
12361
+ const written = await writeSite(branch, input.bundle, {
12362
+ seeded: input.seeded,
12363
+ agentsMd: input.agentsMd
12364
+ });
12365
+ if (!options.confirmed && !publishedHere) {
12366
+ const replaced = branch.replaced();
12367
+ if (replaced.length > 0) return {
12368
+ state: "needs-confirmation",
12369
+ replaced
12370
+ };
12371
+ }
12372
+ if (branch.pendingChanges() === 0) return {
12373
+ state: "unchanged",
12374
+ written
12375
+ };
12376
+ const message = `Publish ${input.publicationId}: ${written.written} written, ${written.deleted.length} removed`;
12377
+ try {
12378
+ return {
12379
+ state: "committed",
12380
+ written,
12381
+ commit: await branch.commit(message, options),
12382
+ retried: attempt > 0
12383
+ };
12384
+ } catch (error) {
12385
+ if (error instanceof GitHubError && error.code === "branch-moved" && attempt === 0) continue;
12386
+ throw error;
12387
+ }
12388
+ }
12389
+ }
11675
12390
  //#endregion
11676
12391
  //#region ../../packages/themes/src/index.ts
11677
12392
  var files = /* #__PURE__ */ Object.assign({
@@ -12046,124 +12761,6 @@ function withPublishing(text, patch, options = {}) {
12046
12761
  }, options);
12047
12762
  }
12048
12763
  //#endregion
12049
- //#region ../client/src/lib/document/publish/seeded.ts
12050
- var PAGES_WORKFLOW_PATH = ".github/workflows/pages.yaml";
12051
- var WRANGLER_PATH = "wrangler.jsonc";
12052
- var ASSETS_IGNORE_PATH = ".assetsignore";
12053
- var README_PATH = "README.md";
12054
- var GITIGNORE_PATH = ".gitignore";
12055
- /** Files written when absent and left alone afterwards. */
12056
- function seededFiles(publication) {
12057
- return /* @__PURE__ */ new Map([
12058
- [PAGES_WORKFLOW_PATH, `# Publishes this folder to GitHub Pages as it is: the site is already built by EtherPK,
12059
- # so there is no build step. In the repository's Settings → Pages, set the source to
12060
- # "GitHub Actions" once - every push to main then deploys.
12061
- name: Deploy to GitHub Pages
12062
- on:
12063
- push:
12064
- branches: [main]
12065
- workflow_dispatch:
12066
- permissions:
12067
- contents: read
12068
- pages: write
12069
- id-token: write
12070
- concurrency:
12071
- group: pages
12072
- cancel-in-progress: false
12073
- jobs:
12074
- deploy:
12075
- runs-on: ubuntu-latest
12076
- environment:
12077
- name: github-pages
12078
- url: \${{ steps.deployment.outputs.page_url }}
12079
- steps:
12080
- - uses: actions/checkout@v4
12081
- - uses: actions/configure-pages@v5
12082
- - uses: actions/upload-pages-artifact@v3
12083
- with:
12084
- path: .
12085
- - id: deployment
12086
- uses: actions/deploy-pages@v4
12087
- `],
12088
- [WRANGLER_PATH, `// Cloudflare Workers with static assets (Cloudflare's successor to Pages for new sites).
12089
- // Deploy with \`npx wrangler deploy\`, or connect the repository in the dashboard under
12090
- // Workers → Create → Import a repository and leave the build command empty. The Worker is
12091
- // named after the publication - Cloudflare warns when this name and the Worker's differ.
12092
- {
12093
- "name": "${publication.id}",
12094
- "compatibility_date": "2026-01-01",
12095
- "assets": {
12096
- "directory": ".",
12097
- "html_handling": "auto-trailing-slash",
12098
- "not_found_handling": "404-page"
12099
- }
12100
- }
12101
- `],
12102
- [README_PATH, `# ${publication.name}
12103
-
12104
- This folder is a website published by [EtherPK](https://etherpk.com) from the publication
12105
- \`${publication.id}\`. Every file in it except \`CNAME\` and \`robots.txt\` is regenerated on each
12106
- publish, so edit the notes, the publication page, its include pages and its theme in EtherPK,
12107
- not here.
12108
-
12109
- - Preview: open \`index.html\` in a browser - search works from disk too.
12110
- - GitHub Pages: push this folder to a repository and set Settings → Pages → Source to
12111
- "GitHub Actions" - \`.github/workflows/pages.yaml\` deploys every push to \`main\`.
12112
- - Cloudflare: \`npx wrangler deploy\` uses \`wrangler.jsonc\`, or connect the repository in the
12113
- Workers dashboard with no build command.
12114
- - Netlify, GitLab Pages, Vercel, S3 or your own server: the folder is plain files, so any of
12115
- them serves it as it is. Step by step for each: https://docs.etherpk.com/hosting-a-published-site
12116
- `],
12117
- [GITIGNORE_PATH, `.DS_Store\nThumbs.db\n`],
12118
- [ASSETS_IGNORE_PATH, `# Files in this folder that are not part of the website, kept out of the Worker's assets.
12119
- # Written once by EtherPK - yours to edit.
12120
- .git
12121
- .github
12122
- .gitignore
12123
- .assetsignore
12124
- .nojekyll
12125
- wrangler.jsonc
12126
- AGENTS.md
12127
- README.md
12128
- `]
12129
- ]);
12130
- }
12131
- var AGENTS_START = "<!-- etherpk:start -->";
12132
- var AGENTS_END = "<!-- etherpk:end -->";
12133
- function managedSection(publication) {
12134
- return `${AGENTS_START}
12135
- ## EtherPK published site
12136
-
12137
- This folder is a website generated by [EtherPK](https://etherpk.com) from the publication
12138
- \`${publication.id}\` ("${publication.name}"). It is **output**: every file except \`CNAME\`,
12139
- \`robots.txt\` and the text outside this section of \`AGENTS.md\` is rewritten on each publish
12140
- and files that are no longer produced are deleted.
12141
-
12142
- - Do not edit the HTML, the assets or \`theme/\` here - the change is lost on the next publish.
12143
- - Content changes are made to the documents in EtherPK. A document is published when its
12144
- frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
12145
- (unless the publication takes every public document).
12146
- - The navigation is the outline of the publication page in EtherPK - the theme and the include
12147
- slots (head, favicon, logo, header, footer, before-content, after-content, scripts, styles) are chosen
12148
- and edited there too, in the Publish settings and the Theme editor.
12149
- - \`index.html\` opens from disk for a preview. \`.github/workflows/pages.yaml\` deploys the folder
12150
- to GitHub Pages, \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it
12151
- as it is (https://docs.etherpk.com/hosting-a-published-site).
12152
-
12153
- This section is rewritten by EtherPK on every publish. Put your own notes above or below it.
12154
- ${AGENTS_END}
12155
- `;
12156
- }
12157
- /** The folder's AGENTS.md with the managed section replaced or added, the user's text kept. */
12158
- function agentsMdWithManagedSection(existing, publication) {
12159
- const section = managedSection(publication);
12160
- if (existing === null || existing.trim() === "") return section;
12161
- const start = existing.indexOf(AGENTS_START);
12162
- const end = existing.indexOf(AGENTS_END);
12163
- if (start !== -1 && end !== -1 && end > start) return existing.slice(0, start) + section.trimEnd() + existing.slice(end + 20);
12164
- return `${existing.trimEnd()}\n\n${section}`;
12165
- }
12166
- //#endregion
12167
12764
  //#region ../client/src/lib/document/fence-answer.ts
12168
12765
  var PICTURE_TYPES = /* @__PURE__ */ new Set([
12169
12766
  "image/svg+xml",
@@ -13580,7 +14177,7 @@ async function publishedFence(asked, info, concept, pictures, warnings) {
13580
14177
  function assetHrefOf(name) {
13581
14178
  return `assets/${encodeURIComponent(name)}`;
13582
14179
  }
13583
- function bodyOf$1(doc) {
14180
+ function bodyOf$2(doc) {
13584
14181
  return parseFrontmatter(doc.text).body;
13585
14182
  }
13586
14183
  /** Every `http(s)` resource a piece of HTML or CSS pulls in, for the report. */
@@ -13767,7 +14364,7 @@ async function publishPublication(source, publication, env, options = {}) {
13767
14364
  });
13768
14365
  const bodies = [...included, ...includeDocs.values()].map((d) => ({
13769
14366
  doc: d,
13770
- body: bodyOf$1(d)
14367
+ body: bodyOf$2(d)
13771
14368
  }));
13772
14369
  for (const { doc, body } of bodies) for (const fence of probe.fences(body)) {
13773
14370
  const fenceKey = `${fence.lang}\n${fence.code}`;
@@ -13880,7 +14477,7 @@ async function publishPublication(source, publication, env, options = {}) {
13880
14477
  progress("rendering", done++, included.length);
13881
14478
  const slug = slugs.get(conceptKey$1(doc.concept));
13882
14479
  scopeDiagrams(`mermaid_${slug}`);
13883
- const rendered = renderer.render(bodyOf$1(doc));
14480
+ const rendered = renderer.render(bodyOf$2(doc));
13884
14481
  const isHome = doc === homeDoc;
13885
14482
  const page = {
13886
14483
  doc,
@@ -13897,7 +14494,7 @@ async function publishPublication(source, publication, env, options = {}) {
13897
14494
  if (dated.date !== void 0) page.date = dated.date;
13898
14495
  take(dated.issues);
13899
14496
  }
13900
- for (const link of linksOf(bodyOf$1(doc), resolver, doc)) report.missingLinks.push(link);
14497
+ for (const link of linksOf(bodyOf$2(doc), resolver, doc)) report.missingLinks.push(link);
13901
14498
  pages.push(page);
13902
14499
  report.included.push({
13903
14500
  concept: doc.concept,
@@ -13938,7 +14535,7 @@ async function publishPublication(source, publication, env, options = {}) {
13938
14535
  });
13939
14536
  continue;
13940
14537
  }
13941
- const body = bodyOf$1(doc);
14538
+ const body = bodyOf$2(doc);
13942
14539
  if (cssSlots.has(slot)) {
13943
14540
  const fence = renderer.fences(body).find((f) => f.lang === "css");
13944
14541
  if (!fence) {
@@ -14109,7 +14706,7 @@ async function publishPublication(source, publication, env, options = {}) {
14109
14706
  })));
14110
14707
  const wanted = /* @__PURE__ */ new Map();
14111
14708
  for (const page of pages) for (const name of page.rendered.assets) if (!wanted.has(name)) wanted.set(name, page.doc.concept);
14112
- for (const [, doc] of includeDocs) for (const name of renderer.render(bodyOf$1(doc)).assets) if (!wanted.has(name)) wanted.set(name, doc.concept);
14709
+ for (const [, doc] of includeDocs) for (const name of renderer.render(bodyOf$2(doc)).assets) if (!wanted.has(name)) wanted.set(name, doc.concept);
14113
14710
  let assetsDone = 0;
14114
14711
  for (const [name, from] of wanted) {
14115
14712
  progress("assets", assetsDone++, wanted.size);
@@ -14464,7 +15061,7 @@ async function runPublish(publication, deps) {
14464
15061
  return {
14465
15062
  report,
14466
15063
  bundle,
14467
- seeded: seededFiles(publication),
15064
+ seeded: seededFiles(publication, deps.destination),
14468
15065
  agentsMd: (existing) => agentsMdWithManagedSection(existing, publication)
14469
15066
  };
14470
15067
  }
@@ -14987,14 +15584,35 @@ function nodeSiteFolder(root) {
14987
15584
  //#endregion
14988
15585
  //#region src/publish-folders.ts
14989
15586
  /**
14990
- * Where each [[Publication]]'s [[Publish Folder]] is on this machine (ADR 0086): configuration a
14991
- * person sets with `etherpk-mcp publish … --out <dir>`, read by the `publish` tool and by the
14992
- * command without `--out`. A file of its own beside the logins (`publish.json`), because it is
14993
- * not a secret and outlives a login; keyed by the graph the way its cache directory is keyed -
14994
- * the server's host and graph id for a synced graph, the folder's path hash for a served folder -
14995
- * so two graphs with a publication both called `blog` never share a folder. Never written into a
14996
- * graph folder, which is the user's content (ADR 0079).
14997
- */
15587
+ * Where each [[Publication]] publishes from this machine (ADR 0086, ADR 0152): its [[Publish
15588
+ * Folder]], set with `etherpk-mcp publish … --out <dir>`, or its [[Publish Repository]], set with
15589
+ * `--github owner/name[#branch]`. One or the other: setting one forgets the other, as in the
15590
+ * Client. Read by the `publish` tool and by the command run without either. A file of its own
15591
+ * beside the logins (`publish.json`), because it is not a secret and outlives a login (the GitHub
15592
+ * token is never in it: it comes from `ETHERPK_GITHUB_TOKEN`); keyed by the graph the way its
15593
+ * cache directory is keyed - the server's host and graph id for a synced graph, the folder's path
15594
+ * hash for a served folder - so two graphs with a publication both called `blog` never share a
15595
+ * destination. Never written into a graph folder, which is the user's content (ADR 0079).
15596
+ */
15597
+ /**
15598
+ * `owner/name` or `owner/name#branch` (a github.com address works too), as `--github` takes it.
15599
+ * Null when it is not a repository, or the branch is not a name git takes.
15600
+ */
15601
+ function parseRepositoryArgument(text) {
15602
+ const [repository, branch, ...rest] = text.trim().split("#");
15603
+ if (rest.length > 0) return null;
15604
+ const parsed = parseRepository(repository);
15605
+ const name = branch?.trim() || "main";
15606
+ if (!parsed || !isBranchName(name)) return null;
15607
+ return {
15608
+ ...parsed,
15609
+ branch: name
15610
+ };
15611
+ }
15612
+ /** How a repository is written for a person: `owner/name#branch`. */
15613
+ function repositoryLabel(repo) {
15614
+ return `${repo.owner}/${repo.name}#${repo.branch}`;
15615
+ }
14998
15616
  function defaultPublishFoldersPath(env = process.env) {
14999
15617
  const override = env.ETHERPK_MCP_PUBLISH_CONFIG?.trim();
15000
15618
  if (override) return override;
@@ -15014,14 +15632,25 @@ function parsePublishFolders(raw) {
15014
15632
  } catch {
15015
15633
  return out;
15016
15634
  }
15635
+ const isMap = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15017
15636
  const folders = parsed?.folders;
15018
- if (typeof folders !== "object" || folders === null || Array.isArray(folders)) return out;
15019
- for (const [graphKey, byPublication] of Object.entries(folders)) {
15020
- if (typeof byPublication !== "object" || byPublication === null || Array.isArray(byPublication)) continue;
15637
+ if (isMap(folders)) for (const [graphKey, byPublication] of Object.entries(folders)) {
15638
+ if (!isMap(byPublication)) continue;
15021
15639
  const kept = {};
15022
15640
  for (const [publication, path] of Object.entries(byPublication)) if (typeof path === "string" && isAbsolute(path)) kept[publication] = path;
15023
15641
  if (Object.keys(kept).length > 0) out.folders[graphKey] = kept;
15024
15642
  }
15643
+ const repositories = parsed?.repositories;
15644
+ if (isMap(repositories)) for (const [graphKey, byPublication] of Object.entries(repositories)) {
15645
+ if (!isMap(byPublication)) continue;
15646
+ const kept = {};
15647
+ for (const [publication, repo] of Object.entries(byPublication)) {
15648
+ if (!isMap(repo) || typeof repo.owner !== "string" || typeof repo.name !== "string" || typeof repo.branch !== "string") continue;
15649
+ const checked = parseRepositoryArgument(`${repo.owner}/${repo.name}#${repo.branch}`);
15650
+ if (checked) kept[publication] = checked;
15651
+ }
15652
+ if (Object.keys(kept).length > 0) (out.repositories ??= {})[graphKey] = kept;
15653
+ }
15025
15654
  return out;
15026
15655
  }
15027
15656
  async function readPublishFolders(path) {
@@ -15043,6 +15672,33 @@ async function writePublishFolders(path, folders) {
15043
15672
  function publishFolderOf(folders, graphKey, publicationId) {
15044
15673
  return folders.folders[graphKey]?.[publicationId] ?? null;
15045
15674
  }
15675
+ /** The repository set for a publication on this machine, or null. */
15676
+ function publishRepositoryOf(folders, graphKey, publicationId) {
15677
+ return folders.repositories?.[graphKey]?.[publicationId] ?? null;
15678
+ }
15679
+ /** `publications` with one entry dropped, and the graph's map dropped when it empties. */
15680
+ function without(byGraph, graphKey, publicationId) {
15681
+ const out = { ...byGraph ?? {} };
15682
+ const rest = { ...out[graphKey] ?? {} };
15683
+ delete rest[publicationId];
15684
+ if (Object.keys(rest).length > 0) out[graphKey] = rest;
15685
+ else delete out[graphKey];
15686
+ return out;
15687
+ }
15688
+ /** The same file with one publication's repository set, in place of any folder - the caller writes it. */
15689
+ function withPublishRepository(folders, graphKey, publicationId, repo) {
15690
+ const repositories = folders.repositories ?? {};
15691
+ return {
15692
+ folders: without(folders.folders, graphKey, publicationId),
15693
+ repositories: {
15694
+ ...repositories,
15695
+ [graphKey]: {
15696
+ ...repositories[graphKey] ?? {},
15697
+ [publicationId]: repo
15698
+ }
15699
+ }
15700
+ };
15701
+ }
15046
15702
  /**
15047
15703
  * How two absolute folder paths relate, by the platform's own `relative` rather than by splitting
15048
15704
  * on `/` (Windows paths use `\`, and a shared prefix such as `notes` and `notes-site` is not a
@@ -15082,15 +15738,19 @@ async function canonicalFolder(folder) {
15082
15738
  async function publishFolderOverlap(graphFolder, publishFolder) {
15083
15739
  return relateFolders(await canonicalFolder(graphFolder), await canonicalFolder(publishFolder));
15084
15740
  }
15085
- /** The same file with one publication's folder set (made absolute) - the caller writes it. */
15741
+ /** The same file with one publication's folder set (made absolute), in place of any repository - the caller writes it. */
15086
15742
  function withPublishFolder(folders, graphKey, publicationId, folder) {
15087
- return { folders: {
15088
- ...folders.folders,
15089
- [graphKey]: {
15090
- ...folders.folders[graphKey] ?? {},
15091
- [publicationId]: resolve(folder)
15092
- }
15093
- } };
15743
+ const repositories = without(folders.repositories, graphKey, publicationId);
15744
+ return {
15745
+ folders: {
15746
+ ...folders.folders,
15747
+ [graphKey]: {
15748
+ ...folders.folders[graphKey] ?? {},
15749
+ [publicationId]: resolve(folder)
15750
+ }
15751
+ },
15752
+ ...Object.keys(repositories).length > 0 ? { repositories } : {}
15753
+ };
15094
15754
  }
15095
15755
  //#endregion
15096
15756
  //#region ../client/src/lib/document/protection/cipher-fence.ts
@@ -18107,9 +18767,12 @@ async function graphInfo(graph) {
18107
18767
  * which the Settings tab uses too, so a publication page an agent creates is the one a person
18108
18768
  * would have created.
18109
18769
  *
18110
- * Two things are this host's own. The [[Publish Folder]] is configuration a person set on the
18770
+ * Three things are this host's own. The [[Publish Folder]] is configuration a person set on the
18111
18771
  * command line, never a tool argument: the site writer deletes strays from the paths it owns,
18112
- * and a folder an agent inferred could lose real files to that rule. And a publish whose pages
18772
+ * and a folder an agent inferred could lose real files to that rule. A [[Publish Repository]] is
18773
+ * set the same way, and only the person's own `etherpk-mcp publish` commits to it, with the
18774
+ * token in `ETHERPK_GITHUB_TOKEN`: a commit there can deploy the site, so the agent's tool checks
18775
+ * such a publication (a dry run) and never publishes it (ADR 0152). And a publish whose pages
18113
18776
  * hold Mermaid needs the browser `diagrams setup` installed; without one it refuses rather than
18114
18777
  * publishing a site with its diagrams turned to text.
18115
18778
  */
@@ -18172,8 +18835,11 @@ async function settle$1(graph) {
18172
18835
  const result = await graph.settle();
18173
18836
  if (!result.settled) throw new ToolError("not_settled", result.message);
18174
18837
  }
18175
- /** How a publication reads to an agent: its page's settings, and whether this machine can publish it. */
18176
- function publicationView(publication, folder) {
18838
+ /**
18839
+ * How a publication reads to an agent: its page's settings, and where this machine publishes it.
18840
+ * A repository shows as `owner/name#branch`; it is not the agent's to publish to.
18841
+ */
18842
+ function publicationView(publication, destination) {
18177
18843
  return {
18178
18844
  id: publication.id,
18179
18845
  name: publication.name,
@@ -18185,13 +18851,17 @@ function publicationView(publication, folder) {
18185
18851
  theme: publication.theme,
18186
18852
  recent: publication.recent,
18187
18853
  includes: publication.includes,
18188
- publishFolder: folder
18854
+ publishFolder: destination.folder,
18855
+ publishRepository: destination.repository ? repositoryLabel(destination.repository) : null
18189
18856
  };
18190
18857
  }
18191
- async function foldersFor(graph, env) {
18858
+ async function destinationsFor(graph, env) {
18192
18859
  const folders = await readPublishFolders(defaultPublishFoldersPath(env));
18193
18860
  const key = publishGraphKey(graph.backend, graph.graphId);
18194
- return (publicationId) => publishFolderOf(folders, key, publicationId);
18861
+ return (publicationId) => ({
18862
+ folder: publishFolderOf(folders, key, publicationId),
18863
+ repository: publishRepositoryOf(folders, key, publicationId)
18864
+ });
18195
18865
  }
18196
18866
  /** The publications the graph defines, the issues in their pages, and the public documents none takes. */
18197
18867
  async function listPublications(graph, host) {
@@ -18199,9 +18869,9 @@ async function listPublications(graph, host) {
18199
18869
  await graph.store.refresh();
18200
18870
  const { source, unsettled } = await graph.publishing.readSource();
18201
18871
  const summary = summarisePublishing(source);
18202
- const folderOf = await foldersFor(graph, env);
18872
+ const destinationOf = await destinationsFor(graph, env);
18203
18873
  return {
18204
- publications: summary.publications.map((p) => publicationView(p, folderOf(p.id))),
18874
+ publications: summary.publications.map((p) => publicationView(p, destinationOf(p.id))),
18205
18875
  issues: summary.issues,
18206
18876
  publicInNoPublication: summary.publicInNoPublication,
18207
18877
  ...unsettled.length > 0 ? { unsettled } : {}
@@ -18247,10 +18917,10 @@ async function createPublication(graph, args, host) {
18247
18917
  await settle$1(graph);
18248
18918
  }
18249
18919
  const after = summarisePublishing((await graph.publishing.readSource()).source).publications.find((p) => p.id === id);
18250
- const folderOf = await foldersFor(graph, env);
18920
+ const destinationOf = await destinationsFor(graph, env);
18251
18921
  return {
18252
18922
  created: true,
18253
- publication: after ? publicationView(after, folderOf(id)) : {
18923
+ publication: after ? publicationView(after, destinationOf(id)) : {
18254
18924
  id,
18255
18925
  page: concept
18256
18926
  }
@@ -18272,8 +18942,14 @@ async function updatePublication(graph, args, host) {
18272
18942
  await updatePublicationPage(frontmatterStore(graph), current.concept, current, changes);
18273
18943
  await settle$1(graph);
18274
18944
  const after = summarisePublishing((await graph.publishing.readSource()).source).publications.find((p) => p.id === current.id);
18275
- const folderOf = await foldersFor(graph, env);
18276
- return { publication: publicationView(after ?? current, folderOf(current.id)) };
18945
+ const destinationOf = await destinationsFor(graph, env);
18946
+ return { publication: publicationView(after ?? current, destinationOf(current.id)) };
18947
+ }
18948
+ /** A GitHub refusal or a refused branch, as this host words it: the token is an environment variable here. */
18949
+ function repositoryRefusal(error, repo) {
18950
+ if (error instanceof GitHubError) return new ToolError("github_refused", `${describeGitHubError(error, repo, { replaceToken: "Make a new one and set ETHERPK_GITHUB_TOKEN to it." })} Nothing was published.`);
18951
+ if (error instanceof Error && /knowledge graph/.test(error.message)) return new ToolError("repository_holds_graph", `${error.message} Nothing was published.`);
18952
+ return error;
18277
18953
  }
18278
18954
  /** How many items fall under each key. */
18279
18955
  function countBy(items, keyOf) {
@@ -18307,12 +18983,13 @@ async function publish(graph, args, host) {
18307
18983
  const summary = summarisePublishing(source);
18308
18984
  const publication = summary.publications.find((p) => p.id === args.id.trim());
18309
18985
  if (!publication) throw publicationNotFound(args.id, summary.publications, via);
18310
- const folder = (await foldersFor(graph, env))(publication.id);
18986
+ const { folder, repository } = (await destinationsFor(graph, env))(publication.id);
18311
18987
  const dryRun = args.dryRun === true;
18312
- if (!folder && !dryRun) {
18313
- const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
18314
- throw new ToolError("no_publish_folder", `No publish folder is set for "${publication.name}" on this machine. Ask the user to run: ${cmd} publish ${where} --publication ${publication.id} --out <dir> - that publishes once and remembers the folder for this tool.`);
18315
- }
18988
+ const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
18989
+ if (repository && !dryRun && via === "agent") throw new ToolError("publishes_to_repository", `"${publication.name}" publishes to the GitHub repository ${repositoryLabel(repository)}, and an agent's publish never commits to a repository, because a commit there can deploy the site. Use dryRun to check it. To publish it, ask the user to run: ${cmd} publish ${where} --publication ${publication.id}`);
18990
+ const token = env.ETHERPK_GITHUB_TOKEN?.trim();
18991
+ if (repository && !dryRun && !token) throw new ToolError("no_github_token", `"${publication.name}" publishes to ${repositoryLabel(repository)}, and ETHERPK_GITHUB_TOKEN is not set. Set it to a GitHub token that may read and write the repository's Contents. Nothing was published.`);
18992
+ if (!folder && !repository && !dryRun) throw new ToolError("no_publish_folder", via === "cli" ? `No publish folder or repository is set for "${publication.name}" on this machine. Run: ${cmd} publish ${where} --publication ${publication.id} --out <dir>, or --github <owner/name> - either is remembered.` : `No publish folder is set for "${publication.name}" on this machine. Ask the user to run: ${cmd} publish ${where} --publication ${publication.id} --out <dir> - that publishes once and remembers the folder for this tool.`);
18316
18993
  if (folder && !dryRun) await checkPublishFolder(graph, publication.id, folder, host);
18317
18994
  const selected = selectDocuments(source.documents, publication, summary.publications);
18318
18995
  const includeTexts = Object.values(publication.includes).map((concept) => source.documents.find((d) => d.concept.toLowerCase() === concept.toLowerCase())?.text ?? "");
@@ -18342,12 +19019,33 @@ async function publish(graph, args, host) {
18342
19019
  renderer,
18343
19020
  maps
18344
19021
  }),
18345
- unsettled
19022
+ unsettled,
19023
+ destination: repository ? "repository" : "folder"
18346
19024
  });
19025
+ let committed = null;
19026
+ if (run.report.ok && repository && !dryRun) {
19027
+ try {
19028
+ committed = await publishToRepository(createGitHubApi({
19029
+ token,
19030
+ baseUrl: env.ETHERPK_GITHUB_API_URL?.trim() || void 0
19031
+ }), repository, {
19032
+ publicationId: publication.id,
19033
+ bundle: run.bundle,
19034
+ seeded: run.seeded,
19035
+ agentsMd: run.agentsMd
19036
+ }, { confirmed: args.replace === true });
19037
+ } catch (error) {
19038
+ throw repositoryRefusal(error, repository);
19039
+ }
19040
+ if (committed.state === "needs-confirmation") {
19041
+ const listed = committed.replaced.slice(0, 10).join(", ") + (committed.replaced.length > 10 ? ` and ${committed.replaced.length - 10} more` : "");
19042
+ throw new ToolError("repository_needs_confirmation", `${repositoryLabel(repository)} does not hold this site yet, and publishing would replace or delete ${committed.replaced.length} file${committed.replaced.length === 1 ? "" : "s"} there that it did not write: ${listed}. Nothing was committed. Run the command again with --replace to publish anyway. The old versions stay in the branch's history.`);
19043
+ }
19044
+ }
18347
19045
  const written = run.report.ok && folder && !dryRun ? await writeSite(nodeSiteFolder(folder), run.bundle, {
18348
19046
  seeded: run.seeded,
18349
19047
  agentsMd: run.agentsMd
18350
- }) : null;
19048
+ }) : committed?.written ?? null;
18351
19049
  const excludedByReason = {};
18352
19050
  for (const e of run.report.excluded) excludedByReason[e.reason] = (excludedByReason[e.reason] ?? 0) + 1;
18353
19051
  return {
@@ -18355,6 +19053,10 @@ async function publish(graph, args, host) {
18355
19053
  ...dryRun ? { dryRun: true } : {},
18356
19054
  publication: run.report.publication,
18357
19055
  folder,
19056
+ ...repository ? {
19057
+ repository: repositoryLabel(repository),
19058
+ commit: committed?.state === "committed" ? committed.commit.url : null
19059
+ } : {},
18358
19060
  written: written ? {
18359
19061
  written: written.written,
18360
19062
  unchanged: written.unchanged,
@@ -18413,15 +19115,19 @@ function cliPublishOutput(result) {
18413
19115
  async function publishingInfo(graph, host) {
18414
19116
  const { env, cmd } = hostOf$1(host);
18415
19117
  const { source } = await graph.publishing.readSource();
18416
- const folderOf = await foldersFor(graph, env);
19118
+ const destinationOf = await destinationsFor(graph, env);
18417
19119
  const chromium = await chromiumStatus(env, cmd);
18418
19120
  return {
18419
- publications: summarisePublishing(source).publications.map((p) => ({
18420
- id: p.id,
18421
- name: p.name,
18422
- kind: p.kind,
18423
- publishFolder: folderOf(p.id)
18424
- })),
19121
+ publications: summarisePublishing(source).publications.map((p) => {
19122
+ const { folder, repository } = destinationOf(p.id);
19123
+ return {
19124
+ id: p.id,
19125
+ name: p.name,
19126
+ kind: p.kind,
19127
+ publishFolder: folder,
19128
+ publishRepository: repository ? repositoryLabel(repository) : null
19129
+ };
19130
+ }),
18425
19131
  mermaid: {
18426
19132
  available: chromium.executable !== null,
18427
19133
  ...chromium.executable ? {} : { setup: chromium.setupCommand }
@@ -19784,6 +20490,7 @@ async function runGraphHost(deps) {
19784
20490
  return startSession(socket, request, reply);
19785
20491
  case "publish":
19786
20492
  case "publish-check":
20493
+ case "publish-github":
19787
20494
  if (stopping) return answer({ etherpk: "stopping" });
19788
20495
  return runPublish(request, answer);
19789
20496
  default: return answer({
@@ -23295,7 +24002,7 @@ function describeFilesystemSaveFailure(error, concept) {
23295
24002
  //#endregion
23296
24003
  //#region src/headless-documents.ts
23297
24004
  /** The document's text after its frontmatter block, and where that block ends. */
23298
- function bodyOf(text) {
24005
+ function bodyOf$1(text) {
23299
24006
  const head = frontmatterSpan(text)?.end ?? 0;
23300
24007
  return {
23301
24008
  head,
@@ -23310,16 +24017,16 @@ function bodyOf(text) {
23310
24017
  function bodyView(handle) {
23311
24018
  return {
23312
24019
  id: handle.id,
23313
- getText: () => bodyOf(handle.getText()).body,
24020
+ getText: () => bodyOf$1(handle.getText()).body,
23314
24021
  applyChange(change, origin) {
23315
- const { head } = bodyOf(handle.getText());
24022
+ const { head } = bodyOf$1(handle.getText());
23316
24023
  handle.applyChange({
23317
24024
  from: change.from + head,
23318
24025
  to: change.to + head,
23319
24026
  insert: change.insert
23320
24027
  }, origin);
23321
24028
  },
23322
- subscribe: (listener) => handle.subscribe((text) => listener(bodyOf(text).body))
24029
+ subscribe: (listener) => handle.subscribe((text) => listener(bodyOf$1(text).body))
23323
24030
  };
23324
24031
  }
23325
24032
  //#endregion
@@ -23445,6 +24152,34 @@ function createBreather(now = () => performance.now()) {
23445
24152
  };
23446
24153
  }
23447
24154
  //#endregion
24155
+ //#region ../client/src/lib/storage/server/duplicate-names.ts
24156
+ /** A document's text below its [[Frontmatter]] block, if it has one. */
24157
+ function bodyOf(text) {
24158
+ return text.slice(frontmatterSpan(text)?.end ?? 0);
24159
+ }
24160
+ /**
24161
+ * The text a Merge leaves in the first document: its own text whole, then each other document's
24162
+ * text below its block, after a blank line, in the order given. Every other document's identity is
24163
+ * already the first one's, and its aliases join in the registry, so its block adds nothing.
24164
+ *
24165
+ * Always the first text followed by an addition, so the store writes it as an append: an edit made
24166
+ * meanwhile on another device anywhere in the first document is kept.
24167
+ */
24168
+ function mergedText(texts) {
24169
+ let merged = texts[0] ?? "";
24170
+ for (const other of texts.slice(1)) {
24171
+ const body = bodyOf(other).replace(/^\s+/, "");
24172
+ if (body === "") continue;
24173
+ if (merged.trim() === "") {
24174
+ merged += body;
24175
+ continue;
24176
+ }
24177
+ const separator = merged.endsWith("\n\n") ? "" : merged.endsWith("\n") ? "\n" : "\n\n";
24178
+ merged += separator + body;
24179
+ }
24180
+ return merged;
24181
+ }
24182
+ //#endregion
23448
24183
  //#region ../client/src/lib/storage/server/server-document-store.ts
23449
24184
  /**
23450
24185
  * ServerDocumentStore (plan Phase 3 Task 6): the DocumentStore seam over the E2EE sync
@@ -23462,6 +24197,12 @@ function createServerDocumentStore(graph, options) {
23462
24197
  const ENGINE_BATCH = 100;
23463
24198
  /** Longest a cold-empty document holds its loading state waiting for first catchup. */
23464
24199
  const COLD_CONTENT_TIMEOUT_MS = 15e3;
24200
+ /**
24201
+ * Longest a create waits, while connected, for the registry (and a journal's own document) to
24202
+ * catch up before it checks the name again (ADR 0157). Long enough for a reconnect's first
24203
+ * pages, short enough that a slow relay does not hold up a person who is typing.
24204
+ */
24205
+ const CREATE_CONFIRM_MS = 5e3;
23465
24206
  const breathe = createBreather();
23466
24207
  const changeListeners = /* @__PURE__ */ new Set();
23467
24208
  const docsChangedListeners = /* @__PURE__ */ new Set();
@@ -23470,6 +24211,16 @@ function createServerDocumentStore(graph, options) {
23470
24211
  /** docId to the name it had when first seen renamed from elsewhere, and its latest name. */
23471
24212
  const renamesFromElsewhere = /* @__PURE__ */ new Map();
23472
24213
  const resurrectionClaims = /* @__PURE__ */ new Map();
24214
+ /**
24215
+ * Journal entries this device started before its registry had caught up (ADR 0157). A derived
24216
+ * id can already be a registry key that another device set and then deleted while this one was
24217
+ * away. Two concurrent sets of one Y.Map key keep one of them, and when the kept one is the
24218
+ * deleted one, this device's entry goes too: its text would be in the document but no name
24219
+ * would reach it. A claim sets the entry again when that happens, until the registry has
24220
+ * caught up once with this device connected, after which a delete is someone's decision.
24221
+ */
24222
+ const creationClaims = /* @__PURE__ */ new Map();
24223
+ const stopClaimWatches = /* @__PURE__ */ new Set();
23473
24224
  let disposed = false;
23474
24225
  /** Whether the encrypted registry has been read to its terminal relay page this session. */
23475
24226
  let registryConfirmed = false;
@@ -23555,10 +24306,21 @@ function createServerDocumentStore(graph, options) {
23555
24306
  /**
23556
24307
  * Every name a document answers to, to its docId. A page's own name outranks another page's
23557
24308
  * alias of the same name, whatever order the registry lists them in, as the index resolves it.
24309
+ *
24310
+ * A name that more than one document answers to (a [[Duplicate Name]], ADR 0157) keeps the
24311
+ * document it opened before, while that document still answers to it. The registry lists a key
24312
+ * that arrived by sync last, so without this a duplicate arriving would move every open tab
24313
+ * and link of the name onto the other device's document, and two devices would each open the
24314
+ * other's. A name moves only when its document leaves it: deleted, merged or renamed away.
23558
24315
  */
23559
- function currentDocIds() {
24316
+ function currentDocIds(previous) {
23560
24317
  const out = /* @__PURE__ */ new Map();
23561
- registry.forEach((entry, docId) => out.set(conceptKey(conceptOf(entry)), docId));
24318
+ registry.forEach((entry, docId) => {
24319
+ const key = conceptKey(conceptOf(entry));
24320
+ const held = out.get(key);
24321
+ if (held !== void 0 && previous?.get(key) === held) return;
24322
+ out.set(key, docId);
24323
+ });
23562
24324
  registry.forEach((entry, docId) => {
23563
24325
  for (const alias of entry.aliases ?? []) {
23564
24326
  const key = conceptKey(alias);
@@ -23597,7 +24359,7 @@ function createServerDocumentStore(graph, options) {
23597
24359
  const next = currentConcepts();
23598
24360
  for (const [key, concept] of known) if (!next.has(key) && !renamedAway.has(key)) removedListeners.forEach((l) => l(concept));
23599
24361
  known = next;
23600
- docIdsByConcept = currentDocIds();
24362
+ docIdsByConcept = currentDocIds(docIdsByConcept);
23601
24363
  knownRegistry = nextRegistry;
23602
24364
  docsChangedListeners.forEach((l) => l());
23603
24365
  if (!restored) {
@@ -23618,6 +24380,16 @@ function createServerDocumentStore(graph, options) {
23618
24380
  }
23619
24381
  for (const [docId, change] of event.changes.keys) {
23620
24382
  if (change.action !== "delete") continue;
24383
+ const created = creationClaims.get(docId);
24384
+ if (created) queueMicrotask(() => {
24385
+ if (registry.has(docId) || !creationClaims.has(docId)) return;
24386
+ const engine = graph.docSync(docId);
24387
+ if (engine.lifecycle() === "deleting" || engine.doc.getText("content").length === 0) {
24388
+ creationClaims.delete(docId);
24389
+ return;
24390
+ }
24391
+ registry.set(docId, created);
24392
+ });
23621
24393
  const claimed = resurrectionClaims.get(docId);
23622
24394
  if (!claimed) continue;
23623
24395
  queueMicrotask(() => {
@@ -23832,7 +24604,7 @@ function createServerDocumentStore(graph, options) {
23832
24604
  registryConfirmed = true;
23833
24605
  }
23834
24606
  known = currentConcepts();
23835
- docIdsByConcept = currentDocIds();
24607
+ docIdsByConcept = currentDocIds(docIdsByConcept);
23836
24608
  knownRegistry = currentRegistryIdentities();
23837
24609
  },
23838
24610
  listDocuments() {
@@ -23856,6 +24628,9 @@ function createServerDocumentStore(graph, options) {
23856
24628
  pages.sort((a, b) => a.concept.localeCompare(b.concept));
23857
24629
  return [...journals, ...pages];
23858
24630
  },
24631
+ documentIdOf(target) {
24632
+ return docIdFor(target);
24633
+ },
23859
24634
  listIdentities() {
23860
24635
  const out = [];
23861
24636
  registry.forEach((entry, docId) => {
@@ -24126,26 +24901,83 @@ function createServerDocumentStore(graph, options) {
24126
24901
  materialised.release();
24127
24902
  }
24128
24903
  },
24904
+ async mergeDuplicates(docIds, expected, mergeOptions) {
24905
+ const timeoutMs = mergeOptions?.timeoutMs ?? COLD_CONTENT_TIMEOUT_MS;
24906
+ const [first, ...others] = docIds;
24907
+ if (!first || others.length === 0 || !oneName(docIds)) return "gone";
24908
+ if (!graph.isConnected()) return "offline";
24909
+ if (!await confirmRegistry(timeoutMs)) return "unconfirmed";
24910
+ const materialised = await materialise(docIds, timeoutMs);
24911
+ try {
24912
+ if (!oneName(docIds)) return "gone";
24913
+ if (materialised.unconfirmed.length > 0) return "unconfirmed";
24914
+ const texts = docIds.map((id) => graph.docSync(id).doc.getText("content").toString());
24915
+ if (texts.some((text) => documentProtection(text).kind === "document")) return "protected";
24916
+ if (docIds.some((id, index) => texts[index] !== expected.get(id))) return "changed";
24917
+ const firstText = texts[0];
24918
+ const addition = mergedText(["", ...texts.slice(1)]);
24919
+ if (addition !== "" && !firstText.endsWith(addition)) {
24920
+ const merged = mergedText(texts);
24921
+ const ytext = graph.docSync(first).doc.getText("content");
24922
+ ytext.doc.transact(() => ytext.insert(firstText.length, merged.slice(firstText.length)), STORE);
24923
+ }
24924
+ const firstEntry = registry.get(first);
24925
+ const aliases = others.reduce((union, id) => mergeDocuments({
24926
+ body: "",
24927
+ aliases: union
24928
+ }, {
24929
+ body: "",
24930
+ aliases: registry.get(id)?.aliases ?? []
24931
+ }).aliases, firstEntry.aliases ?? []);
24932
+ if (aliases.length !== (firstEntry.aliases ?? []).length) writeAliases(first, firstEntry, aliases);
24933
+ if (!await acknowledged(first, timeoutMs)) return "not-acknowledged";
24934
+ if (others.filter((id, index) => graph.docSync(id).doc.getText("content").toString() !== texts[index + 1]).length > 0 || !oneName(docIds)) return "changed-after-append";
24935
+ for (const id of others) {
24936
+ creationClaims.delete(id);
24937
+ await graph.deleteDoc(id);
24938
+ registry.delete(id);
24939
+ }
24940
+ return "merged";
24941
+ } finally {
24942
+ materialised.release();
24943
+ }
24944
+ },
24129
24945
  async reconcile() {},
24130
24946
  async resolveConflict() {},
24131
- async createJournal(date, body = "") {
24947
+ async createJournal(date, body = "", createOptions) {
24132
24948
  const day = date.trim();
24133
24949
  if (!isJournalConcept(day)) throw new Error(`"${date}" is not a calendar day.`);
24950
+ await confirmBeforeCreate();
24134
24951
  if (docIdFor(day)) throw new Error(`A document for "${day}" already exists`);
24135
- const docId = crypto.randomUUID();
24136
- registry.set(docId, {
24952
+ const docId = await graph.journalDocId(day);
24953
+ const engine = graph.docSync(docId);
24954
+ await graph.whenReady(docId);
24955
+ const connected = graph.isConnected();
24956
+ const caughtUp = connected && await reachedWithin(graph.caughtUpDoc(docId), CREATE_CONFIRM_MS);
24957
+ if (docIdFor(day)) throw new Error(`A document for "${day}" already exists`);
24958
+ const entry = {
24137
24959
  kind: "journal",
24138
24960
  date: day
24139
- });
24140
- if (body !== "") {
24141
- const ytext = graph.docSync(docId).doc.getText("content");
24142
- ytext.doc.transact(() => ytext.insert(0, body));
24143
- }
24961
+ };
24962
+ registry.set(docId, entry);
24963
+ engine.claimCreation();
24964
+ if (!caughtUp) holdCreationClaim(docId, entry);
24965
+ const ytext = engine.doc.getText("content");
24966
+ if (!connected && ytext.length > 0) ytext.doc.transact(() => ytext.delete(0, ytext.length), STORE);
24967
+ else if (connected && !caughtUp && ytext.length > 0) console.warn(`[sync] "${day}" was started before its document caught up; its earlier text is kept`);
24968
+ const template = createOptions?.template ?? "";
24969
+ if (template !== "" && body.startsWith(template)) {
24970
+ await seedTemplate(docId, template);
24971
+ const rest = body.slice(template.length);
24972
+ if (rest !== "") ytext.doc.transact(() => ytext.insert(template.length, rest));
24973
+ } else if (body !== "") ytext.doc.transact(() => ytext.insert(0, body));
24144
24974
  return day;
24145
24975
  },
24146
24976
  async createPage(title, body = "") {
24147
24977
  if (isJournalConcept(title)) throw new Error(dayIsNotAPageName(title));
24148
24978
  if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
24979
+ await confirmBeforeCreate();
24980
+ if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
24149
24981
  const docId = crypto.randomUUID();
24150
24982
  registry.set(docId, {
24151
24983
  kind: "page",
@@ -24232,6 +25064,7 @@ function createServerDocumentStore(graph, options) {
24232
25064
  async deleteDocument(concept) {
24233
25065
  const docId = docIdFor(concept);
24234
25066
  if (!docId) return;
25067
+ creationClaims.delete(docId);
24235
25068
  await graph.deleteDoc(docId);
24236
25069
  registry.delete(docId);
24237
25070
  },
@@ -24241,10 +25074,90 @@ function createServerDocumentStore(graph, options) {
24241
25074
  unobserveDocUpdates();
24242
25075
  for (const stop of resurrectors.values()) stop();
24243
25076
  resurrectors.clear();
25077
+ for (const stop of stopClaimWatches) stop();
25078
+ stopClaimWatches.clear();
24244
25079
  await graph.flushAll();
24245
25080
  graph.dispose();
24246
25081
  }
24247
25082
  };
25083
+ /**
25084
+ * Confirm before create (ADR 0157): while connected, wait (bounded) for the registry's current
25085
+ * catch-up, so a name another device created moments ago is seen before a second document is
25086
+ * made for it. A reconnect starts a new catch-up cycle a microtask after the socket opens,
25087
+ * which the yield lets begin. Offline nothing can arrive, and the create goes ahead at once.
25088
+ */
25089
+ async function confirmBeforeCreate() {
25090
+ if (!graph.isConnected()) return;
25091
+ await Promise.resolve();
25092
+ await reachedWithin(graph.rootCaughtUp(), CREATE_CONFIRM_MS);
25093
+ }
25094
+ /**
25095
+ * Seed a [[Journal Template]] as one Yjs update whose client id is derived from the document,
25096
+ * its generation and the template's text (ADR 0157). Two devices that start the day with the
25097
+ * same template produce the same struct, which the CRDT keeps once. The generation keeps a day
25098
+ * started again after a delete from reusing the struct the deleted generation held. If the
25099
+ * struct is already here and deleted (a device's cached copy of that generation), it adds
25100
+ * nothing, and the template is inserted as an ordinary edit instead.
25101
+ */
25102
+ async function seedTemplate(docId, template) {
25103
+ const engine = graph.docSync(docId);
25104
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${docId}\n${engine.generation()}\n${template}`)));
25105
+ const seed = new Y.Doc();
25106
+ seed.clientID = new DataView(digest.buffer).getUint32(0);
25107
+ seed.getText("content").insert(0, template);
25108
+ Y.applyUpdate(engine.doc, Y.encodeStateAsUpdate(seed), STORE);
25109
+ seed.destroy();
25110
+ const ytext = engine.doc.getText("content");
25111
+ if (!ytext.toString().startsWith(template)) ytext.doc.transact(() => ytext.insert(0, template), STORE);
25112
+ }
25113
+ /** Whether every one of `docIds` is still in the registry under one name. */
25114
+ function oneName(docIds) {
25115
+ const keys = /* @__PURE__ */ new Set();
25116
+ for (const id of docIds) {
25117
+ const entry = registry.get(id);
25118
+ if (!entry) return false;
25119
+ keys.add(conceptKey(conceptOf(entry)));
25120
+ }
25121
+ return keys.size === 1;
25122
+ }
25123
+ /**
25124
+ * Send a document's pending edits and wait, bounded, until the relay has acknowledged every
25125
+ * one of them: what a Merge waits for before it deletes anything. False when it has not.
25126
+ */
25127
+ async function acknowledged(docId, timeoutMs) {
25128
+ const engine = graph.docSync(docId);
25129
+ await engine.flush();
25130
+ const deadline = Date.now() + timeoutMs;
25131
+ while (engine.unsentOperations() > 0) {
25132
+ if (disposed || Date.now() >= deadline) return false;
25133
+ await new Promise((resolve) => setTimeout(resolve, 50));
25134
+ }
25135
+ return true;
25136
+ }
25137
+ /**
25138
+ * Keep a creation claim until the registry has caught up once with this device connected
25139
+ * (`creationClaims`). Every concurrent set this device's entry could lose to is then here.
25140
+ */
25141
+ function holdCreationClaim(docId, entry) {
25142
+ creationClaims.set(docId, entry);
25143
+ let released = false;
25144
+ const release = () => {
25145
+ if (released) return;
25146
+ released = true;
25147
+ stop();
25148
+ stopClaimWatches.delete(stop);
25149
+ (async () => {
25150
+ await Promise.resolve();
25151
+ await graph.rootCaughtUp();
25152
+ if (creationClaims.get(docId) === entry) creationClaims.delete(docId);
25153
+ })().catch(() => {});
25154
+ };
25155
+ const stop = graph.onActivity((activity) => {
25156
+ if (activity.connection === "open") release();
25157
+ });
25158
+ stopClaimWatches.add(stop);
25159
+ if (graph.isConnected()) release();
25160
+ }
24248
25161
  async function confirmRegistry(timeoutMs = 5e3) {
24249
25162
  if (registryConfirmed) return true;
24250
25163
  if (!graph.isConnected()) return false;
@@ -24626,7 +25539,7 @@ async function openHeadlessGraph(deps) {
24626
25539
  continue;
24627
25540
  }
24628
25541
  if (documentProtection(read.text).kind === "document") continue;
24629
- bodies.set(identity.concept, bodyOf(read.text).body);
25542
+ bodies.set(identity.concept, bodyOf$1(read.text).body);
24630
25543
  }
24631
25544
  return {
24632
25545
  bodies,
@@ -25818,7 +26731,7 @@ function bindServeLifetime(deps) {
25818
26731
  * etherpk-mcp stop [--graph <id or name> [--sync-server <url>] | --folder <path> | --all]
25819
26732
  * etherpk-mcp logout [--sync-server <url> | --all]
25820
26733
  * etherpk-mcp semantic setup | status | remove
25821
- * etherpk-mcp publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --dry-run]
26734
+ * etherpk-mcp publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --github <owner/name[#branch]> | --dry-run] [--replace]
25822
26735
  * etherpk-mcp diagrams setup | status
25823
26736
  *
25824
26737
  * One config file holds a login per Sync Server (ADR 0075). `--sync-server` names the one a
@@ -25889,8 +26802,9 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
25889
26802
  to a service to be embedded.
25890
26803
  ${CMD} semantic status | remove
25891
26804
  Whether it is set up here, or delete it (logout --all deletes it too).
25892
- ${CMD} publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --dry-run]
25893
- Publish one publication of a graph to a folder on this machine and print the report.
26805
+ ${CMD} publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --github <owner/name[#branch]> | --dry-run] [--replace]
26806
+ Publish one publication of a graph to a folder on this machine, or to a branch of a
26807
+ GitHub repository, and print the report.
25894
26808
  --out sets the publish folder for that graph and publication and is remembered: from
25895
26809
  then on this command without --out, and the agent's publish tool, write to the same
25896
26810
  folder (the agent cannot choose one). It must be a folder of its own, outside the
@@ -25898,6 +26812,13 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
25898
26812
  and feed files) are rewritten and their strays removed - everything else in it is left
25899
26813
  alone. Publishing writes files - deploying them (a git push, say) is yours. Run it from
25900
26814
  cron for a site that republishes itself.
26815
+ --github commits the site to the repository's branch (main unless #branch says) as one
26816
+ commit, with the token in ETHERPK_GITHUB_TOKEN, and is remembered in place of a folder.
26817
+ The host watching the branch then deploys it. The same files are owned and the rest of
26818
+ the branch is left alone. A publish never creates the branch. The agent's publish tool
26819
+ never commits to a repository: this command is how a publication there is published.
26820
+ --replace lets a first publish replace files on a branch that does not hold this site
26821
+ yet, which it otherwise lists and refuses.
25901
26822
  --dry-run renders and checks the publication and prints the whole report, with the
25902
26823
  names of the pages it mentions, but writes nothing and needs no folder. Keep its output
25903
26824
  out of public logs.
@@ -26308,19 +27229,32 @@ async function publishCommand(args) {
26308
27229
  if (!publication) fail("publish needs --publication <id>. The agent's list_publications tool, or Settings → Publish in EtherPK, shows the ids.");
26309
27230
  const dryRun = args["dry-run"] === true;
26310
27231
  const out = args.out?.trim();
26311
- if (dryRun && out) fail("publish --dry-run writes nothing, so it takes no --out. Leave --out off, or drop --dry-run to publish.");
27232
+ const githubArg = args.github?.trim();
27233
+ if (dryRun && (out || githubArg)) fail("publish --dry-run writes nothing, so it takes no --out or --github. Leave them off, or drop --dry-run to publish.");
27234
+ if (out && githubArg) fail("publish takes --out or --github, not both: a publication publishes to one place on this machine.");
27235
+ const github = githubArg ? parseRepositoryArgument(githubArg) : null;
27236
+ if (githubArg && !github) fail(`--github takes owner/name, or owner/name#branch, such as you/garden-site#main ("${githubArg}" is not one).`);
26312
27237
  const found = await findGraph(request).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
26313
27238
  const env = publishSettings(process.env);
27239
+ const replace = args.replace === true ? { replace: true } : {};
26314
27240
  const { reply, socket } = await requestHost(launcherFor(found), dryRun ? {
26315
27241
  etherpk: "publish-check",
26316
27242
  protocol: 1,
26317
27243
  publication,
26318
27244
  env
27245
+ } : github ? {
27246
+ etherpk: "publish-github",
27247
+ protocol: 1,
27248
+ publication,
27249
+ github,
27250
+ ...replace,
27251
+ env
26319
27252
  } : {
26320
27253
  etherpk: "publish",
26321
27254
  protocol: 1,
26322
27255
  publication,
26323
27256
  ...out ? { out: resolve(out) } : {},
27257
+ ...replace,
26324
27258
  env
26325
27259
  }).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
26326
27260
  socket.destroy();
@@ -26332,7 +27266,7 @@ async function publishCommand(args) {
26332
27266
  return;
26333
27267
  case "refused": return fail(reply.message);
26334
27268
  case "failed":
26335
- if (dryRun && reply.message === "The request was not understood.") return fail(`etherpk-mcp: this graph is served on this computer by an older etherpk-mcp, which cannot run a dry run. Run: ${CMD} stop, then try again.`);
27269
+ if ((dryRun || github) && reply.message === "The request was not understood.") return fail(`etherpk-mcp: this graph is served on this computer by an older etherpk-mcp, which cannot ${dryRun ? "run a dry run" : "publish to a repository"}. Run: ${CMD} stop, then try again.`);
26336
27270
  return fail(`etherpk-mcp: ${reply.message}`);
26337
27271
  case "unsupported": return fail(`etherpk-mcp: this graph is served on this computer by etherpk-mcp ${reply.version}, which cannot run a publish for this version (${VERSION}). Run: ${CMD} stop, then publish again.`);
26338
27272
  default: return fail(`etherpk-mcp: the graph's background process gave an answer this version does not know ("${reply.etherpk}").`);
@@ -26340,9 +27274,10 @@ async function publishCommand(args) {
26340
27274
  }
26341
27275
  /**
26342
27276
  * A command-line publish, as the graph's host runs it: the publication first, so a mistyped id is
26343
- * never remembered as a publish folder, then `--out` checked against the graph's folder and
26344
- * remembered, then the publish the agent's tool runs. The answer is what the command prints, in
26345
- * its own words.
27277
+ * never remembered as a destination, then `--out` checked against the graph's folder, or
27278
+ * `--github` checked as a repository, and remembered, then the publish the agent's tool runs, as
27279
+ * the person (`via: 'cli'`), which may commit to a repository. The answer is what the command
27280
+ * prints, in its own words.
26346
27281
  */
26347
27282
  async function publishInHost(graph, request) {
26348
27283
  const env = withPublishSettings(process.env, request.env ?? {});
@@ -26368,15 +27303,29 @@ async function publishInHost(graph, request) {
26368
27303
  await findPublication(graph, request.publication, host);
26369
27304
  const configPath = defaultPublishFoldersPath(env);
26370
27305
  const key = publishGraphKey(graph.backend, graph.graphId);
26371
- if (request.out) {
27306
+ if (request.etherpk === "publish-github") {
27307
+ const repo = parseRepositoryArgument(`${request.github.owner}/${request.github.name}#${request.github.branch}`);
27308
+ if (!repo) return {
27309
+ etherpk: "refused",
27310
+ message: "etherpk-mcp: --github takes owner/name, or owner/name#branch."
27311
+ };
27312
+ await writePublishFolders(configPath, withPublishRepository(await readPublishFolders(configPath), key, request.publication, repo));
27313
+ notes.push(`etherpk-mcp: "${request.publication}" of "${graph.name}" now publishes to ${repositoryLabel(repo)} (remembered in ${configPath}, in place of any folder).`);
27314
+ } else if (request.out) {
26372
27315
  await checkPublishFolder(graph, request.publication, request.out, host);
26373
27316
  await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, request.publication, request.out));
26374
27317
  notes.push(`etherpk-mcp: publish folder for "${request.publication}" of "${graph.name}" set to ${request.out} (remembered in ${configPath}).`);
26375
- } else if (!publishFolderOf(await readPublishFolders(configPath), key, request.publication)) return {
26376
- etherpk: "refused",
26377
- message: `No publish folder is set for "${request.publication}" of "${graph.name}" on this machine. Pass --out <dir> once - it is remembered.`
26378
- };
26379
- const result = await publish(graph, { id: request.publication }, host);
27318
+ } else {
27319
+ const remembered = await readPublishFolders(configPath);
27320
+ if (!publishFolderOf(remembered, key, request.publication) && !publishRepositoryOf(remembered, key, request.publication)) return {
27321
+ etherpk: "refused",
27322
+ message: `No publish folder or repository is set for "${request.publication}" of "${graph.name}" on this machine. Pass --out <dir> or --github <owner/name> once - it is remembered.`
27323
+ };
27324
+ }
27325
+ const result = await publish(graph, {
27326
+ id: request.publication,
27327
+ replace: request.replace === true
27328
+ }, host);
26380
27329
  return {
26381
27330
  etherpk: "published",
26382
27331
  output: cliPublishOutput(result),
@@ -26892,6 +27841,8 @@ async function main() {
26892
27841
  "no-semantic": { type: "boolean" },
26893
27842
  publication: { type: "string" },
26894
27843
  out: { type: "string" },
27844
+ github: { type: "string" },
27845
+ replace: { type: "boolean" },
26895
27846
  "dry-run": { type: "boolean" },
26896
27847
  all: { type: "boolean" },
26897
27848
  name: { type: "string" },