@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/README.md +23 -3
- package/dist/main.js +1178 -227
- package/dist/main.js.map +1 -1
- package/package.json +5 -5
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.
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
*
|
|
11262
|
-
*
|
|
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 (
|
|
11640
|
-
|
|
11641
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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]]
|
|
14991
|
-
*
|
|
14992
|
-
*
|
|
14993
|
-
*
|
|
14994
|
-
* the
|
|
14995
|
-
*
|
|
14996
|
-
*
|
|
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 (
|
|
15019
|
-
|
|
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
|
-
|
|
15088
|
-
|
|
15089
|
-
|
|
15090
|
-
...folders.folders
|
|
15091
|
-
[
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
18176
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
18872
|
+
const destinationOf = await destinationsFor(graph, env);
|
|
18203
18873
|
return {
|
|
18204
|
-
publications: summary.publications.map((p) => publicationView(p,
|
|
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
|
|
18920
|
+
const destinationOf = await destinationsFor(graph, env);
|
|
18251
18921
|
return {
|
|
18252
18922
|
created: true,
|
|
18253
|
-
publication: after ? publicationView(after,
|
|
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
|
|
18276
|
-
return { publication: publicationView(after ?? current,
|
|
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
|
|
18986
|
+
const { folder, repository } = (await destinationsFor(graph, env))(publication.id);
|
|
18311
18987
|
const dryRun = args.dryRun === true;
|
|
18312
|
-
|
|
18313
|
-
|
|
18314
|
-
|
|
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
|
|
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
|
-
|
|
18421
|
-
|
|
18422
|
-
|
|
18423
|
-
|
|
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) =>
|
|
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 =
|
|
24136
|
-
|
|
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
|
-
|
|
24141
|
-
|
|
24142
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
26344
|
-
* remembered, then the publish the agent's tool runs
|
|
26345
|
-
*
|
|
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.
|
|
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
|
|
26376
|
-
|
|
26377
|
-
|
|
26378
|
-
|
|
26379
|
-
|
|
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" },
|