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.

114 lines · 4.9 KBRaw History
1import path from "node:path";
2import { enc } from "../api.js";
3import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js";
4import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js";
5import { CliError } from "../io.js";
6import { ago, json, table, truncate } from "../output.js";
7
8export interface RepoJson {
9 id: string;
10 name: string;
11 full_name: string;
12 owner: { login: string; type: string };
13 private: boolean;
14 visibility: "public" | "internal" | "private";
15 description: string | null;
16 default_branch: string;
17 archived: boolean;
18 html_url: string;
19 clone_url: string;
20 pushed_at: string | null;
21 updated_at: string;
22 permissions?: { role: string };
23 stats?: Record<string, unknown>;
24}
25
26const create: Command = {
27 name: "repo create",
28 summary: "Create a repository",
29 args: "<name>",
30 options: {
31 org: { type: "string", desc: "Create it in this organization", arg: "ORG" },
32 private: { type: "boolean", desc: "Only collaborators can see it (default)" },
33 internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" },
34 public: { type: "boolean", desc: "Anyone can see it" },
35 description: { type: "string", short: "d", desc: "Description", arg: "TEXT" },
36 "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" },
37 },
38 json: true,
39 minArgs: 1,
40 maxArgs: 1,
41 async run(ctx, o, [name]) {
42 const visibility = exactlyOne(o, ["private", "internal", "public"], "private");
43 const api = await ctx.authedApi();
44 const org = str(o, "org");
45 const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") });
46 if (ctx.json) return json(ctx.io, r);
47 ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`);
48 ctx.out(r.html_url);
49 ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`));
50 },
51};
52
53const listCmd: Command = {
54 name: "repo list",
55 summary: "List repositories you can see",
56 options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } },
57 json: true,
58 maxArgs: 0,
59 async run(ctx, o) {
60 const api = await ctx.api();
61 const owner = str(o, "owner");
62 // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login.
63 const repos = await api.get<RepoJson[]>(owner ? `/users/${enc(owner)}/repos` : "/user/repos");
64 if (ctx.json) return json(ctx.io, repos);
65 if (!repos.length) return ctx.err("No repositories.");
66 ctx.io.stdout.write(table(repos.map((r) => [ctx.c.bold(r.full_name), r.archived ? ctx.c.dim("archived") : r.visibility, truncate(r.description, 50), ctx.c.dim(ago(r.pushed_at ?? r.updated_at))])));
67 },
68};
69
70const view: Command = {
71 name: "repo view",
72 summary: "Show a repository",
73 args: "[OWNER/NAME]",
74 repo: true,
75 json: true,
76 maxArgs: 1,
77 async run(ctx, _o, [arg]) {
78 const ref = arg ? parseRepoArg(arg) : await ctx.repo();
79 const r = await (await ctx.api()).get<RepoJson>(repoPath(ref));
80 if (ctx.json) return json(ctx.io, r);
81 const c = ctx.c;
82 ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`);
83 if (r.description) ctx.out(r.description);
84 const rows: string[][] = [
85 ["default branch", r.default_branch],
86 ["web", r.html_url],
87 ["clone", r.clone_url],
88 ];
89 if (r.permissions?.role) rows.push(["your role", r.permissions.role]);
90 if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]);
91 for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]);
92 ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!])));
93 },
94};
95
96const clone: Command = {
97 name: "repo clone",
98 summary: "Clone a repository",
99 description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.",
100 args: "<OWNER/NAME> [directory]",
101 minArgs: 1,
102 maxArgs: 2,
103 async run(ctx, _o, [arg, dir]) {
104 const ref = parseRepoArg(arg!);
105 const cfg = await ctx.config();
106 const url = `${cfg.url}/${ref.owner}/${ref.name}.git`;
107 const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]);
108 const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env });
109 if (code !== 0) throw new CliError(`git clone exited with ${code}`, code);
110 ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`));
111 },
112};
113
114export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] };