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
21 files changed+1678−7
Files (21)
- apps/diffinery/src/auth/oauth.ts+33−6
- apps/diffinery/src/http/oauth.ts+4−1
- apps/vscode/CHANGELOG.md+9−0
- apps/vscode/README.md+48−0
- apps/vscode/media/diffinery-icons.woff+0−0
- apps/vscode/media/icon.png+0−0
- apps/vscode/package.json+134−0
- apps/vscode/scripts/make-icons.py+70−0
- apps/vscode/scripts/package.mjs+189−0
- apps/vscode/src/api.ts+96−0
- apps/vscode/src/auth.ts+360−0
- apps/vscode/src/extension.ts+92−0
- apps/vscode/src/git.ts+110−0
- apps/vscode/src/remoteSources.ts+72−0
- apps/vscode/src/typings/git.ts+106−0
- apps/vscode/src/updates.ts+54−0
- apps/vscode/src/util.ts+177−0
- apps/vscode/test/util.test.ts+96−0
- apps/vscode/tsconfig.json+1−0
- apps/vscode/vitest.config.ts+4−0
- pnpm-lock.yaml+23−0
modifiedapps/diffinery/src/auth/oauth.ts+33−6View file
| @@ -47,6 +47,28 @@ | ||
| 47 | 47 | "https://chatgpt.com/oauth/codex/client.json": { name: "Codex", clientUri: "https://chatgpt.com/codex", redirectUris: ["http://127.0.0.1/callback", "http://localhost/callback"] }, |
| 48 | 48 | }; |
| 49 | 49 | |
| 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 | + | |
| 50 | 72 | export type AuthMethod = "none" | "client_secret_basic" | "client_secret_post"; |
| 51 | 73 | |
| 52 | 74 | export interface OAuthClient { |
| @@ -297,12 +319,17 @@ | ||
| 297 | 319 | const row = await this.db.prepare("SELECT * FROM oauth_clients WHERE id = ?").bind(id).first<Record<string, string | null>>(); |
| 298 | 320 | const fresh = row && (row.kind === "registered" || (row.fetched_at && Date.now() - Date.parse(row.fetched_at) < METADATA_TTL_MS)); |
| 299 | 321 | 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); | |
| 306 | 333 | if (!doc) return row ? toClient(row) : null; |
| 307 | 334 | const at = now(); |
| 308 | 335 | await this.db |
modifiedapps/diffinery/src/http/oauth.ts+4−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Hono, type Context } from "hono"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | 3 | 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"; | |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | 7 | * OAuth's machine endpoints for MCP clients (auth/oauth.ts has the flow; the consent page is in |
| @@ -30,6 +30,9 @@ | ||
| 30 | 30 | // Clients that put the resource's path after the well-known name (RFC 8414 §3.1 with an issuer path) get the same document. |
| 31 | 31 | oauthApi.get("/.well-known/oauth-authorization-server/mcp", (c) => c.json(authorizationServerMetadata(c.env), 200, DISCOVERY)); |
| 32 | 32 | |
| 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 | + | |
| 33 | 36 | const oauthError = (c: Context, e: OAuthError) => { |
| 34 | 37 | const headers: Record<string, string> = { ...CORS, ...NO_STORE }; |
| 35 | 38 | 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 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 @@ | ||
| 128 | 128 | specifier: ^4.149.0 |
| 129 | 129 | version: 4.149.0(@cloudflare/workers-types@5.20261012.1)(@types/node@22.20.5) |
| 130 | 130 | |
| 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 | + | |
| 131 | 149 | containers/runner: |
| 132 | 150 | dependencies: |
| 133 | 151 | '@diffinery/shared': |
| @@ -1645,6 +1663,9 @@ | ||
| 1645 | 1663 | '@types/unist@3.0.3': |
| 1646 | 1664 | resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} |
| 1647 | 1665 | |
| 1666 | + '@types/vscode@1.85.0': | |
| 1667 | + resolution: {integrity: sha512-CF/RBon/GXwdfmnjZj0WTUMZN5H6YITOfBCP4iEZlOtVQXuzw6t7Le7+cR+7JzdMrnlm7Mfp49Oj2TuSXIWo3g==} | |
| 1668 | + | |
| 1648 | 1669 | '@types/ws@8.18.1': |
| 1649 | 1670 | resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} |
| 1650 | 1671 | |
| @@ -3416,6 +3437,8 @@ | ||
| 3416 | 3437 | |
| 3417 | 3438 | '@types/unist@3.0.3': {} |
| 3418 | 3439 | |
| 3440 | + '@types/vscode@1.85.0': {} | |
| 3441 | + | |
| 3419 | 3442 | '@types/ws@8.18.1': |
| 3420 | 3443 | dependencies: |
| 3421 | 3444 | '@types/node': 22.20.5 |