| 1 | import path from "node:path"; |
| 2 | import { enc } from "../api.js"; |
| 3 | import { exactlyOne, parseRepoArg, repoPath, str, type Command, type Group } from "../cli.js"; |
| 4 | import { basicAuthHeader, gitInteractive, withGitConfig } from "../git.js"; |
| 5 | import { CliError } from "../io.js"; |
| 6 | import { ago, json, table, truncate } from "../output.js"; |
| 7 | |
| 8 | export interface RepoJson { |
| 9 | id: string; |
| 10 | name: string; |
| 11 | full_name: string; |
| 12 | owner: { login: string; type: string }; |
| 13 | private: boolean; |
| 14 | visibility: "public" | "internal" | "private"; |
| 15 | description: string | null; |
| 16 | default_branch: string; |
| 17 | archived: boolean; |
| 18 | html_url: string; |
| 19 | clone_url: string; |
| 20 | pushed_at: string | null; |
| 21 | updated_at: string; |
| 22 | permissions?: { role: string }; |
| 23 | stats?: Record<string, unknown>; |
| 24 | } |
| 25 | |
| 26 | const create: Command = { |
| 27 | name: "repo create", |
| 28 | summary: "Create a repository", |
| 29 | args: "<name>", |
| 30 | options: { |
| 31 | org: { type: "string", desc: "Create it in this organization", arg: "ORG" }, |
| 32 | private: { type: "boolean", desc: "Only collaborators can see it (default)" }, |
| 33 | internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" }, |
| 34 | public: { type: "boolean", desc: "Anyone can see it" }, |
| 35 | description: { type: "string", short: "d", desc: "Description", arg: "TEXT" }, |
| 36 | "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" }, |
| 37 | }, |
| 38 | json: true, |
| 39 | minArgs: 1, |
| 40 | maxArgs: 1, |
| 41 | async run(ctx, o, [name]) { |
| 42 | const visibility = exactlyOne(o, ["private", "internal", "public"], "private"); |
| 43 | const api = await ctx.authedApi(); |
| 44 | const org = str(o, "org"); |
| 45 | const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") }); |
| 46 | if (ctx.json) return json(ctx.io, r); |
| 47 | ctx.out(`${ctx.c.green("✓")} Created ${ctx.c.bold(r.full_name)} (${r.visibility})`); |
| 48 | ctx.out(r.html_url); |
| 49 | ctx.err(ctx.c.dim(`git remote add origin ${r.clone_url}`)); |
| 50 | }, |
| 51 | }; |
| 52 | |
| 53 | const listCmd: Command = { |
| 54 | name: "repo list", |
| 55 | summary: "List repositories you can see", |
| 56 | options: { owner: { type: "string", desc: "Only repositories owned by this user or organization", arg: "OWNER" } }, |
| 57 | json: true, |
| 58 | maxArgs: 0, |
| 59 | async run(ctx, o) { |
| 60 | const api = await ctx.api(); |
| 61 | const owner = str(o, "owner"); |
| 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"); |
| 64 | if (ctx.json) return json(ctx.io, repos); |
| 65 | if (!repos.length) return ctx.err("No repositories."); |
| 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))]))); |
| 67 | }, |
| 68 | }; |
| 69 | |
| 70 | const view: Command = { |
| 71 | name: "repo view", |
| 72 | summary: "Show a repository", |
| 73 | args: "[OWNER/NAME]", |
| 74 | repo: true, |
| 75 | json: true, |
| 76 | maxArgs: 1, |
| 77 | async run(ctx, _o, [arg]) { |
| 78 | const ref = arg ? parseRepoArg(arg) : await ctx.repo(); |
| 79 | const r = await (await ctx.api()).get<RepoJson>(repoPath(ref)); |
| 80 | if (ctx.json) return json(ctx.io, r); |
| 81 | const c = ctx.c; |
| 82 | ctx.out(`${c.bold(r.full_name)} ${c.dim(r.visibility)}${r.archived ? c.yellow(" archived") : ""}`); |
| 83 | if (r.description) ctx.out(r.description); |
| 84 | const rows: string[][] = [ |
| 85 | ["default branch", r.default_branch], |
| 86 | ["web", r.html_url], |
| 87 | ["clone", r.clone_url], |
| 88 | ]; |
| 89 | if (r.permissions?.role) rows.push(["your role", r.permissions.role]); |
| 90 | if (r.pushed_at) rows.push(["last push", ago(r.pushed_at)]); |
| 91 | for (const [k, v] of Object.entries(r.stats ?? {})) if (typeof v === "number" || typeof v === "string") rows.push([k.replace(/_/g, " "), String(v)]); |
| 92 | ctx.io.stdout.write(table(rows.map(([k, v]) => [c.dim(k!), v!]))); |
| 93 | }, |
| 94 | }; |
| 95 | |
| 96 | const clone: Command = { |
| 97 | name: "repo clone", |
| 98 | summary: "Clone a repository", |
| 99 | description: "The token is passed to this one git command as an HTTP header and is never written to .git/config.\nRun 'diffinery auth setup-git' so later fetches and pushes authenticate too.", |
| 100 | args: "<OWNER/NAME> [directory]", |
| 101 | minArgs: 1, |
| 102 | maxArgs: 2, |
| 103 | async run(ctx, _o, [arg, dir]) { |
| 104 | const ref = parseRepoArg(arg!); |
| 105 | const cfg = await ctx.config(); |
| 106 | const url = `${cfg.url}/${ref.owner}/${ref.name}.git`; |
| 107 | const env = cfg.token ? withGitConfig(ctx.io.env, [basicAuthHeader(cfg.url, "x-token", cfg.token), ["protocol.version", "2"]]) : withGitConfig(ctx.io.env, [["protocol.version", "2"]]); |
| 108 | const code = await gitInteractive(ctx.io, ["clone", url, ...(dir ? [dir] : [])], { env }); |
| 109 | if (code !== 0) throw new CliError(`git clone exited with ${code}`, code); |
| 110 | ctx.err(ctx.c.dim(`Cloned into ${dir ?? path.basename(ref.name)}. Tip: 'diffinery auth setup-git' lets plain git push/fetch authenticate.`)); |
| 111 | }, |
| 112 | }; |
| 113 | |
| 114 | export const repoGroup: Group = { name: "repo", summary: "Create, list, view and clone repositories", commands: [create, listCmd, view, clone] }; |