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.

CLI (diffinery): repos, PRs, issues, runs, secrets, agents, self-hosted runner, GitHub migration

Also fills API gaps the CLI needed: user repo listing, org secret
list/delete, variable delete and site-wide invitations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
Claude authored signed1 parent 099d8a4commit aa9f371cef8faf83d5fac2057b2045dbd1800e4fBrowse files
4 files changed+62−14
Files (4)
modifiedapps/cli/src/commands/admin.ts+12−11View file
@@ -39,13 +39,14 @@
3939 const secretList: Command = {
4040 name: "secret list",
4141 summary: "List Actions secret names",
42- options: { org: { type: "string", short: "o", desc: "Organization secrets (not supported by the server yet)", arg: "ORG" } },
42+ options: { org: { type: "string", short: "o", desc: "List organization secrets instead", arg: "ORG" } },
4343 repo: true,
4444 json: true,
4545 maxArgs: 0,
4646 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`);
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);
4950 if (ctx.json) return json(ctx.io, res.secrets);
5051 if (!res.secrets.length) return ctx.err("No secrets.");
5152 ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)])));
@@ -56,13 +57,13 @@
5657 name: "secret delete",
5758 summary: "Delete an Actions secret",
5859 args: "<NAME>",
59- options: { org: { type: "string", short: "o", desc: "Organization secret (not supported by the server yet)", arg: "ORG" } },
60+ options: { org: { type: "string", short: "o", desc: "Delete an organization secret instead", arg: "ORG" } },
6061 repo: true,
6162 minArgs: 1,
6263 maxArgs: 1,
6364 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!)}`);
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!)}`);
6667 ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`);
6768 },
6869 };
@@ -230,10 +231,10 @@
230231
231232 const invite: Command = {
232233 name: "invite",
233- summary: "Invite someone to an organization by email",
234+ summary: "Invite someone by email (to an organization, or to the site as an admin)",
234235 args: "<email>",
235236 options: {
236- org: { type: "string", short: "o", desc: "Organization (required)", arg: "ORG" },
237+ org: { type: "string", short: "o", desc: "Organization to join (omit for a site invite; site admins only)", arg: "ORG" },
237238 role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" },
238239 },
239240 json: true,
@@ -241,12 +242,12 @@
241242 maxArgs: 1,
242243 async run(ctx, o, [email]) {
243244 const org = str(o, "org");
244- if (!org) throw new CliError("--org is required (the server only supports organization invitations)", 2);
245245 const role = str(o, "role");
246+ if (!org && role) throw new CliError("--role needs --org", 2);
246247 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+ const r = await (await ctx.authedApi()).post<{ id: string; url: string }>(org ? `/orgs/${enc(org)}/invitations` : "/invitations", org ? { email, role } : { email });
248249 if (ctx.json) return json(ctx.io, r);
249- ctx.out(`${ctx.c.green("✓")} Invited ${email} to ${org}${role ? ` as ${role}` : ""}`);
250+ ctx.out(`${ctx.c.green("✓")} Invited ${email}${org ? ` to ${org}` : ""}${role ? ` as ${role}` : ""}`);
250251 ctx.out(r.url);
251252 ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days."));
252253 },
modifiedapps/cli/src/commands/repo.ts+1−1View file
@@ -60,7 +60,7 @@
6060 const api = await ctx.api();
6161 const owner = str(o, "owner");
6262 // The server has no /users/:login/repos; /orgs/:owner/repos accepts any account login.
63- const repos = await api.get<RepoJson[]>(owner ? `/orgs/${enc(owner)}/repos` : "/user/repos");
63+ const repos = await api.get<RepoJson[]>(owner ? `/users/${enc(owner)}/repos` : "/user/repos");
6464 if (ctx.json) return json(ctx.io, repos);
6565 if (!repos.length) return ctx.err("No repositories.");
6666 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))])));
modifiedapps/diffinery/src/http/api.ts+39−2View file
@@ -98,6 +98,18 @@
9898 return c.json({ id: inv.id, url: inv.url }, 201);
9999 });
100100
101+// Site-wide invitation (no organization): site admins only.
102+api.post("/invitations", async (c) => {
103+ const a = requireAccount(c);
104+ if (!a.site_admin) throw new HttpError(403, "only site admins can invite people without an organization");
105+ const b = await body<{ email: string }>(c);
106+ if (!/^[^@\s]+@[^@\s]+$/.test(b.email ?? "")) throw new HttpError(422, "a valid email is required");
107+ const dir = c.get("dir");
108+ const inv = await dir.createInvite(a, { email: b.email, role: "member" });
109+ c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "site.invite", detail: { email: b.email } }));
110+ return c.json({ id: inv.id, url: inv.url }, 201);
111+});
112+
101113 // ---------------------------------------------------------------- repositories
102114
103115 api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r))));
@@ -108,6 +120,12 @@
108120 const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
109121 return c.json(repoJson(c, r), 201);
110122 });
123+api.get("/users/:login/repos", async (c) => {
124+ const a = requireAccount(c);
125+ const owner = await c.get("dir").accountByLogin(c.req.param("login"));
126+ if (!owner) throw new HttpError(404, "not found");
127+ return c.json((await c.get("dir").reposVisibleTo(a, owner.id)).map((r) => repoJson(c, r)));
128+});
111129 api.get("/orgs/:org/repos", async (c) => {
112130 const a = requireAccount(c);
113131 const org = await c.get("dir").accountByLogin(c.req.param("org"));
@@ -543,19 +561,38 @@
543561 const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write");
544562 return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) });
545563 });
564+api.delete("/repos/:owner/:repo/actions/variables/:name", async (c) => {
565+ const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
566+ await stub.setVariable(c.req.param("name"), null);
567+ return c.body(null, 204);
568+});
546569 api.put("/repos/:owner/:repo/actions/variables/:name", async (c) => {
547570 const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin");
548571 await stub.setVariable(c.req.param("name"), (await body<{ value: string }>(c)).value);
549572 return c.body(null, 204);
550573 });
551-api.put("/orgs/:org/actions/secrets/:name", async (c) => {
574+async function orgAdmin(c: AppContext) {
552575 const a = requireAccount(c);
553576 if (a.kind === "agent") throw new HttpError(403, "agents cannot manage secrets");
554577 const dir = c.get("dir");
555- const org = await dir.accountByLogin(c.req.param("org"));
578+ const org = await dir.accountByLogin(c.req.param("org")!);
556579 if (!org || org.kind !== "org") throw new HttpError(404, "not found");
557580 const role = await dir.orgRole(org.id, a.id);
558581 if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "organization admins only");
582+ return { a, org };
583+}
584+api.get("/orgs/:org/actions/secrets", async (c) => {
585+ const { org } = await orgAdmin(c);
586+ const { results } = await c.env.DB.prepare("SELECT name, updated_at FROM org_secrets WHERE org_id = ? ORDER BY name").bind(org.id).all<{ name: string; updated_at: string }>();
587+ return c.json({ total_count: results.length, secrets: results });
588+});
589+api.delete("/orgs/:org/actions/secrets/:name", async (c) => {
590+ const { org } = await orgAdmin(c);
591+ await c.env.DB.prepare("DELETE FROM org_secrets WHERE org_id = ? AND name = ?").bind(org.id, c.req.param("name").toUpperCase()).run();
592+ return c.body(null, 204);
593+});
594+api.put("/orgs/:org/actions/secrets/:name", async (c) => {
595+ const { org } = await orgAdmin(c);
559596 const name = c.req.param("name").toUpperCase();
560597 if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new HttpError(422, "invalid secret name");
561598 const { value } = await body<{ value: string }>(c);
modifiedapps/diffinery/test/e2e/e2e.test.mjs+10−0View file
@@ -326,6 +326,16 @@
326326 const forged = await fetch(`${base}/${owner}/demo/issues/1/comment`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "body=hi" });
327327 assert.equal(forged.status, 403);
328328
329+ // ------------------------------------------------------------ the CLI against the same server
330+ const CLI = resolve(APP, "../cli/dist/diffinery.mjs");
331+ if (existsSync(CLI)) {
332+ const cli = (...args) => execFileSync(process.execPath, [CLI, ...args], { encoding: "utf8", cwd: clone, env: { ...process.env, DIFFINERY_URL: base, DIFFINERY_TOKEN: token, NO_COLOR: "1", XDG_CONFIG_HOME: join(work, "cfg") } });
333+ assert.match(cli("whoami"), new RegExp(owner));
334+ const prs = JSON.parse(cli("pr", "list", "--state", "all", "--json"));
335+ assert.ok(prs.some((p) => p.number === 1));
336+ assert.match(cli("workflow", "validate"), /ci\.yml/);
337+ }
338+
329339 // The commit made through MCP carries the agent's identity.
330340 const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`);
331341 assert.match(JSON.stringify(agentCommit), /e2e-bot/);