Compare changes
Choose two branches to see what changed or to start a new pull request.
19 commits
129 files changed+3287−293
Files (129)
- .claude-plugin/marketplace.json+6−6
- .github/workflows/awesome-lists.yml+37−0
- .github/workflows/github-community.yml+29−0
- .github/workflows/mirror-to-github.yml+4−4
- CITATION.cff+6−2
- README.md+100−8
- apps/cli/src/cli.ts+2−1
- apps/cli/src/commands/auth.ts+12−1
- apps/cli/src/commands/claude-plugins.ts+35−5
- apps/cli/src/commands/codex.ts+71−0
- apps/cli/test/helpers.ts+2−1
- apps/cli/test/setup.test.ts+92−0
- apps/diffinery/claude-plugins/diffinery-0.1.3.zip+0−0
- apps/diffinery/claude-plugins/diffinery-0.1.4.zip+0−0
- apps/diffinery/claude-plugins/diffinery-ci-0.2.3.zip+0−0
- apps/diffinery/claude-plugins/diffinery-ci-0.2.4.zip+0−0
- apps/diffinery/docs/accounts/early-access.md+2−0
- apps/diffinery/docs/actions/environments.md+8−4
- apps/diffinery/docs/actions/secrets-and-variables.md+2−2
- apps/diffinery/docs/agents/mcp.md+23−1
- apps/diffinery/docs/agents/review.md+2−0
- apps/diffinery/docs/agents/tasks.md+2−0
- apps/diffinery/docs/agents/today.md+2−0
- apps/diffinery/docs/collaboration/merge-on-green.md+2−0
- apps/diffinery/docs/connect.md+2−0
- apps/diffinery/docs/connect/ci-status-in-claude.md+5−2
- apps/diffinery/docs/connect/claude-code.md+19−1
- apps/diffinery/docs/connect/claude.md+1−1
- apps/diffinery/docs/connect/cloud-agents.md+2−0
- apps/diffinery/docs/connect/codex.md+13−1
- apps/diffinery/docs/connect/other-apps.md+2−0
- apps/diffinery/docs/images/agent-pull-request.webp+0−0
- apps/diffinery/docs/images/agent-task.webp+0−0
- apps/diffinery/docs/images/merge-on-green.webp+0−0
- apps/diffinery/docs/images/today.webp+0−0
- apps/diffinery/docs/reference/limits.md+2−1
- apps/diffinery/docs/repositories/clone-in-jetbrains-ides.md+15−2
- apps/diffinery/docs/repositories/clone-in-your-editor.md+22−2
- apps/diffinery/docs/repositories/overview.md+13−1
- apps/diffinery/docs/security/audit-log.md+1−0
- apps/diffinery/docs/security/push-protection.md+1−1
- apps/diffinery/docs/self-hosting.md+3−0
- apps/diffinery/docs/self-hosting/configuration.md+1−0
- apps/diffinery/scripts/agent-screenshots.mjs+339−0
- apps/diffinery/site/about.md+1−1
- apps/diffinery/site/agents.md+61−8
- apps/diffinery/site/faq.md+1−1
- apps/diffinery/site/features.md+12−4
- apps/diffinery/site/nav.json+1−1
- apps/diffinery/site/open-source.md+2−2
- apps/diffinery/site/privacy.md+107−0
- apps/diffinery/src/agentic/review.ts+2−1
- apps/diffinery/src/agentic/today-repo.ts+14−3
- apps/diffinery/src/agentic/today.ts+1−1
- apps/diffinery/src/app.ts+2−0
- apps/diffinery/src/docs/api-meta.ts+5−0
- apps/diffinery/src/docs/claude-plugins.ts+32−0
- apps/diffinery/src/docs/pages.ts+2−2
- apps/diffinery/src/docs/reference.ts+1−0
- apps/diffinery/src/docs/web.tsx+13−6
- apps/diffinery/src/env.ts+5−0
- apps/diffinery/src/http/api.ts+8−0
- apps/diffinery/src/http/mcp-registry-auth.ts+36−0
- apps/diffinery/src/lib/archive.ts+48−1
- apps/diffinery/src/lib/util.ts+1−1
- apps/diffinery/src/repo/RepoDO.ts+86−44
- apps/diffinery/src/web/repo-settings.tsx+178−5
- apps/diffinery/src/web/site.tsx+93−25
- apps/diffinery/src/web/ui.tsx+1−1
- apps/diffinery/test/e2e/e2e.test.mjs+33−0
- apps/diffinery/test/e2e/security.test.mjs+52−0
- apps/diffinery/test/unit/claude-plugins.test.ts+49−0
- apps/diffinery/test/unit/mcp-registry-auth.test.ts+20−0
- apps/diffinery/test/unit/site.test.ts+11−2
- apps/diffinery/test/unit/today.test.ts+10−0
- apps/jetbrains/CHANGELOG.md+9−0
- apps/jetbrains/README.md+9−0
- apps/jetbrains/dist/diffinery-jetbrains-0.1.0.zip+0−0
- apps/jetbrains/dist/diffinery-jetbrains-0.1.1.zip+0−0
- apps/jetbrains/gradle.properties+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/ShareAction.java+1−1
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0
- apps/jetbrains/src/main/java/com/diffinery/jetbrains/Util.java+40−0
- apps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0
- apps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0
- apps/vscode/CHANGELOG.md+6−0
- apps/vscode/PUBLISHING.md+14−13
- apps/vscode/README.md+21−3
- apps/vscode/install/install.ps1+24−0
- apps/vscode/install/install.sh+20−0
- apps/vscode/package.json+7−2
- apps/vscode/scripts/package.mjs+23−11
- apps/vscode/src/auth.ts+10−0
- apps/vscode/src/extension.ts+3−0
- apps/vscode/src/git.ts+5−3
- apps/vscode/src/servers.ts+122−0
- apps/vscode/src/util.ts+46−6
- apps/vscode/test/package.test.ts+26−0
- apps/vscode/test/signin.test.ts+36−1
- context7.json+1−1
- docs/images/agent-pull-request.png+0−0
- docs/images/agent-task.png+0−0
- docs/images/merge-on-green.png+0−0
- docs/images/today.png+0−0
- package.json+6−2
- plugins/README.md+38−8
- plugins/diffinery-ci/.claude-plugin/icon.png+0−0
- plugins/diffinery-ci/.claude-plugin/plugin.json+14−3
- plugins/diffinery-ci/hooks/lib.ts+36−0
- plugins/diffinery-ci/hooks/register.tsx+27−25
- plugins/diffinery-ci/tests/ci.test.tsx+23−0
- plugins/diffinery/.claude-plugin/icon.png+0−0
- plugins/diffinery/.claude-plugin/plugin.json+14−3
- plugins/diffinery/.codex-plugin/plugin.json+4−4
- plugins/diffinery/.mcp.json+1−1
- plugins/diffinery/codex-skills/pr-status/SKILL.md+5−4
- plugins/diffinery/codex.mcp.json+8−0
- plugins/diffinery/commands/pr-status.md+4−3
- plugins/diffinery/hooks/session-start.sh+55−19
- plugins/diffinery/skills/diffinery/SKILL.md+24−11
- scripts/awesome-lists.mjs+220−0
- scripts/awesome-lists/lists.mjs+119−0
- scripts/github-community.mjs+231−0
- scripts/github-community/roadmap-issue.md+14−0
- scripts/github-community/welcome-discussion.md+16−0
- scripts/mirror-to-github.sh+78−8
- scripts/pack-plugins.py+3−1
modified.claude-plugin/marketplace.json+6−6View file
| @@ -2,21 +2,21 @@ | ||
| 2 | 2 | "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", |
| 3 | 3 | "name": "diffinery", |
| 4 | 4 | "owner": { |
| 5 | - "name": "Codician", | |
| 5 | + "name": "Codician Team", | |
| 6 | 6 | "url": "https://diffinery.com" |
| 7 | 7 | }, |
| 8 | 8 | "metadata": { |
| 9 | 9 | "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" | |
| 11 | 11 | }, |
| 12 | 12 | "plugins": [ |
| 13 | 13 | { |
| 14 | 14 | "name": "diffinery", |
| 15 | 15 | "source": "./plugins/diffinery", |
| 16 | 16 | "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", | |
| 18 | 18 | "author": { |
| 19 | - "name": "Codician" | |
| 19 | + "name": "Codician Team" | |
| 20 | 20 | }, |
| 21 | 21 | "homepage": "https://diffinery.com/docs/connect/claude-code", |
| 22 | 22 | "repository": "https://diffinery.com/codician/diffinery", |
| @@ -35,9 +35,9 @@ | ||
| 35 | 35 | "name": "diffinery-ci", |
| 36 | 36 | "source": "./plugins/diffinery-ci", |
| 37 | 37 | "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", | |
| 39 | 39 | "author": { |
| 40 | - "name": "Codician" | |
| 40 | + "name": "Codician Team" | |
| 41 | 41 | }, |
| 42 | 42 | "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude", |
| 43 | 43 | "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 @@ | ||
| 2 | 2 | |
| 3 | 3 | # Keeps the public copy at github.com/codician-team/diffinery in step with main, |
| 4 | 4 | # 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 | |
| 6 | 6 | # Workflows write on that one repository), a secret of the github-mirror environment. |
| 7 | 7 | # That environment is limited to protected branches, so the token also reaches runs |
| 8 | 8 | # after an agent's merge. Without the token the job says so and does nothing. |
| @@ -28,13 +28,13 @@ | ||
| 28 | 28 | environment: github-mirror |
| 29 | 29 | timeout-minutes: 10 |
| 30 | 30 | env: |
| 31 | - GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} | |
| 31 | + MIRROR_TOKEN: ${{ secrets.MIRROR_TOKEN }} | |
| 32 | 32 | steps: |
| 33 | 33 | - name: Check the GitHub token |
| 34 | 34 | id: token |
| 35 | 35 | 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." | |
| 38 | 38 | echo "skip=true" >> "$GITHUB_OUTPUT" |
| 39 | 39 | fi |
| 40 | 40 | - uses: actions/checkout@v4 |
modifiedCITATION.cff+6−2View file
| @@ -8,9 +8,10 @@ | ||
| 8 | 8 | Containers). It hosts Git repositories, pull requests with reviews and a merge queue, |
| 9 | 9 | issues, releases and a container registry, runs existing .github/workflows files on |
| 10 | 10 | 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. | |
| 12 | 13 | authors: |
| 13 | - - name: "The Diffinery Authors" | |
| 14 | + - name: "Codician Team" | |
| 14 | 15 | version: "0.1.0" |
| 15 | 16 | date-released: "2026-10-25" |
| 16 | 17 | license: Apache-2.0 |
| @@ -31,4 +32,7 @@ | ||
| 31 | 32 | - serverless |
| 32 | 33 | - code-review |
| 33 | 34 | - ai-agents |
| 35 | + - ai-code-review | |
| 36 | + - claude-code | |
| 37 | + - codex | |
| 34 | 38 | - mcp |
modifiedREADME.md+100−8View file
| @@ -1,20 +1,23 @@ | ||
| 1 | 1 | # Diffinery |
| 2 | 2 | |
| 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. | |
| 4 | 4 | |
| 5 | 5 | [](LICENSE) |
| 6 | 6 | [](https://diffinery.com/docs/actions/compatibility) |
| 7 | 7 | [](https://diffinery.com/built-on-cloudflare) |
| 8 | -[](https://diffinery.com/docs/agents/mcp) | |
| 8 | +[](https://registry.modelcontextprotocol.io/v0/servers?search=com.diffinery/diffinery) | |
| 9 | +[](plugins) | |
| 9 | 10 | [](https://diffinery.com/docs) |
| 10 | 11 | [](CONTRIBUTING.md) |
| 11 | 12 | |
| 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) | |
| 13 | 14 | |
| 14 | 15 | [](docs/images/pull-request.png) |
| 15 | 16 | |
| 16 | 17 | 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. |
| 17 | 18 | |
| 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 | + | |
| 18 | 21 | - **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). |
| 19 | 22 | - **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. |
| 20 | 23 | |
| @@ -25,6 +28,8 @@ | ||
| 25 | 28 | - [Who Diffinery is for](#who-diffinery-is-for) |
| 26 | 29 | - [Why Diffinery](#why-diffinery) |
| 27 | 30 | - [Screenshots](#screenshots) |
| 31 | +- [Built for AI coding agents](#built-for-ai-coding-agents) | |
| 32 | +- [Plugins and integrations](#plugins-and-integrations) | |
| 28 | 33 | - [Features](#features) |
| 29 | 34 | - [How it compares](#how-it-compares) |
| 30 | 35 | - [Self-host on your own Cloudflare account](#self-host-on-your-own-cloudflare-account) |
| @@ -48,18 +53,98 @@ | ||
| 48 | 53 | - **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. |
| 49 | 54 | - **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). |
| 50 | 55 | - **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). | |
| 52 | 58 | - **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. |
| 53 | 59 | - **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. |
| 54 | 60 | |
| 55 | 61 | ## Screenshots |
| 56 | 62 | |
| 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 | + | |
| 57 | 65 | | 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` | |
| 58 | 66 | | --- | --- | |
| 59 | 67 | | [](docs/images/repository.png) | [](docs/images/actions-run.png) | |
| 60 | 68 | |
| 61 | 69 | [](docs/images/job-logs-dark.png) |
| 62 | 70 | |
| 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 | +[](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 | +[](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 | +[](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 | +[](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 | + | |
| 63 | 148 | ## Features |
| 64 | 149 | |
| 65 | 150 | | Area | What you get | Docs | |
| @@ -69,8 +154,10 @@ | ||
| 69 | 154 | | Issues and releases | Issues, labels, milestones, reactions, mentions, releases with assets and generated notes | [Collaboration](https://diffinery.com/docs/collaboration/issues) | |
| 70 | 155 | | 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) | |
| 71 | 156 | | 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) | | |
| 74 | 161 | | 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) | |
| 75 | 162 | | 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) | |
| 76 | 163 | |
| @@ -213,8 +300,11 @@ | ||
| 213 | 300 | 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). |
| 214 | 301 | |
| 215 | 302 | **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). | |
| 217 | 304 | |
| 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 | + | |
| 218 | 308 | **Can an AI agent merge its own pull request?** |
| 219 | 309 | 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). |
| 220 | 310 | |
| @@ -222,7 +312,7 @@ | ||
| 222 | 312 | 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). |
| 223 | 313 | |
| 224 | 314 | **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. | |
| 226 | 316 | |
| 227 | 317 | **Is Diffinery the only Git platform on Cloudflare?** |
| 228 | 318 | 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 @@ | ||
| 237 | 327 | - **Security:** please don't open public issues for vulnerabilities. Report them privately as described in [SECURITY.md](SECURITY.md). |
| 238 | 328 | - **Citing Diffinery:** use GitHub's "Cite this repository" button, or [CITATION.cff](CITATION.cff). |
| 239 | 329 | - **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 @@ | ||
| 87 | 87 | if (r.code === 0) { |
| 88 | 88 | const parsed = parseRemote(r.stdout, host); |
| 89 | 89 | 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)`); | |
| 91 | 92 | } |
| 92 | 93 | throw new CliError("no repository given: pass -R OWNER/NAME or run inside a clone of a Diffinery repository"); |
| 93 | 94 | } |
modifiedapps/cli/src/commands/auth.ts+12−1View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { json } from "../output.js"; |
| 9 | 9 | import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js"; |
| 10 | 10 | import { setupClaudePlugins } from "./claude-plugins.js"; |
| 11 | +import { setupCodexMcp } from "./codex.js"; | |
| 11 | 12 | |
| 12 | 13 | interface User { |
| 13 | 14 | login: string; |
| @@ -257,7 +258,10 @@ | ||
| 257 | 258 | sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Where Claude Code is |
| 258 | 259 | installed, it also adds Claude Code's Diffinery plugins (diffinery and diffinery-ci): they're saved in |
| 259 | 260 | ~/.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 | |
| 261 | 265 | whether to turn on auto-merge for the pull requests you open (--auto-merge says yes without asking, |
| 262 | 266 | --no-auto-merge skips the question). Safe to run again; it brings the plugins up to date. |
| 263 | 267 | |
| @@ -333,6 +337,13 @@ | ||
| 333 | 337 | } |
| 334 | 338 | } |
| 335 | 339 | 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 { | |
| 336 | 347 | await offerAutoMerge(new Ctx(ctx.io, {}), url, { yes: o["auto-merge"] === true, skip: o["no-auto-merge"] === true }); |
| 337 | 348 | } catch (e) { |
| 338 | 349 | 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 @@ | ||
| 12 | 12 | * they're saved in Claude Code's settings directory and named in the `env` block of its user settings as |
| 13 | 13 | * CLAUDE_CODE_PLUGIN_DIRS, which Claude Code reads at the start of every session: the terminal, the |
| 14 | 14 | * 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. | |
| 16 | 18 | */ |
| 17 | 19 | |
| 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 | + | |
| 18 | 23 | export const CLAUDE_PLUGINS = ["diffinery", "diffinery-ci"] as const; |
| 19 | 24 | |
| 20 | 25 | /** Where Claude Code keeps its user settings: CLAUDE_CONFIG_DIR, else ~/.claude. */ |
| @@ -46,6 +51,23 @@ | ||
| 46 | 51 | return [...kept, ...ours].join(path.delimiter); |
| 47 | 52 | } |
| 48 | 53 | |
| 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 | + | |
| 49 | 71 | export interface PluginsResult { |
| 50 | 72 | ok: boolean; |
| 51 | 73 | message: string; |
| @@ -101,15 +123,23 @@ | ||
| 101 | 123 | got.push(`${name} ${res.headers.get("x-diffinery-plugin-version") ?? ""}`.trim()); |
| 102 | 124 | } |
| 103 | 125 | |
| 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 }; | |
| 108 | 137 | await writeFile(`${settingsFile}.tmp`, `${JSON.stringify(next, null, 2)}\n`); |
| 109 | 138 | await rename(`${settingsFile}.tmp`, settingsFile); |
| 110 | 139 | } |
| 111 | 140 | const parts = []; |
| 112 | 141 | if (got.length) parts.push(`${got.join(" and ")} load in new Claude Code sessions (named in ${settingsFile})`); |
| 113 | 142 | if (fromMarketplace.length) parts.push(`${fromMarketplace.join(" and ")} already installed from the Diffinery marketplace`); |
| 143 | + if (configs) parts.push(`their Diffinery URL is ${url}`); | |
| 114 | 144 | return { ok: true, message: parts.join("; ") }; |
| 115 | 145 | } |
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 @@ | ||
| 129 | 129 | GIT_AUTHOR_EMAIL: "test@example.com", |
| 130 | 130 | GIT_COMMITTER_NAME: "Test", |
| 131 | 131 | 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. | |
| 133 | 133 | DIFFINERY_NO_CLAUDE_PLUGINS: "1", |
| 134 | + DIFFINERY_NO_CODEX: "1", | |
| 134 | 135 | ...extra, |
| 135 | 136 | }; |
| 136 | 137 | } |
modifiedapps/cli/test/setup.test.ts+92−0View file
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | import { chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; |
| 4 | 4 | import path from "node:path"; |
| 5 | 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 6 | +import { withUrlOption } from "../src/commands/claude-plugins.js"; | |
| 6 | 7 | import { configPath, writeConfig } from "../src/config.js"; |
| 7 | 8 | import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; |
| 8 | 9 | |
| @@ -303,6 +304,22 @@ | ||
| 303 | 304 | expect(dirsOf(market)).toEqual([path.join(market.HOME!, ".claude", "diffinery-plugins", "diffinery.zip")]); |
| 304 | 305 | }); |
| 305 | 306 | |
| 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 | + | |
| 306 | 323 | it("leaves settings it can't read alone, and refuses a download that doesn't match its checksum", async () => { |
| 307 | 324 | const broken = await withClaude("{ not json"); |
| 308 | 325 | const r = await run(["setup"], { env: broken }); |
| @@ -318,3 +335,78 @@ | ||
| 318 | 335 | expect(existsSync(path.join(bad.HOME!, ".claude", "settings.json"))).toBe(false); |
| 319 | 336 | }); |
| 320 | 337 | }); |
| 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 @@ | ||
| 5 | 5 | |
| 6 | 6 | 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). |
| 7 | 7 | |
| 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 | + | |
| 8 | 10 | 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. |
| 9 | 11 | |
| 10 | 12 | ## Request early access |
modifiedapps/diffinery/docs/actions/environments.md+8−4View file
| @@ -25,7 +25,7 @@ | ||
| 25 | 25 | 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. |
| 26 | 26 | 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. |
| 27 | 27 | |
| 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. | |
| 29 | 29 | |
| 30 | 30 | ## Create an environment |
| 31 | 31 | |
| @@ -129,6 +129,8 @@ | ||
| 129 | 129 | |
| 130 | 130 | Setting both values to `true`, or both to `false`, fails with `422 set exactly one of protected_branches and custom_branch_policies`. |
| 131 | 131 | |
| 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 | + | |
| 132 | 134 | The branch policy only applies to workflow jobs. A deployment created directly with the REST API isn't checked against it. |
| 133 | 135 | |
| 134 | 136 | ### Protected branches only |
| @@ -280,12 +282,14 @@ | ||
| 280 | 282 | |
| 281 | 283 | ## Environment secrets and variables |
| 282 | 284 | |
| 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. | |
| 284 | 286 | |
| 285 | 287 | ### Environment secrets |
| 286 | 288 | |
| 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. | |
| 288 | 290 | |
| 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 | + | |
| 289 | 293 | ```sh |
| 290 | 294 | # Create or replace a secret: 201 Created when new, 204 No Content when replaced |
| 291 | 295 | curl -X PUT https://diffinery.com/api/v3/repos/acme/website/environments/production/secrets/CLOUDFLARE_API_TOKEN \ |
| @@ -872,7 +876,7 @@ | ||
| 872 | 876 | |
| 873 | 877 | | Area | GitHub | Diffinery | |
| 874 | 878 | |---|---|---| |
| 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 | | |
| 876 | 880 | | Required reviewers | People and teams | People only, never agents; an agent or a job token can't approve | |
| 877 | 881 | | Prevent self-review | Covers whoever triggered the run | Also covers earlier attempts' starters and the owner of an agent that started it | |
| 878 | 882 | | 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 @@ | ||
| 13 | 13 | | 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) | |
| 14 | 14 | | Repository variable | One repository | Yes | Yes | People with the admin role on the repository | |
| 15 | 15 | | 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 | | |
| 17 | 17 | | Environment variable | Jobs of one repository that use the environment | No | API only | People with the admin role on the repository | |
| 18 | 18 | |
| 19 | 19 | 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 @@ | ||
| 226 | 226 | DEPLOY_URL: ${{ vars.DEPLOY_URL }} # the production variable |
| 227 | 227 | ``` |
| 228 | 228 | |
| 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. | |
| 230 | 230 | |
| 231 | 231 | ```sh |
| 232 | 232 | # Environment secret: 201 when new, 204 when replaced |
modifiedapps/diffinery/docs/agents/mcp.md+23−1View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | |
| 10 | 10 | | Property | Value | |
| 11 | 11 | | --- | --- | |
| 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` | | |
| 13 | 13 | | Transport | Streamable HTTP. Send JSON-RPC 2.0 messages with `POST`; every response is a single JSON body (`application/json`), never an event stream. | |
| 14 | 14 | | Sessions | None. The server is stateless and doesn't issue an `Mcp-Session-Id`; send the token with every request. | |
| 15 | 15 | | Batches | A JSON array of requests gets a JSON array of responses. | |
| @@ -482,6 +482,28 @@ | ||
| 482 | 482 | 7. `list_workflow_runs`, `get_workflow_run` and `get_job_logs` to follow CI, fixing failures with more commits. |
| 483 | 483 | 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. |
| 484 | 484 | |
| 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 | + | |
| 485 | 507 | ## Limitations |
| 486 | 508 | |
| 487 | 509 | - The server offers tools only: no resources, prompts or sampling. |
modifiedapps/diffinery/docs/agents/review.md+2−0View file
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | |
| 6 | 6 | **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. |
| 7 | 7 | |
| 8 | + | |
| 9 | + | |
| 8 | 10 | Agent review is off until a repository admin turns it on. Nothing changes for a repository that doesn't use it. |
| 9 | 11 | |
| 10 | 12 | ## Turn it on |
modifiedapps/diffinery/docs/agents/tasks.md+2−0View file
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | |
| 6 | 6 | 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. |
| 7 | 7 | |
| 8 | + | |
| 9 | + | |
| 8 | 10 | 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). |
| 9 | 11 | |
| 10 | 12 | 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 @@ | ||
| 7 | 7 | |
| 8 | 8 | 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. |
| 9 | 9 | |
| 10 | + | |
| 11 | + | |
| 10 | 12 | ## What it shows |
| 11 | 13 | |
| 12 | 14 | 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | 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. |
| 12 | 12 | |
| 13 | + | |
| 14 | + | |
| 13 | 15 | ## The quickest path (a repository you work on alone) |
| 14 | 16 | |
| 15 | 17 | 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 @@ | ||
| 9 | 9 | https://diffinery.com/mcp |
| 10 | 10 | ``` |
| 11 | 11 | |
| 12 | +On a [self-hosted Diffinery](/docs/self-hosting), use your instance's address instead, such as `https://git.example.com/mcp`. | |
| 13 | + | |
| 12 | 14 | 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. |
| 13 | 15 | |
| 14 | 16 |  |
modifiedapps/diffinery/docs/connect/ci-status-in-claude.md+5−2View file
| @@ -27,7 +27,7 @@ | ||
| 27 | 27 | ```text |
| 28 | 28 | ✓ git will use diffinery for https://diffinery.com |
| 29 | 29 | ✓ 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) | |
| 31 | 31 | ``` |
| 32 | 32 | |
| 33 | 33 | 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 @@ | ||
| 37 | 37 | > [!NOTE] |
| 38 | 38 | > `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. |
| 39 | 39 | |
| 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 | + | |
| 40 | 43 | ## In cloud sessions and project threads |
| 41 | 44 | |
| 42 | 45 | 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 @@ | ||
| 94 | 97 | | Problem | What to do | |
| 95 | 98 | | --- | --- | |
| 96 | 99 | | 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. | | |
| 98 | 101 | | `/diffinery-ci` says there's no pull request | The current branch has none yet. Open one with `diffinery pr create` or `gh pr create`. | |
| 99 | 102 | | 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. | |
| 100 | 103 | | `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 @@ | ||
| 53 | 53 | |
| 54 | 54 | | Part | What it does | |
| 55 | 55 | | --- | --- | |
| 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). | | |
| 57 | 57 | | `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. | |
| 58 | 58 | | `/diffinery:pr-status` | This branch's pull request, its checks, and the log tail of failing jobs. Changes nothing. | |
| 59 | 59 | | 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 @@ | ||
| 109 | 109 | |
| 110 | 110 | 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). |
| 111 | 111 | |
| 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 | + | |
| 112 | 129 | ## Troubleshooting |
| 113 | 130 | |
| 114 | 131 | | Problem | What to do | |
| 115 | 132 | | --- | --- | |
| 116 | 133 | | `/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. | |
| 117 | 134 | | `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). | | |
| 118 | 136 | | `/mcp` shows the server as needing authentication | Choose **Authenticate**, or run `claude mcp login plugin:diffinery:diffinery`. | |
| 119 | 137 | | The browser says "Can't connect this app" | Remove the server and add it again, then sign in. | |
| 120 | 138 | | 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 @@ | ||
| 17 | 17 | | Name | `Diffinery` | |
| 18 | 18 | | Remote MCP server URL | `https://diffinery.com/mcp` | |
| 19 | 19 | |
| 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`. | |
| 21 | 21 | 3. Choose **Connect** next to Diffinery. A Diffinery tab opens. Sign in with your passkey if you aren't signed in. |
| 22 | 22 | 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. |
| 23 | 23 |
modifiedapps/diffinery/docs/connect/cloud-agents.md+2−0View file
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | |
| 16 | 16 | ## Before you start |
| 17 | 17 | |
| 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 | + | |
| 18 | 20 | - 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. |
| 19 | 21 | - 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. |
| 20 | 22 |
modifiedapps/diffinery/docs/connect/codex.md+13−1View file
| @@ -50,7 +50,7 @@ | ||
| 50 | 50 | |
| 51 | 51 | | Part | What it does | |
| 52 | 52 | | --- | --- | |
| 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). | | |
| 54 | 54 | | `diffinery` skill | The loop on Diffinery: branch, `diffinery pr create --intent`, `diffinery pr checks --watch`, `diffinery run logs`, auto-merge, and the agent rules. | |
| 55 | 55 | | `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.) | |
| 56 | 56 | | 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 @@ | ||
| 92 | 92 | |
| 93 | 93 | `codex mcp list` then shows **Bearer token** under **Auth**. More in [Headless use](/docs/connect/how-sign-in-works#headless-use). |
| 94 | 94 | |
| 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 | + | |
| 95 | 107 | ## Troubleshooting |
| 96 | 108 | |
| 97 | 109 | | Problem | What to do | |
modifiedapps/diffinery/docs/connect/other-apps.md+2−0View file
| @@ -9,6 +9,8 @@ | ||
| 9 | 9 | https://diffinery.com/mcp |
| 10 | 10 | ``` |
| 11 | 11 | |
| 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 | + | |
| 12 | 14 | ## Cursor |
| 13 | 15 | |
| 14 | 16 | 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 @@ | ||
| 12 | 12 | | Limit | Value | Applies to | |
| 13 | 13 | | --- | --- | --- | |
| 14 | 14 | | 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). | | |
| 16 | 16 | | Passkey browser prompt | 2 minutes | Time to complete the browser's passkey dialog when signing in or adding a passkey. | |
| 17 | 17 | | Passkey sign-in attempt | 5 minutes | A sign-in or registration that started more than 5 minutes ago can't be completed; start again. | |
| 18 | 18 | | 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 @@ | ||
| 226 | 226 | | 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. | |
| 227 | 227 | | 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. | |
| 228 | 228 | | 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). | | |
| 229 | 230 | | 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. | |
| 230 | 231 | | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. | |
| 231 | 232 | | 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 @@ | ||
| 38 | 38 | |
| 39 | 39 | 1. Choose **File → New → Project from Version Control…**, or **Clone Repository** on the welcome screen. |
| 40 | 40 | 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.) | |
| 42 | 42 | 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. |
| 43 | 43 | 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. |
| 44 | 44 | 6. Check the **Directory** and choose **Clone**. The IDE clones the repository and opens it. |
| @@ -79,8 +79,20 @@ | ||
| 79 | 79 | |
| 80 | 80 | ## Settings |
| 81 | 81 | |
| 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. | |
| 83 | 83 | |
| 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 | + | |
| 84 | 96 | ## Troubleshooting |
| 85 | 97 | |
| 86 | 98 | | What you see | What to do | |
| @@ -98,4 +110,5 @@ | ||
| 98 | 110 | - 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. |
| 99 | 111 | - 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. |
| 100 | 112 | - 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. | |
| 101 | 114 | - 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 @@ | ||
| 38 | 38 | |
| 39 | 39 | 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. |
| 40 | 40 | 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. | |
| 42 | 42 | 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. |
| 43 | 43 | 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. |
| 44 | 44 | 6. Choose a folder. The editor clones the repository and opens it. |
| @@ -83,10 +83,29 @@ | ||
| 83 | 83 | |
| 84 | 84 | | Setting | Default | What it does | |
| 85 | 85 | | --- | --- | --- | |
| 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. | | |
| 87 | 87 | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account, and keep Git's saved copy current. | |
| 88 | 88 | | `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. | |
| 89 | 89 | |
| 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 | + | |
| 90 | 109 | ## JetBrains IDEs |
| 91 | 110 | |
| 92 | 111 | 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 @@ | ||
| 110 | 129 | |
| 111 | 130 | - 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. |
| 112 | 131 | - 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. | |
| 113 | 133 | - 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 @@ | ||
| 151 | 151 | - **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. |
| 152 | 152 | - **Visibility**: **Private** or **Public**. |
| 153 | 153 | |
| 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**. | |
| 155 | 155 | |
| 156 | 156 | ### Secrets and variables |
| 157 | 157 | |
| @@ -387,6 +387,18 @@ | ||
| 387 | 387 | | `type acme/website to confirm the transfer` | The web confirmation box didn't match the repository's full name. | |
| 388 | 388 | | `choose the new owner` | Pick a **New owner** in the web form. | |
| 389 | 389 | |
| 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 | + | |
| 390 | 402 | ## Delete a repository |
| 391 | 403 | |
| 392 | 404 | 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 @@ | ||
| 163 | 163 | | `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 | |
| 164 | 164 | | `repo.delete` | The repository is deleted (web or `DELETE /api/v3/repos/{owner}/{repo}`) | none | |
| 165 | 165 | | `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` | | |
| 166 | 167 | | `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` | |
| 167 | 168 | | `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` | |
| 168 | 169 | | `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 @@ | ||
| 224 | 224 | 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: |
| 225 | 225 | |
| 226 | 226 | 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)). | |
| 228 | 228 | 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}`. |
| 229 | 229 | |
| 230 | 230 | 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | 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. |
| 8 | 8 | |
| 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 | + | |
| 9 | 11 | ## Get the source |
| 10 | 12 | |
| 11 | 13 | 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 @@ | ||
| 54 | 56 | - **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). |
| 55 | 57 | - **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). |
| 56 | 58 | - **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). | |
| 57 | 60 | - **How it fits together.** [Architecture](/docs/self-hosting/architecture) maps each part of Diffinery to the Cloudflare product that runs it. |
| 58 | 61 | |
| 59 | 62 | ## Get help and contribute |
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−0View file
| @@ -43,6 +43,7 @@ | ||
| 43 | 43 | | `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). | |
| 44 | 44 | | `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. | |
| 45 | 45 | | `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). | | |
| 46 | 47 | | `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). | |
| 47 | 48 | | `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. | |
| 48 | 49 |
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 @@ | ||
| 22 | 22 | 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). |
| 23 | 23 | |
| 24 | 24 | - **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). | |
| 26 | 26 | |
| 27 | 27 | ## How we check what we publish |
| 28 | 28 |
modifiedapps/diffinery/site/agents.md+61−8View file
| @@ -1,12 +1,24 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Git hosting and CI built for AI coding agents |
| 3 | 3 | 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. | |
| 5 | 5 | updated: 2026-10-25 |
| 6 | 6 | --- |
| 7 | 7 | |
| 8 | 8 | 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. |
| 9 | 9 | |
| 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 | + | |
| 10 | 22 | ## Agents are accounts, not borrowed tokens |
| 11 | 23 | |
| 12 | 24 | 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 @@ | ||
| 39 | 51 | |
| 40 | 52 | - [Connect AI apps](/docs/connect): one URL and a browser sign-in for Claude, Claude Code, Codex and others. |
| 41 | 53 | - [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). | |
| 43 | 56 | |
| 44 | 57 | ## gh, Git and the REST API work as agents expect |
| 45 | 58 | |
| @@ -49,15 +62,51 @@ | ||
| 49 | 62 | |
| 50 | 63 | 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. |
| 51 | 64 | |
| 52 | -## Agent tasks, review and terminals | |
| 65 | +## Agent tasks: a goal in, a pull request out | |
| 53 | 66 | |
| 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. | |
| 58 | 68 | |
| 69 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 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 | + | |
| 59 | 95 | 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. |
| 60 | 96 | |
| 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 | + | |
| 61 | 110 | ## Docs written for agents to read |
| 62 | 111 | |
| 63 | 112 | 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 @@ | ||
| 66 | 115 | |
| 67 | 116 | ### Which AI coding agents work with Diffinery? |
| 68 | 117 | |
| 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. | |
| 70 | 119 | |
| 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 | + | |
| 71 | 124 | ### Can an agent merge its own pull request? |
| 72 | 125 | |
| 73 | 126 | 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 @@ | ||
| 19 | 19 | |
| 20 | 20 | ### How do I start using Diffinery? |
| 21 | 21 | |
| 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. | |
| 23 | 23 | |
| 24 | 24 | ### Is Diffinery a GitHub alternative? |
| 25 | 25 |
modifiedapps/diffinery/site/features.md+12−4View file
| @@ -40,10 +40,16 @@ | ||
| 40 | 40 | |
| 41 | 41 | ## Built for AI agents |
| 42 | 42 | |
| 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`. | |
| 46 | 50 | |
| 51 | + | |
| 52 | + | |
| 47 | 53 | See [Diffinery for AI agents](/agents) for the whole picture. |
| 48 | 54 | |
| 49 | 55 | ## Works with the tools you already use |
| @@ -54,7 +60,9 @@ | ||
| 54 | 60 | | 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). | |
| 55 | 61 | | VS Code, Cursor, Windsurf | "Clone from Diffinery" with a passkey sign-in. See [Clone in your editor](/docs/repositories/clone-in-your-editor). | |
| 56 | 62 | | 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). | | |
| 58 | 66 | | 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). | |
| 59 | 67 | | The `diffinery` CLI | Repositories, pull requests, issues, runs, secrets, runners and migrations from a terminal. See the [CLI reference](/docs/reference/cli). | |
| 60 | 68 |
modifiedapps/diffinery/site/open-source.md+2−2View file
| @@ -51,7 +51,7 @@ | ||
| 51 | 51 | |
| 52 | 52 | ## What diffinery.com is |
| 53 | 53 | |
| 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. | |
| 55 | 55 | |
| 56 | 56 | ## Contribute |
| 57 | 57 | |
| @@ -71,7 +71,7 @@ | ||
| 71 | 71 | |
| 72 | 72 | ### Is there a hosted version I can sign up for? |
| 73 | 73 | |
| 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). | |
| 75 | 75 | |
| 76 | 76 | ### What does running Diffinery cost? |
| 77 | 77 |
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 @@ | ||
| 351 | 351 | out.push("", "Findings outside the changed lines:"); |
| 352 | 352 | 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)}` : ""}`); |
| 353 | 353 | } |
| 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})_`); | |
| 355 | 356 | return out.join("\n"); |
| 356 | 357 | } |
| 357 | 358 |
modifiedapps/diffinery/src/agentic/today-repo.ts+14−3View file
| @@ -37,6 +37,11 @@ | ||
| 37 | 37 | checks: ChecksState; |
| 38 | 38 | failingChecks: string[]; |
| 39 | 39 | 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; | |
| 40 | 45 | queued: boolean; |
| 41 | 46 | /** |
| 42 | 47 | * What the last mergeability check found, while the base branch and the head are still the commits it saw: |
| @@ -56,7 +61,7 @@ | ||
| 56 | 61 | * current base and head: Today checks it after answering, so a later Today knows whether it conflicts. |
| 57 | 62 | */ |
| 58 | 63 | 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; | |
| 60 | 65 | |
| 61 | 66 | export interface TodayMerged { |
| 62 | 67 | number: number; |
| @@ -150,7 +155,11 @@ | ||
| 150 | 155 | |
| 151 | 156 | type ReviewRow = { number: number; author_id: string; author_login: string; author_kind: string; state: string; commit_id: string; imported: string | null }; |
| 152 | 157 | |
| 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 { | |
| 154 | 163 | const rows = sql |
| 155 | 164 | .exec<PullJoin>( |
| 156 | 165 | `SELECT i.number, i.title, i.author_id, i.author_login, i.author_kind, i.created_at, i.updated_at, |
| @@ -181,6 +190,7 @@ | ||
| 181 | 190 | const votes = [...latest.values()]; |
| 182 | 191 | const approvals = votes.filter((v) => v.state === "APPROVED" && v.author_kind === "user"); |
| 183 | 192 | const checks = checksState(checksFor(r.head_sha)); |
| 193 | + const approvedHead = approvals.some((v) => v.commit_id === r.head_sha); | |
| 184 | 194 | return { |
| 185 | 195 | number: r.number, |
| 186 | 196 | title: r.title, |
| @@ -193,12 +203,13 @@ | ||
| 193 | 203 | createdAt: r.created_at, |
| 194 | 204 | updatedAt: r.updated_at, |
| 195 | 205 | approvedBy: approvals.map((v) => v.author_login), |
| 196 | - approvedHead: approvals.some((v) => v.commit_id === r.head_sha), | |
| 206 | + approvedHead, | |
| 197 | 207 | changesRequestedBy: votes.filter((v) => v.state === "CHANGES_REQUESTED").map((v) => v.author_login), |
| 198 | 208 | reviewerIds: votes.filter((v) => v.state === "APPROVED" || v.state === "CHANGES_REQUESTED").map((v) => v.author_id), |
| 199 | 209 | checks: checks.state, |
| 200 | 210 | failingChecks: checks.failing, |
| 201 | 211 | autoMerge: !!r.auto_merge, |
| 212 | + awaitingApproval: r.author_kind === "agent" && !!r.auto_merge && !approvedHead && !agentApprovalWaived(r.base_ref), | |
| 202 | 213 | queued: !!r.queued, |
| 203 | 214 | mergeState: r.merge_state, |
| 204 | 215 | }; |
modifiedapps/diffinery/src/agentic/today.ts+1−1View file
| @@ -116,7 +116,7 @@ | ||
| 116 | 116 | if (p.draft) return "draft"; |
| 117 | 117 | if (p.mergeState === "in_base") return "in_base"; |
| 118 | 118 | if (p.mergeState === "conflicting") return "conflicting"; |
| 119 | - if (p.queued || p.autoMerge) return "landing"; | |
| 119 | + if (p.queued || (p.autoMerge && !p.awaitingApproval)) return "landing"; | |
| 120 | 120 | if (p.checks === "failure") return "checks_failing"; |
| 121 | 121 | if (p.changesRequestedBy.length) return "changes_requested"; |
| 122 | 122 | if (p.approvedHead) return "approved"; |
modifiedapps/diffinery/src/app.ts+2−0View file
| @@ -36,6 +36,7 @@ | ||
| 36 | 36 | import { mailQueue } from "./mail/send.js"; |
| 37 | 37 | import { mailEvents, mailEventsQueue } from "./mail/events.js"; |
| 38 | 38 | import { securityTxtRoutes } from "./http/security-txt.js"; |
| 39 | +import { mcpRegistryAuthRoutes } from "./http/mcp-registry-auth.js"; | |
| 39 | 40 | import { securityReportsApi } from "./http/security-reports.js"; |
| 40 | 41 | import { earlyAccessApi } from "./http/early-access.js"; |
| 41 | 42 | import { statusApi } from "./http/status.js"; |
| @@ -123,6 +124,7 @@ | ||
| 123 | 124 | app.route("/", oauthApi); |
| 124 | 125 | app.route("/", wellKnown); |
| 125 | 126 | app.route("/", securityTxtRoutes); |
| 127 | +app.route("/", mcpRegistryAuthRoutes); | |
| 126 | 128 | app.route("/", git); |
| 127 | 129 | app.route("/", runner); |
| 128 | 130 | app.route("/", mailApi); |
modifiedapps/diffinery/src/docs/api-meta.ts+5−0View file
| @@ -420,6 +420,11 @@ | ||
| 420 | 420 | 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.", |
| 421 | 421 | access: "admin", |
| 422 | 422 | }, |
| 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 | + }, | |
| 423 | 428 | "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 }, |
| 424 | 429 | "POST /repos/{owner}/{repo}/transfer": { |
| 425 | 430 | 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 @@ | ||
| 124 | 124 | `- 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.`, |
| 125 | 125 | `- 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\`.`, |
| 126 | 126 | `- 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\``, | |
| 129 | 129 | `- 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)`, |
| 130 | 130 | `- 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")}`, |
| 131 | 131 | `- 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 @@ | ||
| 269 | 269 | ["GET /.well-known/openid-configuration", "OIDC discovery for workflow ID tokens. See [OIDC](/docs/actions/oidc)."], |
| 270 | 270 | ["GET /.well-known/jwks", "Public keys that sign workflow ID tokens."], |
| 271 | 271 | ["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)."], | |
| 272 | 273 | ["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)."], |
| 273 | 274 | ["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)."], |
| 274 | 275 | ["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 @@ | ||
| 22 | 22 | import { fenceHighlighter } from "../web/highlight.js"; |
| 23 | 23 | import { ICONS } from "../web/assets.js"; |
| 24 | 24 | import { render, type WebApp } from "../web/ui.js"; |
| 25 | +import { pluginZipFor } from "./claude-plugins.js"; | |
| 25 | 26 | 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"; |
| 26 | 27 | import { allPages, getPage, headings, llmsFullTxt, llmsSmallTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, sectionLlmsFullTxt, sectionLlmsTxt, SLUGS, type Page } from "./pages.js"; |
| 27 | 28 | import { docsJsonLd } from "../web/site.js"; |
| @@ -296,20 +297,26 @@ | ||
| 296 | 297 | |
| 297 | 298 | // The Claude Code plugins, for `diffinery setup` (which checks the SHA-256 header) and claude.ai's Upload |
| 298 | 299 | // 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) => { | |
| 301 | 303 | const file = c.req.param("file"); |
| 302 | 304 | const m = /^([a-z][a-z0-9-]*?)(?:-(\d+\.\d+\.\d+))?\.zip$/.exec(file); |
| 303 | 305 | const plugin = m && Object.hasOwn(CLAUDE_PLUGINS, m[1]!) ? CLAUDE_PLUGINS[m[1]!]! : null; |
| 304 | 306 | 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, { | |
| 308 | 315 | ...PUBLIC, |
| 309 | 316 | "content-type": "application/zip", |
| 310 | 317 | "content-disposition": `attachment; filename="${m[1]}-${plugin.version}.zip"`, |
| 311 | 318 | "x-diffinery-plugin-version": plugin.version, |
| 312 | - "x-diffinery-plugin-sha256": plugin.sha256, | |
| 319 | + "x-diffinery-plugin-sha256": sha256, | |
| 313 | 320 | }); |
| 314 | 321 | }); |
| 315 | 322 |
modifiedapps/diffinery/src/env.ts+5−0View file
| @@ -91,6 +91,11 @@ | ||
| 91 | 91 | * separated by commas. |
| 92 | 92 | */ |
| 93 | 93 | 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; | |
| 94 | 99 | /** Comma-separated emails that become site admins on first sign-in (logins are ignored). Deployed by scripts/bootstrap-admins.sh. */ |
| 95 | 100 | BOOTSTRAP_ADMINS: string; |
| 96 | 101 | /** e.g. example.cloudflareaccess.com — empty disables Access verification. */ |
modifiedapps/diffinery/src/http/api.ts+8−0View file
| @@ -443,6 +443,14 @@ | ||
| 443 | 443 | auditRepo(c, caller, repo, "repo.purge_unreachable", { objects: r.objects, bytes: r.bytes, via: "api" }); |
| 444 | 444 | return c.json({ objects: r.objects, bytes: r.bytes }); |
| 445 | 445 | }); |
| 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 | +}); | |
| 446 | 454 | api.delete("/repos/:owner/:repo", async (c) => { |
| 447 | 455 | const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin", { whileArchived: true }); |
| 448 | 456 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 4 | 5 | */ |
| 5 | 6 | |
| 6 | 7 | export type ArchiveEntry = { path: string; mode: string; content: Uint8Array } | { path: string; dir: true }; |
| @@ -208,6 +209,52 @@ | ||
| 208 | 209 | return concat(parts); |
| 209 | 210 | } |
| 210 | 211 | |
| 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 | + | |
| 211 | 258 | export function concat(parts: Uint8Array[]): Uint8Array { |
| 212 | 259 | let n = 0; |
| 213 | 260 | for (const p of parts) n += p.length; |
modifiedapps/diffinery/src/lib/util.ts+1−1View file
| @@ -111,7 +111,7 @@ | ||
| 111 | 111 | } |
| 112 | 112 | |
| 113 | 113 | /** 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"]); | |
| 115 | 115 | |
| 116 | 116 | /** |
| 117 | 117 | * 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 @@ | ||
| 396 | 396 | */ |
| 397 | 397 | async purgeUnreachable(caller: Caller): Promise<{ objects: number; bytes: number }> { |
| 398 | 398 | 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 { | |
| 399 | 434 | const total = this.store.count(); |
| 400 | 435 | 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); | |
| 429 | 466 | } |
| 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) }; | |
| 445 | 482 | } |
| 446 | 483 | |
| 447 | 484 | // ---------------------------------------------------------------- storage (the owner's storage quota, lib/quotas.ts) |
| @@ -1683,7 +1720,12 @@ | ||
| 1683 | 1720 | private todayMergeChecks = new Set<number>(); |
| 1684 | 1721 | |
| 1685 | 1722 | 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); | |
| 1687 | 1729 | // After answering, find out whether agents' pull requests that may be waiting for a person conflict |
| 1688 | 1730 | // or are already in their base, so the next Today leaves those out of "needs you". |
| 1689 | 1731 | 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 @@ | ||
| 384 | 384 | <button class="btn danger">Purge</button> |
| 385 | 385 | </Form> |
| 386 | 386 | <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 /> | |
| 387 | 398 | <Form action={`${base}/settings/delete`} t={t} match={full}> |
| 388 | 399 | <b>Delete this repository</b> |
| 389 | 400 | <p class="muted small"> |
| @@ -645,9 +656,19 @@ | ||
| 645 | 656 | ); |
| 646 | 657 | } |
| 647 | 658 | |
| 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 | + | |
| 648 | 669 | async function environmentPage(c: AppContext, l: Loaded, name: string) { |
| 649 | 670 | 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)]); | |
| 651 | 672 | const p = e.protection; |
| 652 | 673 | return page( |
| 653 | 674 | c, |
| @@ -681,14 +702,89 @@ | ||
| 681 | 702 | <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." /> |
| 682 | 703 | </div> |
| 683 | 704 | <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} /> | |
| 686 | 706 | </div> |
| 687 | 707 | <div class="factions"> |
| 688 | 708 | <button class="btn primary">Save protection rules</button> |
| 689 | 709 | </div> |
| 690 | 710 | </div> |
| 691 | 711 | </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> | |
| 692 | 788 | <Form action={`${envPath(l, e.name)}/delete`} t={t} class="box" confirm={`Delete the ${e.name} environment, its secrets and its variables?`}> |
| 693 | 789 | <div class="body drow"> |
| 694 | 790 | <div> |
| @@ -919,13 +1015,77 @@ | ||
| 919 | 1015 | const reviewers = await resolveReviewers(c, l.repo, logins.map((login) => ({ type: "User", login }))); |
| 920 | 1016 | const raw = f.s("wait_timer").trim(); |
| 921 | 1017 | 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 | + }); | |
| 924 | 1029 | }, |
| 925 | 1030 | "Protection rules saved.", |
| 926 | 1031 | ); |
| 927 | 1032 | }); |
| 928 | 1033 | |
| 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 | + | |
| 929 | 1089 | post(web, "/:owner/:repo/settings/environments/:env/delete", async (c) => { |
| 930 | 1090 | const l = await load(c, WHILE_IMPORTING); |
| 931 | 1091 | const name = prm(c, "env"); |
| @@ -1029,6 +1189,19 @@ | ||
| 1029 | 1189 | }); |
| 1030 | 1190 | }); |
| 1031 | 1191 | |
| 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 | + | |
| 1032 | 1205 | post(web, "/:owner/:repo/settings/transfer", async (c, f) => { |
| 1033 | 1206 | const l = await load(c, { whileArchived: true }); |
| 1034 | 1207 | const me = requireAccount(c); |
modifiedapps/diffinery/src/web/site.tsx+93−25View file
| @@ -97,9 +97,27 @@ | ||
| 97 | 97 | { 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" }, |
| 98 | 98 | { 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" }, |
| 99 | 99 | { 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" }, | |
| 101 | 101 | { 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" }, |
| 102 | 102 | ], |
| 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 | + ], | |
| 103 | 121 | workflow: `name: CI |
| 104 | 122 | on: [push, pull_request] |
| 105 | 123 | jobs: |
| @@ -158,8 +176,12 @@ | ||
| 158 | 176 | 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).", |
| 159 | 177 | }, |
| 160 | 178 | { |
| 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 | + { | |
| 161 | 183 | 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.", | |
| 163 | 185 | }, |
| 164 | 186 | ], |
| 165 | 187 | }; |
| @@ -183,6 +205,16 @@ | ||
| 183 | 205 | "", |
| 184 | 206 | ...HOME.features.map((f) => `- **[${f.title}](${f.href})**: ${f.text}`), |
| 185 | 207 | "", |
| 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 | + "", | |
| 186 | 218 | "## Your workflows, as they are", |
| 187 | 219 | "", |
| 188 | 220 | "```yaml", |
| @@ -199,7 +231,7 @@ | ||
| 199 | 231 | "", |
| 200 | 232 | "## Open source, on your own Cloudflare account", |
| 201 | 233 | "", |
| 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).`, | |
| 203 | 235 | "", |
| 204 | 236 | "## Frequently asked questions", |
| 205 | 237 | "", |
| @@ -208,6 +240,9 @@ | ||
| 208 | 240 | return absolute(lines.join("\n"), o); |
| 209 | 241 | } |
| 210 | 242 | |
| 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 | + | |
| 211 | 246 | /** Everything the home page says, for IndexNow (web/indexnow.ts): its Markdown and the landing page's own copy. */ |
| 212 | 247 | export async function homeContent(o: string): Promise<string> { |
| 213 | 248 | return `${homeMarkdown(o)}\n${await (<Landing />).toString()}`; |
| @@ -228,6 +263,9 @@ | ||
| 228 | 263 | spdx: "Apache-2.0", |
| 229 | 264 | }; |
| 230 | 265 | |
| 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 | + | |
| 231 | 269 | /** schema.org ids for the site's entities, shared by every page's graph. */ |
| 232 | 270 | const ids = (o: string) => ({ org: `${o}/#organization`, site: `${o}/#website`, app: `${o}/#software`, source: `${o}/#source` }); |
| 233 | 271 | |
| @@ -245,7 +283,12 @@ | ||
| 245 | 283 | applicationSubCategory: "Git hosting and continuous integration", |
| 246 | 284 | operatingSystem: "Web", |
| 247 | 285 | 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 | + ], | |
| 249 | 292 | license: SOURCE.license, |
| 250 | 293 | isAccessibleForFree: true, |
| 251 | 294 | 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 @@ | ||
| 316 | 359 | <a class="btn primary" href="/docs/self-hosting"> |
| 317 | 360 | Deploy your own |
| 318 | 361 | </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> | |
| 319 | 365 | <a class="btn" href={SOURCE.repo}> |
| 320 | 366 | View the source |
| 321 | 367 | </a> |
| 322 | - <a class="btn" href="/docs"> | |
| 323 | - Read the docs | |
| 324 | - </a> | |
| 325 | 368 | </> |
| 326 | 369 | ); |
| 327 | 370 | |
| @@ -342,7 +385,7 @@ | ||
| 342 | 385 | <Cta /> |
| 343 | 386 | </p> |
| 344 | 387 | <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>. | |
| 346 | 389 | </p> |
| 347 | 390 | </section> |
| 348 | 391 | <ul class="mk-stats" aria-label="Diffinery in numbers"> |
| @@ -382,27 +425,47 @@ | ||
| 382 | 425 | {/* One line: whitespace inside <pre> shows. */} |
| 383 | 426 | {raw(`<pre class="mk-code" aria-label="A GitHub Actions workflow that runs on Diffinery unchanged"><code>${escapeHtml(HOME.workflow)}</code></pre>`)} |
| 384 | 427 | </section> |
| 385 | - <section class="mk-sec"> | |
| 428 | + <section class="mk-sec" id="agents"> | |
| 386 | 429 | <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> | |
| 388 | 431 | <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 | + ))} | |
| 401 | 440 | </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> | |
| 402 | 447 | <p> |
| 403 | 448 | <a href="/agents">Diffinery for AI agents</a> · <a href="/docs/connect">Connect Claude, Claude Code or Codex</a> |
| 404 | 449 | </p> |
| 405 | 450 | </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> | |
| 406 | 469 | <section class="mk-sec"> |
| 407 | 470 | <h2>How Diffinery compares</h2> |
| 408 | 471 | <div class="table-wrap"> |
| @@ -443,7 +506,7 @@ | ||
| 443 | 506 | </section> |
| 444 | 507 | <section class="mk-band"> |
| 445 | 508 | <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> | |
| 447 | 510 | <p class="row" style="justify-content:center"> |
| 448 | 511 | <Cta /> |
| 449 | 512 | </p> |
| @@ -564,7 +627,7 @@ | ||
| 564 | 627 | <div class="markdown mk-body">{raw(html)}</div> |
| 565 | 628 | <aside class="mk-band"> |
| 566 | 629 | <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> | |
| 568 | 631 | <p class="row" style="justify-content:center"> |
| 569 | 632 | <Cta /> |
| 570 | 633 | </p> |
| @@ -598,10 +661,12 @@ | ||
| 598 | 661 | "", |
| 599 | 662 | `- [Home page](${o}/index.md): ${HOME.description}`, |
| 600 | 663 | `- 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).`, | |
| 602 | 665 | `- 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.`, |
| 603 | 666 | "- 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.", |
| 604 | 667 | "- 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`, | |
| 605 | 670 | "", |
| 606 | 671 | ]; |
| 607 | 672 | for (const s of SITE_NAV) { |
| @@ -652,6 +717,9 @@ | ||
| 652 | 717 | .mk-table thead th{font-weight:600;border-bottom:1px solid var(--border)}.mk-table thead th:nth-child(2){color:var(--accent)} |
| 653 | 718 | .mk-table tbody th{font-weight:500;color:var(--fg2)} |
| 654 | 719 | .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} | |
| 655 | 723 | .mk-band{margin:56px 0 0;padding:36px 20px;text-align:center;border:1px solid var(--border);border-radius:var(--r);background:var(--bg2)} |
| 656 | 724 | .mk-band p{margin:0 auto 14px;max-width:620px} |
| 657 | 725 | .mk-page{max-width:880px;margin:0 auto} |
modifiedapps/diffinery/src/web/ui.tsx+1−1View file
| @@ -450,7 +450,7 @@ | ||
| 450 | 450 | { 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"]] }, |
| 451 | 451 | { title: "Compare", links: [["/compare", "All comparisons"], ["/compare/github", "vs GitHub"], ["/compare/gitlab", "vs GitLab"], ["/compare/blacksmith", "vs Blacksmith"], ["/compare/depot", "vs Depot"]] }, |
| 452 | 452 | { 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"]] }, | |
| 454 | 454 | ].map((col) => ( |
| 455 | 455 | <nav aria-label={col.title}> |
| 456 | 456 | <h2>{col.title}</h2> |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+33−0View file
| @@ -2299,6 +2299,39 @@ | ||
| 2299 | 2299 | assert.ok((await gh.rest.repos.getAllEnvironments(R)).data.environments.some((e) => e.name === "staging")); |
| 2300 | 2300 | await gh.rest.actions.createEnvironmentVariable({ ...R, environment_name: "staging", name: "REGION", value: "eu" }); |
| 2301 | 2301 | 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(), []); | |
| 2302 | 2335 | await gh.rest.repos.deleteAnEnvironment({ ...R, environment_name: "staging" }); |
| 2303 | 2336 | |
| 2304 | 2337 | // ------------------------------------------------------------ repository_dispatch and repository variables |
modifiedapps/diffinery/test/e2e/security.test.mjs+52−0View file
| @@ -2095,6 +2095,58 @@ | ||
| 2095 | 2095 | assert.equal((await ok("GET", `${R}/branches/main`)).commit.sha, g(["rev-parse", "HEAD"])); |
| 2096 | 2096 | }); |
| 2097 | 2097 | |
| 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 | + | |
| 2098 | 2150 | test("security: webhooks reach only public hosts other than Diffinery, at most 20 per repository", async () => { |
| 2099 | 2151 | const R = `/repos/${OWNER}/hooked`; |
| 2100 | 2152 | 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 @@ | ||
| 9 | 9 | import { web } from "../../src/web/routes.js"; |
| 10 | 10 | import { slugify } from "../../src/web/markdown.js"; |
| 11 | 11 | import type { Env } from "../../src/env.js"; |
| 12 | +import { DOCS_IMAGES } from "../../src/docs/content.generated.js"; | |
| 12 | 13 | import { getPage, llmsFullTxt, llmsTxt } from "../../src/docs/pages.js"; |
| 13 | 14 | import { CLOUDFLARE_CLAIM, faqs, homeMarkdown, sitePage, sitePages } from "../../src/web/site.js"; |
| 14 | 15 | import { RESERVED_LOGINS } from "../../src/lib/util.js"; |
| @@ -53,6 +54,11 @@ | ||
| 53 | 54 | continue; |
| 54 | 55 | } |
| 55 | 56 | 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 | + } | |
| 56 | 62 | if (path === "/docs" || path.startsWith("/docs/")) { |
| 57 | 63 | if (OTHER_PATHS.some((re) => re.test(path))) continue; |
| 58 | 64 | const doc = getPage(path === "/docs" ? "index" : path.slice(6), ORIGIN); |
| @@ -158,11 +164,13 @@ | ||
| 158 | 164 | expect(html).toContain("Code hosting and CI for the agentic era</h1>"); |
| 159 | 165 | const g = graph(html); |
| 160 | 166 | 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. | |
| 162 | 168 | const source = g.find((n) => n["@type"] === "SoftwareSourceCode")!; |
| 163 | 169 | 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"] }); |
| 164 | 170 | 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"); | |
| 166 | 174 | expect(html).not.toMatch(/not been announced|hasn't been announced|invite-only while/i); |
| 167 | 175 | 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}"`); |
| 168 | 176 | }); |
| @@ -199,6 +207,7 @@ | ||
| 199 | 207 | expect(txt).toContain(`(${ORIGIN}/index.md)`); |
| 200 | 208 | for (const p of pages) expect(txt).toContain(`(${ORIGIN}${p.path}.md)`); |
| 201 | 209 | 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"); | |
| 202 | 211 | }); |
| 203 | 212 | |
| 204 | 213 | 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 @@ | ||
| 47 | 47 | it("orders what a person acts on", () => { |
| 48 | 48 | expect(agentPullState(pull({ number: 1, draft: true, checks: "failure" }))).toBe("draft"); |
| 49 | 49 | 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"); | |
| 50 | 53 | expect(agentPullState(pull({ number: 1, checks: "failure", changesRequestedBy: ["bob"] }))).toBe("checks_failing"); |
| 51 | 54 | expect(agentPullState(pull({ number: 1, changesRequestedBy: ["bob"], approvedHead: true }))).toBe("changes_requested"); |
| 52 | 55 | expect(agentPullState(pull({ number: 1, approvedHead: true }))).toBe("approved"); |
| @@ -68,6 +71,7 @@ | ||
| 68 | 71 | const agent = (p: Partial<TodayPull>) => pull({ number: 1, author: OTHER_BOT, mergeState: null, ...p }); |
| 69 | 72 | expect(needsMergeCheck(agent({}))).toBe(true); |
| 70 | 73 | expect(needsMergeCheck(agent({ checks: "pending" }))).toBe(true); |
| 74 | + expect(needsMergeCheck(agent({ autoMerge: true, awaitingApproval: true }))).toBe(true); | |
| 71 | 75 | 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); |
| 72 | 76 | }); |
| 73 | 77 | }); |
| @@ -84,6 +88,12 @@ | ||
| 84 | 88 | expect(t.needsYou.map((x) => `${x.repo.id}#${x.pull.number}:${x.kind}`).sort()).toEqual(["web#1:agent_approval", "web#2:agent_approval"]); |
| 85 | 89 | }); |
| 86 | 90 | |
| 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 | + | |
| 87 | 97 | it("only role-checks repositories with agent work waiting for a person", () => { |
| 88 | 98 | const repos = [ |
| 89 | 99 | { repo: repo("a"), data: data({ pulls: [pull({ number: 1, author: OTHER_BOT })] }) }, |
modifiedapps/jetbrains/CHANGELOG.md+9−0View file
| @@ -1,5 +1,14 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 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 | + | |
| 3 | 12 | ## 0.1.0 |
| 4 | 13 | |
| 5 | 14 | - **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 @@ | ||
| 9 | 9 | It's plain Java (no Kotlin), against the platform's stable API only: `./gradlew verifyPlugin` runs JetBrains' Plugin |
| 10 | 10 | Verifier against IntelliJ IDEA 2024.3, 2025.2 and the version the plugin builds with, and fails on internal API use. |
| 11 | 11 | |
| 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 | + | |
| 12 | 21 | ## Build |
| 13 | 22 | |
| 14 | 23 | 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 | |
| 2 | 2 | # The IDE the plugin is built against. -PideaHome=/path/to/idea uses one already on disk instead of downloading it. |
| 3 | 3 | platformVersion=2026.2.3 |
| 4 | 4 | sinceBuild=243 |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/CloneComponent.java+21−2View file
| @@ -5,7 +5,6 @@ | ||
| 5 | 5 | import com.intellij.openapi.application.ApplicationManager; |
| 6 | 6 | import com.intellij.openapi.application.ModalityState; |
| 7 | 7 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; |
| 8 | -import com.intellij.openapi.options.ShowSettingsUtil; | |
| 9 | 8 | import com.intellij.openapi.project.Project; |
| 10 | 9 | import com.intellij.openapi.ui.TextBrowseFolderListener; |
| 11 | 10 | import com.intellij.openapi.ui.TextFieldWithBrowseButton; |
| @@ -21,6 +20,7 @@ | ||
| 21 | 20 | import com.intellij.ui.ScrollPaneFactory; |
| 22 | 21 | import com.intellij.ui.SearchTextField; |
| 23 | 22 | import com.intellij.ui.SimpleTextAttributes; |
| 23 | +import com.intellij.ui.components.ActionLink; | |
| 24 | 24 | import com.intellij.ui.components.JBLabel; |
| 25 | 25 | import com.intellij.ui.components.JBList; |
| 26 | 26 | import com.intellij.util.ui.JBUI; |
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | import git4idea.commands.Git; |
| 30 | 30 | import java.awt.BorderLayout; |
| 31 | 31 | import java.awt.CardLayout; |
| 32 | +import java.awt.FlowLayout; | |
| 32 | 33 | import java.awt.GridBagConstraints; |
| 33 | 34 | import java.awt.GridBagLayout; |
| 34 | 35 | import java.awt.event.KeyAdapter; |
| @@ -72,6 +73,8 @@ | ||
| 72 | 73 | private final TextFieldWithBrowseButton directory = new TextFieldWithBrowseButton(); |
| 73 | 74 | private final JBLabel serverLabel = new JBLabel(); |
| 74 | 75 | private final JButton signInButton = new JButton("Sign In with Browser…"); |
| 76 | + private final ActionLink suggestedLink = Plugin.link("", this::useSuggested); | |
| 77 | + private String suggested; | |
| 75 | 78 | |
| 76 | 79 | private List<Repo> mine = List.of(); |
| 77 | 80 | private List<Repo> found = List.of(); |
| @@ -117,8 +120,13 @@ | ||
| 117 | 120 | serverLabel.setForeground(UIUtil.getContextHelpForeground()); |
| 118 | 121 | c.insets = JBUI.insetsTop(16); |
| 119 | 122 | 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))); | |
| 120 | 128 | c.insets = JBUI.emptyInsets(); |
| 121 | - p.add(Plugin.link("Change server…", () -> ShowSettingsUtil.getInstance().showSettingsDialog(project, SettingsPage.class)), c); | |
| 129 | + p.add(servers, c); | |
| 122 | 130 | return p; |
| 123 | 131 | } |
| 124 | 132 | |
| @@ -213,6 +221,11 @@ | ||
| 213 | 221 | getDialogStateListener().onListItemChanged(); |
| 214 | 222 | if (a == null) { |
| 215 | 223 | 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); | |
| 216 | 229 | cards.show(root, SIGNED_OUT); |
| 217 | 230 | getDialogStateListener().onOkActionEnabled(false); |
| 218 | 231 | return; |
| @@ -222,6 +235,12 @@ | ||
| 222 | 235 | load(); |
| 223 | 236 | } |
| 224 | 237 | |
| 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 | + | |
| 225 | 244 | private void load() { |
| 226 | 245 | int gen = loads.incrementAndGet(); |
| 227 | 246 | mine = List.of(); |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SettingsPage.java+1−1View file
| @@ -47,7 +47,7 @@ | ||
| 47 | 47 | buttons.add(signIn); |
| 48 | 48 | buttons.add(useToken); |
| 49 | 49 | 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."); | |
| 51 | 51 | serverHelp.setForeground(UIUtil.getContextHelpForeground()); |
| 52 | 52 | 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>"); |
| 53 | 53 | gitHelp.setForeground(UIUtil.getContextHelpForeground()); |
modifiedapps/jetbrains/src/main/java/com/diffinery/jetbrains/SignInDialogs.java+104−0View file
| @@ -2,19 +2,123 @@ | ||
| 2 | 2 | |
| 3 | 3 | import com.intellij.openapi.application.ApplicationManager; |
| 4 | 4 | import com.intellij.openapi.application.ModalityState; |
| 5 | +import com.intellij.openapi.diagnostic.Logger; | |
| 5 | 6 | import com.intellij.openapi.progress.ProgressIndicator; |
| 6 | 7 | import com.intellij.openapi.progress.ProgressManager; |
| 7 | 8 | import com.intellij.openapi.progress.Task; |
| 8 | 9 | import com.intellij.openapi.project.Project; |
| 10 | +import com.intellij.openapi.ui.InputValidatorEx; | |
| 9 | 11 | import com.intellij.openapi.ui.Messages; |
| 12 | +import com.intellij.openapi.updateSettings.impl.UpdateSettings; | |
| 13 | +import java.io.IOException; | |
| 14 | +import java.util.List; | |
| 10 | 15 | import java.util.function.Consumer; |
| 11 | 16 | import org.jetbrains.annotations.NotNull; |
| 12 | 17 | import org.jetbrains.annotations.Nullable; |
| 13 | 18 | |
| 14 | 19 | /** Signing in from anywhere in the IDE: with a progress dialog that can be cancelled, and errors shown as sentences. */ |
| 15 | 20 | final class SignInDialogs { |
| 21 | + private static final Logger LOG = Logger.getInstance(SignInDialogs.class); | |
| 22 | + | |
| 16 | 23 | private SignInDialogs() {} |
| 17 | 24 | |
| 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 | + | |
| 18 | 122 | /** Opens the browser to sign in; `done` runs on the UI thread with the account once signed in. */ |
| 19 | 123 | static void signInWithBrowser(@Nullable Project project, @Nullable Consumer<Accounts.Account> done) { |
| 20 | 124 | 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 @@ | ||
| 7 | 7 | import java.security.MessageDigest; |
| 8 | 8 | import java.security.NoSuchAlgorithmException; |
| 9 | 9 | import java.security.SecureRandom; |
| 10 | +import java.util.ArrayList; | |
| 10 | 11 | import java.util.Base64; |
| 11 | 12 | import java.util.LinkedHashMap; |
| 12 | 13 | import java.util.List; |
| @@ -43,6 +44,45 @@ | ||
| 43 | 44 | return scheme + "://" + u.getRawAuthority() + path; |
| 44 | 45 | } |
| 45 | 46 | |
| 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 | + | |
| 46 | 86 | /** The OAuth client_id of this plugin on a server: the URL of the metadata document the server publishes about it. */ |
| 47 | 87 | static String clientId(String server) { |
| 48 | 88 | return server + "/oauth/clients/jetbrains.json"; |
modifiedapps/jetbrains/src/main/resources/META-INF/plugin.xml+1−0View file
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | <li><b>Push and pull without passwords.</b> When Git asks for a password for Diffinery, the plugin answers with |
| 14 | 14 | your signed-in account, and renews its token before it runs out.</li> |
| 15 | 15 | <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> | |
| 16 | 17 | </ul> |
| 17 | 18 | <p>Guide: <a href="https://diffinery.com/docs/repositories/clone-in-jetbrains-ides">Clone in JetBrains IDEs</a>.</p> |
| 18 | 19 | ]]></description> |
modifiedapps/jetbrains/src/test/java/com/diffinery/jetbrains/UtilTest.java+28−0View file
| @@ -31,6 +31,34 @@ | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | @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 | |
| 34 | 62 | public void authorizeUrl() { |
| 35 | 63 | String url = Util.authorizeUrl(S, "http://127.0.0.1:5000/callback", "ch", "st"); |
| 36 | 64 | assertTrue(url.startsWith(S + "/oauth/authorize?")); |
modifiedapps/vscode/CHANGELOG.md+6−0View file
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | # Changelog |
| 2 | 2 | |
| 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 | + | |
| 3 | 9 | ## 0.1.1 |
| 4 | 10 | |
| 5 | 11 | - "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 @@ | ||
| 6 | 6 | |
| 7 | 7 | ## Before the first upload |
| 8 | 8 | |
| 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. | |
| 19 | 18 | |
| 20 | 19 | ## Listing |
| 21 | 20 | |
| @@ -33,7 +32,9 @@ | ||
| 33 | 32 | ## Each release |
| 34 | 33 | |
| 35 | 34 | 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`. | |
| 39 | 40 | 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 @@ | ||
| 6 | 6 | - **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick |
| 7 | 7 | **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first; |
| 8 | 8 | 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. | |
| 11 | 12 | - **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews |
| 12 | 13 | its token before it expires and keeps the copy Git saved in your keychain current. |
| 13 | 14 | - **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**, |
| @@ -29,6 +30,22 @@ | ||
| 29 | 30 | The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at |
| 30 | 31 | [diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor). |
| 31 | 32 | |
| 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 | + | |
| 32 | 49 | ## Commands |
| 33 | 50 | |
| 34 | 51 | | Command | What it does | |
| @@ -36,6 +53,7 @@ | ||
| 36 | 53 | | Diffinery: Clone Repository | Opens the Diffinery repository picker directly. | |
| 37 | 54 | | Diffinery: Sign In | Signs in through your browser. | |
| 38 | 55 | | 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). | | |
| 39 | 57 | | Diffinery: Sign Out | Signs out and disconnects the editor from your account. | |
| 40 | 58 | | Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. | |
| 41 | 59 | |
| @@ -43,6 +61,6 @@ | ||
| 43 | 61 | |
| 44 | 62 | | Setting | Default | | |
| 45 | 63 | |---|---|---| |
| 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. | | |
| 47 | 65 | | `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. | |
| 48 | 66 | | `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 @@ | ||
| 27 | 27 | $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix" |
| 28 | 28 | Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing |
| 29 | 29 | 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 | + | |
| 30 | 46 | $installed = 0 |
| 31 | 47 | foreach ($e in $known) { |
| 32 | 48 | if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue } |
| @@ -46,6 +62,14 @@ | ||
| 46 | 62 | exit 1 |
| 47 | 63 | } |
| 48 | 64 | 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 | + } | |
| 49 | 73 | Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)" |
| 50 | 74 | } finally { |
| 51 | 75 | Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue |
modifiedapps/vscode/install/install.sh+20−0View file
| @@ -65,6 +65,18 @@ | ||
| 65 | 65 | if command -v sha256sum >/dev/null; then got=$(sha256sum "$vsix" | cut -d' ' -f1); else got=$(shasum -a 256 "$vsix" | cut -d' ' -f1); fi |
| 66 | 66 | [ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed" |
| 67 | 67 | |
| 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 | + | |
| 68 | 80 | installed=0 |
| 69 | 81 | while IFS='|' read -r name label app; do |
| 70 | 82 | if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi |
| @@ -86,4 +98,12 @@ | ||
| 86 | 98 | exit 1 |
| 87 | 99 | fi |
| 88 | 100 | 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 | |
| 89 | 109 | 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 @@ | ||
| 2 | 2 | "name": "@diffinery/vscode", |
| 3 | 3 | "displayName": "Diffinery", |
| 4 | 4 | "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", | |
| 6 | 6 | "publisher": "codician", |
| 7 | 7 | "private": true, |
| 8 | 8 | "license": "Apache-2.0", |
| @@ -86,6 +86,11 @@ | ||
| 86 | 86 | "category": "Diffinery" |
| 87 | 87 | }, |
| 88 | 88 | { |
| 89 | + "command": "diffinery.useServer", | |
| 90 | + "title": "Use Another Server…", | |
| 91 | + "category": "Diffinery" | |
| 92 | + }, | |
| 93 | + { | |
| 89 | 94 | "command": "diffinery.signOut", |
| 90 | 95 | "title": "Sign Out", |
| 91 | 96 | "category": "Diffinery" |
| @@ -103,7 +108,7 @@ | ||
| 103 | 108 | "type": "string", |
| 104 | 109 | "default": "https://diffinery.com", |
| 105 | 110 | "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." | |
| 107 | 112 | }, |
| 108 | 113 | "diffinery.gitAuthentication": { |
| 109 | 114 | "type": "boolean", |
modifiedapps/vscode/scripts/package.mjs+23−11View file
| @@ -1,7 +1,11 @@ | ||
| 1 | 1 | // Builds the extension and packs it as a .vsix, the file VS Code, Cursor, Windsurf and VSCodium install. |
| 2 | 2 | // |
| 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] | |
| 4 | 4 | // |
| 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 | +// | |
| 5 | 9 | // apps/diffinery/scripts/build-docs.mjs calls packageExtension() too, so the Worker serves the build at |
| 6 | 10 | // /-/vscode/diffinery.vsix. The .vsix has the layout `vsce package` writes (extension.vsixmanifest, |
| 7 | 11 | // [Content_Types].xml, extension/…), so it can also go to the Visual Studio Marketplace and Open VSX |
| @@ -18,13 +22,18 @@ | ||
| 18 | 22 | |
| 19 | 23 | const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| 20 | 24 | |
| 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; | |
| 24 | 33 | return { ...rest, name: EXTENSION_NAME }; |
| 25 | 34 | } |
| 26 | 35 | |
| 27 | -function vsixManifest(m) { | |
| 36 | +export function vsixManifest(m) { | |
| 28 | 37 | const props = [ |
| 29 | 38 | ["Microsoft.VisualStudio.Code.Engine", m.engines.vscode], |
| 30 | 39 | ["Microsoft.VisualStudio.Code.ExtensionDependencies", (m.extensionDependencies ?? []).join(",")], |
| @@ -62,6 +71,7 @@ | ||
| 62 | 71 | <Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" /> |
| 63 | 72 | <Asset Type="Microsoft.VisualStudio.Services.Content.Details" Path="extension/readme.md" Addressable="true" /> |
| 64 | 73 | <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" /> | |
| 65 | 75 | <Asset Type="Microsoft.VisualStudio.Services.Icons.Default" Path="extension/${xml(m.icon)}" Addressable="true" /> |
| 66 | 76 | </Assets> |
| 67 | 77 | </PackageManifest> |
| @@ -69,7 +79,7 @@ | ||
| 69 | 79 | } |
| 70 | 80 | |
| 71 | 81 | 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" }; | |
| 73 | 83 | const exts = [...new Set(names.map((n) => path.extname(n).toLowerCase()).filter((e) => e && types[e]))].sort(); |
| 74 | 84 | return `<?xml version="1.0" encoding="utf-8"?> |
| 75 | 85 | <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 @@ | ||
| 146 | 156 | return Buffer.concat([...locals, ...central, end]); |
| 147 | 157 | } |
| 148 | 158 | |
| 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 } = {}) { | |
| 151 | 161 | const pkg = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8")); |
| 152 | - const manifest = manifestFor(pkg); | |
| 162 | + const manifest = manifestFor(pkg, { marketplace }); | |
| 153 | 163 | const bundle = await build({ |
| 154 | 164 | entryPoints: [path.join(ROOT, "src/extension.ts")], |
| 155 | 165 | bundle: true, |
| @@ -166,6 +176,7 @@ | ||
| 166 | 176 | ["extension/package.json", JSON.stringify(manifest, null, 2)], |
| 167 | 177 | ["extension/readme.md", await readFile(path.join(ROOT, "README.md"))], |
| 168 | 178 | ["extension/changelog.md", await readFile(path.join(ROOT, "CHANGELOG.md"))], |
| 179 | + ["extension/LICENSE.txt", await readFile(path.join(ROOT, "../../LICENSE"))], | |
| 169 | 180 | ["extension/dist/extension.js", bundle.outputFiles[0].contents], |
| 170 | 181 | [`extension/${manifest.icon}`, await readFile(path.join(ROOT, manifest.icon))], |
| 171 | 182 | ["extension/media/diffinery-icons.woff", await readFile(path.join(ROOT, "media/diffinery-icons.woff"))], |
| @@ -181,9 +192,10 @@ | ||
| 181 | 192 | if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { |
| 182 | 193 | const i = process.argv.indexOf("--out"); |
| 183 | 194 | 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 }); | |
| 185 | 197 | 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`); | |
| 187 | 199 | await writeFile(file, vsix); |
| 188 | 200 | console.log(`vscode: ${path.relative(process.cwd(), file)} (${(vsix.length / 1024).toFixed(0)} KB)`); |
| 189 | 201 | } |
modifiedapps/vscode/src/auth.ts+10−0View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { randomUUID } from "node:crypto"; |
| 3 | 3 | import * as vscode from "vscode"; |
| 4 | 4 | import { Api } from "./api"; |
| 5 | +import { chooseServer } from "./servers"; | |
| 5 | 6 | import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util"; |
| 6 | 7 | |
| 7 | 8 | export const AUTH_ID = "diffinery"; |
| @@ -184,6 +185,10 @@ | ||
| 184 | 185 | } |
| 185 | 186 | |
| 186 | 187 | 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); | |
| 187 | 192 | const s = this.withToken ? await this.askForToken() : await this.signInWithBrowser(); |
| 188 | 193 | // Signing in again replaces the old sign-in, and ends its connection on the server. |
| 189 | 194 | const old = await this.load(); |
| @@ -320,6 +325,11 @@ | ||
| 320 | 325 | return { id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token }; |
| 321 | 326 | } |
| 322 | 327 | |
| 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 | + | |
| 323 | 333 | async signOut(): Promise<void> { |
| 324 | 334 | const s = await this.load(); |
| 325 | 335 | if (!s) { |
modifiedapps/vscode/src/extension.ts+3−0View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth"; |
| 3 | 3 | import { GitCredentials, Publisher, syncCredentialStore } from "./git"; |
| 4 | 4 | import { RemoteSources } from "./remoteSources"; |
| 5 | +import { offerInstallerServer, switchServer } from "./servers"; | |
| 5 | 6 | import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git"; |
| 6 | 7 | import { checkForUpdates } from "./updates"; |
| 7 | 8 | |
| @@ -26,6 +27,7 @@ | ||
| 26 | 27 | command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed"); |
| 27 | 28 | command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed"); |
| 28 | 29 | command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed"); |
| 30 | + command("diffinery.useServer", () => switchServer(ctx.globalState, () => auth.login()), "Changing the Diffinery server failed"); | |
| 29 | 31 | command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed"); |
| 30 | 32 | |
| 31 | 33 | // ---------------------------------------------------------------- the built-in Git extensions |
| @@ -86,6 +88,7 @@ | ||
| 86 | 88 | "Cloning from Diffinery failed", |
| 87 | 89 | ); |
| 88 | 90 | |
| 91 | + void offerInstallerServer(ctx.globalState, () => auth.login(), log).catch(report("Changing the Diffinery server failed")); | |
| 89 | 92 | void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e)); |
| 90 | 93 | } |
| 91 | 94 |
modifiedapps/vscode/src/git.ts+5−3View file
| @@ -10,14 +10,16 @@ | ||
| 10 | 10 | constructor(private readonly auth: DiffineryAuth) {} |
| 11 | 11 | |
| 12 | 12 | async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> { |
| 13 | - let server: URL; | |
| 13 | + let server: string; | |
| 14 | 14 | try { |
| 15 | - server = new URL(this.auth.server()); | |
| 15 | + server = this.auth.server(); | |
| 16 | 16 | } catch { |
| 17 | 17 | return undefined; |
| 18 | 18 | } |
| 19 | - if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined; | |
| 19 | + if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined; | |
| 20 | 20 | 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; | |
| 21 | 23 | return { username: session.account.label, password: session.accessToken }; |
| 22 | 24 | } |
| 23 | 25 | } |
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 @@ | ||
| 1 | 1 | // Pure helpers, without the vscode module, so the unit tests can run them under Node. |
| 2 | 2 | import { createHash, randomBytes } from "node:crypto"; |
| 3 | +import * as path from "node:path"; | |
| 3 | 4 | |
| 4 | 5 | export const EXTENSION_ID = "codician.diffinery"; |
| 5 | 6 | /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */ |
| 6 | 7 | export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"]; |
| 7 | 8 | export const DEFAULT_SERVER = "https://diffinery.com"; |
| 8 | 9 | |
| 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" } { | |
| 12 | 11 | let u: URL; |
| 13 | 12 | try { |
| 14 | 13 | u = new URL(s.includes("://") ? s : `https://${s}`); |
| 15 | 14 | } 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" }; | |
| 17 | 16 | } |
| 18 | 17 | 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(/\/+$/, "")}` }; | |
| 21 | 20 | } |
| 22 | 21 | |
| 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 | + | |
| 23 | 63 | /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */ |
| 24 | 64 | export const clientId = (server: string) => `${server}/oauth/clients/editor.json`; |
| 25 | 65 |
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 @@ | ||
| 1 | 1 | import { createHash } from "node:crypto"; |
| 2 | 2 | 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"; | |
| 4 | 6 | |
| 5 | 7 | const S = "https://diffinery.com"; |
| 6 | 8 | |
| @@ -14,6 +16,39 @@ | ||
| 14 | 16 | expect(() => normalizeServer("https://")).toThrow(/valid URL/); |
| 15 | 17 | }); |
| 16 | 18 | |
| 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 | + | |
| 17 | 52 | it("returns to the editor by its own scheme when the server knows it", () => { |
| 18 | 53 | expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth"); |
| 19 | 54 | expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth"); |
modifiedcontext7.json+1−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://context7.com/schema/context7.json", |
| 3 | 3 | "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.", | |
| 5 | 5 | "folders": ["apps/diffinery/docs", "apps/diffinery/site"], |
| 6 | 6 | "excludeFolders": ["apps/diffinery/docs/images", "apps/diffinery/site/blog"], |
| 7 | 7 | "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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "diffinery", |
| 3 | 3 | "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.", | |
| 5 | 5 | "homepage": "https://diffinery.com", |
| 6 | 6 | "repository": { |
| 7 | 7 | "type": "git", |
| @@ -26,7 +26,11 @@ | ||
| 26 | 26 | "serverless", |
| 27 | 27 | "code-review", |
| 28 | 28 | "ai-agents", |
| 29 | - "mcp" | |
| 29 | + "ai-code-review", | |
| 30 | + "claude-code", | |
| 31 | + "codex", | |
| 32 | + "mcp", | |
| 33 | + "mcp-server" | |
| 30 | 34 | ], |
| 31 | 35 | "license": "Apache-2.0", |
| 32 | 36 | "packageManager": "pnpm@10.28.0", |
modifiedplugins/README.md+38−8View file
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | A plugin marketplace, kept in the Diffinery repository itself, that makes Claude Code and |
| 4 | 4 | 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)). | |
| 6 | 7 | It has two plugins: `diffinery` (Claude Code and Codex) and `diffinery-ci` (Claude Code), |
| 7 | 8 | described below. |
| 8 | 9 | |
| @@ -10,7 +11,7 @@ | ||
| 10 | 11 | |
| 11 | 12 | | Piece | Claude Code | Codex | |
| 12 | 13 | | --- | --- | --- | |
| 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) | | |
| 14 | 15 | | `diffinery` skill: the CLI, git auth, the branch → PR → CI → merge loop, Diffinery's agent rules | yes | yes | |
| 15 | 16 | | `pr-status`: this branch's PR, its checks, and the log tail of failing jobs | `/diffinery:pr-status` command | `pr-status` skill | |
| 16 | 17 | | 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 @@ | ||
| 25 | 26 | | Status line entry (`PR #7 ✓ 10/10`) and a toast when CI passes or fails | Where Claude Code draws | |
| 26 | 27 | | 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 | |
| 27 | 28 | |
| 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 | |
| 30 | 33 | Claude Code 2.1.287 or later (the desktop app: 2.1.286). Cloud sessions run its hooks but |
| 31 | 34 | draw no mod interface, so there it's the note to Claude and the `/diffinery-ci` text. |
| 32 | 35 | |
| @@ -77,6 +80,30 @@ | ||
| 77 | 80 | you review and trust them: open `/hooks` in the Codex TUI and trust the `diffinery` |
| 78 | 81 | SessionStart hook (optional; the skill works without it). |
| 79 | 82 | |
| 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 | + | |
| 80 | 107 | ## Sign in |
| 81 | 108 | |
| 82 | 109 | The MCP server signs you in with OAuth in the browser: |
| @@ -124,14 +151,15 @@ | ||
| 124 | 151 | |
| 125 | 152 | ## claude.ai and the Claude apps |
| 126 | 153 | |
| 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. | |
| 129 | 157 | |
| 130 | 158 | ## gh |
| 131 | 159 | |
| 132 | 160 | Prefer the `diffinery` CLI: it knows Diffinery's extras (`--intent`, auto-merge, |
| 133 | 161 | `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 | |
| 135 | 163 | branch's pull request with its CI status. See https://diffinery.com/docs/agents/github-cli. |
| 136 | 164 | |
| 137 | 165 | ## Layout |
| @@ -144,7 +172,9 @@ | ||
| 144 | 172 | plugins/diffinery/ |
| 145 | 173 | .claude-plugin/plugin.json Claude Code manifest |
| 146 | 174 | .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) | |
| 148 | 178 | skills/diffinery/SKILL.md the skill (both clients) |
| 149 | 179 | commands/pr-status.md /pr-status (Claude Code) |
| 150 | 180 | 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://anthropic.com/claude-code/plugin.schema.json", |
| 3 | 3 | "name": "diffinery-ci", |
| 4 | - "version": "0.2.3", | |
| 4 | + "version": "0.2.4", | |
| 5 | 5 | "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" }, | |
| 7 | 7 | "homepage": "https://diffinery.com/docs/connect/ci-status-in-claude", |
| 8 | 8 | "repository": "https://diffinery.com/codician/diffinery", |
| 9 | 9 | "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", | |
| 10 | 13 | "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 | + } | |
| 12 | 23 | } |
modifiedplugins/diffinery-ci/hooks/lib.ts+36−0View file
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | export function remoteRepo(url: string, host: string): string | null { |
| 17 | 17 | 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+$/, '') | |
| 18 | 20 | let rest: string | null = null |
| 19 | 21 | const withScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/.exec(u) |
| 20 | 22 | if (withScheme) { |
| @@ -39,6 +41,40 @@ | ||
| 39 | 41 | } |
| 40 | 42 | } |
| 41 | 43 | |
| 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 | + | |
| 42 | 78 | /** Picks the first Diffinery remote from `git remote -v`-style lines, origin first. */ |
| 43 | 79 | export function pickRemote(remotes: readonly { name: string; url: string }[], host: string): string | null { |
| 44 | 80 | 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 @@ | ||
| 8 | 8 | // where the band shows the rest, passes too where nothing draws, so a cloud thread hears either |
| 9 | 9 | // way. /diffinery-ci answers in text where nothing draws. Nothing to set up. |
| 10 | 10 | // |
| 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. | |
| 13 | 15 | |
| 14 | 16 | import { atom, read, update } from 'claude-code' |
| 15 | 17 | import type { Register } from 'claude-code' |
| @@ -19,9 +21,9 @@ | ||
| 19 | 21 | QUERY, |
| 20 | 22 | SYMBOL, |
| 21 | 23 | checksLine, |
| 24 | + chooseAuth, | |
| 22 | 25 | duration, |
| 23 | 26 | factsLine, |
| 24 | - hostOf, | |
| 25 | 27 | ordered, |
| 26 | 28 | parsePr, |
| 27 | 29 | parseRemotes, |
| @@ -39,7 +41,6 @@ | ||
| 39 | 41 | |
| 40 | 42 | const PLUGIN = 'diffinery-ci' |
| 41 | 43 | const PANE = 'diffinery-ci' |
| 42 | -const DEFAULT_URL = 'https://diffinery.com' | |
| 43 | 44 | |
| 44 | 45 | // Change a tag when the stored value's fields change: it declines what an earlier version |
| 45 | 46 | // stored, which outlives a reload. |
| @@ -60,7 +61,7 @@ | ||
| 60 | 61 | off: 'never', |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | -export const register: Register = on => { | |
| 64 | +export const register: Register = (on, options) => { | |
| 64 | 65 | // What the person chose with `/diffinery-ci notes`, kept across sessions. |
| 65 | 66 | let choice: WakeChoice = 'auto' |
| 66 | 67 | |
| @@ -86,28 +87,29 @@ | ||
| 86 | 87 | const seen = new Map<string, string>() |
| 87 | 88 | |
| 88 | 89 | 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. | |
| 107 | 102 | } |
| 108 | 103 | } |
| 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 | |
| 111 | 113 | if (!token) { |
| 112 | 114 | try { |
| 113 | 115 | 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 @@ | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | checksLine, |
| 6 | + chooseAuth, | |
| 6 | 7 | parsePr, |
| 7 | 8 | pollDelay, |
| 8 | 9 | publishes, |
| @@ -50,6 +51,28 @@ | ||
| 50 | 51 | }, |
| 51 | 52 | }) |
| 52 | 53 | |
| 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 | + | |
| 53 | 76 | test('remotes on the Diffinery host are read, lookalikes are not', async () => { |
| 54 | 77 | expect(remoteRepo('https://diffinery.com/codician/demo.git', 'diffinery.com')).toBe('codician/demo') |
| 55 | 78 | 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "$schema": "https://anthropic.com/claude-code/plugin.schema.json", |
| 3 | 3 | "name": "diffinery", |
| 4 | - "version": "0.1.3", | |
| 4 | + "version": "0.1.4", | |
| 5 | 5 | "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" }, | |
| 7 | 7 | "homepage": "https://diffinery.com/docs/connect/claude-code", |
| 8 | 8 | "repository": "https://diffinery.com/codician/diffinery", |
| 9 | 9 | "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 | + } | |
| 11 | 22 | } |
modifiedplugins/diffinery/.codex-plugin/plugin.json+4−4View file
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "diffinery", |
| 3 | - "version": "0.1.3", | |
| 3 | + "version": "0.1.4", | |
| 4 | 4 | "description": "Work with repositories hosted on Diffinery: pull requests, CI checks, workflow logs and issues through the diffinery CLI and the Diffinery MCP server.", |
| 5 | 5 | "author": { |
| 6 | - "name": "Codician" | |
| 6 | + "name": "Codician Team" | |
| 7 | 7 | }, |
| 8 | 8 | "homepage": "https://diffinery.com/docs/connect/claude-code", |
| 9 | 9 | "repository": "https://diffinery.com/codician/diffinery", |
| @@ -20,12 +20,12 @@ | ||
| 20 | 20 | "./skills/", |
| 21 | 21 | "./codex-skills/" |
| 22 | 22 | ], |
| 23 | - "mcpServers": "./.mcp.json", | |
| 23 | + "mcpServers": "./codex.mcp.json", | |
| 24 | 24 | "interface": { |
| 25 | 25 | "displayName": "Diffinery", |
| 26 | 26 | "shortDescription": "Pull requests, CI and issues on Diffinery", |
| 27 | 27 | "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", | |
| 29 | 29 | "category": "Coding", |
| 30 | 30 | "capabilities": [ |
| 31 | 31 | "Read", |
modifiedplugins/diffinery/.mcp.json+1−1View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "mcpServers": { |
| 3 | 3 | "diffinery": { |
| 4 | 4 | "type": "http", |
| 5 | - "url": "https://diffinery.com/mcp" | |
| 5 | + "url": "${user_config.url}/mcp" | |
| 6 | 6 | } |
| 7 | 7 | } |
| 8 | 8 | } |
modifiedplugins/diffinery/codex-skills/pr-status/SKILL.md+5−4View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | 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. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | # PR status |
| @@ -11,9 +11,10 @@ | ||
| 11 | 11 | comments. |
| 12 | 12 | |
| 13 | 13 | 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. | |
| 17 | 18 | 2. Run `diffinery pr view [N]`. If there is no open pull request for the branch, say so |
| 18 | 19 | (suggest `diffinery pr create ... --intent ...`) and stop. |
| 19 | 20 | 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 @@ | ||
| 9 | 9 | change anything: no pushes, merges, re-runs or comments. |
| 10 | 10 | |
| 11 | 11 | 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. | |
| 15 | 16 | 2. Run `diffinery pr view $ARGUMENTS`. If there is no open pull request for the branch, |
| 16 | 17 | say so (suggest `diffinery pr create ... --intent ...`) and stop. |
| 17 | 18 | 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 @@ | ||
| 1 | 1 | #!/bin/sh |
| 2 | 2 | # SessionStart hook for the diffinery plugin (Claude Code and Codex). |
| 3 | 3 | # |
| 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, | |
| 5 | 5 | # 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. | |
| 7 | 7 | # 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. | |
| 8 | 11 | |
| 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 | + | |
| 9 | 20 | main() { |
| 10 | 21 | dir=${CLAUDE_PROJECT_DIR:-$PWD} |
| 11 | 22 | command -v git >/dev/null 2>&1 || return 0 |
| 12 | 23 | |
| 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=$( | |
| 16 | 33 | { |
| 17 | 34 | git -C "$dir" remote get-url origin |
| 18 | 35 | 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 | |
| 22 | 37 | ) |
| 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 | |
| 23 | 54 | [ -n "$slug" ] || return 0 |
| 24 | 55 | slug=${slug%.git} |
| 25 | 56 | |
| @@ -30,22 +61,27 @@ | ||
| 30 | 61 | cli="yes, at ${DIFFINERY_INSTALL:-$HOME/.diffinery}/bin/diffinery (not on PATH)" |
| 31 | 62 | fi |
| 32 | 63 | |
| 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}}") | |
| 33 | 66 | 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 | |
| 42 | 76 | fi |
| 43 | 77 | |
| 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." | |
| 45 | 79 | 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\`." ;; | |
| 47 | 81 | 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 | |
| 49 | 85 | msg="$msg To sign in, ask the user to run \`diffinery login\` (or \`diffinery login --device\`), or set DIFFINERY_TOKEN." |
| 50 | 86 | fi |
| 51 | 87 |
modifiedplugins/diffinery/skills/diffinery/SKILL.md+24−11View file
| @@ -1,13 +1,16 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | 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. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | # Diffinery |
| 7 | 7 | |
| 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). | |
| 11 | 14 | |
| 12 | 15 | ## Is this repository on Diffinery? |
| 13 | 16 | |
| @@ -16,15 +19,19 @@ | ||
| 16 | 19 | ``` |
| 17 | 20 | |
| 18 | 21 | 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 | |
| 20 | 27 | (it treats Diffinery as a GitHub Enterprise host); until then `gh` has no token for it. |
| 21 | 28 | Prefer `diffinery`: it knows Diffinery's extras (`--intent`, `run logs`). |
| 22 | 29 | |
| 23 | 30 | ## Set up (once per machine) |
| 24 | 31 | |
| 25 | 32 | ```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 | |
| 28 | 35 | ``` |
| 29 | 36 | |
| 30 | 37 | The installer puts the CLI in `~/.diffinery/bin`; if `diffinery` is not found right |
| @@ -36,12 +43,15 @@ | ||
| 36 | 43 | - Interactive machine: ask the user to run `diffinery login` (opens a browser) or |
| 37 | 44 | `diffinery login --device` (prints a code to enter elsewhere). Do not try to complete |
| 38 | 45 | 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`. | |
| 39 | 49 | |
| 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 | |
| 41 | 51 | that uses the CLI's token (including `DIFFINERY_TOKEN`). Without the CLI, git accepts |
| 42 | 52 | the token as the HTTPS password. Plain git (clone, fetch, push) needs nothing else. |
| 43 | 53 | |
| 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 | |
| 45 | 55 | `gh pr ...` works in Diffinery clones (needs `gh` installed). |
| 46 | 56 | |
| 47 | 57 | ## The loop |
| @@ -109,8 +119,11 @@ | ||
| 109 | 119 | `search_docs`, `read_docs`, ...): use them when there is no shell or CLI, for a repo |
| 110 | 120 | you have not cloned, or when the user prefers them. If they say authentication is |
| 111 | 121 | 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`. | |
| 114 | 127 | |
| 115 | 128 | ## Rules Diffinery enforces (work with them, not around them) |
| 116 | 129 |
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}) | [](${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 @@ | ||
| 6 | 6 | # personal contributors. |
| 7 | 7 | # The rewrite is deterministic: the same Diffinery history always gives the same |
| 8 | 8 | # 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. | |
| 10 | 11 | # |
| 11 | 12 | # 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 | |
| 13 | 14 | # when set, otherwise with whatever credentials git already has for github.com. |
| 14 | 15 | # Needs git-filter-repo 2.47.0 (pip install git-filter-repo==2.47.0); another |
| 15 | 16 | # version could write different commits. |
| @@ -22,7 +23,7 @@ | ||
| 22 | 23 | SRC=${MIRROR_SRC:-https://diffinery.com/codician/diffinery.git} |
| 23 | 24 | DST=${MIRROR_DST:-https://github.com/codician-team/diffinery.git} |
| 24 | 25 | NAME=${MIRROR_NAME:-codician-team} |
| 25 | -EMAIL=${MIRROR_EMAIL:-codician-team@users.noreply.diffinery.com} | |
| 26 | +EMAIL=${MIRROR_EMAIL:-hi@codician.com} | |
| 26 | 27 | dry_run=${1:-} |
| 27 | 28 | |
| 28 | 29 | work=${MIRROR_WORKDIR:-} |
| @@ -36,8 +37,8 @@ | ||
| 36 | 37 | # The helper reads the token from the environment, so it never lands on a command line. |
| 37 | 38 | auth=(-c 'credential.https://diffinery.com.helper=!f() { [ "$1" = get ] && printf "username=x-token\npassword=%s\n" "$DIFFINERY_TOKEN"; }; f') |
| 38 | 39 | 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') | |
| 41 | 42 | fi |
| 42 | 43 | git "${auth[@]}" clone --quiet --bare --single-branch --branch main "$SRC" "$work/repo.git" |
| 43 | 44 | cd "$work/repo.git" |
| @@ -62,7 +63,76 @@ | ||
| 62 | 63 | fi |
| 63 | 64 | remote=$(git "${auth[@]}" ls-remote "$DST" refs/heads/main | cut -f1) |
| 64 | 65 | 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 | |
| 67 | 69 | 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 @@ | ||
| 17 | 17 | |
| 18 | 18 | ROOT = pathlib.Path(__file__).resolve().parent.parent |
| 19 | 19 | 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"} | |
| 20 | 22 | STAMP = (2026, 1, 1, 0, 0, 0) |
| 21 | 23 | |
| 22 | 24 | |
| @@ -24,7 +26,7 @@ | ||
| 24 | 26 | out = [] |
| 25 | 27 | for p in plugin.rglob("*"): |
| 26 | 28 | 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: | |
| 28 | 30 | continue |
| 29 | 31 | # The engine lays generated type files beside a mod it loads from a working folder. |
| 30 | 32 | if rel.parts[:2] == (".claude-plugin", "types"): |