Diffinery

codician/diffinerypublic

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

Serve the editor extension and its installers, Open in editor buttons, docs and tests (WIP)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ3j7radTeNPDXuJ936W3K
Claude authored signed1 parent 0e995eccommit 822602885ca431ff17a0215197b44157adac877eBrowse files
15 files changed+614−7
Files (15)
modifiedapps/diffinery/docs/connect/other-apps.md+4−0View file
@@ -23,6 +23,8 @@
2323
2424 Cursor lists the server in its MCP settings with a way to sign in; Diffinery's consent page opens in your browser.
2525
26+To clone Diffinery repositories in Cursor (**Clone from Diffinery** in the clone picker) and push without a password, install the Diffinery editor extension: see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
27+
2628 ## VS Code
2729
2830 Add Diffinery to `.vscode/mcp.json` in a workspace (or run **MCP: Add Server** from the Command Palette and choose **HTTP**):
@@ -37,6 +39,8 @@
3739
3840 Start the server from the file or the MCP view, and sign in when VS Code asks.
3941
42+To clone Diffinery repositories in VS Code (**Clone from Diffinery** in the clone picker) and push without a password, install the Diffinery editor extension: see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
43+
4044 ## Any other app
4145
4246 Look for "remote MCP server", "HTTP" or "Streamable HTTP" in the app's settings, and give it `https://diffinery.com/mcp`. If the app supports OAuth for MCP, it finds everything else on its own: Diffinery answers its first request with a `401` that points at the sign-in details, the app registers itself, and you approve it on the consent page. See [How sign-in works](/docs/connect/how-sign-in-works).
modifiedapps/diffinery/docs/migrate-from-github.md+2−0View file
@@ -806,6 +806,8 @@
806806
807807 Git asks for a password the first time: use a Diffinery token, or run `diffinery auth setup-git` once so Git gets it from the CLI. See [Git over HTTPS](/docs/repositories/git).
808808
809+People who clone from VS Code, Cursor or Windsurf install the Diffinery extension, which puts **Clone from Diffinery** next to **Clone from GitHub** in the clone picker and signs Git in through the browser: see [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
810+
809811 Also update anything else that holds the old URL: CI and deploy scripts, package manifests (`repository` fields), submodule URLs in `.gitmodules`, badges and links in READMEs.
810812
811813 ## Workflows
modifiedapps/diffinery/docs/nav.json+1−0View file
@@ -40,6 +40,7 @@
4040 "pages": [
4141 "repositories/overview",
4242 "repositories/git",
43+ "repositories/clone-in-your-editor",
4344 "repositories/git-lfs",
4445 "repositories/browsing-code",
4546 "repositories/branch-protection",
modifiedapps/diffinery/docs/quickstart.md+2−0View file
@@ -51,6 +51,8 @@
5151
5252 `diffinery login` opens an **Authorize the Diffinery CLI** page showing your computer's name. Check it, keep **Write** access for this guide, and click **Authorize**. The terminal prints `✓ Logged in to https://diffinery.com as diffy`. No token to copy: the CLI gets its own, valid for 90 days and listed under **Settings → Access tokens**, and `diffinery logout` revokes it. On a machine without a browser (over SSH, for example), `diffinery login --device` prints a code to enter at `https://diffinery.com/login/device` from any device.
5353
54+Working in VS Code, Cursor or Windsurf? Install the editor extension with `curl -fsSL https://diffinery.com/install-editor | bash`, then use **Git: Clone → Clone from Diffinery**. See [Clone in VS Code, Cursor and other editors](/docs/repositories/clone-in-your-editor).
55+
5456 ## 3. Tokens for scripts and CI
5557
5658 Scripts, CI and plain Git without the CLI authenticate with a personal access token.
addedapps/diffinery/docs/repositories/clone-in-your-editor.md+119−0View file
@@ -1,0 +1,119 @@
1+---
2+title: Clone in VS Code, Cursor and other editors
3+description: Add "Clone from Diffinery" to VS Code, Cursor, Windsurf and VSCodium, sign in with your passkey, and clone, push and publish without tokens. Also JetBrains IDEs.
4+---
5+
6+When you clone a repository in VS Code or Cursor, the clone picker offers **Clone from GitHub**. The Diffinery extension adds **Clone from Diffinery** next to it, lists your repositories, and signs Git in for you, so clone, pull and push work without a token or password. It works in every editor built on VS Code: VS Code, Cursor, Windsurf, VSCodium and others.
7+
8+![VS Code's clone picker: "Provide repository URL or pick a repository source", with Clone from Diffinery listed under remote sources.](/docs/images/editor-clone-picker.webp)
9+
10+## 1. Install the extension
11+
12+Run this in a terminal on the computer with the editor. It installs the extension into every VS Code-based editor it finds:
13+
14+```sh
15+curl -fsSL https://diffinery.com/install-editor | bash
16+```
17+
18+On Windows, in PowerShell:
19+
20+```powershell
21+powershell -c "irm https://diffinery.com/install-editor.ps1 | iex"
22+```
23+
24+To install into one editor only, use the editor's own command:
25+
26+| Editor | Command |
27+| --- | --- |
28+| VS Code | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && code --install-extension diffinery.vsix` |
29+| Cursor | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && cursor --install-extension diffinery.vsix` |
30+| Windsurf | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && windsurf --install-extension diffinery.vsix` |
31+| VSCodium | `curl -fsSLo diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix && codium --install-extension diffinery.vsix` |
32+
33+Without a terminal: download [diffinery.vsix](https://diffinery.com/-/vscode/diffinery.vsix), open the **Extensions** view, choose **…** (Views and More Actions) → **Install from VSIX…**, and pick the file.
34+
35+The extension comes from diffinery.com rather than a marketplace, and keeps itself up to date from there: once a day it checks for a newer build and offers to install it (checked against the SHA-256 that diffinery.com publishes).
36+
37+## 2. Clone a repository
38+
39+1. Open the Command Palette (<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd>, or <kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>P</kbd> on a Mac) and run **Git: Clone**, or choose **Clone Repository** on the editor's welcome page.
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.
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**. Your browser asks to open the editor; allow it.
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+6. Choose a folder. The editor clones the repository and opens it.
45+
46+![Diffinery's consent page for the editor extension: "Connect Diffinery extension for VS Code and Cursor to Diffinery", an app published by diffinery.com that sends you back to vscode://codician.diffinery, with Act as set to you and Write access selected.](/docs/images/editor-consent.webp)
47+
48+![The Diffinery repository list in VS Code's clone picker, each repository with whether it's private or public and its description.](/docs/images/editor-repositories.webp)
49+
50+**Diffinery: Clone Repository** in the Command Palette goes straight to the Diffinery list.
51+
52+## Check that it works
53+
54+- The **Accounts** menu (the person icon at the bottom of the Activity Bar) shows your Diffinery login.
55+- In the cloned repository, `git pull` and `git push` in the editor's terminal or the Source Control view work without asking for a password.
56+- On diffinery.com, **Settings → Access tokens → Connected apps** lists **Diffinery extension for VS Code and Cursor**.
57+
58+## Open in your editor from diffinery.com
59+
60+Every repository's **Clone** box on diffinery.com has **Open in VS Code**, **Cursor**, **Windsurf** and **IntelliJ IDEA** buttons. They use the editors' own clone links, so they work even without the extension: your browser asks to open the editor, and the editor asks where to clone. For a private repository, the extension signs Git in when Git asks; without it, Git asks for your username and an [access token](/docs/accounts/tokens).
61+
62+The links follow this pattern, if you want to put them in a README or a script:
63+
64+```text
65+vscode://vscode.git/clone?url=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git
66+cursor://vscode.git/clone?url=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git
67+windsurf://vscode.git/clone?url=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git
68+jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=https%3A%2F%2Fdiffinery.com%2Facme%2Fwebsite.git
69+```
70+
71+## Publish a local repository to Diffinery
72+
73+In a repository that has commits but no remote, choose **Publish Branch** in the Source Control view (or run **Git: Publish to…**) and pick **Publish to Diffinery**. Choose the owner (you or one of your organizations), the name and whether it's private or public. The extension creates the repository, adds it as the `origin` remote and pushes the branch.
74+
75+## How sign-in and Git passwords work
76+
77+- Signing in uses Diffinery's [OAuth sign-in](/docs/connect/how-sign-in-works), the same one Claude and Codex use. The extension never sees your passkey. It gets a token that lasts 8 hours and renews itself while the editor runs; after 30 days unused the connection lapses and the editor asks you to sign in again.
78+- When Git needs a password for diffinery.com, the editor answers with your signed-in account. Git may save that password in your computer's credential helper (the macOS keychain, Git Credential Manager on Windows, libsecret on Linux). The extension keeps that saved copy current each time the token renews, and removes it when you sign out, so Git in a terminal keeps working while the editor is open.
79+- **Diffinery: Sign Out**, or signing out from the Accounts menu, disconnects the editor on Diffinery too. **Disconnect** under **Settings → Access tokens → Connected apps** does the same from the website.
80+- **Diffinery: Sign In with an Access Token** uses a token you paste instead, for an [agent](/docs/agents/overview) account or a browser-based editor. Create one under **Settings → Access tokens** with Write access. A pasted token isn't renewed and isn't revoked when you sign out.
81+
82+## Settings
83+
84+| Setting | Default | What it does |
85+| --- | --- | --- |
86+| `diffinery.serverUrl` | `https://diffinery.com` | The Diffinery to sign in to. Change it only for a self-hosted Diffinery. |
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+| `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build of the extension. |
89+
90+## JetBrains IDEs
91+
92+IntelliJ IDEA, WebStorm, PyCharm, GoLand and the other JetBrains IDEs clone Diffinery repositories with their built-in Git support:
93+
94+1. Choose **File → New → Project from Version Control** (or **Clone Repository** on the welcome screen).
95+2. Keep **Repository URL**, set **Version control** to **Git**, and paste the clone URL, such as `https://diffinery.com/acme/website.git`.
96+3. When the IDE asks for credentials, enter your Diffinery username and an [access token](/docs/accounts/tokens) as the password, and tick **Remember**.
97+
98+The **IntelliJ IDEA** button on a repository's page does the first two steps for you through JetBrains Toolbox.
99+
100+## Other editors and Git tools
101+
102+Any Git client clones Diffinery repositories over HTTPS with your username and an access token: see [Git](/docs/repositories/git). For Git in any terminal, even with the editor closed, let the [`diffinery` CLI](/docs/repositories/git#let-the-cli-handle-credentials) answer Git's password prompts: `diffinery login`, then `diffinery auth setup-git`. Git then asks the CLI first, and the editor's sign-in is used only inside the editor.
103+
104+## Troubleshooting
105+
106+| What you see | What to do |
107+| --- | --- |
108+| No **Clone from Diffinery** in the picker | Check the extension is installed and enabled in the Extensions view, then reload the window (**Developer: Reload Window**). It needs the editor's built-in Git support (`git.enabled`). |
109+| The browser opens but nothing happens after **Connect** | The browser has to hand the link back to the editor. Allow it when the browser asks to open the editor, and make sure the editor is still running. Your editor's link is shown under **Sends you back to** on the consent page. |
110+| "This app isn't registered with Diffinery" on the consent page | `diffinery.serverUrl` points at a server that doesn't have the editor sign-in yet. Use **Diffinery: Sign In with an Access Token**, or update the server. |
111+| Git still asks for a password | Check `diffinery.gitAuthentication` is on, and that the remote URL is on diffinery.com (`git remote -v`). An old saved password for diffinery.com in your keychain can win; remove it, and the extension's renewals keep the new one current. |
112+| `Authentication failed` once after the editor was closed for hours | Git used a saved token that had expired; it has now forgotten it. Run the command again, from the editor. |
113+| An editor that isn't listed (another VS Code fork) | Sign-in still works: the editor receives the browser's answer on a one-time address on your computer instead of its own link. |
114+
115+## For AI agents reading this page
116+
117+- 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.
118+- 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.
119+- 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/git.md+4−0View file
@@ -46,6 +46,10 @@
4646
4747 Diffinery also accepts the token as the username with an empty password, and as an `Authorization: Bearer dfy_...` header.
4848
49+### Let your editor handle credentials
50+
51+In VS Code, Cursor, Windsurf or VSCodium, the [Diffinery extension](/docs/repositories/clone-in-your-editor) adds **Clone from Diffinery** to the clone picker and answers Git's password prompts with the account you signed in with in the browser. No token to create or paste.
52+
4953 ### Let the CLI handle credentials
5054
5155 The `diffinery` CLI can act as Git's credential helper for the Diffinery host, so plain `git clone`, `fetch` and `push` use the token you logged in with. Install the CLI:
modifiedapps/diffinery/scripts/build-docs.mjs+14−2View file
@@ -3,12 +3,16 @@
33 // - the CLI reference, generated from the CLI's own command registry
44 // - the CLI itself as one file, served at /-/cli/diffinery.mjs, and its install scripts
55 // (apps/cli/install), served at /install and /install.ps1
6+// - the editor extension for VS Code and Cursor (apps/vscode), packed as a .vsix, served at
7+// /-/vscode/diffinery.vsix, and its install scripts (apps/vscode/install), served at /install-editor
8+// and /install-editor.ps1
69 // The REST API and MCP tool references are generated at runtime from the live
710 // router and tool list (src/docs/reference.ts), so none of the references can drift.
811 //
912 // Run by `pnpm build` and `pnpm dev`. Fails on a page missing from the nav (or the
1013 // other way round) and on missing or overlong frontmatter.
1114 import { build } from "esbuild";
15+import { createHash } from "node:crypto";
1216 import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
1317 import { tmpdir } from "node:os";
1418 import path from "node:path";
@@ -19,6 +23,8 @@
1923 const docsDir = path.join(app, "docs");
2024 const cliSrc = path.resolve(app, "../cli/src");
2125 const cliInstall = path.resolve(app, "../cli/install");
26+const editorPackager = path.resolve(app, "../vscode/scripts/package.mjs");
27+const editorInstall = path.resolve(app, "../vscode/install");
2228 const out = path.join(app, "src/docs/content.generated.ts");
2329
2430 /** Pages produced in code rather than from a Markdown file. */
@@ -225,6 +231,7 @@
225231 } finally {
226232 await rm(tmp, { recursive: true, force: true });
227233 }
234+const editor = await (await import(pathToFileURL(editorPackager).href)).packageExtension();
228235 pages["reference/cli"] = {
229236 title: "CLI reference",
230237 description: "Every command and option of the diffinery command-line client, generated from its source.",
@@ -237,7 +244,7 @@
237244 }
238245
239246 const ordered = Object.fromEntries(navSlugs.filter((s) => pages[s]).map((s) => [s, pages[s]]));
240-const src = `// Generated by scripts/build-docs.mjs from docs/ and apps/cli. Do not edit.
247+const src = `// Generated by scripts/build-docs.mjs from docs/, apps/cli and apps/vscode. Do not edit.
241248 /* eslint-disable */
242249 export interface DocPage { title: string; description: string; markdown: string }
243250 export interface DocSection { title: string; pages: string[] }
@@ -247,9 +254,14 @@
247254 export const DOCS_IMAGES: Record<string, { type: string; data: string }> = ${JSON.stringify(images)};
248255 export const CLI_VERSION = ${JSON.stringify(cli.VERSION)};
249256 export const CLI_BUNDLE = ${JSON.stringify(cli.bin)};
257+export const EDITOR_EXTENSION_VERSION = ${JSON.stringify(editor.version)};
258+export const EDITOR_EXTENSION_SHA256 = ${JSON.stringify(createHash("sha256").update(editor.vsix).digest("hex"))};
259+export const EDITOR_EXTENSION_VSIX_B64 = ${JSON.stringify(editor.vsix.toString("base64"))};
260+export const EDITOR_INSTALL_SH = ${JSON.stringify(await readFile(path.join(editorInstall, "install.sh"), "utf8"))};
261+export const EDITOR_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(editorInstall, "install.ps1"), "utf8"))};
250262 export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))};
251263 export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))};
252264 `;
253265 await mkdir(path.dirname(out), { recursive: true });
254266 await writeFile(out, src);
255-console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB)`);
267+console.log(`docs: ${Object.keys(ordered).length} pages (+${RUNTIME_PAGES.size} generated at runtime), ${Object.keys(images).length} images, CLI ${cli.VERSION} (${Math.round(cli.bin.length / 1024)} KiB), editor extension ${editor.version} (${Math.round(editor.vsix.length / 1024)} KiB)`);
modifiedapps/diffinery/src/docs/web.tsx+25−2View file
@@ -4,7 +4,10 @@
44 * agents read: /docs/<page>.md (or any page with `Accept: text/markdown`),
55 * /llms.txt, /llms-full.txt, /docs/api/openapi.json and /docs/search.json.
66 * Also serves the CLI download at /-/cli/diffinery.mjs, and the scripts that install it:
7- * `curl -fsSL https://diffinery.com/install | bash` and `irm https://diffinery.com/install.ps1 | iex`.
7+ * `curl -fsSL https://diffinery.com/install | bash` and `irm https://diffinery.com/install.ps1 | iex`;
8+ * and the extension for VS Code, Cursor and other editors built on VS Code (apps/vscode) at
9+ * /-/vscode/diffinery.vsix, with /-/vscode/version.json (which it updates itself from) and its
10+ * install scripts at /install-editor and /install-editor.ps1.
811 */
912 import { raw } from "hono/html";
1013 import type { AppContext } from "../auth/principal.js";
@@ -14,7 +17,7 @@
1417 import { fenceHighlighter } from "../web/highlight.js";
1518 import { ICONS } from "../web/assets.js";
1619 import { render, type WebApp } from "../web/ui.js";
17-import { CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_VERSION, DOCS_IMAGES } from "./content.generated.js";
20+import { CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH } from "./content.generated.js";
1821 import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js";
1922 import { openApiDocument } from "./reference.js";
2023
@@ -206,6 +209,26 @@
206209 web.get("/install.sh", (c) => installScript(c, CLI_INSTALL_SH));
207210 web.get("/install.ps1", (c) => installScript(c, CLI_INSTALL_PS1));
208211
212+ const vsixName = `diffinery-${EDITOR_EXTENSION_VERSION}.vsix`;
213+ let vsix: Uint8Array<ArrayBuffer> | undefined;
214+ const vsixBody = (c: AppContext) => {
215+ vsix ??= Uint8Array.from(atob(EDITOR_EXTENSION_VSIX_B64), (ch) => ch.charCodeAt(0));
216+ return c.body(vsix, 200, {
217+ ...PUBLIC,
218+ "content-type": "application/octet-stream",
219+ "content-disposition": `attachment; filename="${vsixName}"`,
220+ "x-diffinery-extension-version": EDITOR_EXTENSION_VERSION,
221+ });
222+ };
223+ web.get("/-/vscode/diffinery.vsix", vsixBody);
224+ web.get(`/-/vscode/${vsixName}`, vsixBody);
225+ web.get("/-/vscode/version.json", (c) =>
226+ c.json({ version: EDITOR_EXTENSION_VERSION, url: `/-/vscode/${vsixName}`, sha256: EDITOR_EXTENSION_SHA256 }, 200, PUBLIC),
227+ );
228+ web.get("/install-editor", (c) => installScript(c, EDITOR_INSTALL_SH));
229+ web.get("/install-editor.sh", (c) => installScript(c, EDITOR_INSTALL_SH));
230+ web.get("/install-editor.ps1", (c) => installScript(c, EDITOR_INSTALL_PS1));
231+
209232 // Pictures for the docs pages (docs/images/), decoded once per isolate.
210233 const images = new Map<string, Uint8Array<ArrayBuffer>>();
211234 web.get("/docs/images/:file", (c) => {
modifiedapps/diffinery/src/lib/util.ts+1−1View file
@@ -93,7 +93,7 @@
9393 }
9494
9595 /** Top-level paths the app serves; no account may use them as its login. */
96-export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "twirp", "install", "install.sh", "install.ps1", "cli", "oauth", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
96+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", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "early-access", "security", "status", "v2", "imports", "-", "diffinery-agent-review", "ghost", "github-actions", "diffinery-actions", "noreply", "no-reply", "support", "abuse", "postmaster", "root", "staff"]);
9797
9898 /**
9999 * Who a deleted account's issues, pull requests and comments are attributed to, as on GitHub. Not an account:
modifiedapps/diffinery/src/web/assets.ts+1−0View file
@@ -167,6 +167,7 @@
167167 .ic-dir{color:var(--accent)!important}
168168 .clone{display:flex;gap:6px;align-items:center}
169169 .clone input{font-family:var(--mono);font-size:12px}
170+.openin{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-top:8px}
170171 pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px}
171172 pre.cmd.one{padding:7px 12px;line-height:20px;margin:0;white-space:pre-wrap;word-break:break-all}
172173 .code-wrap{overflow:auto}
modifiedapps/diffinery/src/web/code.tsx+27−2View file
@@ -82,6 +82,29 @@
8282 </div>
8383 );
8484
85+/**
86+ * Clone straight into an editor: the editors' own clone links (VS Code's Git extension handles /clone in VS Code and
87+ * the editors built on it; JetBrains Toolbox handles jetbrains://). They work without Diffinery's extension, which
88+ * adds sign-in for private repositories (docs/repositories/clone-in-your-editor.md).
89+ */
90+export const EDITORS: Array<{ label: string; link: (url: string) => string }> = [
91+ { label: "VS Code", link: (u) => `vscode://vscode.git/clone?url=${encodeURIComponent(u)}` },
92+ { label: "Cursor", link: (u) => `cursor://vscode.git/clone?url=${encodeURIComponent(u)}` },
93+ { label: "Windsurf", link: (u) => `windsurf://vscode.git/clone?url=${encodeURIComponent(u)}` },
94+ { label: "IntelliJ IDEA", link: (u) => `jetbrains://idea/checkout/git?idea.required.plugins.id=Git4Idea&checkout.repo=${encodeURIComponent(u)}` },
95+];
96+
97+const OpenInEditor: FC<{ url: string }> = ({ url }) => (
98+ <div class="openin">
99+ <span class="small muted">Open in</span>
100+ {EDITORS.map((e) => (
101+ <a class="btn sm" href={e.link(url)} rel="nofollow">
102+ {e.label}
103+ </a>
104+ ))}
105+ </div>
106+);
107+
85108 const QuickSetup: FC<{ c: AppContext; l: Loaded }> = ({ c, l }) => {
86109 const url = cloneUrl(c, l.repo);
87110 const b = l.repo.default_branch;
@@ -96,6 +119,7 @@
96119 </div>
97120 <div class="body">
98121 <CloneBox url={url} />
122+ <OpenInEditor url={url} />
99123 {canWrite ? (
100124 <p class="mt muted">
101125 Get started by <a href={`${base}/new/${encPath(b)}?filename=README.md`}>creating a README on the web</a>, or push an existing repository from the command line.
@@ -108,7 +132,7 @@
108132 <h3>Authentication</h3>
109133 <p class="muted">
110134 When Git asks for credentials, use your username and a <a href="/settings/tokens">personal access token</a> as the password. To avoid typing it every time, run <code>git config --global credential.helper store</code> (or use
111- your OS keychain helper). Git 2.26 or newer is required.
135+ your OS keychain helper). Git 2.26 or newer is required. In VS Code, Cursor or Windsurf, the <a href="/docs/repositories/clone-in-your-editor">Diffinery extension</a> signs Git in for you.
112136 </p>
113137 <p class="muted m0">
114138 Workflows in <code>.github/workflows/*.yml</code> run automatically on push. AI agents should use their own <a href="/settings/agents">agent account and token</a>.
@@ -261,8 +285,9 @@
261285 <section>
262286 <h3>Clone</h3>
263287 <CloneBox url={cloneUrl(c, repo)} />
288+ <OpenInEditor url={cloneUrl(c, repo)} />
264289 <p class="small muted" style="margin-top:6px">
265- Use a <a href="/settings/tokens">token</a> as the password.
290+ Use a <a href="/settings/tokens">token</a> as the password, or <a href="/docs/repositories/clone-in-your-editor">sign in from your editor</a>.
266291 </p>
267292 </section>
268293 <section>
addedapps/diffinery/test/e2e/editor-smoke.mjs+234−0View file
@@ -1,0 +1,234 @@
1+// On-demand check of the editor extension (apps/vscode) in a real VS Code or Cursor, against a real `wrangler dev`
2+// Worker: "Clone from Diffinery" in the clone picker, the browser sign-in through Diffinery's OAuth consent page,
3+// the repository list and search, and a clone of a private repository with Git asking the extension for the
4+// password. Not part of `pnpm e2e` (it needs a desktop editor and a display):
5+//
6+// node apps/vscode/scripts/package.mjs --out /tmp/vsix
7+// EDITOR_BIN=/path/to/VSCode-linux-x64/code EDITOR_CLI=/path/to/VSCode-linux-x64/bin/code \
8+// VSIX=/tmp/vsix/diffinery-0.1.0.vsix xvfb-run -a pnpm --filter @diffinery/app exec node test/e2e/editor-smoke.mjs
9+//
10+// EDITOR_BIN is the editor's Electron binary, EDITOR_CLI its command-line tool (Cursor: usr/share/cursor/cursor and
11+// usr/share/cursor/bin/cursor in the extracted AppImage). SHOTS=dir saves screenshots of each step.
12+import { spawn, execFileSync } from "node:child_process";
13+import { createHash, randomBytes } from "node:crypto";
14+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
15+import { createServer } from "node:net";
16+import { tmpdir } from "node:os";
17+import { join, dirname, resolve } from "node:path";
18+import { fileURLToPath } from "node:url";
19+import assert from "node:assert/strict";
20+import { chromium, _electron } from "playwright";
21+
22+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
23+const EDITOR = process.env.EDITOR_BIN;
24+const CLI = process.env.EDITOR_CLI;
25+const VSIX = process.env.VSIX;
26+const SHOTS = process.env.SHOTS;
27+if (!EDITOR || !CLI || !VSIX) throw new Error("set EDITOR_BIN, EDITOR_CLI and VSIX");
28+const work = mkdtempSync(join(tmpdir(), "diffinery-editor-"));
29+const hash = (s) => createHash("sha256").update(s).digest("hex");
30+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31+const ADA_SESSION = "ada-editor-session-0123456789abcdef";
32+const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
33+let devLog = "";
34+
35+const freePort = () =>
36+ new Promise((res) => {
37+ const s = createServer();
38+ s.listen(0, "127.0.0.1", () => {
39+ const { port } = s.address();
40+ s.close(() => res(port));
41+ });
42+ });
43+async function until(what, fn, timeoutMs = 60_000) {
44+ const end = Date.now() + timeoutMs;
45+ while (Date.now() < end) {
46+ const v = await fn();
47+ if (v) return v;
48+ await sleep(300);
49+ }
50+ throw new Error(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`);
51+}
52+const shot = async (page, name) => SHOTS && (mkdirSync(SHOTS, { recursive: true }), await page.screenshot({ path: join(SHOTS, `${name}.png`) }));
53+const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
54+
55+// ---------------------------------------------------------------- a local Diffinery with ada and two repositories
56+const port = await freePort();
57+const base = `http://localhost:${port}`;
58+const persist = join(work, "state");
59+const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
60+wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
61+const at = new Date().toISOString();
62+const later = new Date(Date.now() + 86400_000).toISOString();
63+sql(
64+ [
65+ `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`,
66+ `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`,
67+ `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t1', 'ada1', 'e2e', '${hash(ADA_TOKEN)}', '${ADA_TOKEN.slice(0, 10)}', '["admin"]', '${later}', '${at}')`,
68+ ].join("; "),
69+);
70+const dev = spawn(
71+ "pnpm",
72+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"],
73+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
74+);
75+dev.stdout.on("data", (d) => (devLog += d));
76+dev.stderr.on("data", (d) => (devLog += d));
77+const cleanup = () => {
78+ try {
79+ process.kill(-dev.pid, "SIGTERM");
80+ } catch {}
81+};
82+process.on("exit", cleanup);
83+await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000);
84+console.log("worker up at", base);
85+
86+const api = (method, path, body) => fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${ADA_TOKEN}`, "content-type": "application/json" }, body: body && JSON.stringify(body) }).then(async (r) => (r.ok ? r.json() : Promise.reject(new Error(`${method} ${path}: ${r.status} ${await r.text()}`))));
87+await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "Private repository the editor clones" });
88+await api("POST", "/user/repos", { name: "notes", private: false, description: "Another one" });
89+const seed = join(work, "seed");
90+mkdirSync(seed);
91+const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString();
92+git(seed, "init", "-q", "-b", "main");
93+writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in an editor.\n");
94+git(seed, "add", ".");
95+git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit");
96+git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main");
97+
98+// ---------------------------------------------------------------- the editor
99+const home = join(work, "home");
100+const clones = join(home, "src");
101+const userData = join(work, "user-data");
102+const exts = join(work, "extensions");
103+mkdirSync(clones, { recursive: true });
104+mkdirSync(join(userData, "User"), { recursive: true });
105+// Git saves the password the editor gave it, as a keychain would; the extension keeps that copy current.
106+writeFileSync(join(home, ".gitconfig"), `[credential]\n\thelper = store\n[user]\n\tname = Ada\n\temail = ada@example.com\n`);
107+writeFileSync(
108+ join(userData, "User", "settings.json"),
109+ JSON.stringify({
110+ "diffinery.serverUrl": base,
111+ "diffinery.checkForUpdates": false,
112+ "files.simpleDialog.enable": true,
113+ "git.openAfterClone": "always",
114+ "git.defaultCloneDirectory": clones,
115+ "security.workspace.trust.enabled": false,
116+ "workbench.startupEditor": "none",
117+ "window.dialogStyle": "custom",
118+ "window.titleBarStyle": "custom",
119+ "telemetry.telemetryLevel": "off",
120+ "update.mode": "none",
121+ "extensions.autoCheckUpdates": false,
122+ }),
123+);
124+// The browser the editor opens: record the URL, as a person's browser would receive it.
125+const opened = join(work, "opened.txt");
126+const browserScript = join(work, "browser.sh");
127+writeFileSync(browserScript, `#!/bin/sh\necho "$1" >> ${opened}\n`);
128+chmodSync(browserScript, 0o755);
129+const env = { ...process.env, HOME: home, BROWSER: browserScript, XDG_CURRENT_DESKTOP: "", DONT_PROMPT_WSL_INSTALL: "1" };
130+const common = ["--user-data-dir", userData, "--extensions-dir", exts, "--no-sandbox", "--disable-gpu"];
131+execFileSync(CLI, [...common, "--install-extension", VSIX, "--force"], { env, stdio: "pipe", timeout: 120_000 });
132+console.log("extension installed");
133+
134+const app = await _electron.launch({ executablePath: EDITOR, args: [...common, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"], env, timeout: 120_000 });
135+const win = await app.firstWindow();
136+await win.waitForSelector(".monaco-workbench", { timeout: 120_000 });
137+console.log("editor window open");
138+await sleep(4000);
139+const palette = async (cmd) => {
140+ await win.keyboard.press("Control+Shift+P");
141+ await win.waitForSelector(".quick-input-widget:not([style*='display: none']) input");
142+ await win.keyboard.type(cmd);
143+ await sleep(500);
144+ await win.keyboard.press("Enter");
145+};
146+const quickItems = async () => (await win.$$eval(".quick-input-list .monaco-list-row", (rows) => rows.map((r) => r.textContent?.trim() ?? ""))).filter(Boolean);
147+
148+// 1. The clone picker lists Diffinery as a remote source.
149+await palette("Git: Clone");
150+await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000);
151+console.log("clone picker:", await quickItems());
152+await shot(win, "1-clone-picker");
153+await win.keyboard.type("Clone from Diffinery");
154+await sleep(300);
155+await win.keyboard.press("Enter");
156+
157+// 2. Signing in: the editor asks to allow the sign-in, then opens Diffinery's consent page in the browser.
158+const allow = await until("the sign-in prompt", async () => (await win.$("dialog, .monaco-dialog-box")) && win.$(".monaco-dialog-box .monaco-button:has-text('Allow')"), 30_000);
159+await shot(win, "2-allow-sign-in");
160+await allow.click();
161+const authorize = await until("the editor to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000);
162+const au = new URL(authorize);
163+assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`);
164+assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/editor.json`);
165+const scheme = au.searchParams.get("redirect_uri").split(":")[0];
166+console.log("authorize:", authorize);
167+
168+const browser = await chromium.launch();
169+const ctx = await browser.newContext();
170+let callback;
171+// The consent form's answer redirects to the editor's own scheme, which the browser hands to the operating system.
172+ctx.on("response", (r) => {
173+ const loc = r.headers().location;
174+ if (loc?.startsWith(`${scheme}://`)) callback = loc;
175+});
176+const page = await ctx.newPage();
177+await page.goto(`${base}/login`);
178+await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION);
179+await page.goto(authorize);
180+const consent = ((await page.textContent("main")) ?? "").replace(/\s+/g, " ");
181+assert.match(consent, /Connect Diffinery extension for VS Code and Cursor to Diffinery/);
182+assert.match(consent, /published by localhost/);
183+if (SHOTS) await page.screenshot({ path: join(SHOTS, "3-consent.png"), fullPage: true });
184+await page.click('button[value="approve"]', { noWaitAfter: true }).catch(() => {});
185+// The browser hands the editor's own scheme to the operating system, which passes it to the editor.
186+const cb = await until("the redirect back to the editor", async () => callback, 30_000);
187+console.log("callback:", cb.replace(/code=[^&]+/, "code=…"));
188+await browser.close();
189+execFileSync(CLI, [...common, "--open-url", cb], { env, stdio: "pipe" });
190+
191+// 3. Signed in: the picker lists ada's repositories; search narrows it.
192+await until("ada's repositories in the picker", async () => (await quickItems()).some((t) => t.includes("ada/hello-editor")), 60_000);
193+console.log("repositories:", await quickItems());
194+await shot(win, "4-repositories");
195+await win.keyboard.type("hello");
196+await until("the search result", async () => {
197+ const items = await quickItems();
198+ return items.length === 1 && items[0].includes("ada/hello-editor");
199+}, 20_000);
200+await shot(win, "5-search");
201+await win.keyboard.press("Enter");
202+
203+// 4. Choosing where to clone (the editor's own folder dialog), then Git clones with the extension's password.
204+await until("the folder dialog", async () => (await quickItems()).length > 0 || (await win.$(".quick-input-widget .quick-input-action")), 30_000).catch(() => {});
205+await sleep(800);
206+await shot(win, "6-folder");
207+await win.keyboard.press("Enter");
208+await sleep(500);
209+const clonedReadme = join(clones, "hello-editor", "README.md");
210+await until("the clone on disk", async () => existsSync(clonedReadme), 90_000).catch(async (e) => {
211+ await shot(win, "6b-folder-stuck");
212+ throw e;
213+});
214+assert.match(readFileSync(clonedReadme, "utf8"), /Cloned from Diffinery/);
215+console.log("cloned:", clonedReadme);
216+const stored = readFileSync(join(home, ".git-credentials"), "utf8");
217+assert.match(stored, new RegExp(`^http://ada:dfy_[^@]+@localhost%3a${port}$|^http://ada:dfy_[^@]+@localhost:${port}$`, "m"));
218+assert.ok(!stored.includes(ADA_TOKEN), "the saved credential is the editor's own token");
219+console.log("git credential saved for ada");
220+await sleep(3000);
221+await shot(win, "7-opened");
222+
223+// 5. Signing out removes Git's saved copy and disconnects the editor on the server.
224+await palette("Diffinery: Sign Out");
225+await until("the saved credential to go", async () => !readFileSync(join(home, ".git-credentials"), "utf8").includes("ada:"), 20_000);
226+const grants = JSON.parse(wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--json", "--command", "SELECT revoked_at FROM oauth_grants").toString())[0].results;
227+assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked");
228+console.log("signed out: credential removed, connection revoked");
229+
230+await app.close();
231+cleanup();
232+if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true });
233+console.log("editor smoke test passed");
234+process.exit(0);
modifiedapps/diffinery/test/e2e/oauth.test.mjs+39−0View file
@@ -360,3 +360,42 @@
360360 assert.equal((await whoami(t.access_token)).login, "ada");
361361 await ada.context.close();
362362 });
363+
364+test("OAuth: Diffinery's own editor extension (apps/vscode)", async () => {
365+ const ada = await adaBrowser();
366+ const { verifier, challenge } = pkce();
367+ const client_id = `${base}/oauth/clients/editor.json`;
368+ // The server publishes the extension's metadata document at its client_id.
369+ const doc = await (await fetch(client_id)).json();
370+ assert.equal(doc.client_id, client_id);
371+ assert.ok(doc.redirect_uris.includes("cursor://codician.diffinery/oauth") && doc.redirect_uris.includes("vscode://codician.diffinery/oauth"));
372+ const authorize = (redirect_uri) => `${base}/oauth/authorize?${new URLSearchParams({ response_type: "code", client_id, redirect_uri, code_challenge: challenge, code_challenge_method: "S256", state: "ed", scope: "write" })}`;
373+ // An editor's own scheme: the consent page names the publisher and where it sends you, and approving redirects there.
374+ await ada.page.goto(authorize("cursor://codician.diffinery/oauth"));
375+ assert.match(await text(ada.page), /Connect Diffinery extension for VS Code and Cursor to Diffinery/);
376+ assert.match(await text(ada.page), /An app published by localhost/);
377+ assert.match(await text(ada.page), /Sends you back to\s*cursor:\/\/codician\.diffinery/);
378+ let location;
379+ const onResponse = (r) => (r.headers().location?.startsWith("cursor://") ? (location = r.headers().location) : null);
380+ ada.context.on("response", onResponse);
381+ await ada.page.click('button[value="approve"]', { noWaitAfter: true }).catch(() => {});
382+ const cursorCb = new URL(await until("the redirect to Cursor", async () => location));
383+ ada.context.off("response", onResponse);
384+ assert.equal(cursorCb.searchParams.get("state"), "ed");
385+ const t1 = await form(`${base}/oauth/token`, { grant_type: "authorization_code", code: cursorCb.searchParams.get("code"), client_id, code_verifier: verifier, redirect_uri: "cursor://codician.diffinery/oauth" });
386+ assert.equal(t1.status, 200, await t1.clone().text());
387+ const tok = await t1.json();
388+ assert.equal((await whoami(tok.access_token)).login, "ada");
389+ // The token works for the REST API the extension lists repositories with.
390+ assert.equal((await (await fetch(`${base}/api/v3/user`, { headers: { authorization: `Bearer ${tok.access_token}` } })).json()).login, "ada");
391+ // It renews with the refresh token, as the extension does before the access token expires.
392+ const r = await (await form(`${base}/oauth/token`, { grant_type: "refresh_token", refresh_token: tok.refresh_token, client_id })).json();
393+ assert.match(r.access_token, /^dfy_/);
394+ // An editor the server has no scheme for uses a loopback address, any port.
395+ const cb = await consent(ada, authorize("http://127.0.0.1:45123/callback"), { scope: "write" });
396+ assert.equal(cb.port, "45123");
397+ // Other schemes and hosts aren't the extension's.
398+ await ada.page.goto(authorize("evil://codician.diffinery/oauth"));
399+ assert.match(await text(ada.page), /didn't register\. Nothing was shared/);
400+ await ada.context.close();
401+});
addedapps/vscode/install/install.ps1+52−0View file
@@ -1,0 +1,52 @@
1+# Installs the Diffinery extension into the editors built on VS Code on this computer (Windows):
2+#
3+# powershell -c "irm https://diffinery.com/install-editor.ps1 | iex"
4+#
5+# Set $env:DIFFINERY_EDITORS = "cursor" (names: code, code-insiders, cursor, windsurf, codium) to choose.
6+# Each editor's command-line tool is used from PATH, or from its usual install folder. The .vsix comes
7+# from the server that served this script and is checked against the SHA-256 it publishes. The
8+# extension updates itself afterwards. Served by Diffinery from apps/vscode/install/.
9+$ErrorActionPreference = "Stop"
10+$server = if ($env:DIFFINERY_URL) { $env:DIFFINERY_URL } else { "__DIFFINERY_URL__" }
11+if ($server.StartsWith("__")) { $server = "https://diffinery.com" }
12+$server = $server.TrimEnd("/")
13+
14+$known = @(
15+ @{ Name = "code"; Label = "VS Code"; Paths = @("$env:LOCALAPPDATA\Programs\Microsoft VS Code\bin\code.cmd", "$env:ProgramFiles\Microsoft VS Code\bin\code.cmd") },
16+ @{ Name = "code-insiders"; Label = "VS Code Insiders"; Paths = @("$env:LOCALAPPDATA\Programs\Microsoft VS Code Insiders\bin\code-insiders.cmd") },
17+ @{ Name = "cursor"; Label = "Cursor"; Paths = @("$env:LOCALAPPDATA\Programs\cursor\resources\app\bin\cursor.cmd") },
18+ @{ Name = "windsurf"; Label = "Windsurf"; Paths = @("$env:LOCALAPPDATA\Programs\Windsurf\bin\windsurf.cmd") },
19+ @{ Name = "codium"; Label = "VSCodium"; Paths = @("$env:LOCALAPPDATA\Programs\VSCodium\bin\codium.cmd", "$env:ProgramFiles\VSCodium\bin\codium.cmd") }
20+)
21+$wanted = if ($env:DIFFINERY_EDITORS) { $env:DIFFINERY_EDITORS -split "[ ,]+" } else { @() }
22+
23+$meta = Invoke-RestMethod "$server/-/vscode/version.json"
24+$tmp = Join-Path ([IO.Path]::GetTempPath()) ("diffinery-" + [Guid]::NewGuid())
25+New-Item -ItemType Directory $tmp | Out-Null
26+try {
27+ $vsix = Join-Path $tmp "diffinery-$($meta.version).vsix"
28+ Invoke-WebRequest "$server/-/vscode/diffinery-$($meta.version).vsix" -OutFile $vsix -UseBasicParsing
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+ $installed = 0
31+ foreach ($e in $known) {
32+ if ($wanted.Count -gt 0 -and $wanted -notcontains $e.Name) { continue }
33+ $cli = (Get-Command "$($e.Name).cmd" -ErrorAction SilentlyContinue).Source
34+ if (-not $cli) { $cli = $e.Paths | Where-Object { Test-Path $_ } | Select-Object -First 1 }
35+ if (-not $cli) {
36+ if ($wanted.Count -gt 0) { Write-Host "x $($e.Label): its command-line tool ($($e.Name)) wasn't found" }
37+ continue
38+ }
39+ & $cli --install-extension $vsix --force | Out-Null
40+ if ($LASTEXITCODE -eq 0) { Write-Host "v $($e.Label): Diffinery extension $($meta.version) installed"; $installed++ }
41+ else { Write-Host "x $($e.Label): installing failed" }
42+ }
43+ if ($installed -eq 0) {
44+ Write-Host "No editor took the extension. Install it from the editor instead: Extensions > ... > Install from VSIX..."
45+ Write-Host "with $server/-/vscode/diffinery.vsix, or see $server/docs/repositories/clone-in-your-editor"
46+ exit 1
47+ }
48+ Write-Host ""
49+ Write-Host "Next: in the editor, run Git: Clone and pick Clone from Diffinery. ($server/docs/repositories/clone-in-your-editor)"
50+} finally {
51+ Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
52+}
addedapps/vscode/install/install.sh+89−0View file
@@ -1,0 +1,89 @@
1+#!/usr/bin/env bash
2+# Installs the Diffinery extension into the editors built on VS Code on this computer (Linux and macOS):
3+#
4+# curl -fsSL https://diffinery.com/install-editor | bash # every editor found
5+# curl -fsSL https://diffinery.com/install-editor | bash -s -- cursor # only the ones named
6+#
7+# Names: code, code-insiders, cursor, windsurf, codium. Each editor's command-line tool is used from
8+# PATH, or from its usual place in /Applications on macOS. The .vsix comes from the server that served
9+# this script and is checked against the SHA-256 it publishes. The extension updates itself afterwards.
10+# Served by Diffinery from apps/vscode/install/.
11+if [ -z "${BASH_VERSION:-}" ]; then
12+ echo "This installer needs bash: curl -fsSL __DIFFINERY_URL__/install-editor | bash" >&2
13+ exit 1
14+fi
15+set -euo pipefail
16+
17+server=${DIFFINERY_URL:-__DIFFINERY_URL__}
18+case $server in __*) server=https://diffinery.com ;; esac
19+server=${server%/}
20+
21+if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
22+ bold=$'\033[1m' dim=$'\033[2m' green=$'\033[32m' red=$'\033[31m' reset=$'\033[0m'
23+else
24+ bold='' dim='' green='' red='' reset=''
25+fi
26+say() { printf '%s\n' "$*" >&2; }
27+die() {
28+ say "${red}error${reset}: $*"
29+ exit 1
30+}
31+command -v curl >/dev/null || die "curl is required"
32+
33+# name|label|macOS app bundle
34+known="code|VS Code|Visual Studio Code
35+code-insiders|VS Code Insiders|Visual Studio Code - Insiders
36+cursor|Cursor|Cursor
37+windsurf|Windsurf|Windsurf
38+codium|VSCodium|VSCodium"
39+
40+find_cli() {
41+ local name=$1 app=$2
42+ if command -v "$name" >/dev/null 2>&1; then
43+ command -v "$name"
44+ return
45+ fi
46+ for base in "/Applications" "$HOME/Applications"; do
47+ local p="$base/$app.app/Contents/Resources/app/bin/$name"
48+ if [ -x "$p" ]; then
49+ printf '%s\n' "$p"
50+ return
51+ fi
52+ done
53+ return 1
54+}
55+
56+wanted=" $* "
57+tmp=$(mktemp -d)
58+trap 'rm -rf "$tmp"' EXIT
59+meta=$(curl -fsSL "$server/-/vscode/version.json") || die "couldn't reach $server"
60+version=$(printf '%s' "$meta" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
61+sha=$(printf '%s' "$meta" | sed -n 's/.*"sha256":"\([0-9a-f]*\)".*/\1/p')
62+[ -n "$version" ] && [ -n "$sha" ] || die "unexpected answer from $server/-/vscode/version.json"
63+vsix=$tmp/diffinery-$version.vsix
64+curl -fsSL "$server/-/vscode/diffinery-$version.vsix" -o "$vsix" || die "downloading the extension failed"
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+[ "$got" = "$sha" ] || die "the download didn't match the published SHA-256; nothing was installed"
67+
68+installed=0
69+while IFS='|' read -r name label app; do
70+ if [ "$wanted" != " " ] && [[ $wanted != *" $name "* ]]; then continue; fi
71+ cli=$(find_cli "$name" "$app") || {
72+ [ "$wanted" != " " ] && say "${red}✗${reset} $label: its command-line tool ($name) wasn't found"
73+ continue
74+ }
75+ if "$cli" --install-extension "$vsix" --force >/dev/null 2>"$tmp/err"; then
76+ say "${green}✓${reset} $label: Diffinery extension $version installed"
77+ installed=$((installed + 1))
78+ else
79+ say "${red}✗${reset} $label: $(tail -n 3 "$tmp/err")"
80+ fi
81+done <<<"$known"
82+
83+if [ "$installed" = 0 ]; then
84+ say "No editor took the extension. Install it from the editor instead: Extensions → … → Install from VSIX…"
85+ say "with ${bold}$server/-/vscode/diffinery.vsix${reset}, or see $server/docs/repositories/clone-in-your-editor"
86+ exit 1
87+fi
88+say ""
89+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}"