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
4 files changed+62−14
Files (4)
modifiedapps/cli/src/commands/admin.ts+12−11View file
| @@ -39,13 +39,14 @@ | ||
| 39 | 39 | const secretList: Command = { |
| 40 | 40 | name: "secret list", |
| 41 | 41 | 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" } }, | |
| 43 | 43 | repo: true, |
| 44 | 44 | json: true, |
| 45 | 45 | maxArgs: 0, |
| 46 | 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`); | |
| 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); | |
| 49 | 50 | if (ctx.json) return json(ctx.io, res.secrets); |
| 50 | 51 | if (!res.secrets.length) return ctx.err("No secrets."); |
| 51 | 52 | ctx.io.stdout.write(table(res.secrets.map((s) => [s.name, ctx.c.dim(`updated ${ago(s.updated_at)}`)]))); |
| @@ -56,13 +57,13 @@ | ||
| 56 | 57 | name: "secret delete", |
| 57 | 58 | summary: "Delete an Actions secret", |
| 58 | 59 | 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" } }, | |
| 60 | 61 | repo: true, |
| 61 | 62 | minArgs: 1, |
| 62 | 63 | maxArgs: 1, |
| 63 | 64 | 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!)}`); | |
| 66 | 67 | ctx.out(`${ctx.c.green("✓")} Deleted secret ${name}`); |
| 67 | 68 | }, |
| 68 | 69 | }; |
| @@ -230,10 +231,10 @@ | ||
| 230 | 231 | |
| 231 | 232 | const invite: Command = { |
| 232 | 233 | 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)", | |
| 234 | 235 | args: "<email>", |
| 235 | 236 | 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" }, | |
| 237 | 238 | role: { type: "string", short: "r", desc: "member (default), admin or owner", arg: "ROLE" }, |
| 238 | 239 | }, |
| 239 | 240 | json: true, |
| @@ -241,12 +242,12 @@ | ||
| 241 | 242 | maxArgs: 1, |
| 242 | 243 | async run(ctx, o, [email]) { |
| 243 | 244 | const org = str(o, "org"); |
| 244 | - if (!org) throw new CliError("--org is required (the server only supports organization invitations)", 2); | |
| 245 | 245 | const role = str(o, "role"); |
| 246 | + if (!org && role) throw new CliError("--role needs --org", 2); | |
| 246 | 247 | 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 }); | |
| 248 | 249 | 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}` : ""}`); | |
| 250 | 251 | ctx.out(r.url); |
| 251 | 252 | ctx.err(ctx.c.dim("Send them this link; it is valid for 14 days.")); |
| 252 | 253 | }, |
modifiedapps/cli/src/commands/repo.ts+1−1View file
| @@ -60,7 +60,7 @@ | ||
| 60 | 60 | const api = await ctx.api(); |
| 61 | 61 | const owner = str(o, "owner"); |
| 62 | 62 | // 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"); | |
| 64 | 64 | if (ctx.json) return json(ctx.io, repos); |
| 65 | 65 | if (!repos.length) return ctx.err("No repositories."); |
| 66 | 66 | 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 @@ | ||
| 98 | 98 | return c.json({ id: inv.id, url: inv.url }, 201); |
| 99 | 99 | }); |
| 100 | 100 | |
| 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 | + | |
| 101 | 113 | // ---------------------------------------------------------------- repositories |
| 102 | 114 | |
| 103 | 115 | api.get("/user/repos", async (c) => c.json((await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r)))); |
| @@ -108,6 +120,12 @@ | ||
| 108 | 120 | 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 }); |
| 109 | 121 | return c.json(repoJson(c, r), 201); |
| 110 | 122 | }); |
| 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 | +}); | |
| 111 | 129 | api.get("/orgs/:org/repos", async (c) => { |
| 112 | 130 | const a = requireAccount(c); |
| 113 | 131 | const org = await c.get("dir").accountByLogin(c.req.param("org")); |
| @@ -543,19 +561,38 @@ | ||
| 543 | 561 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "write"); |
| 544 | 562 | return c.json({ variables: Object.entries(await stub.variables()).map(([name, value]) => ({ name, value })) }); |
| 545 | 563 | }); |
| 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 | +}); | |
| 546 | 569 | api.put("/repos/:owner/:repo/actions/variables/:name", async (c) => { |
| 547 | 570 | const { stub } = await loadRepo(c, P(c).owner, P(c).repo, "admin"); |
| 548 | 571 | await stub.setVariable(c.req.param("name"), (await body<{ value: string }>(c)).value); |
| 549 | 572 | return c.body(null, 204); |
| 550 | 573 | }); |
| 551 | -api.put("/orgs/:org/actions/secrets/:name", async (c) => { | |
| 574 | +async function orgAdmin(c: AppContext) { | |
| 552 | 575 | const a = requireAccount(c); |
| 553 | 576 | if (a.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); |
| 554 | 577 | 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")!); | |
| 556 | 579 | if (!org || org.kind !== "org") throw new HttpError(404, "not found"); |
| 557 | 580 | const role = await dir.orgRole(org.id, a.id); |
| 558 | 581 | 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); | |
| 559 | 596 | const name = c.req.param("name").toUpperCase(); |
| 560 | 597 | if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) throw new HttpError(422, "invalid secret name"); |
| 561 | 598 | const { value } = await body<{ value: string }>(c); |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+10−0View file
| @@ -326,6 +326,16 @@ | ||
| 326 | 326 | 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" }); |
| 327 | 327 | assert.equal(forged.status, 403); |
| 328 | 328 | |
| 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 | + | |
| 329 | 339 | // The commit made through MCP carries the agent's identity. |
| 330 | 340 | const agentCommit = await ok("GET", `/repos/${owner}/demo/commits/mcp-change`); |
| 331 | 341 | assert.match(JSON.stringify(agentCommit), /e2e-bot/); |