| 1 | // Pure helpers, without the vscode module, so the unit tests can run them under Node. |
| 2 | import { createHash, randomBytes } from "node:crypto"; |
| 3 | import * as path from "node:path"; |
| 4 | |
| 5 | export const EXTENSION_ID = "codician.diffinery"; |
| 6 | /** Editors whose URL scheme the Diffinery server accepts as a redirect back to this extension (src/auth/oauth.ts there). */ |
| 7 | export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"]; |
| 8 | export const DEFAULT_SERVER = "https://diffinery.com"; |
| 9 | |
| 10 | function checkServer(s: string): { server: string } | { invalid: "url" | "https" } { |
| 11 | let u: URL; |
| 12 | try { |
| 13 | u = new URL(s.includes("://") ? s : `https://${s}`); |
| 14 | } catch { |
| 15 | return { invalid: "url" }; |
| 16 | } |
| 17 | const loopback = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(u.hostname); |
| 18 | if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return { invalid: "https" }; |
| 19 | return { server: `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}` }; |
| 20 | } |
| 21 | |
| 22 | /** The server's base URL from the setting: https (or http on this computer), no trailing slash, no path. */ |
| 23 | export function normalizeServer(raw: string | undefined): string { |
| 24 | const s = (raw ?? "").trim() || DEFAULT_SERVER; |
| 25 | const r = checkServer(s); |
| 26 | if ("server" in r) return r.server; |
| 27 | throw new Error(r.invalid === "url" ? `The Diffinery server URL "${s}" isn't a valid URL. Fix diffinery.serverUrl in your settings.` : `The Diffinery server URL must use https (diffinery.serverUrl is ${s}).`); |
| 28 | } |
| 29 | |
| 30 | /** Why what someone typed as a server address can't be used, for the input box; null when it can. */ |
| 31 | export function serverInputError(raw: string): string | null { |
| 32 | const s = raw.trim(); |
| 33 | if (!s) return "Enter your Diffinery's address, such as git.example.com."; |
| 34 | if (/\s/.test(s)) return "An address has no spaces."; |
| 35 | const r = checkServer(s); |
| 36 | return "server" in r ? null : r.invalid === "url" ? `"${s}" isn't a valid address.` : "The address must use https (http only for a server on this computer)."; |
| 37 | } |
| 38 | |
| 39 | /** A server as people read it: diffinery.com, git.example.com:8443. */ |
| 40 | export function serverLabel(server: string): string { |
| 41 | const u = new URL(server); |
| 42 | return `${u.host}${u.pathname.replace(/\/+$/, "")}`; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Where the editor installer (install/install.sh, install.ps1) leaves the address of the self-hosted Diffinery that |
| 47 | * served it, for the extension to offer on its next start: the CLI's config folder (~/.config/diffinery, or under |
| 48 | * XDG_CONFIG_HOME), on every system. |
| 49 | */ |
| 50 | export function installHintPath(env: NodeJS.ProcessEnv, home: string): string { |
| 51 | const xdg = env.XDG_CONFIG_HOME; |
| 52 | const base = xdg && path.isAbsolute(xdg) ? xdg : path.join(env.HOME || home, ".config"); |
| 53 | return path.join(base, "diffinery", "editor-server"); |
| 54 | } |
| 55 | |
| 56 | /** The server named in the installer's hint file, or null when the file holds anything else. */ |
| 57 | export function readInstallHint(text: string): string | null { |
| 58 | const line = text.split(/\r?\n/, 1)[0]!.trim(); |
| 59 | if (!line || line.length > 2000 || serverInputError(line)) return null; |
| 60 | return normalizeServer(line); |
| 61 | } |
| 62 | |
| 63 | /** The OAuth client_id of this extension on a server: the URL of the metadata document the server publishes about it. */ |
| 64 | export const clientId = (server: string) => `${server}/oauth/clients/editor.json`; |
| 65 | |
| 66 | /** Where the server sends the browser back after approval: the editor's own scheme when the server knows it, else null (use loopback). */ |
| 67 | export function editorRedirect(scheme: string): string | null { |
| 68 | return EDITOR_SCHEMES.includes(scheme) ? `${scheme}://${EXTENSION_ID}/oauth` : null; |
| 69 | } |
| 70 | |
| 71 | export const b64url = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url"); |
| 72 | |
| 73 | export function pkce(): { verifier: string; challenge: string; state: string } { |
| 74 | const verifier = b64url(randomBytes(32)); |
| 75 | return { verifier, challenge: b64url(createHash("sha256").update(verifier).digest()), state: b64url(randomBytes(16)) }; |
| 76 | } |
| 77 | |
| 78 | export function authorizeUrl(server: string, redirectUri: string, challenge: string, state: string): string { |
| 79 | const q = new URLSearchParams({ response_type: "code", client_id: clientId(server), redirect_uri: redirectUri, code_challenge: challenge, code_challenge_method: "S256", state, scope: "write" }); |
| 80 | return `${server}/oauth/authorize?${q}`; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * What a callback from the server carries: the code, or why there isn't one. Checks the state (it must be the one this |
| 85 | * sign-in sent) and, when present, the issuer (RFC 9207), so a code from another server or another attempt is refused. |
| 86 | */ |
| 87 | export function readCallback(query: string, expect: { state: string; server: string }): { code: string } | { error: string } { |
| 88 | const q = new URLSearchParams(query); |
| 89 | if (q.get("state") !== expect.state) return { error: "The sign-in response didn't match this sign-in. Try again." }; |
| 90 | const iss = q.get("iss"); |
| 91 | if (iss && iss.replace(/\/+$/, "") !== expect.server) return { error: `The sign-in response came from ${iss}, not ${expect.server}.` }; |
| 92 | const err = q.get("error"); |
| 93 | if (err) return { error: err === "access_denied" ? "Sign-in was cancelled in the browser." : `Diffinery refused the sign-in: ${q.get("error_description") || err}` }; |
| 94 | const code = q.get("code"); |
| 95 | return code ? { code } : { error: "The sign-in response had no code. Try again." }; |
| 96 | } |
| 97 | |
| 98 | export interface RepoRef { |
| 99 | owner: string; |
| 100 | repo: string; |
| 101 | } |
| 102 | |
| 103 | const NAME = /^[A-Za-z0-9._-]+$/; |
| 104 | |
| 105 | /** owner/repo from "owner/repo", or from a web or clone URL on this server. Null for anything else. */ |
| 106 | export function repoRef(server: string, input: string): RepoRef | null { |
| 107 | const s = input.trim(); |
| 108 | let path: string; |
| 109 | if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) { |
| 110 | let u: URL; |
| 111 | try { |
| 112 | u = new URL(s); |
| 113 | } catch { |
| 114 | return null; |
| 115 | } |
| 116 | const base = new URL(server); |
| 117 | if (u.host.toLowerCase() !== base.host.toLowerCase()) return null; |
| 118 | path = u.pathname.slice(base.pathname.replace(/\/+$/, "").length); |
| 119 | } else { |
| 120 | path = s; |
| 121 | } |
| 122 | const parts = path.replace(/^\/+|\/+$/g, "").split("/"); |
| 123 | if (parts.length < 2) return null; |
| 124 | const owner = parts[0]!; |
| 125 | const repo = parts[1]!.replace(/\.git$/, ""); |
| 126 | if (!NAME.test(owner) || !NAME.test(repo) || repo === "." || repo === "..") return null; |
| 127 | // A web URL deeper in the repository (/owner/repo/tree/main/src) still names the repository; a bare "a/b/c" doesn't. |
| 128 | if (parts.length > 2 && !/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return null; |
| 129 | return { owner, repo }; |
| 130 | } |
| 131 | |
| 132 | export interface RepoJson { |
| 133 | full_name: string; |
| 134 | name: string; |
| 135 | owner: { login: string }; |
| 136 | description: string | null; |
| 137 | private: boolean; |
| 138 | visibility?: string; |
| 139 | archived?: boolean; |
| 140 | fork?: boolean; |
| 141 | clone_url: string; |
| 142 | html_url: string; |
| 143 | default_branch?: string; |
| 144 | pushed_at?: string | null; |
| 145 | updated_at?: string | null; |
| 146 | } |
| 147 | |
| 148 | /** Repositories that match what someone typed: every word in the full name or description, ignoring case. */ |
| 149 | export function matches(r: RepoJson, query: string): boolean { |
| 150 | const words = query.toLowerCase().split(/\s+/).filter(Boolean); |
| 151 | const hay = `${r.full_name} ${r.description ?? ""}`.toLowerCase(); |
| 152 | return words.every((w) => hay.includes(w)); |
| 153 | } |
| 154 | |
| 155 | /** Merges lists of repositories, first occurrence wins, in order. */ |
| 156 | export function dedupe(...lists: RepoJson[][]): RepoJson[] { |
| 157 | const seen = new Set<string>(); |
| 158 | const out: RepoJson[] = []; |
| 159 | for (const list of lists) |
| 160 | for (const r of list) { |
| 161 | const k = r.full_name.toLowerCase(); |
| 162 | if (!seen.has(k)) { |
| 163 | seen.add(k); |
| 164 | out.push(r); |
| 165 | } |
| 166 | } |
| 167 | return out; |
| 168 | } |
| 169 | |
| 170 | /** The next page from a Link header (RFC 8288), as GitHub-style APIs send it. */ |
| 171 | export function nextLink(link: string | null | undefined): string | null { |
| 172 | if (!link) return null; |
| 173 | for (const part of link.split(",")) { |
| 174 | const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part); |
| 175 | if (m) return m[1]!; |
| 176 | } |
| 177 | return null; |
| 178 | } |
| 179 | |
| 180 | /** -1, 0 or 1, comparing dotted version numbers (a pre-release suffix sorts before the release). */ |
| 181 | export function compareVersions(a: string, b: string): number { |
| 182 | const parse = (v: string) => { |
| 183 | const [core, pre] = v.replace(/^v/, "").split("-", 2); |
| 184 | return { nums: core!.split(".").map((n) => Number.parseInt(n, 10) || 0), pre: pre ?? null }; |
| 185 | }; |
| 186 | const x = parse(a); |
| 187 | const y = parse(b); |
| 188 | for (let i = 0; i < Math.max(x.nums.length, y.nums.length); i++) { |
| 189 | const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0); |
| 190 | if (d) return d < 0 ? -1 : 1; |
| 191 | } |
| 192 | if (x.pre === y.pre) return 0; |
| 193 | if (x.pre === null) return 1; |
| 194 | if (y.pre === null) return -1; |
| 195 | return x.pre < y.pre ? -1 : 1; |
| 196 | } |
| 197 | |
| 198 | /** The lines `git credential approve|reject` reads for a token on the server's host. */ |
| 199 | export function credentialInput(server: string, username: string, password: string, expiresAt?: number): string { |
| 200 | const u = new URL(server); |
| 201 | const lines = [`protocol=${u.protocol.replace(/:$/, "")}`, `host=${u.host}`, `username=${username}`, `password=${password}`]; |
| 202 | // Git 2.41+ passes this on, and helpers that keep it (Git Credential Manager, osxkeychain, libsecret, cache) stop |
| 203 | // handing out the token once it has expired, so Git asks the editor for a fresh one instead of failing. |
| 204 | if (expiresAt) lines.push(`password_expiry_utc=${Math.floor(expiresAt / 1000)}`); |
| 205 | if (/[\n\0]/.test(lines.join(""))) throw new Error("credential contains a newline"); |
| 206 | return `${lines.join("\n")}\n\n`; |
| 207 | } |
| 208 | |
| 209 | /** A repository name Diffinery accepts, from a folder name: unsupported characters become "-". */ |
| 210 | export function repoNameFrom(folder: string): string { |
| 211 | const n = folder |
| 212 | .trim() |
| 213 | .replace(/[^A-Za-z0-9._-]+/g, "-") |
| 214 | .replace(/^[-.]+|[-.]+$/g, "") |
| 215 | .slice(0, 100); |
| 216 | return n || "repository"; |
| 217 | } |