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.

110 lines · 5.7 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: 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 */
31export 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
45function 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. */
63export class Publisher implements RemoteSourcePublisher {
64 readonly name = "Diffinery";
65 readonly icon = "diffinery-logo";
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}