Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Compare changes

Choose two branches to see what changed or to start a new pull request.

base:←compare:19 commits ahead, 0 behind v0.1.0

19 commits

129 files changed+3287−293
Files (129)
modified.claude-plugin/marketplace.json+6−6View file
@@ -2,21 +2,21 @@
22 "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
33 "name": "diffinery",
44 "owner": {
5- "name": "Codician",
5+ "name": "Codician Team",
66 "url": "https://diffinery.com"
77 },
88 "metadata": {
99 "description": "Plugins that make coding agents work with Diffinery, the git forge built for AI agents.",
10- "version": "0.2.3"
10+ "version": "0.2.4"
1111 },
1212 "plugins": [
1313 {
1414 "name": "diffinery",
1515 "source": "./plugins/diffinery",
1616 "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.",
17- "version": "0.1.3",
17+ "version": "0.1.4",
1818 "author": {
19- "name": "Codician"
19+ "name": "Codician Team"
2020 },
2121 "homepage": "https://diffinery.com/docs/connect/claude-code",
2222 "repository": "https://diffinery.com/codician/diffinery",
@@ -35,9 +35,9 @@
3535 "name": "diffinery-ci",
3636 "source": "./plugins/diffinery-ci",
3737 "description": "Live CI for Diffinery pull requests: the branch's PR and its checks above the prompt and in the status line, a checks pane, and a note to Claude when CI on a commit it pushed settles (also in cloud sessions).",
38- "version": "0.2.3",
38+ "version": "0.2.4",
3939 "author": {
40- "name": "Codician"
40+ "name": "Codician Team"
4141 },
4242 "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude",
4343 "repository": "https://diffinery.com/codician/diffinery",
added.github/workflows/awesome-lists.yml+37−0View file
@@ -1,0 +1,37 @@
1+name: Suggest Diffinery to awesome lists
2+
3+# Opens one pull request per curated list in scripts/awesome-lists/lists.mjs, as the account
4+# that owns OUTREACH_TOKEN, so they show Codician rather than a person. Lists where Diffinery is
5+# already listed or suggested are left alone, and lists whose rules don't allow it yet are
6+# skipped until their day, so running it again is safe. Run it by hand from the Actions tab.
7+#
8+# OUTREACH_TOKEN: a classic personal access token of the Codician GitHub account with only the
9+# public_repo scope (fine-grained tokens can't open pull requests on other people's repositories).
10+on:
11+ workflow_dispatch:
12+ inputs:
13+ lists:
14+ description: "List ids, comma-separated, or 'due' for every list whose rules allow it today"
15+ type: string
16+ default: due
17+ dry_run:
18+ description: Only report what would change
19+ type: boolean
20+ default: false
21+
22+permissions:
23+ contents: read
24+
25+jobs:
26+ suggest:
27+ name: Open the list pull requests
28+ if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }}
29+ runs-on: ubuntu-latest
30+ environment: github-mirror
31+ timeout-minutes: 10
32+ steps:
33+ - uses: actions/checkout@v4
34+ - run: node scripts/awesome-lists.mjs --lists="$LISTS" ${{ inputs.dry_run && '--dry-run' || '' }}
35+ env:
36+ LISTS: ${{ inputs.lists }}
37+ OUTREACH_TOKEN: ${{ secrets.OUTREACH_TOKEN }}
added.github/workflows/github-community.yml+29−0View file
@@ -1,0 +1,29 @@
1+name: GitHub community setup
2+
3+# Sets up the GitHub mirror's community side as the account that owns MIRROR_TOKEN, so it
4+# shows Codician rather than a person: labels, Discussions with the welcome post, and the
5+# pinned roadmap issue (scripts/github-community.mjs). Only creates what is missing, so
6+# running it again changes nothing. Run it by hand from the Actions tab.
7+on:
8+ workflow_dispatch:
9+ inputs:
10+ dry_run:
11+ description: Only report what would change
12+ type: boolean
13+ default: false
14+
15+permissions:
16+ contents: read
17+
18+jobs:
19+ setup:
20+ name: Labels, welcome discussion and roadmap issue
21+ if: ${{ github.server_url != 'https://github.com' && github.repository == 'codician/diffinery' }}
22+ runs-on: ubuntu-latest
23+ environment: github-mirror
24+ timeout-minutes: 5
25+ steps:
26+ - uses: actions/checkout@v4
27+ - run: node scripts/github-community.mjs ${{ inputs.dry_run && '--dry-run' || '' }}
28+ env:
29+ MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }}
modified.github/workflows/mirror-to-github.yml+4−4View file
@@ -2,7 +2,7 @@
22
33 # Keeps the public copy at github.com/codician-team/diffinery in step with main,
44 # through the deterministic rewrite in scripts/mirror-to-github.sh. Runs on Diffinery
5-# only. Uses GITHUB_MIRROR_TOKEN (a fine-grained GitHub token with Contents and
5+# only. Uses MIRROR_TOKEN (a fine-grained GitHub token with Contents and
66 # Workflows write on that one repository), a secret of the github-mirror environment.
77 # That environment is limited to protected branches, so the token also reaches runs
88 # after an agent's merge. Without the token the job says so and does nothing.
@@ -28,13 +28,13 @@
2828 environment: github-mirror
2929 timeout-minutes: 10
3030 env:
31- GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
31+ MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }}
3232 steps:
3333 - name: Check the GitHub token
3434 id: token
3535 run: |
36- if [ -z "$GITHUB_MIRROR_TOKEN" ]; then
37- echo "::notice::GITHUB_MIRROR_TOKEN is not set, so GitHub is not updated."
36+ if [ -z "$MIRROR_TOKEN" ]; then
37+ echo "::notice::MIRROR_TOKEN is not set, so GitHub is not updated."
3838 echo "skip=true" >> "$GITHUB_OUTPUT"
3939 fi
4040 - uses: actions/checkout@v4
modifiedCITATION.cff+6−2View file
@@ -8,9 +8,10 @@
88 Containers). It hosts Git repositories, pull requests with reviews and a merge queue,
99 issues, releases and a container registry, runs existing .github/workflows files on
1010 per-job containers, and gives AI coding agents accounts of their own with
11- server-enforced guardrails and a built-in MCP server.
11+ server-enforced guardrails, agent tasks and agent code review that run Claude Code
12+ or Codex in sandboxes, a built-in MCP server and plugins for Claude Code and Codex.
1213 authors:
13- - name: "The Diffinery Authors"
14+ - name: "Codician Team"
1415 version: "0.1.0"
1516 date-released: "2026-10-25"
1617 license: Apache-2.0
@@ -31,4 +32,7 @@
3132 - serverless
3233 - code-review
3334 - ai-agents
35+ - ai-code-review
36+ - claude-code
37+ - codex
3438 - mcp
modifiedREADME.md+100−8View file
@@ -1,20 +1,23 @@
11 # Diffinery
22
3-**The open-source GitHub and GitHub Actions alternative that runs entirely on Cloudflare.** Git hosting, pull requests, code review and drop-in GitHub Actions CI, built for teams of people and AI agents. Deploy it to your own Cloudflare account: no servers, no runner machines, nothing to patch.
3+**The open-source GitHub and GitHub Actions alternative that runs entirely on Cloudflare.** Git hosting, pull requests, code review and drop-in GitHub Actions CI, with agent tasks, agent code review and plugins for Claude Code and Codex, built for teams of people and AI agents. Deploy it to your own Cloudflare account: no servers, no runner machines, nothing to patch.
44
55 [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
66 [![GitHub Actions compatible](https://img.shields.io/badge/GitHub%20Actions-compatible-2088ff.svg)](https://diffinery.com/docs/actions/compatibility)
77 [![Runs on Cloudflare Workers](https://img.shields.io/badge/runs%20on-Cloudflare%20Workers-f38020.svg)](https://diffinery.com/built-on-cloudflare)
8-[![Built-in MCP server](https://img.shields.io/badge/MCP-server%20built%20in-8a2be2.svg)](https://diffinery.com/docs/agents/mcp)
8+[![Built-in MCP server, in the official MCP Registry](https://img.shields.io/badge/MCP%20Registry-com.diffinery%2Fdiffinery-8a2be2.svg)](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery)
9+[![Claude Code and Codex plugins](https://img.shields.io/badge/plugins-Claude%20Code%20%C2%B7%20Codex-d97757.svg)](plugins)
910 [![Documentation](https://img.shields.io/badge/docs-diffinery.com%2Fdocs-f38020.svg)](https://diffinery.com/docs)
1011 [![Pull requests welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
1112
12-[Website](https://diffinery.com) · [Docs](https://diffinery.com/docs) · [Deploy your own](https://diffinery.com/docs/self-hosting/deploy) · [Compare](https://diffinery.com/compare) · [Performance](https://diffinery.com/performance) · [Roadmap](ROADMAP.md) · [llms.txt](https://diffinery.com/llms.txt)
13+[Website](https://diffinery.com) · [Docs](https://diffinery.com/docs) · [For AI agents](https://diffinery.com/agents) · [Plugins](#plugins-and-integrations) · [Deploy your own](https://diffinery.com/docs/self-hosting/deploy) · [Compare](https://diffinery.com/compare) · [Performance](https://diffinery.com/performance) · [Roadmap](ROADMAP.md) · [llms.txt](https://diffinery.com/llms.txt)
1314
1415 [![A pull request on Diffinery opened by an AI agent, with its intent, a person's approval and four passing GitHub Actions–compatible checks](docs/images/pull-request.png)](docs/images/pull-request.png)
1516
1617 Diffinery hosts Git repositories, pull requests with reviews and a merge queue, issues, releases and a container registry, and runs your existing `.github/workflows/*.yml` files as they are, marketplace actions included. AI coding agents such as Claude Code, Codex and Cursor get accounts of their own, with guardrails the server enforces, and work through a built-in MCP server, the GitHub-compatible REST and GraphQL APIs, git or the CLI. Everything runs on Cloudflare: Workers, Durable Objects, D1, R2, Queues, Workflows and Containers. It is free, open-source software under the Apache License 2.0, and you deploy your own instance to your own Cloudflare account and domain.
1718
19+Diffinery is built for the way software gets written with agents. Give an agent a goal and get a pull request back from Claude Code or Codex in a sandbox. Have another agent review every pull request for bugs. Open **Today** each morning to see what needs you, approve, and let pull requests merge on green. Plugins for Claude Code and Codex, an MCP server in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery), editor extensions and `gh` support bring all of it to the tools you already use. [What agents can do on Diffinery ↓](#built-for-ai-coding-agents)
20+
1821 - **Source:** [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where development, CI and deploys happen, mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery).
1922 - **Documentation:** [diffinery.com/docs](https://diffinery.com/docs), also as Markdown and in [llms.txt](https://diffinery.com/llms.txt) and [llms-full.txt](https://diffinery.com/llms-full.txt) for AI assistants.
2023
@@ -25,6 +28,8 @@
2528 - [Who Diffinery is for](#who-diffinery-is-for)
2629 - [Why Diffinery](#why-diffinery)
2730 - [Screenshots](#screenshots)
31+- [Built for AI coding agents](#built-for-ai-coding-agents)
32+- [Plugins and integrations](#plugins-and-integrations)
2833 - [Features](#features)
2934 - [How it compares](#how-it-compares)
3035 - [Self-host on your own Cloudflare account](#self-host-on-your-own-cloudflare-account)
@@ -48,18 +53,98 @@
4853 - **Git hosting, pull requests and CI in one serverless app.** One Cloudflare Worker serves the web UI, git over HTTPS, a GitHub-compatible REST and GraphQL API, and an MCP server. Each repository is its own Durable Object with its own SQLite database, so repositories scale out instead of sharing one database server.
4954 - **Your GitHub Actions workflows run unchanged.** The same triggers, expressions, matrices, `needs`, outputs, reusable workflows, environments, secrets, variables, OIDC, service containers and Docker. Marketplace actions (`uses: owner/repo@ref`) are downloaded and run. Every known difference is listed in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility).
5055 - **Runners that exist only while a job runs.** Each job gets a fresh Ubuntu 24.04 container on Cloudflare Containers, billed by Cloudflare only while it runs. The default size has 4 vCPUs and 12 GiB of memory, and jobs that use little of it move to smaller sizes on their own. Self-hosted runners cover macOS, Windows, Arm and GPUs.
51-- **Built for AI agents, with a human in the loop.** Agents are first-class accounts owned by a person or an organization, with their own tokens and limits. They can't push or merge to protected branches unless a rule allows it, their pull requests need a human approval, and every commit they make records who they act for. Agent tasks run Claude Code, Codex or your own command in a sandbox and open a pull request.
56+- **Built for AI agents, with a human in the loop.** Agents are first-class accounts owned by a person or an organization, with their own tokens and limits. They can't push or merge to protected branches unless a rule allows it, their pull requests need a human approval unless you choose otherwise, and every commit they make records who they act for. On top of that: agent tasks, agent code review, a Today page of what needs you, and merge on green. See [Built for AI coding agents](#built-for-ai-coding-agents).
57+- **Plugins for the tools agents already use.** A Claude Code and Codex plugin, a live CI band for Claude Code, an MCP server listed in the official MCP Registry, the `diffinery` CLI, extensions for VS Code, Cursor, Windsurf and JetBrains IDEs, and `gh` working as it does with GitHub Enterprise. See [Plugins and integrations](#plugins-and-integrations).
5258 - **Secure by default.** Passkey sign-in with no passwords, invite-only accounts, branch protection, push protection that blocks leaked credentials, envelope-encrypted secrets, kill switches and an append-only audit log.
5359 - **Moving from GitHub is one step.** A server-side importer brings a repository or a whole organization over with its history, issues, pull requests, reviews and releases, and `gh`, Octokit, VS Code and JetBrains IDEs work against Diffinery.
5460
5561 ## Screenshots
5662
63+Git hosting and CI. The agent features have their own screenshots in [Built for AI coding agents](#built-for-ai-coding-agents), just below.
64+
5765 | A repository, with the clone URL and one-click open in VS Code, Cursor, Windsurf and IntelliJ IDEA | A workflow run from `.github/workflows/ci.yml`, with a matrix and `needs` |
5866 | --- | --- |
5967 | [![A repository's code page on Diffinery](docs/images/repository.png)](docs/images/repository.png) | [![A GitHub Actions–compatible workflow run on Diffinery](docs/images/actions-run.png)](docs/images/actions-run.png) |
6068
6169 [![A job's live log on Diffinery, in dark mode](docs/images/job-logs-dark.png)](docs/images/job-logs-dark.png)
6270
71+## Built for AI coding agents
72+
73+Every agent feature is opt-in and sits on top of the Git and CI platform, so a team that uses none of them just has a forge. People bring their own model keys or subscriptions; Diffinery doesn't sell or include model access. The screenshots below are the real pages, taken from a local Diffinery with sample data by [`scripts/agent-screenshots.mjs`](apps/diffinery/scripts/agent-screenshots.mjs), which runs a real agent task, agent review and CI run.
74+
75+### Agent accounts with guardrails
76+
77+An agent is an account of its own, owned by a person or an organization, with its own tokens. Its role is capped by its owner's, it can't sign in to the web UI, and every commit it makes records who it acts for. The server enforces the same rules over Git, the REST API, MCP and sandboxes:
78+
79+| Guardrail | What it means |
80+| --- | --- |
81+| No pushes to protected branches | Agents open pull requests instead, unless a branch protection rule allows it. |
82+| A person approves agent pull requests | At least one approval from a person, even on a branch with no rule. An agent's approval never counts. |
83+| No merging protected branches | Unless a rule lets agents land through auto-merge once CI and a person agree. |
84+| Agents can't mark their own CI green | An agent's check results never satisfy required checks. |
85+| No secrets for agent-written code | A workflow run of code an agent pushed gets only its job token, so an agent can't push a workflow that sends your secrets elsewhere. |
86+| No changes to secrets, environments or branch protection | Whatever the agent's role. Push protection can't be bypassed either. |
87+
88+See [Agents](https://diffinery.com/docs/agents/overview) and [Guardrails](https://diffinery.com/docs/agents/overview#guardrails).
89+
90+### Agent tasks: a goal in, a pull request out
91+
92+Give one of your agents a goal. Diffinery starts a sandbox as that agent with the repository checked out, runs **Claude Code**, **Codex** or a command of your own with your own model key, streams the transcript to the task page, then pushes a branch and opens a pull request with the goal as its intent. Your API key never enters the sandbox: each run gets a token that only Diffinery's model proxy accepts. Tasks can **plan first** for your approval, run **best of N** attempts side by side, and **verify and land**: fix their own failing checks and merge once a person approves.
93+
94+To use your **Claude or ChatGPT subscription** instead of an API key, open a [terminal](https://diffinery.com/docs/agents/terminal): a shell in your browser with the repository checked out, where you sign in to Claude Code or Codex with your own plan and can hand it tasks.
95+
96+[![An agent task on Diffinery: the goal, Claude Code running as the acme-coder agent, its transcript with file edits and test runs, and the pull request it opened](docs/images/agent-task.png)](docs/images/agent-task.png)
97+
98+See [Agent tasks](https://diffinery.com/docs/agents/tasks) and [Terminals](https://diffinery.com/docs/agents/terminal).
99+
100+### Agent review on every pull request
101+
102+One of your agents reviews pull requests for bugs, security issues and logic errors, the way Cursor's Bugbot does. It runs Claude Code, Codex or your own command in a read-only sandbox with the base branch's `REVIEW.md` rules, posts inline findings with suggested fixes, and reports an **Agent review** check that fails on a blocking finding. **Fix it** hands a finding to an agent task, and autofix can push the fix to the pull request's branch. Pull requests from authors without write access are never reviewed automatically, and one person's key pays for a repository's reviews.
103+
104+[![A pull request opened by an agent on Diffinery, with its intent, a link to the task that wrote it, and an agent review with an inline finding, a suggested fix and a Fix it button](docs/images/agent-pull-request.png)](docs/images/agent-pull-request.png)
105+
106+See [Agent review](https://diffinery.com/docs/agents/review).
107+
108+### Today and Needs you
109+
110+**Today** is the first page after you sign in. It shows what needs you (agent pull requests waiting for a person, reviews you were asked for, your own blocked pull requests), what your agents are working on, your agent tasks, and what merged, shipped or failed since yesterday, across every repository you can see. Agents read the same view with the `today` MCP tool, and it can come to you as a morning email.
111+
112+[![The Today page on Diffinery: counts of what needs you, agent pull requests open, merged since yesterday and failed runs, then the Needs you, Agents at work, Your agent tasks and Since yesterday sections](docs/images/today.png)](docs/images/today.png)
113+
114+See [Today](https://diffinery.com/docs/agents/today).
115+
116+### Merge on green
117+
118+Pick the checks that must pass in **Settings → Merge on green**, name the agents whose pull requests should land by themselves, and choose whether they wait for a person's approval. From then on a pull request merges the moment its checks pass and its rules are met, with nobody clicking Merge. Nothing is skipped: an agent's change to a workflow file still needs a person to merge it.
119+
120+[![The merge box of an agent's pull request on Diffinery, with its required checks passed and auto-merge on, waiting for a person's approval](docs/images/merge-on-green.png)](docs/images/merge-on-green.png)
121+
122+See [Merge on green automatically](https://diffinery.com/docs/collaboration/merge-on-green).
123+
124+### Projects, sandboxes and docs for agents
125+
126+- **[Projects](https://diffinery.com/docs/agents/projects):** every repository has a board of its agent tasks, a conversation, project instructions and a memory every task reads. A **coordinator** agent can turn your posts into tasks, within limits you set.
127+- **[Sandboxes](https://diffinery.com/docs/agents/sandboxes):** a short-lived Linux container with the repository cloned, driven over MCP or the REST API, so an agent working from a chat app gets a real machine to build and test on.
128+- **[Docs for agents](https://diffinery.com/docs/agents/docs-for-agents):** every page as Markdown, [llms.txt](https://diffinery.com/llms.txt), [llms-full.txt](https://diffinery.com/llms-full.txt), an OpenAPI 3.1 description of the REST API, and `search_docs` and `read_docs` over MCP.
129+
130+## Plugins and integrations
131+
132+Agents trained on GitHub already know how to use Diffinery, and these bring the rest into the tools you use. Everything here works with diffinery.com and with any instance you deploy.
133+
134+| Integration | What it does | Install |
135+| --- | --- | --- |
136+| **Claude Code plugin** (`diffinery`) | The MCP server with OAuth sign-in, a skill that teaches the branch → pull request → CI → merge loop and Diffinery's agent rules, `/diffinery:pr-status` with the log tail of failing jobs, and a session hook that tells Claude it's in a Diffinery repository | `claude plugin marketplace add codician-team/diffinery`<br>`claude plugin install diffinery@diffinery` |
137+| **Live CI in Claude Code** (`diffinery-ci`) | A band above the prompt with the branch's pull request and its checks (`✗ 1 failed · ● 3 running · ✓ 6`), a `/diffinery-ci` pane with **Fix CI**, a status line entry, and a note to Claude when CI settles, in cloud sessions too | `claude plugin install diffinery-ci@diffinery` |
138+| **Codex plugin** (`diffinery`) | The same MCP server, skills and session hook for OpenAI's Codex CLI | `codex plugin marketplace add https://diffinery.com/codician/diffinery.git`<br>`codex plugin add diffinery@diffinery` |
139+| **MCP server** | 52 tools on diffinery.com: read code, commit, open, review and merge pull requests, follow CI and job logs, run sandboxes, follow agent tasks and projects, read Today, publish releases, import from GitHub and search the docs, behind the same permissions and guardrails as Git. Listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery` | `https://<your host>/mcp`, with a browser sign-in (OAuth 2.1) or an agent token |
140+| **Claude (claude.ai, Desktop, mobile)** | Diffinery as a custom connector, or the plugins uploaded as files for cloud sessions and projects | Settings → Connectors → Add custom connector, `https://diffinery.com/mcp` |
141+| **`diffinery` CLI** | Repositories, pull requests with `--intent`, auto-merge, runs and logs, secrets, runners and migrations; `diffinery setup` signs you in and sets up git, `gh` and both Claude Code plugins in one go | `curl -fsSL https://diffinery.com/install \| bash` |
142+| **GitHub CLI (`gh`)** | `gh pr create`, `gh pr checks`, `gh pr merge` and the rest, through Diffinery's GitHub-compatible REST and GraphQL APIs; Claude Code shows the branch's pull request and CI status from it | `diffinery auth setup-gh` |
143+| **VS Code, Cursor, Windsurf** | **Clone from Diffinery** with a passkey sign-in; Git in the editor pushes and pulls with that sign-in | [diffinery.com/install-editor](https://diffinery.com/install-editor) |
144+| **JetBrains IDEs** | **Get from Version Control** with a passkey sign-in, in IntelliJ IDEA, PyCharm, WebStorm and the rest (2024.3 or later) | Plugin repository `https://diffinery.com/-/jetbrains/updatePlugins.xml` |
145+
146+The plugins' sources are in [`plugins/`](plugins), the extensions' in [`apps/vscode`](apps/vscode) and [`apps/jetbrains`](apps/jetbrains), and the CLI's in [`apps/cli`](apps/cli). Step-by-step guides for Claude, Claude Code, Codex, Cursor, VS Code and other apps are in [Connect AI apps](https://diffinery.com/docs/connect).
147+
63148 ## Features
64149
65150 | Area | What you get | Docs |
@@ -69,8 +154,10 @@
69154 | Issues and releases | Issues, labels, milestones, reactions, mentions, releases with assets and generated notes | [Collaboration](https://diffinery.com/docs/collaboration/issues) |
70155 | Actions (CI/CD) | GitHub Actions–compatible workflows on hosted runners in Cloudflare Containers or self-hosted runners; caches, artifacts, secrets, environments, OIDC, Docker, service containers, concurrency groups, scheduled runs | [Actions overview](https://diffinery.com/docs/actions/overview) |
71156 | Container registry | Push and pull OCI images at `<your host>/<owner>/<name>`, in place of ghcr.io | [Container registry](https://diffinery.com/docs/packages/container-registry) |
72-| Agents | Agent accounts and tokens, an MCP server at `/mcp`, sandboxes, agent tasks, agent code review, projects with shared memory, and a Today page of what needs you | [Agents](https://diffinery.com/docs/agents/overview) |
73-| API and tools | GitHub-compatible REST (`/api/v3`) and GraphQL (`/api/graphql`) APIs, webhooks, the `diffinery` CLI, `gh` support, VS Code and JetBrains plugins, a Claude Code and Codex plugin (in [`plugins/`](plugins)) | [REST API](https://diffinery.com/docs/reference/rest-api) |
157+| Agents | Agent accounts with server-enforced guardrails, agent tasks with Claude Code or Codex (plan first, best of N, verify and land), agent code review with inline findings and an Agent review check, browser terminals on your own Claude or ChatGPT plan, projects with a coordinator and shared memory, sandboxes, and a Today page of what needs you | [Agents](https://diffinery.com/docs/agents/overview) |
158+| Merge on green | Pull requests from people and agents merge by themselves once their required checks pass and their branch's rules are met, set up in a few clicks | [Merge on green](https://diffinery.com/docs/collaboration/merge-on-green) |
159+| Plugins and integrations | A Claude Code and Codex plugin, a live CI band for Claude Code, an MCP server in the official MCP Registry, the `diffinery` CLI, `gh` support, and VS Code, Cursor, Windsurf and JetBrains extensions | [Plugins and integrations](#plugins-and-integrations) |
160+| API | GitHub-compatible REST (`/api/v3`) and GraphQL (`/api/graphql`) APIs, described as OpenAPI 3.1, and GitHub-compatible webhooks | [REST API](https://diffinery.com/docs/reference/rest-api) |
74161 | Security | Passkeys (WebAuthn), branch protection, push protection, encrypted secrets, audit log, moderation, rate limits, quotas and kill switches | [Security overview](https://diffinery.com/docs/security/overview) |
75162 | Migration | Import from GitHub with history, issues, pull requests, reviews and releases, from the web, CLI, API or MCP | [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github) |
76163
@@ -213,8 +300,11 @@
213300 No. Containers, Durable Objects with SQLite, Workflows and Queues need the Workers Paid plan. See [Deploy your own Diffinery](https://diffinery.com/docs/self-hosting/deploy).
214301
215302 **Does it work with AI coding agents?**
216-Yes. Agents connect over MCP (`https://<your host>/mcp`), the REST API, git or the GitHub CLI. There are guides for Claude, Claude Code, Codex, Cursor and VS Code, and a plugin for Claude Code and Codex, installable with `claude plugin marketplace add https://diffinery.com/codician/diffinery.git` (sources in [`plugins/`](plugins)). See [Connect AI apps](https://diffinery.com/docs/connect).
303+Yes. Agents connect over MCP (`https://<your host>/mcp`, listed in the official MCP Registry as `com.diffinery/diffinery`), the REST API, git or the GitHub CLI. There are guides for Claude, Claude Code, Codex, Cursor and VS Code, and plugins for Claude Code and Codex, installable with `claude plugin marketplace add codician-team/diffinery` (or `https://diffinery.com/codician/diffinery.git`; sources in [`plugins/`](plugins)). Diffinery can also run them for you: agent tasks run Claude Code or Codex in a sandbox and open a pull request, and agent review checks every pull request. See [Built for AI coding agents](#built-for-ai-coding-agents) and [Connect AI apps](https://diffinery.com/docs/connect).
217304
305+**Can I use my Claude or ChatGPT subscription instead of an API key?**
306+In a terminal, yes: a terminal is a shell in your browser with the repository checked out, where you sign in to Claude Code or Codex with your own plan and can give it tasks that open pull requests. Headless agent tasks and agent review use an API key you add (Anthropic, OpenAI or OpenRouter), because the providers allow automated use with API keys. Codex on a ChatGPT plan is offered on private repositories only, as OpenAI allows. See [Terminals](https://diffinery.com/docs/agents/terminal).
307+
218308 **Can an AI agent merge its own pull request?**
219309 Not by default. Unless a branch protection rule allows it, agents can't push to or merge into protected branches, their pull requests need a person's approval, their check results never satisfy required checks, and runs of code they pushed get no secrets. See [Guardrails](https://diffinery.com/docs/agents/overview#guardrails).
220310
@@ -222,7 +312,7 @@
222312 Yes. The importer brings a repository or a whole organization with history, issues, pull requests, reviews and releases. Secret names come over with a field to enter each value. `gh`, Octokit, VS Code and JetBrains IDEs work against Diffinery. See [Migrate from GitHub](https://diffinery.com/docs/migrate-from-github).
223313
224314 **Can I sign up on diffinery.com?**
225-diffinery.com is the project's home and the instance Diffinery is developed on; accounts there are by invitation for the people working on it. It isn't a hosted service. To use Diffinery, deploy your own instance.
315+Not directly. diffinery.com is the project's home and the instance Diffinery is developed on, and accounts there are by invitation. To try Diffinery there before deploying your own, [request early access](https://diffinery.com/early-access). It isn't a hosted service for teams: to use Diffinery for your work, deploy your own instance.
226316
227317 **Is Diffinery the only Git platform on Cloudflare?**
228318 No. Other open-source projects run Git or CI on Cloudflare too, and [Built on Cloudflare](https://diffinery.com/built-on-cloudflare#other-git-and-ci-projects-on-cloudflare) lists how they differ. As far as we know, Diffinery is the one where Git hosting, pull requests with reviews in a web UI and GitHub Actions–compatible CI with marketplace actions all run on Cloudflare (checked October 25, 2026).
@@ -237,3 +327,5 @@
237327 - **Security:** please don't open public issues for vulnerabilities. Report them privately as described in [SECURITY.md](SECURITY.md).
238328 - **Citing Diffinery:** use GitHub's "Cite this repository" button, or [CITATION.cff](CITATION.cff).
239329 - **License:** [Apache License 2.0](LICENSE). See [NOTICE](NOTICE).
330+
331+Diffinery is made by the Codician team.
modifiedapps/cli/src/cli.ts+2−1View file
@@ -87,7 +87,8 @@
8787 if (r.code === 0) {
8888 const parsed = parseRemote(r.stdout, host);
8989 if (parsed) return parsed;
90- throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}; pass -R OWNER/NAME`);
90+ // A remote on another host is often another Diffinery instance (a self-hosted one): say how to switch.
91+ throw new CliError(`the origin remote (${r.stdout.trim()}) does not point at ${host}, the Diffinery this CLI uses; pass -R OWNER/NAME, or if it's another Diffinery, sign in there with diffinery login --url https://HOST (or set DIFFINERY_URL)`);
9192 }
9293 throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository");
9394 }
modifiedapps/cli/src/commands/auth.ts+12−1View file
@@ -8,6 +8,7 @@
88 import { json } from "../output.js";
99 import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js";
1010 import { setupClaudePlugins } from "./claude-plugins.js";
11+import { setupCodexMcp } from "./codex.js";
1112
1213 interface User {
1314 login: string;
@@ -257,7 +258,10 @@
257258 sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Where Claude Code is
258259 installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in
259260 ~/.claude/diffinery-plugins and named in ~/.claude/settings.json, so every new Claude Code session loads
260-them. Skip that with --no-claude-plugins (or DIFFINERY_NO_CLAUDE_PLUGINS=1). Last, on a terminal, it asks
261+them, with their Diffinery URL setting pointed at this server. Skip that with --no-claude-plugins (or
262+DIFFINERY_NO_CLAUDE_PLUGINS=1). For a server other than diffinery.com (--url, a self-hosted Diffinery), it
263+also points Codex's diffinery MCP server there when Codex is installed (codex mcp add; skip with
264+DIFFINERY_NO_CODEX=1). Last, on a terminal, it asks
261265 whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking,
262266 --no-auto-merge skips the question). Safe to run again; it brings the plugins up to date.
263267
@@ -333,6 +337,13 @@
333337 }
334338 }
335339 try {
340+ const p = await setupCodexMcp(ctx.io.env, url);
341+ if (p?.ok) ctx.out(`${ctx.c.green("✓")} Codex: ${p.message}`);
342+ else if (p) ctx.err(`${ctx.c.yellow("warning:")} Codex: ${p.message}`);
343+ } catch (e) {
344+ ctx.err(`${ctx.c.yellow("warning:")} Codex: ${(e as Error).message}`);
345+ }
346+ try {
336347 await offerAutoMerge(new Ctx(ctx.io, {}), url, { yes: o["auto-merge"] === true, skip: o["no-auto-merge"] === true });
337348 } catch (e) {
338349 ctx.err(`${ctx.c.yellow("warning:")} auto-merge: ${(e as Error).message}`);
modifiedapps/cli/src/commands/claude-plugins.ts+35−5View file
@@ -12,9 +12,14 @@
1212 * they're saved in Claude Code's settings directory and named in the `env` block of its user settings as
1313 * CLAUDE_CODE_PLUGIN_DIRS, which Claude Code reads at the start of every session: the terminal, the
1414 * desktop app, VS Code, and cloud sessions and project threads, whose setup script runs this. A cloud
15- * environment keeps them in its snapshot. Running it again brings them up to date.
15+ * environment keeps them in its snapshot. Running it again brings them up to date. Their "Diffinery URL"
16+ * setting is set to the server this runs against (pluginConfigs in the same settings file), for these
17+ * copies and for copies installed from the Diffinery marketplace alike.
1618 */
1719
20+/** What the plugins' Diffinery URL setting defaults to; `pluginConfigs` is written only to change it. */
21+const PLUGIN_DEFAULT_URL = "https://diffinery.com";
22+
1823 export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const;
1924
2025 /** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */
@@ -46,6 +51,23 @@
4651 return [...kept, ...ours].join(path.delimiter);
4752 }
4853
54+/**
55+ * `pluginConfigs` with each plugin id's `url` option set to `url`, other options and plugins kept; null when
56+ * nothing would change. A plugin with no saved URL is left alone for diffinery.com, its default.
57+ */
58+export function withUrlOption(current: unknown, ids: string[], url: string): Record<string, unknown> | null {
59+ const all = current && typeof current === "object" && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {};
60+ let changed = false;
61+ for (const id of ids) {
62+ const entry = all[id] && typeof all[id] === "object" && !Array.isArray(all[id]) ? (all[id] as Record<string, unknown>) : {};
63+ const options = entry.options && typeof entry.options === "object" && !Array.isArray(entry.options) ? (entry.options as Record<string, unknown>) : {};
64+ if (options.url === url || (options.url === undefined && url === PLUGIN_DEFAULT_URL)) continue;
65+ all[id] = { ...entry, options: { ...options, url } };
66+ changed = true;
67+ }
68+ return changed ? all : null;
69+}
70+
4971 export interface PluginsResult {
5072 ok: boolean;
5173 message: string;
@@ -101,15 +123,23 @@
101123 got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim());
102124 }
103125
104- if (files.length) {
105- const block = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? (settings.env as Record<string, unknown>) : {};
106- const current = typeof block.CLAUDE_CODE_PLUGIN_DIRS === "string" ? block.CLAUDE_CODE_PLUGIN_DIRS : undefined;
107- const next = { ...settings, env: { ...block, CLAUDE_CODE_PLUGIN_DIRS: withOurs(current, files) } };
126+ // Plugins loaded from a directory or zip are <name>@inline; marketplace ones keep their enabledPlugins id.
127+ const ids = [...wanted.map((n) => `${n}@inline`), ...enabled.filter((k) => fromMarketplace.some((n) => k.startsWith(`${n}@`)))];
128+ const configs = withUrlOption(settings.pluginConfigs, ids, url);
129+ if (files.length || configs) {
130+ let next = settings;
131+ if (files.length) {
132+ const block = settings.env && typeof settings.env === "object" && !Array.isArray(settings.env) ? (settings.env as Record<string, unknown>) : {};
133+ const current = typeof block.CLAUDE_CODE_PLUGIN_DIRS === "string" ? block.CLAUDE_CODE_PLUGIN_DIRS : undefined;
134+ next = { ...next, env: { ...block, CLAUDE_CODE_PLUGIN_DIRS: withOurs(current, files) } };
135+ }
136+ if (configs) next = { ...next, pluginConfigs: configs };
108137 await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
109138 await rename(`${settingsFile}.tmp`, settingsFile);
110139 }
111140 const parts = [];
112141 if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`);
113142 if (fromMarketplace.length) parts.push(`${fromMarketplace.join(" and ")} already installed from the Diffinery marketplace`);
143+ if (configs) parts.push(`their Diffinery URL is ${url}`);
114144 return { ok: true, message: parts.join("; ") };
115145 }
addedapps/cli/src/commands/codex.ts+71−0View file
@@ -1,0 +1,71 @@
1+import { spawn } from "node:child_process";
2+import { appendFile, mkdir, readFile } from "node:fs/promises";
3+import { homedir } from "node:os";
4+import path from "node:path";
5+
6+/**
7+ * Codex's `diffinery` MCP server, pointed at a self-hosted Diffinery by `diffinery setup`. The Codex plugin's
8+ * server is https://diffinery.com/mcp (Codex has no plugin settings to change it), and a server of the same
9+ * name in Codex's own config replaces it, so for another instance this adds that server to config.toml.
10+ * Not with `codex mcp add`, which starts a browser sign-in for a server that offers OAuth and waits for it.
11+ * Nothing happens for diffinery.com, or where Codex isn't installed.
12+ */
13+
14+const DEFAULT_URL = "https://diffinery.com";
15+
16+function codex(env: NodeJS.ProcessEnv, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
17+ return new Promise((resolve) => {
18+ let child;
19+ try {
20+ child = spawn("codex", args, { env, stdio: ["ignore", "pipe", "pipe"] });
21+ } catch (e) {
22+ return resolve({ code: 127, stdout: "", stderr: (e as Error).message });
23+ }
24+ let stdout = "";
25+ let stderr = "";
26+ child.stdout.on("data", (d: Buffer) => (stdout += d.toString()));
27+ child.stderr.on("data", (d: Buffer) => (stderr += d.toString()));
28+ child.on("error", (e) => resolve({ code: 127, stdout, stderr: e.message }));
29+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
30+ });
31+}
32+
33+export interface CodexResult {
34+ ok: boolean;
35+ message: string;
36+}
37+
38+/** Null when there's nothing to do: diffinery.com, DIFFINERY_NO_CODEX, or no `codex` on PATH. */
39+export async function setupCodexMcp(env: NodeJS.ProcessEnv, url: string): Promise<CodexResult | null> {
40+ if (url === DEFAULT_URL || env.DIFFINERY_NO_CODEX) return null;
41+ const want = `${url}/mcp`;
42+ // The server Codex uses now: the user's own, else the plugin's, else none (exit status 1).
43+ const got = await codex(env, ["mcp", "get", "diffinery", "--json"]);
44+ if (got.code === 127) return null;
45+ let bearer: string | null = null;
46+ if (got.code === 0) {
47+ try {
48+ const t = (JSON.parse(got.stdout) as { transport?: { url?: unknown; bearer_token_env_var?: unknown } }).transport ?? {};
49+ if (t.url === want) return { ok: true, message: `the diffinery MCP server is ${want}` };
50+ // A headless setup that sends DIFFINERY_TOKEN keeps doing so.
51+ if (typeof t.bearer_token_env_var === "string") bearer = t.bearer_token_env_var;
52+ } catch {
53+ // An answer we can't read: add the server all the same.
54+ }
55+ }
56+ const home = env.CODEX_HOME && path.isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : path.join(env.HOME || homedir(), ".codex");
57+ const file = path.join(home, "config.toml");
58+ const text = await readFile(file, "utf8").catch(() => "");
59+ // A server of the user's own goes first (codex removes it however it's written); the plugin's stays.
60+ if (/^\s*\[\s*mcp_servers\s*\.\s*"?diffinery"?\s*\]/m.test(text) || /^\s*mcp_servers\s*\.\s*"?diffinery"?\s*[.=]/m.test(text)) {
61+ const rm = await codex(env, ["mcp", "remove", "diffinery"]);
62+ if (rm.code !== 0) return { ok: false, message: `codex mcp remove diffinery failed: ${(rm.stderr.trim().split("\n")[0] || `exit status ${rm.code}`).slice(0, 200)}` };
63+ }
64+ const now = await readFile(file, "utf8").catch(() => "");
65+ const table = `[mcp_servers.diffinery]\nurl = ${JSON.stringify(want)}\n${bearer ? `bearer_token_env_var = ${JSON.stringify(bearer)}\n` : ""}`;
66+ await mkdir(home, { recursive: true });
67+ await appendFile(file, `${now && !now.endsWith("\n") ? "\n" : ""}${now ? "\n" : ""}${table}`);
68+ const check = await codex(env, ["mcp", "get", "diffinery", "--json"]);
69+ if (!check.stdout.includes(JSON.stringify(want).slice(1, -1))) return { ok: false, message: `Codex doesn't read the diffinery server added to ${file}: check that file` };
70+ return { ok: true, message: `the diffinery MCP server is now ${want} (sign in with: codex mcp login diffinery)` };
71+}
modifiedapps/cli/test/helpers.ts+2−1View file
@@ -129,8 +129,9 @@
129129 GIT_AUTHOR_EMAIL: "test@example.com",
130130 GIT_COMMITTER_NAME: "Test",
131131 GIT_COMMITTER_EMAIL: "test@example.com",
132- // This machine's Claude Code stays out of it; the tests that want it turn it back on.
132+ // This machine's Claude Code and Codex stay out of it; the tests that want them turn them back on.
133133 DIFFINERY_NO_CLAUDE_PLUGINS: "1",
134+ DIFFINERY_NO_CODEX: "1",
134135 ...extra,
135136 };
136137 }
modifiedapps/cli/test/setup.test.ts+92−0View file
@@ -3,6 +3,7 @@
33 import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
44 import path from "node:path";
55 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+import { withUrlOption } from "../src/commands/claude-plugins.js";
67 import { configPath, writeConfig } from "../src/config.js";
78 import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js";
89
@@ -303,6 +304,22 @@
303304 expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]);
304305 });
305306
307+ it("points the plugins' Diffinery URL setting at the server, for its own copies and marketplace ones", async () => {
308+ const env = await withClaude({ enabledPlugins: { "diffinery-ci@diffinery": true }, pluginConfigs: { "diffinery-ci@diffinery": { options: { notes: "all", url: "https://old.example" } }, "other@x": { options: { a: 1 } } } });
309+ const r = await run(["setup"], { env });
310+ expect(r.code).toBe(0);
311+ expect(r.stdout).toContain(`their Diffinery URL is ${srv.url}`);
312+ expect(settingsOf(env).pluginConfigs).toEqual({
313+ "diffinery@inline": { options: { url: srv.url } },
314+ "diffinery-ci@diffinery": { options: { notes: "all", url: srv.url } },
315+ "other@x": { options: { a: 1 } },
316+ });
317+ // diffinery.com is the setting's default: nothing to write unless a URL was saved before.
318+ expect(withUrlOption(undefined, ["diffinery@inline"], "https://diffinery.com")).toBe(null);
319+ expect(withUrlOption({ "diffinery@inline": { options: { url: srv.url } } }, ["diffinery@inline"], "https://diffinery.com")).toEqual({ "diffinery@inline": { options: { url: "https://diffinery.com" } } });
320+ expect(withUrlOption({ "diffinery@inline": { options: { url: srv.url } } }, ["diffinery@inline"], srv.url)).toBe(null);
321+ });
322+
306323 it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => {
307324 const broken = await withClaude("{ not json");
308325 const r = await run(["setup"], { env: broken });
@@ -318,3 +335,78 @@
318335 expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false);
319336 });
320337 });
338+
339+describe("Codex in diffinery setup", () => {
340+ let srv: FakeServer;
341+ beforeEach(async () => {
342+ srv = await fakeServer();
343+ srv.on("GET", "/api/v3/user", { body: { login: "alice", type: "User" } });
344+ });
345+ afterEach(() => srv.close());
346+
347+ /**
348+ * A codex that records its calls. `mcp get` answers `server` (none: exit status 1, as codex does) until
349+ * config.toml names a URL, then that URL; `mcp remove` drops the config's diffinery table.
350+ */
351+ function fakeCodex(server: unknown): { dir: string; calls: () => string[] } {
352+ const dir = tempDir("diffinery-fake-codex-");
353+ const log = path.join(dir, "calls");
354+ writeFileSync(path.join(dir, "answer.json"), JSON.stringify(server ?? null));
355+ writeFileSync(
356+ path.join(dir, "codex"),
357+ [
358+ "#!/bin/sh",
359+ `printf '%s\\n' "$*" >> "${log}"`,
360+ 'cfg="$CODEX_HOME/config.toml"',
361+ 'if [ "$1 $2" = "mcp remove" ]; then sed -i.bak "/mcp_servers.diffinery/,\\$d" "$cfg"; exit 0; fi',
362+ 'if [ "$1 $2" = "mcp get" ]; then',
363+ ' u=$(sed -n \'s/^url = "\\(.*\\)"$/\\1/p\' "$cfg" 2>/dev/null | tail -n 1)',
364+ ' b=$(sed -n \'s/^bearer_token_env_var = "\\(.*\\)"$/\\1/p\' "$cfg" 2>/dev/null | tail -n 1)',
365+ ' if [ -n "$u" ]; then printf \'{"transport":{"url":"%s","bearer_token_env_var":"%s"}}\' "$u" "$b"; exit 0; fi',
366+ server ? ` cat "${dir}/answer.json"` : " echo 'Error: No MCP server named' >&2; exit 1",
367+ "fi",
368+ "exit 0",
369+ "",
370+ ].join("\n"),
371+ );
372+ chmodSync(path.join(dir, "codex"), 0o755);
373+ return { dir, calls: () => (existsSync(log) ? readFileSync(log, "utf8").trim().split("\n") : []) };
374+ }
375+ const machine = async (codexDir: string, config?: string) => {
376+ const env = isolatedEnv({ DIFFINERY_NO_CODEX: "", PATH: `${codexDir}${path.delimiter}${process.env.PATH}` });
377+ env.CODEX_HOME = path.join(env.HOME!, ".codex");
378+ if (config !== undefined) {
379+ mkdirSync(env.CODEX_HOME);
380+ writeFileSync(path.join(env.CODEX_HOME, "config.toml"), config);
381+ }
382+ await writeConfig(env, { url: srv.url, token: "dfy_good" });
383+ return env;
384+ };
385+ const tomlOf = (env: NodeJS.ProcessEnv) => readFileSync(path.join(env.CODEX_HOME!, "config.toml"), "utf8");
386+
387+ it("points Codex's diffinery MCP server at a self-hosted server, keeping a headless token setting", async () => {
388+ const plugin = fakeCodex({ name: "diffinery", transport: { type: "streamable_http", url: "https://diffinery.com/mcp", bearer_token_env_var: null } });
389+ const env = await machine(plugin.dir, 'model = "o3"');
390+ const r = await run(["setup", "--no-auto-merge"], { env });
391+ expect(r.code).toBe(0);
392+ expect(r.stdout).toContain(`✓ Codex: the diffinery MCP server is now ${srv.url}/mcp`);
393+ expect(plugin.calls()).toEqual(["mcp get diffinery --json", "mcp get diffinery --json"]);
394+ expect(tomlOf(env)).toBe(`model = "o3"\n\n[mcp_servers.diffinery]\nurl = "${srv.url}/mcp"\n`);
395+ // Again: nothing to change.
396+ expect((await run(["setup", "--no-auto-merge"], { env })).stdout).toContain(`✓ Codex: the diffinery MCP server is ${srv.url}/mcp`);
397+
398+ // A server of the user's own, sending DIFFINERY_TOKEN: replaced, and still sending it.
399+ const headless = fakeCodex({ name: "diffinery", transport: { url: "https://old.example/mcp", bearer_token_env_var: "DIFFINERY_TOKEN" } });
400+ const henv = await machine(headless.dir, '[mcp_servers.diffinery]\nurl = "https://old.example/mcp"\nbearer_token_env_var = "DIFFINERY_TOKEN"\n');
401+ await run(["setup", "--no-auto-merge"], { env: henv });
402+ expect(headless.calls()).toContain("mcp remove diffinery");
403+ expect(tomlOf(henv)).toBe(`[mcp_servers.diffinery]\nurl = "${srv.url}/mcp"\nbearer_token_env_var = "DIFFINERY_TOKEN"\n`);
404+ });
405+
406+ it("leaves Codex alone for diffinery.com", async () => {
407+ const codex = fakeCodex(null);
408+ const { setupCodexMcp } = await import("../src/commands/codex.js");
409+ expect(await setupCodexMcp({ ...(await machine(codex.dir)) }, "https://diffinery.com")).toBe(null);
410+ expect(codex.calls()).toEqual([]);
411+ });
412+});
deletedapps/diffinery/claude-plugins/diffinery-0.1.3.zip+0−0
Binary file not shown.
addedapps/diffinery/claude-plugins/diffinery-0.1.4.zip+0−0View file
Binary file not shown.
deletedapps/diffinery/claude-plugins/diffinery-ci-0.2.3.zip+0−0
Binary file not shown.
addedapps/diffinery/claude-plugins/diffinery-ci-0.2.4.zip+0−0View file
Binary file not shown.
modifiedapps/diffinery/docs/accounts/early-access.md+2−0View file
@@ -5,6 +5,8 @@
55
66 While a Diffinery instance is invite-only (the default), people without an invitation can ask for one at `/early-access` on that instance. The sign-in page and the sign-up address lead there. A request never grants access by itself: a site administrator reads it and decides whether to send an [invitation](/docs/accounts/invitations).
77
8+On diffinery.com, the project's own instance, this is how to try Diffinery before you deploy your own: [request early access there](https://diffinery.com/early-access).
9+
810 The form is only there while sign-up is closed. On an instance with [open sign-up](/docs/accounts/passkeys-and-sign-in#open-sign-up) (`SIGNUP=open`), `/early-access` leads to the sign-up page instead.
911
1012 ## Request early access
modifiedapps/diffinery/docs/actions/environments.md+8−4View file
@@ -25,7 +25,7 @@
2525 3. When the job starts on `main` (a protected branch), Diffinery records a deployment with an `in_progress` status, and the job gets the `production` secrets and variables.
2626 4. When the job finishes, the deployment gets a final status (`success`, `failure` or `inactive`). Webhooks receive a `deployment` event when the deployment is created and `deployment_status` events for each status.
2727
28-Repository admins manage environments under **Settings → Environments** (protection rules) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Branch patterns, environment secrets and variables are managed with the API.
28+Repository admins manage environments under **Settings → Environments** (protection rules, deployment branches and their patterns, and environment secrets) and with the REST API, which follows GitHub's paths and JSON shapes, so `gh api`, Octokit and existing scripts work after you point them at `https://diffinery.com/api/v3`. Environment variables are managed with the API.
2929
3030 ## Create an environment
3131
@@ -129,6 +129,8 @@
129129
130130 Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`.
131131
132+In the web UI, open **Settings → Environments → (environment)**, choose **No restriction**, **Protected branches only** or **Selected branches and tags** under **Deployment branches**, and select **Save protection rules**. With **Selected branches and tags** saved, a **Branch and tag patterns** box appears below, where you add a pattern (choose whether it matches branches or tags) and remove one.
133+
132134 The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it.
133135
134136 ### Protected branches only
@@ -280,12 +282,14 @@
280282
281283 ## Environment secrets and variables
282284
283-Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. They are managed with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment.
285+Environment secrets and variables work like repository ones, but only jobs that use the environment receive them. Secrets are managed under **Settings → Environments → (environment) → Environment secrets** or with the REST API; variables with the REST API. Secret values are encrypted at rest with a key specific to the repository and the environment.
284286
285287 ### Environment secrets
286288
287-Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and a token with the `admin` scope. The environment must exist.
289+Setting and deleting environment secrets needs a person (not an agent) with the admin role on the repository, and with the API a token with the `admin` scope. The environment must exist.
288290
291+In the web UI, the **Environment secrets** box on the environment's page lists the secret names with when each was last updated. Enter a name and a value and select **Save secret** to add one or replace its value, or select **Delete** next to a name. Values are never shown again after you save them.
292+
289293 ```sh
290294 # Create or replace a secret: 201 Created when new, 204 No Content when replaced
291295 curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \
@@ -872,7 +876,7 @@
872876
873877 | Area | GitHub | Diffinery |
874878 |---|---|---|
875-| Managing environments | Web UI and API | Protection rules in Settings → Environments and the API; patterns, secrets and variables with the API |
879+| Managing environments | Web UI and API | Protection rules, deployment branches, patterns and secrets in Settings → Environments and the API; variables with the API |
876880 | Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve |
877881 | Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it |
878882 | Admin bypass | Admins can bypass protection rules | Nobody bypasses them; `can_admins_bypass` is `false` |
modifiedapps/diffinery/docs/actions/secrets-and-variables.md+2−2View file
@@ -13,7 +13,7 @@
1313 | Organization secret | Every repository the organization owns, its private ones, or the ones you select | No | Yes | Organization owners and admins, and site admins (people only) |
1414 | Repository variable | One repository | Yes | Yes | People with the admin role on the repository |
1515 | Organization variable | Every repository the organization owns | No | API only | Organization owners and admins, and site admins (people only) |
16-| Environment secret | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository |
16+| Environment secret | Jobs of one repository that use the environment | Yes | API only | People with the admin role on the repository |
1717 | Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository |
1818
1919 Agents can't create, change or delete secrets or variables anywhere, whatever their role (see [Agents, secrets and variables](#agents-secrets-and-variables)). Environment secrets and variables are described in [Environment secrets and variables](#environment-secrets-and-variables) below and in full in [Deployments and environments](/docs/actions/environments).
@@ -226,7 +226,7 @@
226226 DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable
227227 ```
228228
229-They're managed with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role.
229+Environment secrets are managed on the environment's page (**Settings → Environments → (environment) → Environment secrets**) or with the REST API, and environment variables with the REST API only. Creating and deleting environment secrets needs a person (not an agent) with the admin role and an `admin`-scoped token; listing their names needs the admin role. Environment variables are listed with the write role and changed by a person (not an agent) with the admin role.
230230
231231 ```sh
232232 # Environment secret: 201 when new, 204 when replaced
modifiedapps/diffinery/docs/agents/mcp.md+23−1View file
@@ -9,7 +9,7 @@
99
1010 | Property | Value |
1111 | --- | --- |
12-| URL | `https://diffinery.com/mcp` |
12+| URL | `https://diffinery.com/mcp`; on a [self-hosted Diffinery](/docs/self-hosting), `/mcp` on your instance, such as `https://git.example.com/mcp` |
1313 | Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. |
1414 | Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. |
1515 | Batches | A JSON array of requests gets a JSON array of responses. |
@@ -482,6 +482,28 @@
482482 7. `list_workflow_runs`, `get_workflow_run` and `get_job_logs` to follow CI, fixing failures with more commits.
483483 8. `get_pull_request` to read reviews and the remaining merge blockers. A person approves and merges. Or the agent calls `enable_auto_merge` where the branch allows it (`allowAgentAutoMerge`), and Diffinery merges once the checks pass and a person has approved (or without the approval where an admin turned on `agentAutoMergeWithoutApproval`); `auto_merge_blockers` lists what auto-merge still needs. Or it merges with `merge_pull_request` where the branch allows agents to merge.
484484
485+## List your instance in the MCP registry
486+
487+The [official MCP registry](https://registry.modelcontextprotocol.io) lists Diffinery's MCP server as `com.diffinery/diffinery`, with the URL `https://{diffinery_host}/mcp`: clients that read the registry ask for your instance's host name. To list your own instance under your domain as well, prove that you own the domain with a key:
488+
489+1. Make a key pair and its proof record (OpenSSL 3):
490+
491+ ```sh
492+ openssl genpkey -algorithm Ed25519 -out mcp-registry-key.pem
493+ echo "v=MCPv1; k=ed25519; p=$(openssl pkey -in mcp-registry-key.pem -pubout -outform DER | tail -c 32 | base64)"
494+ ```
495+
496+2. Add the printed line as `"MCP_REGISTRY_AUTH"` under `vars` in `wrangler.jsonc` and deploy. Check that `https://<your host>/.well-known/mcp-registry-auth` returns it.
497+3. Sign in to the registry with [`mcp-publisher`](https://github.com/modelcontextprotocol/registry) and publish a `server.json` named after your domain in reverse (`com.example/diffinery` for `git.example.com`), with `remotes` set to `[{"type": "streamable-http", "url": "https://git.example.com/mcp"}]`:
498+
499+ ```sh
500+ mcp-publisher login http --domain git.example.com \
501+ --private-key "$(openssl pkey -in mcp-registry-key.pem -noout -text | grep -A3 'priv:' | tail -n +2 | tr -d ' :\n')"
502+ mcp-publisher publish server.json
503+ ```
504+
505+The record holds only the public key. Keep the `.pem` file to publish updates; if you lose it, make a new pair and replace the record.
506+
485507 ## Limitations
486508
487509 - The server offers tools only: no resources, prompts or sampling.
modifiedapps/diffinery/docs/agents/review.md+2−0View file
@@ -5,6 +5,8 @@
55
66 **Agent review** has one of your agents review pull requests, the way Cursor's Bugbot does. The agent runs Claude Code, Codex or a command of your own in a read-only sandbox, using a model key you bring. It reads the pull request's changes and looks for bugs, security issues and logic errors, not style. Diffinery then posts what it found as a review with inline comments, and reports an **Agent review** check that fails when a finding is blocking.
77
8+![A pull request an agent task opened, with an agent review: its summary and an inline finding with a suggested fix and a Fix it button](/docs/images/agent-pull-request.webp)
9+
810 Agent review is off until a repository admin turns it on. Nothing changes for a repository that doesn't use it.
911
1012 ## Turn it on
modifiedapps/diffinery/docs/agents/tasks.md+2−0View file
@@ -5,6 +5,8 @@
55
66 An **agent task** is a goal you give one of your agents, such as "Fix the Contact sales link on the pricing page and add a test". Diffinery starts a sandbox as that agent with the repository checked out, runs a coding agent (Claude Code, Codex or a command of your own) with **your own model API key**, streams its transcript to the task page, then pushes the work to a branch and opens a pull request as the agent. The pull request needs a person's approval like any agent pull request.
77
8+![An agent task's page: the goal, Claude Code running as the acme-coder agent, the pull request it opened, and its transcript with file edits and test runs](/docs/images/agent-task.webp)
9+
810 Tasks are optional. Nothing changes for a repository until someone with a model key starts one. To use your Claude or ChatGPT subscription instead of an API key, run tasks in a [terminal](/docs/agents/terminal#give-it-a-task).
911
1012 The same machinery reviews pull requests: see [Agent review](/docs/agents/review). Reviews show in the repository's task list, labelled **Review**. The repository's **Project** tab shows tasks on a board, with a conversation and a memory every task reads: see [Projects](/docs/agents/projects).
modifiedapps/diffinery/docs/agents/today.md+2−0View file
@@ -7,6 +7,8 @@
77
88 The same view is available to scripts and agents from the REST API and the `today` MCP tool, so your own coding agent can read your morning too. It can also come to you by email as a [morning digest](#the-morning-digest), and it fits a phone's screen.
99
10+![The Today page: counts of what needs you, agent pull requests open, merged since yesterday and failed runs, then the Needs you, Agents at work, Your agent tasks and Since yesterday sections](/docs/images/today.webp)
11+
1012 ## What it shows
1113
1214 At the top, four numbers link to the sections below them: how many items need you, how many agent pull requests are open, how many pull requests merged since yesterday (and how many of them were by agents), and how many workflow runs failed.
modifiedapps/diffinery/docs/collaboration/merge-on-green.md+2−0View file
@@ -10,6 +10,8 @@
1010
1111 After that, every new pull request merges on green. Nothing is skipped: auto-merge waits for every rule of the branch, a person's pull request still needs the approvals the branch asks for, and a change an agent makes to a workflow file still needs a person to merge it.
1212
13+![The merge box of an agent's pull request: the required check passed, and auto-merge is on, waiting for a person's approval](/docs/images/merge-on-green.webp)
14+
1315 ## The quickest path (a repository you work on alone)
1416
1517 1. Push a workflow that runs on pull requests (for example `on: pull_request`; see [Actions](/docs/actions/overview)), open one pull request, and let its checks run once.
modifiedapps/diffinery/docs/connect.md+2−0View file
@@ -9,6 +9,8 @@
99 https://diffinery.com/mcp
1010 ```
1111
12+On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`.
13+
1214 There is no token to copy. The app sends you to Diffinery in your browser, you choose whom it acts as and what it may do, and it's connected. You can see and disconnect every app under **Settings → Access tokens → Connected apps**, which also shows this URL with a copy button.
1315
1416 ![Settings → Access tokens on Diffinery: the MCP server URL with a Copy button, links to the guides for Claude, Claude Code, Codex and other apps, and two connected apps, Claude Code and Codex, each acting as the agent ada-bot with write access.](/docs/images/connected-apps.webp)
modifiedapps/diffinery/docs/connect/ci-status-in-claude.md+5−2View file
@@ -27,7 +27,7 @@
2727 ```text
2828 ✓ git will use diffinery for https://diffinery.com
2929 ✓ gh is signed in to diffinery.com
30- ✓ Claude Code plugins: diffinery 0.1.3 and diffinery-ci 0.2.3 load in new Claude Code sessions (named in ~/.claude/settings.json)
30+ ✓ Claude Code plugins: diffinery 0.1.4 and diffinery-ci 0.2.4 load in new Claude Code sessions (named in ~/.claude/settings.json)
3131 ```
3232
3333 3. Start a new Claude Code session in a clone of a Diffinery repository (or run `/reload-plugins` in an open one). On a branch with a pull request, the band appears above the prompt.
@@ -37,6 +37,9 @@
3737 > [!NOTE]
3838 > `diffinery setup` saves the plugins in `~/.claude/diffinery-plugins/` and names them in `CLAUDE_CODE_PLUGIN_DIRS`, in the `env` block of `~/.claude/settings.json`, which Claude Code reads at the start of every session in the terminal, the desktop app and the editors. It keeps everything else in that file. To skip it, run `diffinery setup --no-claude-plugins`. A plugin you already installed from the Diffinery marketplace (`claude plugin install diffinery-ci@diffinery`) is left as it is.
3939
40+> [!NOTE]
41+> On a [self-hosted Diffinery](/docs/self-hosting), install the CLI from your instance and give `diffinery setup` its URL: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. The band then reads that instance: it follows `DIFFINERY_URL`, else the plugin's **Diffinery URL** setting (which `diffinery setup` sets, and `/config` changes), else the URL `diffinery login` saved. It sends a token only to the instance the token belongs to. See [Claude Code: Self-hosted Diffinery](/docs/connect/claude-code#self-hosted-diffinery).
42+
4043 ## In cloud sessions and project threads
4144
4245 A Claude Code cloud environment gets the plugins from the setup script of the repository's **Settings → Agent setup**, the same three boxes that give it git, gh and the CLI. Follow [Cloud agent environments](/docs/connect/cloud-agents#set-up-a-claude-code-cloud-environment). In short:
@@ -94,7 +97,7 @@
9497 | Problem | What to do |
9598 | --- | --- |
9699 | The band says `not signed in to Diffinery` | Set `DIFFINERY_TOKEN`, run `diffinery login`, or `diffinery auth setup-gh`. The plugin reads the token again on its next look. |
97-| No band, although the branch has a pull request | The folder's remote isn't on diffinery.com (`git remote -v`), the band is hidden (run `/diffinery-ci`), or mods are off: `/plugin` lists the mods a session loaded. |
100+| No band, although the branch has a pull request | The folder's remote isn't on the Diffinery the band reads (`git remote -v`; for your own instance, see the note under [On your computer](#on-your-computer)), the band is hidden (run `/diffinery-ci`), or mods are off: `/plugin` lists the mods a session loaded. |
98101 | `/diffinery-ci` says there's no pull request | The current branch has none yet. Open one with `diffinery pr create` or `gh pr create`. |
99102 | Claude wasn't told when CI failed | Notes follow commits the session itself pushed, and stop after `/diffinery-ci off`. A push from outside the session shows in the band only. |
100103 | `diffinery setup` says `~/.claude/settings.json isn't valid JSON` | Claude Code's settings file has a typo. Fix it (or move it away), then run `diffinery setup` again. |
modifiedapps/diffinery/docs/connect/claude-code.md+19−1View file
@@ -53,7 +53,7 @@
5353
5454 | Part | What it does |
5555 | --- | --- |
56-| MCP server | `https://diffinery.com/mcp` with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
56+| MCP server | `https://diffinery.com/mcp` (or your [own instance's](#self-hosted-diffinery)) with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
5757 | `diffinery` skill | Teaches Claude the loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, read failing logs with `diffinery run logs`, auto-merge, and what agents can and can't do. |
5858 | `/diffinery:pr-status` | This branch's pull request, its checks, and the log tail of failing jobs. Changes nothing. |
5959 | Session hook | In a Diffinery clone, tells Claude the repository is hosted on Diffinery and whether the CLI is installed and signed in. Elsewhere it stays silent. It never uses the network. |
@@ -109,12 +109,30 @@
109109
110110 Keep the single quotes: Claude Code reads `${DIFFINERY_TOKEN}` from the environment each time it connects, so the token is never written to its settings. This entry takes the place of the plugin's own server. The CLI and `diffinery auth setup-git` read `DIFFINERY_TOKEN` too. For Claude Code on the web, set up the environment from the repository's [Agent setup](/docs/connect/cloud-agents), which also brings the CLI and both plugins. More in [Headless use](/docs/connect/how-sign-in-works#headless-use).
111111
112+## Self-hosted Diffinery
113+
114+The plugins work with any Diffinery instance, such as one you [deploy on your own Cloudflare account](/docs/self-hosting). Their **Diffinery URL** setting, `https://diffinery.com` unless you change it, decides the MCP server's address (`<URL>/mcp`), the instance the session hook recognises in a clone, and the instance the [CI band](/docs/connect/ci-status-in-claude) reads. Set it in one of these ways:
115+
116+- **With the CLI.** Install the CLI from your instance and run `diffinery setup` with its URL:
117+
118+ ```sh
119+ curl -fsSL https://git.example.com/install | bash
120+ diffinery setup --url https://git.example.com
121+ ```
122+
123+ It signs you in there, adds both plugins, and sets their Diffinery URL in `~/.claude/settings.json` (under `pluginConfigs`), for plugins installed from the marketplace too. It ends with `✓ Claude Code plugins: … their Diffinery URL is https://git.example.com`.
124+- **From the marketplace.** `claude plugin install diffinery@diffinery` asks for the Diffinery URL when it enables the plugin. Change it later in `/config`, where each plugin option has its own row.
125+- **As files.** The plugin zips your instance serves at `https://git.example.com/-/claude/diffinery.zip` and `https://git.example.com/-/claude/diffinery-ci.zip` already default to that instance, for claude.ai's **Upload plugin** too.
126+
127+`DIFFINERY_URL` in the environment takes precedence for the session hook and the CI band, as it does for the CLI. The MCP server always follows the setting. For a server without the plugin, use your instance's address: `claude mcp add --transport http diffinery https://git.example.com/mcp`.
128+
112129 ## Troubleshooting
113130
114131 | Problem | What to do |
115132 | --- | --- |
116133 | `/plugin` doesn't list `diffinery` | Run `diffinery setup` (step 1) and start a new session. Its `Claude Code plugins` line says what went wrong, if anything. |
117134 | `claude plugin marketplace add` fails with an authentication error | Git can't read codician/diffinery with your sign-in. Run `diffinery auth setup-git`, or use `diffinery setup` instead (step 1). |
135+| `/mcp` shows `https://diffinery.com/mcp` but your repositories are on your own instance | Set the plugin's Diffinery URL: run `diffinery setup --url https://<your instance>`, or change it in `/config`. See [Self-hosted Diffinery](#self-hosted-diffinery). |
118136 | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. |
119137 | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. |
120138 | A tool says it needs the write scope | You connected with **Read**. Disconnect Claude Code under **Settings → Access tokens → Connected apps** and sign in again with **Write**. |
modifiedapps/diffinery/docs/connect/claude.md+1−1View file
@@ -17,7 +17,7 @@
1717 | Name | `Diffinery` |
1818 | Remote MCP server URL | `https://diffinery.com/mcp` |
1919
20- Leave **Advanced settings** (OAuth client ID and secret) empty: Claude registers itself with Diffinery.
20+ Leave **Advanced settings** (OAuth client ID and secret) empty: Claude registers itself with Diffinery. On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`.
2121 3. Choose **Connect** next to Diffinery. A Diffinery tab opens. Sign in with your passkey if you aren't signed in.
2222 4. On the consent page, choose whom Claude acts as and its access, then choose **Connect**. The tab returns to Claude and the connector shows as connected.
2323
modifiedapps/diffinery/docs/connect/cloud-agents.md+2−0View file
@@ -15,6 +15,8 @@
1515
1616 ## Before you start
1717
18+- On a [self-hosted Diffinery](/docs/self-hosting), everything below works the same with your instance's domain in place of `diffinery.com`: its **Agent setup** page shows the setup script and environment variables with its own URLs, and the network setting must allow its domain.
19+
1820 - You need write access to the repository. Admins find **Agent setup** among the repository's settings; people with write access get a **Settings** tab that opens it.
1921 - Generating a token needs a passkey sign-in from the last 2 hours, as for any token. If yours is older, Diffinery asks you to confirm with your passkey first.
2022
modifiedapps/diffinery/docs/connect/codex.md+13−1View file
@@ -50,7 +50,7 @@
5050
5151 | Part | What it does |
5252 | --- | --- |
53-| MCP server | `https://diffinery.com/mcp` with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
53+| MCP server | `https://diffinery.com/mcp` (see [Self-hosted Diffinery](#self-hosted-diffinery) for your own instance) with browser sign-in: every [Diffinery tool](/docs/reference/mcp-tools). |
5454 | `diffinery` skill | The loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, `diffinery run logs`, auto-merge, and the agent rules. |
5555 | `pr-status` skill | This branch's pull request, its checks, and the log tail of failing jobs. (Codex plugins have no slash commands, so this is a skill.) |
5656 | Session hook | In a Diffinery clone, tells Codex the repository is hosted on Diffinery and whether the CLI is installed and signed in. Codex runs plugin hooks only after you trust them: open `/hooks` in Codex and trust it. The skills work without it. |
@@ -92,6 +92,18 @@
9292
9393 `codex mcp list` then shows **Bearer token** under **Auth**. More in [Headless use](/docs/connect/how-sign-in-works#headless-use).
9494
95+## Self-hosted Diffinery
96+
97+The plugin's skills and session hook work with any Diffinery instance, such as one you [deploy on your own Cloudflare account](/docs/self-hosting). The hook recognises a clone of your instance when its URL is in `DIFFINERY_URL` or saved by `diffinery login`. Codex plugins have no settings, so the plugin's MCP server stays `https://diffinery.com/mcp`; a server of your own named `diffinery` takes its place. `diffinery setup` adds it for you when Codex is installed:
98+
99+```sh
100+curl -fsSL https://git.example.com/install | bash
101+diffinery setup --url https://git.example.com # ✓ Codex: the diffinery MCP server is now https://git.example.com/mcp
102+codex mcp login diffinery
103+```
104+
105+Or add it yourself: `codex mcp add diffinery --url https://git.example.com/mcp`. `codex mcp list` then shows your instance's URL for `diffinery`. For headless use, add `--bearer-token-env-var DIFFINERY_TOKEN` and set `DIFFINERY_URL=https://git.example.com` for the CLI.
106+
95107 ## Troubleshooting
96108
97109 | Problem | What to do |
modifiedapps/diffinery/docs/connect/other-apps.md+2−0View file
@@ -9,6 +9,8 @@
99 https://diffinery.com/mcp
1010 ```
1111
12+On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. Use it in place of `https://diffinery.com/mcp` in the examples below.
13+
1214 ## Cursor
1315
1416 Add Diffinery to `~/.cursor/mcp.json`, or to `.cursor/mcp.json` in a project to share it with everyone who opens the project:
addedapps/diffinery/docs/images/agent-pull-request.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/agent-task.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/merge-on-green.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/today.webp+0−0View file
Binary file not shown.
modifiedapps/diffinery/docs/reference/limits.md+2−1View file
@@ -12,7 +12,7 @@
1212 | Limit | Value | Applies to |
1313 | --- | --- | --- |
1414 | Browser session lifetime | 30 days, extended to 30 days from now on each day the session is used, never past 90 days after sign-in | The session cookie you get after signing in with a passkey, a sign-in link or email recovery. An unused session ends 30 days after its last extension, and every session ends 90 days after its sign-in. |
15-| Recent sign-in for sensitive actions | 2 hours | Adding or removing a passkey, creating a token, an agent or an agent token, changing your username, issuing a sign-in link, deleting, transferring or purging unreachable objects of a repository, and deleting an organization. An older browser sign-in gets **Confirm it's you** (`403` with a `confirm` URL over the API). See [Confirming it's you](/docs/security/overview#confirming-its-you). |
15+| Recent sign-in for sensitive actions | 2 hours | Adding or removing a passkey, creating a token, an agent or an agent token, changing your username, issuing a sign-in link, deleting, transferring, purging unreachable objects of or deleting the closed pull requests of a repository, and deleting an organization. An older browser sign-in gets **Confirm it's you** (`403` with a `confirm` URL over the API). See [Confirming it's you](/docs/security/overview#confirming-its-you). |
1616 | Passkey browser prompt | 2 minutes | Time to complete the browser's passkey dialog when signing in or adding a passkey. |
1717 | Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. |
1818 | Sign-in link lifetime | 24 hours, single use | Links a site administrator sends with **Sign-in link** in **Settings → People and invitations**. |
@@ -226,6 +226,7 @@
226226 | Unfinished runs per repository | 500 | Past it, a `workflow_dispatch` or `repository_dispatch` is refused with `429 this repository has 500 unfinished workflow runs, the most allowed; wait for some to finish`, and other events start no run. |
227227 | Self-hosted runners per repository | 100, each with at most 20 labels | Registering one more gets `429 this repository has 100 self-hosted runners, the most allowed; remove one first`. A registration token works for an hour. |
228228 | Purging unreachable objects | Repositories of up to 300,000 objects | A larger repository gets `422`; ask a site administrator. See [Removing a secret that was pushed](/docs/security/push-protection#removing-a-secret-that-was-pushed). |
229+| Deleting closed pull requests | Repositories of up to 300,000 objects | It purges unreachable objects too, so the same limit applies. See [Start over with fresh history](/docs/repositories/overview#start-over-with-fresh-history). |
229230 | Jobs during a deploy | Not interrupted | A deploy doesn't replace the container of a running hosted job: it finishes on the version it started with. If the platform replaces a job's container anyway, the job keeps going for up to 13 minutes, then is cancelled. |
230231 | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. |
231232 | Log line length | 16,384 characters | Longer lines are cut and end with `…`. |
modifiedapps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+15−2View file
@@ -38,7 +38,7 @@
3838
3939 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen.
4040 2. Choose **Diffinery** in the list on the left.
41-3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page.
41+3. The first time, choose **Sign In with Browser…**. Your browser opens Diffinery's consent page. (For a [self-hosted Diffinery](#self-hosted-diffinery), choose **Use another server…** first.)
4242 4. On the consent page, check that it says **Diffinery plugin for JetBrains IDEs**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the IDE.
4343 5. Pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link.
4444 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it.
@@ -79,8 +79,20 @@
7979
8080 ## Settings
8181
82-**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a self-hosted Diffinery. Changing the server signs you out. For a self-hosted Diffinery, the plugin repository is the same address on your server, such as `https://git.example.com/-/jetbrains/updatePlugins.xml`.
82+**Settings → Version Control → Diffinery** shows the account you're signed in with, with **Sign In with Browser…**, **Use an access token…** and **Sign out**, and the **Server**: `https://diffinery.com` unless you use a [self-hosted Diffinery](#self-hosted-diffinery). Changing the server signs you out.
8383
84+## Self-hosted Diffinery
85+
86+The plugin works with any Diffinery, such as one your company runs at `https://git.example.com`.
87+
88+1. Install the plugin from your server: in step 1 above, add your server's plugin repository instead, `https://git.example.com/-/jetbrains/updatePlugins.xml`. The IDE then installs updates from your server. From a terminal: `idea installPlugins com.diffinery https://git.example.com/-/jetbrains/updatePlugins.xml`.
89+2. Open **Get from Version Control → Diffinery**. Under **Sign In with Browser…**, the page shows **Server: diffinery.com** and:
90+ - **Use git.example.com**, when the IDE installs plugins from that server's repository. One click switches to it.
91+ - **Use another server…**, which asks for the address (such as `git.example.com`), checks that a Diffinery answers there, and switches to it. Enter `diffinery.com` to go back.
92+3. The page now shows **Server: git.example.com**. Choose **Sign In with Browser…**; your server's consent page opens.
93+
94+**Git → Share Project on Diffinery…** offers **Use Another Server…** too when you aren't signed in, and **Settings → Version Control → Diffinery → Server** sets the address by hand. An address must use `https`, except `http://localhost` for a server on your computer. Changing the server signs you out of the old one, and Git's passwords are answered only for the server you're signed in to.
95+
8496 ## Troubleshooting
8597
8698 | What you see | What to do |
@@ -98,4 +110,5 @@
98110 - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The plugin is for people using a JetBrains IDE.
99111 - When a person asks how to open a Diffinery repository in IntelliJ IDEA, PyCharm or another JetBrains IDE, point them to this page: they add `https://diffinery.com/-/jetbrains/updatePlugins.xml` under **Settings → Plugins → ⚙ → Manage Plugin Repositories…**, install **Diffinery**, then use **Get from Version Control → Diffinery**. Don't try to complete the browser sign-in for them.
100112 - The plugin repository at `https://diffinery.com/-/jetbrains/updatePlugins.xml` names the current version and its download; the plugin's ID is `com.diffinery`.
113+- For a self-hosted Diffinery, the plugin repository is `https://SERVER/-/jetbrains/updatePlugins.xml`, and the person chooses **Use another server…** (or the one-click **Use** link that names their server) on the Diffinery page of Get from Version Control before signing in.
101114 - For VS Code, Cursor and Windsurf, see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
modifiedapps/diffinery/docs/repositories/clone-in-your-editor.md+22−2View file
@@ -38,7 +38,7 @@
3838
3939 1. Open the Command Palette (`Ctrl+Shift+P`, or `Cmd+Shift+P` on a Mac) and run **Git: Clone**, or choose **Clone Repository** on the editor's welcome page.
4040 2. Choose **Clone from Diffinery**.
41-3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**: your browser opens Diffinery's consent page.
41+3. The first time, the editor asks to sign in with Diffinery. Choose **Allow**, then **Sign in to diffinery.com** (or **Use another Diffinery server…** for a [self-hosted Diffinery](#self-hosted-diffinery)): your browser opens Diffinery's consent page.
4242 4. On the consent page, check that it says **Diffinery extension for VS Code and Cursor**, published by diffinery.com, keep **Act as** set to you and **Access** set to **Write**, and choose **Connect**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the editor.
4343 5. Back in the editor, pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link.
4444 6. Choose a folder. The editor clones the repository and opens it.
@@ -83,10 +83,29 @@
8383
8484 | Setting | Default | What it does |
8585 | --- | --- | --- |
86-| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. Change it only for a self-hosted Diffinery. |
86+| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. For a self-hosted Diffinery, its address; see below. |
8787 | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. |
8888 | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. |
8989
90+## Self-hosted Diffinery
91+
92+The extension works with any Diffinery, such as one your company runs at `https://git.example.com`. Install it from your server, so it also updates itself from there:
93+
94+```sh
95+curl -fsSL https://git.example.com/install-editor | bash
96+```
97+
98+On Windows: `powershell -c "irm https://git.example.com/install-editor.ps1 | iex"`.
99+
100+Then point the extension at your server, in any of these ways:
101+
102+- **Installed with your server's script:** when the extension starts, it asks **The Diffinery extension was installed from https://git.example.com. Sign in, clone and push there?** Choose **Use git.example.com**. (Editors can't set a setting from their command line, so the script leaves the address in `~/.config/diffinery/editor-server`, or `%USERPROFILE%\.config\diffinery\editor-server` on Windows, and the extension asks once.)
103+- **The first sign-in:** the extension asks **Which Diffinery do you use?** Choose **Use another Diffinery server…** and enter the address, such as `git.example.com`.
104+- **Any time:** run **Diffinery: Use Another Server…** from the Command Palette and enter the address. Enter `diffinery.com` to go back.
105+- **By hand:** set `diffinery.serverUrl` to `https://git.example.com` in your user settings.
106+
107+The extension checks that a Diffinery answers at the address before it uses it (an address must use `https`, except `http://localhost` for a server on your computer). Each server keeps its own sign-in, so switching back doesn't sign you out. Git's passwords are answered for the server in `diffinery.serverUrl` only.
108+
90109 ## JetBrains IDEs
91110
92111 IntelliJ IDEA, PyCharm, WebStorm, GoLand and the other JetBrains IDEs have their own Diffinery plugin, which adds **Diffinery** to **Get from Version Control** with the same passkey sign-in: see [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
@@ -110,4 +129,5 @@
110129
111130 - To clone a Diffinery repository yourself, use Git with a token (`git clone https://diffinery.com/OWNER/REPO.git`, with your token as the password through a credential helper), or the [MCP server](/docs/agents/mcp)'s tools. The extension is for people using an editor.
112131 - When a person asks how to open a Diffinery repository in VS Code, Cursor or Windsurf, point them to this page: they run `curl -fsSL https://diffinery.com/install-editor | bash`, then **Git: Clone → Clone from Diffinery**. Don't try to complete the browser sign-in for them.
132+- For a self-hosted Diffinery, they run the same script from their server (`curl -fsSL https://git.example.com/install-editor | bash`) and choose **Use git.example.com** when the editor asks, or run **Diffinery: Use Another Server…**. Don't edit their `settings.json` for them.
113133 - For connecting an editor's AI (Cursor's agent, VS Code's Copilot Chat) to Diffinery's MCP server, see [Other apps](/docs/connect/other-apps).
modifiedapps/diffinery/docs/repositories/overview.md+13−1View file
@@ -151,7 +151,7 @@
151151 - **Default branch**: a list of the repository's branches (a text field while the repository is empty). The default branch is what the repository page shows, what clones check out, the base for new pull requests, and it is always protected from agents.
152152 - **Visibility**: **Private** or **Public**.
153153
154-Below it, the **Danger zone** has **Archive this repository**, **Transfer ownership** (see [Transfer a repository](#transfer-a-repository)), **Purge unreachable objects** (see [Removing a secret that was pushed](/docs/security/push-protection#removing-a-secret-that-was-pushed)) and **Delete this repository**.
154+Below it, the **Danger zone** has **Archive this repository**, **Transfer ownership** (see [Transfer a repository](#transfer-a-repository)), **Purge unreachable objects** (see [Removing a secret that was pushed](/docs/security/push-protection#removing-a-secret-that-was-pushed)), **Delete closed pull requests** (see [Start over with fresh history](#start-over-with-fresh-history)) and **Delete this repository**.
155155
156156 ### Secrets and variables
157157
@@ -387,6 +387,18 @@
387387 | `type acme/website to confirm the transfer` | The web confirmation box didn't match the repository's full name. |
388388 | `choose the new owner` | Pick a **New owner** in the web form. |
389389
390+## Start over with fresh history
391+
392+To restart a repository's history (for example a single new first commit before you open-source it) while keeping its issues, settings, secrets, environments and collaborators:
393+
394+1. **Back up the old history** if you want to keep it: `git clone --mirror` the repository, or `git bundle create backup.bundle --all` in a mirror clone, and keep the bundle somewhere private, such as a release asset in a private repository.
395+2. **Rewrite the branches.** For a single first commit with today's files: `git commit-tree "$(git rev-parse 'main^{tree}')" -m "Initial commit"` gives the commit. A repository admin then force-pushes it to the default branch (`git push --force origin <sha>:main`); agents can't push to the default branch, and a branch protection rule with **Do not allow administrators to bypass these rules** refuses it until you turn that off. Delete or rewrite the other branches and tags.
396+3. **Delete the old pull requests.** Pull requests keep their commits fetchable through `refs/pull/<number>/head` and `refs/pull/<number>/merge`, as on GitHub. Under **Settings → General → Danger zone**, click **Delete closed pull requests**, type the repository's full name and confirm. Or `POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls`, which answers `{"pull_requests": 42, "objects": 1830, "bytes": 5242880}`.
397+
398+**Delete closed pull requests** permanently deletes every closed and merged pull request with its comments, reviews, review comments, timeline events, reactions and `refs/pull` refs, then [purges unreachable objects](/docs/security/push-protection#removing-a-secret-that-was-pushed), so the old commits can't be fetched by their SHA any more. It can't be undone. Issues, open pull requests (close the ones you want gone first; pull requests in the merge queue stay), branches, tags and workflow runs stay, and new pull requests and issues keep counting from the last number, so old links answer 404 instead of pointing at something new.
399+
400+Only repository admins (people, not agents) can do it, with a sign-in from the last 2 hours in the browser (otherwise you're asked to [Confirm it's you](/docs/security/overview#confirming-its-you)). It works for repositories of up to 300,000 objects. It is recorded in the [audit log](/docs/security/audit-log) as `repo.delete_closed_pulls` with the number of pull requests and objects deleted.
401+
390402 ## Delete a repository
391403
392404 Deleting permanently removes the repository with its history, issues, pull requests, releases, runs, secrets, environments, webhooks and pending invitations, its LFS files and release assets, and its Actions caches, artifacts, job logs and cached source archives. It can't be undone. An archived repository can be deleted without unarchiving it first.
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
@@ -163,6 +163,7 @@
163163 | `repo.archive`, `repo.unarchive` | The repository is archived or unarchived, on **General** or with `PATCH /api/v3/repos/{owner}/{repo}` and `{"archived": true}` (or `false`) | none |
164164 | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none |
165165 | `repo.purge_unreachable` | Unreachable objects are purged (**Settings → Danger zone**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`) | `objects`, `bytes` |
166+| `repo.delete_closed_pulls` | Every closed pull request is deleted and unreachable objects are purged (**Settings → Danger zone → Delete closed pull requests**, or `POST /api/v3/repos/{owner}/{repo}/diffinery/delete-closed-pulls`) | `pull_requests`, `objects`, `bytes` |
166167 | `repo.transfer` | The repository is transferred to another owner (web or `POST /api/v3/repos/{owner}/{repo}/transfer`); the target is the new name | `from`, `to` |
167168 | `repo.collaborator.set`, `repo.collaborator.remove` | On **Collaborators**: an account gets a role at once with **Add people** (an agent, an existing collaborator, a member of the owning organization, or yourself), a collaborator's role is changed, or a collaborator is removed. Through the API: `PUT` (when the role applied at once) or `DELETE /api/v3/repos/{owner}/{repo}/collaborators/{username}` | `login`, `role` |
168169 | `repo.invite` | **Add people** or `PUT /api/v3/repos/{owner}/{repo}/collaborators/{username}` sends an invitation, to an email address or a username | `email` or `login`, and `role` |
modifiedapps/diffinery/docs/security/push-protection.md+1−1View file
@@ -224,7 +224,7 @@
224224 Force-pushing a branch to drop a commit doesn't delete the commit. Like git, Diffinery keeps objects no branch reaches, and anyone who can read the repository can still fetch the commit by its SHA (`GET .../git/commits/{sha}`, the commit page, or `git fetch origin <sha>`). To remove it:
225225
226226 1. **Rotate the credential** at its provider. Treat it as exposed: clones, forks, caches and webhook receivers may already have it.
227-2. Rewrite the history without it and force-push every branch and tag that contains it. Close or update any pull request whose head contains it.
227+2. Rewrite the history without it and force-push every branch and tag that contains it. Close or update any pull request whose head contains it; a closed pull request still keeps its commits through `refs/pull/<number>/head` until it is deleted with **Delete closed pull requests** (see [Start over with fresh history](/docs/repositories/overview#start-over-with-fresh-history)).
228228 3. On the repository's **Settings**, under **Danger zone**, click **Purge unreachable objects** (or `POST /api/v3/repos/{owner}/{repo}/diffinery/purge-unreachable`). It permanently deletes every commit, tree and file that no branch, tag, pull request, merge queue entry or unfinished workflow run reaches, and empties the cache of downloadable archives. It answers `{"objects": 3, "bytes": 1234}`.
229229
230230 Only repository admins (people, not agents) can purge, with a sign-in from the last 2 hours in the browser. It is recorded in the [audit log](/docs/security/audit-log) as `repo.purge_unreachable`. A push that was in flight while the purge ran is refused with `the repository's unreachable objects were purged during this push; push again`. Purging works for repositories of up to 300,000 objects. Review comments on commits that were purged no longer show their diff.
modifiedapps/diffinery/docs/self-hosting.md+3−0View file
@@ -6,6 +6,8 @@
66
77 Diffinery is open-source software under the Apache License 2.0, and the way to use it is to run your own instance on your own Cloudflare account and domain. There's no hosted service to sign up for: diffinery.com is the project's own instance. This page is the overview; [Deploy your own Diffinery](/docs/self-hosting/deploy) is the step-by-step guide.
88
9+> **Want to try it before you deploy?** [Request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance. Its administrators review requests and email invitations; see [Early access](/docs/accounts/early-access).
10+
911 ## Get the source
1012
1113 The repository is [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). Clone either one; both carry the same `main` branch, with the `LICENSE` and `NOTICE` files at the top.
@@ -54,6 +56,7 @@
5456 - **Who can join.** An instance is invite-only by default. Set `SIGNUP=open` to let anyone create an account; see [SIGNUP](/docs/self-hosting/configuration#signup) and [Invitations](/docs/accounts/invitations).
5557 - **Limits.** Change the per-account quotas and the instance's hosted job limit with the `QUOTA_*` and `HOSTED_JOBS_LIMIT` variables; see [Configuration](/docs/self-hosting/configuration#worker-variables).
5658 - **Running it.** [Site administration](/docs/admin/site-administration) covers accounts, early access requests, email and audits, and the [status page](/docs/self-hosting/status-page) shows whether each part of your instance works.
59+- **Your team's tools.** The CLI, the Claude Code and Codex plugins, the MCP server and the editor extensions all work with your instance. People install the CLI from it and run `diffinery setup` with its URL, which signs them in and points git, gh, the Claude Code plugins and Codex at it: `curl -fsSL https://git.example.com/install | bash`, then `diffinery setup --url https://git.example.com`. AI apps connect to `https://git.example.com/mcp`. See [Claude Code](/docs/connect/claude-code#self-hosted-diffinery), [Codex](/docs/connect/codex#self-hosted-diffinery), [Clone in your editor](/docs/repositories/clone-in-your-editor) and [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides).
5760 - **How it fits together.** [Architecture](/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it.
5861
5962 ## Get help and contribute
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−0View file
@@ -43,6 +43,7 @@
4343 | `EGRESS` | Not set | No | `web` limits hosted Actions jobs and agent sandboxes (including tasks, reviews and terminals) to HTTP and HTTPS (ports 80 and 443) and DNS. Any other value, or none, leaves them open to the whole internet. Accounts run by a site administrator stay open. See [EGRESS](#egress). |
4444 | `RUNNER_IMAGE` | Set by the deploy | No | The runner image's name and tag (for example `diffinery-runner:96dbbe31790f`), as jobs report it in `runner.image`. `scripts/render-config.mjs` sets it when the deploy uses a prebuilt runner image, and `GET /api/v3/meta` returns it as `runner_image` (`null` when not set). Don't set it by hand. |
4545 | `SECURITY_CONTACT` | Not set | No | More places people can report a vulnerability in your instance, listed in `/.well-known/security.txt` after the instance's own report form (`/security/report`): an email address or an `https://` URL, several separated by commas. See [Security contact](#security-contact). |
46+| `MCP_REGISTRY_AUTH` | Not set | No | The proof record served at `/.well-known/mcp-registry-auth`, such as `v=MCPv1; k=ed25519; p=<public key>`, so you can publish your instance's MCP server to the official MCP registry under your domain. Not set, that path is a 404. See [List your instance in the MCP registry](/docs/agents/mcp#list-your-instance-in-the-mcp-registry). |
4647 | `INDEXNOW` | Not set | No | `off` stops telling search engines about new, changed and removed public pages through IndexNow. Not set, or anything else, it is on whenever `PUBLIC_URL` is a public `https` origin and `MASTER_KEY` is set. Turn it off for an instance search engines can't reach. See [Search engines](/docs/admin/site-administration#search-engines). |
4748 | `TERMINALS` | Not set | No | `off` switches [terminals](/docs/agents/terminal) off: nobody can open one, and open ones end at their next check, within a minute. Anything else, or not set, leaves them on. |
4849
addedapps/diffinery/scripts/agent-screenshots.mjs+339−0View file
@@ -1,0 +1,339 @@
1+// Takes the screenshots of Diffinery's agent features (Today, an agent task, an agent review, a pull request that
2+// merges on green) from a real local Worker with sample data, for the README and the docs:
3+//
4+// pnpm --filter @diffinery/app build && pnpm --filter @diffinery/runner build
5+// node scripts/agent-screenshots.mjs
6+//
7+// It runs the real thing end to end: a sandbox pool (`agent.mjs --sandbox-pool`), an agent task with the Claude Code
8+// runtime, the automatic agent review of the pull request it opens, and merge on green. The one stand-in is Claude Code
9+// itself: a script with its name prints what Claude Code prints (`stream-json`) and changes the files, so no model or
10+// subscription is needed. Then the Worker restarts as https://diffinery.com, so the pages show the addresses people see.
11+//
12+// Writes docs/images/{today,agent-task,agent-pull-request,merge-on-green}.webp here and the same pictures as PNG in the
13+// repository's docs/images/ (the README's).
14+import { spawn, execFileSync } from "node:child_process";
15+import { createHash, randomBytes } from "node:crypto";
16+import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17+import { tmpdir } from "node:os";
18+import { join, dirname, resolve } from "node:path";
19+import { fileURLToPath } from "node:url";
20+import { chromium } from "playwright";
21+import { migratedState } from "../test/e2e/d1-state.mjs";
22+import { freePort } from "../test/e2e/ports.mjs";
23+
24+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25+const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
26+const OUT = join(APP, "docs/images");
27+const README_OUT = resolve(APP, "../../docs/images");
28+const top = mkdtempSync(join(tmpdir(), "diffinery-agent-shots-"));
29+const work = join(top, "work");
30+const poolRoot = join(top, "pool");
31+const poolHome = join(top, "home");
32+const persist = join(work, "state");
33+const bin = join(work, "bin");
34+for (const d of [work, poolHome, bin]) mkdirSync(d, { recursive: true });
35+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36+const hash = (s) => createHash("sha256").update(s).digest("hex");
37+const SESSION = `agent-shots-${randomBytes(12).toString("hex")}`;
38+const procs = [];
39+let log = "";
40+
41+function start(cmd, args, env) {
42+ const p = spawn(cmd, args, { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false", ...env }, stdio: ["ignore", "pipe", "pipe"], detached: true });
43+ // LOG=<file> keeps everything the Worker and the pool print, for when a step times out.
44+ const keep = (d) => {
45+ log += d;
46+ if (process.env.LOG) appendFileSync(process.env.LOG, d);
47+ };
48+ p.stdout.on("data", keep);
49+ p.stderr.on("data", keep);
50+ procs.push(p);
51+ return p;
52+}
53+const stop = (p) => {
54+ try {
55+ process.kill(-p.pid, "SIGTERM");
56+ } catch {}
57+};
58+async function until(what, fn, ms = 120_000) {
59+ let v;
60+ for (const end = Date.now() + ms; Date.now() < end; await sleep(500)) {
61+ v = await fn();
62+ if (v) return v;
63+ }
64+ throw new Error(`timed out waiting for ${what}\n${log.slice(-4000)}`);
65+}
66+const sql = (command) => execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
67+
68+// ---------------------------------------------------------------- the stand-in for Claude Code
69+
70+// `npm install --global @anthropic-ai/claude-code@…` (the runtime's install step) and `claude`, as the jail runs them.
71+writeFileSync(join(bin, "npm"), "#!/bin/sh\nexit 0\n");
72+writeFileSync(
73+ join(bin, "claude"),
74+ `#!/usr/bin/env node
75+const fs = require("fs");
76+if (process.argv.includes("--version")) return console.log("2.1.296 (Claude Code)");
77+const prompt = process.env.DIFFINERY_TASK_PROMPT || "";
78+const say = (o) => console.log(JSON.stringify(o));
79+const text = (t) => say({ type: "assistant", message: { content: [{ type: "text", text: t }] } });
80+const tool = (name, input, out) => {
81+ say({ type: "assistant", message: { content: [{ type: "tool_use", name, input }] } });
82+ if (out) say({ type: "user", message: { content: [{ type: "tool_result", content: out }] } });
83+};
84+say({ type: "system", subtype: "init", model: "claude-sonnet" });
85+if (prompt.startsWith("You are reviewing")) {
86+ text("I'll read the changes and the tests around them.");
87+ tool("Read", { file_path: "src/discounts.js" });
88+ tool("Read", { file_path: "src/checkout.js" });
89+ tool("Bash", { command: "npm test" }, "✔ 9 tests passed");
90+ const findings = {
91+ summary: "Adds discount codes at checkout. One case worth covering: codes are matched case-sensitively.",
92+ findings: [{ path: "src/discounts.js", line: 8, side: "RIGHT", severity: "not_blocking", title: "Codes typed in lower case are rejected", body: "Customers often type codes as they read them in an email, such as \`welcome10\`. \`CODES[code]\` only finds \`WELCOME10\`, so the code is refused with no hint why.", suggestion: " const rule = CODES[code.trim().toUpperCase()];" }],
93+ };
94+ say({ type: "result", subtype: "success", num_turns: 4, result: "Reviewed the changes.\\n\`\`\`json\\n" + JSON.stringify(findings) + "\\n\`\`\`" });
95+ return;
96+}
97+text("I'll add discount codes to checkout: a table of codes, applying one to the cart total, and tests.");
98+tool("Read", { file_path: "src/checkout.js" });
99+tool("Bash", { command: "npm test" }, "✔ 6 tests passed");
100+fs.writeFileSync("src/discounts.js", \`// Discount codes customers can enter at checkout.
101+const CODES = {
102+ WELCOME10: { percent: 10 },
103+ FREESHIP: { freeShipping: true },
104+};
105+
106+export function applyDiscount(cart, code) {
107+ const rule = CODES[code];
108+ if (!rule) throw new Error(\\\`Unknown discount code: \\\${code}\\\`);
109+ const subtotal = rule.percent ? Math.round(cart.subtotal * (100 - rule.percent)) / 100 : cart.subtotal;
110+ return { ...cart, subtotal, shipping: rule.freeShipping ? 0 : cart.shipping, code };
111+}
112+\`);
113+tool("Write", { file_path: "src/discounts.js" });
114+const checkout = fs.readFileSync("src/checkout.js", "utf8");
115+if (!checkout.includes("totalWithCode")) fs.writeFileSync("src/checkout.js", checkout.replace("export function total(cart) {", 'import { applyDiscount } from "./discounts.js";\\n\\nexport function totalWithCode(cart, code) {\\n return total(code ? applyDiscount(cart, code) : cart);\\n}\\n\\nexport function total(cart) {'));
116+tool("Edit", { file_path: "src/checkout.js" });
117+fs.writeFileSync("test/discounts.test.js", \`import { test } from "node:test";
118+import assert from "node:assert/strict";
119+import { applyDiscount } from "../src/discounts.js";
120+
121+test("WELCOME10 takes 10% off the subtotal", () => {
122+ assert.equal(applyDiscount({ subtotal: 50, shipping: 5 }, "WELCOME10").subtotal, 45);
123+});
124+
125+test("FREESHIP removes shipping", () => {
126+ assert.equal(applyDiscount({ subtotal: 50, shipping: 5 }, "FREESHIP").shipping, 0);
127+});
128+
129+test("an unknown code is refused", () => {
130+ assert.throws(() => applyDiscount({ subtotal: 50, shipping: 5 }, "NOPE"), /Unknown discount code/);
131+});
132+\`);
133+tool("Write", { file_path: "test/discounts.test.js" });
134+tool("Bash", { command: "npm test" }, "✔ 9 tests passed");
135+say({ type: "result", subtype: "success", num_turns: 9, result: "Added discount codes at checkout: src/discounts.js with WELCOME10 and FREESHIP, totalWithCode() in src/checkout.js, and three tests. All 9 tests pass." });
136+`,
137+);
138+chmodSync(join(bin, "npm"), 0o755);
139+chmodSync(join(bin, "claude"), 0o755);
140+
141+// ---------------------------------------------------------------- a Worker with a sandbox pool
142+
143+const poolPort = await freePort();
144+const poolSecret = randomBytes(16).toString("hex");
145+const pool = start("node", [AGENT, "--sandbox-pool", "--work-root", poolRoot], { HOME: poolHome, PATH: `${bin}:${process.env.PATH}`, DIFFINERY_SANDBOX_POOL_SECRET: poolSecret, DIFFINERY_SANDBOX_PORT: String(poolPort), DIFFINERY_JAIL_SHARED_FOR_TESTS: work });
146+await until("the sandbox pool", async () => log.includes("listening"), 30_000);
147+
148+migratedState(APP, persist);
149+const at = (hoursAgo) => new Date(Date.now() - hoursAgo * 3600_000).toISOString();
150+sql(`INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('ada1', 'ada', 'user', 'Ada Lovelace', 'ada@example.com', 'active', 1, '${at(24 * 30)}')`);
151+
152+const MASTER_KEY = randomBytes(32).toString("base64");
153+async function worker(varsFor) {
154+ const port = await freePort();
155+ const base = `http://127.0.0.1:${port}`;
156+ const vars = varsFor(base);
157+ const p = start("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, ...Object.entries({ MASTER_KEY, ...vars }).flatMap(([k, v]) => ["--var", `${k}:${v}`]), "--show-interactive-dev-session=false"]);
158+ await until("wrangler dev", async () => {
159+ try {
160+ return (await fetch(`${base}/healthz`)).ok;
161+ } catch {
162+ return false;
163+ }
164+ });
165+ return { p, base };
166+}
167+
168+// Phase 1: the Worker at its own address, which the sandboxes clone from, signed in as Ada (DEV_AUTH).
169+let { p: dev, base } = await worker((b) => ({ PUBLIC_URL: b, DEV_AUTH: "ada@example.com", SANDBOX_POOL_URL: `http://127.0.0.1:${poolPort}`, SANDBOX_POOL_SECRET: poolSecret, AGENT_POLL_MS: "500" }));
170+let token;
171+async function api(method, path, body, tok = token) {
172+ const res = await fetch(`${base}/api/v3${path}`, { method, headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
173+ const text = await res.text();
174+ if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${text}`);
175+ return text ? JSON.parse(text) : null;
176+}
177+const asAda = (method, path, body) => api(method, path, body, null);
178+function git(cwd, args, tok = token) {
179+ const auth = Buffer.from(`x-token:${tok}`).toString("base64");
180+ return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "Ada Lovelace", GIT_AUTHOR_EMAIL: "ada@example.com", GIT_COMMITTER_NAME: "Ada Lovelace", GIT_COMMITTER_EMAIL: "ada@example.com" } });
181+}
182+
183+const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
184+try {
185+ token = (await asAda("POST", "/diffinery/tokens", { name: "screenshots", scopes: ["admin"] })).token;
186+ await asAda("POST", "/user/orgs", { login: "acme", name: "Acme" });
187+ await api("POST", "/orgs/acme/repos", { name: "storefront", description: "The Acme web shop", private: true });
188+ const R = "/repos/acme/storefront";
189+
190+ // A small shop, with CI.
191+ const src = join(work, "storefront");
192+ const files = {
193+ "README.md": "# storefront\n\nThe Acme web shop: cart, checkout and payments.\n",
194+ "package.json": JSON.stringify({ name: "storefront", private: true, type: "module", scripts: { test: "node --test" } }, null, 2) + "\n",
195+ "src/checkout.js": "export function total(cart) {\n return Math.round((cart.subtotal + cart.shipping) * 100) / 100;\n}\n",
196+ "src/cart.js": "export function addItem(cart, item) {\n const subtotal = cart.subtotal + item.price * item.quantity;\n return { ...cart, items: [...cart.items, item], subtotal };\n}\n",
197+ "test/checkout.test.js": 'import { test } from "node:test";\nimport assert from "node:assert/strict";\nimport { total } from "../src/checkout.js";\n\ntest("adds shipping", () => assert.equal(total({ subtotal: 10, shipping: 5 }), 15));\n',
198+ "REVIEW.md": "Prices are in dollars with two decimals. Customer-facing errors must say what to do next.\n",
199+ // A self-hosted runner here (this script runs its jobs with the real runner agent); on diffinery.com it would be ubuntu-latest.
200+ ".github/workflows/ci.yml": "name: CI\non:\n push:\n branches: [main]\n pull_request:\njobs:\n test:\n runs-on: self-hosted\n steps:\n - uses: actions/checkout@v4\n - run: node --test\n",
201+ };
202+ for (const [f, c] of Object.entries(files)) {
203+ mkdirSync(dirname(join(src, f)), { recursive: true });
204+ writeFileSync(join(src, f), c);
205+ }
206+ git(src, ["init", "-q", "-b", "main"]);
207+ git(src, ["add", "-A"]);
208+ git(src, ["commit", "-q", "-m", "Cart and checkout"]);
209+ git(src, ["push", "-q", `${base}/acme/storefront.git`, "main"]);
210+
211+ // Ada's model key (a stand-in: the stand-in Claude Code never calls a model), and two agents of Acme's.
212+ await asAda("PUT", "/diffinery/model-keys/anthropic", { key: `sk-ant-api03-${randomBytes(30).toString("hex")}`, accept_terms: true });
213+ const coder = (await asAda("POST", "/diffinery/agents", { login: "acme-coder", name: "Acme coder", org: "acme", auto_merge: true })).token.token;
214+ await asAda("POST", "/diffinery/agents", { login: "acme-reviewer", name: "Acme reviewer", org: "acme" });
215+
216+ // CI: a self-hosted runner that runs every job the repository queues with the real runner agent, as `diffinery runner` does.
217+ const reg = await api("POST", `${R}/actions/runners/registration-token`);
218+ const runner = (await api("POST", `${R}/actions/runners/register`, { registration_token: reg.token, name: "ci-1", labels: ["linux"] })).token;
219+ let running = true;
220+ const jobs = (async () => {
221+ while (running) {
222+ const r = await fetch(`${base}/api/v3${R}/actions/runners/claim`, { method: "POST", headers: { authorization: `Bearer ${runner}`, "content-type": "application/json" }, body: "{}" }).catch(() => null);
223+ if (r?.status !== 200) {
224+ await sleep(1000);
225+ continue;
226+ }
227+ const claim = await r.json();
228+ await new Promise((done) => {
229+ const job = spawn(process.execPath, [AGENT], { env: { ...process.env, DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, stdio: "ignore" });
230+ job.on("close", done);
231+ });
232+ }
233+ })();
234+ const green = (sha) =>
235+ until(`CI on ${sha.slice(0, 7)}`, async () => {
236+ const runs = ((await api("GET", `${R}/commits/${sha}/check-runs`)).check_runs ?? []).filter((c) => c.name.startsWith("CI"));
237+ if (runs.some((c) => c.conclusion && c.conclusion !== "success")) throw new Error(`CI failed on ${sha}: ${JSON.stringify(runs)}`);
238+ return runs.length && runs.every((c) => c.conclusion === "success");
239+ }, 300_000);
240+
241+ // An agent pull request from earlier today, approved by Ada and merged.
242+ git(src, ["checkout", "-q", "-b", "fix-rounding"]);
243+ writeFileSync(join(src, "src/cart.js"), files["src/cart.js"].replace("cart.subtotal + item.price * item.quantity", "Math.round((cart.subtotal + item.price * item.quantity) * 100) / 100"));
244+ git(src, ["commit", "-qam", "Round the cart subtotal to cents"]);
245+ git(src, ["push", "-q", `${base}/acme/storefront.git`, "fix-rounding"], coder);
246+ const early = await api("POST", `${R}/pulls`, { title: "Round the cart subtotal to cents", head: "fix-rounding", base: "main", body: "Adding 0.1 + 0.2 showed $0.30000000000000004 in the cart.", intent: "Cart totals always show whole cents." }, coder);
247+ await green(early.head.sha);
248+ await api("POST", `${R}/pulls/${early.number}/reviews`, { event: "APPROVE", body: "Thanks!" });
249+ await until("the first pull request to merge", async () => (await api("GET", `${R}/pulls/${early.number}`)).merged || (await api("PUT", `${R}/pulls/${early.number}/merge`, { merge_method: "squash" }).catch(() => null)));
250+ git(src, ["checkout", "-q", "main"]);
251+
252+ // Merge on green: the CI checks must pass, and acme-coder's pull requests land once a person approves.
253+ const page = await browser.newPage();
254+ await asAda("PUT", `${R}/diffinery/agent-review`, { agent: "acme-reviewer", runtime: "claude-code", trigger: "push", autofix: false, accept_terms: true });
255+ await page.goto(`${base}/acme/storefront/settings/merge-on-green`);
256+ await page.waitForLoadState("networkidle");
257+ await page.locator('textarea[name="otherChecks"]').fill("CI / test");
258+ const others = page.locator('input[name="otherAgents"]');
259+ if (await others.count()) await others.fill("acme-coder");
260+ // Agents' pull requests still wait for a person's approval (the page's default lands them as soon as the checks pass).
261+ await page.getByText("Once the checks pass and a person approved").click();
262+ await page.waitForFunction(() => document.querySelector('input[name="approval"]')?.value === "approval");
263+ await page.getByRole("button", { name: /Turn on merge on green|Save/ }).click();
264+ await page.waitForLoadState("networkidle");
265+
266+ // The task: Ada asks acme-coder for a feature, Claude Code writes it in a sandbox and opens a pull request.
267+ const task = await asAda("POST", `${R}/diffinery/tasks`, { goal: "Add discount codes at checkout\n\nLet customers enter a code in the cart. Support WELCOME10 (10% off) and FREESHIP, with tests.", runtime: "claude-code", agent: "acme-coder" });
268+ const done = await until("the task", async () => {
269+ const t = await api("GET", `${R}/diffinery/tasks/${task.number}`);
270+ if (t.state === "failed") throw new Error(`the task failed: ${JSON.stringify(await api("GET", `${R}/diffinery/tasks/${task.number}/transcript`))}`);
271+ return t.state !== "running" && t.pull_request ? t : null;
272+ }, 300_000);
273+ const pr = await api("GET", `${R}/pulls/${done.pull_request.number}`);
274+ await green(pr.head.sha);
275+ await until("the agent review", async () => {
276+ const r = await api("GET", `${R}/pulls/${pr.number}/diffinery/agent-review`);
277+ if (process.env.DEBUG && r.task) console.error("review", JSON.stringify((await api("GET", `${R}/diffinery/tasks/${r.task.number}`)).runs));
278+ return r.findings?.length && r.reviewed_sha === pr.head.sha ? r : null;
279+ }, 300_000);
280+
281+ // Another agent pull request, still being worked on.
282+ git(src, ["checkout", "-q", "-b", "agent/order-emails"]);
283+ writeFileSync(join(src, "src/emails.js"), "export const orderConfirmation = (order) => `Thanks for your order #${order.id}!`;\n");
284+ git(src, ["add", "-A"]);
285+ git(src, ["commit", "-qm", "Order confirmation email"]);
286+ git(src, ["push", "-q", `${base}/acme/storefront.git`, "agent/order-emails"], coder);
287+ const draft = await api("POST", `${R}/pulls`, { title: "Send an order confirmation email", head: "agent/order-emails", base: "main", draft: true, intent: "Customers get an email with their order number right after paying." }, coder);
288+ await green(draft.head.sha);
289+ running = false;
290+ await jobs;
291+
292+ // Phase 2: the same data, served as https://diffinery.com to Ada's signed-in browser.
293+ stop(dev);
294+ await sleep(2000);
295+ sql(`INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(SESSION)}', 'ada1', 'passkey', '${at(0)}', '${at(0)}', '${new Date(Date.now() + 86400_000).toISOString()}')`);
296+ ({ p: dev, base } = await worker(() => ({ PUBLIC_URL: "https://diffinery.com", DEV_AUTH: "" })));
297+
298+ const shots = [];
299+ for (const scheme of ["light"]) {
300+ const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 2, colorScheme: scheme });
301+ // localhost is a secure context, so the browser keeps the __Host- cookie over plain HTTP.
302+ const site = base.replace("127.0.0.1", "localhost");
303+ await context.addCookies([{ name: "__Host-dfy_session", value: SESSION, domain: "localhost", path: "/", secure: true, sameSite: "Lax" }]);
304+ const p = await context.newPage();
305+ const shot = async (name, path, { clip, locator, height = 900 } = {}) => {
306+ await p.setViewportSize({ width: 1280, height });
307+ await p.goto(`${site}${path}`);
308+ await p.waitForLoadState("networkidle");
309+ await sleep(500);
310+ const png = locator ? await p.locator(locator).first().screenshot() : await p.screenshot({ clip });
311+ shots.push({ name, png, page: p });
312+ };
313+ await shot("today", "/", { height: 1000 });
314+ await shot("agent-task", `/acme/storefront/tasks/${task.number}`, { height: 1100 });
315+ await shot("agent-pull-request", `/acme/storefront/pull/${pr.number}`, { height: 1200 });
316+ await shot("merge-on-green", `/acme/storefront/pull/${pr.number}`, { locator: ".mergebox" });
317+ }
318+
319+ for (const { name, png, page: p } of shots) {
320+ writeFileSync(join(README_OUT, `${name}.png`), png);
321+ const webp = await p.evaluate(async (b64) => {
322+ const img = new Image();
323+ img.src = `data:image/png;base64,${b64}`;
324+ await img.decode();
325+ const c = document.createElement("canvas");
326+ c.width = img.width;
327+ c.height = img.height;
328+ c.getContext("2d").drawImage(img, 0, 0);
329+ return c.toDataURL("image/webp", 0.86).split(",")[1];
330+ }, png.toString("base64"));
331+ writeFileSync(join(OUT, `${name}.webp`), Buffer.from(webp, "base64"));
332+ console.log(`${name}: png ${Math.round(png.length / 1024)} KiB, webp ${Math.round((webp.length * 3) / 4 / 1024)} KiB`);
333+ }
334+} finally {
335+ await browser.close();
336+ for (const p of procs) stop(p);
337+ if (process.env.KEEP !== "1") rmSync(top, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
338+ else console.log(`kept ${top}`);
339+}
modifiedapps/diffinery/site/about.md+1−1View file
@@ -22,7 +22,7 @@
2222 Diffinery is free, open-source software under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). There are no plans, prices or sales, and no hosted service for other teams: everyone runs their own instance on their own Cloudflare account and domain, and pays Cloudflare only for what it uses. See [Open source](/open-source) and [Deploy your own Diffinery](/docs/self-hosting/deploy).
2323
2424 - **Source code:** [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery), where Diffinery is developed, mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery).
25-- **diffinery.com** is the project's home: this site, the documentation, and the instance Diffinery itself is built on. Accounts there are by invitation, for the people working on Diffinery.
25+- **diffinery.com** is the project's home: this site, the documentation, and the instance Diffinery itself is built on. Accounts there are by invitation, for the people working on Diffinery and for people trying it through [early access](https://diffinery.com/early-access).
2626
2727 ## How we check what we publish
2828
modifiedapps/diffinery/site/agents.md+61−8View file
@@ -1,12 +1,24 @@
11 ---
22 title: Git hosting and CI built for AI coding agents
33 seo_title: "Git hosting for AI coding agents, with MCP and guardrails · Diffinery"
4-description: AI agents get their own accounts, tokens and guardrails on Diffinery, plus an MCP server, sandboxes, agent tasks and agent review. A person approves what lands.
4+description: AI agents get accounts with guardrails, tasks with Claude Code or Codex, agent review, Today, merge on green and plugins. People decide what lands.
55 updated: 2026-10-25
66 ---
77
88 Coding agents such as Claude Code and Codex now write a large share of many teams' pull requests. Diffinery is an open-source git forge designed for that, which you run on your own Cloudflare account ([Deploy your own Diffinery](/docs/self-hosting/deploy)): agents work through the same Git, REST API and CI as people, but as accounts of their own, with limits the server enforces, so a person stays in charge of what reaches your main branch.
99
10+Diffinery can also run your agents for you. Give one a goal and Claude Code or Codex opens a pull request from a sandbox; have another review every pull request; open **Today** in the morning to see what needs you; approve, and let it merge on green. Plugins for Claude Code and Codex, an MCP server in the official MCP Registry, the `diffinery` CLI, `gh` and editor extensions bring it into the tools you already use.
11+
12+| What | In one line |
13+| --- | --- |
14+| [Agent accounts](/agents#agents-are-accounts-not-borrowed-tokens) | Their own logins and tokens, owned by a person or an organization |
15+| [Guardrails](/agents#guardrails-the-server-enforces) | No pushes to protected branches, a person approves, no secrets for agent code |
16+| [Agent tasks](/agents#agent-tasks-a-goal-in-a-pull-request-out) | A goal in, a pull request out, with Claude Code or Codex in a sandbox |
17+| [Agent review](/agents#agent-review-on-every-pull-request) | Inline findings and an Agent review check on every pull request |
18+| [Today and Needs you](/agents#today-what-needs-you) | What needs you and what your agents did since yesterday |
19+| [Merge on green](/agents#merge-on-green) | Agent pull requests land when their checks pass, after a person approves if you want that |
20+| [Plugins and integrations](/agents#plugins-and-integrations) | Claude Code, Codex, MCP, the CLI, gh, VS Code and JetBrains |
21+
1022 ## Agents are accounts, not borrowed tokens
1123
1224 On most forges an agent acts with a person's token, so its work is indistinguishable from theirs and it can do anything they can. On Diffinery:
@@ -39,7 +51,8 @@
3951
4052 - [Connect AI apps](/docs/connect): one URL and a browser sign-in for Claude, Claude Code, Codex and others.
4153 - [MCP tool reference](/docs/reference/mcp-tools): every tool and its arguments, generated from the server.
42-- A plugin for Claude Code and Codex adds the MCP server, skills and a session hook in one install.
54+- Listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery`, for anyone's own instance.
55+- Plugins for Claude Code and Codex add the MCP server, skills and a session hook in one install: see [Plugins and integrations](/agents#plugins-and-integrations).
4356
4457 ## gh, Git and the REST API work as agents expect
4558
@@ -49,15 +62,51 @@
4962
5063 A [sandbox](/docs/agents/sandboxes) is a short-lived Linux container with the repository already cloned, driven over MCP or the REST API: run commands, read and write files, build, test and push. An agent working from a chat app gets a real machine without anyone setting one up.
5164
52-## Agent tasks, review and terminals
65+## Agent tasks: a goal in, a pull request out
5366
54-- **[Agent tasks](/docs/agents/tasks):** give one of your agents a goal; it runs Claude Code, Codex or your own command in a sandbox with your own model key, then pushes a branch and opens a pull request for a person to review. Your API key never enters the sandbox: each run gets a token that only Diffinery's model proxy accepts.
55-- **[Agent review](/docs/agents/review):** one of your agents reviews pull requests for bugs, security issues and logic errors, posts inline findings and reports an Agent review check.
56-- **[Terminals](/docs/agents/terminal):** a shell in your browser with the repository checked out, where Claude Code or Codex runs on your own Claude or ChatGPT subscription.
57-- **[Projects](/docs/agents/projects) and [Today](/docs/agents/today):** a board of agent work with shared memory and instructions, and one page that shows what needs you and what your agents did since yesterday.
67+Give one of your agents a goal. Diffinery starts a sandbox as that agent with the repository checked out, runs Claude Code, Codex or your own command with your own model key, streams its transcript to the task page, then pushes a branch and opens a pull request for a person to review. Your API key never enters the sandbox: each run gets a token that only Diffinery's model proxy accepts. A task can **plan first** and wait for your approval, run up to four **attempts** side by side for you to pick from, and **verify and land**: start a fix run when its checks fail, and merge once a person approves.
5868
69+![An agent task on Diffinery: the goal, Claude Code running as the acme-coder agent, its transcript with file edits and test runs, and the pull request it opened](/docs/images/agent-task.webp)
70+
71+To use your own **Claude or ChatGPT subscription** instead of an API key, open a [terminal](/docs/agents/terminal): a shell in your browser with the repository checked out, where Claude Code or Codex runs on your own plan, and which can take tasks too. See [Agent tasks](/docs/agents/tasks).
72+
73+## Agent review on every pull request
74+
75+One of your agents reviews pull requests for bugs, security issues and logic errors, the way Cursor's Bugbot does. It runs in a read-only sandbox with the base branch's `REVIEW.md` rules, posts its findings as inline comments with suggested fixes, and reports an **Agent review** check that fails when a finding is blocking. **Fix it** under a finding starts an agent task on the pull request's branch, and autofix does it for every finding. See [Agent review](/docs/agents/review).
76+
77+![A pull request opened by an agent on Diffinery, with its intent, the task that wrote it, and an agent review with an inline finding, a suggested fix and a Fix it button](/docs/images/agent-pull-request.webp)
78+
79+## Today: what needs you
80+
81+[Today](/docs/agents/today) is the first page after you sign in: what needs you (agent pull requests waiting for a person, reviews you were asked for, your own blocked pull requests), what your agents are working on, your agent tasks, and what merged, shipped or failed since yesterday, across every repository you can see. Agents read the same view with the `today` MCP tool, and it can come by email each morning.
82+
83+![The Today page on Diffinery: counts of what needs you, agent pull requests and failed runs, then the Needs you, Agents at work, Your agent tasks and Since yesterday sections](/docs/images/today.webp)
84+
85+## Merge on green
86+
87+In **Settings → Merge on green**, a repository admin picks the checks that must pass and the agents whose pull requests land by themselves, and chooses whether a person's approval is still needed. Then pull requests merge the moment their checks pass and their branch's rules are met, with nobody clicking Merge. An agent's change to a workflow file still needs a person to merge it. See [Merge on green automatically](/docs/collaboration/merge-on-green).
88+
89+![The merge box of an agent's pull request on Diffinery: its required check passed and auto-merge is on, waiting for a person's approval](/docs/images/merge-on-green.webp)
90+
91+## Projects with shared memory
92+
93+Every repository is also a [project](/docs/agents/projects): a board of its agent tasks, a conversation for the people working on it, instructions every task follows and a memory every task reads. Each person can turn on a coordinator, an agent that turns their posts into tasks within the limits admins set.
94+
5995 Every agent feature is optional and off until someone turns it on. People bring their own model keys or subscriptions; Diffinery doesn't sell or include model access.
6096
97+## Plugins and integrations
98+
99+| Tool | What it adds | How to get it |
100+| --- | --- | --- |
101+| Claude Code | The `diffinery` plugin: the MCP server, a skill for the branch → pull request → CI → merge loop, `/diffinery:pr-status` and a session hook. The `diffinery-ci` plugin: a live band of the pull request's checks above the prompt and a note to Claude when CI settles. | `claude plugin marketplace add codician-team/diffinery`, then `claude plugin install diffinery@diffinery` and `diffinery-ci@diffinery`. See [Claude Code](/docs/connect/claude-code). |
102+| Codex | The same MCP server, skills and session hook. | `codex plugin marketplace add https://diffinery.com/codician/diffinery.git`, then `codex plugin add diffinery@diffinery`. See [Codex](/docs/connect/codex). |
103+| Any MCP client | The MCP server, listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery` (checked October 25, 2026). | `https://<your host>/mcp`. See [MCP](/docs/agents/mcp). |
104+| Claude (claude.ai, Desktop, mobile) | Diffinery as a custom connector. | Settings → Connectors → Add custom connector. See [Claude](/docs/connect/claude). |
105+| Terminal | The `diffinery` CLI: pull requests with intent, auto-merge, runs and logs. `diffinery setup` signs in and sets up git, gh and both Claude Code plugins. | `curl -fsSL https://diffinery.com/install \| bash`. See [CLI sign-in](/docs/accounts/cli-sign-in). |
106+| GitHub CLI | `gh pr create`, `gh pr checks`, `gh pr merge` and the rest. | `diffinery auth setup-gh`. See [Use gh](/docs/agents/github-cli). |
107+| VS Code, Cursor, Windsurf | Clone from Diffinery with a passkey sign-in. | [Clone in your editor](/docs/repositories/clone-in-your-editor). |
108+| JetBrains IDEs | Get from Version Control with a passkey sign-in. | [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides). |
109+
61110 ## Docs written for agents to read
62111
63112 Every docs page is available as Markdown (add `.md` to its URL, or send `Accept: text/markdown`). [/llms.txt](/llms.txt) indexes the site and the docs, [/llms-full.txt](/llms-full.txt) holds all of it in one file, the REST API is described as [OpenAPI 3.1](/docs/api/openapi.json), and agents connected over MCP can call `search_docs` and `read_docs`. See [Docs for AI agents](/docs/agents/docs-for-agents).
@@ -66,8 +115,12 @@
66115
67116 ### Which AI coding agents work with Diffinery?
68117
69-Any agent that uses Git, the GitHub CLI, a REST API or MCP. There are step-by-step guides for Claude, Claude Code, Codex, Cursor and VS Code, and a plugin for Claude Code and Codex.
118+Any agent that uses Git, the GitHub CLI, a REST API or MCP. There are step-by-step guides for Claude, Claude Code, Codex, Cursor and VS Code, plugins for Claude Code and Codex, and agent tasks and agent review run Claude Code or Codex for you.
70119
120+### Can I use my Claude or ChatGPT subscription?
121+
122+In a [terminal](/docs/agents/terminal), yes: you sign in to Claude Code or Codex there with your own plan, and can give it tasks that open pull requests. Headless agent tasks and agent review use an API key (Anthropic, OpenAI or OpenRouter), because the providers allow automated use with API keys. Codex on a ChatGPT plan is offered on private repositories only.
123+
71124 ### Can an agent merge its own pull request?
72125
73126 Not by default. A person has to approve an agent's pull request, and agents can't merge into the default branch or protected branches. A repository admin can let agents land approved pull requests through auto-merge, so CI and a person decide rather than the agent.
modifiedapps/diffinery/site/faq.md+1−1View file
@@ -19,7 +19,7 @@
1919
2020 ### How do I start using Diffinery?
2121
22-Deploy your own instance to your Cloudflare account and domain: one command from a clone, `scripts/setup.sh`, creates everything it needs, and you sign in as its first administrator. Then invite your team, or open sign-up. See [Deploy your own Diffinery](/docs/self-hosting/deploy). Diffinery doesn't offer a hosted service for other teams; diffinery.com is the project's own instance.
22+Deploy your own instance to your Cloudflare account and domain: one command from a clone, `scripts/setup.sh`, creates everything it needs, and you sign in as its first administrator. Then invite your team, or open sign-up. See [Deploy your own Diffinery](/docs/self-hosting/deploy). Diffinery doesn't offer a hosted service for teams; to try it before you deploy, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance.
2323
2424 ### Is Diffinery a GitHub alternative?
2525
modifiedapps/diffinery/site/features.md+12−4View file
@@ -40,10 +40,16 @@
4040
4141 ## Built for AI agents
4242
43-- **Agents are accounts.** Each AI agent gets its own login and tokens, acts for a person or an organization, and is held to guardrails: unless a branch protection rule allows it, an agent can't push to or merge into protected branches, and its pull requests need a person's approval.
44-- **An MCP server** at `/mcp` on your instance (`https://diffinery.com/mcp` on diffinery.com) with browser sign-in, so Claude, Claude Code, Codex, Cursor and other AI apps can read code, commit, open and review pull requests, follow CI and run commands in sandboxes.
45-- **Agent tasks, reviews and terminals.** Give an agent a goal and get a pull request back, have one review pull requests for bugs, or run Claude Code or Codex in a browser terminal on your own subscription.
43+- **Agents are accounts.** Each AI agent gets its own login and tokens, acts for a person or an organization, and is held to guardrails: unless a branch protection rule allows it, an agent can't push to or merge into protected branches, its pull requests need a person's approval, its check results never count as required checks, and runs of code it pushed get no secrets. See [Guardrails](/docs/agents/overview#guardrails).
44+- **Agent tasks.** Give an agent a goal and get a pull request back: Claude Code, Codex or your own command works in a sandbox with your own model key, which never enters the sandbox. Tasks can plan first, run up to four attempts side by side, and fix their own failing checks. See [Agent tasks](/docs/agents/tasks).
45+- **Agent review.** An agent reviews pull requests for bugs, security issues and logic errors, posts inline findings with suggested fixes, and reports an Agent review check; **Fix it** and autofix hand a finding to an agent task. See [Agent review](/docs/agents/review).
46+- **Today and Needs you.** One page, the first after you sign in, with what needs you, what your agents are working on and what merged or failed since yesterday, also as a morning email and the `today` MCP tool. See [Today](/docs/agents/today).
47+- **Merge on green.** Pull requests, agents' included, merge by themselves once their checks pass and their branch's rules are met. See [Merge on green](/docs/collaboration/merge-on-green).
48+- **Terminals on your own subscription.** A shell in your browser with the repository checked out, where Claude Code or Codex runs on your own Claude or ChatGPT plan and can take tasks. See [Terminals](/docs/agents/terminal).
49+- **Projects, sandboxes and an MCP server.** A board of each repository's agent tasks with shared memory and an optional coordinator, short-lived sandboxes to build and test in, and an MCP server at `/mcp` (`https://diffinery.com/mcp` on diffinery.com), listed in the official MCP Registry as `com.diffinery/diffinery`.
4650
51+![The Today page on Diffinery: what needs you, the agent pull requests at work, your agent tasks and what changed since yesterday](/docs/images/today.webp)
52+
4753 See [Diffinery for AI agents](/agents) for the whole picture.
4854
4955 ## Works with the tools you already use
@@ -54,7 +60,9 @@
5460 | GitHub CLI (`gh`) | Works as with GitHub Enterprise Server, through Diffinery's GitHub-compatible REST and GraphQL APIs. See [Use gh](/docs/agents/github-cli). |
5561 | VS Code, Cursor, Windsurf | "Clone from Diffinery" with a passkey sign-in. See [Clone in your editor](/docs/repositories/clone-in-your-editor). |
5662 | JetBrains IDEs | Get from Version Control with a passkey sign-in. See [Clone in JetBrains IDEs](/docs/repositories/clone-in-jetbrains-ides). |
57-| Claude Code and Codex | A plugin with the MCP server, skills and a session hook. See [Connect AI apps](/docs/connect). |
63+| Claude Code | The `diffinery` plugin (the MCP server, a skill, `/diffinery:pr-status` and a session hook) and the `diffinery-ci` plugin (a live band of the pull request's CI checks above the prompt). `claude plugin marketplace add codician-team/diffinery`. See [Claude Code](/docs/connect/claude-code) and [CI status in Claude](/docs/connect/ci-status-in-claude). |
64+| Codex | The `diffinery` plugin with the MCP server, skills and a session hook. See [Codex](/docs/connect/codex). |
65+| Claude, Cursor and other MCP clients | The MCP server at `/mcp` with a browser sign-in, listed in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) as `com.diffinery/diffinery`. See [Connect AI apps](/docs/connect). |
5866 | Scripts and integrations | A GitHub-style REST API at `/api/v3`, described as [OpenAPI 3.1](/docs/api/openapi.json), and GitHub-compatible [webhooks](/docs/repositories/webhooks). |
5967 | The `diffinery` CLI | Repositories, pull requests, issues, runs, secrets, runners and migrations from a terminal. See the [CLI reference](/docs/reference/cli). |
6068
modifiedapps/diffinery/site/nav.json+1−1View file
@@ -1,6 +1,6 @@
11 [
22 { "title": "Product", "pages": ["/features", "/open-source", "/agents", "/performance", "/security", "/built-on-cloudflare"] },
33 { "title": "Compare", "pages": ["/compare", "/compare/github", "/compare/gitlab", "/compare/blacksmith", "/compare/depot", "/compare/circleci", "/compare/buildkite", "/compare/bitbucket", "/compare/gitea"] },
4- { "title": "Company", "pages": ["/about", "/faq"] },
4+ { "title": "Company", "pages": ["/about", "/faq", "/privacy"] },
55 { "title": "Blog", "pages": ["/blog", "/blog/github-actions-on-cloudflare-containers"] }
66 ]
modifiedapps/diffinery/site/open-source.md+2−2View file
@@ -51,7 +51,7 @@
5151
5252 ## What diffinery.com is
5353
54-diffinery.com is the project's home: this site, the [documentation](/docs), and the Diffinery instance that Diffinery itself is developed on, with its CI, deploys and agents. Accounts there are by invitation, for the people working on Diffinery. It isn't a hosted service for other teams; to use Diffinery, run your own instance.
54+diffinery.com is the project's home: this site, the [documentation](/docs), and the Diffinery instance that Diffinery itself is developed on, with its CI, deploys and agents. Accounts there are by invitation. If you'd like to try Diffinery before running your own, [request early access](https://diffinery.com/early-access): its administrators review requests and email invitations. It isn't a hosted service for teams; to use Diffinery for your work, run your own instance.
5555
5656 ## Contribute
5757
@@ -71,7 +71,7 @@
7171
7272 ### Is there a hosted version I can sign up for?
7373
74-No. diffinery.com is the project's own instance, by invitation for the people working on Diffinery. You deploy your own instance to your Cloudflare account; see [Deploy your own Diffinery](/docs/self-hosting/deploy).
74+Not one to sign up for. To try Diffinery, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance, where accounts are by invitation. To use Diffinery for your team, deploy your own instance to your Cloudflare account; see [Deploy your own Diffinery](/docs/self-hosting/deploy).
7575
7676 ### What does running Diffinery cost?
7777
addedapps/diffinery/site/privacy.md+107−0View file
@@ -1,0 +1,107 @@
1+---
2+title: Privacy policy
3+seo_title: "Privacy policy · Diffinery"
4+description: What personal data diffinery.com and the Diffinery CLI, editor extensions and agent plugins handle, who processes it, and how long it is kept.
5+updated: 2026-10-25
6+---
7+
8+This policy covers **diffinery.com**, the website and Diffinery instance that Codician runs, and the Diffinery **CLI, editor extensions and AI-agent plugins** that Codician publishes. It explains what personal data they handle, why, who processes it, how long it is kept, and what you can ask of us.
9+
10+**The short version:** we collect what diffinery.com needs to work, we don't sell data, we don't show ads, and there are no analytics or tracking cookies. If you run Diffinery yourself, your instance's data stays on your own Cloudflare account, and Codician never receives it.
11+
12+Codician ("we") is responsible for the data described here. Contact: **hi@codician.com**.
13+
14+## If you run your own Diffinery
15+
16+Diffinery is open-source software (Apache-2.0). When you deploy it to your own Cloudflare account, you, not Codician, decide what your instance collects and keep all of its data: accounts, repositories, logs and everything else stay on your Cloudflare account. The software doesn't send usage data or telemetry to Codician. Each instance does tell search engines about its own public pages through IndexNow; you can turn that off ([Search engines](/docs/admin/site-administration#search-engines)). This policy doesn't cover instances run by other people; ask whoever runs yours.
17+
18+## The CLI, editor extensions and AI-agent plugins
19+
20+The `diffinery` CLI, the VS Code and JetBrains extensions, and the Claude Code and Codex plugins talk only to the Diffinery instance you set them up for (diffinery.com unless you choose another). They sign you in to that instance and send it the requests you make, such as listing pull requests or reading a job log. They send nothing to Codician or anyone else. Your token is kept on your computer: in the CLI's config file, your editor's secret storage, or the place your agent app keeps sign-ins.
21+
22+Downloading the CLI, an extension or a plugin from diffinery.com is an ordinary web request, handled as described under [Visiting the website](/privacy#visiting-the-website). Marketplaces you install from (the Visual Studio Marketplace, Open VSX, the JetBrains Marketplace or a plugin directory) handle your data under their own policies.
23+
24+## Visiting the website
25+
26+Anyone can read diffinery.com's public pages, documentation and public repositories without an account. For those visits we don't collect personal data beyond what any web server handles:
27+
28+- **Cloudflare**, which hosts diffinery.com, processes your IP address, browser user agent and the pages you request to serve the site and protect it from attacks. Our request logs are kept for up to 7 days.
29+- **No analytics or advertising.** There are no analytics scripts, tracking pixels or third-party marketing tools on diffinery.com.
30+
31+### Cookies
32+
33+diffinery.com sets only cookies it needs to work. None are used for advertising or to follow you across sites, so there's no cookie banner.
34+
35+| Cookie | What it does | How long |
36+| --- | --- | --- |
37+| `__Host-dfy_session` | Keeps you signed in | 30 days, or until you sign out |
38+| `__Host-dfy_webauthn` | Holds the passkey challenge while you sign in | 5 minutes |
39+| `dfy_csrf` | Protects forms against cross-site request forgery | 30 days |
40+| `__Host-dfy_d1` | Lets the database show you your own changes straight away | 1 day |
41+| `dfy_flash` | Shows a one-time message after an action | 1 minute |
42+| `dfy_theme` | Remembers light or dark appearance if you choose one | 400 days |
43+| `today_collapsed` | Remembers which sections of the Today page you folded | 1 year |
44+
45+## Forms anyone can use
46+
47+- **Early access requests** (`/early-access`): the email address you give and, if you fill them in, your name, company, what you'd use Diffinery for and where you host code today. Unconfirmed requests are deleted after 7 days; confirmed ones are kept until a site administrator deletes the request, and you can delete yours yourself at any time. To enforce daily limits, we keep a hashed form of your IP address and of the email address you typed for one day. Details: [Early access](/docs/accounts/early-access).
48+- **Security reports** (`/security/report`): what you write, and the email address you give for a reply if you aren't signed in. Only diffinery.com's site administrators can read reports.
49+- People who aren't signed in pass a **Cloudflare Turnstile** check on these forms, which Cloudflare runs using signals from your browser.
50+
51+## Accounts on diffinery.com
52+
53+diffinery.com is invite-only: accounts are for the people working on Diffinery. If you have one, we handle:
54+
55+- **Account data:** your email address, username, display name and the profile details you add; the **public keys** of your passkeys (private keys never leave your device); access tokens and agent accounts you create, stored in a form that only lets us check them; who invited you and when.
56+- **What you put on the instance:** repositories and everything in them, issues, pull requests, reviews, comments, releases, workflow files, job logs, artifacts and container images. Git commits carry the author name and email address configured in git; in a public repository they are public.
57+- **Agent features:** model provider API keys you add are stored encrypted and never enter the sandboxes where agents run. When you start an agent task, review or sandbox, the prompts, code and command output the agent needs go to **the model provider you chose** (such as Anthropic or OpenAI), under your own account with that provider and its own terms and privacy policy. Diffinery keeps each task's transcript, visible to people with write access to the repository, and a record of the tokens each run used, so it can show usage and enforce budgets.
58+- **Security records:** an audit log of significant actions (such as sign-ins, token changes, permission changes and deletions) with who acted, when and from which IP address; a log of the notification emails sent to you and whether they were delivered; addresses that bounced or marked our mail as spam, so we stop writing to them; abuse reports and blocks.
59+
60+We use this data to run the instance and keep it secure: hosting your code, running workflows and agents, signing you in, sending the notifications you've chosen, and preventing abuse. Where laws such as the GDPR apply, the legal bases are providing the service you signed up for and our legitimate interest in keeping it secure. **We don't use your private content to train machine-learning models.**
61+
62+## Who processes the data
63+
64+- **Cloudflare, Inc.** hosts diffinery.com and stores its data: Workers, Durable Objects, D1, R2, Queues, Containers (CI runners and agent sandboxes), Email Sending (notifications) and Turnstile. Data isn't pinned to one country: Cloudflare places it and runs jobs in its data centers around the world, and transfers rely on the Standard Contractual Clauses in Cloudflare's data processing terms.
65+- **The model provider you choose** for agent features, as described above.
66+- **Other people:** public repositories and public profiles are visible to anyone; private repositories to the people given access.
67+- **Authorities:** we disclose data only when the law requires it or when it's needed to protect people's safety or rights, and we tell you first where we're allowed to.
68+
69+We don't sell or rent personal data, and we don't share it for advertising.
70+
71+## How long data is kept
72+
73+| Data | Kept |
74+| --- | --- |
75+| Request logs (with IP addresses) | Up to 7 days |
76+| Sign-in sessions | 30 days, or until you sign out |
77+| Notification email log | 90 days |
78+| Job logs | 90 days |
79+| Workflow artifacts | 1 to 90 days, as the workflow sets (90 by default) |
80+| Account data exports | 7 days |
81+| Audit log | 2 years |
82+| Database backups | 30 days of point-in-time recovery |
83+| Username of a deleted account | Not reusable for 90 days |
84+| Your account, content and provider keys | Until you delete them or your account |
85+
86+## Your choices and rights
87+
88+- **See and export your data:** under **Settings → Account**, download an archive with your profile, settings, repositories (as git bundles) and the issues, comments and reviews you wrote. See [Exporting and deleting your account](/docs/accounts/account-data).
89+- **Correct it:** edit your profile and email address in your settings.
90+- **Delete it:** delete repositories, tokens and provider keys in settings, or your whole account under **Settings → Account**. Commits, issues and comments you made in other people's repositories stay part of those repositories, shown as written by a deleted user, and the audit log keeps what your account did.
91+- **Anything else:** write to **hi@codician.com** to ask for a copy of your data, to object to or restrict a use of it, or with any question. We answer within one month. Depending on where you live, you can also complain to your data protection authority.
92+
93+## Security
94+
95+Data is encrypted in transit; provider keys and secrets are encrypted at rest; runners and sandboxes are isolated per job; sign-in uses passkeys; and access is logged. See [Security](/security). Please report vulnerabilities through [the private report form](/security/report), never a public issue.
96+
97+## Children
98+
99+diffinery.com is not meant for children, and its accounts are for the people working on Diffinery.
100+
101+## Changes
102+
103+We'll update this page when what we do changes and change the date at the top. If a change matters to people with accounts, we'll tell them by email before it takes effect.
104+
105+## Contact
106+
107+Codician, **hi@codician.com**. Security reports: [diffinery.com/security/report](/security/report).
modifiedapps/diffinery/src/agentic/review.ts+2−1View file
@@ -351,7 +351,8 @@
351351 out.push("", "Findings outside the changed lines:");
352352 for (const f of o.body) out.push(`- ${SEVERITY_LABEL[f.severity]}: **${f.title}**${f.path ? ` (\`${f.path}${f.line ? `:${f.line}` : ""}\`)` : ""}${f.body ? ` ${f.body.replace(/\n+/g, " ").slice(0, 600)}` : ""}`);
353353 }
354- out.push("", `<sub>Agent review with ${o.runtime}, using ${o.payer}'s model key · [transcript](${o.task})</sub>`);
354+ // Italic, not <sub>: Diffinery's Markdown shows HTML tags as text.
355+ out.push("", `_Agent review with ${o.runtime}, using ${o.payer}'s model key · [transcript](${o.task})_`);
355356 return out.join("\n");
356357 }
357358
modifiedapps/diffinery/src/agentic/today-repo.ts+14−3View file
@@ -37,6 +37,11 @@
3737 checks: ChecksState;
3838 failingChecks: string[];
3939 autoMerge: boolean;
40+ /**
41+ * An agent's pull request with auto-merge on that still needs a person to approve its latest commit (merge on
42+ * green with approvals kept): it isn't landing until someone does, so Today asks them.
43+ */
44+ awaitingApproval?: boolean;
4045 queued: boolean;
4146 /**
4247 * What the last mergeability check found, while the base branch and the head are still the commits it saw:
@@ -56,7 +61,7 @@
5661 * current base and head: Today checks it after answering, so a later Today knows whether it conflicts.
5762 */
5863 export const needsMergeCheck = (p: TodayPull): boolean =>
59- p.author.kind === "agent" && p.mergeState === null && !p.draft && !p.autoMerge && !p.queued && p.checks !== "failure" && !p.changesRequestedBy.length && !p.approvedHead;
64+ p.author.kind === "agent" && p.mergeState === null && !p.draft && (!p.autoMerge || !!p.awaitingApproval) && !p.queued && p.checks !== "failure" && !p.changesRequestedBy.length && !p.approvedHead;
6065
6166 export interface TodayMerged {
6267 number: number;
@@ -150,7 +155,11 @@
150155
151156 type ReviewRow = { number: number; author_id: string; author_login: string; author_kind: string; state: string; commit_id: string; imported: string | null };
152157
153-export function repoToday(sql: SqlStorage, checksFor: (sha: string) => Check[], since: string): RepoToday {
158+/**
159+ * `agentApprovalWaived(base)`: whether what Diffinery lands for agents into `base` needs no approving review
160+ * (its rule's "as soon as the checks pass"); otherwise an agent's pull request waits for a person even with auto-merge on.
161+ */
162+export function repoToday(sql: SqlStorage, checksFor: (sha: string) => Check[], since: string, agentApprovalWaived: (base: string) => boolean = () => false): RepoToday {
154163 const rows = sql
155164 .exec<PullJoin>(
156165 `SELECT i.number, i.title, i.author_id, i.author_login, i.author_kind, i.created_at, i.updated_at,
@@ -181,6 +190,7 @@
181190 const votes = [...latest.values()];
182191 const approvals = votes.filter((v) => v.state === "APPROVED" && v.author_kind === "user");
183192 const checks = checksState(checksFor(r.head_sha));
193+ const approvedHead = approvals.some((v) => v.commit_id === r.head_sha);
184194 return {
185195 number: r.number,
186196 title: r.title,
@@ -193,12 +203,13 @@
193203 createdAt: r.created_at,
194204 updatedAt: r.updated_at,
195205 approvedBy: approvals.map((v) => v.author_login),
196- approvedHead: approvals.some((v) => v.commit_id === r.head_sha),
206+ approvedHead,
197207 changesRequestedBy: votes.filter((v) => v.state === "CHANGES_REQUESTED").map((v) => v.author_login),
198208 reviewerIds: votes.filter((v) => v.state === "APPROVED" || v.state === "CHANGES_REQUESTED").map((v) => v.author_id),
199209 checks: checks.state,
200210 failingChecks: checks.failing,
201211 autoMerge: !!r.auto_merge,
212+ awaitingApproval: r.author_kind === "agent" && !!r.auto_merge && !approvedHead && !agentApprovalWaived(r.base_ref),
202213 queued: !!r.queued,
203214 mergeState: r.merge_state,
204215 };
modifiedapps/diffinery/src/agentic/today.ts+1−1View file
@@ -116,7 +116,7 @@
116116 if (p.draft) return "draft";
117117 if (p.mergeState === "in_base") return "in_base";
118118 if (p.mergeState === "conflicting") return "conflicting";
119- if (p.queued || p.autoMerge) return "landing";
119+ if (p.queued || (p.autoMerge && !p.awaitingApproval)) return "landing";
120120 if (p.checks === "failure") return "checks_failing";
121121 if (p.changesRequestedBy.length) return "changes_requested";
122122 if (p.approvedHead) return "approved";
modifiedapps/diffinery/src/app.ts+2−0View file
@@ -36,6 +36,7 @@
3636 import { mailQueue } from "./mail/send.js";
3737 import { mailEvents, mailEventsQueue } from "./mail/events.js";
3838 import { securityTxtRoutes } from "./http/security-txt.js";
39+import { mcpRegistryAuthRoutes } from "./http/mcp-registry-auth.js";
3940 import { securityReportsApi } from "./http/security-reports.js";
4041 import { earlyAccessApi } from "./http/early-access.js";
4142 import { statusApi } from "./http/status.js";
@@ -123,6 +124,7 @@
123124 app.route("/", oauthApi);
124125 app.route("/", wellKnown);
125126 app.route("/", securityTxtRoutes);
127+app.route("/", mcpRegistryAuthRoutes);
126128 app.route("/", git);
127129 app.route("/", runner);
128130 app.route("/", mailApi);
modifiedapps/diffinery/src/docs/api-meta.ts+5−0View file
@@ -420,6 +420,11 @@
420420 description: "Permanently deletes every commit, tree and file that no branch, tag, pull request, merge queue entry or unfinished workflow run reaches, and empties the archive cache, so a secret force-pushed away can't be fetched by its SHA. Returns `{ objects, bytes }`. People only, not agents. Repositories of up to 300,000 objects (422 otherwise). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL. Audited as `repo.purge_unreachable`. See /docs/security/push-protection#removing-a-secret-that-was-pushed.",
421421 access: "admin",
422422 },
423+ "POST /repos/{owner}/{repo}/diffinery/delete-closed-pulls": {
424+ summary: "Delete closed pull requests",
425+ description: "For starting over with fresh history after rewriting the branches. Permanently deletes every closed and merged pull request (not ones still in the merge queue) with its comments, reviews, review comments, timeline events, reactions and `refs/pull/{number}/*` refs, then purges unreachable objects as `POST .../diffinery/purge-unreachable` does. Issues, open pull requests, branches, tags and workflow runs stay; pull request numbers are not reused. Returns `{ pull_requests, objects, bytes }`. People only, not agents. Repositories of up to 300,000 objects (422 otherwise). From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL. Audited as `repo.delete_closed_pulls`. See /docs/repositories/overview#start-over-with-fresh-history.",
426+ access: "admin",
427+ },
423428 "DELETE /repos/{owner}/{repo}": { summary: "Delete a repository", description: "People only; agents can't delete repositories. Works on an archived repository too. Audited. From a browser session whose sign-in is more than 2 hours old: 403 `Confirm it's you: sign in with your passkey again to do this.` with a `confirm` URL (see /docs/security/overview#confirming-its-you).", access: "admin", status: 204 },
424429 "POST /repos/{owner}/{repo}/transfer": {
425430 summary: "Transfer a repository",
addedapps/diffinery/src/docs/claude-plugins.ts+32−0View file
@@ -1,0 +1,32 @@
1+import { unzip, zip } from "../lib/archive.js";
2+
3+/** The URL the plugins' "Diffinery URL" setting (userConfig `url`) defaults to in their sources. */
4+export const PLUGIN_DEFAULT_URL = "https://diffinery.com";
5+const MANIFEST = ".claude-plugin/plugin.json";
6+// The fixed timestamp scripts/pack-plugins.py gives every entry, so a rewrite of the same zip is the same bytes.
7+const STAMP = Date.UTC(2026, 0, 1) / 1000;
8+
9+const hex = (b: ArrayBuffer) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, "0")).join("");
10+
11+/**
12+ * A Claude Code plugin zip as `origin` serves it. A self-hosted instance's copy defaults the plugin's
13+ * Diffinery URL setting to that instance, so a plugin uploaded to claude.ai or set up by `diffinery setup`
14+ * from there reaches it with nothing to configure; diffinery.com serves the zip as packed.
15+ */
16+export async function pluginZipFor(origin: string, packed: Uint8Array, sha256: string): Promise<{ bytes: Uint8Array; sha256: string }> {
17+ if (origin === PLUGIN_DEFAULT_URL) return { bytes: packed, sha256 };
18+ const entries = await unzip(packed);
19+ let changed = false;
20+ const out = entries.map((e) => {
21+ if ("dir" in e || e.path !== MANIFEST) return e;
22+ const manifest = JSON.parse(new TextDecoder().decode(e.content)) as { userConfig?: { url?: { default?: unknown } } };
23+ const url = manifest.userConfig?.url;
24+ if (!url || url.default !== PLUGIN_DEFAULT_URL) return e;
25+ url.default = origin;
26+ changed = true;
27+ return { ...e, content: new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`) };
28+ });
29+ if (!changed) return { bytes: packed, sha256 };
30+ const bytes = await zip(out, STAMP);
31+ return { bytes, sha256: hex(await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)) };
32+}
modifiedapps/diffinery/src/docs/pages.ts+2−2View file
@@ -124,8 +124,8 @@
124124 `- Every page is available as Markdown: add \`.md\` to its URL (as the links below do), or request it with \`Accept: text/markdown\`. The home page is ${origin}/index.md.`,
125125 `- All pages in one file: ${origin}/llms-full.txt (large: every page, including the full REST API reference). For a smaller context, ${origin}/llms-small.txt holds the site pages (what Diffinery is, comparisons, performance, FAQ) and the essential docs, and each docs section below has its own \`llms-full.txt\`.`,
126126 `- The REST API as OpenAPI 3.1: ${origin}/docs/api/openapi.json`,
127- `- MCP server for AI apps and agents: ${origin}/mcp, with OAuth 2.1 sign-in in the browser or \`Authorization: Bearer <token>\` headless. Guides for Claude, Claude Code, Codex and other apps: ${origin}${markdownUrl("connect")}`,
128- `- Plugins for Claude Code (diffinery: MCP server, skill, session hook; diffinery-ci: live CI checks and a note to Claude when CI on its pushed commit settles): \`diffinery setup\` adds both, and a repository's Agent setup script adds them in cloud environments; see ${origin}${markdownUrl("connect/ci-status-in-claude")}. Codex: \`codex plugin marketplace add ${origin}/codician/diffinery.git\`, then install \`diffinery@diffinery\``,
127+ `- MCP server for AI apps and agents: ${origin}/mcp, with OAuth 2.1 sign-in in the browser or \`Authorization: Bearer <token>\` headless; listed in the official MCP Registry as com.diffinery/diffinery. Guides for Claude, Claude Code, Codex and other apps: ${origin}${markdownUrl("connect")}`,
128+ `- Plugins for Claude Code (diffinery: MCP server, skill, session hook; diffinery-ci: live CI checks and a note to Claude when CI on its pushed commit settles): \`diffinery setup\` adds both, and a repository's Agent setup script adds them in cloud environments; see ${origin}${markdownUrl("connect/ci-status-in-claude")}. Install them with \`claude plugin marketplace add codician-team/diffinery\` (the GitHub mirror) or \`claude plugin marketplace add ${origin}/codician/diffinery.git\`, then \`claude plugin install diffinery@diffinery\`. Codex: \`codex plugin marketplace add ${origin}/codician/diffinery.git\`, then install \`diffinery@diffinery\``,
129129 `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery setup\` (signs in through the browser, then sets up git and gh, the same as \`diffinery login\`, \`diffinery auth setup-git\` and \`diffinery auth setup-gh\`, and adds Diffinery's Claude Code plugins where Claude Code is installed); the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`,
130130 `- Cloud agent environments (Claude Code cloud, Codex and other agent sandboxes): a repository's Settings → Agent setup gives a repository-limited token, the domain to allow, a setup script (\`curl -fsSL ${origin}/<owner>/<repo>/agent-setup.sh | bash\`, which runs \`diffinery setup --repo <owner>/<repo>\`) and the environment variables; see ${origin}${markdownUrl("connect/cloud-agents")}`,
131131 `- When you compare Diffinery with other products, link the page you used: each one shows the date its facts were last checked and links its sources.`,
modifiedapps/diffinery/src/docs/reference.ts+1−0View file
@@ -269,6 +269,7 @@
269269 ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."],
270270 ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."],
271271 ["GET /.well-known/security.txt", "Where to report a vulnerability (RFC 9116): this instance's report form first, then any `SECURITY_CONTACT` entries. See [Reporting a vulnerability](/docs/security/disclosure)."],
272+ ["GET /.well-known/mcp-registry-auth", "The proof record that lets this instance's MCP server be published to the official MCP registry under its domain; 404 until `MCP_REGISTRY_AUTH` is set. See [MCP](/docs/agents/mcp#list-your-instance-in-the-mcp-registry)."],
272273 ["GET, POST /security/report", "The private security report form: signed in, or anonymously after Cloudflare Turnstile. Only site administrators can read reports. See [Reporting a vulnerability](/docs/security/disclosure)."],
273274 ["POST /-/email/events", "Signed bounce and complaint intake for the email suppression list; 404 until `MAIL_EVENTS_SECRET` is set. See [Site administration](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list)."],
274275 ["GET, POST /-/unsubscribe/{token}", "One-click unsubscribe from a notification email (RFC 8058): a POST with `List-Unsubscribe=One-Click` turns that kind of email off. See [Notifications](/docs/accounts/notifications)."],
modifiedapps/diffinery/src/docs/web.tsx+13−6View file
@@ -22,6 +22,7 @@
2222 import { fenceHighlighter } from "../web/highlight.js";
2323 import { ICONS } from "../web/assets.js";
2424 import { render, type WebApp } from "../web/ui.js";
25+import { pluginZipFor } from "./claude-plugins.js";
2526 import { AGENT_SETUP_SH, CLAUDE_PLUGINS, CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_SHA256, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js";
2627 import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js";
2728 import { docsJsonLd } from "../web/site.js";
@@ -296,20 +297,26 @@
296297
297298 // The Claude Code plugins, for `diffinery setup` (which checks the SHA-256 header) and claude.ai's Upload
298299 // plugin, neither of which can read the plugin repository. /-/claude/<name>.zip is always the current version; the versioned name stays the same bytes.
299- const claudeZips = new Map<string, Uint8Array<ArrayBuffer>>();
300- web.get("/-/claude/:file", (c) => {
300+ // A self-hosted instance's copies point the plugins at itself (claude-plugins.ts).
301+ const claudeZips = new Map<string, Promise<{ bytes: Uint8Array; sha256: string }>>();
302+ web.get("/-/claude/:file", async (c) => {
301303 const file = c.req.param("file");
302304 const m = /^([a-z][a-z0-9-]*?)(?:-(\d+\.\d+\.\d+))?\.zip$/.exec(file);
303305 const plugin = m && Object.hasOwn(CLAUDE_PLUGINS, m[1]!) ? CLAUDE_PLUGINS[m[1]!]! : null;
304306 if (!m || !plugin || (m[2] !== undefined && m[2] !== plugin.version)) return c.text("Not found", 404);
305- let bytes = claudeZips.get(m[1]!);
306- if (!bytes) claudeZips.set(m[1]!, (bytes = Uint8Array.from(atob(plugin.zip), (ch) => ch.charCodeAt(0))));
307- return c.body(bytes, 200, {
307+ const key = `${origin(c)} ${m[1]}`;
308+ let built = claudeZips.get(key);
309+ if (!built) {
310+ claudeZips.set(key, (built = pluginZipFor(origin(c), Uint8Array.from(atob(plugin.zip), (ch) => ch.charCodeAt(0)), plugin.sha256)));
311+ built.catch(() => claudeZips.delete(key));
312+ }
313+ const { bytes, sha256 } = await built;
314+ return c.body(bytes as Uint8Array<ArrayBuffer>, 200, {
308315 ...PUBLIC,
309316 "content-type": "application/zip",
310317 "content-disposition": `attachment; filename="${m[1]}-${plugin.version}.zip"`,
311318 "x-diffinery-plugin-version": plugin.version,
312- "x-diffinery-plugin-sha256": plugin.sha256,
319+ "x-diffinery-plugin-sha256": sha256,
313320 });
314321 });
315322
modifiedapps/diffinery/src/env.ts+5−0View file
@@ -91,6 +91,11 @@
9191 * separated by commas.
9292 */
9393 SECURITY_CONTACT?: string;
94+ /**
95+ * The proof record served at /.well-known/mcp-registry-auth (`v=MCPv1; k=ed25519; p=...`), so the
96+ * instance's MCP server can be published to the official MCP registry under its own domain.
97+ */
98+ MCP_REGISTRY_AUTH?: string;
9499 /** Comma-separated emails that become site admins on first sign-in (logins are ignored). Deployed by scripts/bootstrap-admins.sh. */
95100 BOOTSTRAP_ADMINS: string;
96101 /** e.g. example.cloudflareaccess.com — empty disables Access verification. */
modifiedapps/diffinery/src/http/api.ts+8−0View file
@@ -443,6 +443,14 @@
443443 auditRepo(c, caller, repo, "repo.purge_unreachable", { objects: r.objects, bytes: r.bytes, via: "api" });
444444 return c.json({ objects: r.objects, bytes: r.bytes });
445445 });
446+// Start over with fresh history: delete every closed pull request and its refs/pull refs, then purge.
447+api.post("/repos/:owner/:repo/diffinery/delete-closed-pulls", async (c) => {
448+ const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
449+ requireRecentSignIn(c, `/${repo.owner_login}/${repo.name}/settings`);
450+ const r = await stub.deleteClosedPullRequests(caller);
451+ auditRepo(c, caller, repo, "repo.delete_closed_pulls", { pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes, via: "api" });
452+ return c.json({ pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes });
453+});
446454 api.delete("/repos/:owner/:repo", async (c) => {
447455 const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin", { whileArchived: true });
448456 if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories");
addedapps/diffinery/src/http/mcp-registry-auth.ts+36−0View file
@@ -1,0 +1,36 @@
1+import { Hono } from "hono";
2+import type { AppVars } from "../auth/principal.js";
3+import type { Env } from "../env.js";
4+
5+/**
6+ * /.well-known/mcp-registry-auth: the proof record that lets an instance's owner publish its MCP
7+ * server to the official MCP registry (registry.modelcontextprotocol.io) under the instance's own
8+ * domain, such as com.diffinery/diffinery. The registry fetches this file and checks that the key
9+ * `mcp-publisher login http` signs with is the one named here.
10+ *
11+ * The record comes from MCP_REGISTRY_AUTH, or for diffinery.com from PROOFS below, so the project's
12+ * own key is never served by another instance (it would let that key publish under that instance's
13+ * domain). Anything that isn't one well-formed record is a 404, like an unset one.
14+ */
15+
16+/** Built-in records by host. Rotating a key means changing it here; the private key stays with whoever publishes. */
17+const PROOFS: Record<string, string> = {
18+ "diffinery.com": "v=MCPv1; k=ed25519; p=HbHhQYSP5tE1guSK2Df9MqxTJ8F+ojzjAesiFpgABTU=",
19+};
20+
21+const RECORD = /^v=MCPv1; k=(ed25519|ecdsap384); p=[A-Za-z0-9+/]+={0,2}$/;
22+
23+/** This instance's proof record, or null when it has none. */
24+export function mcpRegistryAuth(env: Pick<Env, "PUBLIC_URL" | "MCP_REGISTRY_AUTH">): string | null {
25+ const own = env.MCP_REGISTRY_AUTH?.trim();
26+ if (own) return RECORD.test(own) ? own : null;
27+ return PROOFS[new URL(env.PUBLIC_URL).hostname] ?? null;
28+}
29+
30+export const mcpRegistryAuthRoutes = new Hono<{ Bindings: Env; Variables: AppVars }>();
31+
32+mcpRegistryAuthRoutes.get("/.well-known/mcp-registry-auth", (c) => {
33+ const record = mcpRegistryAuth(c.env);
34+ if (!record) return c.text("Not found", 404);
35+ return c.body(`${record}\n`, 200, { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=300" });
36+});
modifiedapps/diffinery/src/lib/archive.ts+48−1View file
@@ -1,6 +1,7 @@
11 /**
22 * Minimal tar and zip writers for source archives (zipball/tarball) and run
3- * log bundles. Entries are buffered, which is fine for the size limits used.
3+ * log bundles, and a zip reader for the small zips the server ships itself.
4+ * Entries are buffered, which is fine for the size limits used.
45 */
56
67 export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true };
@@ -208,6 +209,52 @@
208209 return concat(parts);
209210 }
210211
212+async function inflateRaw(data: Uint8Array): Promise<Uint8Array> {
213+ const stream = new Blob([data as Uint8Array<ArrayBuffer>]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
214+ return new Uint8Array(await new Response(stream).arrayBuffer());
215+}
216+
217+/**
218+ * The files of a classic zip (stored or deflated entries, read through the central directory), with
219+ * their unix modes as git modes. For zips the server ships itself, such as the Claude Code plugins:
220+ * it checks each entry's CRC and refuses anything else rather than guess.
221+ */
222+export async function unzip(data: Uint8Array): Promise<ArchiveEntry[]> {
223+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
224+ let end = data.length - 22;
225+ while (end >= 0 && view.getUint32(end, true) !== 0x06054b50) end--;
226+ if (end < 0) throw new Error("not a zip archive");
227+ const count = view.getUint16(end + 10, true);
228+ let at = view.getUint32(end + 16, true);
229+ const out: ArchiveEntry[] = [];
230+ for (let i = 0; i < count; i++) {
231+ if (view.getUint32(at, true) !== 0x02014b50) throw new Error("corrupt zip central directory");
232+ const method = view.getUint16(at + 10, true);
233+ const crc = view.getUint32(at + 16, true);
234+ const size = view.getUint32(at + 20, true);
235+ const nameLen = view.getUint16(at + 28, true);
236+ const extraLen = view.getUint16(at + 30, true);
237+ const commentLen = view.getUint16(at + 32, true);
238+ const unixMode = view.getUint32(at + 38, true) >>> 16;
239+ const local = view.getUint32(at + 42, true);
240+ const path = new TextDecoder().decode(data.subarray(at + 46, at + 46 + nameLen));
241+ at += 46 + nameLen + extraLen + commentLen;
242+ if (path.endsWith("/")) {
243+ out.push({ path, dir: true });
244+ continue;
245+ }
246+ if (view.getUint32(local, true) !== 0x04034b50) throw new Error(`corrupt zip entry ${path}`);
247+ const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
248+ const raw = data.subarray(start, start + size);
249+ if (method !== 0 && method !== 8) throw new Error(`zip entry ${path} uses compression method ${method}`);
250+ const content = method === 8 ? await inflateRaw(raw) : raw.slice();
251+ if (crc32(content) !== crc) throw new Error(`zip entry ${path} fails its CRC check`);
252+ const kind = unixMode >> 12;
253+ out.push({ path, mode: kind === 0o12 ? "120000" : unixMode & 0o111 ? "100755" : "100644", content });
254+ }
255+ return out;
256+}
257+
211258 export function concat(parts: Uint8Array[]): Uint8Array {
212259 let n = 0;
213260 for (const p of parts) n += p.length;
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -111,7 +111,7 @@
111111 }
112112
113113 /** Top-level paths the app serves; no account may use them as its login. */
114-export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "faq", "built-on-cloudflare", "open-source", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
114+export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "install-editor", "install-editor.sh", "install-editor.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "features", "agents", "performance", "compare", "pricing", "blog", "changelog", "customers", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "faq", "built-on-cloudflare", "open-source", "privacy", "terms", "legal", "v2", "imports", "apps", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
115115
116116 /**
117117 * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
modifiedapps/diffinery/src/repo/RepoDO.ts+86−44View file
@@ -396,52 +396,89 @@
396396 */
397397 async purgeUnreachable(caller: Caller): Promise<{ objects: number; bytes: number }> {
398398 if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can purge unreachable objects");
399+ this.checkPurgeSize();
400+ return this.ctx.blockConcurrencyWhile(() => this.purgeHeld());
401+ }
402+
403+ /**
404+ * Start over with fresh history: delete every closed and merged pull request (its conversation,
405+ * reviews, review comments, events, reactions and refs/pull/<n>/* refs), then purge what nothing
406+ * reaches any more. Without it, refs/pull keeps every old commit fetchable after the branches were
407+ * rewritten. Issues, open pull requests (and those in the merge queue), branches, tags and workflow
408+ * runs stay, and numbers are never reused. Runs with every other request held, like a purge.
409+ */
410+ async deleteClosedPullRequests(caller: Caller): Promise<{ pullRequests: number; objects: number; bytes: number }> {
411+ if (caller.kind !== "user" || caller.permissions || !atLeast(caller.role, "admin")) throw new HttpError(403, "only repository admins can delete closed pull requests");
412+ this.checkPurgeSize();
413+ return this.ctx.blockConcurrencyWhile(async () => {
414+ const numbers = this.sql
415+ .exec<{ number: number }>("SELECT number FROM issues WHERE kind = 'pull' AND state = 'closed' AND number NOT IN (SELECT number FROM merge_queue) ORDER BY number")
416+ .toArray()
417+ .map((r) => r.number);
418+ for (const n of numbers) this.forgetPull(n);
419+ const purged = await this.purgeHeld();
420+ return { pullRequests: numbers.length, ...purged };
421+ });
422+ }
423+
424+ /** Every row and ref a pull request left behind. */
425+ private forgetPull(n: number): void {
426+ const comments = this.sql.exec<{ id: string; review_id: string | null }>("SELECT id, review_id FROM comments WHERE number = ?", n).toArray();
427+ for (const c of comments) this.sql.exec("DELETE FROM reactions WHERE subject = ?", `${c.review_id ? "review_comment" : "comment"}:${c.id}`);
428+ this.sql.exec("DELETE FROM reactions WHERE subject = ?", `issue:${n}`);
429+ for (const table of ["comments", "reviews", "issue_events", "issue_milestones", "pull_merge_state", "pulls", "issues"]) this.sql.exec(`DELETE FROM ${table} WHERE number = ?`, n);
430+ this.sql.exec("DELETE FROM refs WHERE name >= ? AND name < ?", `refs/pull/${n}/`, `refs/pull/${n}/￿`);
431+ }
432+
433+ private checkPurgeSize(): void {
399434 const total = this.store.count();
400435 if (total > MAX_PURGE_OBJECTS) throw new HttpError(422, `this repository has ${total} objects; purging works for up to ${MAX_PURGE_OBJECTS}. Ask a site administrator`);
401- return this.ctx.blockConcurrencyWhile(async () => {
402- const roots = [
403- ...this.sql.exec<{ oid: string }>("SELECT oid FROM refs").toArray().map((r) => r.oid),
404- ...this.sql.exec<{ oid: string }>("SELECT head_sha AS oid FROM pulls UNION SELECT head_sha FROM merge_queue UNION SELECT group_sha FROM merge_queue WHERE group_sha IS NOT NULL UNION SELECT parent_sha FROM merge_queue WHERE parent_sha IS NOT NULL").toArray().map((r) => r.oid),
405- ...this.sql.exec<{ oid: string }>("SELECT sha AS oid FROM runs WHERE status != 'completed' UNION SELECT head_sha FROM runs WHERE status != 'completed' AND head_sha IS NOT NULL").toArray().map((r) => r.oid),
406- ];
407- const keep = new Set<string>();
408- const stack = [...roots];
409- while (stack.length) {
410- const oid = stack.pop()!;
411- if (keep.has(oid)) continue;
412- const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
413- if (!row) continue;
414- keep.add(oid);
415- const type = CODE_TYPES[row.type];
416- if (type === "blob") continue;
417- const obj = await this.store.read(oid);
418- if (!obj) continue;
419- if (type === "commit") {
420- const c = parseCommit(obj.content);
421- stack.push(c.tree, ...c.parents);
422- } else if (type === "tree") {
423- // A gitlink (160000) names a commit in another repository.
424- for (const e of parseTree(obj.content)) if (e.mode !== "160000") stack.push(e.oid);
425- } else if (type === "tag") {
426- const target = /^object ([0-9a-f]{40})/.exec(fromUtf8(obj.content))?.[1];
427- if (target) stack.push(target);
428- }
436+ }
437+
438+ /** The purge itself; the caller holds every other request (blockConcurrencyWhile). */
439+ private async purgeHeld(): Promise<{ objects: number; bytes: number }> {
440+ const roots = [
441+ ...this.sql.exec<{ oid: string }>("SELECT oid FROM refs").toArray().map((r) => r.oid),
442+ ...this.sql.exec<{ oid: string }>("SELECT head_sha AS oid FROM pulls UNION SELECT head_sha FROM merge_queue UNION SELECT group_sha FROM merge_queue WHERE group_sha IS NOT NULL UNION SELECT parent_sha FROM merge_queue WHERE parent_sha IS NOT NULL").toArray().map((r) => r.oid),
443+ ...this.sql.exec<{ oid: string }>("SELECT sha AS oid FROM runs WHERE status != 'completed' UNION SELECT head_sha FROM runs WHERE status != 'completed' AND head_sha IS NOT NULL").toArray().map((r) => r.oid),
444+ ];
445+ const keep = new Set<string>();
446+ const stack = [...roots];
447+ while (stack.length) {
448+ const oid = stack.pop()!;
449+ if (keep.has(oid)) continue;
450+ const row = this.sql.exec<{ type: number }>("SELECT type FROM objects WHERE oid = ?", oid).toArray()[0];
451+ if (!row) continue;
452+ keep.add(oid);
453+ const type = CODE_TYPES[row.type];
454+ if (type === "blob") continue;
455+ const obj = await this.store.read(oid);
456+ if (!obj) continue;
457+ if (type === "commit") {
458+ const c = parseCommit(obj.content);
459+ stack.push(c.tree, ...c.parents);
460+ } else if (type === "tree") {
461+ // A gitlink (160000) names a commit in another repository.
462+ for (const e of parseTree(obj.content)) if (e.mode !== "160000") stack.push(e.oid);
463+ } else if (type === "tag") {
464+ const target = /^object ([0-9a-f]{40})/.exec(fromUtf8(obj.content))?.[1];
465+ if (target) stack.push(target);
429466 }
430- const doomed = this.sql.exec<{ oid: string; r2: number; size: number }>("SELECT oid, r2, size FROM objects").toArray().filter((r) => !keep.has(r.oid));
431- for (let i = 0; i < doomed.length; i += 100) {
432- const chunk = doomed.slice(i, i + 100).map((r) => r.oid);
433- this.sql.exec(`DELETE FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk);
434- }
435- this.store.forget();
436- this.store.recount();
437- this.storageChanged();
438- this.purgeEpoch++;
439- const r2 = doomed.filter((r) => r.r2).map((r) => this.store.r2KeyOf(r.oid));
440- for (let i = 0; i < r2.length; i += 1000) await this.env.BUCKET.delete(r2.slice(i, i + 1000));
441- // Cached zipballs and tarballs are rebuilt from what's left on the next download.
442- await deletePrefixes(this.env.BUCKET, [archivePrefix(this.meta.repoId)], 50);
443- return { objects: doomed.length, bytes: doomed.reduce((n, r) => n + r.size, 0) };
444- });
467+ }
468+ const doomed = this.sql.exec<{ oid: string; r2: number; size: number }>("SELECT oid, r2, size FROM objects").toArray().filter((r) => !keep.has(r.oid));
469+ for (let i = 0; i < doomed.length; i += 100) {
470+ const chunk = doomed.slice(i, i + 100).map((r) => r.oid);
471+ this.sql.exec(`DELETE FROM objects WHERE oid IN (${chunk.map(() => "?").join(",")})`, ...chunk);
472+ }
473+ this.store.forget();
474+ this.store.recount();
475+ this.storageChanged();
476+ this.purgeEpoch++;
477+ const r2 = doomed.filter((r) => r.r2).map((r) => this.store.r2KeyOf(r.oid));
478+ for (let i = 0; i < r2.length; i += 1000) await this.env.BUCKET.delete(r2.slice(i, i + 1000));
479+ // Cached zipballs and tarballs are rebuilt from what's left on the next download.
480+ await deletePrefixes(this.env.BUCKET, [archivePrefix(this.meta.repoId)], 50);
481+ return { objects: doomed.length, bytes: doomed.reduce((n, r) => n + r.size, 0) };
445482 }
446483
447484 // ---------------------------------------------------------------- storage (the owner's storage quota, lib/quotas.ts)
@@ -1683,7 +1720,12 @@
16831720 private todayMergeChecks = new Set<number>();
16841721
16851722 todaySummary(since: string) {
1686- const summary = repoToday(this.sql, (sha) => this.checksAndStatuses(sha), since);
1723+ // Agents' auto-merges wait for a person's approval unless the base branch's rule waives it (repo/collab.ts mergeability).
1724+ const waived = (base: string) => {
1725+ const rules = this.protectionFor(base);
1726+ return !!rules?.agentAutoMergeWithoutApproval && !rules.agentsMayMerge;
1727+ };
1728+ const summary = repoToday(this.sql, (sha) => this.checksAndStatuses(sha), since, waived);
16871729 // After answering, find out whether agents' pull requests that may be waiting for a person conflict
16881730 // or are already in their base, so the next Today leaves those out of "needs you".
16891731 const unchecked = summary.pulls.filter((p) => needsMergeCheck(p) && !this.todayMergeChecks.has(p.number)).slice(0, MERGE_CHECKS_PER_SUMMARY);
modifiedapps/diffinery/src/web/repo-settings.tsx+178−5View file
@@ -384,6 +384,17 @@
384384 <button class="btn danger">Purge</button>
385385 </Form>
386386 <hr />
387+ <Form action={`${base}/settings/delete-closed-pulls`} t={t} match={full}>
388+ <b>Delete closed pull requests</b>
389+ <p class="muted small">
390+ To start over with fresh history after rewriting the branches: permanently delete every closed and merged pull request with its conversation, reviews and refs, then purge the commits and files nothing else reaches. Issues, open pull requests, branches, tags and workflow runs stay. Type <b class="mono">{full}</b> to confirm.
391+ </p>
392+ <div class="row">
393+ <input name="confirm" type="text" required autocomplete="off" style="max-width:320px" aria-label="Confirm repository name" />
394+ <button class="btn danger">Delete closed pull requests</button>
395+ </div>
396+ </Form>
397+ <hr />
387398 <Form action={`${base}/settings/delete`} t={t} match={full}>
388399 <b>Delete this repository</b>
389400 <p class="muted small">
@@ -645,9 +656,19 @@
645656 );
646657 }
647658
659+type BranchPolicyChoice = "all" | "protected" | "selected";
660+
661+const BRANCH_POLICY_ITEMS = [
662+ { value: "all", label: "No restriction", description: "Any branch or tag can deploy." },
663+ { value: "protected", label: "Protected branches only", description: "Branches a branch protection rule matches. The only setting under which runs whose code came from an agent receive this environment's secrets." },
664+ { value: "selected", label: "Selected branches and tags", description: "Only branches and tags that match the patterns you add below, after saving." },
665+];
666+
667+const branchPolicyOf = (e: Environment): BranchPolicyChoice => (!e.policy ? "all" : e.policy.protected_branches ? "protected" : "selected");
668+
648669 async function environmentPage(c: AppContext, l: Loaded, name: string) {
649670 const t = csrfToken(c);
650- const e = await l.stub.environment(name);
671+ const [e, secrets] = await Promise.all([l.stub.environment(name), l.stub.environmentSecretNames(name)]);
651672 const p = e.protection;
652673 return page(
653674 c,
@@ -681,14 +702,89 @@
681702 <KCheckbox name="prevent_self_review" checked={p.preventSelfReview} label="Prevent self-review" description="Whoever started a run (or re-ran it, or owns the agent that did) can't approve it." />
682703 </div>
683704 <div class="field">
684- <label>Deployment branches</label>
685- <div class="muted small">{e.policy ? (e.policy.protected_branches ? "Protected branches only." : `Only refs matching: ${e.policy.patterns.map((x) => `${x.type} ${x.name}`).join(", ") || "(none yet)"}.`) : "Any branch or tag."} Change it with the REST API (deployment branch policies).</div>
705+ <KRadio name="branch_policy" label="Deployment branches" value={branchPolicyOf(e)} items={BRANCH_POLICY_ITEMS} />
686706 </div>
687707 <div class="factions">
688708 <button class="btn primary">Save protection rules</button>
689709 </div>
690710 </div>
691711 </Form>
712+ {e.policy?.custom_branch_policies ? (
713+ <div class="box">
714+ <div class="head">
715+ <h2>Branch and tag patterns</h2>
716+ </div>
717+ {e.policy.patterns.length ? (
718+ <ul class="list">
719+ {e.policy.patterns.map((x) => (
720+ <li style="align-items:center">
721+ <span class="mono grow">
722+ <b>{x.name}</b>
723+ </span>
724+ <span class="muted small">{x.type}</span>
725+ <Form action={`${envPath(l, e.name)}/branch-policies/delete`} t={t} class="inline" confirm={`Remove the ${x.type} pattern ${x.name}?`}>
726+ <input type="hidden" name="id" value={String(x.id)} />
727+ <button class="btn sm danger">Remove</button>
728+ </Form>
729+ </li>
730+ ))}
731+ </ul>
732+ ) : (
733+ <div class="empty">No patterns yet, so no branch or tag can deploy here.</div>
734+ )}
735+ <Form action={`${envPath(l, e.name)}/branch-policies`} t={t} class="body row" style="align-items:flex-end;border-top:1px solid var(--border2)">
736+ <div class="field m0" style="min-width:240px">
737+ <label for="env-pattern">Pattern</label>
738+ <input id="env-pattern" name="name" type="text" required maxlength={255} class="mono" placeholder="release/*" autocomplete="off" />
739+ </div>
740+ <div class="field m0">
741+ <KSelect name="type" label="Matches" value="branch" options={[{ value: "branch", label: "Branches" }, { value: "tag", label: "Tags" }]} />
742+ </div>
743+ <button class="btn">Add pattern</button>
744+ </Form>
745+ </div>
746+ ) : null}
747+ <div class="box">
748+ <div class="head">
749+ <h2>{raw(ICONS.key)} Environment secrets</h2>
750+ </div>
751+ {secrets.length ? (
752+ <ul class="list">
753+ {secrets.map((s) => (
754+ <li style="align-items:center">
755+ <span class="mono grow">
756+ <b>{s.name}</b>
757+ </span>
758+ <span class="muted small">updated {ago(s.updatedAt)}</span>
759+ <Form action={`${envPath(l, e.name)}/secrets/delete`} t={t} class="inline" confirm={`Delete secret ${s.name}?`}>
760+ <input type="hidden" name="name" value={s.name} />
761+ <button class="btn sm danger">Delete</button>
762+ </Form>
763+ </li>
764+ ))}
765+ </ul>
766+ ) : (
767+ <div class="empty">No environment secrets yet.</div>
768+ )}
769+ <Form action={`${envPath(l, e.name)}/secrets`} t={t} class="body fbody" style="border-top:1px solid var(--border2)">
770+ <div class="fgrid">
771+ <div class="field">
772+ <label for="env-secret-name">Name</label>
773+ <input id="env-secret-name" name="name" type="text" required pattern="[A-Za-z_][A-Za-z0-9_]*" class="mono" placeholder="DEPLOY_TOKEN" autocomplete="off" />
774+ </div>
775+ <div class="field full">
776+ <label for="env-secret-value">Value</label>
777+ <textarea id="env-secret-value" name="value" required class="mono" rows={4} autocomplete="off" spellcheck={false}></textarea>
778+ <div class="hint">
779+ Only jobs with <code>environment: {e.name}</code> receive it, as <code>{"${{ secrets.NAME }}"}</code>. Setting an existing name replaces its value.{e.policy ? "" : " Adding the first secret limits deployment branches to protected branches only."}
780+ </div>
781+ </div>
782+ </div>
783+ <div class="factions">
784+ <button class="btn primary">Save secret</button>
785+ </div>
786+ </Form>
787+ </div>
692788 <Form action={`${envPath(l, e.name)}/delete`} t={t} class="box" confirm={`Delete the ${e.name} environment, its secrets and its variables?`}>
693789 <div class="body drow">
694790 <div>
@@ -919,13 +1015,77 @@
9191015 const reviewers = await resolveReviewers(c, l.repo, logins.map((login) => ({ type: "User", login })));
9201016 const raw = f.s("wait_timer").trim();
9211017 const waitTimer = raw === "" ? 0 : Number(raw);
922- const e = await l.stub.upsertEnvironment(name, { reviewers, wait_timer: waitTimer, prevent_self_review: f.s("prevent_self_review") === "1" }, l.caller);
923- audit(c, l, "environment.update", { environment: e.name, reviewers: e.protection.reviewers.map((r) => r.login), wait_timer: e.protection.waitTimer, prevent_self_review: e.protection.preventSelfReview });
1018+ const choice = f.keys().includes("branch_policy") ? f.s("branch_policy") : undefined;
1019+ if (choice !== undefined && !["all", "protected", "selected"].includes(choice)) throw new HttpError(422, "choose which branches can deploy");
1020+ const deployment_branch_policy = choice === undefined ? undefined : choice === "all" ? null : { protected_branches: choice === "protected", custom_branch_policies: choice === "selected" };
1021+ const e = await l.stub.upsertEnvironment(name, { reviewers, wait_timer: waitTimer, prevent_self_review: f.s("prevent_self_review") === "1", deployment_branch_policy }, l.caller);
1022+ audit(c, l, "environment.update", {
1023+ environment: e.name,
1024+ reviewers: e.protection.reviewers.map((r) => r.login),
1025+ wait_timer: e.protection.waitTimer,
1026+ prevent_self_review: e.protection.preventSelfReview,
1027+ deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null,
1028+ });
9241029 },
9251030 "Protection rules saved.",
9261031 );
9271032 });
9281033
1034+ post(web, "/:owner/:repo/settings/environments/:env/branch-policies", async (c, f) => {
1035+ const l = await load(c, WHILE_IMPORTING);
1036+ const name = prm(c, "env");
1037+ return act(
1038+ c,
1039+ envPath(l, name),
1040+ async () => {
1041+ const p = await l.stub.addEnvironmentBranchPattern(name, { name: f.s("name"), type: f.s("type") }, l.caller);
1042+ audit(c, l, "environment.branch_policy.create", { environment: name, pattern: p.name, type: p.type });
1043+ },
1044+ "Pattern added.",
1045+ );
1046+ });
1047+
1048+ post(web, "/:owner/:repo/settings/environments/:env/branch-policies/delete", async (c, f) => {
1049+ const l = await load(c, WHILE_IMPORTING);
1050+ const name = prm(c, "env");
1051+ return act(c, envPath(l, name), async () => {
1052+ const id = Number(f.s("id"));
1053+ if (!Number.isInteger(id)) throw new HttpError(422, "invalid pattern");
1054+ await l.stub.removeEnvironmentBranchPattern(name, id, l.caller);
1055+ audit(c, l, "environment.branch_policy.delete", { environment: name, id });
1056+ });
1057+ });
1058+
1059+ post(web, "/:owner/:repo/settings/environments/:env/secrets", async (c, f) => {
1060+ const l = await load(c, WHILE_IMPORTING);
1061+ const env = prm(c, "env");
1062+ return act(
1063+ c,
1064+ envPath(l, env),
1065+ async () => {
1066+ const name = f.s("name").trim();
1067+ try {
1068+ await l.stub.setEnvironmentSecret(env, name, f.s("value"), l.caller);
1069+ } catch (e) {
1070+ const s = statusOf(e);
1071+ if (s.status < 500) throw e;
1072+ throw new HttpError(422, `could not store the secret: ${(e as Error).message || "encryption unavailable"} (is MASTER_KEY configured?)`);
1073+ }
1074+ audit(c, l, "environment.secret.set", { environment: env, name: name.toUpperCase() });
1075+ },
1076+ "Secret saved.",
1077+ );
1078+ });
1079+
1080+ post(web, "/:owner/:repo/settings/environments/:env/secrets/delete", async (c, f) => {
1081+ const l = await load(c, WHILE_IMPORTING);
1082+ const env = prm(c, "env");
1083+ return act(c, envPath(l, env), async () => {
1084+ await l.stub.deleteEnvironmentSecret(env, f.s("name"), l.caller);
1085+ audit(c, l, "environment.secret.delete", { environment: env, name: f.s("name").toUpperCase() });
1086+ });
1087+ });
1088+
9291089 post(web, "/:owner/:repo/settings/environments/:env/delete", async (c) => {
9301090 const l = await load(c, WHILE_IMPORTING);
9311091 const name = prm(c, "env");
@@ -1029,6 +1189,19 @@
10291189 });
10301190 });
10311191
1192+ post(web, "/:owner/:repo/settings/delete-closed-pulls", async (c, f) => {
1193+ const l = await load(c);
1194+ requireAccount(c);
1195+ const full = `${l.repo.owner_login}/${l.repo.name}`;
1196+ return act(c, `${repoUrl(l.repo)}/settings`, async () => {
1197+ if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm`);
1198+ requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`);
1199+ const r = await l.stub.deleteClosedPullRequests(l.caller);
1200+ audit(c, l, "repo.delete_closed_pulls", { pull_requests: r.pullRequests, objects: r.objects, bytes: r.bytes });
1201+ flash(c, "notice", r.pullRequests ? `Deleted ${r.pullRequests} closed pull request${r.pullRequests === 1 ? "" : "s"} and purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.` : `No closed pull requests to delete; purged ${r.objects} unreachable object${r.objects === 1 ? "" : "s"}.`);
1202+ });
1203+ });
1204+
10321205 post(web, "/:owner/:repo/settings/transfer", async (c, f) => {
10331206 const l = await load(c, { whileArchived: true });
10341207 const me = requireAccount(c);
modifiedapps/diffinery/src/web/site.tsx+93−25View file
@@ -97,9 +97,27 @@
9797 { icon: "pr", title: "Pull requests with intent", text: "Reviews, required approvals and checks, auto-merge and a merge queue. Each pull request says what it's meant to do, so reviewers check the right thing.", href: "/features#pull-requests-reviews-and-issues" },
9898 { icon: "play", title: "GitHub Actions–compatible CI", text: "The same triggers, expressions, matrices, reusable workflows, secrets, OIDC and Docker. Actions from GitHub run as they do there.", href: "/features#actions-ci-that-runs-your-github-workflows" },
9999 { icon: "zap", title: "Fast, right-sized runners", text: "4 vCPUs and 12 GiB per job by default, started in seconds. Jobs that only wait move to smaller runners on their own, never at the cost of speed.", href: "/performance" },
100- { icon: "bot", title: "AI agents with guardrails", text: "Agents get their own accounts and tokens, an MCP server, sandboxes, tasks and reviews. They can't push to protected branches or reach your secrets.", href: "/agents" },
100+ { icon: "bot", title: "AI agents with guardrails", text: "Agents get their own accounts and tokens, run tasks with Claude Code or Codex, review pull requests and merge on green. They can't push to protected branches or reach your secrets.", href: "/agents" },
101101 { icon: "shield", title: "Secure by default", text: "Passkeys instead of passwords, invite-only sign-up, encrypted secrets, scoped job tokens and an append-only audit log.", href: "/security" },
102102 ],
103+ /** What agents do on Diffinery: the home page's "Built for AI coding agents" cards, its Markdown and the structured data. */
104+ agents: [
105+ { icon: "shield", title: "Agent accounts with guardrails", text: "Each agent has its own login and tokens, owned by a person or an organization. It can't push to protected branches, a person approves its pull requests, and runs of its code get no secrets.", href: "/docs/agents/overview#guardrails" },
106+ { icon: "bot", title: "Agent tasks", text: "Give an agent a goal: Claude Code or Codex works in a sandbox with your own model key and opens a pull request. Plan first, best of N attempts, and fixes for its own failing checks.", href: "/docs/agents/tasks" },
107+ { icon: "eye", title: "Agent review", text: "An agent reviews every pull request for bugs, security issues and logic errors, posts inline findings with suggested fixes and reports an Agent review check.", href: "/docs/agents/review" },
108+ { icon: "history", title: "Today and Needs you", text: "One page each morning: what needs you, what your agents are working on, and what merged or failed since yesterday, across every repository.", href: "/docs/agents/today" },
109+ { icon: "prMerged", title: "Merge on green", text: "Pull requests merge by themselves once their checks pass, with or without a person's approval, as you choose. An agent's change to a workflow file still needs a person to merge it.", href: "/docs/collaboration/merge-on-green" },
110+ { icon: "key", title: "Your own subscription", text: "Open a terminal in the browser, sign in to Claude Code or Codex with your own Claude or ChatGPT plan, and give it tasks that open pull requests.", href: "/docs/agents/terminal" },
111+ ],
112+ /** The tools Diffinery plugs into: the home page's integrations, its Markdown and the structured data. */
113+ integrations: [
114+ { icon: "bot", title: "Claude Code plugin", text: "The MCP server, a skill for the branch → pull request → CI → merge loop, /diffinery:pr-status, and a live band of the pull request's CI checks above the prompt.", install: "claude plugin marketplace add codician-team/diffinery", href: "/docs/connect/claude-code" },
115+ { icon: "code", title: "Codex plugin", text: "The same MCP server, skills and session hook for OpenAI's Codex CLI.", install: "codex plugin marketplace add https://diffinery.com/codician/diffinery.git", href: "/docs/connect/codex" },
116+ { icon: "link", title: "MCP server, in the official registry", text: "52 tools for code, pull requests, CI logs, sandboxes, Today and the docs, with a browser sign-in, for Claude, Claude Code, Codex, Cursor and any MCP client. Listed as com.diffinery/diffinery.", install: "https://<your host>/mcp", href: "/docs/agents/mcp" },
117+ { icon: "play", title: "The diffinery CLI and gh", text: "Pull requests with intent, auto-merge, runs and logs, runners and migrations. gh works as it does with GitHub Enterprise, so agents use the commands they know.", install: "curl -fsSL https://diffinery.com/install | bash", href: "/docs/agents/github-cli" },
118+ { icon: "repo", title: "VS Code, Cursor and Windsurf", text: "Clone from Diffinery with a passkey sign-in, and push and pull with it.", install: "https://diffinery.com/install-editor", href: "/docs/repositories/clone-in-your-editor" },
119+ { icon: "package", title: "JetBrains IDEs", text: "Get from Version Control with a passkey sign-in, in IntelliJ IDEA, PyCharm, WebStorm and the rest (2024.3 or later).", install: "https://diffinery.com/-/jetbrains/updatePlugins.xml", href: "/docs/repositories/clone-in-jetbrains-ides" },
120+ ],
103121 workflow: `name: CI
104122 on: [push, pull_request]
105123 jobs:
@@ -158,8 +176,12 @@
158176 a: "Yes, under the Apache License 2.0, with no paid edition and nothing sold. The source code is at [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and mirrored to [github.com/codician-team/diffinery](https://github.com/codician-team/diffinery). See [Open source](/open-source).",
159177 },
160178 {
179+ q: "Can I try Diffinery without deploying it?",
180+ a: "Yes. [Request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance: its administrators review requests and email invitations, and you try Diffinery there. To run Diffinery for your team, deploy your own instance. See [Early access](/docs/accounts/early-access).",
181+ },
182+ {
161183 q: "How do I start using Diffinery, and what does it cost?",
162- a: "Deploy your own instance to your Cloudflare account and domain with one command, `scripts/setup.sh`, then sign in as its first administrator and invite your team. See [Deploy your own Diffinery](/docs/self-hosting/deploy). Diffinery is free; you pay Cloudflare for what your instance uses, on the Workers Paid plan (a minimum of $5 USD per month per account, plus usage; [Cloudflare's pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). There's no hosted service to sign up for: diffinery.com is the project's own instance.",
184+ a: "Deploy your own instance to your Cloudflare account and domain with one command, `scripts/setup.sh`, then sign in as its first administrator and invite your team. See [Deploy your own Diffinery](/docs/self-hosting/deploy). Diffinery is free; you pay Cloudflare for what your instance uses, on the Workers Paid plan (a minimum of $5 USD per month per account, plus usage; [Cloudflare's pricing](https://developers.cloudflare.com/workers/platform/pricing/), checked October 25, 2026). There's no hosted service to sign up for; to try Diffinery first, [request early access](https://diffinery.com/early-access) to diffinery.com, the project's own instance.",
163185 },
164186 ],
165187 };
@@ -183,6 +205,16 @@
183205 "",
184206 ...HOME.features.map((f) => `- **[${f.title}](${f.href})**: ${f.text}`),
185207 "",
208+ "## Built for AI coding agents",
209+ "",
210+ AGENTS_LEAD,
211+ "",
212+ ...HOME.agents.map((f) => `- **[${f.title}](${f.href})**: ${f.text}`),
213+ "",
214+ "## Works where you and your agents already work",
215+ "",
216+ ...HOME.integrations.map((f) => `- **[${f.title}](${f.href})**: ${f.text} \`${f.install}\``),
217+ "",
186218 "## Your workflows, as they are",
187219 "",
188220 "```yaml",
@@ -199,7 +231,7 @@
199231 "",
200232 "## Open source, on your own Cloudflare account",
201233 "",
202- `Diffinery is open source under the Apache License 2.0: [source code](${SOURCE.repo}), [mirror on GitHub](${SOURCE.github}). Deploy it with [Deploy your own Diffinery](/docs/self-hosting/deploy); you pay Cloudflare only for what your instance uses. See [Open source](/open-source).`,
234+ `Diffinery is open source under the Apache License 2.0: [source code](${SOURCE.repo}), [mirror on GitHub](${SOURCE.github}). Deploy it with [Deploy your own Diffinery](/docs/self-hosting/deploy); you pay Cloudflare only for what your instance uses. To try it first, [request early access](${EARLY_ACCESS}) to diffinery.com. See [Open source](/open-source).`,
203235 "",
204236 "## Frequently asked questions",
205237 "",
@@ -208,6 +240,9 @@
208240 return absolute(lines.join("\n"), o);
209241 }
210242
243+const AGENTS_LEAD =
244+ "Claude Code, Codex and other agents work through Git, the GitHub CLI, the REST API or the built-in MCP server, as accounts of their own, and Diffinery can run them for you. Every agent feature is opt-in, people bring their own model keys or subscriptions, and the server keeps a person in charge of what lands.";
245+
211246 /** Everything the home page says, for IndexNow (web/indexnow.ts): its Markdown and the landing page's own copy. */
212247 export async function homeContent(o: string): Promise<string> {
213248 return `${homeMarkdown(o)}\n${await (<Landing />).toString()}`;
@@ -228,6 +263,9 @@
228263 spdx: "Apache-2.0",
229264 };
230265
266+/** Where to ask to try Diffinery on the project's own instance, whatever instance serves this page (web/early-access.tsx). */
267+export const EARLY_ACCESS = "https://diffinery.com/early-access";
268+
231269 /** schema.org ids for the site's entities, shared by every page's graph. */
232270 const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` });
233271
@@ -245,7 +283,12 @@
245283 applicationSubCategory: "Git hosting and continuous integration",
246284 operatingSystem: "Web",
247285 description: HOME.lead,
248- featureList: [...HOME.features.map((f) => `${f.title}: ${f.text}`), `Runs entirely on Cloudflare: ${CLOUDFLARE_CLAIM}`, `Open source under the Apache License 2.0, deployed to your own Cloudflare account`],
286+ featureList: [
287+ ...HOME.features.map((f) => `${f.title}: ${f.text}`),
288+ ...HOME.agents.map((f) => `${f.title}: ${f.text}`),
289+ ...HOME.integrations.map((f) => `${f.title}: ${f.text}`),
290+ `Runs entirely on Cloudflare: ${CLOUDFLARE_CLAIM}`, `Open source under the Apache License 2.0, deployed to your own Cloudflare account`,
291+ ],
249292 license: SOURCE.license,
250293 isAccessibleForFree: true,
251294 offers: { "@type": "Offer", price: "0", priceCurrency: "USD", description: "Free, open-source software. Your instance's Cloudflare usage is billed by Cloudflare." },
@@ -316,12 +359,12 @@
316359 <a class="btn primary" href="/docs/self-hosting">
317360 Deploy your own
318361 </a>
362+ <a class="btn" href={EARLY_ACCESS} title="Try Diffinery on diffinery.com before you deploy your own">
363+ Request early access
364+ </a>
319365 <a class="btn" href={SOURCE.repo}>
320366 View the source
321367 </a>
322- <a class="btn" href="/docs">
323- Read the docs
324- </a>
325368 </>
326369 );
327370
@@ -342,7 +385,7 @@
342385 <Cta />
343386 </p>
344387 <p class="muted small">
345- Free, with nothing to buy: you run Diffinery on your own Cloudflare account. Source on <a href={SOURCE.repo}>Diffinery</a> and <a href={SOURCE.github}>GitHub</a>.
388+ Free, with nothing to buy: you run Diffinery on your own Cloudflare account. Want to try it first? <a href={EARLY_ACCESS}>Request early access</a> to diffinery.com. Source on <a href={SOURCE.repo}>Diffinery</a> and <a href={SOURCE.github}>GitHub</a>.
346389 </p>
347390 </section>
348391 <ul class="mk-stats" aria-label="Diffinery in numbers">
@@ -382,27 +425,47 @@
382425 {/* One line: whitespace inside <pre> shows. */}
383426 {raw(`<pre class="mk-code" aria-label="A GitHub Actions workflow that runs on Diffinery unchanged"><code>${escapeHtml(HOME.workflow)}</code></pre>`)}
384427 </section>
385- <section class="mk-sec">
428+ <section class="mk-sec" id="agents">
386429 <h2>Built for AI coding agents</h2>
387- <p class="mk-wide">Claude Code, Codex and other agents work through Git, the GitHub CLI, the REST API or the built-in MCP server, as accounts of their own. The server keeps a person in charge of what lands.</p>
430+ <p class="mk-wide">{AGENTS_LEAD}</p>
388431 <div class="features">
389- <div class="box mk-card">
390- <h3>{raw(ICONS.bot)} Agent accounts</h3>
391- <p class="muted m0">Their own logins and tokens, owned by a person or organization, with every commit stamped with who they act for.</p>
392- </div>
393- <div class="box mk-card">
394- <h3>{raw(ICONS.shield)} Guardrails</h3>
395- <p class="muted m0">No pushes to protected branches, a person's approval on every agent pull request, and no secrets for agent-written code.</p>
396- </div>
397- <div class="box mk-card">
398- <h3>{raw(ICONS.code)} MCP, sandboxes and tasks</h3>
399- <p class="muted m0">One URL connects any MCP app. Agents get sandboxes to build and test in, and tasks turn a goal into a pull request.</p>
400- </div>
432+ {HOME.agents.map((f) => (
433+ <a class="box mk-card" href={f.href}>
434+ <h3>
435+ {raw(ICONS[f.icon as keyof typeof ICONS])} {f.title}
436+ </h3>
437+ <p class="muted m0">{f.text}</p>
438+ </a>
439+ ))}
401440 </div>
441+ <figure class="mk-shot">
442+ <a href="/docs/agents/today">
443+ <img src="/docs/images/today.webp" width="1280" height="1000" loading="lazy" alt="The Today page on Diffinery: what needs you, the agent pull requests at work, your agent tasks and what changed since yesterday" />
444+ </a>
445+ <figcaption class="muted small">Today, the first page after you sign in: an agent pull request waiting for a person, the agents at work and the task that opened it.</figcaption>
446+ </figure>
402447 <p>
403448 <a href="/agents">Diffinery for AI agents</a> · <a href="/docs/connect">Connect Claude, Claude Code or Codex</a>
404449 </p>
405450 </section>
451+ <section class="mk-sec" id="integrations">
452+ <h2>Works where you and your agents already work</h2>
453+ <p class="mk-wide">Plugins and extensions for the tools you use, all with a browser sign-in, for diffinery.com or your own instance.</p>
454+ <div class="features">
455+ {HOME.integrations.map((f) => (
456+ <a class="box mk-card" href={f.href}>
457+ <h3>
458+ {raw(ICONS[f.icon as keyof typeof ICONS])} {f.title}
459+ </h3>
460+ <p class="muted">{f.text}</p>
461+ <code class="mk-install">{f.install}</code>
462+ </a>
463+ ))}
464+ </div>
465+ <p>
466+ <a href="/docs/connect">Connect AI apps</a> · <a href="/features#works-with-the-tools-you-already-use">Every integration</a>
467+ </p>
468+ </section>
406469 <section class="mk-sec">
407470 <h2>How Diffinery compares</h2>
408471 <div class="table-wrap">
@@ -443,7 +506,7 @@
443506 </section>
444507 <section class="mk-band">
445508 <h2>Run Diffinery on your own Cloudflare account</h2>
446- <p class="muted">Open source under Apache-2.0. Your workflows, your editor and gh keep working, your agents get guardrails, and you pay Cloudflare only for what you use.</p>
509+ <p class="muted">Open source under Apache-2.0. Your workflows, your editor and gh keep working, your agents get guardrails, and you pay Cloudflare only for what you use. Not ready to deploy? Request early access and try it on diffinery.com first.</p>
447510 <p class="row" style="justify-content:center">
448511 <Cta />
449512 </p>
@@ -564,7 +627,7 @@
564627 <div class="markdown mk-body">{raw(html)}</div>
565628 <aside class="mk-band">
566629 <h2>Run Diffinery yourself</h2>
567- <p class="muted">Open source under Apache-2.0, deployed to your own Cloudflare account.</p>
630+ <p class="muted">Open source under Apache-2.0, deployed to your own Cloudflare account. Or request early access and try it on diffinery.com first.</p>
568631 <p class="row" style="justify-content:center">
569632 <Cta />
570633 </p>
@@ -598,10 +661,12 @@
598661 "",
599662 `- [Home page](${o}/index.md): ${HOME.description}`,
600663 `- Open source under the Apache License 2.0 (SPDX: ${SOURCE.spdx}). Source code: ${SOURCE.repo}, mirrored to ${SOURCE.github}. Free, with no paid edition and nothing sold.`,
601- `- How to use it: deploy your own instance to your own Cloudflare account and domain (Workers Paid plan); you pay Cloudflare for what it uses. Guide: ${o}/docs/self-hosting/deploy.md. Overview: ${o}/docs/self-hosting.md. There's no hosted service for other teams: diffinery.com is the project's home and its own instance, with accounts by invitation for the people working on Diffinery.`,
664+ `- How to use it: deploy your own instance to your own Cloudflare account and domain (Workers Paid plan); you pay Cloudflare for what it uses. Guide: ${o}/docs/self-hosting/deploy.md. Overview: ${o}/docs/self-hosting.md. There's no hosted service for teams to sign up for: diffinery.com is the project's home and its own instance, with accounts by invitation. To try Diffinery before deploying your own, request early access at ${EARLY_ACCESS} (how it works: ${o}/docs/accounts/early-access.md).`,
602665 `- Runs entirely on Cloudflare (Workers, Durable Objects, R2, D1, Queues, Workflows, Containers): code, pull requests, CI runners and the agent features alike. It is ${CLOUDFLARE_CLAIM} See ${o}/built-on-cloudflare.md, which compares the other Git and CI projects on Cloudflare.`,
603666 "- Hosted CI runners: Ubuntu 24.04 x64 containers, 4 vCPU and 12 GiB of memory by default, with smaller sizes chosen per job from its measured runs. No hosted macOS, Windows or Arm runners; self-hosted runners cover them.",
604667 "- Runs GitHub Actions workflow files from `.github/workflows/` as they are; differences are listed at " + `${o}/docs/actions/compatibility.md`,
668+ `- Built for AI coding agents (${o}/agents.md): agent accounts with server-enforced guardrails (${o}/docs/agents/overview.md); agent tasks that run Claude Code or Codex in a sandbox with the person's own model key and open a pull request (${o}/docs/agents/tasks.md); agent review with inline findings and an Agent review check (${o}/docs/agents/review.md); a Today page of what needs you (${o}/docs/agents/today.md); merge on green (${o}/docs/collaboration/merge-on-green.md); browser terminals where Claude Code or Codex runs on the person's own Claude or ChatGPT plan (${o}/docs/agents/terminal.md). All opt-in; Diffinery doesn't sell or include model access.`,
669+ `- Plugins and integrations: Claude Code and Codex plugins (marketplace codician-team/diffinery on GitHub, or ${SOURCE.repo}.git), an MCP server listed in the official MCP Registry as com.diffinery/diffinery (https://{diffinery_host}/mcp), the diffinery CLI, gh through the GitHub-compatible REST and GraphQL APIs, and extensions for VS Code, Cursor, Windsurf and JetBrains IDEs. Guides: ${o}/docs/connect.md`,
605670 "",
606671 ];
607672 for (const s of SITE_NAV) {
@@ -652,6 +717,9 @@
652717 .mk-table thead th{font-weight:600;border-bottom:1px solid var(--border)}.mk-table thead th:nth-child(2){color:var(--accent)}
653718 .mk-table tbody th{font-weight:500;color:var(--fg2)}
654719 .mk-faq .mk-q{border-top:1px solid var(--border2);padding:14px 0}.mk-faq h3{font-size:16px;margin:0 0 6px}.mk-faq p{margin:0;color:var(--fg2);line-height:1.65;max-width:860px}
720+.mk-install{display:block;font-size:12px;overflow-wrap:anywhere;margin-top:auto}
721+.mk-card:has(.mk-install){display:flex;flex-direction:column}
722+.mk-shot{margin:20px 0 12px}.mk-shot img{display:block;width:100%;height:auto;border:1px solid var(--border);border-radius:var(--r)}.mk-shot figcaption{margin-top:8px}
655723 .mk-band{margin:56px 0 0;padding:36px 20px;text-align:center;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2)}
656724 .mk-band p{margin:0 auto 14px;max-width:620px}
657725 .mk-page{max-width:880px;margin:0 auto}
modifiedapps/diffinery/src/web/ui.tsx+1−1View file
@@ -450,7 +450,7 @@
450450 { title: "Product", links: [["/features", "Features"], ["/agents", "For AI agents"], ["/performance", "Performance"], ["/security", "Security"], ["/built-on-cloudflare", "Built on Cloudflare"], ["/open-source", "Open source"], ["/docs/migrate-from-github", "Migrate from GitHub"]] },
451451 { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] },
452452 { title: "Resources", links: [["/docs", "Docs"], ["/docs/self-hosting", "Self-hosting"], ["/docs/guides", "Guides"], ["/docs/connect", "Connect AI apps"], ["/llms.txt", "llms.txt"], ["/status", "Status"], ["/docs/security/disclosure", "Report a vulnerability"]] },
453- { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], ["https://github.com/codician-team/diffinery", "GitHub mirror"]] },
453+ { title: "Company", links: [["/about", "About"], ["/faq", "FAQ"], ["/blog", "Blog"], ["https://diffinery.com/codician/diffinery", "Source code"], ["https://github.com/codician-team/diffinery", "GitHub mirror"], ["/privacy", "Privacy"]] },
454454 ].map((col) => (
455455 <nav aria-label={col.title}>
456456 <h2>{col.title}</h2>
modifiedapps/diffinery/test/e2e/e2e.test.mjs+33−0View file
@@ -2299,6 +2299,39 @@
22992299 assert.ok((await gh.rest.repos.getAllEnvironments(R)).data.environments.some((e) => e.name === "staging"));
23002300 await gh.rest.actions.createEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION", value: "eu" });
23012301 assert.equal((await gh.rest.actions.getEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION" })).data.value, "eu");
2302+ // The environment page sets deployment branches, their patterns and the environment's secrets.
2303+ const envUrl = `${base}/${owner}/${repo}/settings/environments/staging`;
2304+ const envForm = async (path, fields) => {
2305+ const pageRes = await fetch(envUrl);
2306+ const html = await pageRes.text();
2307+ const r = await fetch(`${envUrl}${path}`, {
2308+ method: "POST",
2309+ body: new URLSearchParams({ _csrf: /name="_csrf" value="([^"]+)"/.exec(html)[1], ...fields }),
2310+ redirect: "manual",
2311+ headers: { cookie: `dfy_csrf=${/dfy_csrf=([^;]+)/.exec(pageRes.headers.get("set-cookie") ?? "")[1]}`, "sec-fetch-site": "same-origin" },
2312+ });
2313+ assert.equal(r.status, 303, await r.text());
2314+ return html;
2315+ };
2316+ const envState = async () => (await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}", { ...R, environment_name: "staging" })).data.deployment_branch_policy;
2317+ const envSecrets = async () => (await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}/secrets", { ...R, environment_name: "staging" })).data.secrets.map((x) => x.name);
2318+ assert.match(await envForm("/secrets", { name: "deploy_key", value: "env-s3cret" }), /<input type="hidden" name="branch_policy" value="all"\/>/);
2319+ assert.deepEqual(await envSecrets(), ["DEPLOY_KEY"]);
2320+ assert.deepEqual(await envState(), { protected_branches: true, custom_branch_policies: false }, "the first secret limits the environment to protected branches");
2321+ await envForm("", { reviewers: "", wait_timer: "0", branch_policy: "selected" });
2322+ assert.deepEqual(await envState(), { protected_branches: false, custom_branch_policies: true });
2323+ await envForm("/branch-policies", { name: "release/*", type: "tag" });
2324+ const patterns = (await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies", { ...R, environment_name: "staging" })).data.branch_policies;
2325+ assert.deepEqual(patterns.map((x) => [x.name, x.type]), [["release/*", "tag"]]);
2326+ const envHtml = await envForm("/branch-policies/delete", { id: String(patterns[0].id) });
2327+ assert.match(envHtml, /release\/\*/);
2328+ assert.match(envHtml, /DEPLOY_KEY/);
2329+ assert.doesNotMatch(envHtml, /env-s3cret/);
2330+ assert.equal((await gh.request("GET /repos/{owner}/{repo}/environments/{environment_name}/deployment-branch-policies", { ...R, environment_name: "staging" })).data.total_count, 0);
2331+ await envForm("", { reviewers: "", wait_timer: "0", branch_policy: "all" });
2332+ assert.equal(await envState(), null);
2333+ await envForm("/secrets/delete", { name: "DEPLOY_KEY" });
2334+ assert.deepEqual(await envSecrets(), []);
23022335 await gh.rest.repos.deleteAnEnvironment({ ...R, environment_name: "staging" });
23032336
23042337 // ------------------------------------------------------------ repository_dispatch and repository variables
modifiedapps/diffinery/test/e2e/security.test.mjs+52−0View file
@@ -2095,6 +2095,58 @@
20952095 assert.equal((await ok("GET", `${R}/branches/main`)).commit.sha, g(["rev-parse", "HEAD"]));
20962096 });
20972097
2098+test("security: an admin can start over by deleting closed pull requests and the commits only they keep", async () => {
2099+ const R = `/repos/${OWNER}/fresh-start`;
2100+ const first = await repoWithReadme("fresh-start");
2101+ await collaborator(R, "rita", "write", T.rita);
2102+ const merged = await pullFrom(R, first, "merged-change");
2103+ await ok("PUT", `${R}/pulls/${merged.number}/merge`, { merge_method: "squash" });
2104+ const closed = await pullFrom(R, first, "closed-change");
2105+ const comment = await ok("POST", `${R}/issues/${closed.number}/comments`, { body: "not this one" });
2106+ await status(201, "POST", `${R}/issues/comments/${comment.id}/reactions`, { content: "+1" });
2107+ await status(201, "POST", `${R}/issues/${closed.number}/reactions`, { content: "eyes" });
2108+ await ok("PATCH", `${R}/pulls/${closed.number}`, { state: "closed" });
2109+ const open = await pullFrom(R, first, "open-change");
2110+ const issue = await ok("POST", `${R}/issues`, { title: "Keep me" });
2111+
2112+ // Fresh history: main back to the first commit, the old branches gone. The closed pull
2113+ // requests still keep their commits fetchable, even through a purge.
2114+ await ok("PATCH", `${R}/git/refs/heads/main`, { sha: first, force: true });
2115+ await status(204, "DELETE", `${R}/git/refs/heads/merged-change`);
2116+ await status(204, "DELETE", `${R}/git/refs/heads/closed-change`);
2117+ await ok("POST", `${R}/diffinery/purge-unreachable`);
2118+ await ok("GET", `${R}/git/commits/${closed.head.sha}`);
2119+ await ok("GET", `${R}/git/commits/${merged.head.sha}`);
2120+
2121+ // People with the admin role only: not writers, not agents.
2122+ await status(403, "POST", `${R}/diffinery/delete-closed-pulls`, undefined, T.rita);
2123+ await status(403, "POST", `${R}/diffinery/delete-closed-pulls`, undefined, T.bot);
2124+ const r = await ok("POST", `${R}/diffinery/delete-closed-pulls`);
2125+ assert.equal(r.pull_requests, 2, JSON.stringify(r));
2126+ assert.ok(r.objects >= 4 && r.bytes > 0, JSON.stringify(r)); // both heads with their trees and files, and the squash commit
2127+ for (const n of [merged.number, closed.number]) {
2128+ await status(404, "GET", `${R}/pulls/${n}`);
2129+ await status(404, "GET", `${R}/issues/${n}`);
2130+ }
2131+ await status(404, "GET", `${R}/issues/comments/${comment.id}`);
2132+ for (const sha of [closed.head.sha, merged.head.sha]) await status(404, "GET", `${R}/git/commits/${sha}`);
2133+ const authed = `${base.replace("://", `://x:${T.olive}@`)}/${OWNER}/fresh-start.git`;
2134+ const refs = execFileSync("git", ["ls-remote", authed], { stdio: "pipe" }).toString();
2135+ assert.ok(!refs.includes(`refs/pull/${closed.number}/`) && !refs.includes(`refs/pull/${merged.number}/`), refs);
2136+ assert.ok(refs.includes(`refs/pull/${open.number}/head`), refs);
2137+
2138+ // The open pull request, the issue and main stay, and numbers aren't reused.
2139+ assert.deepEqual((await ok("GET", `${R}/pulls?state=all`)).map((p) => p.number), [open.number]);
2140+ await ok("GET", `${R}/git/commits/${open.head.sha}`);
2141+ assert.equal((await ok("GET", `${R}/issues/${issue.number}`)).title, "Keep me");
2142+ assert.equal((await ok("GET", `${R}/branches/main`)).commit.sha, first);
2143+ const next = await ok("POST", `${R}/issues`, { title: "Next" });
2144+ assert.equal(next.number, issue.number + 1);
2145+ await until("the audit entry", async () => auditLog("repo.delete_closed_pulls").some((e) => e.target === `${OWNER}/fresh-start` && JSON.parse(e.detail).pull_requests === 2));
2146+ // Nothing left to delete the second time.
2147+ assert.equal((await ok("POST", `${R}/diffinery/delete-closed-pulls`)).pull_requests, 0);
2148+});
2149+
20982150 test("security: webhooks reach only public hosts other than Diffinery, at most 20 per repository", async () => {
20992151 const R = `/repos/${OWNER}/hooked`;
21002152 await repoWithReadme("hooked");
addedapps/diffinery/test/unit/claude-plugins.test.ts+49−0View file
@@ -1,0 +1,49 @@
1+import { createHash } from "node:crypto";
2+import { readdirSync, readFileSync } from "node:fs";
3+import { describe, expect, it } from "vitest";
4+import { pluginZipFor } from "../../src/docs/claude-plugins.js";
5+import { unzip } from "../../src/lib/archive.js";
6+
7+const dec = new TextDecoder();
8+const dir = new URL("../../claude-plugins/", import.meta.url);
9+
10+/** The checked-in zip of one plugin (claude-plugins/<name>-<version>.zip), as the build embeds it. */
11+function packed(name: string): { bytes: Uint8Array; sha256: string } {
12+ const file = readdirSync(dir).find((f) => new RegExp(`^${name}-\\d+\\.\\d+\\.\\d+\\.zip$`).test(f))!;
13+ const bytes = new Uint8Array(readFileSync(new URL(file, dir)));
14+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
15+}
16+
17+const files = async (bytes: Uint8Array) =>
18+ Object.fromEntries((await unzip(bytes)).map((e) => ["dir" in e ? e.path : e.path, "dir" in e ? "" : dec.decode(e.content)]));
19+
20+describe("Claude Code plugins served by an instance", () => {
21+ for (const name of ["diffinery", "diffinery-ci"]) {
22+ it(`${name}: diffinery.com serves the packed zip as it is`, async () => {
23+ const p = packed(name);
24+ const out = await pluginZipFor("https://diffinery.com", p.bytes, p.sha256);
25+ expect(out.bytes).toBe(p.bytes);
26+ expect(out.sha256).toBe(p.sha256);
27+ });
28+
29+ it(`${name}: a self-hosted instance defaults the Diffinery URL setting to itself and changes nothing else`, async () => {
30+ const p = packed(name);
31+ const before = await files(p.bytes);
32+ const out = await pluginZipFor("https://git.example.com", p.bytes, p.sha256);
33+ expect(out.sha256).toBe(createHash("sha256").update(out.bytes).digest("hex"));
34+ const after = await files(out.bytes);
35+ expect(Object.keys(after)).toEqual(Object.keys(before));
36+ const manifest = JSON.parse(after[".claude-plugin/plugin.json"]!);
37+ expect(manifest.userConfig.url.default).toBe("https://git.example.com");
38+ expect({ ...manifest, userConfig: undefined }).toEqual({ ...JSON.parse(before[".claude-plugin/plugin.json"]!), userConfig: undefined });
39+ for (const f of Object.keys(before)) if (f !== ".claude-plugin/plugin.json") expect(after[f]).toBe(before[f]);
40+ // The same instance gets the same bytes every time (a fixed timestamp, as pack-plugins.py writes).
41+ expect((await pluginZipFor("https://git.example.com", p.bytes, p.sha256)).sha256).toBe(out.sha256);
42+ });
43+ }
44+
45+ it("the diffinery plugin's MCP server follows the setting", async () => {
46+ const mcp = JSON.parse((await files(packed("diffinery").bytes))[".mcp.json"]!);
47+ expect(mcp.mcpServers.diffinery.url).toBe("${user_config.url}/mcp");
48+ });
49+});
addedapps/diffinery/test/unit/mcp-registry-auth.test.ts+20−0View file
@@ -1,0 +1,20 @@
1+import { describe, expect, it } from "vitest";
2+import { mcpRegistryAuth } from "../../src/http/mcp-registry-auth.js";
3+
4+describe("mcp-registry-auth", () => {
5+ const record = "v=MCPv1; k=ed25519; p=HbHhQYSP5tE1guSK2Df9MqxTJ8F+ojzjAesiFpgABTU=";
6+
7+ it("serves the instance's own record, and nothing when it is missing or malformed", () => {
8+ expect(mcpRegistryAuth({ PUBLIC_URL: "https://git.example.com", MCP_REGISTRY_AUTH: ` ${record} ` })).toBe(record);
9+ expect(mcpRegistryAuth({ PUBLIC_URL: "https://git.example.com", MCP_REGISTRY_AUTH: "v=MCPv1; k=ecdsap384; p=AbC+/9==" })).toBe("v=MCPv1; k=ecdsap384; p=AbC+/9==");
10+ for (const MCP_REGISTRY_AUTH of [undefined, "", " ", "v=MCPv1; k=rsa; p=AAAA", "v=MCPv1; k=ed25519; p=", `${record}\nextra`, "<html>"]) {
11+ expect(mcpRegistryAuth({ PUBLIC_URL: "https://git.example.com", MCP_REGISTRY_AUTH })).toBeNull();
12+ }
13+ });
14+
15+ it("serves diffinery.com's built-in record only on diffinery.com", () => {
16+ expect(mcpRegistryAuth({ PUBLIC_URL: "https://diffinery.com" })).toMatch(/^v=MCPv1; k=ed25519; p=[A-Za-z0-9+/]{43}=$/);
17+ expect(mcpRegistryAuth({ PUBLIC_URL: "https://diffinery.com.evil.example" })).toBeNull();
18+ expect(mcpRegistryAuth({ PUBLIC_URL: "http://localhost:8787" })).toBeNull();
19+ });
20+});
modifiedapps/diffinery/test/unit/site.test.ts+11−2View file
@@ -9,6 +9,7 @@
99 import { web } from "../../src/web/routes.js";
1010 import { slugify } from "../../src/web/markdown.js";
1111 import type { Env } from "../../src/env.js";
12+import { DOCS_IMAGES } from "../../src/docs/content.generated.js";
1213 import { getPage, llmsFullTxt, llmsTxt } from "../../src/docs/pages.js";
1314 import { CLOUDFLARE_CLAIM, faqs, homeMarkdown, sitePage, sitePages } from "../../src/web/site.js";
1415 import { RESERVED_LOGINS } from "../../src/lib/util.js";
@@ -53,6 +54,11 @@
5354 continue;
5455 }
5556 const [path, frag] = href.split("#") as [string, string | undefined];
57+ // Screenshots, served from docs/images (scripts/build-docs.mjs checks them).
58+ if (path.startsWith("/docs/images/")) {
59+ if (!(path.slice("/docs/images/".length) in DOCS_IMAGES)) problems.push(`no image ${path}`);
60+ continue;
61+ }
5662 if (path === "/docs" || path.startsWith("/docs/")) {
5763 if (OTHER_PATHS.some((re) => re.test(path))) continue;
5864 const doc = getPage(path === "/docs" ? "index" : path.slice(6), ORIGIN);
@@ -158,11 +164,13 @@
158164 expect(html).toContain("Code hosting and CI for the agentic era</h1>");
159165 const g = graph(html);
160166 expect(types(g)).toEqual(expect.arrayContaining(["Organization", "WebSite", "SoftwareApplication", "SoftwareSourceCode", "WebPage", "FAQPage"]));
161- // Open source: the license and the repositories, and no early access or pricing.
167+ // Open source: the license and the repositories, no pricing, and early access to try it on diffinery.com.
162168 const source = g.find((n) => n["@type"] === "SoftwareSourceCode")!;
163169 expect(source).toMatchObject({ codeRepository: "https://diffinery.com/codician/diffinery", license: "https://www.apache.org/licenses/LICENSE-2.0", sameAs: ["https://github.com/codician-team/diffinery"] });
164170 expect(g.find((n) => n["@type"] === "SoftwareApplication")).toMatchObject({ license: "https://www.apache.org/licenses/LICENSE-2.0", isAccessibleForFree: true, installUrl: `${ORIGIN}/docs/self-hosting/deploy`, downloadUrl: "https://diffinery.com/codician/diffinery/releases" });
165- expect(html).not.toContain('href="/early-access"');
171+ expect(html).toContain('href="https://diffinery.com/early-access"');
172+ const faq = g.find((n) => n["@type"] === "FAQPage") as { mainEntity: Array<{ name: string; acceptedAnswer: { text: string } }> };
173+ expect(faq.mainEntity.find((q) => q.name === "Can I try Diffinery without deploying it?")?.acceptedAnswer.text).toContain("Request early access to diffinery.com");
166174 expect(html).not.toMatch(/not been announced|hasn't been announced|invite-only while/i);
167175 for (const href of ["/compare/github", "/compare/blacksmith", "/performance", "/agents", "/about", "/llms.txt", "/open-source", "/docs/self-hosting", "https://diffinery.com/codician/diffinery", "https://github.com/codician-team/diffinery"]) expect(html, href).toContain(`href="${href}"`);
168176 });
@@ -199,6 +207,7 @@
199207 expect(txt).toContain(`(${ORIGIN}/index.md)`);
200208 for (const p of pages) expect(txt).toContain(`(${ORIGIN}${p.path}.md)`);
201209 expect(txt.indexOf(`${ORIGIN}/compare/github.md`)).toBeLessThan(txt.indexOf(`${ORIGIN}/docs/quickstart.md`));
210+ expect(txt).toContain("request early access at https://diffinery.com/early-access");
202211 });
203212
204213 it("states the Cloudflare claim the same way everywhere, backed by the sourced list of other projects", async () => {
modifiedapps/diffinery/test/unit/today.test.ts+10−0View file
@@ -47,6 +47,9 @@
4747 it("orders what a person acts on", () => {
4848 expect(agentPullState(pull({ number: 1, draft: true, checks: "failure" }))).toBe("draft");
4949 expect(agentPullState(pull({ number: 1, autoMerge: true, checks: "failure" }))).toBe("landing");
50+ // Merge on green that keeps approvals: auto-merge is on, but it lands only once a person approves.
51+ expect(agentPullState(pull({ number: 1, autoMerge: true, awaitingApproval: true }))).toBe("waiting_for_approval");
52+ expect(agentPullState(pull({ number: 1, autoMerge: true, awaitingApproval: true, checks: "pending" }))).toBe("checks_running");
5053 expect(agentPullState(pull({ number: 1, checks: "failure", changesRequestedBy: ["bob"] }))).toBe("checks_failing");
5154 expect(agentPullState(pull({ number: 1, changesRequestedBy: ["bob"], approvedHead: true }))).toBe("changes_requested");
5255 expect(agentPullState(pull({ number: 1, approvedHead: true }))).toBe("approved");
@@ -68,6 +71,7 @@
6871 const agent = (p: Partial<TodayPull>) => pull({ number: 1, author: OTHER_BOT, mergeState: null, ...p });
6972 expect(needsMergeCheck(agent({}))).toBe(true);
7073 expect(needsMergeCheck(agent({ checks: "pending" }))).toBe(true);
74+ expect(needsMergeCheck(agent({ autoMerge: true, awaitingApproval: true }))).toBe(true);
7175 for (const p of [{ mergeState: "clean" as const }, { draft: true }, { autoMerge: true }, { queued: true }, { checks: "failure" as const }, { changesRequestedBy: ["bob"] }, { approvedHead: true }, { author: BOB }]) expect(needsMergeCheck(agent(p))).toBe(false);
7276 });
7377 });
@@ -84,6 +88,12 @@
8488 expect(t.needsYou.map((x) => `${x.repo.id}#${x.pull.number}:${x.kind}`).sort()).toEqual(["web#1:agent_approval", "web#2:agent_approval"]);
8589 });
8690
91+ it("asks for an approval that an agent's auto-merge still waits for, but not for one that lands by itself", () => {
92+ const pulls = [pull({ number: 1, author: OTHER_BOT, autoMerge: true, awaitingApproval: true }), pull({ number: 2, author: OTHER_BOT, autoMerge: true })];
93+ const t = classify(input([{ repo: repo("web"), data: data({ pulls }) }]));
94+ expect(t.needsYou.map((x) => `${x.pull.number}:${x.kind}`)).toEqual(["1:agent_approval"]);
95+ });
96+
8797 it("only role-checks repositories with agent work waiting for a person", () => {
8898 const repos = [
8999 { repo: repo("a"), data: data({ pulls: [pull({ number: 1, author: OTHER_BOT })] }) },
modifiedapps/jetbrains/CHANGELOG.md+9−0View file
@@ -1,5 +1,14 @@
11 # Changelog
22
3+## 0.1.1
4+
5+- Works with any Diffinery, not only diffinery.com: **Use another server…** under **Sign In with Browser…** on the
6+ Diffinery page of **Get from Version Control** asks for your server's address, checks that a Diffinery answers
7+ there, and switches to it.
8+- Installed from your own Diffinery's plugin repository, the sign-in page offers that server in one click
9+ (**Use git.example.com**).
10+- **Share Project on Diffinery…** asks which server to sign in to when you aren't signed in yet.
11+
312 ## 0.1.0
413
514 - **Diffinery** in **Get from Version Control**: your repositories and your organizations', search across every
modifiedapps/jetbrains/README.md+9−0View file
@@ -9,6 +9,15 @@
99 It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin
1010 Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use.
1111
12+## Self-hosted Diffinery
13+
14+The server is `server` in the IDE's `diffinery.xml` (`Accounts.setServer`), `https://diffinery.com` by default. People
15+change it from the Diffinery page of **Get from Version Control**: **Use another server…** under the sign-in button
16+(`SignInDialogs.chooseServer`, which checks that `/-/jetbrains/updatePlugins.xml` answers there first), or one click on
17+**Use git.example.com** when the IDE's plugin repositories include a Diffinery's `updatePlugins.xml`
18+(`Util.pluginRepositoryServers`). **Share Project on Diffinery…** offers the same choice before signing in, and
19+**Settings → Version Control → Diffinery → Server** remains. Changing the server signs out of the old one.
20+
1221 ## Build
1322
1423 Gradle 9 through the wrapper, with a JDK 21 or later (the IDE's own JetBrains Runtime works):
deletedapps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0
Binary file not shown.
addedapps/jetbrains/dist/diffinery-jetbrains-0.1.1.zip+0−0View file
Binary file not shown.
modifiedapps/jetbrains/gradle.properties+1−1View file
@@ -1,4 +1,4 @@
1-pluginVersion=0.1.0
1+pluginVersion=0.1.1
22 # The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it.
33 platformVersion=2026.2.3
44 sinceBuild=243
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2View file
@@ -5,7 +5,6 @@
55 import com.intellij.openapi.application.ApplicationManager;
66 import com.intellij.openapi.application.ModalityState;
77 import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory;
8-import com.intellij.openapi.options.ShowSettingsUtil;
98 import com.intellij.openapi.project.Project;
109 import com.intellij.openapi.ui.TextBrowseFolderListener;
1110 import com.intellij.openapi.ui.TextFieldWithBrowseButton;
@@ -21,6 +20,7 @@
2120 import com.intellij.ui.ScrollPaneFactory;
2221 import com.intellij.ui.SearchTextField;
2322 import com.intellij.ui.SimpleTextAttributes;
23+import com.intellij.ui.components.ActionLink;
2424 import com.intellij.ui.components.JBLabel;
2525 import com.intellij.ui.components.JBList;
2626 import com.intellij.util.ui.JBUI;
@@ -29,6 +29,7 @@
2929 import git4idea.commands.Git;
3030 import java.awt.BorderLayout;
3131 import java.awt.CardLayout;
32+import java.awt.FlowLayout;
3233 import java.awt.GridBagConstraints;
3334 import java.awt.GridBagLayout;
3435 import java.awt.event.KeyAdapter;
@@ -72,6 +73,8 @@
7273 private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton();
7374 private final JBLabel serverLabel = new JBLabel();
7475 private final JButton signInButton = new JButton("Sign In with Browser…");
76+ private final ActionLink suggestedLink = Plugin.link("", this::useSuggested);
77+ private String suggested;
7578
7679 private List<Repo> mine = List.of();
7780 private List<Repo> found = List.of();
@@ -117,8 +120,13 @@
117120 serverLabel.setForeground(UIUtil.getContextHelpForeground());
118121 c.insets = JBUI.insetsTop(16);
119122 p.add(serverLabel, c);
123+ // A self-hosted Diffinery is one click away: the one this plugin was installed from, when the IDE's plugin
124+ // repositories name it, or any other by its address.
125+ JPanel servers = new JPanel(new FlowLayout(FlowLayout.CENTER, JBUI.scale(12), 0));
126+ servers.add(suggestedLink);
127+ servers.add(Plugin.link("Use another server…", () -> SignInDialogs.chooseServer(project, this::refresh)));
120128 c.insets = JBUI.emptyInsets();
121- p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c);
129+ p.add(servers, c);
122130 return p;
123131 }
124132
@@ -213,6 +221,11 @@
213221 getDialogStateListener().onListItemChanged();
214222 if (a == null) {
215223 serverLabel.setText("Server: " + Util.host(Accounts.get().server()));
224+ List<String> others = SignInDialogs.pluginRepositoryServers();
225+ suggested = others.isEmpty() ? null : others.get(0);
226+ suggestedLink.setText(suggested == null ? "" : "Use " + Util.host(suggested));
227+ suggestedLink.setToolTipText(suggested == null ? null : "This IDE installs the Diffinery plugin from " + suggested);
228+ suggestedLink.setVisible(suggested != null);
216229 cards.show(root, SIGNED_OUT);
217230 getDialogStateListener().onOkActionEnabled(false);
218231 return;
@@ -222,6 +235,12 @@
222235 load();
223236 }
224237
238+ /** Switches to the Diffinery this plugin came from: it served the plugin, so there's nothing to check. */
239+ private void useSuggested() {
240+ String s = suggested;
241+ if (s != null) ApplicationManager.getApplication().executeOnPooledThread(() -> Accounts.get().setServer(s));
242+ }
243+
225244 private void load() {
226245 int gen = loads.incrementAndGet();
227246 mine = List.of();
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1View file
@@ -47,7 +47,7 @@
4747 buttons.add(signIn);
4848 buttons.add(useToken);
4949 buttons.add(signOut);
50- JBLabel serverHelp = new JBLabel("Change it only for a self-hosted Diffinery. Changing it signs you out.");
50+ JBLabel serverHelp = new JBLabel("https://diffinery.com, or the address of your own Diffinery. Changing it signs you out.");
5151 serverHelp.setForeground(UIUtil.getContextHelpForeground());
5252 JBLabel gitHelp = new JBLabel("<html>When you're signed in, Git uses this account for the server: clone, pull and push need no password.</html>");
5353 gitHelp.setForeground(UIUtil.getContextHelpForeground());
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+1−1View file
@@ -62,7 +62,7 @@
6262 Messages.showInfoMessage(project, "Commit your files on a branch first, then share the project.", "Share Project on Diffinery");
6363 return;
6464 }
65- if (Accounts.get().account() == null) SignInDialogs.signInWithBrowser(project, a -> chooseAndShare(project, repo));
65+ if (Accounts.get().account() == null) SignInDialogs.signIn(project, a -> chooseAndShare(project, repo));
6666 else chooseAndShare(project, repo);
6767 }
6868
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0View file
@@ -2,19 +2,123 @@
22
33 import com.intellij.openapi.application.ApplicationManager;
44 import com.intellij.openapi.application.ModalityState;
5+import com.intellij.openapi.diagnostic.Logger;
56 import com.intellij.openapi.progress.ProgressIndicator;
67 import com.intellij.openapi.progress.ProgressManager;
78 import com.intellij.openapi.progress.Task;
89 import com.intellij.openapi.project.Project;
10+import com.intellij.openapi.ui.InputValidatorEx;
911 import com.intellij.openapi.ui.Messages;
12+import com.intellij.openapi.updateSettings.impl.UpdateSettings;
13+import java.io.IOException;
14+import java.util.List;
1015 import java.util.function.Consumer;
1116 import org.jetbrains.annotations.NotNull;
1217 import org.jetbrains.annotations.Nullable;
1318
1419 /** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */
1520 final class SignInDialogs {
21+ private static final Logger LOG = Logger.getInstance(SignInDialogs.class);
22+
1623 private SignInDialogs() {}
1724
25+ /**
26+ * Signing in from a place that doesn't show the server (Share Project): says which Diffinery, with a way to pick
27+ * another one first. `done` runs on the UI thread with the account once signed in.
28+ */
29+ static void signIn(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
30+ String host = Util.host(Accounts.get().server());
31+ int choice = Messages.showDialog(project, "Sign in to Diffinery at " + host + " with your passkey in the browser, or use another Diffinery server, such as your own.", "Sign In to Diffinery", new String[] {"Sign In to " + host + "…", "Use Another Server…", Messages.getCancelButton()}, 0, Plugin.ICON);
32+ if (choice == 0) signInWithBrowser(project, done);
33+ else if (choice == 1) chooseServer(project, () -> signInWithBrowser(project, done));
34+ }
35+
36+ /**
37+ * The self-hosted Diffinery servers this IDE installs plugins from (Settings → Plugins → Manage Plugin
38+ * Repositories), other than the current one.
39+ */
40+ static List<String> pluginRepositoryServers() {
41+ try {
42+ return Util.pluginRepositoryServers(UpdateSettings.getInstance().getStoredPluginHosts(), Accounts.get().server());
43+ } catch (RuntimeException | LinkageError e) {
44+ // Only a suggestion: an IDE that keeps its plugin repositories elsewhere just doesn't get one.
45+ LOG.info("Couldn't read the IDE's plugin repositories: " + e);
46+ return List.of();
47+ }
48+ }
49+
50+ /**
51+ * "Use another server…": asks for a self-hosted Diffinery's address (offering the one this plugin came from),
52+ * checks that a Diffinery answers there, and switches to it, which signs out of the current server. `done` runs on
53+ * the UI thread once switched; not when cancelled.
54+ */
55+ static void chooseServer(@Nullable Project project, @Nullable Runnable done) {
56+ String current = Accounts.get().server();
57+ List<String> suggested = pluginRepositoryServers();
58+ String initial = !suggested.isEmpty() ? suggested.get(0) : current.equals(Util.DEFAULT_SERVER) ? "" : current;
59+ Accounts.Account signedIn = Accounts.get().account();
60+ String raw = Messages.showInputDialog(
61+ project,
62+ "The address of your Diffinery, as in your browser's address bar, such as git.example.com. Enter diffinery.com to go back."
63+ + (signedIn != null ? "\nChanging the server signs you out of " + Util.host(current) + "." : ""),
64+ "Use Another Diffinery Server",
65+ Plugin.ICON,
66+ initial,
67+ new InputValidatorEx() {
68+ @Override
69+ public String getErrorText(String input) {
70+ return Util.serverInputError(input);
71+ }
72+
73+ @Override
74+ public boolean checkInput(String input) {
75+ return Util.serverInputError(input) == null;
76+ }
77+
78+ @Override
79+ public boolean canClose(String input) {
80+ return checkInput(input);
81+ }
82+ });
83+ if (raw == null) return;
84+ String server = Util.normalizeServer(raw);
85+ if (server.equals(current)) {
86+ if (done != null) done.run();
87+ return;
88+ }
89+ ProgressManager.getInstance().run(new Task.Modal(project, "Looking for Diffinery at " + Util.host(server), true) {
90+ private String problem;
91+
92+ @Override
93+ public void run(@NotNull ProgressIndicator indicator) {
94+ indicator.setIndeterminate(true);
95+ problem = probe(server);
96+ }
97+
98+ @Override
99+ public void onSuccess() {
100+ if (problem != null
101+ && Messages.showYesNoDialog(project, server + " doesn't look like a Diffinery server: " + problem + "\nCheck the address, and use it anyway only if you're sure.", "Use Another Diffinery Server", "Use Anyway", Messages.getCancelButton(), Messages.getWarningIcon())
102+ != Messages.YES) return;
103+ ApplicationManager.getApplication().executeOnPooledThread(() -> {
104+ Accounts.get().setServer(server);
105+ if (done != null) ApplicationManager.getApplication().invokeLater(done, ModalityState.any());
106+ });
107+ }
108+ });
109+ }
110+
111+ /** Null when a Diffinery answers at the server (every instance serves this plugin's repository), else what went wrong. */
112+ private static String probe(String server) {
113+ try {
114+ Api.Response r = Api.send("GET", server + "/-/jetbrains/updatePlugins.xml", null, null, null);
115+ if (r.status() != 200) return "its plugin repository answered " + r.status() + ".";
116+ return r.body().contains("\"" + Plugin.ID + "\"") ? null : "it doesn't serve the Diffinery plugin.";
117+ } catch (IOException e) {
118+ return "it couldn't be reached (" + e.getMessage() + ").";
119+ }
120+ }
121+
18122 /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */
19123 static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) {
20124 ProgressManager.getInstance().run(new Task.Modal(project, "Sign In to Diffinery", true) {
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+40−0View file
@@ -7,6 +7,7 @@
77 import java.security.MessageDigest;
88 import java.security.NoSuchAlgorithmException;
99 import java.security.SecureRandom;
10+import java.util.ArrayList;
1011 import java.util.Base64;
1112 import java.util.LinkedHashMap;
1213 import java.util.List;
@@ -43,6 +44,45 @@
4344 return scheme + "://" + u.getRawAuthority() + path;
4445 }
4546
47+ /** Why what someone typed as a server address can't be used, for the dialog; null when it can. */
48+ static String serverInputError(String raw) {
49+ String s = raw == null ? "" : raw.trim();
50+ if (s.isEmpty()) return "Enter your Diffinery's address, such as git.example.com.";
51+ if (s.chars().anyMatch(Character::isWhitespace)) return "An address has no spaces.";
52+ try {
53+ normalizeServer(s);
54+ return null;
55+ } catch (IllegalArgumentException e) {
56+ return e.getMessage();
57+ }
58+ }
59+
60+ /** The Diffinery that serves a plugin repository (https://git.example.com/-/jetbrains/updatePlugins.xml), or null. */
61+ static String serverOfPluginRepository(String url) {
62+ if (url == null) return null;
63+ String u = url.trim().replaceAll("[?#].*$", "");
64+ String suffix = "/-/jetbrains/updatePlugins.xml";
65+ if (!u.endsWith(suffix) || !SCHEME.matcher(u).find()) return null;
66+ try {
67+ return normalizeServer(u.substring(0, u.length() - suffix.length()));
68+ } catch (IllegalArgumentException e) {
69+ return null;
70+ }
71+ }
72+
73+ /**
74+ * The Diffinery servers among the IDE's plugin repositories, other than `current`, in order: someone who installed
75+ * this plugin from their own Diffinery has its updatePlugins.xml there, so it's the server they most likely mean.
76+ */
77+ static List<String> pluginRepositoryServers(List<String> repositories, String current) {
78+ List<String> out = new ArrayList<>();
79+ for (String r : repositories) {
80+ String s = serverOfPluginRepository(r);
81+ if (s != null && !s.equals(current) && !out.contains(s)) out.add(s);
82+ }
83+ return out;
84+ }
85+
4686 /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */
4787 static String clientId(String server) {
4888 return server + "/oauth/clients/jetbrains.json";
modifiedapps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0View file
@@ -13,6 +13,7 @@
1313 <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with
1414 your signed-in account, and renews its token before it runs out.</li>
1515 <li><b>Share Project on Diffinery.</b> Publish a local Git repository as a new Diffinery repository.</li>
16+ <li><b>Any Diffinery.</b> diffinery.com or your own: choose <b>Use another server…</b> under the sign-in button.</li>
1617 </ul>
1718 <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p>
1819 ]]></description>
modifiedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0View file
@@ -31,6 +31,34 @@
3131 }
3232
3333 @Test
34+ public void serverInput() {
35+ assertNull(Util.serverInputError("git.example.com"));
36+ assertNull(Util.serverInputError(" https://git.example.com/ "));
37+ assertNull(Util.serverInputError("http://localhost:8787"));
38+ assertTrue(Util.serverInputError("").startsWith("Enter"));
39+ assertTrue(Util.serverInputError("git example").contains("spaces"));
40+ assertTrue(Util.serverInputError("http://git.example.com").contains("https"));
41+ assertTrue(Util.serverInputError("https://").contains("URL"));
42+ }
43+
44+ @Test
45+ public void pluginRepositoryServers() {
46+ assertEquals("https://git.example.com", Util.serverOfPluginRepository("https://git.example.com/-/jetbrains/updatePlugins.xml"));
47+ assertEquals("https://git.example.com:8443/forge", Util.serverOfPluginRepository(" https://git.example.com:8443/forge/-/jetbrains/updatePlugins.xml?build=IU-262 "));
48+ assertNull(Util.serverOfPluginRepository("https://plugins.example.com/updatePlugins.xml"));
49+ assertNull(Util.serverOfPluginRepository("/-/jetbrains/updatePlugins.xml"));
50+ assertNull(Util.serverOfPluginRepository("http://git.example.com/-/jetbrains/updatePlugins.xml"));
51+ assertNull(Util.serverOfPluginRepository(null));
52+ List<String> hosts = List.of(
53+ "https://diffinery.com/-/jetbrains/updatePlugins.xml",
54+ "https://plugins.example.com/updatePlugins.xml",
55+ "https://git.example.com/-/jetbrains/updatePlugins.xml",
56+ "https://git.example.com/-/jetbrains/updatePlugins.xml");
57+ assertEquals(List.of("https://git.example.com"), Util.pluginRepositoryServers(hosts, S));
58+ assertEquals(List.of(S), Util.pluginRepositoryServers(hosts, "https://git.example.com"));
59+ }
60+
61+ @Test
3462 public void authorizeUrl() {
3563 String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st");
3664 assertTrue(url.startsWith(S + "/oauth/authorize?"));
modifiedapps/vscode/CHANGELOG.md+6−0View file
@@ -1,5 +1,11 @@
11 # Changelog
22
3+## 0.1.2
4+
5+- Works with any Diffinery, not only diffinery.com: the first sign-in asks which Diffinery you use, with **Use another Diffinery server…** for a self-hosted one.
6+- New command **Diffinery: Use Another Server…**. Each server keeps its own sign-in.
7+- Installed with a self-hosted Diffinery's `install-editor` script, the extension asks on its next start whether to use that server.
8+
39 ## 0.1.1
410
511 - "Sign In with an Access Token" goes through the editor's own sign-in, so the first clone afterwards no longer asks to allow the extension to sign in.
modifiedapps/vscode/PUBLISHING.md+14−13View file
@@ -6,16 +6,15 @@
66
77 ## Before the first upload
88
9-1. Set `"license": "Apache-2.0"` in `package.json` and ship a copy of the repository's `LICENSE` in the package (both
10- registries show it; `vsce` warns without it).
11-2. Create the `codician` publisher on the Marketplace (https://marketplace.visualstudio.com/manage, which needs an
12- Azure DevOps organization for the personal access token) and the `codician` namespace on Open VSX
13- (`npx ovsx create-namespace codician`, after signing the Eclipse publisher agreement). The extension id
14- `codician.diffinery` is what the server's OAuth client (`EDITOR_EXTENSION_ID` in `apps/diffinery/src/auth/oauth.ts`)
15- and the docs use, so keep it.
16-3. Marketplace builds update through the marketplace: ship them with `diffinery.checkForUpdates` defaulting to
17- `false`, or have `src/updates.ts` skip the check when the extension was installed from a gallery, so the two
18- update paths don't fight.
9+1. Create the `codician` publisher on the Marketplace (https://marketplace.visualstudio.com/manage, display name
10+ **Codician**; a personal access token for command-line uploads needs an Azure DevOps organization) and the
11+ `codician` namespace on Open VSX (`pnpm dlx ovsx create-namespace codician`, after signing the Eclipse publisher
12+ agreement). The extension id `codician.diffinery` is what the server's OAuth client (`EDITOR_EXTENSION_ID` in
13+ `apps/diffinery/src/auth/oauth.ts`) and the docs use, so keep it.
14+2. The package already carries the license (`"license": "Apache-2.0"` and the repository's `LICENSE` as
15+ `extension/LICENSE.txt`), and the marketplace build (`--marketplace` below) ships with `diffinery.checkForUpdates`
16+ off by default, so the marketplace's updates and the extension's own check against the Diffinery server don't
17+ fight.
1918
2019 ## Listing
2120
@@ -33,7 +32,9 @@
3332 ## Each release
3433
3534 1. Bump `version` in `package.json` and add a `CHANGELOG.md` entry.
36-2. `node scripts/package.mjs --out dist` builds `dist/diffinery-<version>.vsix`, the same file diffinery.com serves.
37-3. `npx @vscode/vsce publish --packagePath dist/diffinery-<version>.vsix` and
38- `npx ovsx publish dist/diffinery-<version>.vsix`, with the tokens from step 2 of *Before the first upload* in `VSCE_PAT` and `OVSX_PAT`.
35+2. `node scripts/package.mjs --out dist --marketplace` builds `dist/diffinery-<version>-marketplace.vsix`
36+ (without `--marketplace`, `dist/diffinery-<version>.vsix` is the file diffinery.com serves).
37+3. Upload it on the publisher's page (**New extension → Visual Studio Code**, or **Update** on the extension) and on
38+ Open VSX, or from the command line: `pnpm dlx @vscode/vsce publish --packagePath dist/diffinery-<version>-marketplace.vsix` and
39+ `pnpm dlx ovsx publish dist/diffinery-<version>-marketplace.vsix`, with the tokens from step 1 of *Before the first upload* in `VSCE_PAT` and `OVSX_PAT`.
3940 4. Deploying Diffinery keeps diffinery.com serving the same version for people who installed from there.
modifiedapps/vscode/README.md+21−3View file
@@ -6,8 +6,9 @@
66 - **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick
77 **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first;
88 type to search everything you can read, or paste `owner/name`.
9-- **Sign in with your passkey.** The first time, the editor opens diffinery.com in your browser, you approve
10- the connection, and you're back in the editor. Diffinery shows up in the Accounts menu. Nothing to copy.
9+- **Sign in with your passkey.** The first time, choose your Diffinery (diffinery.com or your own server), the
10+ editor opens it in your browser, you approve the connection, and you're back in the editor. Diffinery shows up
11+ in the Accounts menu. Nothing to copy.
1112 - **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews
1213 its token before it expires and keeps the copy Git saved in your keychain current.
1314 - **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**,
@@ -29,6 +30,22 @@
2930 The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at
3031 [diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor).
3132
33+## Self-hosted Diffinery
34+
35+The extension works with any Diffinery, not only diffinery.com:
36+
37+- **The first time you sign in** (from **Clone from Diffinery**, the Accounts menu or **Diffinery: Sign In**), the
38+ extension asks which Diffinery you use. Choose **Use another Diffinery server…** and enter its address, such as
39+ `git.example.com`. The extension checks that a Diffinery answers there and saves it in `diffinery.serverUrl`.
40+- **Diffinery: Use Another Server…** changes the server at any time. Each server keeps its own sign-in, so you can
41+ switch back without signing in again.
42+- **Installed from your server** with `curl -fsSL https://git.example.com/install-editor | bash` (or its
43+ `install-editor.ps1`), the extension asks on its next start whether to use that server: choose
44+ **Use git.example.com**. The installer leaves the address in `~/.config/diffinery/editor-server` for this; it
45+ doesn't edit the editor's settings.
46+
47+The extension then updates itself from your server too.
48+
3249 ## Commands
3350
3451 | Command | What it does |
@@ -36,6 +53,7 @@
3653 | Diffinery: Clone Repository | Opens the Diffinery repository picker directly. |
3754 | Diffinery: Sign In | Signs in through your browser. |
3855 | Diffinery: Sign In with an Access Token | Uses a token from Settings → Access tokens instead (agents, browser-based editors). |
56+| Diffinery: Use Another Server… | Switches to a self-hosted Diffinery (or back to diffinery.com). |
3957 | Diffinery: Sign Out | Signs out and disconnects the editor from your account. |
4058 | Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. |
4159
@@ -43,6 +61,6 @@
4361
4462 | Setting | Default | |
4563 |---|---|---|
46-| `diffinery.serverUrl` | `https://diffinery.com` | Change only for a self-hosted Diffinery. |
64+| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to use: set it to your server's address for a self-hosted Diffinery. |
4765 | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. |
4866 | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build. |
modifiedapps/vscode/install/install.ps1+24−0View file
@@ -27,6 +27,22 @@
2727 $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix"
2828 Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing
2929 if ((Get-FileHash $vsix -Algorithm SHA256).Hash.ToLower() -ne $meta.sha256) { throw "The download didn't match the published SHA-256; nothing was installed." }
30+ # A self-hosted Diffinery: leave its address where the extension looks when it starts (src/servers.ts), and it asks
31+ # whether to use it. Editors' command-line tools can't change a setting, and their settings files are JSON with
32+ # comments, so this doesn't edit them. Written before installing, since a running editor starts a new extension at once.
33+ $conf = if ($env:XDG_CONFIG_HOME -and [IO.Path]::IsPathRooted($env:XDG_CONFIG_HOME)) { $env:XDG_CONFIG_HOME } else { Join-Path $(if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }) ".config" }
34+ $hintFile = Join-Path $conf "diffinery\editor-server"
35+ $hint = $false
36+ if ($server -ne "https://diffinery.com") {
37+ try {
38+ New-Item -ItemType Directory -Force (Split-Path $hintFile) | Out-Null
39+ [IO.File]::WriteAllText($hintFile, "$server`n")
40+ $hint = $true
41+ } catch {}
42+ } else {
43+ Remove-Item -Force $hintFile -ErrorAction SilentlyContinue
44+ }
45+
3046 $installed = 0
3147 foreach ($e in $known) {
3248 if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue }
@@ -46,6 +62,14 @@
4662 exit 1
4763 }
4864 Write-Host ""
65+ if ($server -ne "https://diffinery.com") {
66+ if ($hint) {
67+ Write-Host "This Diffinery is $server. When the editor starts, the extension asks to use it: choose Use $($server -replace '^[a-z]+://', '')."
68+ Write-Host "If it doesn't ask, run Diffinery: Use Another Server... from the Command Palette and enter $server."
69+ } else {
70+ Write-Host "This Diffinery is $server`: in the editor, run Diffinery: Use Another Server... from the Command Palette and enter it."
71+ }
72+ }
4973 Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)"
5074 } finally {
5175 Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
modifiedapps/vscode/install/install.sh+20−0View file
@@ -65,6 +65,18 @@
6565 if command -v sha256sum >/dev/null; then got=$(sha256sum "$vsix" | cut -d' ' -f1); else got=$(shasum -a 256 "$vsix" | cut -d' ' -f1); fi
6666 [ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed"
6767
68+# A self-hosted Diffinery: leave its address where the extension looks when it starts (src/servers.ts), and it asks
69+# whether to use it. Editors' command-line tools can't change a setting, and their settings files are JSON with
70+# comments, so this doesn't edit them. Written before installing, since a running editor starts a new extension at once.
71+conf=${XDG_CONFIG_HOME:-}
72+case $conf in /*) ;; *) conf=$HOME/.config ;; esac
73+hint=""
74+if [ "$server" != "https://diffinery.com" ]; then
75+ if mkdir -p "$conf/diffinery" 2>/dev/null && printf '%s\n' "$server" >"$conf/diffinery/editor-server" 2>/dev/null; then hint=1; fi
76+else
77+ rm -f "$conf/diffinery/editor-server" 2>/dev/null || true
78+fi
79+
6880 installed=0
6981 while IFS='|' read -r name label app; do
7082 if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi
@@ -86,4 +98,12 @@
8698 exit 1
8799 fi
88100 say ""
101+if [ "$server" != "https://diffinery.com" ]; then
102+ if [ -n "$hint" ]; then
103+ say "This Diffinery is ${bold}$server${reset}. When the editor starts, the extension asks to use it: choose ${bold}Use ${server#*://}${reset}."
104+ say "If it doesn't ask, run ${bold}Diffinery: Use Another Server…${reset} from the Command Palette and enter $server."
105+ else
106+ say "This Diffinery is ${bold}$server${reset}: in the editor, run ${bold}Diffinery: Use Another Server…${reset} from the Command Palette and enter it."
107+ fi
108+fi
89109 say "Next: in the editor, run ${bold}Git: Clone${reset} and pick ${bold}Clone from Diffinery${reset}. ${dim}($server/docs/repositories/clone-in-your-editor)${reset}"
modifiedapps/vscode/package.json+7−2View file
@@ -2,7 +2,7 @@
22 "name": "@diffinery/vscode",
33 "displayName": "Diffinery",
44 "description": "Clone, publish and push Diffinery repositories from VS Code, Cursor, Windsurf and other editors built on VS Code.",
5- "version": "0.1.1",
5+ "version": "0.1.2",
66 "publisher": "codician",
77 "private": true,
88 "license": "Apache-2.0",
@@ -86,6 +86,11 @@
8686 "category": "Diffinery"
8787 },
8888 {
89+ "command": "diffinery.useServer",
90+ "title": "Use Another Server…",
91+ "category": "Diffinery"
92+ },
93+ {
8994 "command": "diffinery.signOut",
9095 "title": "Sign Out",
9196 "category": "Diffinery"
@@ -103,7 +108,7 @@
103108 "type": "string",
104109 "default": "https://diffinery.com",
105110 "scope": "application",
106- "markdownDescription": "The Diffinery server to sign in to. Change it only for a self-hosted Diffinery."
111+ "markdownDescription": "The Diffinery server to sign in to: `https://diffinery.com`, or the address of a self-hosted Diffinery. The first sign-in asks which one, and **Diffinery: Use Another Server…** changes it."
107112 },
108113 "diffinery.gitAuthentication": {
109114 "type": "boolean",
modifiedapps/vscode/scripts/package.mjs+23−11View file
@@ -1,7 +1,11 @@
11 // Builds the extension and packs it as a .vsix, the file VS Code, Cursor, Windsurf and VSCodium install.
22 //
3-// node apps/vscode/scripts/package.mjs [--out dir] (writes <dir>/diffinery-<version>.vsix, default apps/vscode/dist)
3+// node apps/vscode/scripts/package.mjs [--out dir] [--marketplace]
44 //
5+// writes <dir>/diffinery-<version>.vsix (default dir apps/vscode/dist). --marketplace writes
6+// diffinery-<version>-marketplace.vsix instead, the build for the Visual Studio Marketplace and Open VSX:
7+// the same extension with diffinery.checkForUpdates off by default, because the marketplace updates it.
8+//
59 // apps/diffinery/scripts/build-docs.mjs calls packageExtension() too, so the Worker serves the build at
610 // /-/vscode/diffinery.vsix. The .vsix has the layout `vsce package` writes (extension.vsixmanifest,
711 // [Content_Types].xml, extension/…), so it can also go to the Visual Studio Marketplace and Open VSX
@@ -18,13 +22,18 @@
1822
1923 const xml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2024
21-/** The manifest as installed: the workspace package's fields, named for the editor (publisher.name = codician.diffinery). */
22-function manifestFor(pkg) {
23- const { scripts: _s, devDependencies: _d, dependencies: _deps, private: _p, ...rest } = pkg;
25+/**
26+ * The manifest as installed: the workspace package's fields, named for the editor (publisher.name = codician.diffinery).
27+ * A marketplace build turns the extension's own update check off by default, so it doesn't offer diffinery.com's
28+ * build next to the marketplace's updates.
29+ */
30+export function manifestFor(pkg, { marketplace = false } = {}) {
31+ const { scripts: _s, devDependencies: _d, dependencies: _deps, private: _p, ...rest } = structuredClone(pkg);
32+ if (marketplace) rest.contributes.configuration.properties["diffinery.checkForUpdates"].default = false;
2433 return { ...rest, name: EXTENSION_NAME };
2534 }
2635
27-function vsixManifest(m) {
36+export function vsixManifest(m) {
2837 const props = [
2938 ["Microsoft.VisualStudio.Code.Engine", m.engines.vscode],
3039 ["Microsoft.VisualStudio.Code.ExtensionDependencies", (m.extensionDependencies ?? []).join(",")],
@@ -62,6 +71,7 @@
6271 <Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" />
6372 <Asset Type="Microsoft.VisualStudio.Services.Content.Details" Path="extension/readme.md" Addressable="true" />
6473 <Asset Type="Microsoft.VisualStudio.Services.Content.Changelog" Path="extension/changelog.md" Addressable="true" />
74+ <Asset Type="Microsoft.VisualStudio.Services.Content.License" Path="extension/LICENSE.txt" Addressable="true" />
6575 <Asset Type="Microsoft.VisualStudio.Services.Icons.Default" Path="extension/${xml(m.icon)}" Addressable="true" />
6676 </Assets>
6777 </PackageManifest>
@@ -69,7 +79,7 @@
6979 }
7080
7181 function contentTypes(names) {
72- const types = { ".json": "application/json", ".js": "application/javascript", ".md": "text/markdown", ".png": "image/png", ".woff": "font/woff", ".vsixmanifest": "text/xml" };
82+ const types = { ".json": "application/json", ".js": "application/javascript", ".md": "text/markdown", ".txt": "text/plain", ".png": "image/png", ".woff": "font/woff", ".vsixmanifest": "text/xml" };
7383 const exts = [...new Set(names.map((n) => path.extname(n).toLowerCase()).filter((e) => e && types[e]))].sort();
7484 return `<?xml version="1.0" encoding="utf-8"?>
7585 <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">${exts.map((e) => `<Default Extension="${e}" ContentType="${types[e]}"/>`).join("")}</Types>
@@ -146,10 +156,10 @@
146156 return Buffer.concat([...locals, ...central, end]);
147157 }
148158
149-/** Builds and packs the extension. Returns its version and the .vsix bytes. */
150-export async function packageExtension() {
159+/** Builds and packs the extension (for a marketplace with `marketplace: true`). Returns its version and the .vsix bytes. */
160+export async function packageExtension({ marketplace = false } = {}) {
151161 const pkg = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8"));
152- const manifest = manifestFor(pkg);
162+ const manifest = manifestFor(pkg, { marketplace });
153163 const bundle = await build({
154164 entryPoints: [path.join(ROOT, "src/extension.ts")],
155165 bundle: true,
@@ -166,6 +176,7 @@
166176 ["extension/package.json", JSON.stringify(manifest, null, 2)],
167177 ["extension/readme.md", await readFile(path.join(ROOT, "README.md"))],
168178 ["extension/changelog.md", await readFile(path.join(ROOT, "CHANGELOG.md"))],
179+ ["extension/LICENSE.txt", await readFile(path.join(ROOT, "../../LICENSE"))],
169180 ["extension/dist/extension.js", bundle.outputFiles[0].contents],
170181 [`extension/${manifest.icon}`, await readFile(path.join(ROOT, manifest.icon))],
171182 ["extension/media/diffinery-icons.woff", await readFile(path.join(ROOT, "media/diffinery-icons.woff"))],
@@ -181,9 +192,10 @@
181192 if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
182193 const i = process.argv.indexOf("--out");
183194 const out = i > 0 ? path.resolve(process.argv[i + 1]) : path.join(ROOT, "dist");
184- const { version, vsix } = await packageExtension();
195+ const marketplace = process.argv.includes("--marketplace");
196+ const { version, vsix } = await packageExtension({ marketplace });
185197 await mkdir(out, { recursive: true });
186- const file = path.join(out, `${EXTENSION_NAME}-${version}.vsix`);
198+ const file = path.join(out, `${EXTENSION_NAME}-${version}${marketplace ? "-marketplace" : ""}.vsix`);
187199 await writeFile(file, vsix);
188200 console.log(`vscode: ${path.relative(process.cwd(), file)} (${(vsix.length / 1024).toFixed(0)} KB)`);
189201 }
modifiedapps/vscode/src/auth.ts+10−0View file
@@ -2,6 +2,7 @@
22 import { randomUUID } from "node:crypto";
33 import * as vscode from "vscode";
44 import { Api } from "./api";
5+import { chooseServer } from "./servers";
56 import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util";
67
78 export const AUTH_ID = "diffinery";
@@ -184,6 +185,10 @@
184185 }
185186
186187 async createSession(): Promise<vscode.AuthenticationSession> {
188+ // Every way in (the Accounts menu, the clone picker, Publish, the commands) first asks which Diffinery, until
189+ // someone has chosen, so a self-hosted one needs no trip to the settings. It may change diffinery.serverUrl, and
190+ // with it this.key().
191+ await chooseServer(this.ctx.globalState);
187192 const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser();
188193 // Signing in again replaces the old sign-in, and ends its connection on the server.
189194 const old = await this.load();
@@ -320,6 +325,11 @@
320325 return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token };
321326 }
322327
328+ /** Who is signed in to the server in the settings, if anyone. */
329+ async login(): Promise<string | undefined> {
330+ return (await this.load())?.login;
331+ }
332+
323333 async signOut(): Promise<void> {
324334 const s = await this.load();
325335 if (!s) {
modifiedapps/vscode/src/extension.ts+3−0View file
@@ -2,6 +2,7 @@
22 import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth";
33 import { GitCredentials, Publisher, syncCredentialStore } from "./git";
44 import { RemoteSources } from "./remoteSources";
5+import { offerInstallerServer, switchServer } from "./servers";
56 import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git";
67 import { checkForUpdates } from "./updates";
78
@@ -26,6 +27,7 @@
2627 command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed");
2728 command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed");
2829 command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed");
30+ command("diffinery.useServer", () => switchServer(ctx.globalState, () => auth.login()), "Changing the Diffinery server failed");
2931 command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed");
3032
3133 // ---------------------------------------------------------------- the built-in Git extensions
@@ -86,6 +88,7 @@
8688 "Cloning from Diffinery failed",
8789 );
8890
91+ void offerInstallerServer(ctx.globalState, () => auth.login(), log).catch(report("Changing the Diffinery server failed"));
8992 void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e));
9093 }
9194
modifiedapps/vscode/src/git.ts+5−3View file
@@ -10,14 +10,16 @@
1010 constructor(private readonly auth: DiffineryAuth) {}
1111
1212 async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> {
13- let server: URL;
13+ let server: string;
1414 try {
15- server = new URL(this.auth.server());
15+ server = this.auth.server();
1616 } catch {
1717 return undefined;
1818 }
19- if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined;
19+ if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined;
2020 const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true });
21+ // Signing in starts by choosing the server: if another one was chosen, its token isn't for this host.
22+ if (this.auth.server() !== server) return undefined;
2123 return { username: session.account.label, password: session.accessToken };
2224 }
2325 }
addedapps/vscode/src/servers.ts+122−0View file
@@ -1,0 +1,122 @@
1+import { readFile } from "node:fs/promises";
2+import { homedir } from "node:os";
3+import * as vscode from "vscode";
4+import { DEFAULT_SERVER, installHintPath, normalizeServer, readInstallHint, serverInputError, serverLabel } from "./util";
5+
6+/** Set (globalState) once someone has chosen a server, so signing in stops asking which one. */
7+const CHOSEN = "diffinery.serverChosen";
8+/** The installer's server this editor already asked about (globalState), so it asks once per address. */
9+const HINT_ANSWERED = "diffinery.installHintAnswered";
10+
11+/** The server in diffinery.serverUrl, or undefined when the setting isn't a usable address. */
12+export function configuredServer(): string | undefined {
13+ try {
14+ return normalizeServer(vscode.workspace.getConfiguration("diffinery").get<string>("serverUrl"));
15+ } catch {
16+ return undefined;
17+ }
18+}
19+
20+/** Saves the server in the user settings; diffinery.com clears the setting, so it follows the default. */
21+export async function useServer(state: vscode.Memento, server: string): Promise<void> {
22+ await vscode.workspace.getConfiguration("diffinery").update("serverUrl", server === DEFAULT_SERVER ? undefined : server, vscode.ConfigurationTarget.Global);
23+ await state.update(CHOSEN, true);
24+}
25+
26+/**
27+ * Asks for a self-hosted Diffinery's address, and checks that a Diffinery answers there before it's used, so a typo
28+ * ends here rather than at an error page in the browser. Undefined when cancelled.
29+ */
30+export async function askForServer(value?: string): Promise<string | undefined> {
31+ const raw = await vscode.window.showInputBox({
32+ title: "Use Another Diffinery Server",
33+ prompt: "The address of your Diffinery, as in your browser's address bar. The extension signs in, clones and pushes there.",
34+ placeHolder: "git.example.com",
35+ value,
36+ ignoreFocusOut: true,
37+ validateInput: serverInputError,
38+ });
39+ if (raw === undefined) return undefined;
40+ const server = normalizeServer(raw);
41+ const found = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Looking for Diffinery at ${serverLabel(server)}…` }, () => isDiffinery(server));
42+ if (found !== true) {
43+ const go = await vscode.window.showWarningMessage(`${server} doesn't look like a Diffinery server`, { modal: true, detail: `${found} Check the address, and use it anyway only if you're sure.` }, "Use Anyway");
44+ if (!go) return undefined;
45+ }
46+ return server;
47+}
48+
49+/** True when the server answers like Diffinery (every instance serves this extension at /-/vscode/), else what went wrong. */
50+async function isDiffinery(server: string): Promise<true | string> {
51+ try {
52+ const res = await fetch(`${server}/-/vscode/version.json`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
53+ if (!res.ok) return `${server}/-/vscode/version.json answered ${res.status}.`;
54+ const body = (await res.json()) as { version?: unknown };
55+ return typeof body.version === "string" ? true : `${server}/-/vscode/version.json isn't what Diffinery serves.`;
56+ } catch (e) {
57+ return `It couldn't be reached (${e instanceof Error ? e.message : String(e)}).`;
58+ }
59+}
60+
61+/**
62+ * The first sign-in asks which Diffinery: diffinery.com, or a self-hosted one by its address, so nobody has to find
63+ * the setting first. Once someone has chosen, or set diffinery.serverUrl themselves, it doesn't ask again
64+ * ("Diffinery: Use Another Server…" changes it later).
65+ */
66+export async function chooseServer(state: vscode.Memento): Promise<void> {
67+ if (state.get<boolean>(CHOSEN) || vscode.workspace.getConfiguration("diffinery").inspect<string>("serverUrl")?.globalValue !== undefined) return;
68+ const items: Array<vscode.QuickPickItem & { server?: string }> = [
69+ { label: `$(diffinery-logo) Sign in to ${serverLabel(DEFAULT_SERVER)}`, server: DEFAULT_SERVER },
70+ { label: "$(server) Use another Diffinery server…", detail: "A self-hosted Diffinery on your own domain" },
71+ ];
72+ const pick = await vscode.window.showQuickPick(items, { title: "Sign in to Diffinery", placeHolder: "Which Diffinery do you use?", ignoreFocusOut: true });
73+ if (!pick) throw new vscode.CancellationError();
74+ const server = pick.server ?? (await askForServer());
75+ if (!server) throw new vscode.CancellationError();
76+ await useServer(state, server);
77+}
78+
79+/** "Diffinery: Use Another Server…": switches the server. Sign-ins on other servers stay, for switching back. */
80+export async function switchServer(state: vscode.Memento, signedInAs: () => Promise<string | undefined>): Promise<void> {
81+ const server = await askForServer(configuredServer());
82+ if (!server) return;
83+ await useServer(state, server);
84+ await announce(server, await signedInAs());
85+}
86+
87+async function announce(server: string, login: string | undefined): Promise<void> {
88+ if (login) {
89+ void vscode.window.showInformationMessage(`Diffinery now uses ${serverLabel(server)}, where you're signed in as ${login}.`);
90+ return;
91+ }
92+ const pick = await vscode.window.showInformationMessage(`Diffinery now uses ${serverLabel(server)}. Sign in to clone and push there.`, "Sign In");
93+ if (pick) void vscode.commands.executeCommand("diffinery.signIn");
94+}
95+
96+/**
97+ * The installer that a self-hosted Diffinery serves leaves its address in a file (util.ts installHintPath), since an
98+ * editor's command line can't change a setting. The file only suggests: the person is asked, once per address.
99+ */
100+export async function offerInstallerServer(state: vscode.Memento, signedInAs: () => Promise<string | undefined>, log: vscode.LogOutputChannel): Promise<void> {
101+ let server: string | null;
102+ try {
103+ server = readInstallHint(await readFile(installHintPath(process.env, homedir()), "utf8"));
104+ } catch {
105+ return; // no file: installed from diffinery.com, or by hand
106+ }
107+ if (!server || state.get<string>(HINT_ANSWERED) === server) return;
108+ const current = configuredServer();
109+ if (server === current) {
110+ await state.update(HINT_ANSWERED, server);
111+ return;
112+ }
113+ log.info(`installed from ${server}; asking whether to use it`);
114+ const use = `Use ${serverLabel(server)}`;
115+ const keep = current ? `Keep ${serverLabel(current)}` : "Not Now";
116+ const pick = await vscode.window.showInformationMessage(`The Diffinery extension was installed from ${server}. Sign in, clone and push there?`, use, keep);
117+ if (!pick) return; // closed without answering: ask again next time
118+ await state.update(HINT_ANSWERED, server);
119+ if (pick !== use) return;
120+ await useServer(state, server);
121+ await announce(server, await signedInAs());
122+}
modifiedapps/vscode/src/util.ts+46−6View file
@@ -1,25 +1,65 @@
11 // Pure helpers, without the vscode module, so the unit tests can run them under Node.
22 import { createHash, randomBytes } from "node:crypto";
3+import * as path from "node:path";
34
45 export const EXTENSION_ID = "codician.diffinery";
56 /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */
67 export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"];
78 export const DEFAULT_SERVER = "https://diffinery.com";
89
9-/** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */
10-export function normalizeServer(raw: string | undefined): string {
11- const s = (raw ?? "").trim() || DEFAULT_SERVER;
10+function checkServer(s: string): { server: string } | { invalid: "url" | "https" } {
1211 let u: URL;
1312 try {
1413 u = new URL(s.includes("://") ? s : `https://${s}`);
1514 } catch {
16- throw new Error(`The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.`);
15+ return { invalid: "url" };
1716 }
1817 const loopback = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(u.hostname);
19- if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) throw new Error(`The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`);
20- return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}`;
18+ if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return { invalid: "https" };
19+ return { server: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` };
2120 }
2221
22+/** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */
23+export function normalizeServer(raw: string | undefined): string {
24+ const s = (raw ?? "").trim() || DEFAULT_SERVER;
25+ const r = checkServer(s);
26+ if ("server" in r) return r.server;
27+ throw new Error(r.invalid === "url" ? `The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.` : `The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`);
28+}
29+
30+/** Why what someone typed as a server address can't be used, for the input box; null when it can. */
31+export function serverInputError(raw: string): string | null {
32+ const s = raw.trim();
33+ if (!s) return "Enter your Diffinery's address, such as git.example.com.";
34+ if (/\s/.test(s)) return "An address has no spaces.";
35+ const r = checkServer(s);
36+ return "server" in r ? null : r.invalid === "url" ? `"${s}" isn't a valid address.` : "The address must use https (http only for a server on this computer).";
37+}
38+
39+/** A server as people read it: diffinery.com, git.example.com:8443. */
40+export function serverLabel(server: string): string {
41+ const u = new URL(server);
42+ return `${u.host}${u.pathname.replace(/\/+$/, "")}`;
43+}
44+
45+/**
46+ * Where the editor installer (install/install.sh, install.ps1) leaves the address of the self-hosted Diffinery that
47+ * served it, for the extension to offer on its next start: the CLI's config folder (~/.config/diffinery, or under
48+ * XDG_CONFIG_HOME), on every system.
49+ */
50+export function installHintPath(env: NodeJS.ProcessEnv, home: string): string {
51+ const xdg = env.XDG_CONFIG_HOME;
52+ const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || home, ".config");
53+ return path.join(base, "diffinery", "editor-server");
54+}
55+
56+/** The server named in the installer's hint file, or null when the file holds anything else. */
57+export function readInstallHint(text: string): string | null {
58+ const line = text.split(/\r?\n/, 1)[0]!.trim();
59+ if (!line || line.length > 2000 || serverInputError(line)) return null;
60+ return normalizeServer(line);
61+}
62+
2363 /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */
2464 export const clientId = (server: string) => `${server}/oauth/clients/editor.json`;
2565
addedapps/vscode/test/package.test.ts+26−0View file
@@ -1,0 +1,26 @@
1+import { readFileSync } from "node:fs";
2+import path from "node:path";
3+import { describe, expect, it } from "vitest";
4+// @ts-expect-error: a plain .mjs build script without type declarations
5+import { manifestFor, packageExtension, vsixManifest } from "../scripts/package.mjs";
6+
7+const pkg = JSON.parse(readFileSync(path.join(__dirname, "../package.json"), "utf8"));
8+const updateDefault = (m: typeof pkg): boolean => m.contributes.configuration.properties["diffinery.checkForUpdates"].default;
9+
10+describe("package.mjs", () => {
11+ it("keeps the self-update check on for diffinery.com's build and turns it off for marketplaces", () => {
12+ expect(updateDefault(manifestFor(pkg))).toBe(true);
13+ expect(updateDefault(manifestFor(pkg, { marketplace: true }))).toBe(false);
14+ expect(updateDefault(pkg)).toBe(true);
15+ expect(manifestFor(pkg, { marketplace: true })).toMatchObject({ name: "diffinery", publisher: "codician", license: "Apache-2.0" });
16+ });
17+
18+ it("ships the license in both builds", async () => {
19+ for (const marketplace of [false, true]) {
20+ const { vsix } = await packageExtension({ marketplace });
21+ const text = Buffer.from(vsix).toString("latin1");
22+ expect(text).toContain("extension/LICENSE.txt");
23+ }
24+ expect(vsixManifest(manifestFor(pkg))).toContain('<Asset Type="Microsoft.VisualStudio.Services.Content.License" Path="extension/LICENSE.txt" Addressable="true" />');
25+ });
26+});
modifiedapps/vscode/test/signin.test.ts+36−1View file
@@ -1,6 +1,8 @@
11 import { createHash } from "node:crypto";
22 import { describe, expect, it } from "vitest";
3-import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "../src/util";
3+import { readFileSync } from "node:fs";
4+import path from "node:path";
5+import { authorizeUrl, clientId, editorRedirect, installHintPath, normalizeServer, pkce, readCallback, readInstallHint, serverInputError, serverLabel } from "../src/util";
46
57 const S = "https://diffinery.com";
68
@@ -14,6 +16,39 @@
1416 expect(() => normalizeServer("https://")).toThrow(/valid URL/);
1517 });
1618
19+ it("checks a server address someone types, and shows it by its host", () => {
20+ expect(serverInputError("git.example.com")).toBeNull();
21+ expect(serverInputError(" https://git.example.com/ ")).toBeNull();
22+ expect(serverInputError("http://localhost:8787")).toBeNull();
23+ expect(serverInputError("")).toMatch(/Enter/);
24+ expect(serverInputError("git example")).toMatch(/spaces/);
25+ expect(serverInputError("http://git.example.com")).toMatch(/https/);
26+ expect(serverInputError("https://")).toMatch(/valid/);
27+ expect(serverLabel(S)).toBe("diffinery.com");
28+ expect(serverLabel("https://git.example.com:8443")).toBe("git.example.com:8443");
29+ });
30+
31+ it("reads the server the installer left, where the installers write it", () => {
32+ expect(installHintPath({ HOME: "/home/ada" }, "/ignored")).toBe(path.join("/home/ada", ".config", "diffinery", "editor-server"));
33+ expect(installHintPath({ HOME: "/home/ada", XDG_CONFIG_HOME: "/cfg" }, "/ignored")).toBe(path.join("/cfg", "diffinery", "editor-server"));
34+ expect(installHintPath({ XDG_CONFIG_HOME: "relative" }, "/home/bo")).toBe(path.join("/home/bo", ".config", "diffinery", "editor-server"));
35+ expect(readInstallHint("https://git.example.com/\n")).toBe("https://git.example.com");
36+ expect(readInstallHint("git.example.com\r\n")).toBe("https://git.example.com");
37+ expect(readInstallHint("http://git.example.com\n")).toBeNull();
38+ expect(readInstallHint("")).toBeNull();
39+ expect(readInstallHint("{\"server\": 1}")).toBeNull();
40+ const sh = readFileSync(path.join(__dirname, "../install/install.sh"), "utf8");
41+ const ps1 = readFileSync(path.join(__dirname, "../install/install.ps1"), "utf8");
42+ expect(sh).toContain('"$conf/diffinery/editor-server"');
43+ expect(ps1).toContain('"diffinery\\editor-server"');
44+ });
45+
46+ it("registers every command it contributes", () => {
47+ const pkg = JSON.parse(readFileSync(path.join(__dirname, "../package.json"), "utf8"));
48+ const src = readFileSync(path.join(__dirname, "../src/extension.ts"), "utf8");
49+ for (const c of pkg.contributes.commands as { command: string }[]) expect(src).toContain(`"${c.command}"`);
50+ });
51+
1752 it("returns to the editor by its own scheme when the server knows it", () => {
1853 expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth");
1954 expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth");
modifiedcontext7.json+1−1View file
@@ -1,7 +1,7 @@
11 {
22 "$schema": "https://context7.com/schema/context7.json",
33 "projectTitle": "Diffinery",
4- "description": "Open-source git forge with GitHub Actions-compatible CI, pull requests, an MCP server and AI agent guardrails, running entirely on Cloudflare Workers, Durable Objects, R2 and Containers.",
4+ "description": "Open-source git forge with GitHub Actions-compatible CI, pull requests, AI agent guardrails, agent tasks and agent review with Claude Code or Codex, an MCP server and Claude Code and Codex plugins, running entirely on Cloudflare Workers, Durable Objects, R2 and Containers.",
55 "folders": ["apps/diffinery/docs", "apps/diffinery/site"],
66 "excludeFolders": ["apps/diffinery/docs/images", "apps/diffinery/site/blog"],
77 "rules": [
addeddocs/images/agent-pull-request.png+0−0View file
Binary file not shown.
addeddocs/images/agent-task.png+0−0View file
Binary file not shown.
addeddocs/images/merge-on-green.png+0−0View file
Binary file not shown.
addeddocs/images/today.png+0−0View file
Binary file not shown.
modifiedpackage.json+6−2View file
@@ -1,7 +1,7 @@
11 {
22 "name": "diffinery",
33 "private": true,
4- "description": "Open-source GitHub and GitHub Actions alternative that runs entirely on Cloudflare: git hosting, pull requests, code review, drop-in GitHub Actions CI and AI agent guardrails.",
4+ "description": "Open-source GitHub and GitHub Actions alternative that runs entirely on Cloudflare: git hosting, pull requests, code review, drop-in GitHub Actions CI, AI agent guardrails, agent tasks and reviews with Claude Code or Codex, and plugins for Claude Code and Codex.",
55 "homepage": "https://diffinery.com",
66 "repository": {
77 "type": "git",
@@ -26,7 +26,11 @@
2626 "serverless",
2727 "code-review",
2828 "ai-agents",
29- "mcp"
29+ "ai-code-review",
30+ "claude-code",
31+ "codex",
32+ "mcp",
33+ "mcp-server"
3034 ],
3135 "license": "Apache-2.0",
3236 "packageManager": "pnpm@10.28.0",
modifiedplugins/README.md+38−8View file
@@ -2,7 +2,8 @@
22
33 A plugin marketplace, kept in the Diffinery repository itself, that makes Claude Code and
44 OpenAI's Codex CLI work smoothly with repositories hosted on [Diffinery](https://diffinery.com),
5-the git forge built for AI agents.
5+the open-source git forge built for AI agents: on diffinery.com or on any instance you deploy
6+yourself (see [Any Diffinery instance](#any-diffinery-instance)).
67 It has two plugins: `diffinery` (Claude Code and Codex) and `diffinery-ci` (Claude Code),
78 described below.
89
@@ -10,7 +11,7 @@
1011
1112 | Piece | Claude Code | Codex |
1213 | --- | --- | --- |
13-| `diffinery` MCP server (`https://diffinery.com/mcp`, OAuth sign-in) | yes | yes |
14+| `diffinery` MCP server (`<Diffinery URL>/mcp`, OAuth sign-in) | yes | yes (diffinery.com; see below for others) |
1415 | `diffinery` skill: the CLI, git auth, the branch → PR → CI → merge loop, Diffinery's agent rules | yes | yes |
1516 | `pr-status`: this branch's PR, its checks, and the log tail of failing jobs | `/diffinery:pr-status` command | `pr-status` skill |
1617 | SessionStart hook: in a Diffinery clone, tells the agent the repo is on Diffinery and whether the `diffinery` CLI is installed and signed in | yes | yes, once you trust it (`/hooks`) |
@@ -25,8 +26,10 @@
2526 | Status line entry (`PR #7 ✓ 10/10`) and a toast when CI passes or fails | Where Claude Code draws |
2627 | After the session pushes a branch or opens its PR, a note to Claude once that commit's checks settle (failures where the band shows the rest, passes too where nothing draws, so a cloud thread hears either way; `/diffinery-ci notes auto|failures|all|off` changes it, nothing to set up) | Everywhere hooks run, cloud sessions and project threads included |
2728
28-It reads Diffinery's GraphQL API with the token from `DIFFINERY_TOKEN`, `diffinery login`,
29-or gh signed in to the host (`diffinery auth setup-gh`), and sends it nowhere else. It needs
29+It reads the GraphQL API of the Diffinery instance in `DIFFINERY_URL`, the plugin's Diffinery
30+URL setting or the CLI's saved URL (in that order, else diffinery.com), with the token from
31+`DIFFINERY_TOKEN`, `diffinery login`, or gh signed in to the host (`diffinery auth setup-gh`),
32+and sends a token only to the instance it belongs to. It needs
3033 Claude Code 2.1.287 or later (the desktop app: 2.1.286). Cloud sessions run its hooks but
3134 draw no mod interface, so there it's the note to Claude and the `/diffinery-ci` text.
3235
@@ -77,6 +80,30 @@
7780 you review and trust them: open `/hooks` in the Codex TUI and trust the `diffinery`
7881 SessionStart hook (optional; the skill works without it).
7982
83+## Any Diffinery instance
84+
85+Diffinery is open source, and every plugin here works with any instance, not only
86+diffinery.com. Both Claude Code plugins have a **Diffinery URL** setting (`userConfig.url`,
87+default `https://diffinery.com`). It sets the MCP server's address (`.mcp.json` uses
88+`${user_config.url}/mcp`), the instance the SessionStart hook recognises in a clone (it gets
89+the value as `CLAUDE_PLUGIN_OPTION_URL`), and the instance `diffinery-ci` reads.
90+
91+- `diffinery setup --url https://git.example.com` sets it for you, in `pluginConfigs` in
92+ `~/.claude/settings.json`, for the copies it installs (`diffinery@inline`) and for
93+ marketplace installs alike. Install the CLI from the instance itself:
94+ `curl -fsSL https://git.example.com/install | bash`.
95+- Claude Code asks for it when you enable a marketplace install; change it later in `/config`.
96+- The zips an instance serves at `/-/claude/<name>.zip` default the setting to that instance
97+ (`apps/diffinery/src/docs/claude-plugins.ts`), so uploads to claude.ai need nothing else.
98+- The hook also knows `DIFFINERY_URL` and the URL `diffinery login` saved; `DIFFINERY_URL`
99+ takes precedence for the hook and for `diffinery-ci`, as it does for the CLI.
100+
101+Codex substitutes no plugin settings, so the Codex manifest reads its own
102+`codex.mcp.json`, which names `https://diffinery.com/mcp`. A server of your own named
103+`diffinery` replaces it, and `diffinery setup --url` adds one when Codex is installed:
104+`codex mcp add diffinery --url https://git.example.com/mcp`. The skills and the hook work
105+with any instance in Codex too.
106+
80107 ## Sign in
81108
82109 The MCP server signs you in with OAuth in the browser:
@@ -124,14 +151,15 @@
124151
125152 ## claude.ai and the Claude apps
126153
127-Settings → Connectors → Add custom connector, with the URL `https://diffinery.com/mcp`.
128-You sign in with your Diffinery account when you connect it.
154+Settings → Connectors → Add custom connector, with the URL `https://diffinery.com/mcp`
155+(or your own instance's `https://git.example.com/mcp`). You sign in with your Diffinery
156+account when you connect it.
129157
130158 ## gh
131159
132160 Prefer the `diffinery` CLI: it knows Diffinery's extras (`--intent`, auto-merge,
133161 `run logs`). `gh` works too once you run `diffinery auth setup-gh`, which signs gh in to
134-diffinery.com as a GitHub Enterprise host. That is also what lets Claude Code show the
162+the Diffinery host (diffinery.com or yours) as a GitHub Enterprise host. That is also what lets Claude Code show the
135163 branch's pull request with its CI status. See https://diffinery.com/docs/agents/github-cli.
136164
137165 ## Layout
@@ -144,7 +172,9 @@
144172 plugins/diffinery/
145173 .claude-plugin/plugin.json Claude Code manifest
146174 .codex-plugin/plugin.json Codex manifest
147- .mcp.json the diffinery MCP server (both clients)
175+ .claude-plugin/icon.png the listing icon (also in diffinery-ci)
176+ .mcp.json the diffinery MCP server (Claude Code: ${user_config.url}/mcp)
177+ codex.mcp.json the diffinery MCP server (Codex: diffinery.com)
148178 skills/diffinery/SKILL.md the skill (both clients)
149179 commands/pr-status.md /pr-status (Claude Code)
150180 codex-skills/pr-status/SKILL.md pr-status skill (Codex has no plugin commands)
addedplugins/diffinery-ci/.claude-plugin/icon.png+0−0View file
Binary file not shown.
modifiedplugins/diffinery-ci/.claude-plugin/plugin.json+14−3View file
@@ -1,12 +1,23 @@
11 {
22 "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
33 "name": "diffinery-ci",
4- "version": "0.2.3",
4+ "version": "0.2.4",
55 "description": "Live CI for Diffinery pull requests in Claude Code: the branch's PR and its checks above the prompt and in the status line, a checks pane, and a note to Claude when CI on a commit it pushed settles.",
6- "author": { "name": "Codician" },
6+ "author": { "name": "Codician Team" },
77 "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude",
88 "repository": "https://diffinery.com/codician/diffinery",
99 "license": "Apache-2.0",
10+ "icon": "./.claude-plugin/icon.png",
11+ "documentationUrl": "https://diffinery.com/docs/connect/ci-status-in-claude",
12+ "supportUrl": "https://diffinery.com/codician/diffinery/issues",
1013 "keywords": ["diffinery", "ci", "pull-requests", "status"],
11- "types": "./types/index.d.ts"
14+ "types": "./types/index.d.ts",
15+ "userConfig": {
16+ "url": {
17+ "type": "string",
18+ "title": "Diffinery URL",
19+ "description": "The Diffinery instance your repositories are on: https://diffinery.com, or your own, such as https://git.example.com.",
20+ "default": "https://diffinery.com"
21+ }
22+ }
1223 }
modifiedplugins/diffinery-ci/hooks/lib.ts+36−0View file
@@ -15,6 +15,8 @@
1515 */
1616 export function remoteRepo(url: string, host: string): string | null {
1717 const u = url.trim()
18+ // Remotes name the host with or without the port the Diffinery URL has (localhost:8787).
19+ host = host.replace(/:\d+$/, '')
1820 let rest: string | null = null
1921 const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/.exec(u)
2022 if (withScheme) {
@@ -39,6 +41,40 @@
3941 }
4042 }
4143
44+export const DEFAULT_URL = 'https://diffinery.com'
45+
46+/** What readAuth found: the environment, the plugin's Diffinery URL setting and the CLI's config file. */
47+export type AuthInputs = {
48+ envUrl: string | null
49+ envToken: string | null
50+ option: string | null
51+ savedUrl: string | null
52+ savedToken: string | null
53+}
54+
55+const clean = (url: string | null): string | null => {
56+ const u = url?.trim().replace(/\/+$/, '')
57+ return u && hostOf(u) ? u : null
58+}
59+
60+/**
61+ * Which Diffinery instance to read, and the token that belongs to it. The instance is
62+ * DIFFINERY_URL, else the plugin's Diffinery URL setting when it isn't the default, else the
63+ * URL `diffinery login` saved, else diffinery.com. A token only goes to its own instance, as
64+ * the CLI uses it: DIFFINERY_TOKEN to DIFFINERY_URL (else the saved URL, else diffinery.com),
65+ * the saved token to the saved URL (else diffinery.com). Null token: try gh for the host.
66+ */
67+export function chooseAuth(i: AuthInputs): { url: string; host: string; token: string | null; source: string } {
68+ const envUrl = clean(i.envUrl)
69+ const saved = clean(i.savedUrl)
70+ const option = clean(i.option)
71+ const url = envUrl ?? (option && option !== DEFAULT_URL ? option : null) ?? saved ?? DEFAULT_URL
72+ const host = hostOf(url)!
73+ if (i.envToken && hostOf(envUrl ?? saved ?? DEFAULT_URL) === host) return { url, host, token: i.envToken, source: 'DIFFINERY_TOKEN' }
74+ if (i.savedToken && hostOf(saved ?? DEFAULT_URL) === host) return { url, host, token: i.savedToken, source: 'diffinery login' }
75+ return { url, host, token: null, source: '' }
76+}
77+
4278 /** Picks the first Diffinery remote from `git remote -v`-style lines, origin first. */
4379 export function pickRemote(remotes: readonly { name: string; url: string }[], host: string): string | null {
4480 const ordered = [...remotes].sort((a, b) => Number(b.name === 'origin') - Number(a.name === 'origin'))
modifiedplugins/diffinery-ci/hooks/register.tsx+27−25View file
@@ -8,8 +8,10 @@
88 // where the band shows the rest, passes too where nothing draws, so a cloud thread hears either
99 // way. /diffinery-ci answers in text where nothing draws. Nothing to set up.
1010 //
11-// It reads Diffinery's GraphQL API with the token of the diffinery CLI (DIFFINERY_TOKEN or
12-// `diffinery login`) or of gh signed in to the Diffinery host, and sends it only there.
11+// It reads the GraphQL API of the Diffinery instance the session uses (DIFFINERY_URL, the
12+// plugin's Diffinery URL setting, the CLI's saved URL, else diffinery.com) with the token of
13+// the diffinery CLI (DIFFINERY_TOKEN or `diffinery login`) or of gh signed in to that host,
14+// and sends a token only to the instance it belongs to.
1315
1416 import { atom, read, update } from 'claude-code'
1517 import type { Register } from 'claude-code'
@@ -19,9 +21,9 @@
1921 QUERY,
2022 SYMBOL,
2123 checksLine,
24+ chooseAuth,
2225 duration,
2326 factsLine,
24- hostOf,
2527 ordered,
2628 parsePr,
2729 parseRemotes,
@@ -39,7 +41,6 @@
3941
4042 const PLUGIN = 'diffinery-ci'
4143 const PANE = 'diffinery-ci'
42-const DEFAULT_URL = 'https://diffinery.com'
4344
4445 // Change a tag when the stored value's fields change: it declines what an earlier version
4546 // stored, which outlives a reload.
@@ -60,7 +61,7 @@
6061 off: 'never',
6162 }
6263
63-export const register: Register = on => {
64+export const register: Register = (on, options) => {
6465 // What the person chose with `/diffinery-ci notes`, kept across sessions.
6566 let choice: WakeChoice = 'auto'
6667
@@ -86,28 +87,29 @@
8687 const seen = new Map<string, string>()
8788
8889 const readAuth = async (): Promise<Auth> => {
89- let url = (await $.env.get('DIFFINERY_URL')) ?? null
90- let token = (await $.env.get('DIFFINERY_TOKEN')) ?? null
91- let source = token ? 'DIFFINERY_TOKEN' : ''
92- if (!url || !token) {
93- const xdg = await $.env.get('XDG_CONFIG_HOME')
94- const home = await $.env.get('HOME')
95- const base = xdg && xdg.startsWith('/') ? xdg : home ? `${home}/.config` : null
96- if (base) {
97- try {
98- const cfg = JSON.parse(await $.fs.read(`${base}/diffinery/config.json`)) as Record<string, unknown>
99- if (!url && typeof cfg.url === 'string') url = cfg.url
100- if (!token && typeof cfg.token === 'string' && cfg.token) {
101- token = cfg.token
102- source = 'diffinery login'
103- }
104- } catch {
105- // No config file, or not JSON: the CLI isn't signed in here.
106- }
90+ let savedUrl: string | null = null
91+ let savedToken: string | null = null
92+ const xdg = await $.env.get('XDG_CONFIG_HOME')
93+ const home = await $.env.get('HOME')
94+ const base = xdg && xdg.startsWith('/') ? xdg : home ? `${home}/.config` : null
95+ if (base) {
96+ try {
97+ const cfg = JSON.parse(await $.fs.read(`${base}/diffinery/config.json`)) as Record<string, unknown>
98+ if (typeof cfg.url === 'string') savedUrl = cfg.url
99+ if (typeof cfg.token === 'string' && cfg.token) savedToken = cfg.token
100+ } catch {
101+ // No config file, or not JSON: the CLI isn't signed in here.
107102 }
108103 }
109- url = (url ?? DEFAULT_URL).replace(/\/+$/, '')
110- const host = hostOf(url) ?? 'diffinery.com'
104+ const picked = chooseAuth({
105+ envUrl: (await $.env.get('DIFFINERY_URL')) ?? null,
106+ envToken: (await $.env.get('DIFFINERY_TOKEN')) ?? null,
107+ option: typeof options?.url === 'string' ? options.url : null,
108+ savedUrl,
109+ savedToken,
110+ })
111+ const { url, host } = picked
112+ let { token, source } = picked
111113 if (!token) {
112114 try {
113115 const gh = await $.process.run(['gh', 'auth', 'token', '--hostname', host], { timeoutMs: 5000 })
modifiedplugins/diffinery-ci/tests/ci.test.tsx+23−0View file
@@ -3,6 +3,7 @@
33
44 import {
55 checksLine,
6+ chooseAuth,
67 parsePr,
78 pollDelay,
89 publishes,
@@ -50,6 +51,28 @@
5051 },
5152 })
5253
54+test('the instance comes from DIFFINERY_URL, the setting, then the CLI, and tokens stay with theirs', async () => {
55+ const none = { envUrl: null, envToken: null, option: null, savedUrl: null, savedToken: null }
56+ expect(chooseAuth(none)).toEqual({ url: 'https://diffinery.com', host: 'diffinery.com', token: null, source: '' })
57+ expect(chooseAuth({ ...none, option: 'https://diffinery.com', savedUrl: 'https://git.example.com/', savedToken: 't' })).toEqual({
58+ url: 'https://git.example.com',
59+ host: 'git.example.com',
60+ token: 't',
61+ source: 'diffinery login',
62+ })
63+ // The setting picks the instance, but the CLI's diffinery.com token doesn't go there.
64+ expect(chooseAuth({ ...none, option: 'https://git.example.com', savedToken: 't', envToken: 'e' })).toEqual({
65+ url: 'https://git.example.com',
66+ host: 'git.example.com',
67+ token: null,
68+ source: '',
69+ })
70+ expect(chooseAuth({ ...none, envUrl: 'http://localhost:8787', envToken: 'e', option: 'https://git.example.com' }).token).toBe('e')
71+ expect(chooseAuth({ ...none, envUrl: 'http://localhost:8787', envToken: 'e' }).host).toBe('localhost:8787')
72+ expect(chooseAuth({ ...none, option: 'not a url', savedToken: 't' })).toEqual({ url: 'https://diffinery.com', host: 'diffinery.com', token: 't', source: 'diffinery login' })
73+ expect(remoteRepo('http://localhost:8787/dev/repo.git', 'localhost:8787')).toBe('dev/repo')
74+})
75+
5376 test('remotes on the Diffinery host are read, lookalikes are not', async () => {
5477 expect(remoteRepo('https://diffinery.com/codician/demo.git', 'diffinery.com')).toBe('codician/demo')
5578 expect(remoteRepo('https://x-token@diffinery.com/codician/demo', 'diffinery.com')).toBe('codician/demo')
addedplugins/diffinery/.claude-plugin/icon.png+0−0View file
Binary file not shown.
modifiedplugins/diffinery/.claude-plugin/plugin.json+14−3View file
@@ -1,11 +1,22 @@
11 {
22 "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
33 "name": "diffinery",
4- "version": "0.1.3",
4+ "version": "0.1.4",
55 "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.",
6- "author": { "name": "Codician" },
6+ "author": { "name": "Codician Team" },
77 "homepage": "https://diffinery.com/docs/connect/claude-code",
88 "repository": "https://diffinery.com/codician/diffinery",
99 "license": "Apache-2.0",
10- "keywords": ["diffinery", "git", "pull-requests", "ci", "code-review", "mcp"]
10+ "icon": "./.claude-plugin/icon.png",
11+ "documentationUrl": "https://diffinery.com/docs/connect/claude-code",
12+ "supportUrl": "https://diffinery.com/codician/diffinery/issues",
13+ "keywords": ["diffinery", "git", "pull-requests", "ci", "code-review", "mcp"],
14+ "userConfig": {
15+ "url": {
16+ "type": "string",
17+ "title": "Diffinery URL",
18+ "description": "The Diffinery instance your repositories are on: https://diffinery.com, or your own, such as https://git.example.com.",
19+ "default": "https://diffinery.com"
20+ }
21+ }
1122 }
modifiedplugins/diffinery/.codex-plugin/plugin.json+4−4View file
@@ -1,9 +1,9 @@
11 {
22 "name": "diffinery",
3- "version": "0.1.3",
3+ "version": "0.1.4",
44 "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.",
55 "author": {
6- "name": "Codician"
6+ "name": "Codician Team"
77 },
88 "homepage": "https://diffinery.com/docs/connect/claude-code",
99 "repository": "https://diffinery.com/codician/diffinery",
@@ -20,12 +20,12 @@
2020 "./skills/",
2121 "./codex-skills/"
2222 ],
23- "mcpServers": "./.mcp.json",
23+ "mcpServers": "./codex.mcp.json",
2424 "interface": {
2525 "displayName": "Diffinery",
2626 "shortDescription": "Pull requests, CI and issues on Diffinery",
2727 "longDescription": "Lets Codex open pull requests, watch CI checks, read failing job logs and manage issues on repositories hosted on Diffinery, through the diffinery CLI and the Diffinery MCP server.",
28- "developerName": "Codician",
28+ "developerName": "Codician Team",
2929 "category": "Coding",
3030 "capabilities": [
3131 "Read",
modifiedplugins/diffinery/.mcp.json+1−1View file
@@ -2,7 +2,7 @@
22 "mcpServers": {
33 "diffinery": {
44 "type": "http",
5- "url": "https://diffinery.com/mcp"
5+ "url": "${user_config.url}/mcp"
66 }
77 }
88 }
modifiedplugins/diffinery/codex-skills/pr-status/SKILL.md+5−4View file
@@ -1,6 +1,6 @@
11 ---
22 name: pr-status
3-description: Show the Diffinery pull request for the current branch (or a given PR number), its CI checks, and the log tail of any failing job. Use when the user asks for PR status, CI status, or why checks failed in a repository hosted on diffinery.com. Codex counterpart of the Claude Code /pr-status command.
3+description: Show the Diffinery pull request for the current branch (or a given PR number), its CI checks, and the log tail of any failing job. Use when the user asks for PR status, CI status, or why checks failed in a repository hosted on Diffinery (diffinery.com or a self-hosted instance). Codex counterpart of the Claude Code /pr-status command.
44 ---
55
66 # PR status
@@ -11,9 +11,10 @@
1111 comments.
1212
1313 1. Run `git remote -v` and `git branch --show-current`. If no remote is on
14- diffinery.com, say this repository is not hosted on Diffinery and stop. If
15- `diffinery` is not installed, say how to install it
16- (`curl -fsSL https://diffinery.com/install | bash`) and stop.
14+ diffinery.com or another Diffinery instance (the one `diffinery whoami` names, or one
15+ the session's Diffinery note names), say this repository is not hosted on Diffinery
16+ and stop. If `diffinery` is not installed, say how to install it
17+ (`curl -fsSL https://HOST/install | bash`, with the repository's Diffinery host) and stop.
1718 2. Run `diffinery pr view [N]`. If there is no open pull request for the branch, say so
1819 (suggest `diffinery pr create ... --intent ...`) and stop.
1920 3. Run `diffinery pr checks [N]` (exit status 0 = passed, 1 = failed, 8 = pending; a
addedplugins/diffinery/codex.mcp.json+8−0View file
@@ -1,0 +1,8 @@
1+{
2+ "mcpServers": {
3+ "diffinery": {
4+ "type": "http",
5+ "url": "https://diffinery.com/mcp"
6+ }
7+ }
8+}
modifiedplugins/diffinery/commands/pr-status.md+4−3View file
@@ -9,9 +9,10 @@
99 change anything: no pushes, merges, re-runs or comments.
1010
1111 1. Run `git remote -v` and `git branch --show-current`. If no remote is on
12- diffinery.com, say this repository is not hosted on Diffinery and stop. If
13- `diffinery` is not installed, say how to install it
14- (`curl -fsSL https://diffinery.com/install | bash`) and stop.
12+ diffinery.com or another Diffinery instance (the one `diffinery whoami` names, or one
13+ the session's Diffinery note names), say this repository is not hosted on Diffinery
14+ and stop. If `diffinery` is not installed, say how to install it
15+ (`curl -fsSL https://HOST/install | bash`, with the repository's Diffinery host) and stop.
1516 2. Run `diffinery pr view $ARGUMENTS`. If there is no open pull request for the branch,
1617 say so (suggest `diffinery pr create ... --intent ...`) and stop.
1718 3. Run `diffinery pr checks $ARGUMENTS` (exit status 0 = passed, 1 = failed,
modifiedplugins/diffinery/hooks/session-start.sh+55−19View file
@@ -1,25 +1,56 @@
11 #!/bin/sh
22 # SessionStart hook for the diffinery plugin (Claude Code and Codex).
33 #
4-# When the session's working directory is a git repository with a remote on diffinery.com,
4+# When the session's working directory is a git repository with a remote on a Diffinery instance,
55 # prints JSON that adds a short note to the agent's context: where the repository is hosted,
6-# whether the diffinery CLI is installed and signed in, and that it is preferred over gh there.
6+# whether the diffinery CLI is installed and signed in there, and that it is preferred over gh.
77 # Elsewhere it prints nothing. It never touches the network and always exits 0.
8+#
9+# The instances it knows, in this order: DIFFINERY_URL, the plugin's Diffinery URL setting
10+# (CLAUDE_PLUGIN_OPTION_URL, Claude Code only), the URL `diffinery login` saved, and diffinery.com.
811
12+# The origin of a URL as scheme://host[:port], lower-cased; nothing unless it is plain http(s) with a
13+# host of letters, digits, dots and dashes, so it is safe to put in a pattern and in JSON.
14+origin_of() {
15+ printf '%s\n' "$1" | tr 'A-Z' 'a-z' |
16+ sed -n -E 's#^[[:space:]]*(https?://)?([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(:[0-9]{1,5})?(/.*)?[[:space:]]*$#\1\2\4#p' |
17+ sed '/:\/\//!s#^#https://#' | head -n 1
18+}
19+
920 main() {
1021 dir=${CLAUDE_PROJECT_DIR:-$PWD}
1122 command -v git >/dev/null 2>&1 || return 0
1223
13- # origin first, then any other remote. Only diffinery.com itself counts (not lookalike
14- # hosts), and owner/repo are limited to characters that are safe to put in JSON.
15- slug=$(
24+ case ${XDG_CONFIG_HOME:-} in
25+ /*) cfg=$XDG_CONFIG_HOME/diffinery/config.json ;;
26+ *) cfg=$HOME/.config/diffinery/config.json ;;
27+ esac
28+ saved=
29+ [ -f "$cfg" ] && saved=$(sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$cfg" 2>/dev/null | head -n 1)
30+
31+ # origin first, then any other remote.
32+ remotes=$(
1633 {
1734 git -C "$dir" remote get-url origin
1835 git -C "$dir" config --get-regexp '^remote\..*\.url$' | sed 's/^[^ ]* //'
19- } 2>/dev/null |
20- sed -n -E 's#^([A-Za-z][A-Za-z0-9+.-]*://)?([^@/]+@)?diffinery\.com(:[0-9]+)?[:/]+([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/*$#\4/\5#p' |
21- head -n 1
36+ } 2>/dev/null
2237 )
38+ [ -n "$remotes" ] || return 0
39+
40+ # Only the instance's host itself counts (not lookalike hosts), and owner/repo are limited to
41+ # characters that are safe to put in JSON.
42+ slug= base=
43+ for u in "${DIFFINERY_URL:-}" "${CLAUDE_PLUGIN_OPTION_URL:-}" "$saved" https://diffinery.com; do
44+ base=$(origin_of "$u")
45+ [ -n "$base" ] || continue
46+ host=${base#*://}
47+ host=${host%%:*}
48+ pat=$(printf '%s' "$host" | sed 's/[.]/[.]/g')
49+ slug=$(printf '%s\n' "$remotes" |
50+ sed -n -E "s#^([A-Za-z][A-Za-z0-9+.-]*://)?([^@/]+@)?$pat(:[0-9]+)?[:/]+([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/*\$#\\4/\\5#p" |
51+ head -n 1)
52+ [ -n "$slug" ] && break
53+ done
2354 [ -n "$slug" ] || return 0
2455 slug=${slug%.git}
2556
@@ -30,22 +61,27 @@
3061 cli="yes, at ${DIFFINERY_INSTALL:-$HOME/.diffinery}/bin/diffinery (not on PATH)"
3162 fi
3263
64+ # The CLI's token belongs to the instance the CLI points at: DIFFINERY_URL, else the saved URL.
65+ cli_url=$(origin_of "${DIFFINERY_URL:-${saved:-https://diffinery.com}}")
3366 signed=no
34- case ${XDG_CONFIG_HOME:-} in
35- /*) cfg=$XDG_CONFIG_HOME/diffinery/config.json ;;
36- *) cfg=$HOME/.config/diffinery/config.json ;;
37- esac
38- if [ -n "${DIFFINERY_TOKEN:-}" ]; then
39- signed="yes (DIFFINERY_TOKEN)"
40- elif [ -f "$cfg" ] && grep -q '"token"' "$cfg" 2>/dev/null; then
41- signed=yes
67+ if [ "${cli_url#*://}" != "${base#*://}" ]; then
68+ other=yes
69+ else
70+ other=no
71+ if [ -n "${DIFFINERY_TOKEN:-}" ]; then
72+ signed="yes (DIFFINERY_TOKEN)"
73+ elif [ -f "$cfg" ] && grep -q '"token"' "$cfg" 2>/dev/null; then
74+ signed=yes
75+ fi
4276 fi
4377
44- msg="This repository is hosted on Diffinery ($slug). Use the diffinery CLI (installed: $cli, signed in: $signed) or the diffinery MCP tools for pull requests, CI and issues (they support Diffinery's --intent, auto-merge and run logs); gh works once \`diffinery auth setup-gh\` has signed it in to diffinery.com. The diffinery skill has the workflow."
78+ msg="This repository is hosted on the Diffinery instance at $base ($slug). Use the diffinery CLI (installed: $cli, signed in there: $signed) or the diffinery MCP tools for pull requests, CI and issues (they support Diffinery's --intent, auto-merge and run logs); gh works once \`diffinery auth setup-gh\` has signed it in to $host. The diffinery skill has the workflow."
4579 case $cli in
46- no) msg="$msg Install the CLI with \`curl -fsSL https://diffinery.com/install | bash\`." ;;
80+ no) msg="$msg Install the CLI with \`curl -fsSL $base/install | bash\`." ;;
4781 esac
48- if [ "$signed" = no ]; then
82+ if [ "$other" = yes ]; then
83+ msg="$msg The CLI points at ${cli_url:-another instance}: to use it here, ask the user to run \`diffinery setup --url $base\` (or \`diffinery login --url $base\`), or set DIFFINERY_URL=$base and DIFFINERY_TOKEN."
84+ elif [ "$signed" = no ]; then
4985 msg="$msg To sign in, ask the user to run \`diffinery login\` (or \`diffinery login --device\`), or set DIFFINERY_TOKEN."
5086 fi
5187
modifiedplugins/diffinery/skills/diffinery/SKILL.md+24−11View file
@@ -1,13 +1,16 @@
11 ---
22 name: diffinery
3-description: Work with repositories hosted on Diffinery (diffinery.com), the git forge built for AI agents. Use whenever the current repository has a git remote on diffinery.com, or the user mentions Diffinery, or asks about pull requests, CI, checks, workflow runs, job logs, merges or issues in a Diffinery-hosted repository. Covers the diffinery CLI (preferred over gh here), the diffinery MCP tools, git auth and the agent rules.
3+description: Work with repositories hosted on Diffinery (diffinery.com or a self-hosted instance), the open-source git forge built for AI agents. Use whenever the current repository has a git remote on diffinery.com or another Diffinery instance, or the user mentions Diffinery, or asks about pull requests, CI, checks, workflow runs, job logs, merges or issues in a Diffinery-hosted repository. Covers the diffinery CLI (preferred over gh here), the diffinery MCP tools, git auth and the agent rules.
44 ---
55
66 # Diffinery
77
8-Diffinery is a git forge built for AI agents. It speaks git over HTTPS and has a
9-GitHub-compatible API. Prefer the `diffinery` CLI or the `diffinery` MCP tools for pull
10-requests, CI and issues: they know Diffinery's extras (`--intent`, auto-merge, run logs).
8+Diffinery is an open-source git forge built for AI agents. It speaks git over HTTPS and
9+has a GitHub-compatible API. It runs at https://diffinery.com and on instances people deploy
10+on their own Cloudflare accounts; everything here works the same on each, with that
11+instance's URL in place of `https://diffinery.com` (written `$URL` below). Prefer the
12+`diffinery` CLI or the `diffinery` MCP tools for pull requests, CI and issues: they know
13+Diffinery's extras (`--intent`, auto-merge, run logs).
1114
1215 ## Is this repository on Diffinery?
1316
@@ -16,15 +19,19 @@
1619 ```
1720
1821 A remote like `https://diffinery.com/OWNER/REPO.git` means yes, and everything below
19-applies. `gh` works here once `diffinery auth setup-gh` has signed it in to diffinery.com
22+applies. A remote on another host may be a self-hosted Diffinery: the SessionStart note
23+says so when the host is the plugin's Diffinery URL setting, `DIFFINERY_URL` or the URL
24+`diffinery login` saved, and `curl -fsS https://HOST/api/v3/meta` answers
25+`{"name":"Diffinery",...}` on any Diffinery. `diffinery whoami` prints the instance the
26+CLI uses. `gh` works here once `diffinery auth setup-gh` has signed it in to the instance
2027 (it treats Diffinery as a GitHub Enterprise host); until then `gh` has no token for it.
2128 Prefer `diffinery`: it knows Diffinery's extras (`--intent`, `run logs`).
2229
2330 ## Set up (once per machine)
2431
2532 ```sh
26-command -v diffinery || curl -fsSL https://diffinery.com/install | bash # Windows: irm https://diffinery.com/install.ps1 | iex
27-diffinery whoami # who you are signed in as
33+command -v diffinery || curl -fsSL $URL/install | bash # Windows: irm $URL/install.ps1 | iex
34+diffinery whoami # who you are signed in as, and on which instance
2835 ```
2936
3037 The installer puts the CLI in `~/.diffinery/bin`; if `diffinery` is not found right
@@ -36,12 +43,15 @@
3643 - Interactive machine: ask the user to run `diffinery login` (opens a browser) or
3744 `diffinery login --device` (prints a code to enter elsewhere). Do not try to complete
3845 a browser sign-in yourself.
46+- Another instance than diffinery.com: `diffinery setup --url $URL` (or
47+ `diffinery login --url $URL`) points the CLI there and saves it; for one command or a
48+ headless machine, set `DIFFINERY_URL=$URL` with `DIFFINERY_TOKEN`.
3949
40-Git auth: `diffinery auth setup-git` installs a credential helper for diffinery.com
50+Git auth: `diffinery auth setup-git` installs a credential helper for the instance
4151 that uses the CLI's token (including `DIFFINERY_TOKEN`). Without the CLI, git accepts
4252 the token as the HTTPS password. Plain git (clone, fetch, push) needs nothing else.
4353
44-gh: `diffinery auth setup-gh` signs `gh` in to diffinery.com with the same token, so
54+gh: `diffinery auth setup-gh` signs `gh` in to the instance with the same token, so
4555 `gh pr ...` works in Diffinery clones (needs `gh` installed).
4656
4757 ## The loop
@@ -109,8 +119,11 @@
109119 `search_docs`, `read_docs`, ...): use them when there is no shell or CLI, for a repo
110120 you have not cloned, or when the user prefers them. If they say authentication is
111121 needed, ask the user to sign in (`/mcp` in Claude Code, `codex mcp login diffinery`
112- in Codex), or use the CLI instead.
113-- Diffinery's own documentation: `search_docs` / `read_docs`, or https://diffinery.com/docs.
122+ in Codex), or use the CLI instead. The plugin's server is `https://diffinery.com/mcp`
123+ unless its Diffinery URL setting (Claude Code: `/plugin`, then the plugin's options) says
124+ otherwise; Codex users on another instance add their own server named `diffinery` with
125+ `codex mcp add diffinery --url $URL/mcp`.
126+- Diffinery's own documentation: `search_docs` / `read_docs`, or `$URL/docs`.
114127
115128 ## Rules Diffinery enforces (work with them, not around them)
116129
addedscripts/awesome-lists.mjs+220−0View file
@@ -1,0 +1,220 @@
1+#!/usr/bin/env node
2+// Suggests Diffinery to curated "awesome" lists on GitHub (awesome-lists/lists.mjs), one pull
3+// request per list, as the account that owns OUTREACH_TOKEN, so they show Codician rather than
4+// a person. For each list it forks the repository, adds one line to the right section on a
5+// branch in the fork, and opens the pull request.
6+//
7+// A list where Diffinery is already listed, or where the pull request is already open or was
8+// closed, is left alone, so running it again changes nothing. A list whose rules don't allow
9+// the suggestion yet (notBefore) is skipped until that day. Run by the "Suggest Diffinery to
10+// awesome lists" workflow; by hand:
11+//
12+// OUTREACH_TOKEN=ghp_... node scripts/awesome-lists.mjs [--dry-run] [--lists=id,id]
13+//
14+// OUTREACH_TOKEN must be a classic personal access token with the public_repo scope: GitHub's
15+// fine-grained tokens can't contribute to public repositories the account isn't a member of.
16+import { LISTS, FOOTER } from "./awesome-lists/lists.mjs";
17+
18+const TOKEN = process.env.OUTREACH_TOKEN;
19+const DRY = process.argv.includes("--dry-run");
20+const pick = (process.argv.find((a) => a.startsWith("--lists="))?.slice(8) || "due").trim();
21+const today = (process.env.TODAY || new Date().toISOString()).slice(0, 10);
22+const API = process.env.GITHUB_API_URL_OVERRIDE || "https://api.github.com";
23+const BRANCH = "add-diffinery";
24+
25+const results = [];
26+const report = (list, outcome, detail = "") => {
27+ results.push({ list, outcome, detail });
28+ const line = `${list}: ${outcome}${detail ? ` (${detail})` : ""}`;
29+ console.log(outcome === "failed" ? `::warning::${line}` : line);
30+};
31+
32+class HttpError extends Error {
33+ constructor(status, message) {
34+ super(message);
35+ this.status = status;
36+ }
37+}
38+
39+async function rest(method, url, body) {
40+ const res = await fetch(`${API}${url}`, {
41+ method,
42+ headers: {
43+ authorization: `Bearer ${TOKEN}`,
44+ accept: "application/vnd.github+json",
45+ "x-github-api-version": "2022-11-28",
46+ "user-agent": "diffinery-awesome-lists",
47+ ...(body ? { "content-type": "application/json" } : {}),
48+ },
49+ body: body ? JSON.stringify(body) : undefined,
50+ });
51+ const text = await res.text();
52+ if (!res.ok) throw new HttpError(res.status, `${method} ${url}: ${res.status} ${text.slice(0, 300)}`);
53+ return text ? JSON.parse(text) : null;
54+}
55+
56+/** A file's text. The raw media type also works for files over 1 MB, where `content` comes back empty. */
57+async function fileText(repo, file, ref) {
58+ const res = await fetch(`${API}/repos/${repo}/contents/${encodeURIComponent(file)}?ref=${encodeURIComponent(ref)}`, {
59+ headers: { authorization: `Bearer ${TOKEN}`, accept: "application/vnd.github.raw+json", "x-github-api-version": "2022-11-28", "user-agent": "diffinery-awesome-lists" },
60+ });
61+ const text = await res.text();
62+ if (!res.ok) throw new HttpError(res.status, `GET ${repo}/${file}: ${res.status} ${text.slice(0, 300)}`);
63+ return text;
64+}
65+
66+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
67+
68+/**
69+ * The file with `entry.line` added to `entry.section`, or an error message. The section runs
70+ * from its heading to the next heading of the same or a higher level.
71+ */
72+export function addLine(text, entry) {
73+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
74+ const lines = text.split(eol);
75+ const start = lines.findIndex((l) => l.trim() === entry.section);
76+ if (start < 0) return { error: `no section "${entry.section}"` };
77+ const level = entry.section.match(/^#+/)?.[0].length ?? 6;
78+ let end = lines.length;
79+ for (let i = start + 1; i < lines.length; i++) {
80+ const h = lines[i].match(/^(#+)\s/);
81+ if (h && h[1].length <= level) {
82+ end = i;
83+ break;
84+ }
85+ }
86+ const items = [];
87+ for (let i = start + 1; i < end; i++) if (entry.item.test(lines[i])) items.push(i);
88+ if (!items.length) return { error: `no entries in "${entry.section}"` };
89+ let at;
90+ if (entry.place === "alpha") {
91+ const ours = entry.key(entry.line);
92+ const before = items.filter((i) => entry.key(lines[i]) < ours);
93+ // Sorts first: goes above the first entry.
94+ at = before.length ? before[before.length - 1] + 1 : items[0];
95+ // An entry's indented sub-items stay with it.
96+ while (before.length && at < end && /^\s+\S/.test(lines[at]) && !entry.item.test(lines[at])) at++;
97+ } else {
98+ at = items[items.length - 1] + 1;
99+ while (at < end && /^\s+[*-] /.test(lines[at])) at++;
100+ }
101+ lines.splice(at, 0, entry.line);
102+ return { text: lines.join(eol), at: at + 1 };
103+}
104+
105+async function suggest(entry, me) {
106+ if (entry.notBefore && today < entry.notBefore) {
107+ report(entry.id, "not yet", `its rules allow it from ${entry.notBefore}`);
108+ return;
109+ }
110+ const upstream = await rest("GET", `/repos/${entry.repo}`);
111+ if (upstream.archived) {
112+ report(entry.id, "archived", "the list is read-only now, so drop it from awesome-lists/lists.mjs");
113+ return;
114+ }
115+ const base = upstream.default_branch;
116+
117+ // A pull request from our fork, in any state: open means done, closed means the maintainers said no.
118+ const prs = await rest("GET", `/repos/${entry.repo}/pulls?state=all&per_page=100&head=${me}:${BRANCH}`);
119+ if (prs.length) {
120+ const pr = prs[0];
121+ report(entry.id, pr.merged_at ? "merged" : pr.state === "open" ? "already open" : "closed before, left alone", pr.html_url);
122+ return;
123+ }
124+
125+ const text = await fileText(entry.repo, entry.file, base);
126+ if (/codician-team\/diffinery|diffinery\.com/i.test(text)) {
127+ report(entry.id, "already listed");
128+ return;
129+ }
130+ const preview = addLine(text, entry);
131+ if (preview.error) {
132+ report(entry.id, "failed", `${entry.file}: ${preview.error}; the list changed, so update awesome-lists/lists.mjs`);
133+ return;
134+ }
135+ if (DRY) {
136+ report(entry.id, "would open", `${entry.file} line ${preview.at}: ${entry.line}`);
137+ return;
138+ }
139+
140+ // Named after the owner too: two lists can share a repository name (and a fork that already
141+ // exists comes back under its own name).
142+ const fork = await rest("POST", `/repos/${entry.repo}/forks`, { name: entry.repo.replace("/", "-"), default_branch_only: true });
143+ const forkBase = fork.default_branch || base;
144+ // Forking is asynchronous; wait until the fork answers.
145+ for (let i = 0; i < 20; i++) {
146+ try {
147+ await rest("GET", `/repos/${fork.full_name}/branches/${encodeURIComponent(forkBase)}`);
148+ break;
149+ } catch (e) {
150+ if (i === 19) throw e;
151+ await sleep(3000);
152+ }
153+ }
154+ // A fork left from an earlier run may be behind; bring it up to date where it can be.
155+ await rest("POST", `/repos/${fork.full_name}/merge-upstream`, { branch: forkBase }).catch(() => {});
156+ const head = (await rest("GET", `/repos/${fork.full_name}/git/ref/heads/${encodeURIComponent(forkBase)}`)).object.sha;
157+ const tree = (await rest("GET", `/repos/${fork.full_name}/git/commits/${head}`)).tree.sha;
158+ const changed = addLine(await fileText(fork.full_name, entry.file, head), entry);
159+ if (changed.error) {
160+ report(entry.id, "failed", `${entry.file} in ${fork.full_name}: ${changed.error}`);
161+ return;
162+ }
163+ // Through git objects rather than the contents API, which can't write files over 1 MB.
164+ const blob = await rest("POST", `/repos/${fork.full_name}/git/blobs`, { content: Buffer.from(changed.text, "utf8").toString("base64"), encoding: "base64" });
165+ const newTree = await rest("POST", `/repos/${fork.full_name}/git/trees`, { base_tree: tree, tree: [{ path: entry.file, mode: "100644", type: "blob", sha: blob.sha }] });
166+ const commit = await rest("POST", `/repos/${fork.full_name}/git/commits`, { message: entry.title.replace(/\s*🤖+$/u, ""), tree: newTree.sha, parents: [head] });
167+ try {
168+ await rest("POST", `/repos/${fork.full_name}/git/refs`, { ref: `refs/heads/${BRANCH}`, sha: commit.sha });
169+ } catch (e) {
170+ if (!(e instanceof HttpError) || e.status !== 422) throw e;
171+ // Left from an earlier run that stopped before opening the pull request.
172+ await rest("PATCH", `/repos/${fork.full_name}/git/refs/heads/${BRANCH}`, { sha: commit.sha, force: true });
173+ }
174+ const pr = await rest("POST", `/repos/${entry.repo}/pulls`, {
175+ title: entry.title,
176+ head: `${me}:${BRANCH}`,
177+ base,
178+ body: entry.body + FOOTER,
179+ maintainer_can_modify: true,
180+ });
181+ report(entry.id, "opened", pr.html_url);
182+}
183+
184+async function summary() {
185+ const file = process.env.GITHUB_STEP_SUMMARY;
186+ if (!file) return;
187+ const { appendFile } = await import("node:fs/promises");
188+ const rows = results.map((r) => `| ${r.list} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n");
189+ await appendFile(file, `## Suggest Diffinery to awesome lists${DRY ? " (dry run)" : ""}\n\n| List | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`);
190+}
191+
192+if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) {
193+ const chosen = pick === "due" ? LISTS : LISTS.filter((l) => pick.split(",").map((s) => s.trim()).includes(l.id));
194+ if (!chosen.length) {
195+ console.log(`::error::No list matches "${pick}". Lists: ${LISTS.map((l) => l.id).join(", ")}`);
196+ process.exit(1);
197+ }
198+ if (!TOKEN) {
199+ console.log("::notice::OUTREACH_TOKEN is not set, so nothing was changed.");
200+ process.exit(0);
201+ }
202+ let me;
203+ try {
204+ me = (await rest("GET", "/user")).login;
205+ } catch (e) {
206+ console.log(`::error::OUTREACH_TOKEN can't read /user: ${e.message}`);
207+ process.exit(1);
208+ }
209+ console.log(`Acting as ${me}${DRY ? " (dry run)" : ""}`);
210+ for (const entry of chosen) {
211+ try {
212+ await suggest(entry, me);
213+ } catch (e) {
214+ const hint = e instanceof HttpError && (e.status === 403 || e.status === 404) ? "the token needs the classic public_repo scope: " : "";
215+ report(entry.id, "failed", hint + e.message);
216+ }
217+ }
218+ await summary();
219+ process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;
220+}
addedscripts/awesome-lists/lists.mjs+119−0View file
@@ -1,0 +1,119 @@
1+// The curated lists Diffinery is suggested to, one pull request each, opened by
2+// scripts/awesome-lists.mjs. Each entry follows that list's own contributing rules (checked
3+// 2026-10-25): its section, its line format and where in the section new lines go.
4+//
5+// id name for the workflow's `lists` input
6+// repo the list's GitHub repository
7+// file the file the line goes into
8+// section the heading line of the section, exactly as in the file
9+// item which lines in the section are entries (a regex on the line)
10+// place "end": after the section's last entry; "alpha": after the last entry that sorts before ours
11+// key for "alpha": the sort key of an entry line
12+// line the line to add
13+// notBefore first day the list's rules allow the suggestion (YYYY-MM-DD), if it has such a rule
14+
15+const REPO = "https://github.com/codician-team/diffinery";
16+const linkText = (line) => (line.match(/\[([^\]]+)\]/)?.[1] ?? "").toLowerCase();
17+
18+export const FOOTER = "\n\n---\n_Generated by [Claude Code](https://claude.ai/code)_\n";
19+
20+export const LISTS = [
21+ {
22+ id: "awesome-mcp-servers",
23+ repo: "punkpeye/awesome-mcp-servers",
24+ file: "README.md",
25+ section: '### 🔄 <a name="version-control"></a>Version Control',
26+ item: /^- \[/,
27+ place: "alpha",
28+ key: linkText,
29+ line: `- [codician-team/diffinery](${REPO}) 📇 ☁️ - MCP server built into Diffinery, an open-source git forge you deploy to your own Cloudflare account: files and commits, pull requests and reviews, issues, CI runs and job logs, releases, sandboxes and agent tasks.`,
30+ // The list fast-tracks pull requests from automated agents that mark their title like this.
31+ title: "Add Diffinery to Version Control 🤖🤖🤖",
32+ body: `Adds [Diffinery](${REPO}) to **Version Control**.
33+
34+Diffinery is an open-source (Apache-2.0) git forge with GitHub Actions compatible CI that you deploy to your own Cloudflare account. Every instance serves an MCP server at \`/mcp\` (streamable HTTP, OAuth or token sign-in) with tools for files and commits, branches, pull requests and reviews, issues, workflow runs and job logs, releases, sandboxes and agent tasks. It's installable and self-hosted, so it fits this list rather than the remote-only one.
35+
36+- Docs: https://diffinery.com/docs/agents/mcp
37+- Official MCP Registry: \`com.diffinery/diffinery\`
38+
39+This pull request was opened by an automated agent for the Codician team, which builds Diffinery.`,
40+ },
41+ {
42+ id: "awesome-git",
43+ repo: "dictcp/awesome-git",
44+ file: "README.md",
45+ section: "## Self-Hosted Repository",
46+ item: /^\s*\* /,
47+ place: "end",
48+ line: "* [Diffinery](https://diffinery.com/) - an open-source git forge you deploy to your own Cloudflare account, with pull requests, code review and GitHub Actions compatible CI. Apache-2.0.",
49+ title: "Add Diffinery to Self-Hosted Repository",
50+ body: `Adds [Diffinery](https://diffinery.com/) ([source](${REPO})) to **Self-Hosted Repository**.
51+
52+Diffinery is an open-source (Apache-2.0) git forge: repositories, pull requests with code review, issues and GitHub Actions compatible CI. You host it yourself on your own Cloudflare account (Workers, Durable Objects, D1, R2, Containers) instead of on a server.
53+
54+Opened by an automated agent for the Codician team, which builds Diffinery.`,
55+ },
56+ {
57+ id: "awesome-ci",
58+ repo: "ligurio/awesome-ci",
59+ file: "README.md",
60+ section: "### Introduction",
61+ item: /^\|\[/,
62+ place: "alpha",
63+ key: linkText,
64+ line: `|[Diffinery](https://diffinery.com/) | Git forge with built-in CI that runs GitHub Actions workflow files on Cloudflare Containers. | Languages: everything that runs on Ubuntu 24.04 x64, self-hosted runners for other platforms. Open source (Apache-2.0), self-hosted on your own Cloudflare account | Diffinery | [Documentation](https://diffinery.com/docs/actions/overview) | [Open Source](${REPO}) | [![Stars](https://img.shields.io/github/stars/codician-team/diffinery.svg)](${REPO}) |`,
65+ title: "Add Diffinery",
66+ body: `Adds [Diffinery](https://diffinery.com/) to the table, in alphabetical order.
67+
68+Diffinery is an open-source (Apache-2.0) git forge with built-in CI. It runs GitHub Actions workflow files from \`.github/workflows/\` on Cloudflare Containers (Ubuntu 24.04 x64, 4 vCPU and 12 GiB by default), and supports self-hosted runners for other platforms. You host the whole thing on your own Cloudflare account. Differences from GitHub Actions: https://diffinery.com/docs/actions/compatibility
69+
70+Opened by an automated agent for the Codician team, which builds Diffinery.`,
71+ },
72+ {
73+ id: "awesome-cloudflare-zh",
74+ repo: "zhuima/awesome-cloudflare",
75+ file: "README.md",
76+ section: "## 开发者工具",
77+ item: /^\| \[/,
78+ place: "end",
79+ line: `| [Diffinery](${REPO}) | 完全运行在 Cloudflare 上的开源 Git 代码托管平台:仓库、Pull Request 与代码评审、Issue,兼容 GitHub Actions 的 CI(运行在 Cloudflare Containers 上),以及供 AI 代理使用的 MCP 服务器。基于 Workers + Durable Objects + D1 + R2 + Queues 构建,部署到你自己的 Cloudflare 账户。 | <https://diffinery.com> | 维护中 |`,
80+ title: "添加 Diffinery 到开发者工具 / Add Diffinery to developer tools",
81+ body: `在「开发者工具」末尾添加 [Diffinery](${REPO})。
82+
83+Diffinery 是一个开源(Apache-2.0)的 Git 代码托管平台,完全运行在 Cloudflare 上(Workers、Durable Objects、D1、R2、Queues、Containers):仓库、Pull Request 与代码评审、Issue,兼容 GitHub Actions 的 CI,以及供 AI 代理使用的 MCP 服务器。部署到你自己的 Cloudflare 账户。
84+
85+Adds Diffinery, an open-source git forge that runs entirely on Cloudflare, at the end of the developer tools table. Opened by an automated agent for the Codician team, which builds Diffinery.`,
86+ },
87+ {
88+ id: "awesome-claude-code-plugins",
89+ repo: "ccplugins/awesome-claude-code-plugins",
90+ file: "README.md",
91+ section: "## External Marketplaces",
92+ item: /^\| \[/,
93+ place: "end",
94+ line: `| [codician-team/diffinery](${REPO}) | Plugins for the Diffinery git forge: diffinery (MCP server for repositories, pull requests, issues, CI and sandboxes, plus a skill) and diffinery-ci (live CI status for the commits Claude pushes) | \`claude plugin marketplace add codician-team/diffinery\` then \`claude plugin install diffinery@diffinery\` |`,
95+ title: "Add the Diffinery marketplace",
96+ body: `Adds the [Diffinery](${REPO}) plugin marketplace to **External Marketplaces**.
97+
98+Diffinery is an open-source (Apache-2.0) git forge you deploy to your own Cloudflare account. Its marketplace has two plugins: \`diffinery\` (its MCP server and a skill for working with repositories, pull requests, issues, CI and sandboxes) and \`diffinery-ci\` (tells Claude when CI on a commit it pushed has finished). Docs: https://diffinery.com/docs/connect/ci-status-in-claude
99+
100+Opened by an automated agent for the Codician team, which builds Diffinery.`,
101+ },
102+ {
103+ id: "awesome-cloudflare",
104+ repo: "irazasyed/awesome-cloudflare",
105+ file: "readme.md",
106+ section: "### Open Source",
107+ item: /^- \[/,
108+ place: "end",
109+ line: `- [Diffinery](${REPO}) - Open-source git forge with pull requests, code review and GitHub Actions compatible CI, running on Workers, Durable Objects, D1, R2 and Containers.`,
110+ // contributing.md: "If you just created something, wait at least 30 days before submitting."
111+ notBefore: "2026-11-24",
112+ title: "Add Diffinery",
113+ body: `Adds [Diffinery](${REPO}) at the bottom of **Apps → Open Source**.
114+
115+Diffinery is an open-source (Apache-2.0) git forge that runs entirely on Cloudflare: repositories live in Durable Objects and R2, metadata in D1, and its GitHub Actions compatible CI runs on Containers. You deploy it to your own account. It belongs here because it's a complete, self-hostable app built only from Cloudflare's developer platform; https://diffinery.com/built-on-cloudflare explains how.
116+
117+Opened by an automated agent for the Codician team, which builds Diffinery.`,
118+ },
119+];
addedscripts/github-community.mjs+231−0View file
@@ -1,0 +1,231 @@
1+#!/usr/bin/env node
2+// Sets up the community side of the GitHub mirror (github.com/codician-team/diffinery) as the
3+// account that owns MIRROR_TOKEN, so posts show Codician rather than a person:
4+//
5+// - the labels `roadmap`, `help wanted` and `good first issue`
6+// - Discussions turned on, and the welcome post (github-community/welcome-discussion.md) in Announcements
7+// - the roadmap issue (github-community/roadmap-issue.md), labelled and pinned
8+//
9+// Every step looks first and only creates what is missing, so running it again changes nothing.
10+// A step the token isn't allowed to do is reported with the permission it needs, and the others
11+// still run. Run by the "GitHub community setup" workflow; by hand:
12+//
13+// MIRROR_TOKEN=github_pat_... node scripts/github-community.mjs [--dry-run]
14+//
15+// GitHub has no API to pin a discussion or to edit discussion categories, so those stay as they are.
16+import { readFile } from "node:fs/promises";
17+import path from "node:path";
18+import { fileURLToPath } from "node:url";
19+
20+const REPO = process.env.MIRROR_REPO || "codician-team/diffinery";
21+const TOKEN = process.env.MIRROR_TOKEN;
22+const DRY = process.argv.includes("--dry-run");
23+const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "github-community");
24+
25+const WELCOME_TITLE = "Welcome to Diffinery";
26+const ROADMAP_TITLE = "Roadmap: what Diffinery doesn't do yet";
27+const LABELS = [
28+ { name: "roadmap", color: "f08a45", description: "Known gaps and planned work" },
29+ { name: "help wanted", color: "008672", description: "Extra attention is needed" },
30+ { name: "good first issue", color: "7057ff", description: "Good for newcomers" },
31+];
32+
33+const results = [];
34+const report = (step, outcome, detail = "") => {
35+ results.push({ step, outcome, detail });
36+ const line = `${step}: ${outcome}${detail ? ` (${detail})` : ""}`;
37+ console.log(outcome === "failed" ? `::warning::${line}` : line);
38+};
39+
40+class HttpError extends Error {
41+ constructor(status, message) {
42+ super(message);
43+ this.status = status;
44+ }
45+}
46+
47+async function rest(method, url, body) {
48+ const res = await fetch(`https://api.github.com${url}`, {
49+ method,
50+ headers: {
51+ authorization: `Bearer ${TOKEN}`,
52+ accept: "application/vnd.github+json",
53+ "x-github-api-version": "2022-11-28",
54+ "user-agent": "diffinery-github-community",
55+ ...(body ? { "content-type": "application/json" } : {}),
56+ },
57+ body: body ? JSON.stringify(body) : undefined,
58+ });
59+ const text = await res.text();
60+ if (!res.ok) throw new HttpError(res.status, `${method} ${url}: ${res.status} ${text.slice(0, 300)}`);
61+ return text ? JSON.parse(text) : null;
62+}
63+
64+async function graphql(query, variables) {
65+ const data = await rest("POST", "/graphql", { query, variables });
66+ if (data.errors?.length) throw new HttpError(403, data.errors.map((e) => e.message).join("; "));
67+ return data.data;
68+}
69+
70+/** Why a step failed, in words that name the token permission to add. */
71+const why = (e, permission) => (e instanceof HttpError && (e.status === 403 || e.status === 404) ? `the token needs ${permission}: ${e.message}` : e.message);
72+
73+async function labels() {
74+ for (const label of LABELS) {
75+ try {
76+ await rest("GET", `/repos/${REPO}/labels/${encodeURIComponent(label.name)}`);
77+ report(`Label "${label.name}"`, "already there");
78+ } catch (e) {
79+ if (!(e instanceof HttpError) || e.status !== 404) {
80+ report(`Label "${label.name}"`, "failed", why(e, "Issues: Read and write"));
81+ continue;
82+ }
83+ if (DRY) {
84+ report(`Label "${label.name}"`, "would create");
85+ continue;
86+ }
87+ try {
88+ await rest("POST", `/repos/${REPO}/labels`, label);
89+ report(`Label "${label.name}"`, "created");
90+ } catch (e2) {
91+ report(`Label "${label.name}"`, "failed", why(e2, "Issues: Read and write"));
92+ }
93+ }
94+ }
95+}
96+
97+async function discussions() {
98+ const [owner, name] = REPO.split("/");
99+ let repo = await rest("GET", `/repos/${REPO}`);
100+ if (!repo.has_discussions) {
101+ if (DRY) {
102+ report("Discussions", "would turn on");
103+ return;
104+ }
105+ try {
106+ repo = await rest("PATCH", `/repos/${REPO}`, { has_discussions: true });
107+ report("Discussions", "turned on");
108+ } catch (e) {
109+ report("Discussions", "failed", why(e, "Administration: Read and write (or turn Discussions on in Settings → General → Features)"));
110+ return;
111+ }
112+ } else report("Discussions", "already on");
113+
114+ try {
115+ const q = await graphql(
116+ `query($owner: String!, $name: String!) {
117+ repository(owner: $owner, name: $name) {
118+ id
119+ discussionCategories(first: 25) { nodes { id name } }
120+ discussions(first: 100) { nodes { title url } }
121+ }
122+ }`,
123+ { owner, name },
124+ );
125+ const existing = q.repository.discussions.nodes.find((d) => d.title === WELCOME_TITLE);
126+ if (existing) {
127+ report("Welcome discussion", "already there", existing.url);
128+ return;
129+ }
130+ const category = q.repository.discussionCategories.nodes.find((c) => c.name === "Announcements") ?? q.repository.discussionCategories.nodes.find((c) => c.name === "General");
131+ if (!category) {
132+ report("Welcome discussion", "failed", "no Announcements or General category");
133+ return;
134+ }
135+ if (DRY) {
136+ report("Welcome discussion", "would post", `in ${category.name}`);
137+ return;
138+ }
139+ const body = await readFile(path.join(DIR, "welcome-discussion.md"), "utf8");
140+ const created = await graphql(
141+ `mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
142+ createDiscussion(input: { repositoryId: $repositoryId, categoryId: $categoryId, title: $title, body: $body }) { discussion { url } }
143+ }`,
144+ { repositoryId: q.repository.id, categoryId: category.id, title: WELCOME_TITLE, body },
145+ );
146+ report("Welcome discussion", "posted", `${created.createDiscussion.discussion.url} in ${category.name}`);
147+ } catch (e) {
148+ report("Welcome discussion", "failed", why(e, "Discussions: Read and write"));
149+ }
150+}
151+
152+async function roadmap() {
153+ let issue;
154+ try {
155+ // Pull requests come back from this endpoint too; they have a pull_request field.
156+ for (let page = 1; page <= 10 && !issue; page++) {
157+ const list = await rest("GET", `/repos/${REPO}/issues?state=all&per_page=100&page=${page}`);
158+ issue = list.find((i) => !i.pull_request && i.title === ROADMAP_TITLE);
159+ if (list.length < 100) break;
160+ }
161+ } catch (e) {
162+ report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
163+ return;
164+ }
165+ if (issue) {
166+ report("Roadmap issue", "already there", issue.html_url);
167+ // GitHub drops labels from a new issue when the token may not label, so add them later.
168+ const missing = ["roadmap", "help wanted"].filter((l) => !issue.labels.some((x) => (x.name ?? x) === l));
169+ if (missing.length && DRY) report("Roadmap issue labels", "would add", missing.join(", "));
170+ else if (missing.length) {
171+ try {
172+ await rest("POST", `/repos/${REPO}/issues/${issue.number}/labels`, { labels: missing });
173+ report("Roadmap issue labels", "added", missing.join(", "));
174+ } catch (e) {
175+ report("Roadmap issue labels", "failed", why(e, "Issues: Read and write"));
176+ }
177+ }
178+ } else if (DRY) {
179+ report("Roadmap issue", "would open");
180+ return;
181+ } else {
182+ try {
183+ const body = await readFile(path.join(DIR, "roadmap-issue.md"), "utf8");
184+ issue = await rest("POST", `/repos/${REPO}/issues`, { title: ROADMAP_TITLE, body, labels: ["roadmap", "help wanted"] });
185+ report("Roadmap issue", "opened", issue.html_url);
186+ } catch (e) {
187+ report("Roadmap issue", "failed", why(e, "Issues: Read and write"));
188+ return;
189+ }
190+ }
191+
192+ try {
193+ const q = await graphql(`query($id: ID!) { node(id: $id) { ... on Issue { isPinned } } }`, { id: issue.node_id });
194+ if (q.node.isPinned) {
195+ report("Pin roadmap issue", "already pinned");
196+ return;
197+ }
198+ if (DRY) {
199+ report("Pin roadmap issue", "would pin");
200+ return;
201+ }
202+ await graphql(`mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { isPinned } } }`, { id: issue.node_id });
203+ report("Pin roadmap issue", "pinned");
204+ } catch (e) {
205+ report("Pin roadmap issue", "failed", why(e, "Issues: Read and write"));
206+ }
207+}
208+
209+async function summary() {
210+ const file = process.env.GITHUB_STEP_SUMMARY;
211+ if (!file) return;
212+ const { appendFile } = await import("node:fs/promises");
213+ const rows = results.map((r) => `| ${r.step} | ${r.outcome} | ${r.detail.replace(/\|/g, "\\|")} |`).join("\n");
214+ await appendFile(file, `## GitHub community setup${DRY ? " (dry run)" : ""}\n\n| Step | Outcome | Detail |\n| --- | --- | --- |\n${rows}\n`);
215+}
216+
217+if (!TOKEN) {
218+ console.log("::notice::MIRROR_TOKEN is not set, so nothing was changed.");
219+ process.exit(0);
220+}
221+try {
222+ const me = await rest("GET", "/user");
223+ console.log(`Acting as ${me.login} on ${REPO}${DRY ? " (dry run)" : ""}`);
224+} catch {
225+ console.log(`Acting on ${REPO} with a token that can't read /user${DRY ? " (dry run)" : ""}`);
226+}
227+await labels();
228+await discussions();
229+await roadmap();
230+await summary();
231+process.exitCode = results.some((r) => r.outcome === "failed") ? 1 : 0;
addedscripts/github-community/roadmap-issue.md+14−0View file
@@ -1,0 +1,14 @@
1+This issue tracks the main gaps people ask about. Nothing here has a date. Comment with what matters most to you, or 👍 an item below, and we'll split items into their own issues as work starts. The full list is in [ROADMAP.md](https://github.com/codician-team/diffinery/blob/main/ROADMAP.md).
2+
3+- [ ] Hosted macOS, Windows and Arm runners (today: [self-hosted runners](https://diffinery.com/docs/actions/runners#self-hosted-runners))
4+- [ ] Git over SSH (today: HTTPS with a credential helper)
5+- [ ] Forks and pull requests between repositories
6+- [ ] Richer reviews: pending reviews, suggested changes, resolving threads
7+- [ ] Code-owner reviews and signed-commit requirements in branch protection
8+- [ ] Workflow events that are accepted but don't start runs yet, such as `issues` and `issue_comment`
9+- [ ] Importing from Gitea, Forgejo and GitLab
10+- [ ] Wikis, discussions and Pages-style static sites
11+
12+**Good places to start contributing:** the gaps in [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility), each with the GitHub behaviour to match, and issues labelled `good first issue`.
13+
14+– the Codician team
addedscripts/github-community/welcome-discussion.md+16−0View file
@@ -1,0 +1,16 @@
1+Welcome! Diffinery is an open-source GitHub and GitHub Actions alternative that runs on your own Cloudflare account.
2+
3+**Where things go**
4+- **Q&A**: help deploying or running workflows. Include your workflow file and the job log if you can.
5+- **Ideas**: features you'd like. The [roadmap](https://github.com/codician-team/diffinery/blob/main/ROADMAP.md) lists the known gaps.
6+- **Show and tell**: your instance, your migration from GitHub, your agent setup.
7+- **Issues**: bugs, and workflows that run on GitHub but not on Diffinery. Security problems go to [the private report form](https://diffinery.com/security/report), never a public issue.
8+
9+**Getting started**
10+- [Deploy your own](https://diffinery.com/docs/self-hosting/deploy)
11+- [Compatibility with GitHub Actions](https://diffinery.com/docs/actions/compatibility)
12+- [Contributing](https://github.com/codician-team/diffinery/blob/main/CONTRIBUTING.md)
13+
14+Development happens on [diffinery.com/codician/diffinery](https://diffinery.com/codician/diffinery) and is mirrored here; pull requests are welcome in either place.
15+
16+– the Codician team
modifiedscripts/mirror-to-github.sh+78−8View file
@@ -6,10 +6,11 @@
66 # personal contributors.
77 # The rewrite is deterministic: the same Diffinery history always gives the same
88 # GitHub commits, so a sync is a fast-forward unless Diffinery's own history was
9-# rewritten (then GitHub follows it). Only main is mirrored.
9+# rewritten (then GitHub follows it). Only main, the tags on it and their releases
10+# are mirrored.
1011 #
1112 # Reads Diffinery with DIFFINERY_TOKEN when set (not needed once the repo is public);
12-# MIRROR_SRC can also be a local clone that has main. Pushes with GITHUB_MIRROR_TOKEN
13+# MIRROR_SRC can also be a local clone that has main. Pushes with MIRROR_TOKEN
1314 # when set, otherwise with whatever credentials git already has for github.com.
1415 # Needs git-filter-repo 2.47.0 (pip install git-filter-repo==2.47.0); another
1516 # version could write different commits.
@@ -22,7 +23,7 @@
2223 SRC=${MIRROR_SRC:-https://diffinery.com/codician/diffinery.git}
2324 DST=${MIRROR_DST:-https://github.com/codician-team/diffinery.git}
2425 NAME=${MIRROR_NAME:-codician-team}
25-EMAIL=${MIRROR_EMAIL:-codician-team@users.noreply.diffinery.com}
26+EMAIL=${MIRROR_EMAIL:-hi@codician.com}
2627 dry_run=${1:-}
2728
2829 work=${MIRROR_WORKDIR:-}
@@ -36,8 +37,8 @@
3637 # The helper reads the token from the environment, so it never lands on a command line.
3738 auth=(-c 'credential.https://diffinery.com.helper=!f() { [ "$1" = get ] && printf "username=x-token\npassword=%s\n" "$DIFFINERY_TOKEN"; }; f')
3839 fi
39-if [ -n "${GITHUB_MIRROR_TOKEN:-}" ]; then
40- auth+=(-c 'credential.https://github.com.helper=!f() { [ "$1" = get ] && printf "username=x-access-token\npassword=%s\n" "$GITHUB_MIRROR_TOKEN"; }; f')
40+if [ -n "${MIRROR_TOKEN:-}" ]; then
41+ auth+=(-c 'credential.https://github.com.helper=!f() { [ "$1" = get ] && printf "username=x-access-token\npassword=%s\n" "$MIRROR_TOKEN"; }; f')
4142 fi
4243 git "${auth[@]}" clone --quiet --bare --single-branch --branch main "$SRC" "$work/repo.git"
4344 cd "$work/repo.git"
@@ -62,7 +63,76 @@
6263 fi
6364 remote=$(git "${auth[@]}" ls-remote "$DST" refs/heads/main | cut -f1)
6465 if [ "$remote" = "$dst_head" ]; then
65- echo "GitHub is up to date."
66- exit 0
66+ echo "GitHub main is up to date."
67+else
68+ git "${auth[@]}" push --force "$DST" main:main
6769 fi
68-git "${auth[@]}" push --force "$DST" main:main
70+
71+# Tags on main follow their commits, as lightweight tags (so no tagger identity).
72+declare -A rewritten
73+while read -r old new; do rewritten[$old]=$new; done < <(tail -n +2 filter-repo/commit-map)
74+remote_tags=$(git "${auth[@]}" ls-remote --tags "$DST")
75+mirrored=()
76+for tag in $(git tag); do
77+ target=${rewritten[$(git rev-parse "$tag^{commit}")]:-}
78+ [ -n "$target" ] && [ "$target" != 0000000000000000000000000000000000000000 ] || continue
79+ if grep -qx "$target"$'\t'"refs/tags/$tag" <<<"$remote_tags" || git "${auth[@]}" push --force "$DST" "$target:refs/tags/$tag"; then
80+ mirrored+=("$tag")
81+ else
82+ echo "warning: could not push tag $tag" >&2
83+ fi
84+done
85+
86+# Releases on the mirrored tags get the same name, notes and flags on GitHub (assets aren't copied).
87+# Needs MIRROR_TOKEN for GitHub's API.
88+if [ -n "${MIRROR_TOKEN:-}" ] && [ ${#mirrored[@]} -gt 0 ]; then
89+ MIRROR_TAGS="${mirrored[*]}" python3 - <<'PY'
90+import json, os, urllib.error, urllib.request
91+
92+src = os.environ.get("MIRROR_SRC_API", "https://diffinery.com/api/v3/repos/codician/diffinery")
93+dst = os.environ.get("MIRROR_DST_API", "https://api.github.com/repos/codician-team/diffinery")
94+tags = set(os.environ["MIRROR_TAGS"].split())
95+
96+def call(method, url, body=None, token=None):
97+ req = urllib.request.Request(url, method=method, data=None if body is None else json.dumps(body).encode())
98+ req.add_header("Accept", "application/vnd.github+json")
99+ req.add_header("User-Agent", "diffinery-mirror")
100+ req.add_header("Content-Type", "application/json")
101+ if token:
102+ req.add_header("Authorization", f"Bearer {token}")
103+ try:
104+ with urllib.request.urlopen(req, timeout=30) as r:
105+ data = r.read()
106+ return r.status, json.loads(data) if data else None
107+ except urllib.error.HTTPError as e:
108+ return e.code, None
109+
110+token = os.environ["MIRROR_TOKEN"]
111+status, me = call("GET", f"{dst.split('/repos/')[0]}/user", token=token)
112+me = me.get("login") if status == 200 else None
113+status, releases = call("GET", f"{src}/releases?per_page=100", token=os.environ.get("DIFFINERY_TOKEN"))
114+if status != 200:
115+ raise SystemExit(f"could not list Diffinery releases ({status})")
116+for r in releases:
117+ if r["draft"] or r["tag_name"] not in tags:
118+ continue
119+ want = {"name": r["name"], "body": r["body"] or "", "prerelease": r["prerelease"]}
120+ status, have = call("GET", f"{dst}/releases/tags/{r['tag_name']}", token=token)
121+ if status == 200 and me and (have.get("author") or {}).get("login") != me:
122+ # A mirrored release belongs to the mirror's account: one made by anyone else is made again.
123+ status, _ = call("DELETE", f"{dst}/releases/{have['id']}", token=token)
124+ if status != 204:
125+ print(f"warning: could not replace GitHub release {r['tag_name']} ({status})")
126+ continue
127+ print(f"release {r['tag_name']}: deleted the copy {have['author']['login']} made")
128+ status = 404
129+ if status == 404:
130+ status, _ = call("POST", f"{dst}/releases", {"tag_name": r["tag_name"], **want}, token)
131+ print(f"release {r['tag_name']}: created ({status})")
132+ elif status == 200 and any(have.get(k) != v for k, v in want.items()):
133+ status, _ = call("PATCH", f"{dst}/releases/{have['id']}", want, token)
134+ print(f"release {r['tag_name']}: updated ({status})")
135+ elif status != 200:
136+ print(f"warning: could not read GitHub release {r['tag_name']} ({status})")
137+PY
138+fi
modifiedscripts/pack-plugins.py+3−1View file
@@ -17,6 +17,8 @@
1717
1818 ROOT = pathlib.Path(__file__).resolve().parent.parent
1919 SKIP_DIRS = {"tests", ".codex-plugin", "codex-skills", "node_modules"}
20+# Codex reads its own MCP config: Codex substitutes no ${user_config.*} values.
21+SKIP_FILES = {"codex.mcp.json"}
2022 STAMP = (2026, 1, 1, 0, 0, 0)
2123
2224
@@ -24,7 +26,7 @@
2426 out = []
2527 for p in plugin.rglob("*"):
2628 rel = p.relative_to(plugin)
27- if not p.is_file() or any(part in SKIP_DIRS for part in rel.parts):
29+ if not p.is_file() or any(part in SKIP_DIRS for part in rel.parts) or rel.as_posix() in SKIP_FILES:
2830 continue
2931 # The engine lays generated type files beside a mod it loads from a working folder.
3032 if rel.parts[:2] == (".claude-plugin", "types"):