@lizard-build/cli 4.0.59 → 4.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1110 @@
1
+ import chalk from "chalk";
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
4
+ import { basename } from "node:path";
5
+ import { APIError } from "../lib/api.js";
6
+ import { validToken } from "../lib/auth.js";
7
+ import { isJSONMode, printJSON, table, success, info, warn, link } from "../lib/format.js";
8
+ import { lg, qs, seg, segs, resolveRepo, repoPath, myLogin, cloneURL, gitURL, helperCommand, ago, ACTIVE_RUN, } from "../lib/lizard-git.js";
9
+ /**
10
+ * Lizard Git (git.lizard.build) from the terminal: repositories, issues, pull requests,
11
+ * reviews, CI runs, releases, secrets, collaborators, webhooks, branch rules, labels,
12
+ * notifications, tokens, and the credential helper that lets plain `git` push and pull.
13
+ *
14
+ * Registered under `lizard git`, next to the GitHub integration commands.
15
+ */
16
+ const collect = (v, prev) => [...prev, v];
17
+ const list = (v) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : undefined);
18
+ const int = (v) => {
19
+ const n = Number(v);
20
+ if (!Number.isInteger(n) || n <= 0)
21
+ throw new APIError(400, `expected a number, got "${v}"`);
22
+ return n;
23
+ };
24
+ /** Print data in JSON mode, or run the human printer. */
25
+ function out(data, human) {
26
+ if (isJSONMode())
27
+ printJSON(data ?? { ok: true });
28
+ else
29
+ human();
30
+ }
31
+ function done(data, msg) {
32
+ out(data ?? { ok: true }, () => success(msg));
33
+ }
34
+ function state(s) {
35
+ switch (s) {
36
+ case "success":
37
+ case "merged":
38
+ case "approved": return chalk.green(s);
39
+ case "failure":
40
+ case "error":
41
+ case "changes_requested": return chalk.red(s);
42
+ case "open": return chalk.green(s);
43
+ case "closed":
44
+ case "cancelled":
45
+ case "skipped": return chalk.dim(s);
46
+ case "queued":
47
+ case "running":
48
+ case "waiting":
49
+ case "pending": return chalk.yellow(s);
50
+ default: return s;
51
+ }
52
+ }
53
+ /** Text from --body, --body-file, or "" */
54
+ function bodyFrom(opts) {
55
+ if (opts.bodyFile)
56
+ return opts.bodyFile === "-" ? readFileSync(0, "utf8") : readFileSync(opts.bodyFile, "utf8");
57
+ return opts.body;
58
+ }
59
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
60
+ function repoOpt(c) {
61
+ return c.option("-R, --repo <owner/name>", "Repository (default: the Lizard Git remote of this checkout)");
62
+ }
63
+ export function registerLizardGit(git) {
64
+ git.description("Lizard Git (git.lizard.build) repositories, pull requests, CI; and GitHub integration");
65
+ // ── Sign-in for plain git ────────────────────────────────────────────
66
+ git
67
+ .command("setup")
68
+ .description("Let git push and pull Lizard Git repositories with your Lizard sign-in (credential helper)")
69
+ .option("--local", "Only for the repository in this directory")
70
+ .action(async (opts) => {
71
+ const key = `credential.${gitURL()}.helper`;
72
+ const scope = opts.local ? "--local" : "--global";
73
+ // An empty value first resets helpers inherited for this host; then ours.
74
+ for (const value of ["", helperCommand()]) {
75
+ const r = spawnSync("git", ["config", scope, value ? "--add" : "--replace-all", key, value], { encoding: "utf8" });
76
+ if (r.status !== 0)
77
+ throw new APIError(500, `git config failed: ${r.stderr || r.error?.message}`);
78
+ }
79
+ spawnSync("git", ["config", scope, `credential.${gitURL()}.username`, "lizard"]);
80
+ const who = await myLogin();
81
+ out({ helper: helperCommand(), scope: opts.local ? "local" : "global", host: gitURL(), login: who }, () => {
82
+ success(`git now signs in to ${gitURL()} as ${chalk.bold(who)} (${opts.local ? "this repository" : "every repository"})`);
83
+ info(chalk.dim(` Try: lizard git clone ${who}/<repo>`));
84
+ });
85
+ });
86
+ git
87
+ .command("credential")
88
+ .description("git credential helper (used by `lizard git setup`; not run by hand)")
89
+ .argument("[operation]", "get, store or erase")
90
+ .action(async (op) => {
91
+ if (op !== "get")
92
+ return; // store/erase: nothing kept, nothing to forget
93
+ const input = readFileSync(0, "utf8");
94
+ const fields = Object.fromEntries(input.split("\n").filter(Boolean).map((l) => l.split(/=(.*)/s).slice(0, 2)));
95
+ const want = new URL(gitURL());
96
+ if (fields.host && fields.host !== want.host)
97
+ return;
98
+ if (fields.protocol && fields.protocol !== want.protocol.replace(":", ""))
99
+ return;
100
+ const token = process.env.LIZARD_GIT_TOKEN || validToken();
101
+ if (!token) {
102
+ process.stderr.write("lizard: not signed in; run `lizard login`\n");
103
+ return;
104
+ }
105
+ process.stdout.write(`username=lizard\npassword=${token}\n`);
106
+ });
107
+ git
108
+ .command("clone")
109
+ .description("Clone a Lizard Git repository (signed in with your Lizard account)")
110
+ .argument("<repo>", "owner/name, or name for one of yours")
111
+ .argument("[dir]", "Directory (default: the repository name)")
112
+ .action(async (repoArg, dir) => {
113
+ const repo = await resolveRepo(repoArg);
114
+ // Kept in the clone's config (--config applies before the fetch too): an empty helper
115
+ // first, so helpers set globally for this host don't answer instead.
116
+ const args = ["clone", "--config", `credential.${gitURL()}.helper=`, "--config", `credential.${gitURL()}.helper=${helperCommand()}`, cloneURL(repo)];
117
+ if (dir)
118
+ args.push(dir);
119
+ const r = spawnSync("git", args, { stdio: isJSONMode() ? ["ignore", "ignore", "inherit"] : "inherit" });
120
+ if (r.status !== 0)
121
+ throw new APIError(500, `git clone failed (exit ${r.status})`);
122
+ done({ repo, dir: dir ?? repo.split("/")[1], url: cloneURL(repo) }, `Cloned ${repo}`);
123
+ });
124
+ // ── Repositories ─────────────────────────────────────────────────────
125
+ const repo = git.command("repo").description("Lizard Git repositories");
126
+ repo
127
+ .command("list")
128
+ .alias("ls")
129
+ .description("Your repositories and the ones you collaborate on")
130
+ .option("--owner <login>", "Only this owner's")
131
+ .option("--starred", "The ones you starred")
132
+ .option("-q, --query <text>", "Search names and descriptions")
133
+ .action(async (opts) => {
134
+ const login = await myLogin();
135
+ const repos = await lg.get(qs("/api/repos", {
136
+ owner: opts.owner, member: opts.owner || opts.starred ? undefined : login, starred: opts.starred ? login : undefined, q: opts.query,
137
+ }));
138
+ out(repos, () => {
139
+ if (!repos.length)
140
+ return info("No repositories. Create one: lizard git repo create <name>");
141
+ table(["REPO", "VISIBILITY", "UPDATED", "DESCRIPTION"], repos.map((r) => [
142
+ `${r.owner}/${r.name}`, r.private ? "private" : "public", ago(r.pushedAt ?? r.updatedAt), (r.description ?? "").slice(0, 60),
143
+ ]));
144
+ });
145
+ });
146
+ repo
147
+ .command("create")
148
+ .description("Create a repository")
149
+ .argument("<name>")
150
+ .option("--private", "Only you and collaborators can see it")
151
+ .option("-d, --description <text>")
152
+ .option("--readme", "Start with a README (otherwise empty, ready for a push)")
153
+ .option("--default-branch <name>", "Default branch (main)")
154
+ .action(async (name, opts) => {
155
+ const r = await lg.post("/api/repos", {
156
+ name, private: !!opts.private, description: opts.description, readme: !!opts.readme, defaultBranch: opts.defaultBranch,
157
+ });
158
+ const full = `${r.owner}/${r.name}`;
159
+ out({ ...r, url: `${gitURL()}/${full}`, cloneUrl: cloneURL(full) }, () => {
160
+ success(`Created ${chalk.bold(full)} ${link(`${gitURL()}/${full}`)}`);
161
+ info(chalk.dim(` Clone: lizard git clone ${full} Push: git remote add origin ${cloneURL(full)}`));
162
+ });
163
+ });
164
+ repoOpt(repo.command("view").description("A repository's details").argument("[repo]"))
165
+ .action(async (arg, opts) => {
166
+ const r = await resolveRepo(arg ?? opts.repo);
167
+ const m = await lg.get(repoPath(r));
168
+ out(m, () => {
169
+ info(`${chalk.bold(r)} ${m.repo.private ? "private" : "public"} ${chalk.dim(`you: ${m.role || "reader"}`)}`);
170
+ if (m.repo.description)
171
+ info(m.repo.description);
172
+ if (m.forkOf)
173
+ info(chalk.dim(`forked from ${m.forkOf}`));
174
+ info(`default branch ${m.repo.defaultBranch} · ${m.branches} branches · ${m.tags} tags · ${m.openIssues} open issues · ${m.openPulls} open pull requests · ${m.repo.stars} stars`);
175
+ info(`${link(`${gitURL()}/${r}`)}\nclone: ${m.cloneUrl}`);
176
+ });
177
+ });
178
+ repoOpt(repo.command("edit").description("Change name, description, visibility or default branch").argument("[repo]"))
179
+ .option("--name <name>")
180
+ .option("-d, --description <text>")
181
+ .option("--private").option("--public")
182
+ .option("--default-branch <name>")
183
+ .action(async (arg, opts) => {
184
+ const r = await resolveRepo(arg ?? opts.repo);
185
+ await lg.patch(repoPath(r), {
186
+ name: opts.name, description: opts.description, defaultBranch: opts.defaultBranch,
187
+ private: opts.private ? true : opts.public ? false : undefined,
188
+ });
189
+ done({ ok: true, repo: opts.name ? `${r.split("/")[0]}/${opts.name}` : r }, `Updated ${r}`);
190
+ });
191
+ repoOpt(repo.command("delete").description("Delete a repository").argument("[repo]"))
192
+ .option("--yes", "Confirm")
193
+ .action(async (arg, opts) => {
194
+ const r = await resolveRepo(arg ?? opts.repo);
195
+ if (!opts.yes)
196
+ throw new APIError(400, `This deletes ${r} with its issues, pull requests, runs and releases. Run again with --yes.`);
197
+ await lg.delete(repoPath(r));
198
+ done({ ok: true, deleted: r }, `Deleted ${r}`);
199
+ });
200
+ repoOpt(repo.command("fork").description("Fork a repository into your account").argument("[repo]"))
201
+ .option("--name <name>", "Name of the fork (default: the same)")
202
+ .option("--clone", "Clone the fork afterwards")
203
+ .action(async (arg, opts) => {
204
+ const r = await resolveRepo(arg ?? opts.repo);
205
+ const f = await lg.post(`${repoPath(r)}/forks`, { name: opts.name });
206
+ const full = `${f.owner}/${f.name}`;
207
+ if (opts.clone)
208
+ spawnSync("git", ["clone", "--config", `credential.${gitURL()}.helper=${helperCommand()}`, cloneURL(full)], { stdio: "inherit" });
209
+ done({ ...f, url: `${gitURL()}/${full}` }, `Forked ${r} to ${chalk.bold(full)}`);
210
+ });
211
+ repoOpt(repo.command("forks").description("Forks of a repository").argument("[repo]"))
212
+ .action(async (arg, opts) => {
213
+ const r = await resolveRepo(arg ?? opts.repo);
214
+ const fs = await lg.get(`${repoPath(r)}/forks`);
215
+ out(fs, () => fs.length ? table(["FORK", "UPDATED"], fs.map((f) => [`${f.owner}/${f.name}`, ago(f.pushedAt ?? f.updatedAt)])) : info("No forks."));
216
+ });
217
+ for (const [cmd, method, msg] of [["star", "put", "Starred"], ["unstar", "delete", "Unstarred"]]) {
218
+ repoOpt(repo.command(cmd).description(`${msg.replace(/ed$/, "")} a repository`).argument("[repo]"))
219
+ .action(async (arg, opts) => {
220
+ const r = await resolveRepo(arg ?? opts.repo);
221
+ await (method === "put" ? lg.put(`${repoPath(r)}/star`) : lg.delete(`${repoPath(r)}/star`));
222
+ done({ ok: true }, `${msg} ${r}`);
223
+ });
224
+ }
225
+ // ── Branches, files, commits ─────────────────────────────────────────
226
+ const branch = git.command("branch").description("Branches and tags of a Lizard Git repository");
227
+ repoOpt(branch.command("list").alias("ls").description("Branches and tags"))
228
+ .option("--tags", "Tags only")
229
+ .action(async (opts) => {
230
+ const r = await resolveRepo(opts.repo);
231
+ const refs = (await lg.get(`${repoPath(r)}/refs`)).filter((x) => (opts.tags ? x.kind === "tag" : true));
232
+ out(refs, () => table(["NAME", "KIND", "COMMIT", "UPDATED", "SUBJECT"], refs.map((x) => [x.name, x.kind, x.sha.slice(0, 7), ago(x.date), (x.subject ?? "").slice(0, 50)])));
233
+ });
234
+ repoOpt(branch.command("create").description("Create a branch").argument("<name>"))
235
+ .option("--from <ref>", "Start point (default branch)")
236
+ .action(async (name, opts) => {
237
+ const r = await resolveRepo(opts.repo);
238
+ const b = await lg.post(`${repoPath(r)}/branches`, { name, from: opts.from });
239
+ done(b, `Created branch ${name} at ${String(b.sha).slice(0, 7)}`);
240
+ });
241
+ repoOpt(branch.command("delete").description("Delete a branch").argument("<name>"))
242
+ .action(async (name, opts) => {
243
+ const r = await resolveRepo(opts.repo);
244
+ await lg.delete(`${repoPath(r)}/branches/${segs(name)}`);
245
+ done({ ok: true }, `Deleted branch ${name}`);
246
+ });
247
+ repoOpt(git.command("ls").description("List files in a Lizard Git repository").argument("[path]"))
248
+ .option("--ref <ref>", "Branch, tag or commit")
249
+ .option("-r, --recursive", "Every file path")
250
+ .action(async (path, opts) => {
251
+ const r = await resolveRepo(opts.repo);
252
+ if (opts.recursive) {
253
+ const files = await lg.get(qs(`${repoPath(r)}/files`, { ref: opts.ref }));
254
+ const f = path ? files.filter((p) => p.startsWith(path.replace(/\/?$/, "/"))) : files;
255
+ return out(f, () => process.stdout.write(f.join("\n") + (f.length ? "\n" : "")));
256
+ }
257
+ const t = await lg.get(qs(`${repoPath(r)}/tree`, { ref: opts.ref, path }));
258
+ const entries = t.entries ?? t;
259
+ out(t, () => table(["TYPE", "NAME", "SIZE"], entries.map((e) => [e.type, e.type === "tree" ? chalk.bold(e.name + "/") : e.name, e.size != null ? String(e.size) : ""])));
260
+ });
261
+ repoOpt(git.command("cat").description("Print a file from a Lizard Git repository").argument("<path>"))
262
+ .option("--ref <ref>", "Branch, tag or commit (default branch)")
263
+ .option("-o, --output <file>", "Write to a file instead")
264
+ .action(async (path, opts) => {
265
+ const r = await resolveRepo(opts.repo);
266
+ const ref = opts.ref ?? (await lg.get(repoPath(r))).repo.defaultBranch;
267
+ const [o, n] = r.split("/");
268
+ const data = await lg.bytes(`/raw/${seg(o)}/${seg(n)}/${segs(ref)}/${segs(path)}`);
269
+ if (opts.output) {
270
+ writeFileSync(opts.output, data);
271
+ return done({ path, ref, bytes: data.length, output: opts.output }, `Wrote ${data.length} bytes to ${opts.output}`);
272
+ }
273
+ if (isJSONMode())
274
+ return printJSON({ path, ref, content: data.toString("utf8") });
275
+ process.stdout.write(data);
276
+ });
277
+ repoOpt(git.command("log").description("Commits of a Lizard Git repository"))
278
+ .option("--ref <ref>", "Branch, tag or commit")
279
+ .option("--path <path>", "Only commits that touch this path")
280
+ .option("--page <n>", "Page", "1")
281
+ .action(async (opts) => {
282
+ const r = await resolveRepo(opts.repo);
283
+ const c = await lg.get(qs(`${repoPath(r)}/commits`, { ref: opts.ref, path: opts.path, page: opts.page }));
284
+ out(c, () => table(["COMMIT", "AUTHOR", "WHEN", "SUBJECT"], c.commits.map((x) => [chalk.yellow(x.sha.slice(0, 7)), x.authorName, ago(x.date), x.subject.slice(0, 70)])));
285
+ });
286
+ repoOpt(git.command("show").description("One commit: message and changed files").argument("<sha>"))
287
+ .action(async (sha, opts) => {
288
+ const r = await resolveRepo(opts.repo);
289
+ const c = await lg.get(`${repoPath(r)}/commits/${seg(sha)}`);
290
+ out(c, () => {
291
+ info(`${chalk.yellow(c.commit.sha)}\n${c.commit.authorName} <${c.commit.authorEmail}> ${ago(c.commit.date)}\n\n ${c.commit.subject}\n`);
292
+ for (const f of c.diff?.files ?? [])
293
+ info(` ${f.status} ${f.newPath || f.oldPath} ${chalk.green(`+${f.additions}`)} ${chalk.red(`-${f.deletions}`)}`);
294
+ });
295
+ });
296
+ repoOpt(git.command("search").description("Search the code of a Lizard Git repository").argument("<text>"))
297
+ .option("--ref <ref>")
298
+ .action(async (text, opts) => {
299
+ const r = await resolveRepo(opts.repo);
300
+ const hits = await lg.get(qs(`${repoPath(r)}/search`, { q: text, ref: opts.ref }));
301
+ out(hits, () => hits.length ? hits.forEach((h) => info(`${chalk.cyan(h.path)}:${h.line}: ${(h.text ?? "").trim()}`)) : info("No matches."));
302
+ });
303
+ repoOpt(git.command("commit").description("Commit files on the server, without a clone or push"))
304
+ .requiredOption("-m, --message <text>", "Commit message")
305
+ .option("-b, --branch <name>", "Branch (default branch)")
306
+ .option("-a, --add <file[:path]>", "Add or replace a file: local file, optionally :path in the repository; repeatable", collect, [])
307
+ .option("--delete <path>", "Delete a path; repeatable", collect, [])
308
+ .option("--expect <sha>", "Fail (409) unless the branch is still at this commit")
309
+ .option("--create-from <ref>", "Create the branch from this ref if it doesn't exist")
310
+ .option("--idempotency-key <key>", "The same key gives the same commit on a retry")
311
+ .action(async (opts) => {
312
+ const r = await resolveRepo(opts.repo);
313
+ const files = [];
314
+ for (const a of opts.add) {
315
+ const i = a.lastIndexOf(":");
316
+ const [local, target] = i > 0 && !existsSync(a) ? [a.slice(0, i), a.slice(i + 1)] : [a, a];
317
+ if (!existsSync(local) || !statSync(local).isFile())
318
+ throw new APIError(400, `no such file: ${local}`);
319
+ files.push({ path: target.replace(/^\.\//, ""), content: readFileSync(local).toString("base64"), base64: true });
320
+ }
321
+ for (const d of opts.delete)
322
+ files.push({ path: d, delete: true });
323
+ if (!files.length)
324
+ throw new APIError(400, "nothing to commit: pass --add and/or --delete");
325
+ const branchName = opts.branch ?? (await lg.get(repoPath(r))).repo.defaultBranch;
326
+ const c = await lg.post(`${repoPath(r)}/commits`, {
327
+ branch: branchName, message: opts.message, files, expect: opts.expect, createFrom: opts.createFrom,
328
+ }, opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : undefined);
329
+ done(c, `Committed ${String(c.sha).slice(0, 7)} to ${branchName}`);
330
+ });
331
+ repoOpt(git.command("land").description("Rebase a branch onto another on the server and move it there").argument("<head>", "Branch or commit to land"))
332
+ .requiredOption("--onto <branch>", "Branch to land on")
333
+ .option("--delete-head", "Delete the head branch afterwards")
334
+ .action(async (head, opts) => {
335
+ const r = await resolveRepo(opts.repo);
336
+ const l = await lg.post(`${repoPath(r)}/land`, { head, onto: opts.onto, deleteHead: !!opts.deleteHead });
337
+ done(l, `Landed ${head} on ${opts.onto} at ${String(l.sha ?? "").slice(0, 7)}`);
338
+ });
339
+ // ── Issues and pull requests ─────────────────────────────────────────
340
+ for (const kind of ["issue", "pr"]) {
341
+ const pulls = kind === "pr";
342
+ const noun = pulls ? "pull request" : "issue";
343
+ const grp = git.command(kind).description(pulls ? "Pull requests: create, review, merge" : "Issues");
344
+ repoOpt(grp.command("list").alias("ls").description(`List ${noun}s`))
345
+ .option("-s, --state <state>", "open, closed or all", "open")
346
+ .option("-l, --label <name>")
347
+ .option("-a, --assignee <login>")
348
+ .option("-q, --query <text>")
349
+ .action(async (opts) => {
350
+ const r = await resolveRepo(opts.repo);
351
+ const l = await lg.get(qs(`${repoPath(r)}/issues`, { state: opts.state, label: opts.label, assignee: opts.assignee, q: opts.query, pulls: pulls ? 1 : undefined }));
352
+ out(l, () => {
353
+ if (!l.items.length)
354
+ return info(`No ${opts.state === "all" ? "" : opts.state + " "}${noun}s. (${l.open} open, ${l.closed} closed)`);
355
+ table(["#", "STATE", "TITLE", pulls ? "BRANCH" : "LABELS", "AUTHOR", "UPDATED"], l.items.map((i) => [
356
+ String(i.number), state(i.draft ? "draft" : i.state), i.title.slice(0, 60),
357
+ pulls ? `${i.headRepo ? i.headRepo.split("/")[0] + ":" : ""}${i.headRef} → ${i.baseRef}` : (i.labels ?? []).map((x) => x.name).join(","),
358
+ i.author, ago(i.updatedAt),
359
+ ]));
360
+ });
361
+ });
362
+ const create = repoOpt(grp.command("create").description(`Open ${pulls ? "a pull request" : "an issue"}`))
363
+ .requiredOption("-t, --title <text>")
364
+ .option("-b, --body <text>")
365
+ .option("-F, --body-file <file>", "Body from a file (- for stdin)");
366
+ if (pulls) {
367
+ create
368
+ .option("-H, --head <branch>", "Branch with the changes (default: the current git branch); owner:branch for a fork")
369
+ .option("-B, --base <branch>", "Branch to merge into (default branch)")
370
+ .option("--head-repo <owner/name>", "The fork the head branch is on")
371
+ .option("-d, --draft", "Open as a draft")
372
+ .option("-r, --reviewer <login>", "Ask for a review; repeatable", collect, []);
373
+ }
374
+ create.option("-l, --label <name>", "Add a label; repeatable", collect, []).option("-a, --assignee <login>", "Assign; repeatable", collect, []);
375
+ create.action(async (opts) => {
376
+ const r = await resolveRepo(opts.repo);
377
+ const body = { title: opts.title, body: bodyFrom(opts) ?? "" };
378
+ if (pulls) {
379
+ let head = opts.head;
380
+ if (!head) {
381
+ const b = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
382
+ head = b.status === 0 ? b.stdout.trim() : "";
383
+ if (!head || head === "HEAD")
384
+ throw new APIError(400, "pass --head <branch>");
385
+ }
386
+ Object.assign(body, { head, base: opts.base, headRepo: opts.headRepo, draft: !!opts.draft, reviewers: opts.reviewer.length ? opts.reviewer : undefined });
387
+ }
388
+ const { number } = await lg.post(`${repoPath(r)}/issues`, body);
389
+ if (opts.label.length || opts.assignee.length) {
390
+ await lg.patch(`${repoPath(r)}/issues/${number}`, { labels: opts.label.length ? opts.label : undefined, assignees: opts.assignee.length ? opts.assignee : undefined });
391
+ }
392
+ const url = `${gitURL()}/${r}/${pulls ? "pull" : "issues"}/${number}`;
393
+ out({ number, url }, () => success(`Opened ${noun} #${number} ${link(url)}`));
394
+ });
395
+ repoOpt(grp.command("view").description(`Show ${pulls ? "a pull request" : "an issue"} with its conversation`).argument("<number>", "", int))
396
+ .option("--comments", "Show every comment (default: the last 10)")
397
+ .action(async (n, opts) => {
398
+ const r = await resolveRepo(opts.repo);
399
+ const d = await lg.get(`${repoPath(r)}/issues/${n}`);
400
+ let reviews = null;
401
+ if (d.issue.isPull)
402
+ reviews = await lg.get(`${repoPath(r)}/pulls/${n}/reviews`).catch(() => null);
403
+ out(reviews ? { ...d, reviews } : d, () => {
404
+ const i = d.issue;
405
+ info(`${chalk.bold(i.title)} ${chalk.dim(`#${i.number}`)} ${state(i.draft ? "draft" : i.state)}`);
406
+ info(chalk.dim(`${i.author} opened ${ago(i.createdAt)}${i.isPull ? ` · ${i.headRepo ? i.headRepo + ":" : ""}${i.headRef} → ${i.baseRef}` : ""}`));
407
+ if (i.labels?.length)
408
+ info(`labels: ${i.labels.map((l) => l.name).join(", ")}`);
409
+ if (i.assignees?.length)
410
+ info(`assignees: ${i.assignees.map((a) => a.login).join(", ")}`);
411
+ if (d.pull) {
412
+ const p = d.pull;
413
+ if (p.merge)
414
+ info(`merge: ${p.merge.conflicts?.length ? chalk.red("conflicts in " + p.merge.conflicts.join(", ")) : chalk.green("no conflicts")}`);
415
+ if (p.gate?.blockers?.length)
416
+ info(`${chalk.yellow("blocked:")} ${p.gate.blockers.join("; ")}`);
417
+ if (reviews?.decision)
418
+ info(`reviews: ${reviews.decision.approvals.length} approved${reviews.decision.changesRequested.length ? `, changes requested by ${reviews.decision.changesRequested.join(", ")}` : ""}`);
419
+ }
420
+ if (i.body?.trim())
421
+ info(`\n${i.body.trim()}\n`);
422
+ const cs = (d.comments ?? []).filter((c) => c.kind === "comment" || !c.kind);
423
+ for (const c of opts.comments ? cs : cs.slice(-10))
424
+ info(`${chalk.bold(c.author)} ${chalk.dim(ago(c.createdAt))}\n ${String(c.body).trim().replace(/\n/g, "\n ")}\n`);
425
+ info(link(`${gitURL()}/${r}/${i.isPull ? "pull" : "issues"}/${i.number}`));
426
+ });
427
+ });
428
+ repoOpt(grp.command("comment").description("Add a comment").argument("<number>", "", int))
429
+ .option("-b, --body <text>")
430
+ .option("-F, --body-file <file>", "Body from a file (- for stdin)")
431
+ .action(async (n, opts) => {
432
+ const r = await resolveRepo(opts.repo);
433
+ const body = bodyFrom(opts);
434
+ if (!body?.trim())
435
+ throw new APIError(400, "pass --body or --body-file");
436
+ const c = await lg.post(`${repoPath(r)}/issues/${n}/comments`, { body });
437
+ done(c, `Commented on #${n}`);
438
+ });
439
+ const edit = repoOpt(grp.command("edit").description("Change title, body, labels, assignees" + (pulls ? ", reviewers" : "")).argument("<number>", "", int))
440
+ .option("-t, --title <text>")
441
+ .option("-b, --body <text>")
442
+ .option("-F, --body-file <file>")
443
+ .option("--add-label <names>", "Comma-separated").option("--remove-label <names>", "Comma-separated")
444
+ .option("--add-assignee <logins>", "Comma-separated").option("--remove-assignee <logins>", "Comma-separated");
445
+ if (pulls)
446
+ edit.option("--add-reviewer <logins>", "Comma-separated").option("--remove-reviewer <logins>", "Comma-separated");
447
+ edit.action(async (n, opts) => {
448
+ const r = await resolveRepo(opts.repo);
449
+ const d = await lg.get(`${repoPath(r)}/issues/${n}`);
450
+ const merge = (cur, add, remove) => {
451
+ if (!add && !remove)
452
+ return undefined;
453
+ const rm = new Set(list(remove) ?? []);
454
+ return [...new Set([...cur, ...(list(add) ?? [])])].filter((x) => !rm.has(x));
455
+ };
456
+ const reviewers = pulls ? merge((d.pull?.reviewRequests ?? []).map((x) => x.login), opts.addReviewer, opts.removeReviewer) : undefined;
457
+ await lg.patch(`${repoPath(r)}/issues/${n}`, {
458
+ title: opts.title, body: bodyFrom(opts),
459
+ labels: merge(d.issue.labels.map((l) => l.name), opts.addLabel, opts.removeLabel),
460
+ assignees: merge(d.issue.assignees.map((a) => a.login), opts.addAssignee, opts.removeAssignee),
461
+ reviewers,
462
+ });
463
+ done({ ok: true }, `Updated #${n}`);
464
+ });
465
+ for (const [cmd, st] of [["close", "closed"], ["reopen", "open"]]) {
466
+ repoOpt(grp.command(cmd).description(`${cmd === "close" ? "Close" : "Reopen"} ${pulls ? "a pull request" : "an issue"}`).argument("<number>", "", int))
467
+ .option("-c, --comment <text>", "Comment first")
468
+ .action(async (n, opts) => {
469
+ const r = await resolveRepo(opts.repo);
470
+ if (opts.comment)
471
+ await lg.post(`${repoPath(r)}/issues/${n}/comments`, { body: opts.comment });
472
+ await lg.patch(`${repoPath(r)}/issues/${n}`, { state: st });
473
+ done({ ok: true, number: n, state: st }, `${cmd === "close" ? "Closed" : "Reopened"} #${n}`);
474
+ });
475
+ }
476
+ if (!pulls)
477
+ continue;
478
+ repoOpt(grp.command("ready").description("Mark a draft ready for review").argument("<number>", "", int))
479
+ .option("--undo", "Back to draft")
480
+ .action(async (n, opts) => {
481
+ const r = await resolveRepo(opts.repo);
482
+ await lg.patch(`${repoPath(r)}/issues/${n}`, { draft: !!opts.undo });
483
+ done({ ok: true, draft: !!opts.undo }, opts.undo ? `#${n} is a draft again` : `#${n} is ready for review`);
484
+ });
485
+ repoOpt(grp.command("diff").description("The changes of a pull request").argument("<number>", "", int))
486
+ .option("--stat", "Only the files changed")
487
+ .action(async (n, opts) => {
488
+ const r = await resolveRepo(opts.repo);
489
+ const d = await lg.get(`${repoPath(r)}/pulls/${n}/diff`);
490
+ out(d, () => {
491
+ for (const f of d.diff?.files ?? []) {
492
+ const name = f.oldPath && f.newPath && f.oldPath !== f.newPath ? `${f.oldPath} → ${f.newPath}` : f.newPath || f.oldPath;
493
+ process.stdout.write(`${chalk.bold(name)} ${chalk.green(`+${f.additions}`)} ${chalk.red(`-${f.deletions}`)}${f.binary ? " (binary)" : ""}\n`);
494
+ if (opts.stat)
495
+ continue;
496
+ for (const h of f.hunks ?? []) {
497
+ process.stdout.write(chalk.cyan(h.header) + "\n");
498
+ for (const l of h.lines ?? []) {
499
+ const t = `${l.k}${l.t}`;
500
+ process.stdout.write((l.k === "+" ? chalk.green(t) : l.k === "-" ? chalk.red(t) : t) + "\n");
501
+ }
502
+ }
503
+ }
504
+ });
505
+ });
506
+ repoOpt(grp.command("commits").description("Commits of a pull request").argument("<number>", "", int))
507
+ .action(async (n, opts) => {
508
+ const r = await resolveRepo(opts.repo);
509
+ const cs = await lg.get(`${repoPath(r)}/pulls/${n}/commits`);
510
+ out(cs, () => table(["COMMIT", "AUTHOR", "SUBJECT"], cs.map((c) => [chalk.yellow(c.sha.slice(0, 7)), c.authorName, c.subject.slice(0, 70)])));
511
+ });
512
+ repoOpt(grp.command("review").description("Approve, request changes, or comment on a pull request").argument("<number>", "", int))
513
+ .option("--approve").option("--request-changes").option("--comment", "A review that only comments (default)")
514
+ .option("-b, --body <text>")
515
+ .option("-F, --body-file <file>")
516
+ .option("--line <path:line=text>", "A comment on a line of the new code; repeatable", collect, [])
517
+ .action(async (n, opts) => {
518
+ const r = await resolveRepo(opts.repo);
519
+ const event = opts.approve ? "APPROVE" : opts.requestChanges ? "REQUEST_CHANGES" : "COMMENT";
520
+ const comments = opts.line.map((s) => {
521
+ const m = s.match(/^(.+):(\d+)=(.*)$/s);
522
+ if (!m)
523
+ throw new APIError(400, `--line is path:line=text, got "${s}"`);
524
+ return { path: m[1], line: Number(m[2]), side: "RIGHT", body: m[3] };
525
+ });
526
+ const rv = await lg.post(`${repoPath(r)}/pulls/${n}/reviews`, { event, body: bodyFrom(opts) ?? "", comments });
527
+ done(rv, `${event === "APPROVE" ? "Approved" : event === "REQUEST_CHANGES" ? "Requested changes on" : "Reviewed"} #${n}`);
528
+ });
529
+ repoOpt(grp.command("reviews").description("Reviews and line comments of a pull request").argument("<number>", "", int))
530
+ .action(async (n, opts) => {
531
+ const r = await resolveRepo(opts.repo);
532
+ const rv = await lg.get(`${repoPath(r)}/pulls/${n}/reviews`);
533
+ out(rv, () => {
534
+ for (const x of rv.reviews)
535
+ info(`${chalk.bold(x.author)} ${state(x.state)} ${chalk.dim(ago(x.createdAt))}${x.body ? `\n ${x.body}` : ""}`);
536
+ for (const c of rv.comments)
537
+ info(`${chalk.cyan(`${c.path}:${c.line}`)} ${chalk.bold(c.author)}: ${c.body}`);
538
+ info(`approvals: ${rv.decision.approvals.join(", ") || "none"}${rv.decision.changesRequested.length ? ` · changes requested: ${rv.decision.changesRequested.join(", ")}` : ""}`);
539
+ });
540
+ });
541
+ repoOpt(grp.command("checks").description("CI runs and commit statuses of a pull request's head").argument("<number>", "", int))
542
+ .option("-w, --watch", "Wait until every check has finished")
543
+ .action(async (n, opts) => {
544
+ const r = await resolveRepo(opts.repo);
545
+ for (;;) {
546
+ const d = await lg.get(`${repoPath(r)}/issues/${n}`);
547
+ const sha = d.pull?.headSha;
548
+ if (!sha)
549
+ throw new APIError(404, `#${n} has no head commit`);
550
+ const [st, runs] = await Promise.all([
551
+ lg.get(`${repoPath(r)}/commits/${sha}/status`),
552
+ lg.get(qs(`${repoPath(r)}/actions/runs`, { sha })),
553
+ ]);
554
+ const pending = st.statuses.some((s) => s.state === "pending") || runs.runs.some((x) => ACTIVE_RUN.has(x.status));
555
+ if (opts.watch && pending) {
556
+ if (!isJSONMode())
557
+ info(chalk.dim(`waiting for ${runs.runs.filter((x) => ACTIVE_RUN.has(x.status)).length} run(s)...`));
558
+ await sleep(5000);
559
+ continue;
560
+ }
561
+ out({ sha, state: st.state, statuses: st.statuses, runs: runs.runs }, () => {
562
+ info(`${chalk.yellow(sha.slice(0, 7))} ${state(st.state)}`);
563
+ const rows = [
564
+ ...runs.runs.map((x) => [state(x.status), x.name, `run #${x.number}`, ago(x.finishedAt ?? x.startedAt ?? x.createdAt)]),
565
+ ...st.statuses.map((s) => [state(s.state), s.context, s.description ?? "", ago(s.createdAt)]),
566
+ ];
567
+ rows.length ? table(["STATE", "CHECK", "DETAIL", "WHEN"], rows) : info("No checks.");
568
+ });
569
+ const failed = st.state === "failure" || st.state === "error" || runs.runs.some((x) => x.status === "failure");
570
+ if (failed)
571
+ process.exitCode = 1;
572
+ return;
573
+ }
574
+ });
575
+ repoOpt(grp.command("merge").description("Merge a pull request").argument("<number>", "", int))
576
+ .option("--squash").option("--rebase").option("--merge", "A merge commit (default)")
577
+ .option("-m, --message <text>", "Commit message")
578
+ .option("-d, --delete-branch", "Delete the head branch afterwards")
579
+ .option("--admin", "Merge past the branch rule (admins, when the rule allows)")
580
+ .action(async (n, opts) => {
581
+ const r = await resolveRepo(opts.repo);
582
+ const m = await lg.post(`${repoPath(r)}/pulls/${n}/merge`, {
583
+ method: opts.squash ? "squash" : opts.rebase ? "rebase" : "merge", message: opts.message, deleteBranch: !!opts.deleteBranch, bypass: !!opts.admin,
584
+ });
585
+ done(m, `Merged #${n} (${String(m.sha).slice(0, 7)})`);
586
+ });
587
+ repoOpt(grp.command("checkout").description("Check out a pull request's branch in this clone").argument("<number>", "", int))
588
+ .action(async (n, opts) => {
589
+ const r = await resolveRepo(opts.repo);
590
+ const d = await lg.get(`${repoPath(r)}/issues/${n}`);
591
+ const local = d.issue.headRepo && d.issue.headRepo !== r ? `pr-${n}` : d.issue.headRef;
592
+ const remoteRef = d.issue.headRepo && d.issue.headRepo !== r ? `refs/pull/${n}/head` : `refs/heads/${d.issue.headRef}`;
593
+ const run = (args) => {
594
+ const x = spawnSync("git", args, { stdio: isJSONMode() ? ["ignore", "ignore", "inherit"] : "inherit" });
595
+ if (x.status !== 0)
596
+ throw new APIError(500, `git ${args[0]} failed`);
597
+ };
598
+ run(["fetch", "origin", `+${remoteRef}:refs/remotes/origin/${local}`]);
599
+ const exists = spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${local}`]).status === 0;
600
+ run(exists ? ["checkout", local] : ["checkout", "-b", local, "--track", `origin/${local}`]);
601
+ done({ branch: local }, `On branch ${local} (#${n})`);
602
+ });
603
+ }
604
+ // ── CI runs and workflows ────────────────────────────────────────────
605
+ const run = git.command("run").description("CI runs (.lizard/workflows, GitHub Actions syntax)");
606
+ repoOpt(run.command("list").alias("ls").description("Recent runs"))
607
+ .option("-b, --branch <name>").option("--commit <sha>").option("-w, --workflow <path>").option("-s, --status <status>")
608
+ .option("--pr <number>", "Runs of a pull request")
609
+ .option("--page <n>", "Page", "1")
610
+ .action(async (opts) => {
611
+ const r = await resolveRepo(opts.repo);
612
+ const l = await lg.get(qs(`${repoPath(r)}/actions/runs`, { branch: opts.branch, sha: opts.commit, workflow: opts.workflow, status: opts.status, pr: opts.pr, page: opts.page }));
613
+ out(l, () => l.runs.length ? table(["#", "STATUS", "WORKFLOW", "EVENT", "REF", "TITLE", "WHEN"], l.runs.map((x) => [
614
+ String(x.number), state(x.status), x.name, x.event, x.ref.replace(/^refs\/(heads|tags)\//, ""), (x.title ?? "").slice(0, 40), ago(x.createdAt),
615
+ ])) : info("No runs yet. Add a workflow under .lizard/workflows/ and push."));
616
+ });
617
+ repoOpt(run.command("view").description("A run with its jobs and steps").argument("<number>", "", int))
618
+ .action(async (n, opts) => {
619
+ const r = await resolveRepo(opts.repo);
620
+ const x = await lg.get(`${repoPath(r)}/actions/runs/${n}`);
621
+ out(x, () => {
622
+ info(`${chalk.bold(x.name)} #${x.number} ${state(x.status)} ${chalk.dim(`${x.event} on ${x.ref.replace(/^refs\/heads\//, "")} at ${x.sha.slice(0, 7)} by ${x.actor}`)}`);
623
+ if (x.error)
624
+ info(chalk.red(x.error));
625
+ for (const j of x.jobs ?? []) {
626
+ const secs = j.startedAt && j.finishedAt ? ` ${j.finishedAt - j.startedAt}s` : "";
627
+ info(` ${state(j.status)} ${chalk.bold(j.name)} ${chalk.dim(`job ${j.id}${secs}`)}`);
628
+ for (const s of j.steps ?? [])
629
+ info(` ${state(s.status ?? "")} ${s.name}`);
630
+ }
631
+ info(link(`${gitURL()}/${r}/actions/runs/${x.number}`));
632
+ });
633
+ });
634
+ repoOpt(run.command("logs").alias("log").description("A run's job logs").argument("<number>", "", int))
635
+ .option("-j, --job <name|id>", "Only this job")
636
+ .option("-f, --follow", "Keep printing until the run ends")
637
+ .action(async (n, opts) => {
638
+ const r = await resolveRepo(opts.repo);
639
+ const x = await lg.get(`${repoPath(r)}/actions/runs/${n}`);
640
+ const jobs = (x.jobs ?? []).filter((j) => !opts.job || String(j.id) === opts.job || j.name === opts.job || j.key === opts.job);
641
+ if (!jobs.length)
642
+ throw new APIError(404, opts.job ? `no job ${opts.job} in run ${n}` : `run ${n} has no jobs`);
643
+ const result = {};
644
+ for (const j of jobs) {
645
+ if (!isJSONMode() && jobs.length > 1)
646
+ process.stdout.write(chalk.bold(`\n== ${j.name}\n`));
647
+ let offset = 0, text = "";
648
+ for (;;) {
649
+ const c = await lg.get(qs(`${repoPath(r)}/actions/jobs/${j.id}/log`, { offset }));
650
+ if (c.text) {
651
+ text += c.text;
652
+ if (!isJSONMode())
653
+ process.stdout.write(c.text.replace(/^##\[lizard:(step|end)\].*\n/gm, (l) => (l.startsWith("##[lizard:step]") ? chalk.cyan("▸ " + l.replace(/^##\[lizard:step\] \d+ /, "")) : "")));
654
+ }
655
+ offset = c.next;
656
+ if (c.done && offset >= c.size)
657
+ break;
658
+ if (!opts.follow && offset >= c.size)
659
+ break;
660
+ if (!c.text)
661
+ await sleep(1500);
662
+ }
663
+ result[j.name] = text;
664
+ }
665
+ if (isJSONMode())
666
+ printJSON({ run: n, logs: result });
667
+ });
668
+ repoOpt(run.command("watch").description("Wait for a run to finish; exit 1 if it failed").argument("<number>", "", int))
669
+ .option("--interval <seconds>", "", "3")
670
+ .action(async (n, opts) => {
671
+ const r = await resolveRepo(opts.repo);
672
+ let last = "";
673
+ for (;;) {
674
+ const x = await lg.get(`${repoPath(r)}/actions/runs/${n}`);
675
+ const line = (x.jobs ?? []).map((j) => `${j.name}:${j.status}`).join(" ");
676
+ if (!isJSONMode() && line !== last)
677
+ info(`${state(x.status)} ${line}`);
678
+ last = line;
679
+ if (!ACTIVE_RUN.has(x.status)) {
680
+ if (isJSONMode())
681
+ printJSON(x);
682
+ if (x.status !== "success")
683
+ process.exitCode = 1;
684
+ return;
685
+ }
686
+ await sleep(Number(opts.interval) * 1000);
687
+ }
688
+ });
689
+ for (const [cmd, path, msg, desc] of [
690
+ ["cancel", "cancel", "Cancelled", "Cancel a run"],
691
+ ["rerun", "rerun", "Started again as", "Run the same workflow at the same commit again"],
692
+ ["approve", "approve", "Approved", "Let a run from a fork's pull request start"],
693
+ ]) {
694
+ repoOpt(run.command(cmd).description(desc).argument("<number>", "", int))
695
+ .action(async (n, opts) => {
696
+ const r = await resolveRepo(opts.repo);
697
+ const x = await lg.post(`${repoPath(r)}/actions/runs/${n}/${path}`);
698
+ done(x, cmd === "rerun" ? `${msg} run #${x.number}` : `${msg} run #${n}`);
699
+ });
700
+ }
701
+ repoOpt(run.command("artifacts").description("Artifacts a run uploaded").argument("<number>", "", int))
702
+ .action(async (n, opts) => {
703
+ const r = await resolveRepo(opts.repo);
704
+ const a = await lg.get(`${repoPath(r)}/actions/runs/${n}/artifacts`);
705
+ out(a, () => a.length ? table(["NAME", "SIZE", "JOB", "WHEN"], a.map((x) => [x.name, `${x.size} B`, x.job, ago(x.createdAt)])) : info("No artifacts."));
706
+ });
707
+ repoOpt(run.command("download").description("Download an artifact (.tar.gz)").argument("<number>", "", int).argument("<name>"))
708
+ .option("-o, --output <file>", "File (default: <name>.tar.gz)")
709
+ .option("-x, --extract <dir>", "Unpack into this directory instead")
710
+ .action(async (n, name, opts) => {
711
+ const r = await resolveRepo(opts.repo);
712
+ const data = await lg.bytes(`${repoPath(r)}/actions/runs/${n}/artifacts/${seg(name)}`);
713
+ if (opts.extract) {
714
+ spawnSync("mkdir", ["-p", opts.extract]);
715
+ const t = spawnSync("tar", ["xzf", "-", "-C", opts.extract], { input: data });
716
+ if (t.status !== 0)
717
+ throw new APIError(500, `tar failed: ${t.stderr}`);
718
+ return done({ name, bytes: data.length, extracted: opts.extract }, `Unpacked ${name} into ${opts.extract}`);
719
+ }
720
+ const file = opts.output ?? `${name}.tar.gz`;
721
+ writeFileSync(file, data);
722
+ done({ name, bytes: data.length, output: file }, `Saved ${name} to ${file} (${data.length} bytes)`);
723
+ });
724
+ const workflow = git.command("workflow").description("CI workflows of a repository");
725
+ repoOpt(workflow.command("list").alias("ls").description("Workflows and their triggers"))
726
+ .action(async (opts) => {
727
+ const r = await resolveRepo(opts.repo);
728
+ const w = await lg.get(`${repoPath(r)}/actions/workflows`);
729
+ out(w, () => {
730
+ if (!w.enabled)
731
+ warn(`CI is off for this repository: ${w.reason}`);
732
+ w.workflows.length ? table(["WORKFLOW", "NAME", "ON", "MANUAL"], w.workflows.map((x) => [x.path, x.name, x.events.join(","), x.dispatch ? "yes" : ""])) : info("No workflows in .lizard/workflows/");
733
+ });
734
+ });
735
+ repoOpt(workflow.command("run").description("Start a workflow that has on: workflow_dispatch").argument("<workflow>", "Path or file name, e.g. ci.yml"))
736
+ .option("--ref <ref>", "Branch or tag (default branch)")
737
+ .option("-f, --field <key=value>", "An input; repeatable", collect, [])
738
+ .option("-w, --watch", "Wait for it to finish")
739
+ .action(async (wf, opts) => {
740
+ const r = await resolveRepo(opts.repo);
741
+ const path = wf.includes("/") ? wf : `.lizard/workflows/${wf}`;
742
+ const inputs = Object.fromEntries(opts.field.map((f) => {
743
+ const i = f.indexOf("=");
744
+ if (i <= 0)
745
+ throw new APIError(400, `--field is key=value, got "${f}"`);
746
+ return [f.slice(0, i), f.slice(i + 1)];
747
+ }));
748
+ const x = await lg.post(`${repoPath(r)}/actions/workflows/dispatch`, { workflow: path, ref: opts.ref, inputs });
749
+ if (!opts.watch)
750
+ return done(x, `Started run #${x.number} ${link(`${gitURL()}/${r}/actions/runs/${x.number}`)}`);
751
+ for (;;) {
752
+ const s = await lg.get(`${repoPath(r)}/actions/runs/${x.number}`);
753
+ if (!ACTIVE_RUN.has(s.status)) {
754
+ if (s.status !== "success")
755
+ process.exitCode = 1;
756
+ return out(s, () => info(`run #${x.number} ${state(s.status)}`));
757
+ }
758
+ await sleep(3000);
759
+ }
760
+ });
761
+ // ── Secrets ──────────────────────────────────────────────────────────
762
+ const secret = git.command("secret").description("CI secrets of a repository (${{ secrets.NAME }})");
763
+ repoOpt(secret.command("list").alias("ls").description("Secret names (values are never shown)"))
764
+ .action(async (opts) => {
765
+ const r = await resolveRepo(opts.repo);
766
+ const s = await lg.get(`${repoPath(r)}/actions/secrets`);
767
+ out(s, () => s.secrets.length ? table(["NAME", "UPDATED"], s.secrets.map((x) => [x.name, ago(x.updatedAt)])) : info("No secrets."));
768
+ });
769
+ repoOpt(secret.command("set").description("Set a secret").argument("<name>"))
770
+ .option("-b, --body <value>", "The value (default: read from stdin)")
771
+ .action(async (name, opts) => {
772
+ const r = await resolveRepo(opts.repo);
773
+ const value = opts.body ?? readFileSync(0, "utf8").replace(/\n$/, "");
774
+ await lg.put(`${repoPath(r)}/actions/secrets/${seg(name)}`, { value });
775
+ done({ ok: true, name }, `Set secret ${name}`);
776
+ });
777
+ repoOpt(secret.command("delete").alias("rm").description("Delete a secret").argument("<name>"))
778
+ .action(async (name, opts) => {
779
+ const r = await resolveRepo(opts.repo);
780
+ await lg.delete(`${repoPath(r)}/actions/secrets/${seg(name)}`);
781
+ done({ ok: true, name }, `Deleted secret ${name}`);
782
+ });
783
+ // ── Collaborators and invitations ────────────────────────────────────
784
+ const collab = git.command("collaborator").alias("collab").description("People with access to a repository");
785
+ repoOpt(collab.command("list").alias("ls").description("Collaborators and pending invitations"))
786
+ .action(async (opts) => {
787
+ const r = await resolveRepo(opts.repo);
788
+ const c = await lg.get(`${repoPath(r)}/collaborators`);
789
+ out(c, () => {
790
+ c.collaborators.length ? table(["LOGIN", "ROLE", "SINCE"], c.collaborators.map((x) => [x.login, x.role, ago(x.createdAt)])) : info("No collaborators.");
791
+ if (c.invitations?.length) {
792
+ info("\nInvited:");
793
+ table(["ID", "LOGIN", "ROLE", "BY"], c.invitations.map((x) => [String(x.id), x.login, x.role, x.inviter]));
794
+ }
795
+ });
796
+ });
797
+ repoOpt(collab.command("add").description("Invite someone (they sign in to Lizard Git once first)").argument("<login>"))
798
+ .option("--role <role>", "read, write or admin", "write")
799
+ .action(async (login, opts) => {
800
+ const r = await resolveRepo(opts.repo);
801
+ const x = await lg.post(`${repoPath(r)}/collaborators`, { login, role: opts.role });
802
+ done(x, x.status === "updated" ? `${login} is now ${opts.role}` : `Invited ${login} as ${opts.role}; they're in once they accept`);
803
+ });
804
+ repoOpt(collab.command("role").description("Change someone's role").argument("<login>").argument("<role>", "read, write or admin"))
805
+ .action(async (login, role, opts) => {
806
+ const r = await resolveRepo(opts.repo);
807
+ done(await lg.patch(`${repoPath(r)}/collaborators/${seg(login)}`, { role }), `${login} is now ${role}`);
808
+ });
809
+ repoOpt(collab.command("remove").alias("rm").description("Remove someone (your own login: leave)").argument("<login>"))
810
+ .action(async (login, opts) => {
811
+ const r = await resolveRepo(opts.repo);
812
+ await lg.delete(`${repoPath(r)}/collaborators/${seg(login)}`);
813
+ done({ ok: true }, `Removed ${login} from ${r}`);
814
+ });
815
+ const inv = git.command("invitation").alias("invite").description("Repository invitations");
816
+ inv.command("list").alias("ls").description("Invitations waiting for you")
817
+ .action(async () => {
818
+ const l = await lg.get("/api/user/invitations");
819
+ out(l, () => l.length ? table(["ID", "REPO", "ROLE", "FROM", "EXPIRES"], l.map((x) => [String(x.id), `${x.owner}/${x.repo}`, x.role, x.inviter, ago(x.expiresAt).replace(" ago", "")])) : info("No invitations."));
820
+ });
821
+ for (const answer of ["accept", "decline"]) {
822
+ inv.command(answer).description(`${answer === "accept" ? "Accept" : "Decline"} an invitation`).argument("<id>", "", int)
823
+ .action(async (id) => {
824
+ await lg.post(`/api/user/invitations/${id}/${answer}`);
825
+ done({ ok: true }, `${answer === "accept" ? "Accepted" : "Declined"} invitation ${id}`);
826
+ });
827
+ }
828
+ repoOpt(inv.command("cancel").description("Withdraw an invitation you sent").argument("<id>", "", int))
829
+ .action(async (id, opts) => {
830
+ const r = await resolveRepo(opts.repo);
831
+ await lg.delete(`${repoPath(r)}/invitations/${id}`);
832
+ done({ ok: true }, `Cancelled invitation ${id}`);
833
+ });
834
+ // ── Webhooks ─────────────────────────────────────────────────────────
835
+ const hook = git.command("webhook").alias("hook").description("Webhooks (GitHub-style payloads, X-Hub-Signature-256)");
836
+ repoOpt(hook.command("list").alias("ls").description("Webhooks of a repository"))
837
+ .action(async (opts) => {
838
+ const r = await resolveRepo(opts.repo);
839
+ const h = await lg.get(`${repoPath(r)}/hooks`);
840
+ out(h, () => h.hooks.length ? table(["ID", "URL", "EVENTS", "ACTIVE", "LAST"], h.hooks.map((x) => [String(x.id), x.url, x.events.join(","), x.active ? "yes" : "no", x.lastStatus ? String(x.lastStatus) : x.lastError ?? ""])) : info(`No webhooks. Events: ${h.events.join(", ")}`));
841
+ });
842
+ const hookFields = (c) => c
843
+ .option("-e, --events <list>", "Comma-separated events (default push), or *")
844
+ .option("--secret <secret>", "Signs each delivery (X-Hub-Signature-256)")
845
+ .option("--content-type <type>", "json or form")
846
+ .option("--inactive", "Don't deliver for now").option("--active");
847
+ hookFields(repoOpt(hook.command("create").description("Add a webhook").argument("<url>")))
848
+ .action(async (url, opts) => {
849
+ const r = await resolveRepo(opts.repo);
850
+ const h = await lg.post(`${repoPath(r)}/hooks`, { url, events: list(opts.events), secret: opts.secret, contentType: opts.contentType, active: !opts.inactive });
851
+ done(h, `Added webhook ${h.id} (${h.events.join(", ")})`);
852
+ });
853
+ hookFields(repoOpt(hook.command("edit").description("Change a webhook").argument("<id>", "", int)))
854
+ .option("--url <url>")
855
+ .action(async (id, opts) => {
856
+ const r = await resolveRepo(opts.repo);
857
+ done(await lg.patch(`${repoPath(r)}/hooks/${id}`, {
858
+ url: opts.url, events: list(opts.events), secret: opts.secret, contentType: opts.contentType, active: opts.inactive ? false : opts.active ? true : undefined,
859
+ }), `Updated webhook ${id}`);
860
+ });
861
+ repoOpt(hook.command("delete").alias("rm").description("Delete a webhook").argument("<id>", "", int))
862
+ .action(async (id, opts) => {
863
+ const r = await resolveRepo(opts.repo);
864
+ await lg.delete(`${repoPath(r)}/hooks/${id}`);
865
+ done({ ok: true }, `Deleted webhook ${id}`);
866
+ });
867
+ repoOpt(hook.command("ping").description("Send a ping delivery").argument("<id>", "", int))
868
+ .action(async (id, opts) => {
869
+ const r = await resolveRepo(opts.repo);
870
+ await lg.post(`${repoPath(r)}/hooks/${id}/pings`);
871
+ done({ ok: true }, `Ping queued; see: lizard git webhook deliveries ${id}`);
872
+ });
873
+ repoOpt(hook.command("deliveries").description("Recent deliveries of a webhook").argument("<id>", "", int))
874
+ .option("--show <delivery>", "One delivery with its request and response")
875
+ .action(async (id, opts) => {
876
+ const r = await resolveRepo(opts.repo);
877
+ if (opts.show) {
878
+ const d = await lg.get(`${repoPath(r)}/hooks/${id}/deliveries/${seg(opts.show)}`);
879
+ return out(d, () => info(JSON.stringify(d, null, 2)));
880
+ }
881
+ const l = await lg.get(`${repoPath(r)}/hooks/${id}/deliveries`);
882
+ out(l, () => l.length ? table(["ID", "EVENT", "STATUS", "MS", "WHEN"], l.map((x) => [String(x.id), x.event, x.status ? String(x.status) : chalk.red(x.error ?? "failed"), String(x.durationMs ?? ""), ago(x.createdAt)])) : info("No deliveries yet."));
883
+ });
884
+ repoOpt(hook.command("redeliver").description("Send a delivery again").argument("<id>", "", int).argument("<delivery>", "", int))
885
+ .action(async (id, delivery, opts) => {
886
+ const r = await resolveRepo(opts.repo);
887
+ await lg.post(`${repoPath(r)}/hooks/${id}/deliveries/${delivery}/redeliver`);
888
+ done({ ok: true }, `Redelivery of ${delivery} queued`);
889
+ });
890
+ // ── Branch protection ────────────────────────────────────────────────
891
+ const protect = git.command("protect").description("Branch rules: required reviews and checks, no force pushes");
892
+ repoOpt(protect.command("list").alias("ls").description("Branch rules"))
893
+ .action(async (opts) => {
894
+ const r = await resolveRepo(opts.repo);
895
+ const l = await lg.get(`${repoPath(r)}/branch-rules`);
896
+ out(l, () => l.length ? table(["ID", "BRANCHES", "PR", "APPROVALS", "CHECKS", "NO FORCE", "NO DELETE", "ADMINS TOO", "CODEOWNERS"], l.map((x) => [
897
+ String(x.id), x.pattern, x.requirePullRequest ? "yes" : "", String(x.requiredApprovals || ""), x.requiredChecks.join(","),
898
+ x.blockForcePush ? "yes" : "", x.blockDeletion ? "yes" : "", x.enforceAdmins ? "yes" : "", x.requireCodeOwners ? "yes" : "",
899
+ ])) : info("No branch rules."));
900
+ });
901
+ repoOpt(protect.command("set").description("Create or change the rule for a branch pattern").argument("<pattern>", "Branch name or glob, e.g. main or release/*"))
902
+ .option("--require-pr", "Changes only through pull requests").option("--no-require-pr")
903
+ .option("--approvals <n>", "Approving reviews required")
904
+ .option("--checks <list>", "Comma-separated status checks that must pass (\"\" for none)")
905
+ .option("--block-force-push").option("--no-block-force-push")
906
+ .option("--block-deletion").option("--no-block-deletion")
907
+ .option("--enforce-admins", "Admins too").option("--no-enforce-admins")
908
+ .option("--code-owners", "Owners in CODEOWNERS must approve").option("--no-code-owners")
909
+ .action(async (pattern, opts) => {
910
+ const r = await resolveRepo(opts.repo);
911
+ const body = {
912
+ pattern,
913
+ requirePullRequest: opts.requirePr,
914
+ requiredApprovals: opts.approvals !== undefined ? Number(opts.approvals) : undefined,
915
+ requiredChecks: opts.checks !== undefined ? list(opts.checks) ?? [] : undefined,
916
+ blockForcePush: opts.blockForcePush,
917
+ blockDeletion: opts.blockDeletion,
918
+ enforceAdmins: opts.enforceAdmins,
919
+ requireCodeOwners: opts.codeOwners,
920
+ };
921
+ const existing = (await lg.get(`${repoPath(r)}/branch-rules`)).find((x) => x.pattern === pattern);
922
+ const rule = existing ? await lg.patch(`${repoPath(r)}/branch-rules/${existing.id}`, body) : await lg.post(`${repoPath(r)}/branch-rules`, body);
923
+ done(rule, `${existing ? "Updated" : "Created"} the rule for ${pattern}`);
924
+ });
925
+ repoOpt(protect.command("delete").alias("rm").description("Remove the rule for a branch pattern").argument("<pattern>"))
926
+ .action(async (pattern, opts) => {
927
+ const r = await resolveRepo(opts.repo);
928
+ const rule = (await lg.get(`${repoPath(r)}/branch-rules`)).find((x) => x.pattern === pattern || String(x.id) === pattern);
929
+ if (!rule)
930
+ throw new APIError(404, `no rule for ${pattern}`);
931
+ await lg.delete(`${repoPath(r)}/branch-rules/${rule.id}`);
932
+ done({ ok: true }, `Removed the rule for ${rule.pattern}`);
933
+ });
934
+ // ── Labels ───────────────────────────────────────────────────────────
935
+ const label = git.command("label").description("Issue and pull request labels");
936
+ repoOpt(label.command("list").alias("ls").description("Labels"))
937
+ .action(async (opts) => {
938
+ const r = await resolveRepo(opts.repo);
939
+ const l = await lg.get(`${repoPath(r)}/labels`);
940
+ out(l, () => table(["NAME", "COLOR", "DESCRIPTION"], l.map((x) => [chalk.hex(`#${x.color}`)("● ") + x.name, x.color, x.description])));
941
+ });
942
+ repoOpt(label.command("create").description("Create a label").argument("<name>"))
943
+ .option("-c, --color <hex>", "e.g. d73a4a").option("-d, --description <text>")
944
+ .action(async (name, opts) => {
945
+ const r = await resolveRepo(opts.repo);
946
+ done(await lg.post(`${repoPath(r)}/labels`, { name, color: opts.color?.replace(/^#/, ""), description: opts.description }), `Created label ${name}`);
947
+ });
948
+ repoOpt(label.command("edit").description("Change a label").argument("<name>"))
949
+ .option("--name <new>").option("-c, --color <hex>").option("-d, --description <text>")
950
+ .action(async (name, opts) => {
951
+ const r = await resolveRepo(opts.repo);
952
+ done(await lg.patch(`${repoPath(r)}/labels/${seg(name)}`, { name: opts.name, color: opts.color?.replace(/^#/, ""), description: opts.description }), `Updated label ${name}`);
953
+ });
954
+ repoOpt(label.command("delete").alias("rm").description("Delete a label").argument("<name>"))
955
+ .action(async (name, opts) => {
956
+ const r = await resolveRepo(opts.repo);
957
+ await lg.delete(`${repoPath(r)}/labels/${seg(name)}`);
958
+ done({ ok: true }, `Deleted label ${name}`);
959
+ });
960
+ // ── Releases ─────────────────────────────────────────────────────────
961
+ const release = git.command("release").description("Releases and their files");
962
+ const findRelease = async (r, tag) => lg.get(`${repoPath(r)}/releases/tags/${seg(tag)}`);
963
+ repoOpt(release.command("list").alias("ls").description("Releases"))
964
+ .action(async (opts) => {
965
+ const r = await resolveRepo(opts.repo);
966
+ const l = await lg.get(`${repoPath(r)}/releases`);
967
+ out(l, () => l.length ? table(["TAG", "NAME", "KIND", "FILES", "WHEN"], l.map((x) => [x.tag, x.name, x.draft ? "draft" : x.prerelease ? "pre-release" : "", String(x.assets?.length ?? 0), ago(x.publishedAt ?? x.createdAt)])) : info("No releases."));
968
+ });
969
+ repoOpt(release.command("create").description("Create a release (and its tag, if new)").argument("<tag>"))
970
+ .option("--target <ref>", "Where a new tag points (default branch)")
971
+ .option("-t, --title <text>").option("-n, --notes <text>").option("-F, --notes-file <file>")
972
+ .option("--draft").option("--prerelease")
973
+ .option("-a, --asset <file>", "Attach a file; repeatable", collect, [])
974
+ .action(async (tag, opts) => {
975
+ const r = await resolveRepo(opts.repo);
976
+ const rel = await lg.post(`${repoPath(r)}/releases`, {
977
+ tag, target: opts.target, name: opts.title ?? tag, body: opts.notesFile ? readFileSync(opts.notesFile, "utf8") : opts.notes ?? "", draft: !!opts.draft, prerelease: !!opts.prerelease,
978
+ });
979
+ for (const f of opts.asset)
980
+ await lg.upload(qs(`${repoPath(r)}/releases/${rel.id}/assets`, { name: basename(f) }), readFileSync(f), "application/octet-stream");
981
+ done(rel, `Created release ${tag}${opts.asset.length ? ` with ${opts.asset.length} file(s)` : ""} ${link(`${gitURL()}/${r}/releases/tag/${tag}`)}`);
982
+ });
983
+ repoOpt(release.command("view").description("A release and its files").argument("<tag>"))
984
+ .action(async (tag, opts) => {
985
+ const r = await resolveRepo(opts.repo);
986
+ const x = await findRelease(r, tag);
987
+ out(x, () => {
988
+ info(`${chalk.bold(x.name || x.tag)} ${chalk.dim(x.tag)}${x.draft ? " draft" : ""}${x.prerelease ? " pre-release" : ""} ${x.sha?.slice(0, 7) ?? ""}`);
989
+ if (x.body)
990
+ info(`\n${x.body}\n`);
991
+ for (const a of x.assets ?? [])
992
+ info(` ${a.name} ${a.size} B`);
993
+ });
994
+ });
995
+ repoOpt(release.command("edit").description("Change a release").argument("<tag>"))
996
+ .option("-t, --title <text>").option("-n, --notes <text>")
997
+ .option("--draft").option("--no-draft").option("--prerelease").option("--no-prerelease")
998
+ .action(async (tag, opts) => {
999
+ const r = await resolveRepo(opts.repo);
1000
+ const x = await findRelease(r, tag);
1001
+ done(await lg.patch(`${repoPath(r)}/releases/${x.id}`, { name: opts.title, body: opts.notes, draft: opts.draft, prerelease: opts.prerelease }), `Updated release ${tag}`);
1002
+ });
1003
+ repoOpt(release.command("delete").alias("rm").description("Delete a release (the tag stays)").argument("<tag>"))
1004
+ .action(async (tag, opts) => {
1005
+ const r = await resolveRepo(opts.repo);
1006
+ const x = await findRelease(r, tag);
1007
+ await lg.delete(`${repoPath(r)}/releases/${x.id}`);
1008
+ done({ ok: true }, `Deleted release ${tag}`);
1009
+ });
1010
+ repoOpt(release.command("upload").description("Attach files to a release").argument("<tag>").argument("<files...>"))
1011
+ .action(async (tag, files, opts) => {
1012
+ const r = await resolveRepo(opts.repo);
1013
+ const x = await findRelease(r, tag);
1014
+ const up = [];
1015
+ for (const f of files)
1016
+ up.push(await lg.upload(qs(`${repoPath(r)}/releases/${x.id}/assets`, { name: basename(f) }), readFileSync(f), "application/octet-stream"));
1017
+ done(up, `Uploaded ${files.length} file(s) to ${tag}`);
1018
+ });
1019
+ repoOpt(release.command("download").description("Download a release's files").argument("<tag>"))
1020
+ .option("-p, --pattern <name>", "Only this file")
1021
+ .option("-D, --dir <dir>", "Directory", ".")
1022
+ .action(async (tag, opts) => {
1023
+ const r = await resolveRepo(opts.repo);
1024
+ const x = await findRelease(r, tag);
1025
+ const got = [];
1026
+ for (const a of (x.assets ?? []).filter((a) => !opts.pattern || a.name === opts.pattern)) {
1027
+ const data = await lg.bytes(`${repoPath(r)}/releases/${x.id}/assets/${a.id}`);
1028
+ writeFileSync(`${opts.dir}/${a.name}`, data);
1029
+ got.push({ name: a.name, bytes: data.length });
1030
+ }
1031
+ done(got, `Downloaded ${got.length} file(s)`);
1032
+ });
1033
+ repoOpt(release.command("delete-asset").description("Remove a file from a release").argument("<tag>").argument("<name>"))
1034
+ .action(async (tag, name, opts) => {
1035
+ const r = await resolveRepo(opts.repo);
1036
+ const x = await findRelease(r, tag);
1037
+ const a = (x.assets ?? []).find((a) => a.name === name);
1038
+ if (!a)
1039
+ throw new APIError(404, `${tag} has no file ${name}`);
1040
+ await lg.delete(`${repoPath(r)}/releases/${x.id}/assets/${a.id}`);
1041
+ done({ ok: true }, `Removed ${name} from ${tag}`);
1042
+ });
1043
+ // ── Commit statuses ──────────────────────────────────────────────────
1044
+ const cs = git.command("commit-status").description("Commit statuses (external checks; what branch rules can require)");
1045
+ repoOpt(cs.command("list").alias("ls").description("Statuses of a commit or branch").argument("[ref]", "Commit, branch or tag (default branch)"))
1046
+ .action(async (ref, opts) => {
1047
+ const r = await resolveRepo(opts.repo);
1048
+ const x = await lg.get(`${repoPath(r)}/commits/${seg(ref ?? (await lg.get(repoPath(r))).repo.defaultBranch)}/status`);
1049
+ out(x, () => {
1050
+ info(`${x.sha.slice(0, 7)} ${state(x.state)}`);
1051
+ if (x.statuses.length)
1052
+ table(["STATE", "CONTEXT", "DESCRIPTION", "WHEN"], x.statuses.map((s) => [state(s.state), s.context, s.description ?? "", ago(s.createdAt)]));
1053
+ });
1054
+ });
1055
+ repoOpt(cs.command("set").description("Report a status on a commit").argument("<sha>"))
1056
+ .requiredOption("-s, --state <state>", "pending, success, failure or error")
1057
+ .requiredOption("-c, --context <name>", "e.g. ci/tests")
1058
+ .option("-d, --description <text>").option("-u, --target-url <url>")
1059
+ .action(async (sha, opts) => {
1060
+ const r = await resolveRepo(opts.repo);
1061
+ done(await lg.post(`${repoPath(r)}/statuses/${seg(sha)}`, { state: opts.state, context: opts.context, description: opts.description, target_url: opts.targetUrl }), `${opts.context}: ${opts.state} on ${sha.slice(0, 7)}`);
1062
+ });
1063
+ // ── Notifications, tokens, whoami ────────────────────────────────────
1064
+ const notif = git.command("notification").alias("inbox").description("Your Lizard Git notifications");
1065
+ notif.command("list").alias("ls").description("Unread notifications (--all for everything)")
1066
+ .option("--all")
1067
+ .action(async (opts) => {
1068
+ const n = await lg.get(qs("/api/notifications", { all: opts.all ? 1 : undefined }));
1069
+ out(n, () => n.notifications.length ? table(["ID", "", "REPO", "#", "TITLE", "WHY", "WHEN"], n.notifications.map((x) => [
1070
+ String(x.id), x.unread ? chalk.cyan("●") : "", x.repo, x.number ? String(x.number) : "", (x.title ?? "").slice(0, 50), x.reason, ago(x.updatedAt),
1071
+ ])) : info("Nothing new."));
1072
+ });
1073
+ notif.command("read").description("Mark notifications read (all, or the ids given)").argument("[ids...]")
1074
+ .action(async (ids) => {
1075
+ await lg.post("/api/notifications/read", ids.length ? { ids: ids.map(Number) } : { all: true });
1076
+ done({ ok: true }, ids.length ? `Marked ${ids.length} read` : "Marked all read");
1077
+ });
1078
+ const token = git.command("token").description("Lizard Git tokens (lzg_...): limited to repos, refs, paths, or read-only");
1079
+ token.command("list").alias("ls").description("Your tokens")
1080
+ .action(async () => {
1081
+ const l = await lg.get("/api/user/tokens");
1082
+ out(l, () => l.length ? table(["ID", "NAME", "REPOS", "REFS", "PATHS", "READ-ONLY", "LAST USED"], l.map((x) => [
1083
+ String(x.id), x.name, (x.scope?.repos ?? []).join(",") || "all", (x.scope?.refs ?? []).join(",") || "all", (x.scope?.paths ?? []).join(",") || "all", x.scope?.readOnly ? "yes" : "", ago(x.lastUsedAt),
1084
+ ])) : info("No tokens."));
1085
+ });
1086
+ token.command("create").description("Make a token (shown once)").argument("<name>")
1087
+ .option("--repo <owner/name>", "Only this repository; repeatable", collect, [])
1088
+ .option("--ref <pattern>", "Only these branches/tags (refs/heads/x, globs); repeatable", collect, [])
1089
+ .option("--path <prefix>", "Only these paths; repeatable", collect, [])
1090
+ .option("--read-only")
1091
+ .action(async (name, opts) => {
1092
+ const t = await lg.post("/api/user/tokens", { name, repos: opts.repo, refs: opts.ref, paths: opts.path, readOnly: !!opts.readOnly });
1093
+ out(t, () => {
1094
+ success(`Created token ${name}`);
1095
+ process.stdout.write(t.token + "\n");
1096
+ info(chalk.dim(" Shown once. Use it as the git password, or LIZARD_GIT_TOKEN for this CLI."));
1097
+ });
1098
+ });
1099
+ token.command("delete").alias("rm").description("Revoke a token").argument("<id>", "", int)
1100
+ .action(async (id) => {
1101
+ await lg.delete(`/api/user/tokens/${id}`);
1102
+ done({ ok: true }, `Revoked token ${id}`);
1103
+ });
1104
+ git.command("whoami").description("Who you are on Lizard Git")
1105
+ .action(async () => {
1106
+ const u = await lg.get("/api/session");
1107
+ out(u, () => info(`${chalk.bold(u.login)} on ${gitURL()}${process.env.LIZARD_GIT_TOKEN ? " (LIZARD_GIT_TOKEN)" : ""}`));
1108
+ });
1109
+ }
1110
+ //# sourceMappingURL=git-hosting.js.map