@appsoftwareltd/etherpk-mcp 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -717,7 +717,7 @@ function looksLikeAgentSetupCode(value) {
717
717
  }
718
718
  var package_default = {
719
719
  name: "@appsoftwareltd/etherpk-mcp",
720
- version: "0.14.1",
720
+ version: "0.15.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",
@@ -4201,8 +4201,8 @@ function parseBlocks(markdown) {
4201
4201
  * does.
4202
4202
  */
4203
4203
  /** 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}$`);
4204
+ var NAME$1 = "[a-z][a-z0-9-]*";
4205
+ var EXTENSION_FENCE_INFO = new RegExp(`^${NAME$1}\\.${NAME$1}$`);
4206
4206
  /** Whether an info word names an Extension Fence: an extension's id, a dot and a name. */
4207
4207
  function isExtensionFenceInfo(info) {
4208
4208
  return EXTENSION_FENCE_INFO.test(info);
@@ -11258,8 +11258,10 @@ async function hostRequest(endpoint, request, options = {}) {
11258
11258
  /**
11259
11259
  * The settings a publish reads, which the command line decides even when the graph's host runs
11260
11260
  * the publish: where the publish folders are remembered, which browser draws diagrams and maps,
11261
- * and which style a map's picture is drawn over. The host was started from another environment
11262
- * (an agent's), and a scheduled publish may name its own browser or its own map host.
11261
+ * which style a map's picture is drawn over, and the GitHub token and API a [[Publish
11262
+ * Repository]] is committed with (ADR 0152). The host was started from another environment (an
11263
+ * agent's), and a scheduled publish may name its own browser, map host or token. The token goes
11264
+ * no further than this request: the host uses it for this publish and keeps nothing of it.
11263
11265
  */
11264
11266
  var PUBLISH_SETTINGS = [
11265
11267
  "ETHERPK_MCP_PUBLISH_CONFIG",
@@ -11267,7 +11269,9 @@ var PUBLISH_SETTINGS = [
11267
11269
  "ETHERPK_CHROMIUM",
11268
11270
  "PLAYWRIGHT_BROWSERS_PATH",
11269
11271
  "PLAYWRIGHT_CHROMIUM_EXECUTABLE",
11270
- "ETHERPK_MAP_STYLE_URL"
11272
+ "ETHERPK_MAP_STYLE_URL",
11273
+ "ETHERPK_GITHUB_TOKEN",
11274
+ "ETHERPK_GITHUB_API_URL"
11271
11275
  ];
11272
11276
  /** The publish settings in `env`, for a `publish` request. */
11273
11277
  function publishSettings(env) {
@@ -11625,6 +11629,13 @@ function sameBytes(a, b) {
11625
11629
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
11626
11630
  return true;
11627
11631
  }
11632
+ /** Whether the folder already holds `content` at `path`: compared where it can, read back where it cannot. */
11633
+ async function holds$1(folder, path, content) {
11634
+ if (folder.sameContent) return folder.sameContent(path, content);
11635
+ if (typeof content === "string") return await folder.readText(path) === content;
11636
+ const existing = await folder.readBytes(path);
11637
+ return existing !== null && sameBytes(existing, content);
11638
+ }
11628
11639
  /** Write a site into a folder under the rules above, whatever stands behind the folder. */
11629
11640
  async function writeSite(folder, bundle, options) {
11630
11641
  const result = {
@@ -11636,17 +11647,9 @@ async function writeSite(folder, bundle, options) {
11636
11647
  let done = 0;
11637
11648
  for (const [path, content] of bundle) {
11638
11649
  options.onProgress?.(done++, total);
11639
- if (typeof content === "string") {
11640
- if (await folder.readText(path) === content) {
11641
- result.unchanged++;
11642
- continue;
11643
- }
11644
- } else {
11645
- const existing = await folder.readBytes(path);
11646
- if (existing && sameBytes(existing, content)) {
11647
- result.unchanged++;
11648
- continue;
11649
- }
11650
+ if (await holds$1(folder, path, content)) {
11651
+ result.unchanged++;
11652
+ continue;
11650
11653
  }
11651
11654
  await folder.writeFile(path, content);
11652
11655
  result.written++;
@@ -11672,6 +11675,570 @@ async function writeSite(folder, bundle, options) {
11672
11675
  options.onProgress?.(total, total);
11673
11676
  return result;
11674
11677
  }
11678
+ var GitHubError = class extends Error {
11679
+ code;
11680
+ status;
11681
+ detail;
11682
+ constructor(code, message, status, detail) {
11683
+ super(message);
11684
+ this.code = code;
11685
+ this.status = status;
11686
+ this.detail = detail;
11687
+ this.name = "GitHubError";
11688
+ }
11689
+ };
11690
+ /** Writes GitHub asks to be spaced (its "best practices for using the REST API"). */
11691
+ var WRITE_SPACING_MS = 1e3;
11692
+ /** How many times a request GitHub failed on (5xx) is tried again. Creating git objects is idempotent. */
11693
+ var SERVER_ERROR_RETRIES = 2;
11694
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11695
+ /** True for a 403 or 429 that GitHub sends as a rate limit, rather than a refusal. */
11696
+ function isRateLimit(status, headers, message) {
11697
+ if (status === 429) return true;
11698
+ if (status !== 403) return false;
11699
+ return headers.has("retry-after") || headers.get("x-ratelimit-remaining") === "0" || /rate limit/i.test(message);
11700
+ }
11701
+ /** How long to wait before trying again, in GitHub's order: retry-after, the reset time, a doubling minute. */
11702
+ function waitFor(headers, attempt, now) {
11703
+ const retryAfter = Number(headers.get("retry-after"));
11704
+ if (Number.isFinite(retryAfter) && retryAfter > 0) return retryAfter * 1e3;
11705
+ const reset = Number(headers.get("x-ratelimit-reset"));
11706
+ if (headers.get("x-ratelimit-remaining") === "0" && Number.isFinite(reset) && reset * 1e3 > now) return reset * 1e3 - now;
11707
+ return 6e4 * 2 ** attempt;
11708
+ }
11709
+ /** The code for a refusal GitHub sent, from its status and message. */
11710
+ function refusalCode(status, message) {
11711
+ if (status === 401) return "bad-token";
11712
+ if (/not a fast.?forward/i.test(message)) return "branch-moved";
11713
+ if (/protected branch|pull request|GH006|signed/i.test(message)) return "branch-protected";
11714
+ if (status === 409 && /empty/i.test(message)) return "empty-repository";
11715
+ if (status === 403) return "read-only";
11716
+ if (status === 404) return "no-access";
11717
+ return "unexpected";
11718
+ }
11719
+ function createGitHubApi(options) {
11720
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
11721
+ const baseUrl = (options.baseUrl ?? "https://api.github.com").replace(/\/+$/, "");
11722
+ const sleep = options.sleep ?? realSleep;
11723
+ const now = options.now ?? Date.now;
11724
+ let lastWrite = Number.NEGATIVE_INFINITY;
11725
+ let writeQueue = Promise.resolve();
11726
+ async function send(method, path, body) {
11727
+ try {
11728
+ return await doFetch(`${baseUrl}${path}`, {
11729
+ method,
11730
+ headers: {
11731
+ Accept: "application/vnd.github+json",
11732
+ Authorization: `Bearer ${options.token}`,
11733
+ "X-GitHub-Api-Version": "2022-11-28",
11734
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
11735
+ },
11736
+ body: body === void 0 ? void 0 : JSON.stringify(body),
11737
+ credentials: "omit"
11738
+ });
11739
+ } catch (error) {
11740
+ throw new GitHubError("offline", `GitHub could not be reached: ${error instanceof Error ? error.message : String(error)}`);
11741
+ }
11742
+ }
11743
+ async function attempt(method, path, body, write) {
11744
+ let rateLimited = 0;
11745
+ let failed = 0;
11746
+ for (;;) {
11747
+ if (write) {
11748
+ const wait = lastWrite + WRITE_SPACING_MS - now();
11749
+ if (wait > 0) await sleep(wait);
11750
+ lastWrite = now();
11751
+ }
11752
+ const response = await send(method, path, body);
11753
+ if (response.ok) {
11754
+ const text = await response.text();
11755
+ return text === "" ? null : JSON.parse(text);
11756
+ }
11757
+ const message = await response.json().then((json) => typeof json?.message === "string" ? json.message : "").catch(() => "");
11758
+ if (isRateLimit(response.status, response.headers, message)) {
11759
+ const wait = waitFor(response.headers, rateLimited, now());
11760
+ if (rateLimited >= 4) throw new GitHubError("rate-limited", `GitHub asked for a wait: ${message}`, response.status, { retryAt: now() + wait });
11761
+ rateLimited++;
11762
+ options.onWait?.(now() + wait);
11763
+ await sleep(wait);
11764
+ continue;
11765
+ }
11766
+ if (response.status >= 500 && failed < SERVER_ERROR_RETRIES) {
11767
+ failed++;
11768
+ await sleep(2e3 * failed);
11769
+ continue;
11770
+ }
11771
+ throw new GitHubError(refusalCode(response.status, message), message || `${response.status} ${response.statusText}`, response.status);
11772
+ }
11773
+ }
11774
+ return { request(method, path, body) {
11775
+ if (method === "GET") return attempt(method, path, body, false);
11776
+ const run = writeQueue.then(() => attempt(method, path, body, true));
11777
+ writeQueue = run.catch(() => void 0);
11778
+ return run;
11779
+ } };
11780
+ }
11781
+ /** `/repos/{owner}/{name}`, each part escaped. */
11782
+ function repoPath(repo) {
11783
+ return `/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`;
11784
+ }
11785
+ /** A branch name in a ref path: a branch may hold `/`, which stays a separator. */
11786
+ function branchPath(branch) {
11787
+ return branch.split("/").map(encodeURIComponent).join("/");
11788
+ }
11789
+ /** Standard base64, as GitHub's blob and contents endpoints take and give it. */
11790
+ function bytesToBase64(bytes) {
11791
+ let binary = "";
11792
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
11793
+ return btoa(binary);
11794
+ }
11795
+ /** GitHub's base64 wraps lines; the line breaks are not part of it. */
11796
+ function base64ToBytes(text) {
11797
+ const binary = atob(text.replace(/\s+/g, ""));
11798
+ const bytes = new Uint8Array(binary.length);
11799
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
11800
+ return bytes;
11801
+ }
11802
+ //#endregion
11803
+ //#region ../client/src/lib/document/publish/host/github-repository.ts
11804
+ /**
11805
+ * A [[Publish Repository]] before any publish: reading what a person typed as a repository,
11806
+ * checking that their GitHub token can reach it, and starting the branch when they ask (ADR 0152).
11807
+ *
11808
+ * The check runs when the repository is saved in the Publish tab and before the first publish to
11809
+ * one that was never checked, rather than behind a button of its own. Write access is proven by
11810
+ * writing: a fine-grained token's own permissions are not in any answer GitHub gives (a
11811
+ * repository's `permissions` are the person's role), so the check uploads a tiny blob that no
11812
+ * commit refers to, which GitHub discards.
11813
+ *
11814
+ * A publish never creates a branch: a branch name mistyped would publish where no host looks.
11815
+ * The person asks for it (`createBranch`), after the check has said the branch is not there.
11816
+ */
11817
+ var OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
11818
+ var NAME = /^[A-Za-z0-9._-]{1,100}$/;
11819
+ /**
11820
+ * `owner/name` from what a person typed or pasted: `owner/name`, a github.com address, a clone
11821
+ * url or an ssh address. Null when it is none of those.
11822
+ */
11823
+ function parseRepository(input) {
11824
+ const parts = input.trim().replace(/^git@github\.com:/i, "").replace(/^(?:https?:\/\/)?(?:www\.)?github\.com\//i, "").replace(/\/+$/, "").replace(/\.git$/i, "").split("/");
11825
+ if (parts.length !== 2) return null;
11826
+ const [owner, name] = parts;
11827
+ if (!OWNER.test(owner) || !NAME.test(name) || name === "." || name === "..") return null;
11828
+ return {
11829
+ owner,
11830
+ name
11831
+ };
11832
+ }
11833
+ /** Whether git takes `name` as a branch name (the rules of `git check-ref-format` a person can break by typing). */
11834
+ function isBranchName(name) {
11835
+ if (name === "" || name.startsWith("/") || name.endsWith("/") || name.endsWith(".") || name.endsWith(".lock")) return false;
11836
+ if (name.includes("..") || name.includes("//") || name.includes("@{")) return false;
11837
+ if ([...name].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) return false;
11838
+ return !/[\s~^:?*[\\]/.test(name);
11839
+ }
11840
+ function megabytes(bytes) {
11841
+ return `${Math.round(bytes / 1048576)} MB`;
11842
+ }
11843
+ function clock(ms) {
11844
+ return new Date(ms).toLocaleTimeString([], {
11845
+ hour: "2-digit",
11846
+ minute: "2-digit"
11847
+ });
11848
+ }
11849
+ /**
11850
+ * An error from a check or a publish, worded for the person: what happened, why, and what to do.
11851
+ * `replaceToken` says where a new token goes: the Publish tab's GitHub section by default, an
11852
+ * environment variable at the Headless Client's command line.
11853
+ */
11854
+ function describeGitHubError(error, repo, options = {}) {
11855
+ if (!(error instanceof GitHubError)) return error instanceof Error ? error.message : String(error);
11856
+ const where = `${repo.owner}/${repo.name}`;
11857
+ switch (error.code) {
11858
+ case "offline": return "GitHub could not be reached. Check the connection and try again.";
11859
+ 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."}`;
11860
+ case "no-access": return `GitHub found no repository ${where} for this token. Check the name, and that the token's Repository access includes it.`;
11861
+ 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.`;
11862
+ case "branch-missing": return `${where} has no branch "${repo.branch}". Check the name, or create the branch.`;
11863
+ case "empty-repository": return `${where} is empty. Create the branch to make its first commit.`;
11864
+ case "branch-moved": return `The branch "${repo.branch}" changed twice while EtherPK was publishing to it. Publish again.`;
11865
+ 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.`;
11866
+ 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())}.`;
11867
+ 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.`;
11868
+ 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.`;
11869
+ case "unexpected": return `GitHub refused the request${error.status ? ` (${error.status})` : ""}: ${error.message}`;
11870
+ }
11871
+ }
11872
+ //#endregion
11873
+ //#region ../client/src/lib/document/publish/seeded.ts
11874
+ var PAGES_WORKFLOW_PATH = ".github/workflows/pages.yaml";
11875
+ var WRANGLER_PATH = "wrangler.jsonc";
11876
+ var ASSETS_IGNORE_PATH = ".assetsignore";
11877
+ var README_PATH = "README.md";
11878
+ var GITIGNORE_PATH = ".gitignore";
11879
+ /**
11880
+ * Files written when absent and left alone afterwards. A Publish Repository gets no Pages
11881
+ * workflow: GitHub refuses a commit that adds one unless the token may also change workflows,
11882
+ * and Pages serves a branch as it is without one ("Deploy from a branch"), so its README says
11883
+ * that instead.
11884
+ */
11885
+ function seededFiles(publication, destination = "folder") {
11886
+ const files = /* @__PURE__ */ new Map([
11887
+ [PAGES_WORKFLOW_PATH, `# Publishes this folder to GitHub Pages as it is: the site is already built by EtherPK,
11888
+ # so there is no build step. In the repository's Settings → Pages, set the source to
11889
+ # "GitHub Actions" once - every push to main then deploys.
11890
+ name: Deploy to GitHub Pages
11891
+ on:
11892
+ push:
11893
+ branches: [main]
11894
+ workflow_dispatch:
11895
+ permissions:
11896
+ contents: read
11897
+ pages: write
11898
+ id-token: write
11899
+ concurrency:
11900
+ group: pages
11901
+ cancel-in-progress: false
11902
+ jobs:
11903
+ deploy:
11904
+ runs-on: ubuntu-latest
11905
+ environment:
11906
+ name: github-pages
11907
+ url: \${{ steps.deployment.outputs.page_url }}
11908
+ steps:
11909
+ - uses: actions/checkout@v4
11910
+ - uses: actions/configure-pages@v5
11911
+ - uses: actions/upload-pages-artifact@v3
11912
+ with:
11913
+ path: .
11914
+ - id: deployment
11915
+ uses: actions/deploy-pages@v4
11916
+ `],
11917
+ [WRANGLER_PATH, `// Cloudflare Workers with static assets (Cloudflare's successor to Pages for new sites).
11918
+ // Deploy with \`npx wrangler deploy\`, or connect the repository in the dashboard under
11919
+ // Workers → Create → Import a repository and leave the build command empty. The Worker is
11920
+ // named after the publication - Cloudflare warns when this name and the Worker's differ.
11921
+ {
11922
+ "name": "${publication.id}",
11923
+ "compatibility_date": "2026-01-01",
11924
+ "assets": {
11925
+ "directory": ".",
11926
+ "html_handling": "auto-trailing-slash",
11927
+ "not_found_handling": "404-page"
11928
+ }
11929
+ }
11930
+ `],
11931
+ [README_PATH, `# ${publication.name}
11932
+
11933
+ This folder is a website published by [EtherPK](https://etherpk.com) from the publication
11934
+ \`${publication.id}\`. Every file in it except \`CNAME\` and \`robots.txt\` is regenerated on each
11935
+ publish, so edit the notes, the publication page, its include pages and its theme in EtherPK,
11936
+ not here.
11937
+
11938
+ - Preview: open \`index.html\` in a browser - search works from disk too.
11939
+ ${destination === "repository" ? `- GitHub Pages: in the repository's Settings → Pages, set Source to "Deploy from a branch" and
11940
+ choose this branch and \`/ (root)\`. Every publish then deploys.` : `- GitHub Pages: push this folder to a repository and set Settings → Pages → Source to
11941
+ "GitHub Actions" - \`.github/workflows/pages.yaml\` deploys every push to \`main\`.`}
11942
+ - Cloudflare: \`npx wrangler deploy\` uses \`wrangler.jsonc\`, or connect the repository in the
11943
+ Workers dashboard with no build command.
11944
+ - Netlify, GitLab Pages, Vercel, S3 or your own server: the folder is plain files, so any of
11945
+ them serves it as it is. Step by step for each: https://docs.etherpk.com/hosting-a-published-site
11946
+ `],
11947
+ [GITIGNORE_PATH, `.DS_Store\nThumbs.db\n`],
11948
+ [ASSETS_IGNORE_PATH, `# Files in this folder that are not part of the website, kept out of the Worker's assets.
11949
+ # Written once by EtherPK - yours to edit.
11950
+ .git
11951
+ .github
11952
+ .gitignore
11953
+ .assetsignore
11954
+ .nojekyll
11955
+ wrangler.jsonc
11956
+ AGENTS.md
11957
+ README.md
11958
+ `]
11959
+ ]);
11960
+ if (destination === "repository") files.delete(PAGES_WORKFLOW_PATH);
11961
+ return files;
11962
+ }
11963
+ var AGENTS_START = "<!-- etherpk:start -->";
11964
+ var AGENTS_END = "<!-- etherpk:end -->";
11965
+ function managedSection(publication) {
11966
+ return `${AGENTS_START}
11967
+ ## EtherPK published site
11968
+
11969
+ This folder is a website generated by [EtherPK](https://etherpk.com) from the publication
11970
+ \`${publication.id}\` ("${publication.name}"). It is **output**: every file except \`CNAME\`,
11971
+ \`robots.txt\` and the text outside this section of \`AGENTS.md\` is rewritten on each publish
11972
+ and files that are no longer produced are deleted.
11973
+
11974
+ - Do not edit the HTML, the assets or \`theme/\` here - the change is lost on the next publish.
11975
+ - Content changes are made to the documents in EtherPK. A document is published when its
11976
+ frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
11977
+ (unless the publication takes every public document).
11978
+ - The navigation is the outline of the publication page in EtherPK - the theme and the include
11979
+ slots (head, favicon, logo, header, footer, before-content, after-content, scripts, styles) are chosen
11980
+ and edited there too, in the Publish settings and the Theme editor.
11981
+ - EtherPK publishes this site into a folder, or straight to its repository on GitHub, from any
11982
+ device. A change made here to a file it writes is lost on the next publish, wherever that runs.
11983
+ - \`index.html\` opens from disk for a preview. On GitHub Pages the site deploys through
11984
+ \`.github/workflows/pages.yaml\` where there is one, or with Pages set to "Deploy from a branch".
11985
+ \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it as it is
11986
+ (https://docs.etherpk.com/hosting-a-published-site).
11987
+
11988
+ This section is rewritten by EtherPK on every publish. Put your own notes above or below it.
11989
+ ${AGENTS_END}
11990
+ `;
11991
+ }
11992
+ /**
11993
+ * The publication whose site a folder's AGENTS.md says the folder holds, or null when it carries
11994
+ * no published-site section. A first publish to a [[Publish Repository]] asks before it replaces
11995
+ * files on a branch that does not say it holds this publication's site.
11996
+ */
11997
+ function publishedPublicationOf(agentsMd) {
11998
+ if (agentsMd === null) return null;
11999
+ const start = agentsMd.indexOf(AGENTS_START);
12000
+ const end = agentsMd.indexOf(AGENTS_END);
12001
+ if (start === -1 || end <= start) return null;
12002
+ const section = agentsMd.slice(start, end);
12003
+ if (!section.includes("## EtherPK published site")) return null;
12004
+ return /from the publication\s+`([a-z0-9-]+)`/.exec(section)?.[1] ?? null;
12005
+ }
12006
+ /** The folder's AGENTS.md with the managed section replaced or added, the user's text kept. */
12007
+ function agentsMdWithManagedSection(existing, publication) {
12008
+ const section = managedSection(publication);
12009
+ if (existing === null || existing.trim() === "") return section;
12010
+ const start = existing.indexOf(AGENTS_START);
12011
+ const end = existing.indexOf(AGENTS_END);
12012
+ if (start !== -1 && end !== -1 && end > start) return existing.slice(0, start) + section.trimEnd() + existing.slice(end + 20);
12013
+ return `${existing.trimEnd()}\n\n${section}`;
12014
+ }
12015
+ //#endregion
12016
+ //#region ../client/src/lib/document/publish/host/git-blob-sha.ts
12017
+ /**
12018
+ * The name git gives a file's content: the SHA-1 of `blob <byte length>\0` followed by the bytes,
12019
+ * as `git hash-object` prints it. A [[Publish Repository]] compares it with the names in the
12020
+ * branch's tree to find what a publish changed without downloading a single file, and an
12021
+ * unchanged image keeps its name, so a later publish never sends it again.
12022
+ *
12023
+ * WebCrypto, which the browser and Node both have, so the Client and the Headless Client share it.
12024
+ * SHA-1 here is git's object name, not a security measure.
12025
+ */
12026
+ var encoder$1 = new TextEncoder();
12027
+ async function gitBlobSha(content) {
12028
+ const bytes = typeof content === "string" ? encoder$1.encode(content) : content;
12029
+ const header = encoder$1.encode(`blob ${bytes.byteLength}\0`);
12030
+ const object = new Uint8Array(header.byteLength + bytes.byteLength);
12031
+ object.set(header, 0);
12032
+ object.set(bytes, header.byteLength);
12033
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-1", object));
12034
+ return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join("");
12035
+ }
12036
+ /** Tree entries sent in one request, and inline text in one request, before the next batch starts. */
12037
+ var TREE_BATCH_ENTRIES = 300;
12038
+ var TREE_BATCH_BYTES = 4194304;
12039
+ var decoder = new TextDecoder();
12040
+ var encoder = new TextEncoder();
12041
+ /** Read the branch: its head commit and every file in its tree. */
12042
+ async function openRepositoryBranch(api, repo) {
12043
+ const base = repoPath(repo);
12044
+ let head;
12045
+ try {
12046
+ head = (await api.request("GET", `${base}/git/ref/heads/${branchPath(repo.branch)}`)).object.sha;
12047
+ } catch (error) {
12048
+ if (error instanceof GitHubError && error.code === "no-access") {
12049
+ await api.request("GET", base);
12050
+ throw new GitHubError("branch-missing", error.message, error.status);
12051
+ }
12052
+ throw error;
12053
+ }
12054
+ const treeSha = (await api.request("GET", `${base}/git/commits/${head}`)).tree.sha;
12055
+ const listing = await api.request("GET", `${base}/git/trees/${treeSha}?recursive=1`);
12056
+ if (listing.truncated) throw new GitHubError("truncated", "GitHub listed the branch only in part.");
12057
+ const tree = /* @__PURE__ */ new Map();
12058
+ for (const entry of listing.tree) if (entry.type === "blob") tree.set(entry.path, {
12059
+ mode: entry.mode,
12060
+ sha: entry.sha
12061
+ });
12062
+ /** Path → new content, or null for a removal. */
12063
+ const staged = /* @__PURE__ */ new Map();
12064
+ const downloaded = /* @__PURE__ */ new Map();
12065
+ async function bytesAt(path) {
12066
+ const change = staged.get(path);
12067
+ if (change !== void 0) return change === null ? null : typeof change === "string" ? encoder.encode(change) : change;
12068
+ const entry = tree.get(path);
12069
+ if (!entry) return null;
12070
+ let bytes = downloaded.get(entry.sha);
12071
+ if (!bytes) {
12072
+ bytes = base64ToBytes((await api.request("GET", `${base}/git/blobs/${entry.sha}`)).content);
12073
+ downloaded.set(entry.sha, bytes);
12074
+ }
12075
+ return bytes;
12076
+ }
12077
+ return {
12078
+ head,
12079
+ async readText(path) {
12080
+ const bytes = await bytesAt(path);
12081
+ return bytes === null ? null : decoder.decode(bytes);
12082
+ },
12083
+ readBytes: bytesAt,
12084
+ async sameContent(path, content) {
12085
+ const entry = tree.get(path);
12086
+ return entry !== void 0 && entry.sha === await gitBlobSha(content);
12087
+ },
12088
+ async writeFile(path, content) {
12089
+ staged.set(path, content);
12090
+ },
12091
+ async remove(path) {
12092
+ if (tree.has(path)) staged.set(path, null);
12093
+ },
12094
+ async listFiles() {
12095
+ return [...tree.keys()];
12096
+ },
12097
+ replaced() {
12098
+ return [...staged.keys()].filter((path) => tree.has(path) && path !== "AGENTS.md").sort();
12099
+ },
12100
+ pendingChanges() {
12101
+ return staged.size;
12102
+ },
12103
+ async commit(message, options = {}) {
12104
+ if (staged.size === 0) return null;
12105
+ const report = options.onProgress ?? (() => {});
12106
+ const maxFileBytes = options.maxFileBytes ?? 104857600;
12107
+ for (const [path, content] of staged) {
12108
+ if (content === null) continue;
12109
+ const size = typeof content === "string" ? encoder.encode(content).byteLength : content.byteLength;
12110
+ if (size > maxFileBytes) throw new GitHubError("too-large", `${path} is over GitHub's file size limit.`, void 0, {
12111
+ path,
12112
+ size
12113
+ });
12114
+ }
12115
+ const known = new Set([...tree.values()].map((entry) => entry.sha));
12116
+ const binaries = [...staged].filter((change) => change[1] instanceof Uint8Array);
12117
+ const blobShas = /* @__PURE__ */ new Map();
12118
+ report("uploading", 0, binaries.length);
12119
+ for (const [index, [path, bytes]] of binaries.entries()) {
12120
+ let sha = await gitBlobSha(bytes);
12121
+ if (!known.has(sha)) {
12122
+ sha = (await api.request("POST", `${base}/git/blobs`, {
12123
+ content: bytesToBase64(bytes),
12124
+ encoding: "base64"
12125
+ })).sha;
12126
+ known.add(sha);
12127
+ }
12128
+ blobShas.set(path, sha);
12129
+ report("uploading", index + 1, binaries.length);
12130
+ }
12131
+ const batches = batch([...staged].map(([path, content]) => {
12132
+ const mode = tree.get(path)?.mode ?? "100644";
12133
+ if (content === null) return {
12134
+ path,
12135
+ mode,
12136
+ type: "blob",
12137
+ sha: null
12138
+ };
12139
+ if (typeof content === "string") return {
12140
+ path,
12141
+ mode,
12142
+ type: "blob",
12143
+ content
12144
+ };
12145
+ return {
12146
+ path,
12147
+ mode,
12148
+ type: "blob",
12149
+ sha: blobShas.get(path)
12150
+ };
12151
+ }), options.treeBatch ?? TREE_BATCH_ENTRIES);
12152
+ let root = treeSha;
12153
+ for (const [index, entriesOfBatch] of batches.entries()) {
12154
+ report("committing", index, batches.length + 2);
12155
+ root = (await api.request("POST", `${base}/git/trees`, {
12156
+ base_tree: root,
12157
+ tree: entriesOfBatch
12158
+ })).sha;
12159
+ }
12160
+ report("committing", batches.length, batches.length + 2);
12161
+ const commit = await api.request("POST", `${base}/git/commits`, {
12162
+ message,
12163
+ tree: root,
12164
+ parents: [head]
12165
+ });
12166
+ report("committing", batches.length + 1, batches.length + 2);
12167
+ await api.request("PATCH", `${base}/git/refs/heads/${branchPath(repo.branch)}`, {
12168
+ sha: commit.sha,
12169
+ force: false
12170
+ });
12171
+ report("committing", batches.length + 2, batches.length + 2);
12172
+ return {
12173
+ sha: commit.sha,
12174
+ url: commit.html_url
12175
+ };
12176
+ }
12177
+ };
12178
+ }
12179
+ /** Tree entries in batches small enough for one request: by count, and by the text they carry. */
12180
+ function batch(entries, size) {
12181
+ const out = [];
12182
+ let current = [];
12183
+ let bytes = 0;
12184
+ for (const entry of entries) {
12185
+ const weight = entry.content?.length ?? 0;
12186
+ if (current.length > 0 && (current.length >= size || bytes + weight > TREE_BATCH_BYTES)) {
12187
+ out.push(current);
12188
+ current = [];
12189
+ bytes = 0;
12190
+ }
12191
+ current.push(entry);
12192
+ bytes += weight;
12193
+ }
12194
+ if (current.length > 0) out.push(current);
12195
+ return out;
12196
+ }
12197
+ /** The graph's own AGENTS.md section (`storage/fs/agents-md-section.md`). */
12198
+ var GRAPH_AGENTS_HEADING = /^## EtherPK knowledge graph\s*$/m;
12199
+ /** True when the branch looks like a [[Graph Folder]], which a publish would gut. */
12200
+ async function holdsGraph(branch) {
12201
+ const files = await branch.listFiles();
12202
+ if (files.some((path) => path.startsWith("pages/")) && files.some((path) => path.startsWith("journals/"))) return true;
12203
+ const agents = files.includes("AGENTS.md") ? await branch.readText("AGENTS.md") : null;
12204
+ return agents !== null && GRAPH_AGENTS_HEADING.test(agents);
12205
+ }
12206
+ /** Publish a rendered site to the branch as one commit, under the rules at the top of this module. */
12207
+ async function publishToRepository(api, repo, input, options = {}) {
12208
+ for (let attempt = 0;; attempt++) {
12209
+ options.onProgress?.("reading", 0, 0);
12210
+ const branch = await openRepositoryBranch(api, repo);
12211
+ 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.`);
12212
+ const publishedHere = publishedPublicationOf(await branch.readText("AGENTS.md")) === input.publicationId;
12213
+ const written = await writeSite(branch, input.bundle, {
12214
+ seeded: input.seeded,
12215
+ agentsMd: input.agentsMd
12216
+ });
12217
+ if (!options.confirmed && !publishedHere) {
12218
+ const replaced = branch.replaced();
12219
+ if (replaced.length > 0) return {
12220
+ state: "needs-confirmation",
12221
+ replaced
12222
+ };
12223
+ }
12224
+ if (branch.pendingChanges() === 0) return {
12225
+ state: "unchanged",
12226
+ written
12227
+ };
12228
+ const message = `Publish ${input.publicationId}: ${written.written} written, ${written.deleted.length} removed`;
12229
+ try {
12230
+ return {
12231
+ state: "committed",
12232
+ written,
12233
+ commit: await branch.commit(message, options),
12234
+ retried: attempt > 0
12235
+ };
12236
+ } catch (error) {
12237
+ if (error instanceof GitHubError && error.code === "branch-moved" && attempt === 0) continue;
12238
+ throw error;
12239
+ }
12240
+ }
12241
+ }
11675
12242
  //#endregion
11676
12243
  //#region ../../packages/themes/src/index.ts
11677
12244
  var files = /* #__PURE__ */ Object.assign({
@@ -12046,124 +12613,6 @@ function withPublishing(text, patch, options = {}) {
12046
12613
  }, options);
12047
12614
  }
12048
12615
  //#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
12616
  //#region ../client/src/lib/document/fence-answer.ts
12168
12617
  var PICTURE_TYPES = /* @__PURE__ */ new Set([
12169
12618
  "image/svg+xml",
@@ -14464,7 +14913,7 @@ async function runPublish(publication, deps) {
14464
14913
  return {
14465
14914
  report,
14466
14915
  bundle,
14467
- seeded: seededFiles(publication),
14916
+ seeded: seededFiles(publication, deps.destination),
14468
14917
  agentsMd: (existing) => agentsMdWithManagedSection(existing, publication)
14469
14918
  };
14470
14919
  }
@@ -14987,14 +15436,35 @@ function nodeSiteFolder(root) {
14987
15436
  //#endregion
14988
15437
  //#region src/publish-folders.ts
14989
15438
  /**
14990
- * Where each [[Publication]]'s [[Publish Folder]] is on this machine (ADR 0086): configuration a
14991
- * person sets with `etherpk-mcp publish … --out <dir>`, read by the `publish` tool and by the
14992
- * command without `--out`. A file of its own beside the logins (`publish.json`), because it is
14993
- * not a secret and outlives a login; keyed by the graph the way its cache directory is keyed -
14994
- * the server's host and graph id for a synced graph, the folder's path hash for a served folder -
14995
- * so two graphs with a publication both called `blog` never share a folder. Never written into a
14996
- * graph folder, which is the user's content (ADR 0079).
14997
- */
15439
+ * Where each [[Publication]] publishes from this machine (ADR 0086, ADR 0152): its [[Publish
15440
+ * Folder]], set with `etherpk-mcp publish … --out <dir>`, or its [[Publish Repository]], set with
15441
+ * `--github owner/name[#branch]`. One or the other: setting one forgets the other, as in the
15442
+ * Client. Read by the `publish` tool and by the command run without either. A file of its own
15443
+ * beside the logins (`publish.json`), because it is not a secret and outlives a login (the GitHub
15444
+ * token is never in it: it comes from `ETHERPK_GITHUB_TOKEN`); keyed by the graph the way its
15445
+ * cache directory is keyed - the server's host and graph id for a synced graph, the folder's path
15446
+ * hash for a served folder - so two graphs with a publication both called `blog` never share a
15447
+ * destination. Never written into a graph folder, which is the user's content (ADR 0079).
15448
+ */
15449
+ /**
15450
+ * `owner/name` or `owner/name#branch` (a github.com address works too), as `--github` takes it.
15451
+ * Null when it is not a repository, or the branch is not a name git takes.
15452
+ */
15453
+ function parseRepositoryArgument(text) {
15454
+ const [repository, branch, ...rest] = text.trim().split("#");
15455
+ if (rest.length > 0) return null;
15456
+ const parsed = parseRepository(repository);
15457
+ const name = branch?.trim() || "main";
15458
+ if (!parsed || !isBranchName(name)) return null;
15459
+ return {
15460
+ ...parsed,
15461
+ branch: name
15462
+ };
15463
+ }
15464
+ /** How a repository is written for a person: `owner/name#branch`. */
15465
+ function repositoryLabel(repo) {
15466
+ return `${repo.owner}/${repo.name}#${repo.branch}`;
15467
+ }
14998
15468
  function defaultPublishFoldersPath(env = process.env) {
14999
15469
  const override = env.ETHERPK_MCP_PUBLISH_CONFIG?.trim();
15000
15470
  if (override) return override;
@@ -15014,14 +15484,25 @@ function parsePublishFolders(raw) {
15014
15484
  } catch {
15015
15485
  return out;
15016
15486
  }
15487
+ const isMap = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
15017
15488
  const folders = parsed?.folders;
15018
- if (typeof folders !== "object" || folders === null || Array.isArray(folders)) return out;
15019
- for (const [graphKey, byPublication] of Object.entries(folders)) {
15020
- if (typeof byPublication !== "object" || byPublication === null || Array.isArray(byPublication)) continue;
15489
+ if (isMap(folders)) for (const [graphKey, byPublication] of Object.entries(folders)) {
15490
+ if (!isMap(byPublication)) continue;
15021
15491
  const kept = {};
15022
15492
  for (const [publication, path] of Object.entries(byPublication)) if (typeof path === "string" && isAbsolute(path)) kept[publication] = path;
15023
15493
  if (Object.keys(kept).length > 0) out.folders[graphKey] = kept;
15024
15494
  }
15495
+ const repositories = parsed?.repositories;
15496
+ if (isMap(repositories)) for (const [graphKey, byPublication] of Object.entries(repositories)) {
15497
+ if (!isMap(byPublication)) continue;
15498
+ const kept = {};
15499
+ for (const [publication, repo] of Object.entries(byPublication)) {
15500
+ if (!isMap(repo) || typeof repo.owner !== "string" || typeof repo.name !== "string" || typeof repo.branch !== "string") continue;
15501
+ const checked = parseRepositoryArgument(`${repo.owner}/${repo.name}#${repo.branch}`);
15502
+ if (checked) kept[publication] = checked;
15503
+ }
15504
+ if (Object.keys(kept).length > 0) (out.repositories ??= {})[graphKey] = kept;
15505
+ }
15025
15506
  return out;
15026
15507
  }
15027
15508
  async function readPublishFolders(path) {
@@ -15043,6 +15524,33 @@ async function writePublishFolders(path, folders) {
15043
15524
  function publishFolderOf(folders, graphKey, publicationId) {
15044
15525
  return folders.folders[graphKey]?.[publicationId] ?? null;
15045
15526
  }
15527
+ /** The repository set for a publication on this machine, or null. */
15528
+ function publishRepositoryOf(folders, graphKey, publicationId) {
15529
+ return folders.repositories?.[graphKey]?.[publicationId] ?? null;
15530
+ }
15531
+ /** `publications` with one entry dropped, and the graph's map dropped when it empties. */
15532
+ function without(byGraph, graphKey, publicationId) {
15533
+ const out = { ...byGraph ?? {} };
15534
+ const rest = { ...out[graphKey] ?? {} };
15535
+ delete rest[publicationId];
15536
+ if (Object.keys(rest).length > 0) out[graphKey] = rest;
15537
+ else delete out[graphKey];
15538
+ return out;
15539
+ }
15540
+ /** The same file with one publication's repository set, in place of any folder - the caller writes it. */
15541
+ function withPublishRepository(folders, graphKey, publicationId, repo) {
15542
+ const repositories = folders.repositories ?? {};
15543
+ return {
15544
+ folders: without(folders.folders, graphKey, publicationId),
15545
+ repositories: {
15546
+ ...repositories,
15547
+ [graphKey]: {
15548
+ ...repositories[graphKey] ?? {},
15549
+ [publicationId]: repo
15550
+ }
15551
+ }
15552
+ };
15553
+ }
15046
15554
  /**
15047
15555
  * How two absolute folder paths relate, by the platform's own `relative` rather than by splitting
15048
15556
  * on `/` (Windows paths use `\`, and a shared prefix such as `notes` and `notes-site` is not a
@@ -15082,15 +15590,19 @@ async function canonicalFolder(folder) {
15082
15590
  async function publishFolderOverlap(graphFolder, publishFolder) {
15083
15591
  return relateFolders(await canonicalFolder(graphFolder), await canonicalFolder(publishFolder));
15084
15592
  }
15085
- /** The same file with one publication's folder set (made absolute) - the caller writes it. */
15593
+ /** The same file with one publication's folder set (made absolute), in place of any repository - the caller writes it. */
15086
15594
  function withPublishFolder(folders, graphKey, publicationId, folder) {
15087
- return { folders: {
15088
- ...folders.folders,
15089
- [graphKey]: {
15090
- ...folders.folders[graphKey] ?? {},
15091
- [publicationId]: resolve(folder)
15092
- }
15093
- } };
15595
+ const repositories = without(folders.repositories, graphKey, publicationId);
15596
+ return {
15597
+ folders: {
15598
+ ...folders.folders,
15599
+ [graphKey]: {
15600
+ ...folders.folders[graphKey] ?? {},
15601
+ [publicationId]: resolve(folder)
15602
+ }
15603
+ },
15604
+ ...Object.keys(repositories).length > 0 ? { repositories } : {}
15605
+ };
15094
15606
  }
15095
15607
  //#endregion
15096
15608
  //#region ../client/src/lib/document/protection/cipher-fence.ts
@@ -18107,9 +18619,12 @@ async function graphInfo(graph) {
18107
18619
  * which the Settings tab uses too, so a publication page an agent creates is the one a person
18108
18620
  * would have created.
18109
18621
  *
18110
- * Two things are this host's own. The [[Publish Folder]] is configuration a person set on the
18622
+ * Three things are this host's own. The [[Publish Folder]] is configuration a person set on the
18111
18623
  * command line, never a tool argument: the site writer deletes strays from the paths it owns,
18112
- * and a folder an agent inferred could lose real files to that rule. And a publish whose pages
18624
+ * and a folder an agent inferred could lose real files to that rule. A [[Publish Repository]] is
18625
+ * set the same way, and only the person's own `etherpk-mcp publish` commits to it, with the
18626
+ * token in `ETHERPK_GITHUB_TOKEN`: a commit there can deploy the site, so the agent's tool checks
18627
+ * such a publication (a dry run) and never publishes it (ADR 0152). And a publish whose pages
18113
18628
  * hold Mermaid needs the browser `diagrams setup` installed; without one it refuses rather than
18114
18629
  * publishing a site with its diagrams turned to text.
18115
18630
  */
@@ -18172,8 +18687,11 @@ async function settle$1(graph) {
18172
18687
  const result = await graph.settle();
18173
18688
  if (!result.settled) throw new ToolError("not_settled", result.message);
18174
18689
  }
18175
- /** How a publication reads to an agent: its page's settings, and whether this machine can publish it. */
18176
- function publicationView(publication, folder) {
18690
+ /**
18691
+ * How a publication reads to an agent: its page's settings, and where this machine publishes it.
18692
+ * A repository shows as `owner/name#branch`; it is not the agent's to publish to.
18693
+ */
18694
+ function publicationView(publication, destination) {
18177
18695
  return {
18178
18696
  id: publication.id,
18179
18697
  name: publication.name,
@@ -18185,13 +18703,17 @@ function publicationView(publication, folder) {
18185
18703
  theme: publication.theme,
18186
18704
  recent: publication.recent,
18187
18705
  includes: publication.includes,
18188
- publishFolder: folder
18706
+ publishFolder: destination.folder,
18707
+ publishRepository: destination.repository ? repositoryLabel(destination.repository) : null
18189
18708
  };
18190
18709
  }
18191
- async function foldersFor(graph, env) {
18710
+ async function destinationsFor(graph, env) {
18192
18711
  const folders = await readPublishFolders(defaultPublishFoldersPath(env));
18193
18712
  const key = publishGraphKey(graph.backend, graph.graphId);
18194
- return (publicationId) => publishFolderOf(folders, key, publicationId);
18713
+ return (publicationId) => ({
18714
+ folder: publishFolderOf(folders, key, publicationId),
18715
+ repository: publishRepositoryOf(folders, key, publicationId)
18716
+ });
18195
18717
  }
18196
18718
  /** The publications the graph defines, the issues in their pages, and the public documents none takes. */
18197
18719
  async function listPublications(graph, host) {
@@ -18199,9 +18721,9 @@ async function listPublications(graph, host) {
18199
18721
  await graph.store.refresh();
18200
18722
  const { source, unsettled } = await graph.publishing.readSource();
18201
18723
  const summary = summarisePublishing(source);
18202
- const folderOf = await foldersFor(graph, env);
18724
+ const destinationOf = await destinationsFor(graph, env);
18203
18725
  return {
18204
- publications: summary.publications.map((p) => publicationView(p, folderOf(p.id))),
18726
+ publications: summary.publications.map((p) => publicationView(p, destinationOf(p.id))),
18205
18727
  issues: summary.issues,
18206
18728
  publicInNoPublication: summary.publicInNoPublication,
18207
18729
  ...unsettled.length > 0 ? { unsettled } : {}
@@ -18247,10 +18769,10 @@ async function createPublication(graph, args, host) {
18247
18769
  await settle$1(graph);
18248
18770
  }
18249
18771
  const after = summarisePublishing((await graph.publishing.readSource()).source).publications.find((p) => p.id === id);
18250
- const folderOf = await foldersFor(graph, env);
18772
+ const destinationOf = await destinationsFor(graph, env);
18251
18773
  return {
18252
18774
  created: true,
18253
- publication: after ? publicationView(after, folderOf(id)) : {
18775
+ publication: after ? publicationView(after, destinationOf(id)) : {
18254
18776
  id,
18255
18777
  page: concept
18256
18778
  }
@@ -18272,8 +18794,14 @@ async function updatePublication(graph, args, host) {
18272
18794
  await updatePublicationPage(frontmatterStore(graph), current.concept, current, changes);
18273
18795
  await settle$1(graph);
18274
18796
  const after = summarisePublishing((await graph.publishing.readSource()).source).publications.find((p) => p.id === current.id);
18275
- const folderOf = await foldersFor(graph, env);
18276
- return { publication: publicationView(after ?? current, folderOf(current.id)) };
18797
+ const destinationOf = await destinationsFor(graph, env);
18798
+ return { publication: publicationView(after ?? current, destinationOf(current.id)) };
18799
+ }
18800
+ /** A GitHub refusal or a refused branch, as this host words it: the token is an environment variable here. */
18801
+ function repositoryRefusal(error, repo) {
18802
+ 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.`);
18803
+ if (error instanceof Error && /knowledge graph/.test(error.message)) return new ToolError("repository_holds_graph", `${error.message} Nothing was published.`);
18804
+ return error;
18277
18805
  }
18278
18806
  /** How many items fall under each key. */
18279
18807
  function countBy(items, keyOf) {
@@ -18307,12 +18835,13 @@ async function publish(graph, args, host) {
18307
18835
  const summary = summarisePublishing(source);
18308
18836
  const publication = summary.publications.find((p) => p.id === args.id.trim());
18309
18837
  if (!publication) throw publicationNotFound(args.id, summary.publications, via);
18310
- const folder = (await foldersFor(graph, env))(publication.id);
18838
+ const { folder, repository } = (await destinationsFor(graph, env))(publication.id);
18311
18839
  const dryRun = args.dryRun === true;
18312
- if (!folder && !dryRun) {
18313
- const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
18314
- throw new ToolError("no_publish_folder", `No publish folder is set for "${publication.name}" on this machine. Ask the user to run: ${cmd} publish ${where} --publication ${publication.id} --out <dir> - that publishes once and remembers the folder for this tool.`);
18315
- }
18840
+ const where = graph.backend.kind === "folder" ? `--folder "${graph.backend.path}"` : `--graph "${graph.name}"`;
18841
+ 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}`);
18842
+ const token = env.ETHERPK_GITHUB_TOKEN?.trim();
18843
+ 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.`);
18844
+ 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
18845
  if (folder && !dryRun) await checkPublishFolder(graph, publication.id, folder, host);
18317
18846
  const selected = selectDocuments(source.documents, publication, summary.publications);
18318
18847
  const includeTexts = Object.values(publication.includes).map((concept) => source.documents.find((d) => d.concept.toLowerCase() === concept.toLowerCase())?.text ?? "");
@@ -18342,12 +18871,33 @@ async function publish(graph, args, host) {
18342
18871
  renderer,
18343
18872
  maps
18344
18873
  }),
18345
- unsettled
18874
+ unsettled,
18875
+ destination: repository ? "repository" : "folder"
18346
18876
  });
18877
+ let committed = null;
18878
+ if (run.report.ok && repository && !dryRun) {
18879
+ try {
18880
+ committed = await publishToRepository(createGitHubApi({
18881
+ token,
18882
+ baseUrl: env.ETHERPK_GITHUB_API_URL?.trim() || void 0
18883
+ }), repository, {
18884
+ publicationId: publication.id,
18885
+ bundle: run.bundle,
18886
+ seeded: run.seeded,
18887
+ agentsMd: run.agentsMd
18888
+ }, { confirmed: args.replace === true });
18889
+ } catch (error) {
18890
+ throw repositoryRefusal(error, repository);
18891
+ }
18892
+ if (committed.state === "needs-confirmation") {
18893
+ const listed = committed.replaced.slice(0, 10).join(", ") + (committed.replaced.length > 10 ? ` and ${committed.replaced.length - 10} more` : "");
18894
+ 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.`);
18895
+ }
18896
+ }
18347
18897
  const written = run.report.ok && folder && !dryRun ? await writeSite(nodeSiteFolder(folder), run.bundle, {
18348
18898
  seeded: run.seeded,
18349
18899
  agentsMd: run.agentsMd
18350
- }) : null;
18900
+ }) : committed?.written ?? null;
18351
18901
  const excludedByReason = {};
18352
18902
  for (const e of run.report.excluded) excludedByReason[e.reason] = (excludedByReason[e.reason] ?? 0) + 1;
18353
18903
  return {
@@ -18355,6 +18905,10 @@ async function publish(graph, args, host) {
18355
18905
  ...dryRun ? { dryRun: true } : {},
18356
18906
  publication: run.report.publication,
18357
18907
  folder,
18908
+ ...repository ? {
18909
+ repository: repositoryLabel(repository),
18910
+ commit: committed?.state === "committed" ? committed.commit.url : null
18911
+ } : {},
18358
18912
  written: written ? {
18359
18913
  written: written.written,
18360
18914
  unchanged: written.unchanged,
@@ -18413,15 +18967,19 @@ function cliPublishOutput(result) {
18413
18967
  async function publishingInfo(graph, host) {
18414
18968
  const { env, cmd } = hostOf$1(host);
18415
18969
  const { source } = await graph.publishing.readSource();
18416
- const folderOf = await foldersFor(graph, env);
18970
+ const destinationOf = await destinationsFor(graph, env);
18417
18971
  const chromium = await chromiumStatus(env, cmd);
18418
18972
  return {
18419
- publications: summarisePublishing(source).publications.map((p) => ({
18420
- id: p.id,
18421
- name: p.name,
18422
- kind: p.kind,
18423
- publishFolder: folderOf(p.id)
18424
- })),
18973
+ publications: summarisePublishing(source).publications.map((p) => {
18974
+ const { folder, repository } = destinationOf(p.id);
18975
+ return {
18976
+ id: p.id,
18977
+ name: p.name,
18978
+ kind: p.kind,
18979
+ publishFolder: folder,
18980
+ publishRepository: repository ? repositoryLabel(repository) : null
18981
+ };
18982
+ }),
18425
18983
  mermaid: {
18426
18984
  available: chromium.executable !== null,
18427
18985
  ...chromium.executable ? {} : { setup: chromium.setupCommand }
@@ -19784,6 +20342,7 @@ async function runGraphHost(deps) {
19784
20342
  return startSession(socket, request, reply);
19785
20343
  case "publish":
19786
20344
  case "publish-check":
20345
+ case "publish-github":
19787
20346
  if (stopping) return answer({ etherpk: "stopping" });
19788
20347
  return runPublish(request, answer);
19789
20348
  default: return answer({
@@ -25818,7 +26377,7 @@ function bindServeLifetime(deps) {
25818
26377
  * etherpk-mcp stop [--graph <id or name> [--sync-server <url>] | --folder <path> | --all]
25819
26378
  * etherpk-mcp logout [--sync-server <url> | --all]
25820
26379
  * etherpk-mcp semantic setup | status | remove
25821
- * etherpk-mcp publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --dry-run]
26380
+ * etherpk-mcp publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --github <owner/name[#branch]> | --dry-run] [--replace]
25822
26381
  * etherpk-mcp diagrams setup | status
25823
26382
  *
25824
26383
  * One config file holds a login per Sync Server (ADR 0075). `--sync-server` names the one a
@@ -25889,8 +26448,9 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
25889
26448
  to a service to be embedded.
25890
26449
  ${CMD} semantic status | remove
25891
26450
  Whether it is set up here, or delete it (logout --all deletes it too).
25892
- ${CMD} publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --dry-run]
25893
- Publish one publication of a graph to a folder on this machine and print the report.
26451
+ ${CMD} publish (--graph <id or name> | --folder <path>) --publication <id> [--out <dir> | --github <owner/name[#branch]> | --dry-run] [--replace]
26452
+ Publish one publication of a graph to a folder on this machine, or to a branch of a
26453
+ GitHub repository, and print the report.
25894
26454
  --out sets the publish folder for that graph and publication and is remembered: from
25895
26455
  then on this command without --out, and the agent's publish tool, write to the same
25896
26456
  folder (the agent cannot choose one). It must be a folder of its own, outside the
@@ -25898,6 +26458,13 @@ var USAGE = `etherpk-mcp - EtherPK Headless Client (an MCP server over one synce
25898
26458
  and feed files) are rewritten and their strays removed - everything else in it is left
25899
26459
  alone. Publishing writes files - deploying them (a git push, say) is yours. Run it from
25900
26460
  cron for a site that republishes itself.
26461
+ --github commits the site to the repository's branch (main unless #branch says) as one
26462
+ commit, with the token in ETHERPK_GITHUB_TOKEN, and is remembered in place of a folder.
26463
+ The host watching the branch then deploys it. The same files are owned and the rest of
26464
+ the branch is left alone. A publish never creates the branch. The agent's publish tool
26465
+ never commits to a repository: this command is how a publication there is published.
26466
+ --replace lets a first publish replace files on a branch that does not hold this site
26467
+ yet, which it otherwise lists and refuses.
25901
26468
  --dry-run renders and checks the publication and prints the whole report, with the
25902
26469
  names of the pages it mentions, but writes nothing and needs no folder. Keep its output
25903
26470
  out of public logs.
@@ -26308,19 +26875,32 @@ async function publishCommand(args) {
26308
26875
  if (!publication) fail("publish needs --publication <id>. The agent's list_publications tool, or Settings → Publish in EtherPK, shows the ids.");
26309
26876
  const dryRun = args["dry-run"] === true;
26310
26877
  const out = args.out?.trim();
26311
- if (dryRun && out) fail("publish --dry-run writes nothing, so it takes no --out. Leave --out off, or drop --dry-run to publish.");
26878
+ const githubArg = args.github?.trim();
26879
+ 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.");
26880
+ if (out && githubArg) fail("publish takes --out or --github, not both: a publication publishes to one place on this machine.");
26881
+ const github = githubArg ? parseRepositoryArgument(githubArg) : null;
26882
+ if (githubArg && !github) fail(`--github takes owner/name, or owner/name#branch, such as you/garden-site#main ("${githubArg}" is not one).`);
26312
26883
  const found = await findGraph(request).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
26313
26884
  const env = publishSettings(process.env);
26885
+ const replace = args.replace === true ? { replace: true } : {};
26314
26886
  const { reply, socket } = await requestHost(launcherFor(found), dryRun ? {
26315
26887
  etherpk: "publish-check",
26316
26888
  protocol: 1,
26317
26889
  publication,
26318
26890
  env
26891
+ } : github ? {
26892
+ etherpk: "publish-github",
26893
+ protocol: 1,
26894
+ publication,
26895
+ github,
26896
+ ...replace,
26897
+ env
26319
26898
  } : {
26320
26899
  etherpk: "publish",
26321
26900
  protocol: 1,
26322
26901
  publication,
26323
26902
  ...out ? { out: resolve(out) } : {},
26903
+ ...replace,
26324
26904
  env
26325
26905
  }).catch((error) => fail(`etherpk-mcp: ${describeError(error)}`));
26326
26906
  socket.destroy();
@@ -26332,7 +26912,7 @@ async function publishCommand(args) {
26332
26912
  return;
26333
26913
  case "refused": return fail(reply.message);
26334
26914
  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.`);
26915
+ 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
26916
  return fail(`etherpk-mcp: ${reply.message}`);
26337
26917
  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
26918
  default: return fail(`etherpk-mcp: the graph's background process gave an answer this version does not know ("${reply.etherpk}").`);
@@ -26340,9 +26920,10 @@ async function publishCommand(args) {
26340
26920
  }
26341
26921
  /**
26342
26922
  * A command-line publish, as the graph's host runs it: the publication first, so a mistyped id is
26343
- * never remembered as a publish folder, then `--out` checked against the graph's folder and
26344
- * remembered, then the publish the agent's tool runs. The answer is what the command prints, in
26345
- * its own words.
26923
+ * never remembered as a destination, then `--out` checked against the graph's folder, or
26924
+ * `--github` checked as a repository, and remembered, then the publish the agent's tool runs, as
26925
+ * the person (`via: 'cli'`), which may commit to a repository. The answer is what the command
26926
+ * prints, in its own words.
26346
26927
  */
26347
26928
  async function publishInHost(graph, request) {
26348
26929
  const env = withPublishSettings(process.env, request.env ?? {});
@@ -26368,15 +26949,29 @@ async function publishInHost(graph, request) {
26368
26949
  await findPublication(graph, request.publication, host);
26369
26950
  const configPath = defaultPublishFoldersPath(env);
26370
26951
  const key = publishGraphKey(graph.backend, graph.graphId);
26371
- if (request.out) {
26952
+ if (request.etherpk === "publish-github") {
26953
+ const repo = parseRepositoryArgument(`${request.github.owner}/${request.github.name}#${request.github.branch}`);
26954
+ if (!repo) return {
26955
+ etherpk: "refused",
26956
+ message: "etherpk-mcp: --github takes owner/name, or owner/name#branch."
26957
+ };
26958
+ await writePublishFolders(configPath, withPublishRepository(await readPublishFolders(configPath), key, request.publication, repo));
26959
+ notes.push(`etherpk-mcp: "${request.publication}" of "${graph.name}" now publishes to ${repositoryLabel(repo)} (remembered in ${configPath}, in place of any folder).`);
26960
+ } else if (request.out) {
26372
26961
  await checkPublishFolder(graph, request.publication, request.out, host);
26373
26962
  await writePublishFolders(configPath, withPublishFolder(await readPublishFolders(configPath), key, request.publication, request.out));
26374
26963
  notes.push(`etherpk-mcp: publish folder for "${request.publication}" of "${graph.name}" set to ${request.out} (remembered in ${configPath}).`);
26375
- } else if (!publishFolderOf(await readPublishFolders(configPath), key, request.publication)) return {
26376
- etherpk: "refused",
26377
- message: `No publish folder is set for "${request.publication}" of "${graph.name}" on this machine. Pass --out <dir> once - it is remembered.`
26378
- };
26379
- const result = await publish(graph, { id: request.publication }, host);
26964
+ } else {
26965
+ const remembered = await readPublishFolders(configPath);
26966
+ if (!publishFolderOf(remembered, key, request.publication) && !publishRepositoryOf(remembered, key, request.publication)) return {
26967
+ etherpk: "refused",
26968
+ 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.`
26969
+ };
26970
+ }
26971
+ const result = await publish(graph, {
26972
+ id: request.publication,
26973
+ replace: request.replace === true
26974
+ }, host);
26380
26975
  return {
26381
26976
  etherpk: "published",
26382
26977
  output: cliPublishOutput(result),
@@ -26892,6 +27487,8 @@ async function main() {
26892
27487
  "no-semantic": { type: "boolean" },
26893
27488
  publication: { type: "string" },
26894
27489
  out: { type: "string" },
27490
+ github: { type: "string" },
27491
+ replace: { type: "boolean" },
26895
27492
  "dry-run": { type: "boolean" },
26896
27493
  all: { type: "boolean" },
26897
27494
  name: { type: "string" },