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.

217 lines · 9.4 KBRaw History
1// Pure helpers, without the vscode module, so the unit tests can run them under Node.
2import { createHash, randomBytes } from "node:crypto";
3import * as path from "node:path";
4
5export 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). */
7export const EDITOR_SCHEMES = ["vscode", "vscode-insiders", "cursor", "windsurf", "vscodium", "positron", "kiro"];
8export const DEFAULT_SERVER = "https://diffinery.com";
9
10function 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. */
23export 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. */
31export 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. */
40export 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 */
50export 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. */
57export 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. */
64export 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). */
67export function editorRedirect(scheme: string): string | null {
68 return EDITOR_SCHEMES.includes(scheme) ? `${scheme}://${EXTENSION_ID}/oauth` : null;
69}
70
71export const b64url = (b: Uint8Array | Buffer) => Buffer.from(b).toString("base64url");
72
73export 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
78export 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 */
87export 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
98export interface RepoRef {
99 owner: string;
100 repo: string;
101}
102
103const 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. */
106export 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
132export 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. */
149export 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. */
156export 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. */
171export 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). */
181export 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. */
199export 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 "-". */
210export 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}