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.

112 lines · 5.8 KBRaw History
1import { spawn } from "node:child_process";
2import * as path from "node:path";
3import * as vscode from "vscode";
4import { AUTH_ID, SCOPES, type DiffineryAuth, type Stored } from "./auth";
5import type { Credentials, CredentialsProvider, GitAPI, RemoteSourcePublisher, Repository } from "./typings/git";
6import { 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). */
9export class GitCredentials implements CredentialsProvider {
10 constructor(private readonly auth: DiffineryAuth) {}
11
12 async getCredentials(host: vscode.Uri): Promise<Credentials | undefined> {
13 let server: string;
14 try {
15 server = this.auth.server();
16 } catch {
17 return undefined;
18 }
19 if (host.authority.toLowerCase() !== new URL(server).host.toLowerCase()) return undefined;
20 const session = await vscode.authentication.getSession(AUTH_ID, SCOPES, { createIfNone: true });
21 // Signing in starts by choosing the server: if another one was chosen, its token isn't for this host.
22 if (this.auth.server() !== server) return undefined;
23 return { username: session.account.label, password: session.accessToken };
24 }
25}
26
27/**
28 * Keeps the token Git saved for the Diffinery server current. After Git uses a password from the editor it saves
29 * it with the computer's credential helper (the macOS keychain, Git Credential Manager on Windows, libsecret), and
30 * asks that helper first next time. Signed in through the browser, the token changes every few hours, so each
31 * renewal replaces the saved one (with its expiry, for helpers that honour it), and signing out removes it.
32 */
33export function syncCredentialStore(auth: DiffineryAuth, git: () => GitAPI | undefined, log: vscode.LogOutputChannel): vscode.Disposable {
34 return auth.onDidStore(async ({ before, after }) => {
35 if (!vscode.workspace.getConfiguration("diffinery").get<boolean>("gitAuthentication", true)) return;
36 const gitPath = git()?.git.path;
37 if (!gitPath) return;
38 try {
39 if (before && before.accessToken !== after?.accessToken) await credential(gitPath, "reject", before);
40 if (after && after.accessToken !== before?.accessToken) await credential(gitPath, "approve", after);
41 } catch (e) {
42 log.warn("updating Git's saved Diffinery credential failed", e);
43 }
44 });
45}
46
47function credential(gitPath: string, op: "approve" | "reject", s: Stored): Promise<void> {
48 return new Promise((resolve, reject) => {
49 const child = spawn(gitPath, ["credential", op], {
50 // Never prompt: approve and reject only talk to the configured helpers.
51 env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", SSH_ASKPASS: "", GCM_INTERACTIVE: "never" },
52 stdio: ["pipe", "ignore", "pipe"],
53 timeout: 15_000,
54 windowsHide: true,
55 });
56 let err = "";
57 child.stderr.on("data", (d) => (err += d));
58 child.on("error", reject);
59 child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`git credential ${op} exited ${code}: ${err.trim()}`))));
60 child.stdin.end(credentialInput(s.server, s.login, s.accessToken, s.kind === "oauth" ? s.expiresAt : undefined));
61 });
62}
63
64/** "Publish to Diffinery" when publishing a repository that has no remote yet. */
65export class Publisher implements RemoteSourcePublisher {
66 readonly name = "Diffinery";
67 readonly icon = "diffinery-logo";
68
69 constructor(private readonly auth: DiffineryAuth) {}
70
71 async publishRepository(repository: Repository): Promise<void> {
72 const api = await this.auth.api(true);
73 if (!api) return;
74 const head = repository.state.HEAD;
75 if (!head?.commit) {
76 void vscode.window.showWarningMessage("Commit your changes first, then publish the repository to Diffinery.");
77 return;
78 }
79 const [me, orgs] = await Promise.all([api.user(), api.orgs().catch(() => [])]);
80 const owners = [
81 { label: me.login, description: "your account", owner: null as string | null },
82 ...orgs.map((o) => ({ label: o.login, description: "organization", owner: o.login as string | null })),
83 ];
84 const owner = owners.length === 1 ? owners[0] : await vscode.window.showQuickPick(owners, { title: "Publish to Diffinery", placeHolder: "Owner of the new repository" });
85 if (!owner) return;
86 const name = await vscode.window.showInputBox({
87 title: "Publish to Diffinery",
88 prompt: `Repository name (it will be ${owner.label}/<name>)`,
89 value: repoNameFrom(path.basename(repository.rootUri.fsPath)),
90 validateInput: (v) => (/^[A-Za-z0-9._-]{1,100}$/.test(v) && v !== "." && v !== ".." ? null : "Use letters, digits, '.', '-' or '_'."),
91 });
92 if (!name) return;
93 const vis = await vscode.window.showQuickPick(
94 [
95 { label: "$(lock) Private", description: "Only people and teams you give access can see it.", private: true },
96 { label: "$(globe) Public", description: "Anyone on the internet can see and clone it.", private: false },
97 ],
98 { title: "Publish to Diffinery", placeHolder: `Who can see ${owner.label}/${name}?` },
99 );
100 if (!vis) return;
101 const branch = head.name;
102 const created = await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: `Publishing to Diffinery as ${owner.label}/${name}…` }, async () => {
103 const repo = await api.createRepo(owner.owner, { name, private: vis.private });
104 const remote = repository.state.remotes.some((r) => r.name === "origin") ? "diffinery" : "origin";
105 await repository.addRemote(remote, repo.clone_url);
106 if (branch) await repository.push(remote, branch, true);
107 return repo;
108 });
109 const open = await vscode.window.showInformationMessage(`Published ${created.full_name} to Diffinery.`, "Open on Diffinery");
110 if (open) void vscode.env.openExternal(vscode.Uri.parse(created.html_url));
111 }
112}