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.

Editor extension: tested in Cursor and VS Code including Publish to Diffinery; docs screenshots; marketplace notes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ3j7radTeNPDXuJ936W3K
Claude authored signed1 parent 3898f26commit 313fb0eaa45ac8d54740089e92b2ceae063e94f0Browse files
8 files changed+99−19
Files (8)
addedapps/diffinery/docs/images/editor-clone-picker.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/editor-consent.webp+0−0View file
Binary file not shown.
addedapps/diffinery/docs/images/editor-repositories.webp+0−0View file
Binary file not shown.
modifiedapps/diffinery/docs/repositories/clone-in-your-editor.md+5−5View file
@@ -36,14 +36,14 @@
3636
3737 ## 2. Clone a repository
3838
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.
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.
4040 2. Choose **Clone from Diffinery**.
4141 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.
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.
4343 5. Back in the editor, pick a repository. The list shows your repositories and your organizations', most recently pushed first. Type to search every repository you can read, or paste `owner/name` or a diffinery.com link.
4444 6. Choose a folder. The editor clones the repository and opens it.
4545
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)
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 an app on this computer, with Act as set to you and Write access selected.](/docs/images/editor-consent.webp)
4747
4848 ![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)
4949
@@ -106,11 +106,11 @@
106106 | What you see | What to do |
107107 | --- | --- |
108108 | 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. |
109+| The browser opens but the editor doesn't continue after **Connect** | The browser hands the answer to the editor on a one-time address on your computer (`127.0.0.1`). Make sure the editor is still running and the sign-in notification is still showing, then try again. A firewall or proxy that blocks local addresses stops it; use **Diffinery: Sign In with an Access Token** instead. |
110110 | "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. |
111111 | 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. |
112112 | `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. |
113+| An editor that isn't listed (another VS Code fork) | It works the same way: the extension needs only the editor's built-in Git support. |
114114
115115 ## For AI agents reading this page
116116
modifiedapps/diffinery/scripts/docs-screenshots.mjs+10−4View file
@@ -4,7 +4,9 @@
44 //
55 // pnpm --filter @diffinery/app build && node scripts/docs-screenshots.mjs
66 //
7-// Writes docs/images/consent-claude-code.webp, consent-codex.webp and connected-apps.webp.
7+// Writes docs/images/consent-claude-code.webp, consent-codex.webp, editor-consent.webp and connected-apps.webp; name
8+// some of them as arguments to write only those. The editor extension's own pictures (the clone picker) come from
9+// test/e2e/editor-smoke.mjs.
810 import { spawn, execFileSync } from "node:child_process";
911 import { createHash, randomBytes } from "node:crypto";
1012 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -26,6 +28,7 @@
2628 });
2729 // The browser talks to the Worker here; the pages themselves say diffinery.com.
2830 const ORIGIN = `http://localhost:${port}`;
31+const only = process.argv.slice(2);
2932 const work = mkdtempSync(join(tmpdir(), "diffinery-shots-"));
3033 const persist = join(work, "state");
3134 const SESSION = "docs-screenshots-session-0123456789abcdef";
@@ -79,6 +82,7 @@
7982
8083 /** Encodes the screenshot as WebP in the browser itself (no image library needed). */
8184 async function save(name, png) {
85+ if (only.length && !only.includes(name)) return;
8286 const data = await page.evaluate(async (b64) => {
8387 const img = new Image();
8488 img.src = `data:image/png;base64,${b64}`;
@@ -94,13 +98,13 @@
9498 }
9599
96100 // Connects an app identified by its client metadata document, as Claude Code and Codex do.
97- async function connect(clientId, redirect, file) {
101+ async function connect(clientId, redirect, file, { actAs = "ada-bot", scope = "read write", resource = `${PUBLIC}/mcp` } = {}) {
98102 const verifier = b64url(randomBytes(32));
99103 const challenge = b64url(createHash("sha256").update(verifier).digest());
100- const q = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirect, state: b64url(randomBytes(8)), code_challenge: challenge, code_challenge_method: "S256", scope: "read write", resource: `${PUBLIC}/mcp` });
104+ const q = new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: redirect, state: b64url(randomBytes(8)), code_challenge: challenge, code_challenge_method: "S256", scope, ...(resource ? { resource } : {}) });
101105 await page.goto(`${ORIGIN}/oauth/authorize?${q}`);
102106 await page.waitForLoadState("networkidle");
103- await page.getByText("ada-bot", { exact: false }).first().click();
107+ if (actAs) await page.getByText(actAs, { exact: false }).first().click();
104108 await sleep(300);
105109 const card = page.locator("main .box").first();
106110 await save(file, await card.screenshot());
@@ -130,6 +134,8 @@
130134
131135 await connect("https://claude.ai/oauth/claude-code-client-metadata", "http://localhost:53682/callback", "consent-claude-code.webp");
132136 await connect("https://chatgpt.com/oauth/codex/client.json", "http://127.0.0.1:1455/callback", "consent-codex.webp");
137+ // The editor extension, as it signs in from VS Code or Cursor: as the person, for Git, without the MCP resource.
138+ await connect(`${PUBLIC}/oauth/clients/editor.json`, "http://127.0.0.1:52817/callback", "editor-consent.webp", { actAs: null, scope: "write", resource: null });
133139
134140 await page.goto(`${ORIGIN}/settings/tokens`);
135141 await page.waitForLoadState("networkidle");
modifiedapps/diffinery/test/e2e/editor-smoke.mjs+45−9View file
@@ -8,7 +8,9 @@
88 // VSIX=/tmp/vsix/diffinery-0.1.0.vsix xvfb-run -a pnpm --filter @diffinery/app exec node test/e2e/editor-smoke.mjs
99 //
1010 // 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.
11+// usr/share/cursor/bin/cursor in the extracted AppImage; Cursor also needs --skip-onboarding, which this passes last: VS Code would read the next argument as its value). SHOTS=dir
12+// saves screenshots of each step, plus the clone picker on its own (*-box.png, for the docs); THEME sets the color
13+// theme, such as "Default Light Modern", and SCALE the device scale factor.
1214 import { spawn, execFileSync } from "node:child_process";
1315 import { createHash, randomBytes } from "node:crypto";
1416 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
@@ -50,6 +52,7 @@
5052 throw new Error(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`);
5153 }
5254 const shot = async (page, name) => SHOTS && (mkdirSync(SHOTS, { recursive: true }), await page.screenshot({ path: join(SHOTS, `${name}.png`) }));
55+const shotPicker = async (page, name) => SHOTS && (await page.locator(".quick-input-widget:not([style*='display: none'])").screenshot({ path: join(SHOTS, `${name}-box.png`) }));
5356 const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
5457
5558 // ---------------------------------------------------------------- a local Diffinery with ada and two repositories
@@ -84,8 +87,8 @@
8487 console.log("worker up at", base);
8588
8689 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" });
90+await api("POST", "/user/repos", { name: "hello-editor", private: true, description: "The analytical engine, in TypeScript" });
91+await api("POST", "/user/repos", { name: "notes", private: false, description: "Notes on the engine, and drafts of the next paper" });
8992 const seed = join(work, "seed");
9093 mkdirSync(seed);
9194 const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString();
@@ -101,6 +104,14 @@
101104 const userData = join(work, "user-data");
102105 const exts = join(work, "extensions");
103106 mkdirSync(clones, { recursive: true });
107+// Opened on a folder: Cursor shows its agent window instead of the editor until you log in to Cursor, unless a folder is open.
108+// The folder the editor opens: a local repository with a commit and no remote, for "Publish to Diffinery".
109+const workspace = join(home, "difference-engine");
110+mkdirSync(workspace, { recursive: true });
111+git(workspace, "init", "-q", "-b", "main");
112+writeFileSync(join(workspace, "engine.ts"), "export const add = (a: number, b: number) => a + b;\n");
113+git(workspace, "add", ".");
114+git(workspace, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "Start the engine");
104115 mkdirSync(join(userData, "User"), { recursive: true });
105116 // Git saves the password the editor gave it, as a keychain would; the extension keeps that copy current.
106117 writeFileSync(join(home, ".gitconfig"), `[credential]\n\thelper = store\n[user]\n\tname = Ada\n\temail = ada@example.com\n`);
@@ -110,7 +121,7 @@
110121 "diffinery.serverUrl": base,
111122 "diffinery.checkForUpdates": false,
112123 "files.simpleDialog.enable": true,
113- "git.openAfterClone": "always",
124+ "git.openAfterClone": "alwaysNewWindow",
114125 "git.defaultCloneDirectory": clones,
115126 "security.workspace.trust.enabled": false,
116127 "workbench.startupEditor": "none",
@@ -119,6 +130,7 @@
119130 "telemetry.telemetryLevel": "off",
120131 "update.mode": "none",
121132 "extensions.autoCheckUpdates": false,
133+ ...(process.env.THEME ? { "workbench.colorTheme": process.env.THEME } : {}),
122134 }),
123135 );
124136 // The browser the editor opens: record the URL, as a person's browser would receive it.
@@ -131,11 +143,11 @@
131143 chmodSync(browserScript, 0o755);
132144 const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}`, BROWSER: browserScript, XDG_CURRENT_DESKTOP: "", DONT_PROMPT_WSL_INSTALL: "1" };
133145 // No OS keyring in a test container: the editor keeps secrets with its own weaker encryption instead.
134-const common = ["--user-data-dir", userData, "--extensions-dir", exts, "--no-sandbox", "--disable-gpu", "--password-store=basic"];
146+const common = ["--user-data-dir", userData, "--extensions-dir", exts, "--no-sandbox", "--disable-gpu", "--password-store=basic", ...(process.env.SCALE ? [`--force-device-scale-factor=${process.env.SCALE}`] : [])];
135147 execFileSync(CLI, [...common, "--install-extension", VSIX, "--force"], { env, stdio: "pipe", timeout: 120_000 });
136148 console.log("extension installed");
137149
138-const app = await _electron.launch({ executablePath: EDITOR, args: [...common, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--new-window"], env, timeout: 120_000 });
150+const app = await _electron.launch({ executablePath: EDITOR, args: [workspace, ...common, "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", "--skip-onboarding"], env, timeout: 120_000 });
139151 const win = await app.firstWindow();
140152 // On a failure, a screenshot of the editor as it was.
141153 async function fail(e) {
@@ -151,7 +163,7 @@
151163 await sleep(4000);
152164 const palette = async (cmd) => {
153165 await win.keyboard.press("Control+Shift+P");
154- await win.waitForSelector(".quick-input-widget:not([style*='display: none']) input");
166+ await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input");
155167 await win.keyboard.type(cmd);
156168 await sleep(500);
157169 await win.keyboard.press("Enter");
@@ -171,6 +183,7 @@
171183 }),
172184 );
173185 await shot(win, "1-clone-picker");
186+await shotPicker(win, "1-clone-picker");
174187 await win.keyboard.type("Clone from Diffinery");
175188 await sleep(300);
176189 await win.keyboard.press("Enter");
@@ -211,6 +224,7 @@
211224 await until("ada's repositories in the picker", async () => (await quickItems()).some((t) => t.includes("ada/hello-editor")), 60_000);
212225 console.log("repositories:", await quickItems());
213226 await shot(win, "4-repositories");
227+await shotPicker(win, "4-repositories");
214228 await win.keyboard.type("hello");
215229 await until("the search result", async () => {
216230 const items = await quickItems();
@@ -236,10 +250,32 @@
236250 assert.match(stored, new RegExp(`^http://ada:dfy_[^@]+@localhost%3a${port}$|^http://ada:dfy_[^@]+@localhost:${port}$`, "m"));
237251 assert.ok(!stored.includes(ADA_TOKEN), "the saved credential is the editor's own token");
238252 console.log("git credential saved for ada");
253+await sleep(4000);
254+const cloneWin = app.windows().at(-1);
255+await cloneWin.waitForSelector(".monaco-workbench", { timeout: 60_000 });
239256 await sleep(3000);
240-await shot(win, "7-opened");
257+await shot(cloneWin, "7-opened");
241258
242-// 5. Signing out removes Git's saved copy and disconnects the editor on the server.
259+// 5. Publishing the open folder: Git's "Publish Branch" offers Diffinery, which creates the repository and pushes.
260+await palette("Git: Publish Branch");
261+await until("Publish to Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Publish to Diffinery")), 30_000);
262+console.log("publish picker:", await quickItems());
263+await win.keyboard.type("Diffinery");
264+await sleep(300);
265+await win.keyboard.press("Enter");
266+await until("the repository name box", async () => (await win.inputValue(".quick-input-widget:not([style*='display: none']) .quick-input-box input").catch(() => "")) === "difference-engine", 30_000);
267+await shot(win, "8-publish-name");
268+await win.keyboard.press("Enter");
269+await until("the visibility choice", async () => (await quickItems()).some((t) => t.includes("Private")), 20_000);
270+await win.keyboard.press("Enter");
271+const published = await until("the pushed branch on Diffinery", async () => (await api("GET", "/repos/ada/difference-engine/branches").catch(() => null))?.find((b) => b.name === "main"), 60_000);
272+assert.equal(published.commit.sha, git(workspace, "rev-parse", "HEAD").trim());
273+assert.equal((await api("GET", "/repos/ada/difference-engine")).private, true);
274+assert.match(git(workspace, "remote", "get-url", "origin"), /\/ada\/difference-engine\.git/);
275+await shot(win, "9-published");
276+console.log("published: ada/difference-engine, private, main pushed");
277+
278+// 6. Signing out removes Git's saved copy and disconnects the editor on the server.
243279 await palette("Diffinery: Sign Out");
244280 await until("the saved credential to go", async () => !readFileSync(join(home, ".git-credentials"), "utf8").includes("ada:"), 20_000);
245281 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;
addedapps/vscode/PUBLISHING.md+38−0View file
@@ -1,0 +1,38 @@
1+# Publishing to the VS Code Marketplace and Open VSX
2+
3+Not done yet: for now the extension is served only from diffinery.com (`/-/vscode/diffinery.vsix`, built by
4+`apps/diffinery/scripts/build-docs.mjs`). These are the steps for when Diffinery goes public. Cursor, Windsurf and
5+VSCodium install from Open VSX; VS Code installs from the Visual Studio Marketplace, so both are needed.
6+
7+## Before the first upload
8+
9+1. Add a `LICENSE` file and a `license` field in `package.json` (both registries show it; `vsce` warns without it).
10+2. Create the `codician` publisher on the Marketplace (https://marketplace.visualstudio.com/manage, needs an Azure
11+ DevOps organization for the personal access token) and the `codician` namespace on Open VSX
12+ (`npx ovsx create-namespace codician`, after signing the Eclipse publisher agreement). The extension id
13+ `codician.diffinery` is what the server's OAuth client (`EDITOR_EXTENSION_ID` in `apps/diffinery/src/auth/oauth.ts`)
14+ and the docs use, so keep it.
15+3. Marketplace builds update through the marketplace: ship them with `diffinery.checkForUpdates` defaulting to
16+ `false`, or have `src/updates.ts` skip the check when the extension was installed from a gallery, so the two
17+ update paths don't fight.
18+
19+## Listing
20+
21+| Field | Value |
22+| --- | --- |
23+| Name | Diffinery |
24+| Short description | `description` in `package.json` |
25+| Categories | SCM Providers, Other |
26+| Icon | `media/icon.png` (128×128) |
27+| Banner | `galleryBanner` in `package.json` |
28+| Details page | `README.md` (relative image links are rewritten to the repository) |
29+| Changelog | `CHANGELOG.md` |
30+| Q&A / issues | `bugs.url` in `package.json` |
31+
32+## Each release
33+
34+1. Bump `version` in `package.json` and add a `CHANGELOG.md` entry.
35+2. `node scripts/package.mjs --out dist` builds `dist/diffinery-<version>.vsix`, the same file diffinery.com serves.
36+3. `npx @vscode/vsce publish --packagePath dist/diffinery-<version>.vsix` and
37+ `npx ovsx publish dist/diffinery-<version>.vsix`, with the tokens from steps 2 above in `VSCE_PAT` and `OVSX_PAT`.
38+4. Deploying Diffinery keeps diffinery.com serving the same version for people who installed from there.
modifiedapps/vscode/src/git.ts+1−1View file
@@ -76,7 +76,7 @@
7676 }
7777 const [me, orgs] = await Promise.all([api.user(), api.orgs().catch(() => [])]);
7878 const owners = [
79- { label: me.login, description: me.type === "Bot" ? "the agent's owner's account" : "your account", owner: null as string | null },
79+ { label: me.login, description: "your account", owner: null as string | null },
8080 ...orgs.map((o) => ({ label: o.login, description: "organization", owner: o.login as string | null })),
8181 ];
8282 const owner = owners.length === 1 ? owners[0] : await vscode.window.showQuickPick(owners, { title: "Publish to Diffinery", placeHolder: "Owner of the new repository" });