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.

301 lines · 13.3 KBRaw History
1import { readFile } from "node:fs/promises";
2import path from "node:path";
3import { enc } from "../api.js";
4import { list, positiveInt, repoPath, str, type Command, type Group } from "../cli.js";
5import { CliError, ExitCode, readAll, valueFromFlagOrStdin } from "../io.js";
6import { ago, json, table } from "../output.js";
7
8const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
9function checkName(name: string, what: string): string {
10 if (!NAME_RE.test(name)) throw new CliError(`invalid ${what} name "${name}": use letters, digits and _, not starting with a digit`, 2);
11 return name;
12}
13
14// ---------------------------------------------------------------- secrets
15
16const secretSet: Command = {
17 name: "secret set",
18 summary: "Create or update an Actions secret",
19 description: "The value comes from --body, from stdin when piped, or a hidden prompt.\nRepository level by default; --org sets an organization secret.",
20 args: "<NAME>",
21 options: {
22 body: { type: "string", short: "b", desc: "Secret value", arg: "VALUE" },
23 org: { type: "string", short: "o", desc: "Set an organization secret instead", arg: "ORG" },
24 },
25 repo: true,
26 minArgs: 1,
27 maxArgs: 1,
28 async run(ctx, o, [name]) {
29 checkName(name!, "secret");
30 const org = str(o, "org");
31 const target = org ? `/orgs/${enc(org)}/actions/secrets/${enc(name!)}` : `${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`;
32 const value = await valueFromFlagOrStdin(ctx.io, str(o, "body"), `Value for ${name}: `);
33 if (!value) throw new CliError("secret value is empty");
34 await (await ctx.authedApi()).put(target, { value });
35 ctx.out(`${ctx.c.green("✓")} Set secret ${name}${org ? ` for ${org}` : ""}`);
36 },
37};
38
39const secretList: Command = {
40 name: "secret list",
41 summary: "List Actions secret names",
42 options: { org: { type: "string", short: "o", desc: "Organization secrets (not supported by the server yet)", arg: "ORG" } },
43 repo: true,
44 json: true,
45 maxArgs: 0,
46 async run(ctx, o) {
47 if (str(o, "org")) throw new CliError("listing organization secrets is not supported by this Diffinery server (no GET /orgs/:org/actions/secrets)");
48 const res = await (await ctx.authedApi()).get<{ secrets: Array<{ name: string; updated_at: string }> }>(`${repoPath(await ctx.repo())}/actions/secrets`);
49 if (ctx.json) return json(ctx.io, res.secrets);
50 if (!res.secrets.length) return ctx.err("No secrets.");
51 ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)])));
52 },
53};
54
55const secretDelete: Command = {
56 name: "secret delete",
57 summary: "Delete an Actions secret",
58 args: "<NAME>",
59 options: { org: { type: "string", short: "o", desc: "Organization secret (not supported by the server yet)", arg: "ORG" } },
60 repo: true,
61 minArgs: 1,
62 maxArgs: 1,
63 async run(ctx, o, [name]) {
64 if (str(o, "org")) throw new CliError("deleting organization secrets is not supported by this Diffinery server (no DELETE /orgs/:org/actions/secrets/:name)");
65 await (await ctx.authedApi()).delete(`${repoPath(await ctx.repo())}/actions/secrets/${enc(name!)}`);
66 ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`);
67 },
68};
69
70const variableSet: Command = {
71 name: "variable set",
72 summary: "Create or update an Actions variable",
73 description: "The value comes from --body, or from stdin when piped.",
74 args: "<NAME>",
75 options: { body: { type: "string", short: "b", desc: "Variable value", arg: "VALUE" } },
76 repo: true,
77 minArgs: 1,
78 maxArgs: 1,
79 async run(ctx, o, [name]) {
80 checkName(name!, "variable");
81 let value = str(o, "body");
82 if (value === undefined) {
83 if (ctx.io.stdin.isTTY) throw new CliError("pass --body VALUE or pipe the value on stdin", 2);
84 value = (await readAll(ctx.io.stdin)).replace(/\r?\n$/, "");
85 }
86 await (await ctx.authedApi()).put(`${repoPath(await ctx.repo())}/actions/variables/${enc(name!)}`, { value });
87 ctx.out(`${ctx.c.green("✓")} Set variable ${name}`);
88 },
89};
90
91const variableList: Command = {
92 name: "variable list",
93 summary: "List Actions variables",
94 repo: true,
95 json: true,
96 maxArgs: 0,
97 async run(ctx) {
98 const res = await (await ctx.api()).get<{ variables: Array<{ name: string; value: string }> }>(`${repoPath(await ctx.repo())}/actions/variables`);
99 if (ctx.json) return json(ctx.io, res.variables);
100 if (!res.variables.length) return ctx.err("No variables.");
101 ctx.io.stdout.write(table(res.variables.map((v) => [v.name, v.value])));
102 },
103};
104
105// ---------------------------------------------------------------- tokens
106
107interface TokenRow {
108 id: string;
109 name: string;
110 prefix: string;
111 scopes: string[];
112 repoIds: string[] | null;
113 expiresAt: string;
114 lastUsedAt: string | null;
115 createdAt: string;
116}
117
118const tokenCreate: Command = {
119 name: "token create",
120 summary: "Create a personal access token (printed once)",
121 description: "Needs an admin-scoped token (or create tokens in the web UI).",
122 options: {
123 name: { type: "string", short: "n", desc: "Token name (required)", arg: "NAME" },
124 scope: { type: "string", short: "s", multiple: true, desc: "read, write (default) or admin", arg: "SCOPE" },
125 "expires-days": { type: "string", short: "e", desc: "Days until it expires (1-366, default 90)", arg: "DAYS" },
126 repo: { type: "string", short: "R", multiple: true, desc: "Restrict the token to this repository", arg: "OWNER/NAME" },
127 },
128 json: true,
129 maxArgs: 0,
130 async run(ctx, o) {
131 const name = str(o, "name");
132 if (!name) throw new CliError("--name is required", 2);
133 const scopes = list(o, "scope").flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean);
134 for (const s of scopes) if (!["read", "write", "admin"].includes(s)) throw new CliError(`unknown scope "${s}" (read, write or admin)`, 2);
135 const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
136 const repos = list(o, "repo");
137 const t = await (await ctx.authedApi()).post<{ token: string; id: string; expiresAt: string }>("/diffinery/tokens", { name, scopes: scopes.length ? scopes : undefined, expires_in_days: days, repositories: repos.length ? repos : undefined });
138 if (ctx.json) return json(ctx.io, t);
139 ctx.out(t.token);
140 ctx.err(ctx.c.dim(`Token ${t.id} expires ${t.expiresAt.slice(0, 10)}. It will not be shown again.`));
141 },
142};
143
144const tokenList: Command = {
145 name: "token list",
146 summary: "List your tokens",
147 json: true,
148 maxArgs: 0,
149 async run(ctx) {
150 const rows = await (await ctx.authedApi()).get<TokenRow[]>("/diffinery/tokens");
151 if (ctx.json) return json(ctx.io, rows);
152 if (!rows.length) return ctx.err("No tokens.");
153 const c = ctx.c;
154 ctx.io.stdout.write(
155 table([
156 ["ID", "NAME", "PREFIX", "SCOPES", "EXPIRES", "LAST USED"].map((h) => c.dim(h)),
157 ...rows.map((t) => [t.id, t.name, `${t.prefix}…`, t.scopes.join(",") + (t.repoIds ? ` (${t.repoIds.length} repo${t.repoIds.length === 1 ? "" : "s"})` : ""), t.expiresAt.slice(0, 10), t.lastUsedAt ? ago(t.lastUsedAt) : c.dim("never")]),
158 ]),
159 );
160 },
161};
162
163const tokenRevoke: Command = {
164 name: "token revoke",
165 summary: "Revoke a token",
166 args: "<id>",
167 minArgs: 1,
168 maxArgs: 1,
169 async run(ctx, _o, [id]) {
170 await (await ctx.authedApi()).delete(`/diffinery/tokens/${enc(id!)}`);
171 ctx.out(`${ctx.c.green("✓")} Revoked token ${id}`);
172 },
173};
174
175// ---------------------------------------------------------------- agents & invites
176
177const agentCreate: Command = {
178 name: "agent create",
179 summary: "Create an agent account and print its token once",
180 args: "<login>",
181 options: {
182 org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" },
183 name: { type: "string", short: "n", desc: "Display name", arg: "NAME" },
184 scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" },
185 "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" },
186 },
187 json: true,
188 minArgs: 1,
189 maxArgs: 1,
190 async run(ctx, o, [login]) {
191 const scopes = list(o, "scope");
192 const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
193 const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", {
194 login,
195 name: str(o, "name"),
196 org: str(o, "org"),
197 token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined,
198 });
199 if (ctx.json) return json(ctx.io, r);
200 ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`);
201 ctx.out(r.token.token);
202 ctx.err(ctx.c.dim(`This token (expires ${r.token.expiresAt.slice(0, 10)}) will not be shown again. Give it to the agent as DIFFINERY_TOKEN.`));
203 },
204};
205
206const agentList: Command = {
207 name: "agent list",
208 summary: "List agents you or your organizations own",
209 json: true,
210 maxArgs: 0,
211 async run(ctx) {
212 const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents");
213 if (ctx.json) return json(ctx.io, rows);
214 if (!rows.length) return ctx.err("No agents.");
215 ctx.io.stdout.write(table(rows.map((a) => [ctx.c.bold(a.login), a.name && a.name !== a.login ? a.name : "", ctx.c.dim(typeof a.policy?.maxRole === "string" ? `max role ${a.policy.maxRole}` : "")])));
216 },
217};
218
219const agentDisable: Command = {
220 name: "agent disable",
221 summary: "Disable an agent (its tokens stop working)",
222 args: "<login>",
223 minArgs: 1,
224 maxArgs: 1,
225 async run(ctx, _o, [login]) {
226 await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`);
227 ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`);
228 },
229};
230
231const invite: Command = {
232 name: "invite",
233 summary: "Invite someone to an organization by email",
234 args: "<email>",
235 options: {
236 org: { type: "string", short: "o", desc: "Organization (required)", arg: "ORG" },
237 role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" },
238 },
239 json: true,
240 minArgs: 1,
241 maxArgs: 1,
242 async run(ctx, o, [email]) {
243 const org = str(o, "org");
244 if (!org) throw new CliError("--org is required (the server only supports organization invitations)", 2);
245 const role = str(o, "role");
246 if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2);
247 const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(`/orgs/${enc(org)}/invitations`, { email, role });
248 if (ctx.json) return json(ctx.io, r);
249 ctx.out(`${ctx.c.green("✓")} Invited ${email} to ${org}${role ? ` as ${role}` : ""}`);
250 ctx.out(r.url);
251 ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days."));
252 },
253};
254
255// ---------------------------------------------------------------- raw API
256
257const apiCmd: Command = {
258 name: "api",
259 summary: "Make an authenticated API request and print the response",
260 args: "<METHOD> <PATH>",
261 description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/.
262--input sends a JSON body from a file, or from stdin with '-'.`,
263 options: {
264 input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" },
265 field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" },
266 },
267 minArgs: 2,
268 maxArgs: 2,
269 async run(ctx, o, [method, p]) {
270 const m = method!.toUpperCase();
271 if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2);
272 let body: string | undefined;
273 const input = str(o, "input");
274 if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8");
275 const fields: Record<string, string> = {};
276 for (const f of list(o, "field")) {
277 const i = f.indexOf("=");
278 if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2);
279 fields[f.slice(0, i)] = f.slice(i + 1);
280 }
281 const hasFields = Object.keys(fields).length > 0;
282 if (hasFields && m !== "GET" && m !== "HEAD") {
283 if (body) throw new CliError("use either --input or -f, not both", 2);
284 body = JSON.stringify(fields);
285 }
286 const api = await ctx.api();
287 const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) });
288 const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2);
289 if (out) ctx.out(out);
290 if (res.status >= 400) {
291 ctx.err(ctx.c.red(`HTTP ${res.status}`));
292 throw new ExitCode(1);
293 }
294 },
295};
296
297export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] };
298export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] };
299export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] };
300export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] };
301export const adminCommands: Command[] = [invite, apiCmd];