Diffinery

codician/diffinerypublic

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

177 lines · 7.6 KBRaw History
1// Pure helpers, without the vscode module, so the unit tests can run them under Node.
2import { createHash, randomBytes } from "node:crypto";
3
4export 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). */
6export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"];
7export 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. */
10export 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. */
24export 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). */
27export function editorRedirect(scheme: string): string | null {
28 return EDITOR_SCHEMES.includes(scheme) ? `${scheme}://${EXTENSION_ID}/oauth` : null;
29}
30
31export const b64url = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url");
32
33export 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
38export 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 */
47export 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
58export interface RepoRef {
59 owner: string;
60 repo: string;
61}
62
63const 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. */
66export 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
92export 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. */
109export 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. */
116export 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. */
131export 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). */
141export 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. */
159export 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 "-". */
170export 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}