| 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. |
| 20 | Repository level by default; --org sets an organization secret. An organization secret |
| 21 | reaches 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 | |
| 51 | const 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 | |
| 68 | const 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 | |
| 83 | const 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 | |
| 104 | const 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 | |
| 120 | interface 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 | |
| 131 | const 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 | |
| 157 | const 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 | |
| 176 | const 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 | |
| 190 | const 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 | |
| 220 | const 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 | |
| 233 | const 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 | |
| 245 | const 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 | |
| 271 | const 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 | |
| 311 | export const secretGroup: Group = { name: "secret", summary: "Manage Actions secrets", commands: [secretSet, secretList, secretDelete] }; |
| 312 | export const variableGroup: Group = { name: "variable", summary: "Manage Actions variables", commands: [variableSet, variableList] }; |
| 313 | export const tokenGroup: Group = { name: "token", summary: "Manage personal access tokens", commands: [tokenCreate, tokenList, tokenRevoke] }; |
| 314 | export const agentGroup: Group = { name: "agent", summary: "Manage agent accounts", commands: [agentCreate, agentList, agentDisable] }; |
| 315 | export const adminCommands: Command[] = [invite, apiCmd]; |