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.

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