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.

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