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.

Merge pull request #231 from claude/editor-clone

Clone from Diffinery in VS Code, Cursor and other editors
test-bot authored 2 parents f26063a12e2ca0commit 12fa3c8f32e26e19dcf7a67f9577bbbf83fcf5ebBrowse files
42 files changed+2411−18
Files (42)
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).
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/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 (`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+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**. The browser shows **You're signed in to Diffinery**; close the tab and go back to the editor.
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 an app on this computer, 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 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. |
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) | It works the same way: the extension needs only the editor's built-in Git support. |
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/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/src/auth/oauth.ts+33−6View file
@@ -47,6 +47,28 @@
4747 "https://chatgpt.com/oauth/codex/client.json": { name: "Codex", clientUri: "https://chatgpt.com/codex", redirectUris: ["http://127.0.0.1/callback", "http://localhost/callback"] },
4848 };
4949
50+/**
51+ * Diffinery's own extension for VS Code and the editors built on it (apps/vscode). Its client_id is the URL of
52+ * a metadata document this server publishes about itself, so it's resolved here instead of fetched (a client_id
53+ * can't point at Diffinery itself). Each editor sends people back to the extension through its own URL scheme;
54+ * an editor not listed uses a loopback address on the person's computer.
55+ */
56+export const EDITOR_CLIENT_PATH = "/oauth/clients/editor.json";
57+export const EDITOR_EXTENSION_ID = "codician.diffinery";
58+export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"] as const;
59+export const editorClientId = (env: Pick<Env, "PUBLIC_URL">) => `${base(env)}${EDITOR_CLIENT_PATH}`;
60+export function editorClientDocument(env: Pick<Env, "PUBLIC_URL">) {
61+ return {
62+ client_id: editorClientId(env),
63+ client_name: "Diffinery extension for VS Code and Cursor",
64+ client_uri: `${base(env)}/docs/repositories/clone-in-your-editor`,
65+ redirect_uris: [...EDITOR_SCHEMES.map((s) => `${s}://${EDITOR_EXTENSION_ID}/oauth`), "http://127.0.0.1/callback"],
66+ grant_types: ["authorization_code", "refresh_token"],
67+ response_types: ["code"],
68+ token_endpoint_auth_method: "none",
69+ };
70+}
71+
5072 export type AuthMethod = "none" | "client_secret_basic" | "client_secret_post";
5173
5274 export interface OAuthClient {
@@ -297,12 +319,17 @@
297319 const row = await this.db.prepare("SELECT * FROM oauth_clients WHERE id = ?").bind(id).first<Record<string, string | null>>();
298320 const fresh = row && (row.kind === "registered" || (row.fetched_at && Date.now() - Date.parse(row.fetched_at) < METADATA_TTL_MS));
299321 if (row && (fresh || opts.fetch === false)) return toClient(row);
300- if (opts.fetch === false || !isMetadataClientId(id, localOnly(this.env))) return null;
301- const doc =
302- (await this.fetchMetadata(id).catch((e) => {
303- console.error("client metadata document failed", id, String(e));
304- return null;
305- })) ?? KNOWN_CLIENTS[id] ?? null;
322+ const own = id === editorClientId(this.env) ? editorClientDocument(this.env) : null;
323+ if (opts.fetch === false && !own) return null;
324+ if (!own && !isMetadataClientId(id, localOnly(this.env))) return null;
325+ const doc = own
326+ ? { name: own.client_name, clientUri: own.client_uri, redirectUris: own.redirect_uris }
327+ : ((await this.fetchMetadata(id).catch((e) => {
328+ console.error("client metadata document failed", id, String(e));
329+ return null;
330+ })) ??
331+ KNOWN_CLIENTS[id] ??
332+ null);
306333 if (!doc) return row ? toClient(row) : null;
307334 const at = now();
308335 await this.db
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/http/oauth.ts+4−1View file
@@ -1,7 +1,7 @@
11 import { Hono, type Context } from "hono";
22 import type { Env } from "../env.js";
33 import type { AppVars } from "../auth/principal.js";
4-import { OAuth, OAuthError, authorizationServerMetadata, protectedResourceMetadata } from "../auth/oauth.js";
4+import { EDITOR_CLIENT_PATH, OAuth, OAuthError, authorizationServerMetadata, editorClientDocument, protectedResourceMetadata } from "../auth/oauth.js";
55
66 /**
77 * OAuth's machine endpoints for MCP clients (auth/oauth.ts has the flow; the consent page is in
@@ -30,6 +30,9 @@
3030 // Clients that put the resource's path after the well-known name (RFC 8414 §3.1 with an issuer path) get the same document.
3131 oauthApi.get("/.well-known/oauth-authorization-server/mcp", (c) => c.json(authorizationServerMetadata(c.env), 200, DISCOVERY));
3232
33+// The metadata document of Diffinery's own editor extension (apps/vscode), whose client_id is this URL.
34+oauthApi.get(EDITOR_CLIENT_PATH, (c) => c.json(editorClientDocument(c.env), 200, DISCOVERY));
35+
3336 const oauthError = (c: Context, e: OAuthError) => {
3437 const headers: Record<string, string> = { ...CORS, ...NO_STORE };
3538 if (e.status === 401) headers["www-authenticate"] = 'Basic realm="Diffinery OAuth"';
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+289−0View file
@@ -1,0 +1,289 @@
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; 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.
14+import { spawn, execFileSync } from "node:child_process";
15+import { createHash, randomBytes } from "node:crypto";
16+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
17+import { createServer } from "node:net";
18+import { tmpdir } from "node:os";
19+import { join, dirname, resolve } from "node:path";
20+import { fileURLToPath } from "node:url";
21+import assert from "node:assert/strict";
22+import { chromium, _electron } from "playwright";
23+
24+const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
25+const EDITOR = process.env.EDITOR_BIN;
26+const CLI = process.env.EDITOR_CLI;
27+const VSIX = process.env.VSIX;
28+const SHOTS = process.env.SHOTS;
29+if (!EDITOR || !CLI || !VSIX) throw new Error("set EDITOR_BIN, EDITOR_CLI and VSIX");
30+const work = mkdtempSync(join(tmpdir(), "diffinery-editor-"));
31+const hash = (s) => createHash("sha256").update(s).digest("hex");
32+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33+const ADA_SESSION = "ada-editor-session-0123456789abcdef";
34+const ADA_TOKEN = `dfy_${randomBytes(24).toString("hex")}`;
35+let devLog = "";
36+
37+const freePort = () =>
38+ new Promise((res) => {
39+ const s = createServer();
40+ s.listen(0, "127.0.0.1", () => {
41+ const { port } = s.address();
42+ s.close(() => res(port));
43+ });
44+ });
45+async function until(what, fn, timeoutMs = 60_000) {
46+ const end = Date.now() + timeoutMs;
47+ while (Date.now() < end) {
48+ const v = await fn();
49+ if (v) return v;
50+ await sleep(300);
51+ }
52+ throw new Error(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-3000)}`);
53+}
54+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`) }));
56+const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
57+
58+// ---------------------------------------------------------------- a local Diffinery with ada and two repositories
59+const port = await freePort();
60+const base = `http://localhost:${port}`;
61+const persist = join(work, "state");
62+const sql = (command) => wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", command);
63+wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist);
64+const at = new Date().toISOString();
65+const later = new Date(Date.now() + 86400_000).toISOString();
66+sql(
67+ [
68+ `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('ada1', 'ada', 'user', 'Ada', 'ada@example.com', 'active', '${at}')`,
69+ `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(ADA_SESSION)}', 'ada1', 'passkey', '${at}', '${at}', '${later}')`,
70+ `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}')`,
71+ ].join("; "),
72+);
73+const dev = spawn(
74+ "pnpm",
75+ ["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"],
76+ { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
77+);
78+dev.stdout.on("data", (d) => (devLog += d));
79+dev.stderr.on("data", (d) => (devLog += d));
80+const cleanup = () => {
81+ try {
82+ process.kill(-dev.pid, "SIGTERM");
83+ } catch {}
84+};
85+process.on("exit", cleanup);
86+await until("wrangler dev", async () => (await fetch(`${base}/healthz`).catch(() => null))?.ok, 120_000);
87+console.log("worker up at", base);
88+
89+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()}`))));
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" });
92+const seed = join(work, "seed");
93+mkdirSync(seed);
94+const git = (cwd, ...args) => execFileSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }).toString();
95+git(seed, "init", "-q", "-b", "main");
96+writeFileSync(join(seed, "README.md"), "# hello-editor\n\nCloned from Diffinery in an editor.\n");
97+git(seed, "add", ".");
98+git(seed, "-c", "user.name=Ada", "-c", "user.email=ada@example.com", "commit", "-qm", "First commit");
99+git(seed, "push", "-q", `http://x:${ADA_TOKEN}@localhost:${port}/ada/hello-editor.git`, "main");
100+
101+// ---------------------------------------------------------------- the editor
102+const home = join(work, "home");
103+const clones = join(home, "src");
104+const userData = join(work, "user-data");
105+const exts = join(work, "extensions");
106+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");
115+mkdirSync(join(userData, "User"), { recursive: true });
116+// Git saves the password the editor gave it, as a keychain would; the extension keeps that copy current.
117+writeFileSync(join(home, ".gitconfig"), `[credential]\n\thelper = store\n[user]\n\tname = Ada\n\temail = ada@example.com\n`);
118+writeFileSync(
119+ join(userData, "User", "settings.json"),
120+ JSON.stringify({
121+ "diffinery.serverUrl": base,
122+ "diffinery.checkForUpdates": false,
123+ "files.simpleDialog.enable": true,
124+ "git.openAfterClone": "alwaysNewWindow",
125+ "git.defaultCloneDirectory": clones,
126+ "security.workspace.trust.enabled": false,
127+ "workbench.startupEditor": "none",
128+ "window.dialogStyle": "custom",
129+ "window.titleBarStyle": "custom",
130+ "telemetry.telemetryLevel": "off",
131+ "update.mode": "none",
132+ "extensions.autoCheckUpdates": false,
133+ ...(process.env.THEME ? { "workbench.colorTheme": process.env.THEME } : {}),
134+ }),
135+);
136+// The browser the editor opens: record the URL, as a person's browser would receive it.
137+const opened = join(work, "opened.txt");
138+// Electron opens links with xdg-open on Linux, which hands them to $BROWSER when there's no desktop session.
139+const bin = join(work, "bin");
140+mkdirSync(bin);
141+const browserScript = join(bin, "xdg-open");
142+writeFileSync(browserScript, `#!/bin/sh\necho "$1" >> ${opened}\n`);
143+chmodSync(browserScript, 0o755);
144+const env = { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}`, BROWSER: browserScript, XDG_CURRENT_DESKTOP: "", DONT_PROMPT_WSL_INSTALL: "1" };
145+// No OS keyring in a test container: the editor keeps secrets with its own weaker encryption instead.
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}`] : [])];
147+execFileSync(CLI, [...common, "--install-extension", VSIX, "--force"], { env, stdio: "pipe", timeout: 120_000 });
148+console.log("extension installed");
149+
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 });
151+const win = await app.firstWindow();
152+// On a failure, a screenshot of the editor as it was.
153+async function fail(e) {
154+ if (SHOTS) await win.screenshot({ path: join(SHOTS, "failed.png") }).catch(() => {});
155+ console.error(e);
156+ cleanup();
157+ process.exit(1);
158+}
159+process.on("unhandledRejection", fail);
160+process.on("uncaughtException", fail);
161+await win.waitForSelector(".monaco-workbench", { timeout: 120_000 });
162+console.log("editor window open");
163+await sleep(4000);
164+const palette = async (cmd) => {
165+ await win.keyboard.press("Control+Shift+P");
166+ await win.waitForSelector(".quick-input-widget:not([style*='display: none']) .quick-input-box input");
167+ await win.keyboard.type(cmd);
168+ await sleep(500);
169+ await win.keyboard.press("Enter");
170+};
171+const quickItems = async () => (await win.$$eval(".quick-input-list .monaco-list-row", (rows) => rows.map((r) => r.textContent?.trim() ?? ""))).filter(Boolean);
172+
173+// 1. The clone picker lists Diffinery as a remote source.
174+await palette("Git: Clone");
175+await until("Clone from Diffinery in the picker", async () => (await quickItems()).some((t) => t.includes("Clone from Diffinery")), 30_000);
176+console.log("clone picker:", await quickItems());
177+if (process.env.DEBUG_ICON)
178+ console.log(
179+ await win.evaluate(async () => {
180+ const el = document.querySelector(".codicon-diffinery-logo");
181+ const st = el && getComputedStyle(el, "::before");
182+ return { content: st?.content, font: st?.fontFamily, fonts: [...document.fonts].map((f) => `${f.family} ${f.status}`).filter((f) => !/codicon|Segoe/.test(f)) };
183+ }),
184+ );
185+await shot(win, "1-clone-picker");
186+await shotPicker(win, "1-clone-picker");
187+await win.keyboard.type("Clone from Diffinery");
188+await sleep(300);
189+await win.keyboard.press("Enter");
190+
191+// 2. Signing in: the editor asks to allow the sign-in, then opens Diffinery's consent page in the browser.
192+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);
193+await shot(win, "2-allow-sign-in");
194+await allow.click();
195+const authorize = await until("the editor to open the browser", async () => existsSync(opened) && readFileSync(opened, "utf8").trim().split("\n").pop(), 30_000);
196+const au = new URL(authorize);
197+assert.equal(au.origin + au.pathname, `${base}/oauth/authorize`);
198+assert.equal(au.searchParams.get("client_id"), `${base}/oauth/clients/editor.json`);
199+const redirect = new URL(au.searchParams.get("redirect_uri"));
200+// The extension listens on 127.0.0.1 for the browser's answer (no prompts on the way back to the editor).
201+assert.equal(redirect.origin, `http://127.0.0.1:${redirect.port}`);
202+assert.equal(redirect.pathname, "/callback");
203+console.log("authorize:", authorize);
204+
205+const browser = await chromium.launch();
206+const ctx = await browser.newContext();
207+const page = await ctx.newPage();
208+await page.goto(`${base}/login`);
209+await page.evaluate((v) => (document.cookie = `__Host-dfy_session=${v}; path=/; secure; samesite=lax`), ADA_SESSION);
210+await page.goto(authorize);
211+const consent = ((await page.textContent("main")) ?? "").replace(/\s+/g, " ");
212+assert.match(consent, /Connect Diffinery extension for VS Code and Cursor to Diffinery/);
213+assert.match(consent, /published by localhost/);
214+assert.match(consent, /Sends you back to\s*an app on this computer/);
215+if (SHOTS) await page.screenshot({ path: join(SHOTS, "3-consent.png"), fullPage: true });
216+await page.click('button[value="approve"]');
217+await page.waitForURL(/^http:\/\/127\.0\.0\.1:\d+\/callback/);
218+assert.match(await page.textContent("body"), /You're signed in to Diffinery/);
219+if (SHOTS) await page.screenshot({ path: join(SHOTS, "3b-signed-in.png") });
220+console.log("browser: signed in page shown");
221+await browser.close();
222+
223+// 3. Signed in: the picker lists ada's repositories; search narrows it.
224+await until("ada's repositories in the picker", async () => (await quickItems()).some((t) => t.includes("ada/hello-editor")), 60_000);
225+console.log("repositories:", await quickItems());
226+await shot(win, "4-repositories");
227+await shotPicker(win, "4-repositories");
228+await win.keyboard.type("hello");
229+await until("the search result", async () => {
230+ const items = await quickItems();
231+ return items.length === 1 && items[0].includes("ada/hello-editor");
232+}, 20_000);
233+await shot(win, "5-search");
234+await win.keyboard.press("Enter");
235+
236+// 4. Choosing where to clone (the editor's own folder dialog), then Git clones with the extension's password.
237+const destination = win.getByText("Select as Repository Destination", { exact: true });
238+await destination.waitFor({ timeout: 30_000 });
239+await sleep(500);
240+await shot(win, "6-folder");
241+await destination.click();
242+const clonedReadme = join(clones, "hello-editor", "README.md");
243+await until("the clone on disk", async () => existsSync(clonedReadme), 90_000).catch(async (e) => {
244+ await shot(win, "6b-folder-stuck");
245+ throw e;
246+});
247+assert.match(readFileSync(clonedReadme, "utf8"), /Cloned from Diffinery/);
248+console.log("cloned:", clonedReadme);
249+const stored = readFileSync(join(home, ".git-credentials"), "utf8");
250+assert.match(stored, new RegExp(`^http://ada:dfy_[^@]+@localhost%3a${port}$|^http://ada:dfy_[^@]+@localhost:${port}$`, "m"));
251+assert.ok(!stored.includes(ADA_TOKEN), "the saved credential is the editor's own token");
252+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 });
256+await sleep(3000);
257+await shot(cloneWin, "7-opened");
258+
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.
279+await palette("Diffinery: Sign Out");
280+await until("the saved credential to go", async () => !readFileSync(join(home, ".git-credentials"), "utf8").includes("ada:"), 20_000);
281+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;
282+assert.ok(grants.length === 1 && grants[0].revoked_at, "the connection is revoked");
283+console.log("signed out: credential removed, connection revoked");
284+
285+await app.close();
286+cleanup();
287+if (process.env.KEEP !== "1") rmSync(work, { recursive: true, force: true });
288+console.log("editor smoke test passed");
289+process.exit(0);
modifiedapps/diffinery/test/e2e/oauth.test.mjs+39−0View file
@@ -361,3 +361,42 @@
361361 assert.equal((await whoami(t.access_token)).login, "ada");
362362 await ada.context.close();
363363 });
364+
365+test("OAuth: Diffinery's own editor extension (apps/vscode)", async () => {
366+ const ada = await adaBrowser();
367+ const { verifier, challenge } = pkce();
368+ const client_id = `${base}/oauth/clients/editor.json`;
369+ // The server publishes the extension's metadata document at its client_id.
370+ const doc = await (await fetch(client_id)).json();
371+ assert.equal(doc.client_id, client_id);
372+ assert.ok(doc.redirect_uris.includes("cursor://codician.diffinery/oauth") && doc.redirect_uris.includes("vscode://codician.diffinery/oauth"));
373+ 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" })}`;
374+ // An editor's own scheme: the consent page names the publisher and where it sends you, and approving redirects there.
375+ await ada.page.goto(authorize("cursor://codician.diffinery/oauth"));
376+ assert.match(await text(ada.page), /Connect Diffinery extension for VS Code and Cursor to Diffinery/);
377+ assert.match(await text(ada.page), /An app published by localhost/);
378+ assert.match(await text(ada.page), /Sends you back to\s*cursor:\/\/codician\.diffinery/);
379+ let location;
380+ const onResponse = (r) => (r.headers().location?.startsWith("cursor://") ? (location = r.headers().location) : null);
381+ ada.context.on("response", onResponse);
382+ await ada.page.click('button[value="approve"]', { noWaitAfter: true }).catch(() => {});
383+ const cursorCb = new URL(await until("the redirect to Cursor", async () => location));
384+ ada.context.off("response", onResponse);
385+ assert.equal(cursorCb.searchParams.get("state"), "ed");
386+ 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" });
387+ assert.equal(t1.status, 200, await t1.clone().text());
388+ const tok = await t1.json();
389+ assert.equal((await whoami(tok.access_token)).login, "ada");
390+ // The token works for the REST API the extension lists repositories with.
391+ assert.equal((await (await fetch(`${base}/api/v3/user`, { headers: { authorization: `Bearer ${tok.access_token}` } })).json()).login, "ada");
392+ // It renews with the refresh token, as the extension does before the access token expires.
393+ const r = await (await form(`${base}/oauth/token`, { grant_type: "refresh_token", refresh_token: tok.refresh_token, client_id })).json();
394+ assert.match(r.access_token, /^dfy_/);
395+ // An editor the server has no scheme for uses a loopback address, any port.
396+ const cb = await consent(ada, authorize("http://127.0.0.1:45123/callback"), { scope: "write" });
397+ assert.equal(cb.port, "45123");
398+ // Other schemes and hosts aren't the extension's.
399+ await ada.page.goto(authorize("evil://codician.diffinery/oauth"));
400+ assert.match(await text(ada.page), /didn't register\. Nothing was shared/);
401+ await ada.context.close();
402+});
addedapps/vscode/CHANGELOG.md+9−0View file
@@ -1,0 +1,9 @@
1+# Changelog
2+
3+## 0.1.0
4+
5+- "Clone from Diffinery" in the clone picker of VS Code, Cursor, Windsurf, VSCodium and other editors built on VS Code, listing your repositories and your organizations', with search.
6+- Sign in through the browser with your passkey (Diffinery's OAuth), or with an access token. Diffinery appears in the editor's Accounts menu.
7+- Git clone, pull and push to Diffinery use your signed-in account, and the token Git saves is kept current.
8+- "Publish to Diffinery" for a repository that has no remote yet.
9+- Updates itself from diffinery.com.
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.
addedapps/vscode/README.md+48−0View file
@@ -1,0 +1,48 @@
1+# Diffinery for VS Code, Cursor and Windsurf
2+
3+Work with [Diffinery](https://diffinery.com) repositories from VS Code and the editors built on it (Cursor,
4+Windsurf, VSCodium and others), the way you do with GitHub's.
5+
6+- **Clone from Diffinery.** Run **Git: Clone** (or **Clone Repository** on the welcome page) and pick
7+ **Clone from Diffinery**. You see your repositories and your organizations', most recently pushed first;
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.
11+- **Push and pull without passwords.** Git uses your signed-in account for diffinery.com. The extension renews
12+ its token before it expires and keeps the copy Git saved in your keychain current.
13+- **Publish to Diffinery.** Publishing a local repository that has no remote offers **Publish to Diffinery**,
14+ under your account or one of your organizations, private or public.
15+
16+## Install
17+
18+The extension comes from diffinery.com and keeps itself up to date from there. Install it with your editor's
19+command line, for example:
20+
21+```sh
22+curl -fsSLo /tmp/diffinery.vsix https://diffinery.com/-/vscode/diffinery.vsix
23+code --install-extension /tmp/diffinery.vsix # cursor, windsurf or codium for those editors
24+```
25+
26+Or download [diffinery.vsix](https://diffinery.com/-/vscode/diffinery.vsix) and choose **Extensions → … →
27+Install from VSIX…** in the editor.
28+
29+The full guide, including JetBrains IDEs and the "Open in VS Code / Cursor" buttons on every repository, is at
30+[diffinery.com/docs/repositories/clone-in-your-editor](https://diffinery.com/docs/repositories/clone-in-your-editor).
31+
32+## Commands
33+
34+| Command | What it does |
35+|---|---|
36+| Diffinery: Clone Repository | Opens the Diffinery repository picker directly. |
37+| Diffinery: Sign In | Signs in through your browser. |
38+| Diffinery: Sign In with an Access Token | Uses a token from Settings → Access tokens instead (agents, browser-based editors). |
39+| Diffinery: Sign Out | Signs out and disconnects the editor from your account. |
40+| Diffinery: Check for Extension Updates | Installs a newer build from the server, if there is one. |
41+
42+## Settings
43+
44+| Setting | Default | |
45+|---|---|---|
46+| `diffinery.serverUrl` | `https://diffinery.com` | Change only for a self-hosted Diffinery. |
47+| `diffinery.gitAuthentication` | `true` | Answer Git's password prompts for the server with your signed-in account. |
48+| `diffinery.checkForUpdates` | `true` | Check the server once a day for a newer build. |
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}"
addedapps/vscode/media/diffinery-icons.woff+0−0View file
Binary file not shown.
addedapps/vscode/media/icon.png+0−0View file
Binary file not shown.
addedapps/vscode/package.json+134−0View file
@@ -1,0 +1,134 @@
1+{
2+ "name": "@diffinery/vscode",
3+ "displayName": "Diffinery",
4+ "description": "Clone, publish and push Diffinery repositories from VS Code, Cursor, Windsurf and other editors built on VS Code.",
5+ "version": "0.1.0",
6+ "publisher": "codician",
7+ "private": true,
8+ "homepage": "https://diffinery.com/docs/repositories/clone-in-your-editor",
9+ "repository": {
10+ "type": "git",
11+ "url": "https://diffinery.com/codician/diffinery.git"
12+ },
13+ "bugs": {
14+ "url": "https://diffinery.com/codician/diffinery/issues"
15+ },
16+ "icon": "media/icon.png",
17+ "galleryBanner": {
18+ "color": "#18181b",
19+ "theme": "dark"
20+ },
21+ "categories": [
22+ "SCM Providers",
23+ "Other"
24+ ],
25+ "keywords": [
26+ "diffinery",
27+ "git",
28+ "clone",
29+ "remote",
30+ "scm"
31+ ],
32+ "engines": {
33+ "vscode": "^1.85.0"
34+ },
35+ "extensionKind": [
36+ "workspace"
37+ ],
38+ "extensionDependencies": [
39+ "vscode.git",
40+ "vscode.git-base"
41+ ],
42+ "main": "./dist/extension.js",
43+ "activationEvents": [
44+ "onStartupFinished",
45+ "onAuthenticationRequest:diffinery",
46+ "onUri"
47+ ],
48+ "capabilities": {
49+ "virtualWorkspaces": false,
50+ "untrustedWorkspaces": {
51+ "supported": true
52+ }
53+ },
54+ "contributes": {
55+ "authentication": [
56+ {
57+ "id": "diffinery",
58+ "label": "Diffinery"
59+ }
60+ ],
61+ "icons": {
62+ "diffinery-logo": {
63+ "description": "Diffinery",
64+ "default": {
65+ "fontPath": "./media/diffinery-icons.woff",
66+ "fontCharacter": "\\E001"
67+ }
68+ }
69+ },
70+ "commands": [
71+ {
72+ "command": "diffinery.clone",
73+ "title": "Clone Repository",
74+ "category": "Diffinery",
75+ "icon": "$(diffinery-logo)"
76+ },
77+ {
78+ "command": "diffinery.signIn",
79+ "title": "Sign In",
80+ "category": "Diffinery"
81+ },
82+ {
83+ "command": "diffinery.signInWithToken",
84+ "title": "Sign In with an Access Token",
85+ "category": "Diffinery"
86+ },
87+ {
88+ "command": "diffinery.signOut",
89+ "title": "Sign Out",
90+ "category": "Diffinery"
91+ },
92+ {
93+ "command": "diffinery.checkForUpdates",
94+ "title": "Check for Extension Updates",
95+ "category": "Diffinery"
96+ }
97+ ],
98+ "configuration": {
99+ "title": "Diffinery",
100+ "properties": {
101+ "diffinery.serverUrl": {
102+ "type": "string",
103+ "default": "https://diffinery.com",
104+ "scope": "application",
105+ "markdownDescription": "The Diffinery server to sign in to. Change it only for a self-hosted Diffinery."
106+ },
107+ "diffinery.gitAuthentication": {
108+ "type": "boolean",
109+ "default": true,
110+ "scope": "application",
111+ "markdownDescription": "Answer Git's requests for a password for the Diffinery server with your signed-in account, so clone, pull and push just work."
112+ },
113+ "diffinery.checkForUpdates": {
114+ "type": "boolean",
115+ "default": true,
116+ "scope": "application",
117+ "markdownDescription": "Check once a day whether the Diffinery server offers a newer version of this extension, and offer to install it."
118+ }
119+ }
120+ }
121+ },
122+ "scripts": {
123+ "typecheck": "tsc --noEmit",
124+ "test": "vitest run",
125+ "package": "node scripts/package.mjs"
126+ },
127+ "devDependencies": {
128+ "@types/node": "^22.0.0",
129+ "@types/vscode": "1.85.0",
130+ "esbuild": "0.25.11",
131+ "typescript": "^5.9.3",
132+ "vitest": "^4.1.0"
133+ }
134+}
addedapps/vscode/scripts/make-icons.py+70−0View file
@@ -1,0 +1,70 @@
1+"""Generate the extension's icons from Diffinery's mark.
2+
3+ python3 -m venv /tmp/v && /tmp/v/bin/pip install fonttools picosvg brotli pillow
4+ /tmp/v/bin/python apps/vscode/scripts/make-icons.py
5+
6+Writes apps/vscode/media/icon.png (the extension's 128 px icon, the site's app icon scaled
7+down) and apps/vscode/media/diffinery-icons.woff (a one-glyph icon font, so the clone picker
8+can show the mark as `$(diffinery-logo)` next to "Clone from Diffinery", the way GitHub's shows its
9+logo). The output is committed, so builds need none of these tools.
10+"""
11+import base64
12+import io
13+import os
14+import re
15+
16+from fontTools.fontBuilder import FontBuilder
17+from fontTools.pens.cu2quPen import Cu2QuPen
18+from fontTools.pens.transformPen import TransformPen
19+from fontTools.pens.ttGlyphPen import TTGlyphPen
20+from fontTools.svgLib.path import SVGPath
21+from PIL import Image
22+from picosvg.svg import SVG
23+
24+HERE = os.path.dirname(os.path.abspath(__file__))
25+MEDIA = os.path.join(HERE, "..", "media")
26+SITE_ICONS = os.path.join(HERE, "..", "..", "diffinery", "src", "web", "icons.generated.ts")
27+# The mark, as in apps/diffinery/src/web/assets.ts (ICONS.logo), on a 24-unit grid.
28+MARK = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>"""
29+CODEPOINT = 0xE001
30+UPM = 1000
31+
32+
33+def extension_icon():
34+ src = open(SITE_ICONS).read()
35+ m = re.search(r'"icon-512\.png":\s*"([A-Za-z0-9+/=]+)"', src)
36+ if not m:
37+ raise SystemExit("icon-512.png not found in icons.generated.ts")
38+ img = Image.open(io.BytesIO(base64.b64decode(m.group(1)))).convert("RGBA")
39+ img.resize((128, 128), Image.LANCZOS).save(os.path.join(MEDIA, "icon.png"), optimize=True)
40+
41+
42+def icon_font():
43+ # Strokes become filled outlines, as a font needs.
44+ flat = SVG.fromstring(MARK).topicosvg()
45+ fb = FontBuilder(UPM, isTTF=True)
46+ fb.setupGlyphOrder([".notdef", "diffinery"])
47+ fb.setupCharacterMap({CODEPOINT: "diffinery"})
48+ pen = TTGlyphPen(None)
49+ # 24 units -> 1000, y up, baseline at the bottom of the grid.
50+ scale = UPM / 24
51+ for el in flat.shapes():
52+ SVGPath.fromstring(f'<svg xmlns="http://www.w3.org/2000/svg"><path d="{el.d}"/></svg>').draw(
53+ TransformPen(Cu2QuPen(pen, 1.0, reverse_direction=True), (scale, 0, 0, -scale, 0, UPM))
54+ )
55+ glyphs = {".notdef": TTGlyphPen(None).glyph(), "diffinery": pen.glyph()}
56+ fb.setupGlyf(glyphs)
57+ fb.setupHorizontalMetrics({".notdef": (UPM, 0), "diffinery": (UPM, 0)})
58+ fb.setupHorizontalHeader(ascent=UPM, descent=0)
59+ fb.setupNameTable({"familyName": "diffinery-icons", "styleName": "Regular"})
60+ fb.setupOS2(sTypoAscender=UPM, sTypoDescender=0, usWinAscent=UPM, usWinDescent=0)
61+ fb.setupPost()
62+ fb.font.flavor = "woff"
63+ fb.save(os.path.join(MEDIA, "diffinery-icons.woff"))
64+
65+
66+if __name__ == "__main__":
67+ os.makedirs(MEDIA, exist_ok=True)
68+ extension_icon()
69+ icon_font()
70+ print("wrote media/icon.png and media/diffinery-icons.woff")
addedapps/vscode/scripts/package.mjs+189−0View file
@@ -1,0 +1,189 @@
1+// Builds the extension and packs it as a .vsix, the file VS Code, Cursor, Windsurf and VSCodium install.
2+//
3+// node apps/vscode/scripts/package.mjs [--out dir] (writes <dir>/diffinery-<version>.vsix, default apps/vscode/dist)
4+//
5+// apps/diffinery/scripts/build-docs.mjs calls packageExtension() too, so the Worker serves the build at
6+// /-/vscode/diffinery.vsix. The .vsix has the layout `vsce package` writes (extension.vsixmanifest,
7+// [Content_Types].xml, extension/…), so it can also go to the Visual Studio Marketplace and Open VSX
8+// unchanged. Packing it here keeps @vscode/vsce and its dependencies out of every CI install. File dates
9+// are fixed, so the same sources always give the same bytes (and the same published digest).
10+import { build } from "esbuild";
11+import { readFile, mkdir, writeFile } from "node:fs/promises";
12+import path from "node:path";
13+import { fileURLToPath } from "node:url";
14+import { deflateRawSync } from "node:zlib";
15+
16+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
17+const EXTENSION_NAME = "diffinery";
18+
19+const xml = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20+
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;
24+ return { ...rest, name: EXTENSION_NAME };
25+}
26+
27+function vsixManifest(m) {
28+ const props = [
29+ ["Microsoft.VisualStudio.Code.Engine", m.engines.vscode],
30+ ["Microsoft.VisualStudio.Code.ExtensionDependencies", (m.extensionDependencies ?? []).join(",")],
31+ ["Microsoft.VisualStudio.Code.ExtensionPack", ""],
32+ ["Microsoft.VisualStudio.Code.ExtensionKind", (m.extensionKind ?? ["workspace"]).join(",")],
33+ ["Microsoft.VisualStudio.Code.LocalizedLanguages", ""],
34+ ["Microsoft.VisualStudio.Services.Links.Source", m.repository.url],
35+ ["Microsoft.VisualStudio.Services.Links.Getstarted", m.repository.url],
36+ ["Microsoft.VisualStudio.Services.Links.Support", m.bugs.url],
37+ ["Microsoft.VisualStudio.Services.Links.Learn", m.homepage],
38+ ["Microsoft.VisualStudio.Services.Branding.Color", m.galleryBanner.color],
39+ ["Microsoft.VisualStudio.Services.Branding.Theme", m.galleryBanner.theme],
40+ ["Microsoft.VisualStudio.Services.GitHubFlavoredMarkdown", "true"],
41+ ["Microsoft.VisualStudio.Services.Content.Pricing", "Free"],
42+ ];
43+ return `<?xml version="1.0" encoding="utf-8"?>
44+<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
45+ <Metadata>
46+ <Identity Language="en-US" Id="${xml(m.name)}" Version="${xml(m.version)}" Publisher="${xml(m.publisher)}" />
47+ <DisplayName>${xml(m.displayName)}</DisplayName>
48+ <Description xml:space="preserve">${xml(m.description)}</Description>
49+ <Tags>${xml((m.keywords ?? []).join(","))}</Tags>
50+ <Categories>${xml(m.categories.join(","))}</Categories>
51+ <GalleryFlags>Public</GalleryFlags>
52+ <Properties>
53+${props.map(([k, v]) => ` <Property Id="${k}" Value="${xml(v)}" />`).join("\n")}
54+ </Properties>
55+ <Icon>extension/${xml(m.icon)}</Icon>
56+ </Metadata>
57+ <Installation>
58+ <InstallationTarget Id="Microsoft.VisualStudio.Code"/>
59+ </Installation>
60+ <Dependencies/>
61+ <Assets>
62+ <Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true" />
63+ <Asset Type="Microsoft.VisualStudio.Services.Content.Details" Path="extension/readme.md" Addressable="true" />
64+ <Asset Type="Microsoft.VisualStudio.Services.Content.Changelog" Path="extension/changelog.md" Addressable="true" />
65+ <Asset Type="Microsoft.VisualStudio.Services.Icons.Default" Path="extension/${xml(m.icon)}" Addressable="true" />
66+ </Assets>
67+</PackageManifest>
68+`;
69+}
70+
71+function contentTypes(names) {
72+ const types = { ".json": "application/json", ".js": "application/javascript", ".md": "text/markdown", ".png": "image/png", ".woff": "font/woff", ".vsixmanifest": "text/xml" };
73+ const exts = [...new Set(names.map((n) => path.extname(n).toLowerCase()).filter((e) => e && types[e]))].sort();
74+ return `<?xml version="1.0" encoding="utf-8"?>
75+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">${exts.map((e) => `<Default Extension="${e}" ContentType="${types[e]}"/>`).join("")}</Types>
76+`;
77+}
78+
79+// ---------------------------------------------------------------- a small, deterministic zip writer
80+
81+const CRC_TABLE = (() => {
82+ const t = new Uint32Array(256);
83+ for (let n = 0; n < 256; n++) {
84+ let c = n;
85+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
86+ t[n] = c >>> 0;
87+ }
88+ return t;
89+})();
90+function crc32(buf) {
91+ let c = 0xffffffff;
92+ for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
93+ return (c ^ 0xffffffff) >>> 0;
94+}
95+// 2026-01-01 00:00 in MS-DOS date/time.
96+const DOS_TIME = 0;
97+const DOS_DATE = ((2026 - 1980) << 9) | (1 << 5) | 1;
98+
99+function zip(entries) {
100+ const locals = [];
101+ const central = [];
102+ let offset = 0;
103+ for (const { name, data } of entries) {
104+ const nameBuf = Buffer.from(name, "utf8");
105+ const raw = Buffer.from(data);
106+ const deflated = deflateRawSync(raw, { level: 9 });
107+ const stored = deflated.length >= raw.length;
108+ const body = stored ? raw : deflated;
109+ const crc = crc32(raw);
110+ const local = Buffer.alloc(30);
111+ local.writeUInt32LE(0x04034b50, 0);
112+ local.writeUInt16LE(20, 4);
113+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
114+ local.writeUInt16LE(stored ? 0 : 8, 8);
115+ local.writeUInt16LE(DOS_TIME, 10);
116+ local.writeUInt16LE(DOS_DATE, 12);
117+ local.writeUInt32LE(crc, 14);
118+ local.writeUInt32LE(body.length, 18);
119+ local.writeUInt32LE(raw.length, 22);
120+ local.writeUInt16LE(nameBuf.length, 26);
121+ local.writeUInt16LE(0, 28);
122+ locals.push(local, nameBuf, body);
123+ const cen = Buffer.alloc(46);
124+ cen.writeUInt32LE(0x02014b50, 0);
125+ cen.writeUInt16LE(20, 4);
126+ cen.writeUInt16LE(20, 6);
127+ cen.writeUInt16LE(0x0800, 8);
128+ cen.writeUInt16LE(stored ? 0 : 8, 10);
129+ cen.writeUInt16LE(DOS_TIME, 12);
130+ cen.writeUInt16LE(DOS_DATE, 14);
131+ cen.writeUInt32LE(crc, 16);
132+ cen.writeUInt32LE(body.length, 20);
133+ cen.writeUInt32LE(raw.length, 24);
134+ cen.writeUInt16LE(nameBuf.length, 28);
135+ cen.writeUInt32LE(offset, 42);
136+ central.push(cen, nameBuf);
137+ offset += 30 + nameBuf.length + body.length;
138+ }
139+ const cenSize = central.reduce((n, b) => n + b.length, 0);
140+ const end = Buffer.alloc(22);
141+ end.writeUInt32LE(0x06054b50, 0);
142+ end.writeUInt16LE(entries.length, 8);
143+ end.writeUInt16LE(entries.length, 10);
144+ end.writeUInt32LE(cenSize, 12);
145+ end.writeUInt32LE(offset, 16);
146+ return Buffer.concat([...locals, ...central, end]);
147+}
148+
149+/** Builds and packs the extension. Returns its version and the .vsix bytes. */
150+export async function packageExtension() {
151+ const pkg = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8"));
152+ const manifest = manifestFor(pkg);
153+ const bundle = await build({
154+ entryPoints: [path.join(ROOT, "src/extension.ts")],
155+ bundle: true,
156+ minify: true,
157+ platform: "node",
158+ target: "node18",
159+ format: "cjs",
160+ external: ["vscode"],
161+ legalComments: "none",
162+ write: false,
163+ logLevel: "error",
164+ });
165+ const files = [
166+ ["extension/package.json", JSON.stringify(manifest, null, 2)],
167+ ["extension/readme.md", await readFile(path.join(ROOT, "README.md"))],
168+ ["extension/changelog.md", await readFile(path.join(ROOT, "CHANGELOG.md"))],
169+ ["extension/dist/extension.js", bundle.outputFiles[0].contents],
170+ [`extension/${manifest.icon}`, await readFile(path.join(ROOT, manifest.icon))],
171+ ["extension/media/diffinery-icons.woff", await readFile(path.join(ROOT, "media/diffinery-icons.woff"))],
172+ ];
173+ const entries = [
174+ { name: "extension.vsixmanifest", data: vsixManifest(manifest) },
175+ { name: "[Content_Types].xml", data: contentTypes(["extension.vsixmanifest", ...files.map(([n]) => n)]) },
176+ ...files.map(([name, data]) => ({ name, data })),
177+ ];
178+ return { version: manifest.version, vsix: zip(entries) };
179+}
180+
181+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
182+ const i = process.argv.indexOf("--out");
183+ const out = i > 0 ? path.resolve(process.argv[i + 1]) : path.join(ROOT, "dist");
184+ const { version, vsix } = await packageExtension();
185+ await mkdir(out, { recursive: true });
186+ const file = path.join(out, `${EXTENSION_NAME}-${version}.vsix`);
187+ await writeFile(file, vsix);
188+ console.log(`vscode: ${path.relative(process.cwd(), file)} (${(vsix.length / 1024).toFixed(0)} KB)`);
189+}
addedapps/vscode/src/api.ts+96−0View file
@@ -1,0 +1,96 @@
1+import { nextLink, type RepoJson, type RepoRef } from "./util";
2+
3+/** An HTTP error from the Diffinery API, with the server's own message when it sent one. */
4+export class ApiError extends Error {
5+ constructor(
6+ readonly status: number,
7+ message: string,
8+ ) {
9+ super(message);
10+ }
11+}
12+
13+export interface UserJson {
14+ login: string;
15+ id: number;
16+ type: "User" | "Bot" | "Organization";
17+ name?: string | null;
18+}
19+
20+export interface OrgJson {
21+ login: string;
22+ role?: string;
23+}
24+
25+const UA = "Diffinery-VSCode";
26+
27+/** The REST API (GitHub-compatible, at /api/v3) with one token. */
28+export class Api {
29+ constructor(
30+ readonly server: string,
31+ private readonly token: string,
32+ private readonly version: string,
33+ ) {}
34+
35+ private async request(method: string, url: string, body?: unknown): Promise<Response> {
36+ const res = await fetch(url.startsWith("http") ? url : `${this.server}/api/v3${url}`, {
37+ method,
38+ headers: {
39+ authorization: `Bearer ${this.token}`,
40+ accept: "application/json",
41+ "user-agent": `${UA}/${this.version}`,
42+ ...(body === undefined ? {} : { "content-type": "application/json" }),
43+ },
44+ body: body === undefined ? undefined : JSON.stringify(body),
45+ signal: AbortSignal.timeout(30_000),
46+ });
47+ if (!res.ok) {
48+ let msg = `${res.status} ${res.statusText}`;
49+ try {
50+ const j = (await res.json()) as { message?: string; error?: string };
51+ msg = j.message || j.error || msg;
52+ } catch {
53+ // not JSON
54+ }
55+ throw new ApiError(res.status, res.status === 401 ? `Diffinery didn't accept your sign-in (${msg}). Sign in again.` : msg);
56+ }
57+ return res;
58+ }
59+
60+ async get<T>(path: string): Promise<T> {
61+ return (await (await this.request("GET", path)).json()) as T;
62+ }
63+
64+ async post<T>(path: string, body: unknown): Promise<T> {
65+ return (await (await this.request("POST", path, body)).json()) as T;
66+ }
67+
68+ /** Every page of a list, following Link headers, up to `max` items. */
69+ async all<T>(path: string, max = 500): Promise<T[]> {
70+ const out: T[] = [];
71+ let url: string | null = path;
72+ while (url && out.length < max) {
73+ const res: Response = await this.request("GET", url);
74+ out.push(...((await res.json()) as T[]));
75+ url = nextLink(res.headers.get("link"));
76+ }
77+ return out.slice(0, max);
78+ }
79+
80+ user = () => this.get<UserJson>("/user");
81+ orgs = () => this.get<OrgJson[]>("/user/orgs");
82+ /** The repositories the account can read: its own, its organizations', and ones shared with it, most recently pushed first. */
83+ myRepos = () => this.all<RepoJson>("/user/repos?per_page=100");
84+ repo = (r: RepoRef) => this.get<RepoJson>(`/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}`);
85+ async search(query: string): Promise<RepoJson[]> {
86+ const q = new URLSearchParams({ q: query, per_page: "30", sort: "updated" });
87+ return (await this.get<{ items: RepoJson[] }>(`/search/repositories?${q}`)).items;
88+ }
89+ async branches(r: RepoRef): Promise<string[]> {
90+ const list = await this.all<{ name: string }>(`/repos/${encodeURIComponent(r.owner)}/${encodeURIComponent(r.repo)}/branches?per_page=100`, 1000);
91+ return list.map((b) => b.name);
92+ }
93+ createRepo(owner: string | null, body: { name: string; private: boolean; description?: string }) {
94+ return this.post<RepoJson>(owner ? `/orgs/${encodeURIComponent(owner)}/repos` : "/user/repos", body);
95+ }
96+}
addedapps/vscode/src/auth.ts+371−0View file
@@ -1,0 +1,371 @@
1+import * as http from "node:http";
2+import { randomUUID } from "node:crypto";
3+import * as vscode from "vscode";
4+import { Api } from "./api";
5+import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "./util";
6+
7+export const AUTH_ID = "diffinery";
8+/** What the extension asks for: push and pull, issues and pull requests. The person can lower it on the consent page. */
9+export const SCOPES = ["write"];
10+const SIGN_IN_TIMEOUT_MS = 10 * 60_000;
11+/** Refresh this long before the access token expires (they last 8 hours). */
12+const REFRESH_MARGIN_MS = 10 * 60_000;
13+
14+/** A signed-in account as kept in the editor's secret storage. */
15+export interface Stored {
16+ id: string;
17+ server: string;
18+ login: string;
19+ accountId: string;
20+ /** "oauth": approved in the browser, renews itself. "token": an access token someone pasted. */
21+ kind: "oauth" | "token";
22+ accessToken: string;
23+ refreshToken?: string;
24+ /** Epoch ms. */
25+ expiresAt?: number;
26+}
27+
28+interface TokenResponse {
29+ access_token: string;
30+ refresh_token?: string;
31+ expires_in?: number;
32+ error?: string;
33+ error_description?: string;
34+}
35+
36+export class SignInError extends Error {}
37+
38+/**
39+ * Diffinery as an account provider in the editor (the Accounts menu), signed in through Diffinery's OAuth
40+ * (the browser asks the person to approve, with their passkey) or with a pasted access token. Tokens from
41+ * OAuth last 8 hours and are renewed with the refresh token before they run out, so Git never sees an
42+ * expired one while the editor is open.
43+ */
44+export class DiffineryAuth implements vscode.AuthenticationProvider, vscode.Disposable {
45+ private readonly changes = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
46+ readonly onDidChangeSessions = this.changes.event;
47+ /** Fired in the window that changed the stored account (sign-in, renewal, sign-out), for Git's credential store. */
48+ private readonly stored = new vscode.EventEmitter<{ before?: Stored; after?: Stored }>();
49+ readonly onDidStore = this.stored.event;
50+
51+ private readonly callbacks = new Map<string, (query: string) => void>();
52+ private refreshing: Promise<Stored | undefined> | undefined;
53+ private timer: NodeJS.Timeout | undefined;
54+ private last: Stored | undefined;
55+ private readonly disposables: vscode.Disposable[] = [];
56+
57+ constructor(
58+ private readonly ctx: vscode.ExtensionContext,
59+ private readonly log: vscode.LogOutputChannel,
60+ private readonly version: string,
61+ ) {
62+ // Another window signed in, renewed or signed out: the secret storage is shared, so follow it.
63+ this.disposables.push(
64+ ctx.secrets.onDidChange(async (e) => {
65+ if (e.key === this.key()) await this.syncFromStorage();
66+ }),
67+ vscode.workspace.onDidChangeConfiguration(async (e) => {
68+ if (e.affectsConfiguration("diffinery.serverUrl")) await this.syncFromStorage();
69+ }),
70+ );
71+ void this.syncFromStorage();
72+ }
73+
74+ dispose(): void {
75+ if (this.timer) clearTimeout(this.timer);
76+ this.disposables.forEach((d) => d.dispose());
77+ this.changes.dispose();
78+ this.stored.dispose();
79+ }
80+
81+ server(): string {
82+ return normalizeServer(vscode.workspace.getConfiguration("diffinery").get<string>("serverUrl"));
83+ }
84+
85+ private key(): string {
86+ let server: string;
87+ try {
88+ server = this.server();
89+ } catch {
90+ server = "invalid";
91+ }
92+ return `diffinery.account:${server}`;
93+ }
94+
95+ private async load(): Promise<Stored | undefined> {
96+ const raw = await this.ctx.secrets.get(this.key());
97+ if (!raw) return undefined;
98+ try {
99+ const s = JSON.parse(raw) as Stored;
100+ return s.server === this.server() && s.accessToken ? s : undefined;
101+ } catch {
102+ return undefined;
103+ }
104+ }
105+
106+ private async save(s: Stored | undefined): Promise<void> {
107+ const before = this.last;
108+ if (s) await this.ctx.secrets.store(this.key(), JSON.stringify(s));
109+ else await this.ctx.secrets.delete(this.key());
110+ this.announce(s);
111+ this.stored.fire({ before, after: s });
112+ }
113+
114+ /** Tells the editor what changed since it last heard, and plans the next renewal. */
115+ private announce(s: Stored | undefined): void {
116+ const before = this.last;
117+ this.last = s;
118+ this.schedule(s);
119+ if (!before && s) this.changes.fire({ added: [toSession(s)], removed: [], changed: [] });
120+ else if (before && !s) this.changes.fire({ added: [], removed: [toSession(before)], changed: [] });
121+ else if (before && s && before.id !== s.id) this.changes.fire({ added: [toSession(s)], removed: [toSession(before)], changed: [] });
122+ else if (before && s && before.accessToken !== s.accessToken) this.changes.fire({ added: [], removed: [], changed: [toSession(s)] });
123+ }
124+
125+ private async syncFromStorage(): Promise<void> {
126+ try {
127+ this.announce(await this.load());
128+ } catch (e) {
129+ this.log.error("reading the stored Diffinery account failed", e);
130+ }
131+ }
132+
133+ private schedule(s: Stored | undefined): void {
134+ if (this.timer) clearTimeout(this.timer);
135+ this.timer = undefined;
136+ if (!s || s.kind !== "oauth" || !s.expiresAt) return;
137+ // A little jitter, so several windows don't all renew at the same moment.
138+ const wait = Math.max(5_000, s.expiresAt - REFRESH_MARGIN_MS - Date.now() + Math.random() * 60_000);
139+ this.timer = setTimeout(() => void this.fresh().catch((e) => this.log.warn("renewing the Diffinery sign-in failed", e)), Math.min(wait, 2 ** 31 - 1));
140+ }
141+
142+ /** The stored account, renewed first when its token is about to expire. Undefined when signed out. */
143+ async fresh(): Promise<Stored | undefined> {
144+ const s = await this.load();
145+ if (!s || s.kind !== "oauth" || !s.expiresAt || s.expiresAt - Date.now() > REFRESH_MARGIN_MS) return s;
146+ this.refreshing ??= this.refresh(s).finally(() => (this.refreshing = undefined));
147+ return this.refreshing;
148+ }
149+
150+ private async refresh(s: Stored): Promise<Stored | undefined> {
151+ if (!s.refreshToken) return s;
152+ let res: TokenResponse;
153+ try {
154+ res = await tokenRequest(s.server, { grant_type: "refresh_token", refresh_token: s.refreshToken, client_id: clientId(s.server) });
155+ } catch (e) {
156+ if (!(e instanceof SignInError)) {
157+ // Offline or the server is unreachable: keep the account; Git gets the old token while it lasts.
158+ this.log.warn("could not reach Diffinery to renew the sign-in", e);
159+ return s;
160+ }
161+ // Another window may have renewed with the same refresh token a moment earlier: use what it stored.
162+ await new Promise((r) => setTimeout(r, 1500));
163+ const now = await this.load();
164+ if (now && now.refreshToken !== s.refreshToken) return now;
165+ this.log.warn("the Diffinery sign-in ended", e.message);
166+ await this.save(undefined);
167+ void vscode.window.showWarningMessage("Your Diffinery sign-in has ended (it was disconnected, or unused for 30 days). Sign in again to keep cloning and pushing.", "Sign In").then((pick) => {
168+ if (pick) void vscode.commands.executeCommand("diffinery.signIn");
169+ });
170+ return undefined;
171+ }
172+ const next: Stored = { ...s, accessToken: res.access_token, refreshToken: res.refresh_token ?? s.refreshToken, expiresAt: Date.now() + (res.expires_in ?? 8 * 3600) * 1000 };
173+ await this.save(next);
174+ this.log.info(`renewed the Diffinery sign-in for ${s.login}`);
175+ return next;
176+ }
177+
178+ // ------------------------------------------------------------------ AuthenticationProvider
179+
180+ async getSessions(): Promise<vscode.AuthenticationSession[]> {
181+ const s = await this.fresh();
182+ if (s !== this.last && (s?.accessToken !== this.last?.accessToken || s?.id !== this.last?.id)) this.announce(s);
183+ return s ? [toSession(s)] : [];
184+ }
185+
186+ async createSession(): Promise<vscode.AuthenticationSession> {
187+ const s = await this.signInWithBrowser();
188+ await this.save(s);
189+ void vscode.window.showInformationMessage(`Signed in to Diffinery as ${s.login}.`);
190+ return toSession(s);
191+ }
192+
193+ async removeSession(id: string): Promise<void> {
194+ const s = await this.load();
195+ if (!s || s.id !== id) return;
196+ await this.save(undefined);
197+ // Ends the connection on the server too (Settings → Access tokens → Connected apps). A pasted token is the
198+ // person's own and may be used elsewhere, so it's only forgotten here.
199+ if (s.kind === "oauth" && s.refreshToken) {
200+ await fetch(`${s.server}/oauth/revoke`, {
201+ method: "POST",
202+ headers: { "content-type": "application/x-www-form-urlencoded", "user-agent": `Diffinery-VSCode/${this.version}` },
203+ body: new URLSearchParams({ token: s.refreshToken, client_id: clientId(s.server) }),
204+ signal: AbortSignal.timeout(10_000),
205+ }).catch((e) => this.log.warn("revoking the Diffinery connection failed", e));
206+ }
207+ }
208+
209+ // ------------------------------------------------------------------ signing in
210+
211+ /** The editor's URL handler: /oauth is where the browser comes back after approving. */
212+ handleUri(uri: vscode.Uri): boolean {
213+ if (uri.path !== "/oauth") return false;
214+ const state = new URLSearchParams(uri.query).get("state") ?? "";
215+ const done = this.callbacks.get(state);
216+ if (!done) {
217+ void vscode.window.showWarningMessage("This Diffinery sign-in link is from an earlier attempt. Sign in again from the editor.");
218+ return true;
219+ }
220+ done(uri.query);
221+ return true;
222+ }
223+
224+ private async signInWithBrowser(): Promise<Stored> {
225+ if (vscode.env.uiKind === vscode.UIKind.Web) throw new SignInError("Signing in through the browser needs the desktop editor. In a browser-based editor, run “Diffinery: Sign In with an Access Token”.");
226+ const server = this.server();
227+ const { verifier, challenge, state } = pkce();
228+ // The browser comes back to a one-time listener on this computer (RFC 8252): no prompt to open the editor, and
229+ // no "Allow the extension to open this URI?" dialog. Where that can't work (a browser-based remote), it comes
230+ // back through the editor's own URL scheme instead.
231+ let redirectUri: string | null = null;
232+ let wait: Promise<string> | undefined;
233+ const loopback = await listenOnce(state).catch((e) => {
234+ this.log.warn("couldn't listen on 127.0.0.1 for the sign-in", e);
235+ return undefined;
236+ });
237+ if (loopback) {
238+ const external = new URL((await vscode.env.asExternalUri(vscode.Uri.parse(loopback.uri))).toString(true));
239+ if (external.protocol === "http:" && /^(127\.0\.0\.1|localhost)$/.test(external.hostname)) {
240+ redirectUri = `http://127.0.0.1:${external.port || loopback.port}/callback`;
241+ wait = loopback.result;
242+ } else {
243+ loopback.close();
244+ }
245+ }
246+ if (!redirectUri) {
247+ redirectUri = editorRedirect(vscode.env.uriScheme);
248+ if (!redirectUri) throw new SignInError("This editor can't receive Diffinery's sign-in. Run “Diffinery: Sign In with an Access Token” instead.");
249+ wait = new Promise((resolve) => this.callbacks.set(state, resolve));
250+ }
251+ try {
252+ const url = authorizeUrl(server, redirectUri, challenge, state);
253+ this.log.info(`opening ${server}/oauth/authorize for the editor's sign-in (redirect ${redirectUri})`);
254+ const opened = await vscode.env.openExternal(vscode.Uri.parse(url, true));
255+ const query = await vscode.window.withProgress(
256+ { location: vscode.ProgressLocation.Notification, title: "Signing in to Diffinery", cancellable: true },
257+ async (progress, cancel) => {
258+ progress.report({ message: opened ? "Approve the connection in your browser…" : `Open ${url} in your browser and approve the connection…` });
259+ return Promise.race([
260+ wait!,
261+ new Promise<never>((_, reject) => cancel.onCancellationRequested(() => reject(new vscode.CancellationError()))),
262+ new Promise<never>((_, reject) => setTimeout(() => reject(new SignInError("Signing in to Diffinery timed out. Try again.")), SIGN_IN_TIMEOUT_MS)),
263+ ]);
264+ },
265+ );
266+ const r = readCallback(query, { state, server });
267+ if ("error" in r) throw new SignInError(r.error);
268+ const tok = await tokenRequest(server, { grant_type: "authorization_code", code: r.code, code_verifier: verifier, client_id: clientId(server), redirect_uri: redirectUri });
269+ const user = await new Api(server, tok.access_token, this.version).user();
270+ return {
271+ id: randomUUID(),
272+ server,
273+ login: user.login,
274+ accountId: String(user.id),
275+ kind: "oauth",
276+ accessToken: tok.access_token,
277+ refreshToken: tok.refresh_token,
278+ expiresAt: Date.now() + (tok.expires_in ?? 8 * 3600) * 1000,
279+ };
280+ } finally {
281+ this.callbacks.delete(state);
282+ loopback?.close();
283+ }
284+ }
285+
286+ /** Signs in with an access token someone pastes (for an agent account, a self-hosted server, or a browser-based editor). */
287+ async signInWithToken(): Promise<void> {
288+ const server = this.server();
289+ const token = await vscode.window.showInputBox({
290+ title: "Sign in to Diffinery with an access token",
291+ prompt: `Paste a token from ${server}/settings/tokens (write access lets you push). It stays in the editor's secret storage.`,
292+ password: true,
293+ ignoreFocusOut: true,
294+ validateInput: (v) => (/^\s*\S+\s*$/.test(v) ? null : "Paste the whole token, without spaces."),
295+ });
296+ if (!token) return;
297+ const user = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Checking the token with Diffinery…" }, () => new Api(server, token.trim(), this.version).user());
298+ await this.save({ id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token.trim() });
299+ void vscode.window.showInformationMessage(`Signed in to Diffinery as ${user.login}.`);
300+ }
301+
302+ async signOut(): Promise<void> {
303+ const s = await this.load();
304+ if (!s) {
305+ void vscode.window.showInformationMessage("You aren't signed in to Diffinery.");
306+ return;
307+ }
308+ await this.removeSession(s.id);
309+ void vscode.window.showInformationMessage(`Signed out of Diffinery (${s.login}).`);
310+ }
311+
312+ /** An API client for the signed-in account, asking the person to sign in when `ask` and nobody is. */
313+ async api(ask: boolean): Promise<Api | undefined> {
314+ const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, ask ? { createIfNone: true } : { silent: true });
315+ return session ? new Api(this.server(), session.accessToken, this.version) : undefined;
316+ }
317+}
318+
319+const toSession = (s: Stored): vscode.AuthenticationSession => ({ id: s.id, accessToken: s.accessToken, account: { id: s.accountId, label: s.login }, scopes: SCOPES });
320+
321+/** POSTs to the token endpoint. SignInError when the server refuses (the grant is gone); other errors mean it wasn't reached. */
322+async function tokenRequest(server: string, form: Record<string, string>): Promise<TokenResponse> {
323+ const res = await fetch(`${server}/oauth/token`, {
324+ method: "POST",
325+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
326+ body: new URLSearchParams(form),
327+ signal: AbortSignal.timeout(30_000),
328+ });
329+ let body: TokenResponse;
330+ try {
331+ body = (await res.json()) as TokenResponse;
332+ } catch {
333+ throw new Error(`Diffinery's token endpoint answered ${res.status}`);
334+ }
335+ if (res.status >= 500) throw new Error(`Diffinery's token endpoint answered ${res.status}`);
336+ if (!res.ok || !body.access_token) throw new SignInError(body.error_description || body.error || `Diffinery refused the sign-in (${res.status}).`);
337+ return body;
338+}
339+
340+interface Loopback {
341+ uri: string;
342+ port: number;
343+ result: Promise<string>;
344+ close(): void;
345+}
346+
347+/** A listener on 127.0.0.1 that takes one browser callback for this sign-in and says it can be closed. */
348+function listenOnce(state: string): Promise<Loopback> {
349+ return new Promise((resolve, reject) => {
350+ let done: (q: string) => void;
351+ const result = new Promise<string>((r) => (done = r));
352+ const server = http.createServer((req, res) => {
353+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
354+ if (u.pathname !== "/callback" || u.searchParams.get("state") !== state) {
355+ res.writeHead(404).end();
356+ return;
357+ }
358+ const ok = !u.searchParams.has("error");
359+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
360+ res.end(
361+ `<!doctype html><meta charset="utf-8"><title>Diffinery</title><body style="font:16px system-ui,sans-serif;margin:15vh auto;max-width:28rem;text-align:center"><h1 style="font-size:20px">${ok ? "You're signed in to Diffinery" : "Sign-in cancelled"}</h1><p>You can close this tab and go back to your editor.</p></body>`,
362+ );
363+ done(u.search.slice(1));
364+ });
365+ server.on("error", reject);
366+ server.listen(0, "127.0.0.1", () => {
367+ const port = (server.address() as { port: number }).port;
368+ resolve({ uri: `http://127.0.0.1:${port}/callback`, port, result, close: () => server.close() });
369+ });
370+ });
371+}
addedapps/vscode/src/extension.ts+92−0View file
@@ -1,0 +1,92 @@
1+import * as vscode from "vscode";
2+import { AUTH_ID, DiffineryAuth, SCOPES } from "./auth";
3+import { GitCredentials, Publisher, syncCredentialStore } from "./git";
4+import { RemoteSources } from "./remoteSources";
5+import type { GitAPI, GitBaseAPI, GitBaseExtension, GitExtension } from "./typings/git";
6+import { checkForUpdates } from "./updates";
7+
8+export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
9+ const version = String(ctx.extension.packageJSON.version ?? "0.0.0");
10+ const log = vscode.window.createOutputChannel("Diffinery", { log: true });
11+ const auth = new DiffineryAuth(ctx, log, version);
12+ ctx.subscriptions.push(
13+ log,
14+ auth,
15+ vscode.authentication.registerAuthenticationProvider(AUTH_ID, "Diffinery", auth, { supportsMultipleAccounts: false }),
16+ vscode.window.registerUriHandler({ handleUri: (uri) => void auth.handleUri(uri) }),
17+ );
18+
19+ const report = (what: string) => (e: unknown) => {
20+ if (e instanceof vscode.CancellationError) return;
21+ log.error(what, e);
22+ void vscode.window.showErrorMessage(`${what}: ${e instanceof Error ? e.message : String(e)}`);
23+ };
24+ const command = (id: string, run: () => Promise<unknown>, what: string) => ctx.subscriptions.push(vscode.commands.registerCommand(id, () => run().catch(report(what))));
25+
26+ command("diffinery.signIn", async () => vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true }), "Signing in to Diffinery failed");
27+ command("diffinery.signInWithToken", () => auth.signInWithToken(), "Signing in to Diffinery failed");
28+ command("diffinery.signOut", () => auth.signOut(), "Signing out of Diffinery failed");
29+ command("diffinery.checkForUpdates", () => checkForUpdates(ctx, auth.server(), version, log, true), "Checking for updates failed");
30+
31+ // ---------------------------------------------------------------- the built-in Git extensions
32+ let gitBase: GitBaseAPI | undefined;
33+ let git: GitAPI | undefined;
34+ const remoteSources = new RemoteSources(auth);
35+ const gitDisposables: vscode.Disposable[] = [];
36+ ctx.subscriptions.push({ dispose: () => gitDisposables.forEach((d) => d.dispose()) });
37+ let credentials: vscode.Disposable | undefined;
38+ const updateCredentials = () => {
39+ const on = !!git && vscode.workspace.getConfiguration("diffinery").get<boolean>("gitAuthentication", true);
40+ if (on && !credentials) credentials = git!.registerCredentialsProvider(new GitCredentials(auth));
41+ if (!on && credentials) {
42+ credentials.dispose();
43+ credentials = undefined;
44+ }
45+ };
46+ ctx.subscriptions.push(
47+ { dispose: () => credentials?.dispose() },
48+ vscode.workspace.onDidChangeConfiguration((e) => e.affectsConfiguration("diffinery.gitAuthentication") && updateCredentials()),
49+ syncCredentialStore(auth, () => git, log),
50+ );
51+
52+ const baseExt = vscode.extensions.getExtension<GitBaseExtension>("vscode.git-base");
53+ const gitExt = vscode.extensions.getExtension<GitExtension>("vscode.git");
54+ const connect = async () => {
55+ try {
56+ const base = baseExt && (baseExt.isActive ? baseExt.exports : await baseExt.activate());
57+ if (base?.enabled && !gitBase) {
58+ gitBase = base.getAPI(1);
59+ gitDisposables.push(gitBase.registerRemoteSourceProvider(remoteSources));
60+ }
61+ const g = gitExt && (gitExt.isActive ? gitExt.exports : await gitExt.activate());
62+ if (g?.enabled && !git) {
63+ git = g.getAPI(1);
64+ gitDisposables.push(git.registerRemoteSourcePublisher(new Publisher(auth)));
65+ updateCredentials();
66+ }
67+ return { base, g };
68+ } catch (e) {
69+ log.error("connecting to the built-in Git extension failed", e);
70+ return {};
71+ }
72+ };
73+ const { base, g } = await connect();
74+ if (base && !base.enabled) ctx.subscriptions.push(base.onDidChangeEnablement((on) => on && void connect()));
75+ if (g && !g.enabled) ctx.subscriptions.push(g.onDidChangeEnablement((on) => on && void connect()));
76+ if (!baseExt || !gitExt) log.warn("The editor's built-in Git extension isn't available, so Diffinery can't add itself to the clone picker.");
77+
78+ command(
79+ "diffinery.clone",
80+ async () => {
81+ if (!gitBase) throw new Error("The editor's Git support is turned off (git.enabled). Turn it on to clone.");
82+ const picked = await gitBase.pickRemoteSource({ providerName: remoteSources.name, title: "Clone from Diffinery", urlLabel: "Clone from URL" });
83+ const url = typeof picked === "string" ? picked : picked?.url;
84+ if (url) await vscode.commands.executeCommand("git.clone", url);
85+ },
86+ "Cloning from Diffinery failed",
87+ );
88+
89+ void checkForUpdates(ctx, auth.server(), version, log, false).catch((e) => log.warn("update check failed", e));
90+}
91+
92+export function deactivate(): void {}
addedapps/vscode/src/git.ts+110−0View file
@@ -1,0 +1,110 @@
1+import { spawn } from "node:child_process";
2+import * as path from "node:path";
3+import * as vscode from "vscode";
4+import { AUTH_ID, SCOPES, type DiffineryAuth, type Stored } from "./auth";
5+import type { Credentials, CredentialsProvider, GitAPI, RemoteSourcePublisher, Repository } from "./typings/git";
6+import { credentialInput, repoNameFrom } from "./util";
7+
8+/** Answers Git's password prompts for the Diffinery server with the signed-in account (VS Code's Git askpass). */
9+export class GitCredentials implements CredentialsProvider {
10+ constructor(private readonly auth: DiffineryAuth) {}
11+
12+ async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> {
13+ let server: URL;
14+ try {
15+ server = new URL(this.auth.server());
16+ } catch {
17+ return undefined;
18+ }
19+ if (host.authority.toLowerCase() !== server.host.toLowerCase()) return undefined;
20+ const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true });
21+ return { username: session.account.label, password: session.accessToken };
22+ }
23+}
24+
25+/**
26+ * Keeps the token Git saved for the Diffinery server current. After Git uses a password from the editor it saves
27+ * it with the computer's credential helper (the macOS keychain, Git Credential Manager on Windows, libsecret), and
28+ * asks that helper first next time. Signed in through the browser, the token changes every few hours, so each
29+ * renewal replaces the saved one (with its expiry, for helpers that honour it), and signing out removes it.
30+ */
31+export function syncCredentialStore(auth: DiffineryAuth, git: () => GitAPI | undefined, log: vscode.LogOutputChannel): vscode.Disposable {
32+ return auth.onDidStore(async ({ before, after }) => {
33+ if (!vscode.workspace.getConfiguration("diffinery").get<boolean>("gitAuthentication", true)) return;
34+ const gitPath = git()?.git.path;
35+ if (!gitPath) return;
36+ try {
37+ if (before && before.accessToken !== after?.accessToken) await credential(gitPath, "reject", before);
38+ if (after && after.accessToken !== before?.accessToken) await credential(gitPath, "approve", after);
39+ } catch (e) {
40+ log.warn("updating Git's saved Diffinery credential failed", e);
41+ }
42+ });
43+}
44+
45+function credential(gitPath: string, op: "approve" | "reject", s: Stored): Promise<void> {
46+ return new Promise((resolve, reject) => {
47+ const child = spawn(gitPath, ["credential", op], {
48+ // Never prompt: approve and reject only talk to the configured helpers.
49+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "", GCM_INTERACTIVE: "never" },
50+ stdio: ["pipe", "ignore", "pipe"],
51+ timeout: 15_000,
52+ windowsHide: true,
53+ });
54+ let err = "";
55+ child.stderr.on("data", (d) => (err += d));
56+ child.on("error", reject);
57+ child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`git credential ${op} exited ${code}: ${err.trim()}`))));
58+ child.stdin.end(credentialInput(s.server, s.login, s.accessToken, s.kind === "oauth" ? s.expiresAt : undefined));
59+ });
60+}
61+
62+/** "Publish to Diffinery" when publishing a repository that has no remote yet. */
63+export class Publisher implements RemoteSourcePublisher {
64+ readonly name = "Diffinery";
65+ readonly icon = "diffinery-logo";
66+
67+ constructor(private readonly auth: DiffineryAuth) {}
68+
69+ async publishRepository(repository: Repository): Promise<void> {
70+ const api = await this.auth.api(true);
71+ if (!api) return;
72+ const head = repository.state.HEAD;
73+ if (!head?.commit) {
74+ void vscode.window.showWarningMessage("Commit your changes first, then publish the repository to Diffinery.");
75+ return;
76+ }
77+ const [me, orgs] = await Promise.all([api.user(), api.orgs().catch(() => [])]);
78+ const owners = [
79+ { label: me.login, description: "your account", owner: null as string | null },
80+ ...orgs.map((o) => ({ label: o.login, description: "organization", owner: o.login as string | null })),
81+ ];
82+ const owner = owners.length === 1 ? owners[0] : await vscode.window.showQuickPick(owners, { title: "Publish to Diffinery", placeHolder: "Owner of the new repository" });
83+ if (!owner) return;
84+ const name = await vscode.window.showInputBox({
85+ title: "Publish to Diffinery",
86+ prompt: `Repository name (it will be ${owner.label}/<name>)`,
87+ value: repoNameFrom(path.basename(repository.rootUri.fsPath)),
88+ validateInput: (v) => (/^[A-Za-z0-9._-]{1,100}$/.test(v) && v !== "." && v !== ".." ? null : "Use letters, digits, '.', '-' or '_'."),
89+ });
90+ if (!name) return;
91+ const vis = await vscode.window.showQuickPick(
92+ [
93+ { label: "$(lock) Private", description: "Only people and teams you give access can see it.", private: true },
94+ { label: "$(globe) Public", description: "Anyone on the internet can see and clone it.", private: false },
95+ ],
96+ { title: "Publish to Diffinery", placeHolder: `Who can see ${owner.label}/${name}?` },
97+ );
98+ if (!vis) return;
99+ const branch = head.name;
100+ const created = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Publishing to Diffinery as ${owner.label}/${name}…` }, async () => {
101+ const repo = await api.createRepo(owner.owner, { name, private: vis.private });
102+ const remote = repository.state.remotes.some((r) => r.name === "origin") ? "diffinery" : "origin";
103+ await repository.addRemote(remote, repo.clone_url);
104+ if (branch) await repository.push(remote, branch, true);
105+ return repo;
106+ });
107+ const open = await vscode.window.showInformationMessage(`Published ${created.full_name} to Diffinery.`, "Open on Diffinery");
108+ if (open) void vscode.env.openExternal(vscode.Uri.parse(created.html_url));
109+ }
110+}
addedapps/vscode/src/remoteSources.ts+72−0View file
@@ -1,0 +1,72 @@
1+import * as vscode from "vscode";
2+import type { DiffineryAuth } from "./auth";
3+import type { RemoteSource, RemoteSourceAction, RemoteSourceProvider } from "./typings/git";
4+import { dedupe, matches, repoRef, type RepoJson } from "./util";
5+
6+/** How long the list of your repositories is reused while you type in the picker. */
7+const MINE_TTL_MS = 60_000;
8+
9+/**
10+ * "Clone from Diffinery" in the editor's clone picker (and "Add remote from Diffinery"): your repositories and
11+ * your organizations', most recently pushed first, and as you type, any repository you can read that matches.
12+ */
13+export class RemoteSources implements RemoteSourceProvider {
14+ readonly name = "Diffinery";
15+ readonly icon = "diffinery-logo";
16+ readonly supportsQuery = true;
17+ readonly placeholder = "Repository name or owner/name (type to search)";
18+
19+ private mine: RepoJson[] = [];
20+ private mineAt = 0;
21+
22+ constructor(private readonly auth: DiffineryAuth) {}
23+
24+ async getRemoteSources(query?: string): Promise<RemoteSource[]> {
25+ const api = await this.auth.api(true);
26+ if (!api) return [];
27+ const q = (query ?? "").trim();
28+ if (!q || Date.now() - this.mineAt > MINE_TTL_MS) {
29+ this.mine = await api.myRepos();
30+ this.mineAt = Date.now();
31+ }
32+ if (!q) return this.mine.map(source);
33+ const ref = repoRef(api.server, q);
34+ const [direct, found] = await Promise.all([ref ? api.repo(ref).then((r) => [r], () => []) : Promise.resolve([]), api.search(q).catch(() => [] as RepoJson[])]);
35+ return dedupe(direct, this.mine.filter((r) => matches(r, q)), found).map(source);
36+ }
37+
38+ async getBranches(url: string): Promise<string[]> {
39+ const api = await this.auth.api(true);
40+ const ref = api && repoRef(api.server, url);
41+ if (!api || !ref) return [];
42+ const [branches, repo] = await Promise.all([api.branches(ref), api.repo(ref)]);
43+ const main = repo.default_branch;
44+ return branches.sort((a, b) => (a === main ? -1 : b === main ? 1 : 0));
45+ }
46+
47+ getRemoteSourceActions(url: string): RemoteSourceAction[] {
48+ let server: string;
49+ try {
50+ server = this.auth.server();
51+ } catch {
52+ return [];
53+ }
54+ const ref = repoRef(server, url);
55+ if (!ref || !/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) return [];
56+ return [
57+ {
58+ label: "Open on Diffinery",
59+ icon: "link-external",
60+ run: (branch: string) => {
61+ const base = `${server}/${ref.owner}/${ref.repo}`;
62+ void vscode.env.openExternal(vscode.Uri.parse(branch ? `${base}/tree/${branch.split("/").map(encodeURIComponent).join("/")}` : base));
63+ },
64+ },
65+ ];
66+ }
67+}
68+
69+function source(r: RepoJson): RemoteSource {
70+ const tags = [r.private ? "Private" : "Public", r.archived ? "archived" : "", r.fork ? "fork" : ""].filter(Boolean);
71+ return { name: r.full_name, icon: "diffinery-logo", description: tags.join(" · "), detail: r.description || undefined, url: r.clone_url };
72+}
addedapps/vscode/src/typings/git.ts+106−0View file
@@ -1,0 +1,106 @@
1+// The parts of VS Code's built-in Git extensions' APIs this extension uses (vscode.git and vscode.git-base,
2+// version 1). Declared here rather than copied whole: only these members are relied on.
3+import type { Disposable, Event, ProviderResult, Uri } from "vscode";
4+
5+export interface RemoteSource {
6+ readonly name: string;
7+ readonly description?: string;
8+ readonly detail?: string;
9+ /** Codicon name. */
10+ readonly icon?: string;
11+ readonly url: string | string[];
12+}
13+
14+export interface RemoteSourceAction {
15+ readonly label: string;
16+ /** Codicon name. */
17+ readonly icon: string;
18+ run(branch: string): void;
19+}
20+
21+export interface RemoteSourceProvider {
22+ readonly name: string;
23+ /** Codicon name. */
24+ readonly icon?: string;
25+ readonly label?: string;
26+ readonly placeholder?: string;
27+ readonly supportsQuery?: boolean;
28+ getBranches?(url: string): ProviderResult<string[]>;
29+ getRemoteSourceActions?(url: string): ProviderResult<RemoteSourceAction[]>;
30+ getRemoteSources(query?: string): ProviderResult<RemoteSource[]>;
31+}
32+
33+export interface PickRemoteSourceOptions {
34+ readonly providerLabel?: (provider: RemoteSourceProvider) => string;
35+ readonly urlLabel?: string | ((url: string) => string);
36+ readonly providerName?: string;
37+ readonly title?: string;
38+ readonly placeholder?: string;
39+ readonly branch?: boolean;
40+ readonly showRecentSources?: boolean;
41+}
42+
43+export interface GitBaseAPI {
44+ registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
45+ pickRemoteSource(options: PickRemoteSourceOptions): Promise<string | { url: string; branch?: string } | undefined>;
46+}
47+
48+export interface GitBaseExtension {
49+ readonly enabled: boolean;
50+ readonly onDidChangeEnablement: Event<boolean>;
51+ getAPI(version: 1): GitBaseAPI;
52+}
53+
54+export interface Credentials {
55+ readonly username: string;
56+ readonly password: string;
57+}
58+
59+export interface CredentialsProvider {
60+ getCredentials(host: Uri): ProviderResult<Credentials>;
61+}
62+
63+export interface Remote {
64+ readonly name: string;
65+ readonly fetchUrl?: string;
66+ readonly pushUrl?: string;
67+ readonly isReadOnly: boolean;
68+}
69+
70+export interface Branch {
71+ readonly name?: string;
72+ readonly commit?: string;
73+ readonly upstream?: { readonly remote: string; readonly name: string };
74+}
75+
76+export interface RepositoryState {
77+ readonly HEAD: Branch | undefined;
78+ readonly remotes: Remote[];
79+}
80+
81+export interface Repository {
82+ readonly rootUri: Uri;
83+ readonly state: RepositoryState;
84+ addRemote(name: string, url: string): Promise<void>;
85+ push(remoteName?: string, branchName?: string, setUpstream?: boolean): Promise<void>;
86+}
87+
88+export interface RemoteSourcePublisher {
89+ readonly name: string;
90+ /** Codicon name. */
91+ readonly icon?: string;
92+ publishRepository(repository: Repository): Promise<void>;
93+}
94+
95+export interface GitAPI {
96+ readonly git: { readonly path: string };
97+ readonly repositories: Repository[];
98+ registerCredentialsProvider(provider: CredentialsProvider): Disposable;
99+ registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable;
100+}
101+
102+export interface GitExtension {
103+ readonly enabled: boolean;
104+ readonly onDidChangeEnablement: Event<boolean>;
105+ getAPI(version: 1): GitAPI;
106+}
addedapps/vscode/src/updates.ts+54−0View file
@@ -1,0 +1,54 @@
1+import { createHash } from "node:crypto";
2+import * as vscode from "vscode";
3+import { compareVersions } from "./util";
4+
5+const DAY_MS = 86_400_000;
6+const LAST_CHECK = "diffinery.lastUpdateCheck";
7+
8+interface Latest {
9+ version: string;
10+ url: string;
11+ sha256: string;
12+}
13+
14+/**
15+ * The extension is installed from the Diffinery server rather than a marketplace, so it updates itself from there:
16+ * once a day (or when asked) it reads /-/vscode/version.json and offers to install a newer build, checked against
17+ * the digest the server published.
18+ */
19+export async function checkForUpdates(ctx: vscode.ExtensionContext, server: string, current: string, log: vscode.LogOutputChannel, manual: boolean): Promise<void> {
20+ if (!manual) {
21+ if (!vscode.workspace.getConfiguration("diffinery").get<boolean>("checkForUpdates", true)) return;
22+ const last = ctx.globalState.get<number>(LAST_CHECK, 0);
23+ if (Date.now() - last < DAY_MS) return;
24+ }
25+ await ctx.globalState.update(LAST_CHECK, Date.now());
26+ let latest: Latest;
27+ try {
28+ const res = await fetch(`${server}/-/vscode/version.json`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(15_000) });
29+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
30+ latest = (await res.json()) as Latest;
31+ } catch (e) {
32+ log.warn("checking for a newer Diffinery extension failed", e);
33+ if (manual) void vscode.window.showWarningMessage(`Couldn't check ${server} for a newer Diffinery extension: ${e instanceof Error ? e.message : e}`);
34+ return;
35+ }
36+ if (compareVersions(latest.version, current) <= 0) {
37+ if (manual) void vscode.window.showInformationMessage(`The Diffinery extension is up to date (${current}).`);
38+ return;
39+ }
40+ const pick = await vscode.window.showInformationMessage(`Diffinery extension ${latest.version} is available (you have ${current}).`, "Install", "Later");
41+ if (pick !== "Install") return;
42+ await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Installing Diffinery extension ${latest.version}…` }, async () => {
43+ const res = await fetch(new URL(latest.url, server), { signal: AbortSignal.timeout(60_000) });
44+ if (!res.ok) throw new Error(`Downloading the extension failed: ${res.status} ${res.statusText}`);
45+ const bytes = new Uint8Array(await res.arrayBuffer());
46+ if (createHash("sha256").update(bytes).digest("hex") !== latest.sha256) throw new Error("The downloaded extension didn't match the published digest; nothing was installed.");
47+ const file = vscode.Uri.joinPath(ctx.globalStorageUri, `diffinery-${latest.version}.vsix`);
48+ await vscode.workspace.fs.createDirectory(ctx.globalStorageUri);
49+ await vscode.workspace.fs.writeFile(file, bytes);
50+ await vscode.commands.executeCommand("workbench.extensions.installExtension", file);
51+ });
52+ const reload = await vscode.window.showInformationMessage(`Diffinery extension ${latest.version} is installed. Reload the window to use it.`, "Reload Window");
53+ if (reload) void vscode.commands.executeCommand("workbench.action.reloadWindow");
54+}
addedapps/vscode/src/util.ts+177−0View file
@@ -1,0 +1,177 @@
1+// Pure helpers, without the vscode module, so the unit tests can run them under Node.
2+import { createHash, randomBytes } from "node:crypto";
3+
4+export const EXTENSION_ID = "codician.diffinery";
5+/** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */
6+export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"];
7+export const DEFAULT_SERVER = "https://diffinery.com";
8+
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;
12+ let u: URL;
13+ try {
14+ u = new URL(s.includes("://") ? s : `https://${s}`);
15+ } catch {
16+ throw new Error(`The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.`);
17+ }
18+ 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(/\/+$/, "")}`;
21+}
22+
23+/** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */
24+export const clientId = (server: string) => `${server}/oauth/clients/editor.json`;
25+
26+/** Where the server sends the browser back after approval: the editor's own scheme when the server knows it, else null (use loopback). */
27+export function editorRedirect(scheme: string): string | null {
28+ return EDITOR_SCHEMES.includes(scheme) ? `${scheme}://${EXTENSION_ID}/oauth` : null;
29+}
30+
31+export const b64url = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url");
32+
33+export function pkce(): { verifier: string; challenge: string; state: string } {
34+ const verifier = b64url(randomBytes(32));
35+ return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()), state: b64url(randomBytes(16)) };
36+}
37+
38+export function authorizeUrl(server: string, redirectUri: string, challenge: string, state: string): string {
39+ const q = new URLSearchParams({ response_type: "code", client_id: clientId(server), redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: "S256", state, scope: "write" });
40+ return `${server}/oauth/authorize?${q}`;
41+}
42+
43+/**
44+ * What a callback from the server carries: the code, or why there isn't one. Checks the state (it must be the one this
45+ * sign-in sent) and, when present, the issuer (RFC 9207), so a code from another server or another attempt is refused.
46+ */
47+export function readCallback(query: string, expect: { state: string; server: string }): { code: string } | { error: string } {
48+ const q = new URLSearchParams(query);
49+ if (q.get("state") !== expect.state) return { error: "The sign-in response didn't match this sign-in. Try again." };
50+ const iss = q.get("iss");
51+ if (iss && iss.replace(/\/+$/, "") !== expect.server) return { error: `The sign-in response came from ${iss}, not ${expect.server}.` };
52+ const err = q.get("error");
53+ if (err) return { error: err === "access_denied" ? "Sign-in was cancelled in the browser." : `Diffinery refused the sign-in: ${q.get("error_description") || err}` };
54+ const code = q.get("code");
55+ return code ? { code } : { error: "The sign-in response had no code. Try again." };
56+}
57+
58+export interface RepoRef {
59+ owner: string;
60+ repo: string;
61+}
62+
63+const NAME = /^[A-Za-z0-9._-]+$/;
64+
65+/** owner/repo from "owner/repo", or from a web or clone URL on this server. Null for anything else. */
66+export function repoRef(server: string, input: string): RepoRef | null {
67+ const s = input.trim();
68+ let path: string;
69+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
70+ let u: URL;
71+ try {
72+ u = new URL(s);
73+ } catch {
74+ return null;
75+ }
76+ const base = new URL(server);
77+ if (u.host.toLowerCase() !== base.host.toLowerCase()) return null;
78+ path = u.pathname.slice(base.pathname.replace(/\/+$/, "").length);
79+ } else {
80+ path = s;
81+ }
82+ const parts = path.replace(/^\/+|\/+$/g, "").split("/");
83+ if (parts.length < 2) return null;
84+ const owner = parts[0]!;
85+ const repo = parts[1]!.replace(/\.git$/, "");
86+ if (!NAME.test(owner) || !NAME.test(repo) || repo === "." || repo === "..") return null;
87+ // A web URL deeper in the repository (/owner/repo/tree/main/src) still names the repository; a bare "a/b/c" doesn't.
88+ if (parts.length > 2 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return null;
89+ return { owner, repo };
90+}
91+
92+export interface RepoJson {
93+ full_name: string;
94+ name: string;
95+ owner: { login: string };
96+ description: string | null;
97+ private: boolean;
98+ visibility?: string;
99+ archived?: boolean;
100+ fork?: boolean;
101+ clone_url: string;
102+ html_url: string;
103+ default_branch?: string;
104+ pushed_at?: string | null;
105+ updated_at?: string | null;
106+}
107+
108+/** Repositories that match what someone typed: every word in the full name or description, ignoring case. */
109+export function matches(r: RepoJson, query: string): boolean {
110+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
111+ const hay = `${r.full_name} ${r.description ?? ""}`.toLowerCase();
112+ return words.every((w) => hay.includes(w));
113+}
114+
115+/** Merges lists of repositories, first occurrence wins, in order. */
116+export function dedupe(...lists: RepoJson[][]): RepoJson[] {
117+ const seen = new Set<string>();
118+ const out: RepoJson[] = [];
119+ for (const list of lists)
120+ for (const r of list) {
121+ const k = r.full_name.toLowerCase();
122+ if (!seen.has(k)) {
123+ seen.add(k);
124+ out.push(r);
125+ }
126+ }
127+ return out;
128+}
129+
130+/** The next page from a Link header (RFC 8288), as GitHub-style APIs send it. */
131+export function nextLink(link: string | null | undefined): string | null {
132+ if (!link) return null;
133+ for (const part of link.split(",")) {
134+ const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);
135+ if (m) return m[1]!;
136+ }
137+ return null;
138+}
139+
140+/** -1, 0 or 1, comparing dotted version numbers (a pre-release suffix sorts before the release). */
141+export function compareVersions(a: string, b: string): number {
142+ const parse = (v: string) => {
143+ const [core, pre] = v.replace(/^v/, "").split("-", 2);
144+ return { nums: core!.split(".").map((n) => Number.parseInt(n, 10) || 0), pre: pre ?? null };
145+ };
146+ const x = parse(a);
147+ const y = parse(b);
148+ for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) {
149+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
150+ if (d) return d < 0 ? -1 : 1;
151+ }
152+ if (x.pre === y.pre) return 0;
153+ if (x.pre === null) return 1;
154+ if (y.pre === null) return -1;
155+ return x.pre < y.pre ? -1 : 1;
156+}
157+
158+/** The lines `git credential approve|reject` reads for a token on the server's host. */
159+export function credentialInput(server: string, username: string, password: string, expiresAt?: number): string {
160+ const u = new URL(server);
161+ const lines = [`protocol=${u.protocol.replace(/:$/, "")}`, `host=${u.host}`, `username=${username}`, `password=${password}`];
162+ // Git 2.41+ passes this on, and helpers that keep it (Git Credential Manager, osxkeychain, libsecret, cache) stop
163+ // handing out the token once it has expired, so Git asks the editor for a fresh one instead of failing.
164+ if (expiresAt) lines.push(`password_expiry_utc=${Math.floor(expiresAt / 1000)}`);
165+ if (/[\n\0]/.test(lines.join(""))) throw new Error("credential contains a newline");
166+ return `${lines.join("\n")}\n\n`;
167+}
168+
169+/** A repository name Diffinery accepts, from a folder name: unsupported characters become "-". */
170+export function repoNameFrom(folder: string): string {
171+ const n = folder
172+ .trim()
173+ .replace(/[^A-Za-z0-9._-]+/g, "-")
174+ .replace(/^[-.]+|[-.]+$/g, "")
175+ .slice(0, 100);
176+ return n || "repository";
177+}
addedapps/vscode/test/repos.test.ts+61−0View file
@@ -1,0 +1,61 @@
1+import { describe, expect, it } from "vitest";
2+import { compareVersions, credentialInput, dedupe, matches, nextLink, repoNameFrom, repoRef, type RepoJson } from "../src/util";
3+
4+
5+const S = "https://diffinery.com";
6+const repo = (full: string, description: string | null = null): RepoJson => ({
7+ full_name: full,
8+ name: full.split("/")[1]!,
9+ owner: { login: full.split("/")[0]! },
10+ description,
11+ private: true,
12+ clone_url: `${S}/${full}.git`,
13+ html_url: `${S}/${full}`,
14+});
15+
16+describe("repositories", () => {
17+ it("reads owner/repo from names and URLs on this server only", () => {
18+ expect(repoRef(S, "codician/diffinery")).toEqual({ owner: "codician", repo: "diffinery" });
19+ expect(repoRef(S, "https://diffinery.com/codician/diffinery.git")).toEqual({ owner: "codician", repo: "diffinery" });
20+ expect(repoRef(S, "https://diffinery.com/codician/diffinery/tree/main/src")).toEqual({ owner: "codician", repo: "diffinery" });
21+ expect(repoRef(S, "https://github.com/codician/diffinery")).toBeNull();
22+ expect(repoRef(S, "diffinery")).toBeNull();
23+ expect(repoRef(S, "a/b/c")).toBeNull();
24+ expect(repoRef(S, "a b/c")).toBeNull();
25+ expect(repoRef("http://localhost:8787", "http://localhost:8787/ada/x.git")).toEqual({ owner: "ada", repo: "x" });
26+ });
27+
28+ it("matches every word, merges without duplicates", () => {
29+ const a = repo("codician/diffinery", "A git forge for agents");
30+ const b = repo("codician/tactfolio", "Backtesting");
31+ expect(matches(a, "forge DIFF")).toBe(true);
32+ expect(matches(b, "forge")).toBe(false);
33+ expect(dedupe([a], [b, repo("Codician/Diffinery")]).map((r) => r.full_name)).toEqual(["codician/diffinery", "codician/tactfolio"]);
34+ });
35+
36+ it("follows Link headers", () => {
37+ expect(nextLink('<https://diffinery.com/api/v3/user/repos?page=2>; rel="next", <https://diffinery.com/api/v3/user/repos?page=5>; rel="last"')).toBe("https://diffinery.com/api/v3/user/repos?page=2");
38+ expect(nextLink('<https://x/?page=1>; rel="prev"')).toBeNull();
39+ expect(nextLink(null)).toBeNull();
40+ });
41+
42+ it("names a repository after a folder", () => {
43+ expect(repoNameFrom("My Project (old)")).toBe("My-Project-old");
44+ expect(repoNameFrom("...")).toBe("repository");
45+ });
46+});
47+
48+describe("updates and Git", () => {
49+ it("compares versions", () => {
50+ expect(compareVersions("0.2.0", "0.1.9")).toBe(1);
51+ expect(compareVersions("0.1.0", "0.1.0")).toBe(0);
52+ expect(compareVersions("0.1.0", "0.1.10")).toBe(-1);
53+ expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1);
54+ });
55+
56+ it("writes what git credential approve reads", () => {
57+ expect(credentialInput(S, "ada", "dfy_x", 1_800_000_000_500)).toBe("protocol=https\nhost=diffinery.com\nusername=ada\npassword=dfy_x\npassword_expiry_utc=1800000000\n\n");
58+ expect(credentialInput("http://localhost:8787", "ada", "t")).toBe("protocol=http\nhost=localhost:8787\nusername=ada\npassword=t\n\n");
59+ expect(() => credentialInput(S, "ada\nhost=evil", "t")).toThrow();
60+ });
61+});
addedapps/vscode/test/signin.test.ts+40−0View file
@@ -1,0 +1,40 @@
1+import { createHash } from "node:crypto";
2+import { describe, expect, it } from "vitest";
3+import { authorizeUrl, clientId, editorRedirect, normalizeServer, pkce, readCallback } from "../src/util";
4+
5+const S = "https://diffinery.com";
6+
7+describe("server and OAuth", () => {
8+ it("normalizes the server setting", () => {
9+ expect(normalizeServer(undefined)).toBe(S);
10+ expect(normalizeServer(" https://diffinery.com/ ")).toBe(S);
11+ expect(normalizeServer("diffinery.example.org")).toBe("https://diffinery.example.org");
12+ expect(normalizeServer("http://localhost:8787")).toBe("http://localhost:8787");
13+ expect(() => normalizeServer("http://diffinery.com")).toThrow(/https/);
14+ expect(() => normalizeServer("https://")).toThrow(/valid URL/);
15+ });
16+
17+ it("returns to the editor by its own scheme when the server knows it", () => {
18+ expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth");
19+ expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth");
20+ expect(editorRedirect("some-fork")).toBeNull();
21+ expect(clientId(S)).toBe("https://diffinery.com/oauth/clients/editor.json");
22+ });
23+
24+ it("makes a PKCE pair and an authorization URL", () => {
25+ const p = pkce();
26+ expect(p.challenge).toBe(createHash("sha256").update(p.verifier).digest("base64url"));
27+ const u = new URL(authorizeUrl(S, "cursor://codician.diffinery/oauth", p.challenge, p.state));
28+ expect(u.origin + u.pathname).toBe(`${S}/oauth/authorize`);
29+ expect(Object.fromEntries(u.searchParams)).toEqual({ response_type: "code", client_id: clientId(S), redirect_uri: "cursor://codician.diffinery/oauth", code_challenge: p.challenge, code_challenge_method: "S256", state: p.state, scope: "write" });
30+ });
31+
32+ it("accepts only the callback of this sign-in, from this server", () => {
33+ const expect_ = { state: "st", server: S };
34+ expect(readCallback(`code=dfa_1&state=st&iss=${encodeURIComponent(S)}`, expect_)).toEqual({ code: "dfa_1" });
35+ expect(readCallback("code=dfa_1&state=other", expect_)).toHaveProperty("error");
36+ expect(readCallback("code=dfa_1&state=st&iss=https%3A%2F%2Fevil.example", expect_)).toEqual({ error: expect.stringMatching(/evil\.example/) });
37+ expect(readCallback("error=access_denied&state=st", expect_)).toEqual({ error: "Sign-in was cancelled in the browser." });
38+ expect(readCallback("state=st", expect_)).toHaveProperty("error");
39+ });
40+});
addedapps/vscode/tsconfig.json+1−0View file
@@ -1,0 +1,1 @@
1+{ "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src", "test"] }
addedapps/vscode/vitest.config.ts+4−0View file
@@ -1,0 +1,4 @@
1+import { defineConfig } from "vitest/config";
2+import { timeouts } from "../../vitest.shared";
3+
4+export default defineConfig({ test: timeouts });
modifiedpnpm-lock.yaml+23−0View file
@@ -128,6 +128,24 @@
128128 specifier: ^4.149.0
129129 version: 4.149.0(@cloudflare/workers-types@5.20261012.1)(@types/node@22.20.5)
130130
131+ apps/vscode:
132+ devDependencies:
133+ '@types/node':
134+ specifier: ^22.0.0
135+ version: 22.20.5
136+ '@types/vscode':
137+ specifier: 1.85.0
138+ version: 1.85.0
139+ esbuild:
140+ specifier: 0.25.11
141+ version: 0.25.11
142+ typescript:
143+ specifier: ^5.9.3
144+ version: 5.9.3
145+ vitest:
146+ specifier: ^4.1.0
147+ version: 4.1.11(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.25.11)(yaml@2.9.1))
148+
131149 containers/runner:
132150 dependencies:
133151 '@diffinery/shared':
@@ -1645,6 +1663,9 @@
16451663 '@types/unist@3.0.3':
16461664 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
16471665
1666+ '@types/vscode@1.85.0':
1667+ resolution: {integrity: sha512-CF/RBon/GXwdfmnjZj0WTUMZN5H6YITOfBCP4iEZlOtVQXuzw6t7Le7+cR+7JzdMrnlm7Mfp49Oj2TuSXIWo3g==}
1668+
16481669 '@types/ws@8.18.1':
16491670 resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
16501671
@@ -3416,6 +3437,8 @@
34163437
34173438 '@types/unist@3.0.3': {}
34183439
3440+ '@types/vscode@1.85.0': {}
3441+
34193442 '@types/ws@8.18.1':
34203443 dependencies:
34213444 '@types/node': 22.20.5