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.

303 lines · 13.5 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: "Tokens can't create tokens, so on diffinery.com this needs the web UI (Settings → Access tokens); the command works against a local development server.",
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 description: "Creating an agent mints its first token, which tokens can't do: on diffinery.com create agents in the web UI (Settings → Agents); the command works against a local development server.",
182 args: "<login>",
183 options: {
184 org: { type: "string", short: "o", desc: "Owned by this organization instead of you", arg: "ORG" },
185 name: { type: "string", short: "n", desc: "Display name", arg: "NAME" },
186 scope: { type: "string", short: "s", multiple: true, desc: "Token scope: read, write (default) or admin", arg: "SCOPE" },
187 "expires-days": { type: "string", short: "e", desc: "Token lifetime in days (default 90)", arg: "DAYS" },
188 },
189 json: true,
190 minArgs: 1,
191 maxArgs: 1,
192 async run(ctx, o, [login]) {
193 const scopes = list(o, "scope");
194 const days = str(o, "expires-days") ? positiveInt(str(o, "expires-days"), "--expires-days") : undefined;
195 const r = await (await ctx.authedApi()).post<{ agent: { login: string; html_url: string }; token: { token: string; id: string; expiresAt: string } }>("/diffinery/agents", {
196 login,
197 name: str(o, "name"),
198 org: str(o, "org"),
199 token: scopes.length || days ? { scopes: scopes.length ? scopes : undefined, expires_in_days: days } : undefined,
200 });
201 if (ctx.json) return json(ctx.io, r);
202 ctx.out(`${ctx.c.green("✓")} Created agent ${ctx.c.bold(r.agent.login)}`);
203 ctx.out(r.token.token);
204 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.`));
205 },
206};
207
208const agentList: Command = {
209 name: "agent list",
210 summary: "List agents you or your organizations own",
211 json: true,
212 maxArgs: 0,
213 async run(ctx) {
214 const rows = await (await ctx.authedApi()).get<Array<{ login: string; name: string | null; owner_id: string; policy: Record<string, unknown> }>>("/diffinery/agents");
215 if (ctx.json) return json(ctx.io, rows);
216 if (!rows.length) return ctx.err("No agents.");
217 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}` : "")])));
218 },
219};
220
221const agentDisable: Command = {
222 name: "agent disable",
223 summary: "Disable an agent (its tokens stop working)",
224 args: "<login>",
225 minArgs: 1,
226 maxArgs: 1,
227 async run(ctx, _o, [login]) {
228 await (await ctx.authedApi()).delete(`/diffinery/agents/${enc(login!)}`);
229 ctx.out(`${ctx.c.green("✓")} Disabled agent ${login}`);
230 },
231};
232
233const invite: Command = {
234 name: "invite",
235 summary: "Invite someone by email (to an organization, or to the site as an admin)",
236 args: "<email>",
237 options: {
238 org: { type: "string", short: "o", desc: "Organization to join (omit for a site invite; site admins only)", arg: "ORG" },
239 role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" },
240 },
241 json: true,
242 minArgs: 1,
243 maxArgs: 1,
244 async run(ctx, o, [email]) {
245 const org = str(o, "org");
246 const role = str(o, "role");
247 if (!org && role) throw new CliError("--role needs --org", 2);
248 if (role && !["member", "admin", "owner"].includes(role)) throw new CliError("--role must be member, admin or owner", 2);
249 const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(org ? `/orgs/${enc(org)}/invitations` : "/invitations", org ? { email, role } : { email });
250 if (ctx.json) return json(ctx.io, r);
251 ctx.out(`${ctx.c.green("✓")} Invited ${email}${org ? ` to ${org}` : ""}${role ? ` as ${role}` : ""}`);
252 ctx.out(r.url);
253 ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days."));
254 },
255};
256
257// ---------------------------------------------------------------- raw API
258
259const apiCmd: Command = {
260 name: "api",
261 summary: "Make an authenticated API request and print the response",
262 args: "<METHOD> <PATH>",
263 description: `PATH is relative to /api/v3 (e.g. /user, /repos/OWNER/NAME/pulls) unless it starts with /api/.
264--input sends a JSON body from a file, or from stdin with '-'.`,
265 options: {
266 input: { type: "string", short: "i", desc: "Request body file, or '-' for stdin", arg: "FILE" },
267 field: { type: "string", short: "f", multiple: true, desc: "Add a string field to the JSON body (or query for GET)", arg: "KEY=VALUE" },
268 },
269 minArgs: 2,
270 maxArgs: 2,
271 async run(ctx, o, [method, p]) {
272 const m = method!.toUpperCase();
273 if (!/^[A-Z]+$/.test(m)) throw new CliError(`invalid method "${method}"`, 2);
274 let body: string | undefined;
275 const input = str(o, "input");
276 if (input) body = input === "-" ? await readAll(ctx.io.stdin) : await readFile(path.resolve(ctx.io.cwd, input), "utf8");
277 const fields: Record<string, string> = {};
278 for (const f of list(o, "field")) {
279 const i = f.indexOf("=");
280 if (i <= 0) throw new CliError(`-f expects key=value, got "${f}"`, 2);
281 fields[f.slice(0, i)] = f.slice(i + 1);
282 }
283 const hasFields = Object.keys(fields).length > 0;
284 if (hasFields && m !== "GET" && m !== "HEAD") {
285 if (body) throw new CliError("use either --input or -f, not both", 2);
286 body = JSON.stringify(fields);
287 }
288 const api = await ctx.api();
289 const res = await api.raw<unknown>(m, p!, { body, query: m === "GET" && hasFields ? fields : undefined, allow: Array.from({ length: 500 }, (_, i) => i + 100) });
290 const out = typeof res.data === "string" ? res.data : res.data === null ? "" : JSON.stringify(res.data, null, 2);
291 if (out) ctx.out(out);
292 if (res.status >= 400) {
293 ctx.err(ctx.c.red(`HTTP ${res.status}`));
294 throw new ExitCode(1);
295 }
296 },
297};
298
299export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] };
300export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] };
301export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] };
302export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] };
303export const adminCommands: Command[] = [invite, apiCmd];