| 1 | import { readFile } from "node:fs/promises"; |
| 2 | import path from "node:path"; |
| 3 | import { enc } from "../api.js"; |
| 4 | import { list, positiveInt, repoPath, str, type Command, type Group } from "../cli.js"; |
| 5 | import { CliError, ExitCode, readAll, valueFromFlagOrStdin } from "../io.js"; |
| 6 | import { ago, json, table } from "../output.js"; |
| 7 | |
| 8 | const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; |
| 9 | function 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 | |
| 16 | const 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 | |
| 39 | const 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 | |
| 56 | const 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 | |
| 71 | const 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 | |
| 92 | const 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 | |
| 108 | interface 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 | |
| 119 | const 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 | |
| 145 | const 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 | |
| 164 | const 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 | |
| 178 | const 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 | |
| 207 | const 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 | |
| 220 | const 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 | |
| 232 | const 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 | |
| 258 | const 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 | |
| 298 | export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] }; |
| 299 | export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] }; |
| 300 | export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] }; |
| 301 | export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] }; |
| 302 | export const adminCommands: Command[] = [invite, apiCmd]; |