Diffinery

codician/diffinerypublic

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

Editor extension for VS Code and Cursor: Clone from Diffinery, sign-in, Git credentials, publish (WIP)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ3j7radTeNPDXuJ936W3K
Claude authored signed1 parent b19a33ecommit 0e995ec5e8277498603a6b68425c4e33a65f508dBrowse files
21 files changed+1678−7
Files (21)
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/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"';
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/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/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": {
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)"
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)` 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+360−0View file
@@ -1,0 +1,360 @@
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+ let redirectUri = editorRedirect(vscode.env.uriScheme);
229+ let wait: Promise<string>;
230+ let loopback: Loopback | undefined;
231+ if (redirectUri) {
232+ wait = new Promise((resolve) => this.callbacks.set(state, resolve));
233+ } else {
234+ // An editor the server has no scheme for: a one-time listener on this computer instead (RFC 8252).
235+ loopback = await listenOnce(state);
236+ const external = await vscode.env.asExternalUri(vscode.Uri.parse(loopback.uri));
237+ redirectUri = loopback.uri.replace(/:\d+\//, `:${new URL(external.toString(true)).port || loopback.port}/`);
238+ wait = loopback.result;
239+ }
240+ try {
241+ const url = authorizeUrl(server, redirectUri, challenge, state);
242+ this.log.info(`opening ${server}/oauth/authorize for the editor's sign-in (redirect ${redirectUri})`);
243+ const opened = await vscode.env.openExternal(vscode.Uri.parse(url, true));
244+ const query = await vscode.window.withProgress(
245+ { location: vscode.ProgressLocation.Notification, title: "Signing in to Diffinery", cancellable: true },
246+ async (progress, cancel) => {
247+ progress.report({ message: opened ? "Approve the connection in your browser…" : `Open ${url} in your browser and approve the connection…` });
248+ return Promise.race([
249+ wait,
250+ new Promise<never>((_, reject) => cancel.onCancellationRequested(() => reject(new vscode.CancellationError()))),
251+ new Promise<never>((_, reject) => setTimeout(() => reject(new SignInError("Signing in to Diffinery timed out. Try again.")), SIGN_IN_TIMEOUT_MS)),
252+ ]);
253+ },
254+ );
255+ const r = readCallback(query, { state, server });
256+ if ("error" in r) throw new SignInError(r.error);
257+ const tok = await tokenRequest(server, { grant_type: "authorization_code", code: r.code, code_verifier: verifier, client_id: clientId(server), redirect_uri: redirectUri });
258+ const user = await new Api(server, tok.access_token, this.version).user();
259+ return {
260+ id: randomUUID(),
261+ server,
262+ login: user.login,
263+ accountId: String(user.id),
264+ kind: "oauth",
265+ accessToken: tok.access_token,
266+ refreshToken: tok.refresh_token,
267+ expiresAt: Date.now() + (tok.expires_in ?? 8 * 3600) * 1000,
268+ };
269+ } finally {
270+ this.callbacks.delete(state);
271+ loopback?.close();
272+ }
273+ }
274+
275+ /** Signs in with an access token someone pastes (for an agent account, a self-hosted server, or a browser-based editor). */
276+ async signInWithToken(): Promise<void> {
277+ const server = this.server();
278+ const token = await vscode.window.showInputBox({
279+ title: "Sign in to Diffinery with an access token",
280+ prompt: `Paste a token from ${server}/settings/tokens (write access lets you push). It stays in the editor's secret storage.`,
281+ password: true,
282+ ignoreFocusOut: true,
283+ validateInput: (v) => (/^\s*\S+\s*$/.test(v) ? null : "Paste the whole token, without spaces."),
284+ });
285+ if (!token) return;
286+ const user = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: "Checking the token with Diffinery…" }, () => new Api(server, token.trim(), this.version).user());
287+ await this.save({ id: randomUUID(), server, login: user.login, accountId: String(user.id), kind: "token", accessToken: token.trim() });
288+ void vscode.window.showInformationMessage(`Signed in to Diffinery as ${user.login}.`);
289+ }
290+
291+ async signOut(): Promise<void> {
292+ const s = await this.load();
293+ if (!s) {
294+ void vscode.window.showInformationMessage("You aren't signed in to Diffinery.");
295+ return;
296+ }
297+ await this.removeSession(s.id);
298+ void vscode.window.showInformationMessage(`Signed out of Diffinery (${s.login}).`);
299+ }
300+
301+ /** An API client for the signed-in account, asking the person to sign in when `ask` and nobody is. */
302+ async api(ask: boolean): Promise<Api | undefined> {
303+ const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, ask ? { createIfNone: true } : { silent: true });
304+ return session ? new Api(this.server(), session.accessToken, this.version) : undefined;
305+ }
306+}
307+
308+const toSession = (s: Stored): vscode.AuthenticationSession => ({ id: s.id, accessToken: s.accessToken, account: { id: s.accountId, label: s.login }, scopes: SCOPES });
309+
310+/** POSTs to the token endpoint. SignInError when the server refuses (the grant is gone); other errors mean it wasn't reached. */
311+async function tokenRequest(server: string, form: Record<string, string>): Promise<TokenResponse> {
312+ const res = await fetch(`${server}/oauth/token`, {
313+ method: "POST",
314+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
315+ body: new URLSearchParams(form),
316+ signal: AbortSignal.timeout(30_000),
317+ });
318+ let body: TokenResponse;
319+ try {
320+ body = (await res.json()) as TokenResponse;
321+ } catch {
322+ throw new Error(`Diffinery's token endpoint answered ${res.status}`);
323+ }
324+ if (res.status >= 500) throw new Error(`Diffinery's token endpoint answered ${res.status}`);
325+ if (!res.ok || !body.access_token) throw new SignInError(body.error_description || body.error || `Diffinery refused the sign-in (${res.status}).`);
326+ return body;
327+}
328+
329+interface Loopback {
330+ uri: string;
331+ port: number;
332+ result: Promise<string>;
333+ close(): void;
334+}
335+
336+/** A listener on 127.0.0.1 that takes one browser callback for this sign-in and says it can be closed. */
337+function listenOnce(state: string): Promise<Loopback> {
338+ return new Promise((resolve, reject) => {
339+ let done: (q: string) => void;
340+ const result = new Promise<string>((r) => (done = r));
341+ const server = http.createServer((req, res) => {
342+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
343+ if (u.pathname !== "/callback" || u.searchParams.get("state") !== state) {
344+ res.writeHead(404).end();
345+ return;
346+ }
347+ const ok = !u.searchParams.has("error");
348+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
349+ res.end(
350+ `<!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>`,
351+ );
352+ done(u.search.slice(1));
353+ });
354+ server.on("error", reject);
355+ server.listen(0, "127.0.0.1", () => {
356+ const port = (server.address() as { port: number }).port;
357+ resolve({ uri: `http://127.0.0.1:${port}/callback`, port, result, close: () => server.close() });
358+ });
359+ });
360+}
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";
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: me.type === "Bot" ? "the agent's owner's account" : "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";
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", 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/util.test.ts+96−0View file
@@ -1,0 +1,96 @@
1+import { createHash } from "node:crypto";
2+import { describe, expect, it } from "vitest";
3+import { authorizeUrl, clientId, compareVersions, credentialInput, dedupe, editorRedirect, matches, nextLink, normalizeServer, pkce, readCallback, repoNameFrom, repoRef, type RepoJson } from "../src/util";
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("server and OAuth", () => {
17+ it("normalizes the server setting", () => {
18+ expect(normalizeServer(undefined)).toBe(S);
19+ expect(normalizeServer(" https://diffinery.com/ ")).toBe(S);
20+ expect(normalizeServer("diffinery.example.org")).toBe("https://diffinery.example.org");
21+ expect(normalizeServer("http://localhost:8787")).toBe("http://localhost:8787");
22+ expect(() => normalizeServer("http://diffinery.com")).toThrow(/https/);
23+ expect(() => normalizeServer("https://")).toThrow(/valid URL/);
24+ });
25+
26+ it("returns to the editor by its own scheme when the server knows it", () => {
27+ expect(editorRedirect("vscode")).toBe("vscode://codician.diffinery/oauth");
28+ expect(editorRedirect("cursor")).toBe("cursor://codician.diffinery/oauth");
29+ expect(editorRedirect("some-fork")).toBeNull();
30+ expect(clientId(S)).toBe("https://diffinery.com/oauth/clients/editor.json");
31+ });
32+
33+ it("makes a PKCE pair and an authorization URL", () => {
34+ const p = pkce();
35+ expect(p.challenge).toBe(createHash("sha256").update(p.verifier).digest("base64url"));
36+ const u = new URL(authorizeUrl(S, "cursor://codician.diffinery/oauth", p.challenge, p.state));
37+ expect(u.origin + u.pathname).toBe(`${S}/oauth/authorize`);
38+ 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" });
39+ });
40+
41+ it("accepts only the callback of this sign-in, from this server", () => {
42+ const expect_ = { state: "st", server: S };
43+ expect(readCallback(`code=dfa_1&state=st&iss=${encodeURIComponent(S)}`, expect_)).toEqual({ code: "dfa_1" });
44+ expect(readCallback("code=dfa_1&state=other", expect_)).toHaveProperty("error");
45+ expect(readCallback("code=dfa_1&state=st&iss=https%3A%2F%2Fevil.example", expect_)).toEqual({ error: expect.stringMatching(/evil\.example/) });
46+ expect(readCallback("error=access_denied&state=st", expect_)).toEqual({ error: "Sign-in was cancelled in the browser." });
47+ expect(readCallback("state=st", expect_)).toHaveProperty("error");
48+ });
49+});
50+
51+describe("repositories", () => {
52+ it("reads owner/repo from names and URLs on this server only", () => {
53+ expect(repoRef(S, "codician/diffinery")).toEqual({ owner: "codician", repo: "diffinery" });
54+ expect(repoRef(S, "https://diffinery.com/codician/diffinery.git")).toEqual({ owner: "codician", repo: "diffinery" });
55+ expect(repoRef(S, "https://diffinery.com/codician/diffinery/tree/main/src")).toEqual({ owner: "codician", repo: "diffinery" });
56+ expect(repoRef(S, "https://github.com/codician/diffinery")).toBeNull();
57+ expect(repoRef(S, "diffinery")).toBeNull();
58+ expect(repoRef(S, "a/b/c")).toBeNull();
59+ expect(repoRef(S, "a b/c")).toBeNull();
60+ expect(repoRef("http://localhost:8787", "http://localhost:8787/ada/x.git")).toEqual({ owner: "ada", repo: "x" });
61+ });
62+
63+ it("matches every word, merges without duplicates", () => {
64+ const a = repo("codician/diffinery", "A git forge for agents");
65+ const b = repo("codician/tactfolio", "Backtesting");
66+ expect(matches(a, "forge DIFF")).toBe(true);
67+ expect(matches(b, "forge")).toBe(false);
68+ expect(dedupe([a], [b, repo("Codician/Diffinery")]).map((r) => r.full_name)).toEqual(["codician/diffinery", "codician/tactfolio"]);
69+ });
70+
71+ it("follows Link headers", () => {
72+ 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");
73+ expect(nextLink('<https://x/?page=1>; rel="prev"')).toBeNull();
74+ expect(nextLink(null)).toBeNull();
75+ });
76+
77+ it("names a repository after a folder", () => {
78+ expect(repoNameFrom("My Project (old)")).toBe("My-Project-old");
79+ expect(repoNameFrom("...")).toBe("repository");
80+ });
81+});
82+
83+describe("updates and Git", () => {
84+ it("compares versions", () => {
85+ expect(compareVersions("0.2.0", "0.1.9")).toBe(1);
86+ expect(compareVersions("0.1.0", "0.1.0")).toBe(0);
87+ expect(compareVersions("0.1.0", "0.1.10")).toBe(-1);
88+ expect(compareVersions("1.0.0-beta", "1.0.0")).toBe(-1);
89+ });
90+
91+ it("writes what git credential approve reads", () => {
92+ 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");
93+ expect(credentialInput("http://localhost:8787", "ada", "t")).toBe("protocol=http\nhost=localhost:8787\nusername=ada\npassword=t\n\n");
94+ expect(() => credentialInput(S, "ada\nhost=evil", "t")).toThrow();
95+ });
96+});
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