Public: anyone can read this; the crew can change it.
Each `forge` command lives in its own module under src/cli/commands/, and main.ts dispatches from one table #7588
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7424-each-forge-command-lives-in-its-own-modu → main 10 commits fork 4 threads · 4 unresolved
23 files
changed +1319
−1066
AGENTS.md +2
| @@ -5,3 +5,5 @@ Read `CLAUDE.md` first: it is the rulebook for agents editing this repo. | ||
| 5 | 5 | README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, not a new README section. |
| 6 | 6 | |
| 7 | 7 | When you write an issue, end it with a `Files:` line naming the files the work touches (`Files: src/a.ts, docs/b.md`). The hot-file hold reads only that line when it is there; without it, every path the issue mentions counts. |
| 8 | ||
| 9 | A new `forge` command is a new file in `src/cli/commands/` (exporting `command`) plus one line in its `index.ts`. |
src/cli/commands/asks.ts added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | import { ForgeApi, type Ask, type Bulk } from "../api.ts"; | |
| 2 | import { parseWish, pickAsks, type Wish } from "../decide.ts"; | |
| 3 | import { askLine, type Io } from "../io.ts"; | |
| 4 | import { parseRepoRef, type Args } from "../args.ts"; | |
| 5 | import { requireConfig } from "../context.ts"; | |
| 6 | import { EXIT } from "../exit.ts"; | |
| 7 | import { inert, type Out } from "../out.ts"; | |
| 8 | import { displayWidth, pad, truncate } from "../../tui/term/width.ts"; | |
| 9 | import type { Command } from "./types.ts"; | |
| 10 | ||
| 11 | /** | |
| 12 | * What waits on you, one line each, in the order the forge ranks it: kind, | |
| 13 | * repository, title cut to the terminal, and the address to decide it at. | |
| 14 | */ | |
| 15 | export async function asks( | |
| 16 | out: Out, | |
| 17 | args: Args, | |
| 18 | argv: readonly string[] = [], | |
| 19 | confirm: Confirm = confirmOn({ ask: askLine }), | |
| 20 | ): Promise<number> { | |
| 21 | const wish = parseWish(argv); | |
| 22 | if (typeof wish === "string") { | |
| 23 | out.err(`forge asks: ${wish}`); | |
| 24 | return EXIT.usage; | |
| 25 | } | |
| 26 | const only = args.value("repo"); | |
| 27 | const ref = only === undefined ? undefined : parseRepoRef(only); | |
| 28 | if (only !== undefined && ref === undefined) { | |
| 29 | out.err("forge asks: --repo expects owner/name"); | |
| 30 | return EXIT.usage; | |
| 31 | } | |
| 32 | const loaded = await requireConfig(out); | |
| 33 | if (typeof loaded === "number") return loaded; | |
| 34 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 35 | const answer = await api.asks(); | |
| 36 | if (!answer.ok) { | |
| 37 | out.err(`forge asks: ${answer.error.message}`); | |
| 38 | return answer.error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 39 | } | |
| 40 | const repo = ref === undefined ? undefined : `${ref.owner}/${ref.name}`; | |
| 41 | if (wish !== undefined && wish.action === "undo") | |
| 42 | return decide(out, api, wish, [], true, confirm); | |
| 43 | if (wish !== undefined) { | |
| 44 | const rows = answer.value.asks.filter( | |
| 45 | (a) => repo === undefined || a.repo === repo, | |
| 46 | ); | |
| 47 | return decide(out, api, wish, rows, args.flag("yes"), confirm); | |
| 48 | } | |
| 49 | if (args.flag("json")) { | |
| 50 | const raw = answer.value.raw; | |
| 51 | out.line(JSON.stringify(repo === undefined ? raw : onlyRepo(raw, repo))); | |
| 52 | return EXIT.ok; | |
| 53 | } | |
| 54 | const rows = answer.value.asks.filter( | |
| 55 | (a) => repo === undefined || a.repo === repo, | |
| 56 | ); | |
| 57 | if (rows.length === 0) { | |
| 58 | out.line("Nothing waits on you."); | |
| 59 | return EXIT.ok; | |
| 60 | } | |
| 61 | const numberW = String(rows.length).length; | |
| 62 | const lines = askLines(rows, out.width - numberW - 2, out); | |
| 63 | lines.forEach((line, i) => | |
| 64 | out.line(`${String(i + 1).padStart(numberW)} ${line}`), | |
| 65 | ); | |
| 66 | return EXIT.ok; | |
| 67 | } | |
| 68 | ||
| 69 | /** A y/N from the person at the keyboard, undefined when there is none. */ | |
| 70 | const confirmOn = | |
| 71 | (io: Io): Confirm => | |
| 72 | async (question) => { | |
| 73 | const line = await io.ask(question); | |
| 74 | return line === null ? undefined : /^\s*y(es)?\s*$/i.test(line); | |
| 75 | }; | |
| 76 | ||
| 77 | /** Whether to go ahead: undefined when there is no one to ask. */ | |
| 78 | export type Confirm = (question: string) => Promise<boolean | undefined>; | |
| 79 | ||
| 80 | /** | |
| 81 | * Decide asks by the numbers of the list just read: the plan first, then, | |
| 82 | * after a yes (or `--yes`), the deed and the line that undoes it. | |
| 83 | */ | |
| 84 | async function decide( | |
| 85 | out: Out, | |
| 86 | api: ForgeApi, | |
| 87 | wish: Wish, | |
| 88 | rows: readonly Ask[], | |
| 89 | yes: boolean, | |
| 90 | confirm: Confirm, | |
| 91 | ): Promise<number> { | |
| 92 | const fail = (e: { message: string; status: number }): number => { | |
| 93 | out.err(`forge asks: ${e.message}`); | |
| 94 | return e.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 95 | }; | |
| 96 | if (wish.action === "undo") { | |
| 97 | const back = await api.undo(wish.numbers[0]!); | |
| 98 | if (!back.ok) return fail(back.error); | |
| 99 | printBulk(out, back.value, "undo", true); | |
| 100 | return EXIT.ok; | |
| 101 | } | |
| 102 | const ids = pickAsks(wish.numbers, rows); | |
| 103 | if (!Array.isArray(ids)) { | |
| 104 | out.err(`forge asks: ${ids.refused}`); | |
| 105 | return EXIT.usage; | |
| 106 | } | |
| 107 | const plan = await api.bulk(wish.action, ids, wish.reason, true); | |
| 108 | if (!plan.ok) return fail(plan.error); | |
| 109 | printBulk(out, plan.value, wish.action, false); | |
| 110 | const changes = plan.value.rows.filter((r) => r.outcome === "change").length; | |
| 111 | if (changes === 0) { | |
| 112 | out.line("Nothing would change."); | |
| 113 | return EXIT.ok; | |
| 114 | } | |
| 115 | if (!yes) { | |
| 116 | const said = await confirm("Apply? [y/N] "); | |
| 117 | if (said === undefined) { | |
| 118 | out.err("forge asks: not applied — no terminal to ask on; add --yes"); | |
| 119 | return EXIT.usage; | |
| 120 | } | |
| 121 | if (!said) { | |
| 122 | out.line("Nothing applied."); | |
| 123 | return EXIT.ok; | |
| 124 | } | |
| 125 | } | |
| 126 | const done = await api.bulk(wish.action, ids, wish.reason, false); | |
| 127 | if (!done.ok) return fail(done.error); | |
| 128 | printBulk(out, done.value, wish.action, true); | |
| 129 | if (done.value.id !== undefined) { | |
| 130 | out.line(`Receipt ${inert(done.value.id)}`); | |
| 131 | out.line(`Undo: forge asks --undo ${inert(done.value.id)}`); | |
| 132 | } | |
| 133 | return EXIT.ok; | |
| 134 | } | |
| 135 | ||
| 136 | function printBulk(out: Out, bulk: Bulk, action: string, done: boolean): void { | |
| 137 | out.line(done ? `Applied: ${action}` : `Would ${action}:`); | |
| 138 | for (const r of bulk.rows) { | |
| 139 | const move = r.before === r.after ? "" : ` ${r.before} → ${r.after}`; | |
| 140 | const why = r.reason === "" ? "" : ` (${r.reason})`; | |
| 141 | out.line(` ${pad(inert(r.outcome), 9)} ${inert(r.label)}${inert(move)}${inert(why)}`); | |
| 142 | } | |
| 143 | for (const s of bulk.skipped) | |
| 144 | out.line(` skipped ${inert(s.item)} (${inert(s.reason)})`); | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * The asks as lines no wider than `width - 1`, so none wraps on its own: | |
| 149 | * kind and repository padded to their widest, the address whole, and the | |
| 150 | * title cut to what those leave. Coloured only when `out` is given. | |
| 151 | */ | |
| 152 | export function askLines( | |
| 153 | rows: readonly Ask[], | |
| 154 | width: number, | |
| 155 | out?: Pick<Out, "paint" | "c">, | |
| 156 | ): string[] { | |
| 157 | const kindW = Math.max(...rows.map((a) => displayWidth(inert(a.kind)))); | |
| 158 | const repoW = Math.max(...rows.map((a) => displayWidth(inert(a.repo)))); | |
| 159 | return rows.map((a) => { | |
| 160 | const url = inert(a.url); | |
| 161 | // Three two-space gaps; the title gets what the other columns leave. | |
| 162 | const room = Math.max(1, width - 1 - kindW - repoW - displayWidth(url) - 6); | |
| 163 | const kind = pad(inert(a.kind), kindW); | |
| 164 | return [ | |
| 165 | out === undefined ? kind : out.paint(kind, out.c.accent), | |
| 166 | pad(inert(a.repo), repoW), | |
| 167 | truncate(inert(a.title), room), | |
| 168 | out === undefined ? url : out.paint(url, out.c.faint), | |
| 169 | ].join(" "); | |
| 170 | }); | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * The API's answer with only one repository's rows; the forge's own needs | |
| 175 | * belong to none. | |
| 176 | */ | |
| 177 | function onlyRepo(raw: unknown, repo: string): unknown { | |
| 178 | if (typeof raw !== "object" || raw === null) return raw; | |
| 179 | const body = raw as { rows?: unknown }; | |
| 180 | return { | |
| 181 | ...body, | |
| 182 | rows: Array.isArray(body.rows) | |
| 183 | ? body.rows.filter((r) => (r as { repo?: unknown } | null)?.repo === repo) | |
| 184 | : body.rows, | |
| 185 | forge: [], | |
| 186 | }; | |
| 187 | } | |
| 188 | ||
| 189 | export const command: Command = { | |
| 190 | name: "asks", | |
| 191 | usage: { | |
| 192 | synopsis: "forge asks", | |
| 193 | flags: "[--repo owner/name] [--json] | --ship|--close|--dismiss <n…> [--reason words] [--yes]", | |
| 194 | }, | |
| 195 | run: (out, args, _help, io, argv) => asks(out, args, argv, confirmOn(io)), | |
| 196 | }; |
src/cli/commands/index.ts added +36
| @@ -0,0 +1,36 @@ | ||
| 1 | /** | |
| 2 | * Every `forge` subcommand, in the order `forge help` lists them. A new | |
| 3 | * command is a new file in this directory plus one line here. | |
| 4 | */ | |
| 5 | import { command as ui } from "./ui.ts"; | |
| 6 | import { command as login } from "./login.ts"; | |
| 7 | import { command as logout } from "./logout.ts"; | |
| 8 | import { command as whoami } from "./whoami.ts"; | |
| 9 | import { command as sync } from "./sync.ts"; | |
| 10 | import { command as status } from "./status.ts"; | |
| 11 | import { command as ls } from "./ls.ts"; | |
| 12 | import { command as pin } from "./pin.ts"; | |
| 13 | import { command as unpin } from "./unpin.ts"; | |
| 14 | import { command as open } from "./open.ts"; | |
| 15 | import { command as pulls } from "./pulls.ts"; | |
| 16 | import { command as asks } from "./asks.ts"; | |
| 17 | import { command as search } from "./search.ts"; | |
| 18 | import { command as ship } from "./ship.ts"; | |
| 19 | import type { Command } from "./types.ts"; | |
| 20 | ||
| 21 | export const commands: readonly Command[] = [ | |
| 22 | ui, | |
| 23 | login, | |
| 24 | logout, | |
| 25 | whoami, | |
| 26 | sync, | |
| 27 | status, | |
| 28 | ls, | |
| 29 | pin, | |
| 30 | unpin, | |
| 31 | open, | |
| 32 | pulls, | |
| 33 | asks, | |
| 34 | search, | |
| 35 | ship, | |
| 36 | ]; |
src/cli/commands/login.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import type { Args } from "../args.ts"; | |
| 3 | import { | |
| 4 | configPath, | |
| 5 | normalizeUrl, | |
| 6 | PLAIN_HTTP_REFUSAL, | |
| 7 | plainHttpRemote, | |
| 8 | saveConfig, | |
| 9 | } from "../config.ts"; | |
| 10 | import { EXIT } from "../exit.ts"; | |
| 11 | import type { Out } from "../out.ts"; | |
| 12 | import { G } from "../../tui/term/screen.ts"; | |
| 13 | import type { Command } from "./types.ts"; | |
| 14 | ||
| 15 | async function login(out: Out, args: Args): Promise<number> { | |
| 16 | const raw = args.positional[0] ?? args.value("url"); | |
| 17 | if (raw === undefined) { | |
| 18 | out.err("forge login: which forge? e.g. forge login forge.example.com"); | |
| 19 | return EXIT.usage; | |
| 20 | } | |
| 21 | const url = normalizeUrl(raw); | |
| 22 | // Before the token is asked for, let alone sent: nothing crosses the | |
| 23 | // network in clear unless the person said this host is one they trust. | |
| 24 | const insecure = args.flag("insecure"); | |
| 25 | if (plainHttpRemote(url) && !insecure) { | |
| 26 | out.err(`forge login: ${PLAIN_HTTP_REFUSAL}`); | |
| 27 | return EXIT.usage; | |
| 28 | } | |
| 29 | const token = args.value("token") ?? (await askSecret(`Token for ${url}: `)); | |
| 30 | if (token === "") { | |
| 31 | out.err("forge login: no token given"); | |
| 32 | return EXIT.usage; | |
| 33 | } | |
| 34 | ||
| 35 | // Verify before storing: a config that does not work is worse than none. | |
| 36 | const api = new ForgeApi(url, token); | |
| 37 | const me = await api.whoami(); | |
| 38 | if (!me.ok) { | |
| 39 | out.err(`forge login: ${me.error.message}`); | |
| 40 | return me.error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 41 | } | |
| 42 | const stored = await saveConfig({ url, token }, { insecure }); | |
| 43 | out.line( | |
| 44 | `${out.paint(G.check, out.c.success)} signed in to ${url} as ${me.value.username}`, | |
| 45 | ); | |
| 46 | out.line( | |
| 47 | out.paint( | |
| 48 | stored.keychain | |
| 49 | ? ` token in your login keychain; ${configPath()} holds only the URL` | |
| 50 | : ` token in ${configPath()} (0600)`, | |
| 51 | out.c.faint, | |
| 52 | ), | |
| 53 | ); | |
| 54 | return EXIT.ok; | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * A secret, read without echo. Raw mode is the only way to stop the terminal | |
| 59 | * printing it, and it is restored on every path — including ctrl+c, which | |
| 60 | * here means "no token", not "leave the tty broken". | |
| 61 | */ | |
| 62 | async function askSecret(prompt: string): Promise<string> { | |
| 63 | const stdin = process.stdin; | |
| 64 | if (stdin.isTTY !== true) { | |
| 65 | // Piped input: read one line. `echo $TOKEN | forge login url` is a | |
| 66 | // legitimate way to do this from a script. | |
| 67 | const text = await new Response(Bun.stdin.stream()).text(); | |
| 68 | return text.split("\n")[0]?.trim() ?? ""; | |
| 69 | } | |
| 70 | process.stdout.write(prompt); | |
| 71 | stdin.setRawMode(true); | |
| 72 | stdin.resume(); | |
| 73 | return new Promise<string>((resolve) => { | |
| 74 | let value = ""; | |
| 75 | const finish = (result: string): void => { | |
| 76 | stdin.off("data", onData); | |
| 77 | stdin.setRawMode(false); | |
| 78 | stdin.pause(); | |
| 79 | process.stdout.write("\n"); | |
| 80 | resolve(result); | |
| 81 | }; | |
| 82 | const onData = (chunk: Buffer): void => { | |
| 83 | for (const byte of chunk) { | |
| 84 | if (byte === 3) return finish(""); // ctrl+c | |
| 85 | if (byte === 13 || byte === 10) return finish(value.trim()); | |
| 86 | if (byte === 127 || byte === 8) { | |
| 87 | value = value.slice(0, -1); | |
| 88 | continue; | |
| 89 | } | |
| 90 | if (byte >= 0x20) value += String.fromCharCode(byte); | |
| 91 | } | |
| 92 | }; | |
| 93 | stdin.on("data", onData); | |
| 94 | }); | |
| 95 | } | |
| 96 | ||
| 97 | export const command: Command = { | |
| 98 | name: "login", | |
| 99 | usage: { synopsis: "forge login <url>", flags: "[--token T] [--insecure]" }, | |
| 100 | run: login, | |
| 101 | }; |
src/cli/commands/logout.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | import { clearConfig } from "../config.ts"; | |
| 2 | import { EXIT } from "../exit.ts"; | |
| 3 | import type { Command } from "./types.ts"; | |
| 4 | ||
| 5 | export const command: Command = { | |
| 6 | name: "logout", | |
| 7 | usage: { synopsis: "forge logout" }, | |
| 8 | async run(out) { | |
| 9 | await clearConfig(); | |
| 10 | out.line("signed out"); | |
| 11 | return EXIT.ok; | |
| 12 | }, | |
| 13 | }; |
src/cli/commands/ls.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { rootDir } from "../config.ts"; | |
| 3 | import { asJson, requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import { cell, type Out } from "../out.ts"; | |
| 6 | import { dirtyCount, settled, since } from "../report.ts"; | |
| 7 | import { syncOnce, type RepoReport } from "../sync.ts"; | |
| 8 | import { G } from "../../tui/term/screen.ts"; | |
| 9 | import { trackText } from "../../tui/local/common.ts"; | |
| 10 | import type { Command } from "./types.ts"; | |
| 11 | ||
| 12 | function lsState(out: Out, r: RepoReport): ReturnType<typeof cell> { | |
| 13 | if (!r.present) return cell("not cloned", out.c.attn); | |
| 14 | const dirty = dirtyCount(r); | |
| 15 | if (dirty > 0) return cell(`${dirty} dirty`, out.c.accent); | |
| 16 | if (settled(r)) return cell(G.check, out.c.success); | |
| 17 | return cell(r.upstream === null ? "no upstream" : "", out.c.faint); | |
| 18 | } | |
| 19 | ||
| 20 | export const command: Command = { | |
| 21 | name: "ls", | |
| 22 | usage: { synopsis: "forge ls", flags: "[--json]" }, | |
| 23 | async run(out, args) { | |
| 24 | const loaded = await requireConfig(out); | |
| 25 | if (typeof loaded === "number") return loaded; | |
| 26 | // `ls` answers from disk, so it keeps working when the forge does not. | |
| 27 | const report = await syncOnce({ | |
| 28 | api: new ForgeApi(loaded.url, loaded.token), | |
| 29 | token: loaded.token, | |
| 30 | root: rootDir(args.value("root")), | |
| 31 | fetch: false, | |
| 32 | }); | |
| 33 | if (args.flag("json")) { | |
| 34 | out.line(JSON.stringify(asJson(report))); | |
| 35 | return EXIT.ok; | |
| 36 | } | |
| 37 | out.line(out.paint(report.root, out.c.faint)); | |
| 38 | out.table( | |
| 39 | [ | |
| 40 | { header: "REPO", max: 44 }, | |
| 41 | { header: "BRANCH", max: 24 }, | |
| 42 | { header: "SYNC", align: "right" }, | |
| 43 | { header: "STATE", max: 28 }, | |
| 44 | { header: "FETCHED", align: "right" }, | |
| 45 | ], | |
| 46 | report.repos.map((r) => [ | |
| 47 | cell(`${r.owner}/${r.name}`, r.present ? out.c.fg : out.c.mutedFg), | |
| 48 | cell(r.present ? (r.branch ?? "detached") : "—", out.c.mutedFg), | |
| 49 | cell(trackText(r.ahead, r.behind), out.c.attn), | |
| 50 | lsState(out, r), | |
| 51 | cell(r.present ? since(r.lastFetch) : "", out.c.faint), | |
| 52 | ]), | |
| 53 | ); | |
| 54 | return EXIT.ok; | |
| 55 | }, | |
| 56 | }; |
src/cli/commands/open.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { parseRepoRef } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import type { Command } from "./types.ts"; | |
| 6 | ||
| 7 | export const command: Command = { | |
| 8 | name: "open", | |
| 9 | usage: { synopsis: "forge open <owner/name>", flags: "[--web]" }, | |
| 10 | async run(out, args) { | |
| 11 | const ref = parseRepoRef(args.positional[0]); | |
| 12 | if (ref === undefined) { | |
| 13 | out.err("forge open: expected owner/name"); | |
| 14 | return EXIT.usage; | |
| 15 | } | |
| 16 | const loaded = await requireConfig(out); | |
| 17 | if (typeof loaded === "number") return loaded; | |
| 18 | const url = new ForgeApi(loaded.url, loaded.token).webUrl( | |
| 19 | ref.owner, | |
| 20 | ref.name, | |
| 21 | ); | |
| 22 | out.line(url); | |
| 23 | if (!args.flag("web")) return EXIT.ok; | |
| 24 | const opener = process.platform === "darwin" ? "open" : "xdg-open"; | |
| 25 | try { | |
| 26 | const proc = Bun.spawn([opener, url], { | |
| 27 | stdout: "ignore", | |
| 28 | stderr: "ignore", | |
| 29 | }); | |
| 30 | await proc.exited; | |
| 31 | } catch { | |
| 32 | out.err(`forge open: could not run ${opener}`); | |
| 33 | return EXIT.failed; | |
| 34 | } | |
| 35 | return EXIT.ok; | |
| 36 | }, | |
| 37 | }; |
src/cli/commands/pin.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { parseRepoRef, type Args } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import type { Out } from "../out.ts"; | |
| 6 | import { G } from "../../tui/term/screen.ts"; | |
| 7 | import type { Command } from "./types.ts"; | |
| 8 | ||
| 9 | /** Shared by `pin` and `unpin`: the two differ only in the call and the verb. */ | |
| 10 | export async function setPin( | |
| 11 | out: Out, | |
| 12 | args: Args, | |
| 13 | on: boolean, | |
| 14 | ): Promise<number> { | |
| 15 | const verb = on ? "pin" : "unpin"; | |
| 16 | const ref = parseRepoRef(args.positional[0]); | |
| 17 | if (ref === undefined) { | |
| 18 | out.err(`forge ${verb}: expected owner/name`); | |
| 19 | return EXIT.usage; | |
| 20 | } | |
| 21 | const loaded = await requireConfig(out); | |
| 22 | if (typeof loaded === "number") return loaded; | |
| 23 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 24 | const result = on | |
| 25 | ? await api.pin(ref.owner, ref.name) | |
| 26 | : await api.unpin(ref.owner, ref.name); | |
| 27 | if (!result.ok) { | |
| 28 | out.err( | |
| 29 | result.error.status === 404 | |
| 30 | ? `forge ${verb}: this forge does not support pins yet` | |
| 31 | : `forge ${verb}: ${result.error.message}`, | |
| 32 | ); | |
| 33 | return EXIT.failed; | |
| 34 | } | |
| 35 | out.line( | |
| 36 | `${out.paint(G.check, out.c.success)} ${on ? "pinned" : "unpinned"} ${ref.owner}/${ref.name}`, | |
| 37 | ); | |
| 38 | if (on) | |
| 39 | out.line(out.paint(" run `forge sync` to bring it down", out.c.faint)); | |
| 40 | return EXIT.ok; | |
| 41 | } | |
| 42 | ||
| 43 | export const command: Command = { | |
| 44 | name: "pin", | |
| 45 | usage: { synopsis: "forge pin <owner/name>" }, | |
| 46 | run: (out, args) => setPin(out, args, true), | |
| 47 | }; |
src/cli/commands/pulls.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { parseRepoRef, type Args } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import { folderRepo } from "../folder-repo.ts"; | |
| 6 | import { inert, type Out } from "../out.ts"; | |
| 7 | import { displayWidth, pad, truncate } from "../../tui/term/width.ts"; | |
| 8 | import type { Command } from "./types.ts"; | |
| 9 | ||
| 10 | async function pulls(out: Out, args: Args): Promise<number> { | |
| 11 | const loaded = await requireConfig(out); | |
| 12 | if (typeof loaded === "number") return loaded; | |
| 13 | const flagged = args.value("repo"); | |
| 14 | const ref = | |
| 15 | flagged !== undefined | |
| 16 | ? parseRepoRef(flagged) | |
| 17 | : await folderRepo(process.cwd(), loaded.url); | |
| 18 | if (ref === undefined) { | |
| 19 | out.err( | |
| 20 | flagged !== undefined | |
| 21 | ? "forge pulls: --repo expects owner/name" | |
| 22 | : `forge pulls: this folder is not a clone of ${loaded.url} — run it in a clone, or pass --repo owner/name`, | |
| 23 | ); | |
| 24 | return EXIT.usage; | |
| 25 | } | |
| 26 | const closed = args.flag("closed"); | |
| 27 | const got = await new ForgeApi(loaded.url, loaded.token).pulls( | |
| 28 | ref.owner, | |
| 29 | ref.name, | |
| 30 | closed ? "closed" : "open", | |
| 31 | ); | |
| 32 | if (!got.ok) { | |
| 33 | out.err(`forge pulls: ${got.error.message}`); | |
| 34 | return EXIT.failed; | |
| 35 | } | |
| 36 | if (args.flag("json")) { | |
| 37 | out.line(JSON.stringify(got.value)); | |
| 38 | return EXIT.ok; | |
| 39 | } | |
| 40 | const items = got.value.items as Record<string, unknown>[]; | |
| 41 | if (items.length === 0) { | |
| 42 | out.line(closed ? "No closed pulls." : "No open pulls."); | |
| 43 | return EXIT.ok; | |
| 44 | } | |
| 45 | const tone = { | |
| 46 | green: out.c.success, | |
| 47 | red: out.c.danger, | |
| 48 | running: out.c.attn, | |
| 49 | none: out.c.faint, | |
| 50 | } as const; | |
| 51 | const rows = items.map((item) => ({ | |
| 52 | number: `#${String(item["number"])}`, | |
| 53 | title: inert(String(item["title"] ?? "")), | |
| 54 | author: inert(String(item["author"] ?? "")), | |
| 55 | check: String(item["check"]) as keyof typeof tone, | |
| 56 | preview: inert(typeof item["preview"] === "string" ? item["preview"] : ""), | |
| 57 | })); | |
| 58 | const wide = (pick: (r: (typeof rows)[number]) => string): number => | |
| 59 | Math.max(...rows.map((r) => displayWidth(pick(r)))); | |
| 60 | const nW = wide((r) => r.number); | |
| 61 | const aW = Math.min( | |
| 62 | 20, | |
| 63 | wide((r) => r.author), | |
| 64 | ); | |
| 65 | const cW = wide((r) => r.check); | |
| 66 | const pW = wide((r) => r.preview); | |
| 67 | const gaps = 2 * (pW > 0 ? 4 : 3); | |
| 68 | const tW = Math.max(12, out.width - nW - aW - cW - pW - gaps); | |
| 69 | for (const r of rows) { | |
| 70 | const parts = [ | |
| 71 | pad(r.number, nW, "right"), | |
| 72 | pad(truncate(r.title, tW), tW), | |
| 73 | pad(truncate(r.author, aW), aW), | |
| 74 | out.paint(pad(r.check, cW), tone[r.check] ?? out.c.faint), | |
| 75 | ]; | |
| 76 | if (pW > 0) parts.push(r.preview); | |
| 77 | out.line(parts.join(" ").replace(/\s+$/, "")); | |
| 78 | } | |
| 79 | if (got.value.next_cursor !== null) | |
| 80 | out.line( | |
| 81 | out.paint( | |
| 82 | `The list is cut after ${rows.length} pulls; the forge has more.`, | |
| 83 | out.c.faint, | |
| 84 | ), | |
| 85 | ); | |
| 86 | return EXIT.ok; | |
| 87 | } | |
| 88 | ||
| 89 | export const command: Command = { | |
| 90 | name: "pulls", | |
| 91 | usage: { | |
| 92 | synopsis: "forge pulls", | |
| 93 | flags: "[--repo owner/name] [--closed] [--json]", | |
| 94 | }, | |
| 95 | run: pulls, | |
| 96 | }; |
src/cli/commands/search.ts added +214
| @@ -0,0 +1,214 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import type { Args } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import { printUsage } from "../help.ts"; | |
| 6 | import { inert, type Out } from "../out.ts"; | |
| 7 | import { displayWidth, pad, truncate } from "../../tui/term/width.ts"; | |
| 8 | import type { Command } from "./types.ts"; | |
| 9 | ||
| 10 | /** The forge's default and ceiling for `limit` (`src/actions/search.ts`). */ | |
| 11 | const SEARCH_API_LIMIT = 20; | |
| 12 | const SEARCH_API_LIMIT_MAX = 100; | |
| 13 | ||
| 14 | const SEARCH_KINDS = ["code", "issues", "pulls", "repos", "all"] as const; | |
| 15 | export type SearchFor = (typeof SEARCH_KINDS)[number]; | |
| 16 | ||
| 17 | /** | |
| 18 | * `GET /api/v1/search` from a shell: every hit one line, grouped by kind. | |
| 19 | * The API has no `pulls` kind — its `issues` answers both — so `--kind | |
| 20 | * issues` and `--kind pulls` ask for `issues` at the API's ceiling and | |
| 21 | * keep N of the one kind (`narrowConversations`): asking for N and then | |
| 22 | * filtering would print fewer than N, or none. | |
| 23 | */ | |
| 24 | async function search(out: Out, args: Args): Promise<number> { | |
| 25 | const words = args.positional.join(" ").trim(); | |
| 26 | if (words === "") { | |
| 27 | out.err("forge search: what for? e.g. forge search retry budget"); | |
| 28 | printUsage(out); | |
| 29 | return EXIT.usage; | |
| 30 | } | |
| 31 | const kindRaw = args.value("kind"); | |
| 32 | const kind = SEARCH_KINDS.find((k) => k === kindRaw); | |
| 33 | if (kindRaw !== undefined && kind === undefined) { | |
| 34 | out.err(`forge search: --kind is one of ${SEARCH_KINDS.join(", ")}`); | |
| 35 | return EXIT.usage; | |
| 36 | } | |
| 37 | const limitRaw = args.value("limit"); | |
| 38 | const limit = limitRaw === undefined ? undefined : Number(limitRaw); | |
| 39 | if (limit !== undefined && (!/^\d+$/.test(limitRaw!) || limit < 1)) { | |
| 40 | out.err("forge search: --limit expects a whole number above 0"); | |
| 41 | return EXIT.usage; | |
| 42 | } | |
| 43 | const loaded = await requireConfig(out); | |
| 44 | if (typeof loaded === "number") return loaded; | |
| 45 | const oneConversation = kind === "issues" || kind === "pulls"; | |
| 46 | const answer = await new ForgeApi(loaded.url, loaded.token).search(words, { | |
| 47 | ...(kind === undefined ? {} : { kind: oneConversation ? "issues" : kind }), | |
| 48 | ...(oneConversation | |
| 49 | ? { limit: SEARCH_API_LIMIT_MAX } | |
| 50 | : limit === undefined ? {} : { limit }), | |
| 51 | }); | |
| 52 | if (!answer.ok) { | |
| 53 | const { message, retryAfter, status } = answer.error; | |
| 54 | const when = | |
| 55 | retryAfter === undefined || new RegExp(`\\b${retryAfter}s\\b`).test(message) | |
| 56 | ? "" | |
| 57 | : ` (retry in ${retryAfter}s)`; | |
| 58 | out.err(`forge search: ${message}${when}`); | |
| 59 | if (status === 429) return EXIT.rateLimited; | |
| 60 | return status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 61 | } | |
| 62 | const found = oneConversation | |
| 63 | ? narrowConversations(answer.value, kind, limit ?? SEARCH_API_LIMIT) | |
| 64 | : answer.value; | |
| 65 | if (args.flag("json")) { | |
| 66 | out.line(JSON.stringify(found)); | |
| 67 | return EXIT.ok; | |
| 68 | } | |
| 69 | const lines = searchLines(found, kind ?? "all", out.width, out); | |
| 70 | if (lines.length === 0) { | |
| 71 | out.line(`Nothing found for "${inert(words)}".`); | |
| 72 | return EXIT.ok; | |
| 73 | } | |
| 74 | for (const line of lines) out.line(line); | |
| 75 | return EXIT.ok; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * The answer with `issues` kept to one kind (`issue` or `pull`) and cut to | |
| 80 | * `limit`; `truncated.issues` is set when the cut dropped any, or when the | |
| 81 | * forge had already cut its own window, so `--json` and the group header | |
| 82 | * both say the list is not whole. Everything else passes through. | |
| 83 | */ | |
| 84 | export function narrowConversations( | |
| 85 | raw: unknown, | |
| 86 | kind: "issues" | "pulls", | |
| 87 | limit: number, | |
| 88 | ): unknown { | |
| 89 | if (typeof raw !== "object" || raw === null) return raw; | |
| 90 | const body = raw as Record<string, unknown>; | |
| 91 | const want = kind === "pulls" ? "pull" : "issue"; | |
| 92 | const kept = rowsOf(body["issues"]).filter((r) => r["kind"] === want); | |
| 93 | const truncated = (typeof body["truncated"] === "object" && body["truncated"] !== null | |
| 94 | ? body["truncated"] | |
| 95 | : {}) as Record<string, unknown>; | |
| 96 | return { | |
| 97 | ...body, | |
| 98 | issues: kept.slice(0, limit), | |
| 99 | truncated: { ...truncated, issues: truncated["issues"] === true || kept.length > limit }, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * The search answer as lines no wider than `width - 1` where the text can | |
| 105 | * give way: each kind headed by its name and count, then one hit a line — | |
| 106 | * `owner/name:path:line text` for code, `owner/name#N title state` for | |
| 107 | * issues and pulls, `owner/name description` for repositories. Anything | |
| 108 | * the answer carries that is not the expected shape is skipped, not fatal. | |
| 109 | */ | |
| 110 | export function searchLines( | |
| 111 | raw: unknown, | |
| 112 | kind: SearchFor, | |
| 113 | width: number, | |
| 114 | out?: Pick<Out, "paint" | "c">, | |
| 115 | ): string[] { | |
| 116 | const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>; | |
| 117 | const truncated = (body["truncated"] ?? {}) as Record<string, unknown>; | |
| 118 | const conversations = rowsOf(body["issues"]).map(conversationRow); | |
| 119 | const groups: { kind: SearchFor; rows: [string, string, string][]; more: boolean }[] = [ | |
| 120 | { kind: "code", rows: rowsOf(body["code"]).map(codeRow), more: truncated["code"] === true }, | |
| 121 | { | |
| 122 | kind: "issues", | |
| 123 | rows: conversations.filter((r) => r[0] === "issue").map((r) => r.slice(1) as [string, string, string]), | |
| 124 | more: truncated["issues"] === true, | |
| 125 | }, | |
| 126 | { | |
| 127 | kind: "pulls", | |
| 128 | rows: conversations.filter((r) => r[0] === "pull").map((r) => r.slice(1) as [string, string, string]), | |
| 129 | more: truncated["issues"] === true, | |
| 130 | }, | |
| 131 | { kind: "repos", rows: rowsOf(body["repos"]).map(repoRow), more: truncated["repos"] === true }, | |
| 132 | ]; | |
| 133 | const lines: string[] = []; | |
| 134 | for (const group of groups) { | |
| 135 | const rows = group.rows.filter((r) => r[0] !== ""); | |
| 136 | if (rows.length === 0 || (kind !== "all" && kind !== group.kind)) continue; | |
| 137 | if (lines.length > 0) lines.push(""); | |
| 138 | const head = `${group.kind} (${rows.length}${group.more ? ", more not shown" : ""})`; | |
| 139 | lines.push(out === undefined ? head : out.paint(head, out.c.accent)); | |
| 140 | // Code's place differs in width line to line; the others line up. | |
| 141 | const leadW = group.kind === "code" ? 0 : Math.max(...rows.map((r) => displayWidth(r[0]))); | |
| 142 | const tails = rows.some((r) => r[2] !== ""); | |
| 143 | const tailW = tails ? Math.max(...rows.map((r) => displayWidth(r[2]))) + 2 : 0; | |
| 144 | const textW = Math.max(...rows.map((r) => displayWidth(r[1]))); | |
| 145 | for (const [lead, text, tail] of rows) { | |
| 146 | const first = leadW === 0 ? lead : pad(lead, leadW); | |
| 147 | const room = Math.max(8, width - 1 - displayWidth(first) - 2 - tailW); | |
| 148 | const cut = truncate(text, room); | |
| 149 | const shown = tails ? pad(cut, Math.min(room, textW)) : cut; | |
| 150 | lines.push( | |
| 151 | [ | |
| 152 | first, | |
| 153 | shown, | |
| 154 | ...(tail === "" ? [] : [out === undefined ? tail : out.paint(tail, out.c.faint)]), | |
| 155 | ].join(" ").trimEnd(), | |
| 156 | ); | |
| 157 | } | |
| 158 | if (group.kind === "code" && (body["moreCode"] === true || body["codeSkipped"] === true)) { | |
| 159 | const note = " some repositories were not searched in time; --json says which kinds are partial"; | |
| 160 | lines.push(out === undefined ? note : out.paint(note, out.c.faint)); | |
| 161 | } | |
| 162 | } | |
| 163 | return lines; | |
| 164 | } | |
| 165 | ||
| 166 | function rowsOf(value: unknown): Record<string, unknown>[] { | |
| 167 | return Array.isArray(value) | |
| 168 | ? value.filter((v): v is Record<string, unknown> => typeof v === "object" && v !== null) | |
| 169 | : []; | |
| 170 | } | |
| 171 | ||
| 172 | /** `owner/name` from a local hit's `repo`, or an origin hit's `fullName`. */ | |
| 173 | function repoName(r: Record<string, unknown>): string { | |
| 174 | const repo = r["repo"] as Record<string, unknown> | undefined; | |
| 175 | if (typeof repo?.["owner"] === "string" && typeof repo["name"] === "string") | |
| 176 | return `${repo["owner"]}/${repo["name"]}`; | |
| 177 | if (typeof r["owner"] === "string" && typeof r["name"] === "string") | |
| 178 | return `${r["owner"]}/${r["name"]}`; | |
| 179 | return typeof r["fullName"] === "string" ? r["fullName"] : ""; | |
| 180 | } | |
| 181 | ||
| 182 | function textOf(value: unknown): string { | |
| 183 | return typeof value === "string" ? inert(value) : ""; | |
| 184 | } | |
| 185 | ||
| 186 | function codeRow(r: Record<string, unknown>): [string, string, string] { | |
| 187 | const name = repoName(r); | |
| 188 | const path = textOf(r["path"]); | |
| 189 | const line = typeof r["line"] === "number" ? r["line"] : 0; | |
| 190 | if (name === "" || path === "") return ["", "", ""]; | |
| 191 | return [`${inert(name)}:${path}:${line}`, textOf(r["text"]).trim(), ""]; | |
| 192 | } | |
| 193 | ||
| 194 | function conversationRow(r: Record<string, unknown>): [string, string, string, string] { | |
| 195 | const name = repoName(r); | |
| 196 | const n = r["number"]; | |
| 197 | if (name === "" || typeof n !== "number") return ["", "", "", ""]; | |
| 198 | return [textOf(r["kind"]), `${inert(name)}#${n}`, textOf(r["title"]), textOf(r["state"])]; | |
| 199 | } | |
| 200 | ||
| 201 | function repoRow(r: Record<string, unknown>): [string, string, string] { | |
| 202 | const name = repoName(r); | |
| 203 | return [inert(name), textOf(r["description"]), r["archived"] === true ? "archived" : ""]; | |
| 204 | } | |
| 205 | ||
| 206 | ||
| 207 | export const command: Command = { | |
| 208 | name: "search", | |
| 209 | usage: { | |
| 210 | synopsis: "forge search <words…>", | |
| 211 | flags: "[--kind code|issues|pulls|repos|all] [--limit N] [--json]", | |
| 212 | }, | |
| 213 | run: search, | |
| 214 | }; |
src/cli/commands/ship.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | import { ForgeApi, type ApiError } from "../api.ts"; | |
| 2 | import type { Args } from "../args.ts"; | |
| 3 | import { requireConfig } from "../context.ts"; | |
| 4 | import { EXIT } from "../exit.ts"; | |
| 5 | import { resolveRepo } from "../here.ts"; | |
| 6 | import type { Io } from "../io.ts"; | |
| 7 | import { inert, type Out } from "../out.ts"; | |
| 8 | import { G } from "../../tui/term/screen.ts"; | |
| 9 | import type { Command } from "./types.ts"; | |
| 10 | ||
| 11 | /** | |
| 12 | * `forge ship 12`: read the pull, show it, ask, merge. The merge route's | |
| 13 | * rules stay the server's — this adds none and retries nothing; a refusal | |
| 14 | * prints the forge's own words. | |
| 15 | */ | |
| 16 | async function ship(out: Out, args: Args, io: Io): Promise<number> { | |
| 17 | const raw = args.positional[0]; | |
| 18 | const number = raw === undefined ? NaN : Number(raw.replace(/^#/, "")); | |
| 19 | if (!Number.isSafeInteger(number) || number < 1) { | |
| 20 | out.err("forge ship: which pull? e.g. forge ship 12"); | |
| 21 | return EXIT.usage; | |
| 22 | } | |
| 23 | const loaded = await requireConfig(out); | |
| 24 | if (typeof loaded === "number") return loaded; | |
| 25 | const ref = await resolveRepo(args.value("repo"), loaded.url); | |
| 26 | if (typeof ref === "string") { | |
| 27 | out.err(`forge ship: ${ref}`); | |
| 28 | return EXIT.usage; | |
| 29 | } | |
| 30 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 31 | const pull = await api.pull(ref.owner, ref.name, number); | |
| 32 | if (!pull.ok) return shipError(out, pull.error); | |
| 33 | const check = await api.pullCheck(ref.owner, ref.name, number); | |
| 34 | const p = pull.value; | |
| 35 | out.line(`${ref.owner}/${ref.name}#${p.number} ${inert(p.title)}`); | |
| 36 | const facts = [ | |
| 37 | `check ${check.ok ? inert(check.value) : "unknown"}`, | |
| 38 | p.merged | |
| 39 | ? "already merged" | |
| 40 | : p.state !== "open" | |
| 41 | ? p.state | |
| 42 | : p.mergeable === false | |
| 43 | ? "not mergeable" | |
| 44 | : p.mergeable === true | |
| 45 | ? "mergeable" | |
| 46 | : "mergeable unknown", | |
| 47 | ...(p.held ? ["held"] : []), | |
| 48 | ]; | |
| 49 | out.line(out.paint(` ${facts.join(" · ")}`, out.c.faint)); | |
| 50 | ||
| 51 | if (!args.flag("yes")) { | |
| 52 | const answer = await io.ask(`Ship #${p.number}? [y/N] `); | |
| 53 | if (answer === null) { | |
| 54 | out.err("forge ship: not a terminal — pass --yes to ship without asking"); | |
| 55 | return EXIT.usage; | |
| 56 | } | |
| 57 | if (answer.trim().toLowerCase() !== "y") { | |
| 58 | out.line("not shipped"); | |
| 59 | return EXIT.ok; | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | const merged = await api.merge(ref.owner, ref.name, number); | |
| 64 | if (!merged.ok) return shipError(out, merged.error); | |
| 65 | out.line( | |
| 66 | `${out.paint(G.check, out.c.success)} shipped #${p.number} as ${merged.value.mergeOid.slice(0, 7)}`, | |
| 67 | ); | |
| 68 | out.line(api.pullUrl(ref.owner, ref.name, number)); | |
| 69 | return EXIT.ok; | |
Non-blocking: blank line between ship and shipError, as in the other modules. |
||
| 70 | } | |
| 71 | ||
| 72 | function shipError(out: Out, error: ApiError): number { | |
| 73 | out.err(`forge ship: ${inert(error.message)}`); | |
| 74 | if (error.status === 401) return EXIT.notConfigured; | |
| 75 | if (error.status === 403 || error.status === 409 || error.status === 422) | |
| 76 | return EXIT.refused; | |
| 77 | return EXIT.failed; | |
| 78 | } | |
| 79 | ||
| 80 | export const command: Command = { | |
| 81 | name: "ship", | |
| 82 | usage: { synopsis: "forge ship <number>", flags: "[--repo owner/name] [--yes]" }, | |
| 83 | run: (out, args, _help, io) => ship(out, args, io), | |
| 84 | }; |
src/cli/commands/status.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import { runSync } from "./sync.ts"; | |
| 2 | import type { Command } from "./types.ts"; | |
| 3 | ||
| 4 | export const command: Command = { | |
| 5 | name: "status", | |
| 6 | usage: { synopsis: "forge status", flags: "[--root DIR] [--json]" }, | |
| 7 | run: (out, args) => runSync(out, args, false), | |
| 8 | }; |
src/cli/commands/sync.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { parseDuration, type Args } from "../args.ts"; | |
| 3 | import { rootDir } from "../config.ts"; | |
| 4 | import { asJson, requireConfig } from "../context.ts"; | |
| 5 | import { EXIT } from "../exit.ts"; | |
| 6 | import { systemNotifier } from "../notify.ts"; | |
| 7 | import type { Out } from "../out.ts"; | |
| 8 | import { printReport } from "../report.ts"; | |
| 9 | import { changesBetween, syncOnce, type SyncReport } from "../sync.ts"; | |
| 10 | import type { Command } from "./types.ts"; | |
| 11 | ||
| 12 | export async function runSync( | |
outdated
Signature is over 80 columns; format it like its neighbours. |
||
| 13 | out: Out, | |
| 14 | args: Args, | |
| 15 | network: boolean, | |
| 16 | ): Promise<number> { | |
| 17 | const loaded = await requireConfig(out); | |
| 18 | if (typeof loaded === "number") return loaded; | |
| 19 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 20 | const root = rootDir(args.value("root")); | |
| 21 | const ff = args.flag("ff"); | |
| 22 | ||
| 23 | const json = args.flag("json"); | |
| 24 | ||
| 25 | const once = async ( | |
| 26 | previous: SyncReport | null, | |
| 27 | quiet: boolean, | |
| 28 | ): Promise<SyncReport> => { | |
| 29 | const report = await syncOnce({ | |
| 30 | api, | |
| 31 | token: loaded.token, | |
| 32 | root, | |
| 33 | fetch: network, | |
| 34 | ff, | |
| 35 | ...(quiet || json || !out.color | |
| 36 | ? {} | |
| 37 | : { onProgress: (repo, text) => status(out, `${repo}: ${text}`) }), | |
| 38 | }); | |
| 39 | if (!quiet && !json && out.color) status(out, ""); | |
| 40 | if (previous === null || !quiet) { | |
| 41 | if (json) out.line(JSON.stringify(asJson(report))); | |
| 42 | else printReport(out, report); | |
| 43 | } | |
| 44 | return report; | |
| 45 | }; | |
| 46 | ||
| 47 | if (!args.flag("watch")) { | |
| 48 | const report = await once(null, false); | |
| 49 | return report.failures > 0 ? EXIT.partial : EXIT.ok; | |
| 50 | } | |
| 51 | ||
| 52 | const interval = parseDuration(args.value("interval")) ?? 300_000; | |
| 53 | const notify = systemNotifier(); | |
| 54 | let previous = await once(null, false); | |
| 55 | if (!json) { | |
| 56 | out.line(); | |
| 57 | out.line( | |
| 58 | out.paint( | |
| 59 | `watching ${previous.repos.length} repos every ${args.value("interval") ?? "5m"} — ctrl+c to stop`, | |
| 60 | out.c.faint, | |
| 61 | ), | |
| 62 | ); | |
| 63 | } | |
| 64 | for (;;) { | |
| 65 | await sleep(interval); | |
| 66 | const report = await once(previous, true); | |
| 67 | const changes = changesBetween(previous, report); | |
| 68 | previous = report; | |
| 69 | // Only a real transition reaches the user; everything else is the | |
| 70 | // report, which is there when they choose to look at it. A watch that | |
| 71 | // notified on every pass would be muted within the hour. | |
| 72 | if (changes.length === 0) continue; | |
| 73 | if (json) out.line(JSON.stringify({ ...asJson(report), changes })); | |
| 74 | else { | |
| 75 | out.line(); | |
| 76 | out.line(new Date().toLocaleTimeString()); | |
| 77 | printReport(out, report); | |
| 78 | } | |
| 79 | await notify("forge", changes.slice(0, 4).join("\n")); | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /** One transient line, overwritten in place. Never printed to a pipe. */ | |
| 84 | function status(out: Out, text: string): void { | |
| 85 | if (!out.color) return; | |
| 86 | const room = Math.max(0, out.width - 1); | |
| 87 | process.stdout.write(`\r\x1b[K${text.slice(0, room)}`); | |
| 88 | if (text === "") process.stdout.write("\r"); | |
| 89 | } | |
| 90 | ||
| 91 | function sleep(ms: number): Promise<void> { | |
| 92 | return new Promise((resolve) => setTimeout(resolve, ms)); | |
| 93 | } | |
| 94 | ||
| 95 | export const command: Command = { | |
| 96 | name: "sync", | |
| 97 | usage: { | |
| 98 | synopsis: "forge sync", | |
| 99 | flags: "[--watch] [--interval 5m] [--ff] [--root DIR]", | |
| 100 | }, | |
| 101 | run: (out, args) => runSync(out, args, true), | |
| 102 | }; |
src/cli/commands/types.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import type { Args } from "../args.ts"; | |
| 2 | import type { Io } from "../io.ts"; | |
| 3 | import type { Out } from "../out.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * One `forge` subcommand. `usage` is its line in `forge help`: the synopsis | |
| 7 | * is painted, `flags` is the plain text after it. | |
| 8 | */ | |
| 9 | export interface Command { | |
| 10 | name: string; | |
| 11 | usage: { synopsis: string; flags?: string }; | |
| 12 | /** `help` prints the usage; it is handed in so commands never import it. */ | |
| 13 | run( | |
| 14 | out: Out, | |
| 15 | args: Args, | |
| 16 | help: (out: Out) => void, | |
| 17 | io: Io, | |
| 18 | /** What followed the command name, as typed. */ | |
| 19 | argv: readonly string[], | |
| 20 | ): Promise<number>; | |
| 21 | } |
src/cli/commands/ui.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | import { loadConfig, PLAIN_HTTP_REFUSAL, rootDir } from "../config.ts"; | |
| 2 | import { EXIT } from "../exit.ts"; | |
| 3 | import type { Command } from "./types.ts"; | |
outdated
Cycle: help.ts -> commands/index.ts -> ui.ts -> help.ts. Inject the usage printer instead of importing it. |
||
| 4 | ||
| 5 | export const command: Command = { | |
| 6 | name: "ui", | |
| 7 | usage: { synopsis: "forge", flags: "the local terminal UI" }, | |
| 8 | async run(out, args, help) { | |
| 9 | if (process.stdout.isTTY !== true) { | |
| 10 | help(out); | |
| 11 | return EXIT.usage; | |
| 12 | } | |
| 13 | const loaded = await loadConfig(); | |
| 14 | if (loaded === "plain_http") { | |
| 15 | out.err(`forge: ${PLAIN_HTTP_REFUSAL}`); | |
| 16 | return EXIT.usage; | |
| 17 | } | |
| 18 | const config = typeof loaded === "string" ? null : loaded.config; | |
| 19 | const { runLocalTui } = await import("../../tui/local/app.ts"); | |
| 20 | await runLocalTui({ | |
| 21 | root: rootDir(args.value("root")), | |
| 22 | ...(config === null ? {} : { config }), | |
| 23 | }); | |
| 24 | return EXIT.ok; | |
| 25 | }, | |
| 26 | }; |
src/cli/commands/unpin.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import { setPin } from "./pin.ts"; | |
| 2 | import type { Command } from "./types.ts"; | |
| 3 | ||
| 4 | export const command: Command = { | |
| 5 | name: "unpin", | |
| 6 | usage: { synopsis: "forge unpin <owner/name>" }, | |
| 7 | run: (out, args) => setPin(out, args, false), | |
| 8 | }; |
src/cli/commands/whoami.ts added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import { ForgeApi } from "../api.ts"; | |
| 2 | import { requireConfig } from "../context.ts"; | |
| 3 | import { EXIT } from "../exit.ts"; | |
| 4 | import type { Command } from "./types.ts"; | |
| 5 | ||
| 6 | export const command: Command = { | |
| 7 | name: "whoami", | |
| 8 | usage: { synopsis: "forge whoami" }, | |
| 9 | async run(out) { | |
| 10 | const loaded = await requireConfig(out); | |
| 11 | if (typeof loaded === "number") return loaded; | |
| 12 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 13 | const me = await api.whoami(); | |
| 14 | if (!me.ok) { | |
| 15 | out.err(`forge whoami: ${me.error.message}`); | |
| 16 | return EXIT.failed; | |
| 17 | } | |
| 18 | out.line(`${me.value.username} at ${loaded.url}`); | |
| 19 | return EXIT.ok; | |
| 20 | }, | |
| 21 | }; |
src/cli/context.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** What more than one command needs: the signed-in config and the report as data. */ | |
| 2 | import { | |
| 3 | configPath, | |
| 4 | loadConfig, | |
| 5 | PLAIN_HTTP_REFUSAL, | |
| 6 | type Config, | |
| 7 | } from "./config.ts"; | |
| 8 | import { EXIT } from "./exit.ts"; | |
| 9 | import type { Out } from "./out.ts"; | |
| 10 | import { dirtyCount } from "./report.ts"; | |
| 11 | import type { RepoReport, SyncReport } from "./sync.ts"; | |
| 12 | ||
| 13 | export async function requireConfig(out: Out): Promise<Config | number> { | |
| 14 | const loaded = await loadConfig(); | |
| 15 | if (loaded === "no_config") { | |
| 16 | out.err("forge: not signed in — run `forge login <url>`"); | |
| 17 | return EXIT.notConfigured; | |
| 18 | } | |
| 19 | if (loaded === "no_token") { | |
| 20 | out.err(`forge: ${configPath()} has no usable token — run \`forge login\``); | |
| 21 | return EXIT.notConfigured; | |
| 22 | } | |
| 23 | if (loaded === "plain_http") { | |
| 24 | out.err(`forge: ${PLAIN_HTTP_REFUSAL}`); | |
| 25 | return EXIT.usage; | |
| 26 | } | |
| 27 | return loaded.config; | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * The report as data. `--json` exists so a script can branch on the same | |
| 32 | * facts the table shows without parsing columns — so every field the table | |
| 33 | * draws from is here, spelled the way the rest of the client spells it. | |
| 34 | */ | |
| 35 | export function asJson(report: SyncReport): Record<string, unknown> { | |
| 36 | return { | |
| 37 | root: report.root, | |
| 38 | pinsUnavailable: report.pinsUnavailable, | |
| 39 | failures: report.failures, | |
| 40 | repos: report.repos.map((r: RepoReport) => ({ | |
| 41 | owner: r.owner, | |
| 42 | name: r.name, | |
| 43 | path: r.dir, | |
| 44 | present: r.present, | |
| 45 | cloned: r.cloned, | |
| 46 | pinned: r.pinned, | |
| 47 | branch: r.branch, | |
| 48 | upstream: r.upstream, | |
| 49 | ahead: r.ahead, | |
| 50 | behind: r.behind, | |
| 51 | staged: r.staged, | |
| 52 | changed: r.changed, | |
| 53 | untracked: r.untracked, | |
| 54 | conflicted: r.conflicted, | |
| 55 | dirty: dirtyCount(r), | |
| 56 | stashes: r.stashes, | |
| 57 | unpushed: r.unpushed, | |
| 58 | fastForwarded: r.fastForwarded, | |
| 59 | lastFetch: r.lastFetch === null ? null : r.lastFetch.toISOString(), | |
| 60 | error: r.error, | |
| 61 | })), | |
| 62 | }; | |
| 63 | } |
src/cli/exit.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** The exit codes a script can branch on without parsing text. */ | |
| 2 | export const EXIT = { | |
| 3 | ok: 0, | |
| 4 | failed: 1, | |
| 5 | usage: 2, | |
| 6 | notConfigured: 3, | |
| 7 | /** The run completed, but at least one repo did not. */ | |
| 8 | partial: 4, | |
| 9 | /** The forge heard the request and said no: a rule, a conflict, a hold. */ | |
| 10 | refused: 5, | |
| 11 | /** The forge said "not now"; it named when to try again. */ | |
| 12 | rateLimited: 6, | |
| 13 | } as const; |
src/cli/help.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | import { configPath, rootDir } from "./config.ts"; | |
| 2 | import { commands } from "./commands/index.ts"; | |
| 3 | import type { Out } from "./out.ts"; | |
| 4 | ||
| 5 | /** | |
| 6 | * The CLI's own number, kept by hand: it ships compiled, to machines no | |
| 7 | * release stamps. The server's comes from its build (`kernel/version.ts`). | |
| 8 | */ | |
| 9 | export const VERSION = "0.0.1"; | |
| 10 | ||
| 11 | /** Where the flags column starts, so every command's notes line up. */ | |
| 12 | const FLAGS_COLUMN = 31; | |
| 13 | ||
| 14 | /** `forge help`: one line per command, in the table's order. */ | |
| 15 | export function printUsage(out: Out): void { | |
| 16 | const b = (text: string): string => out.paint(text, out.c.fg); | |
| 17 | out.line(`forge ${VERSION} — your workbench, on this machine`); | |
| 18 | out.line(); | |
| 19 | for (const { usage } of commands) { | |
| 20 | const head = ` ${usage.synopsis}`; | |
| 21 | out.line( | |
| 22 | usage.flags === undefined | |
| 23 | ? b(head) | |
| 24 | : b(head) + | |
| 25 | " ".repeat(Math.max(1, FLAGS_COLUMN - head.length)) + | |
| 26 | usage.flags, | |
| 27 | ); | |
| 28 | } | |
| 29 | out.line(); | |
| 30 | out.line( | |
| 31 | out.paint(` config ${configPath()} root ${rootDir()}`, out.c.faint), | |
| 32 | ); | |
| 33 | out.line( | |
| 34 | out.paint( | |
| 35 | " FORGE_URL, FORGE_TOKEN, FORGE_ROOT and NO_COLOR are honoured.", | |
| 36 | out.c.faint, | |
| 37 | ), | |
| 38 | ); | |
| 39 | } |
src/cli/io.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | /** What the CLI needs from the person at the keyboard; a test fakes it. */ | |
| 2 | export interface Io { | |
| 3 | /** One answered line, or null when there is no terminal to ask. */ | |
| 4 | ask(prompt: string): Promise<string | null>; | |
| 5 | } | |
| 6 | ||
| 7 | /** One line from the terminal, echoed; null when stdin is not a terminal. */ | |
| 8 | export async function askLine(prompt: string): Promise<string | null> { | |
| 9 | const stdin = process.stdin; | |
| 10 | if (stdin.isTTY !== true || process.stdout.isTTY !== true) return null; | |
| 11 | process.stdout.write(prompt); | |
| 12 | stdin.resume(); | |
| 13 | return new Promise<string>((resolve) => { | |
| 14 | // ctrl+d or a closed terminal is an answer too, and the answer is N. | |
| 15 | const finish = (answer: string): void => { | |
| 16 | stdin.off("data", onData); | |
| 17 | stdin.off("end", onEnd); | |
| 18 | stdin.off("close", onEnd); | |
| 19 | stdin.pause(); | |
| 20 | resolve(answer); | |
| 21 | }; | |
| 22 | const onData = (chunk: Buffer): void => | |
| 23 | finish(chunk.toString("utf8").split("\n")[0] ?? ""); | |
| 24 | const onEnd = (): void => { | |
| 25 | process.stdout.write("\n"); | |
| 26 | finish(""); | |
| 27 | }; | |
| 28 | stdin.on("data", onData); | |
| 29 | stdin.once("end", onEnd); | |
| 30 | stdin.once("close", onEnd); | |
| 31 | }); | |
| 32 | } |
src/cli/main.ts +20 −1066
| @@ -18,62 +18,23 @@ | ||
| 18 | 18 | * an exit code — so a script can branch on the result without parsing text. |
| 19 | 19 | */ |
| 20 | 20 | |
| 21 | import { parseArgs, parseDuration, parseRepoRef, type Args } from "./args.ts"; | |
| 22 | import { ForgeApi, type ApiError, type Ask, type Bulk } from "./api.ts"; | |
| 23 | import { parseWish, pickAsks, type Wish } from "./decide.ts"; | |
| 24 | import { | |
| 25 | clearConfig, | |
| 26 | configPath, | |
| 27 | loadConfig, | |
| 28 | normalizeUrl, | |
| 29 | PLAIN_HTTP_REFUSAL, | |
| 30 | plainHttpRemote, | |
| 31 | rootDir, | |
| 32 | saveConfig, | |
| 33 | type Config, | |
| 34 | } from "./config.ts"; | |
| 35 | import { folderRepo } from "./folder-repo.ts"; | |
| 36 | import { systemNotifier } from "./notify.ts"; | |
| 37 | import { resolveRepo } from "./here.ts"; | |
| 38 | import { cell, createOut, inert, type Out } from "./out.ts"; | |
| 39 | import { dirtyCount, printReport, settled, since } from "./report.ts"; | |
| 40 | import { | |
| 41 | changesBetween, | |
| 42 | syncOnce, | |
| 43 | type RepoReport, | |
| 44 | type SyncReport, | |
| 45 | } from "./sync.ts"; | |
| 46 | import { G } from "../tui/term/screen.ts"; | |
| 47 | import { trackText } from "../tui/local/common.ts"; | |
| 48 | import { displayWidth, pad, truncate } from "../tui/term/width.ts"; | |
| 21 | import { parseArgs } from "./args.ts"; | |
| 22 | import { commands } from "./commands/index.ts"; | |
| 23 | import { EXIT } from "./exit.ts"; | |
| 24 | import { printUsage, VERSION } from "./help.ts"; | |
| 25 | import { askLine, type Io } from "./io.ts"; | |
| 26 | import { createOut } from "./out.ts"; | |
| 49 | 27 | |
| 50 | /** | |
| 51 | * The CLI's own number, kept by hand: it ships compiled, to machines no | |
| 52 | * release stamps. The server's comes from its build (`kernel/version.ts`). | |
| 53 | */ | |
| 54 | const VERSION = "0.0.1"; | |
| 28 | // Callers (and tests) have always reached these through this file. | |
| 29 | export { asks, askLines, type Confirm } from "./commands/asks.ts"; | |
| 30 | export { searchLines, narrowConversations, type SearchFor } from "./commands/search.ts"; | |
| 31 | export { EXIT }; | |
| 55 | 32 | |
| 56 | export const EXIT = { | |
| 57 | ok: 0, | |
| 58 | failed: 1, | |
| 59 | usage: 2, | |
| 60 | notConfigured: 3, | |
| 61 | /** The run completed, but at least one repo did not. */ | |
| 62 | partial: 4, | |
| 63 | /** The forge heard the request and said no: a rule, a conflict, a hold. */ | |
| 64 | refused: 5, | |
| 65 | /** The forge said "not now"; it named when to try again. */ | |
| 66 | rateLimited: 6, | |
| 67 | } as const; | |
| 33 | /** Every command by name, built once from `commands/index.ts`. */ | |
| 34 | const TABLE = new Map(commands.map((c) => [c.name, c])); | |
| 68 | 35 | |
| 69 | 36 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason"]; |
| 70 | 37 | |
| 71 | /** What the CLI needs from the person at the keyboard; a test fakes it. */ | |
| 72 | export interface Io { | |
| 73 | /** One answered line, or null when there is no terminal to ask. */ | |
| 74 | ask(prompt: string): Promise<string | null>; | |
| 75 | } | |
| 76 | ||
| 77 | 38 | export async function main( |
| 78 | 39 | argv: readonly string[], |
| 79 | 40 | io: Io = { ask: askLine }, |
| @@ -85,1026 +46,19 @@ export async function main( | ||
| 85 | 46 | out.line(`forge ${VERSION}`); |
| 86 | 47 | return EXIT.ok; |
| 87 | 48 | } |
| 88 | if (args.command === undefined) { | |
| 89 | if (args.flag("help") || args.flag("h")) { | |
| 90 | usage(out); | |
| 91 | return EXIT.ok; | |
| 92 | } | |
| 93 | return openTui(out, args); | |
| 94 | } | |
| 95 | if (args.flag("help") || args.flag("h")) { | |
| 96 | usage(out); | |
| 49 | if (args.flag("help") || args.flag("h") || args.command === "help") { | |
| 50 | printUsage(out); | |
| 97 | 51 | return EXIT.ok; |
| 98 | 52 | } |
| 99 | 53 | |
| 100 | switch (args.command) { | |
| 101 | case "help": | |
| 102 | usage(out); | |
| 103 | return EXIT.ok; | |
| 104 | case "login": | |
| 105 | return login(out, args); | |
| 106 | case "logout": | |
| 107 | return logout(out); | |
| 108 | case "whoami": | |
| 109 | return whoami(out); | |
| 110 | case "sync": | |
| 111 | return sync(out, args, true); | |
| 112 | case "status": | |
| 113 | return sync(out, args, false); | |
| 114 | case "ls": | |
| 115 | return list(out, args); | |
| 116 | case "pin": | |
| 117 | return setPin(out, args, true); | |
| 118 | case "unpin": | |
| 119 | return setPin(out, args, false); | |
| 120 | case "pulls": | |
| 121 | return pulls(out, args); | |
| 122 | case "open": | |
| 123 | return openRepo(out, args); | |
| 124 | case "asks": | |
| 125 | return asks(out, args, argv.slice(1)); | |
| 126 | case "search": | |
| 127 | return search(out, args); | |
| 128 | case "ship": | |
| 129 | return ship(out, args, io); | |
| 130 | case "ui": | |
| 131 | return openTui(out, args); | |
| 132 | default: | |
| 133 | out.err(`forge: unknown command "${args.command}"`); | |
| 134 | usage(out); | |
| 135 | return EXIT.usage; | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | function usage(out: Out): void { | |
| 140 | const b = (text: string): string => out.paint(text, out.c.fg); | |
| 141 | out.line(`forge ${VERSION} — your workbench, on this machine`); | |
| 142 | out.line(); | |
| 143 | out.line(b(" forge") + " the local terminal UI"); | |
| 144 | out.line(b(" forge login <url>") + " [--token T] [--insecure]"); | |
| 145 | out.line(b(" forge logout")); | |
| 146 | out.line(b(" forge whoami")); | |
| 147 | out.line( | |
| 148 | b(" forge sync") + | |
| 149 | " [--watch] [--interval 5m] [--ff] [--root DIR]", | |
| 150 | ); | |
| 151 | out.line(b(" forge status") + " [--root DIR] [--json]"); | |
| 152 | out.line(b(" forge ls") + " [--json]"); | |
| 153 | out.line(b(" forge pin <owner/name>")); | |
| 154 | out.line(b(" forge unpin <owner/name>")); | |
| 155 | out.line(b(" forge open <owner/name>") + " [--web]"); | |
| 156 | out.line( | |
| 157 | b(" forge pulls") + " [--repo owner/name] [--closed] [--json]", | |
| 158 | ); | |
| 159 | out.line( | |
| 160 | b(" forge asks") + " [--repo owner/name] [--json]", | |
| 161 | ); | |
| 162 | out.line( | |
| 163 | b(" forge search <words…>") + | |
| 164 | " [--kind code|issues|pulls|repos|all] [--limit N] [--json]", | |
| 165 | ); | |
| 166 | out.line( | |
| 167 | b(" forge asks --ship|--close|--dismiss <n…>") + | |
| 168 | " [--reason words] [--yes]", | |
| 169 | ); | |
| 170 | out.line(b(" forge ship <number>") + " [--repo owner/name] [--yes]"); | |
| 171 | out.line(); | |
| 172 | out.line( | |
| 173 | out.paint(` config ${configPath()} root ${rootDir()}`, out.c.faint), | |
| 174 | ); | |
| 175 | out.line( | |
| 176 | out.paint( | |
| 177 | " FORGE_URL, FORGE_TOKEN, FORGE_ROOT and NO_COLOR are honoured.", | |
| 178 | out.c.faint, | |
| 179 | ), | |
| 180 | ); | |
| 181 | } | |
| 182 | ||
| 183 | // ── account ────────────────────────────────────────────────────────────── | |
| 184 | ||
| 185 | async function login(out: Out, args: Args): Promise<number> { | |
| 186 | const raw = args.positional[0] ?? args.value("url"); | |
| 187 | if (raw === undefined) { | |
| 188 | out.err("forge login: which forge? e.g. forge login forge.example.com"); | |
| 54 | // No command is the terminal UI. | |
| 55 | const command = TABLE.get(args.command ?? "ui"); | |
| 56 | if (command === undefined) { | |
| 57 | out.err(`forge: unknown command "${args.command}"`); | |
| 58 | printUsage(out); | |
| 189 | 59 | return EXIT.usage; |
| 190 | 60 | } |
| 191 | const url = normalizeUrl(raw); | |
| 192 | // Before the token is asked for, let alone sent: nothing crosses the | |
| 193 | // network in clear unless the person said this host is one they trust. | |
| 194 | const insecure = args.flag("insecure"); | |
| 195 | if (plainHttpRemote(url) && !insecure) { | |
| 196 | out.err(`forge login: ${PLAIN_HTTP_REFUSAL}`); | |
| 197 | return EXIT.usage; | |
| 198 | } | |
| 199 | const token = args.value("token") ?? (await askSecret(`Token for ${url}: `)); | |
| 200 | if (token === "") { | |
| 201 | out.err("forge login: no token given"); | |
| 202 | return EXIT.usage; | |
| 203 | } | |
| 204 | ||
| 205 | // Verify before storing: a config that does not work is worse than none. | |
| 206 | const api = new ForgeApi(url, token); | |
| 207 | const me = await api.whoami(); | |
| 208 | if (!me.ok) { | |
| 209 | out.err(`forge login: ${me.error.message}`); | |
| 210 | return me.error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 211 | } | |
| 212 | const stored = await saveConfig({ url, token }, { insecure }); | |
| 213 | out.line( | |
| 214 | `${out.paint(G.check, out.c.success)} signed in to ${url} as ${me.value.username}`, | |
| 215 | ); | |
| 216 | out.line( | |
| 217 | out.paint( | |
| 218 | stored.keychain | |
| 219 | ? ` token in your login keychain; ${configPath()} holds only the URL` | |
| 220 | : ` token in ${configPath()} (0600)`, | |
| 221 | out.c.faint, | |
| 222 | ), | |
| 223 | ); | |
| 224 | return EXIT.ok; | |
| 225 | } | |
| 226 | ||
| 227 | async function logout(out: Out): Promise<number> { | |
| 228 | await clearConfig(); | |
| 229 | out.line("signed out"); | |
| 230 | return EXIT.ok; | |
| 231 | } | |
| 232 | ||
| 233 | async function whoami(out: Out): Promise<number> { | |
| 234 | const loaded = await requireConfig(out); | |
| 235 | if (typeof loaded === "number") return loaded; | |
| 236 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 237 | const me = await api.whoami(); | |
| 238 | if (!me.ok) { | |
| 239 | out.err(`forge whoami: ${me.error.message}`); | |
| 240 | return EXIT.failed; | |
| 241 | } | |
| 242 | out.line(`${me.value.username} at ${loaded.url}`); | |
| 243 | return EXIT.ok; | |
| 244 | } | |
| 245 | ||
| 246 | // ── sync and status ────────────────────────────────────────────────────── | |
| 247 | ||
| 248 | async function sync(out: Out, args: Args, network: boolean): Promise<number> { | |
| 249 | const loaded = await requireConfig(out); | |
| 250 | if (typeof loaded === "number") return loaded; | |
| 251 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 252 | const root = rootDir(args.value("root")); | |
| 253 | const ff = args.flag("ff"); | |
| 254 | ||
| 255 | const json = args.flag("json"); | |
| 256 | ||
| 257 | const once = async ( | |
| 258 | previous: SyncReport | null, | |
| 259 | quiet: boolean, | |
| 260 | ): Promise<SyncReport> => { | |
| 261 | const report = await syncOnce({ | |
| 262 | api, | |
| 263 | token: loaded.token, | |
| 264 | root, | |
| 265 | fetch: network, | |
| 266 | ff, | |
| 267 | ...(quiet || json || !out.color | |
| 268 | ? {} | |
| 269 | : { onProgress: (repo, text) => status(out, `${repo}: ${text}`) }), | |
| 270 | }); | |
| 271 | if (!quiet && !json && out.color) status(out, ""); | |
| 272 | if (previous === null || !quiet) { | |
| 273 | if (json) out.line(JSON.stringify(asJson(report))); | |
| 274 | else printReport(out, report); | |
| 275 | } | |
| 276 | return report; | |
| 277 | }; | |
| 278 | ||
| 279 | if (!args.flag("watch")) { | |
| 280 | const report = await once(null, false); | |
| 281 | return report.failures > 0 ? EXIT.partial : EXIT.ok; | |
| 282 | } | |
| 283 | ||
| 284 | const interval = parseDuration(args.value("interval")) ?? 300_000; | |
| 285 | const notify = systemNotifier(); | |
| 286 | let previous = await once(null, false); | |
| 287 | if (!json) { | |
| 288 | out.line(); | |
| 289 | out.line( | |
| 290 | out.paint( | |
| 291 | `watching ${previous.repos.length} repos every ${args.value("interval") ?? "5m"} — ctrl+c to stop`, | |
| 292 | out.c.faint, | |
| 293 | ), | |
| 294 | ); | |
| 295 | } | |
| 296 | for (;;) { | |
| 297 | await sleep(interval); | |
| 298 | const report = await once(previous, true); | |
| 299 | const changes = changesBetween(previous, report); | |
| 300 | previous = report; | |
| 301 | // Only a real transition reaches the user; everything else is the | |
| 302 | // report, which is there when they choose to look at it. A watch that | |
| 303 | // notified on every pass would be muted within the hour. | |
| 304 | if (changes.length === 0) continue; | |
| 305 | if (json) out.line(JSON.stringify({ ...asJson(report), changes })); | |
| 306 | else { | |
| 307 | out.line(); | |
| 308 | out.line(new Date().toLocaleTimeString()); | |
| 309 | printReport(out, report); | |
| 310 | } | |
| 311 | await notify("forge", changes.slice(0, 4).join("\n")); | |
| 312 | } | |
| 313 | } | |
| 314 | ||
| 315 | async function list(out: Out, args: Args): Promise<number> { | |
| 316 | const loaded = await requireConfig(out); | |
| 317 | if (typeof loaded === "number") return loaded; | |
| 318 | // `ls` answers from disk, so it keeps working when the forge does not. | |
| 319 | const report = await syncOnce({ | |
| 320 | api: new ForgeApi(loaded.url, loaded.token), | |
| 321 | token: loaded.token, | |
| 322 | root: rootDir(args.value("root")), | |
| 323 | fetch: false, | |
| 324 | }); | |
| 325 | if (args.flag("json")) { | |
| 326 | out.line(JSON.stringify(asJson(report))); | |
| 327 | return EXIT.ok; | |
| 328 | } | |
| 329 | out.line(out.paint(report.root, out.c.faint)); | |
| 330 | out.table( | |
| 331 | [ | |
| 332 | { header: "REPO", max: 44 }, | |
| 333 | { header: "BRANCH", max: 24 }, | |
| 334 | { header: "SYNC", align: "right" }, | |
| 335 | { header: "STATE", max: 28 }, | |
| 336 | { header: "FETCHED", align: "right" }, | |
| 337 | ], | |
| 338 | report.repos.map((r) => [ | |
| 339 | cell(`${r.owner}/${r.name}`, r.present ? out.c.fg : out.c.mutedFg), | |
| 340 | cell(r.present ? (r.branch ?? "detached") : "—", out.c.mutedFg), | |
| 341 | cell(trackText(r.ahead, r.behind), out.c.attn), | |
| 342 | lsState(out, r), | |
| 343 | cell(r.present ? since(r.lastFetch) : "", out.c.faint), | |
| 344 | ]), | |
| 345 | ); | |
| 346 | return EXIT.ok; | |
| 347 | } | |
| 348 | ||
| 349 | function lsState(out: Out, r: RepoReport): ReturnType<typeof cell> { | |
| 350 | if (!r.present) return cell("not cloned", out.c.attn); | |
| 351 | const dirty = dirtyCount(r); | |
| 352 | if (dirty > 0) return cell(`${dirty} dirty`, out.c.accent); | |
| 353 | if (settled(r)) return cell(G.check, out.c.success); | |
| 354 | return cell(r.upstream === null ? "no upstream" : "", out.c.faint); | |
| 355 | } | |
| 356 | ||
| 357 | /** | |
| 358 | * The report as data. `--json` exists so a script can branch on the same | |
| 359 | * facts the table shows without parsing columns — so every field the table | |
| 360 | * draws from is here, spelled the way the rest of the client spells it. | |
| 361 | */ | |
| 362 | function asJson(report: SyncReport): Record<string, unknown> { | |
| 363 | return { | |
| 364 | root: report.root, | |
| 365 | pinsUnavailable: report.pinsUnavailable, | |
| 366 | failures: report.failures, | |
| 367 | repos: report.repos.map((r: RepoReport) => ({ | |
| 368 | owner: r.owner, | |
| 369 | name: r.name, | |
| 370 | path: r.dir, | |
| 371 | present: r.present, | |
| 372 | cloned: r.cloned, | |
| 373 | pinned: r.pinned, | |
| 374 | branch: r.branch, | |
| 375 | upstream: r.upstream, | |
| 376 | ahead: r.ahead, | |
| 377 | behind: r.behind, | |
| 378 | staged: r.staged, | |
| 379 | changed: r.changed, | |
| 380 | untracked: r.untracked, | |
| 381 | conflicted: r.conflicted, | |
| 382 | dirty: dirtyCount(r), | |
| 383 | stashes: r.stashes, | |
| 384 | unpushed: r.unpushed, | |
| 385 | fastForwarded: r.fastForwarded, | |
| 386 | lastFetch: r.lastFetch === null ? null : r.lastFetch.toISOString(), | |
| 387 | error: r.error, | |
| 388 | })), | |
| 389 | }; | |
| 390 | } | |
| 391 | ||
| 392 | // ── pins ───────────────────────────────────────────────────────────────── | |
| 393 | ||
| 394 | async function setPin(out: Out, args: Args, on: boolean): Promise<number> { | |
| 395 | const verb = on ? "pin" : "unpin"; | |
| 396 | const ref = parseRepoRef(args.positional[0]); | |
| 397 | if (ref === undefined) { | |
| 398 | out.err(`forge ${verb}: expected owner/name`); | |
| 399 | return EXIT.usage; | |
| 400 | } | |
| 401 | const loaded = await requireConfig(out); | |
| 402 | if (typeof loaded === "number") return loaded; | |
| 403 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 404 | const result = on | |
| 405 | ? await api.pin(ref.owner, ref.name) | |
| 406 | : await api.unpin(ref.owner, ref.name); | |
| 407 | if (!result.ok) { | |
| 408 | out.err( | |
| 409 | result.error.status === 404 | |
| 410 | ? `forge ${verb}: this forge does not support pins yet` | |
| 411 | : `forge ${verb}: ${result.error.message}`, | |
| 412 | ); | |
| 413 | return EXIT.failed; | |
| 414 | } | |
| 415 | out.line( | |
| 416 | `${out.paint(G.check, out.c.success)} ${on ? "pinned" : "unpinned"} ${ref.owner}/${ref.name}`, | |
| 417 | ); | |
| 418 | if (on) | |
| 419 | out.line(out.paint(" run `forge sync` to bring it down", out.c.faint)); | |
| 420 | return EXIT.ok; | |
| 421 | } | |
| 422 | ||
| 423 | async function openRepo(out: Out, args: Args): Promise<number> { | |
| 424 | const ref = parseRepoRef(args.positional[0]); | |
| 425 | if (ref === undefined) { | |
| 426 | out.err("forge open: expected owner/name"); | |
| 427 | return EXIT.usage; | |
| 428 | } | |
| 429 | const loaded = await requireConfig(out); | |
| 430 | if (typeof loaded === "number") return loaded; | |
| 431 | const url = new ForgeApi(loaded.url, loaded.token).webUrl( | |
| 432 | ref.owner, | |
| 433 | ref.name, | |
| 434 | ); | |
| 435 | out.line(url); | |
| 436 | if (!args.flag("web")) return EXIT.ok; | |
| 437 | const opener = process.platform === "darwin" ? "open" : "xdg-open"; | |
| 438 | try { | |
| 439 | const proc = Bun.spawn([opener, url], { | |
| 440 | stdout: "ignore", | |
| 441 | stderr: "ignore", | |
| 442 | }); | |
| 443 | await proc.exited; | |
| 444 | } catch { | |
| 445 | out.err(`forge open: could not run ${opener}`); | |
| 446 | return EXIT.failed; | |
| 447 | } | |
| 448 | return EXIT.ok; | |
| 449 | } | |
| 450 | ||
| 451 | // ── pulls ──────────────────────────────────────────────────────────────── | |
| 452 | ||
| 453 | async function pulls(out: Out, args: Args): Promise<number> { | |
| 454 | const loaded = await requireConfig(out); | |
| 455 | if (typeof loaded === "number") return loaded; | |
| 456 | const flagged = args.value("repo"); | |
| 457 | const ref = | |
| 458 | flagged !== undefined | |
| 459 | ? parseRepoRef(flagged) | |
| 460 | : await folderRepo(process.cwd(), loaded.url); | |
| 461 | if (ref === undefined) { | |
| 462 | out.err( | |
| 463 | flagged !== undefined | |
| 464 | ? "forge pulls: --repo expects owner/name" | |
| 465 | : `forge pulls: this folder is not a clone of ${loaded.url} — run it in a clone, or pass --repo owner/name`, | |
| 466 | ); | |
| 467 | return EXIT.usage; | |
| 468 | } | |
| 469 | const closed = args.flag("closed"); | |
| 470 | const got = await new ForgeApi(loaded.url, loaded.token).pulls( | |
| 471 | ref.owner, | |
| 472 | ref.name, | |
| 473 | closed ? "closed" : "open", | |
| 474 | ); | |
| 475 | if (!got.ok) { | |
| 476 | out.err(`forge pulls: ${got.error.message}`); | |
| 477 | return EXIT.failed; | |
| 478 | } | |
| 479 | if (args.flag("json")) { | |
| 480 | out.line(JSON.stringify(got.value)); | |
| 481 | return EXIT.ok; | |
| 482 | } | |
| 483 | const items = got.value.items as Record<string, unknown>[]; | |
| 484 | if (items.length === 0) { | |
| 485 | out.line(closed ? "No closed pulls." : "No open pulls."); | |
| 486 | return EXIT.ok; | |
| 487 | } | |
| 488 | const tone = { | |
| 489 | green: out.c.success, | |
| 490 | red: out.c.danger, | |
| 491 | running: out.c.attn, | |
| 492 | none: out.c.faint, | |
| 493 | } as const; | |
| 494 | const rows = items.map((item) => ({ | |
| 495 | number: `#${String(item["number"])}`, | |
| 496 | title: inert(String(item["title"] ?? "")), | |
| 497 | author: inert(String(item["author"] ?? "")), | |
| 498 | check: String(item["check"]) as keyof typeof tone, | |
| 499 | preview: inert(typeof item["preview"] === "string" ? item["preview"] : ""), | |
| 500 | })); | |
| 501 | const wide = (pick: (r: (typeof rows)[number]) => string): number => | |
| 502 | Math.max(...rows.map((r) => displayWidth(pick(r)))); | |
| 503 | const nW = wide((r) => r.number); | |
| 504 | const aW = Math.min(20, wide((r) => r.author)); | |
| 505 | const cW = wide((r) => r.check); | |
| 506 | const pW = wide((r) => r.preview); | |
| 507 | const gaps = 2 * (pW > 0 ? 4 : 3); | |
| 508 | const tW = Math.max(12, out.width - nW - aW - cW - pW - gaps); | |
| 509 | for (const r of rows) { | |
| 510 | const parts = [ | |
| 511 | pad(r.number, nW, "right"), | |
| 512 | pad(truncate(r.title, tW), tW), | |
| 513 | pad(truncate(r.author, aW), aW), | |
| 514 | out.paint(pad(r.check, cW), tone[r.check] ?? out.c.faint), | |
| 515 | ]; | |
| 516 | if (pW > 0) parts.push(r.preview); | |
| 517 | out.line(parts.join(" ").replace(/\s+$/, "")); | |
| 518 | } | |
| 519 | if (got.value.next_cursor !== null) | |
| 520 | out.line( | |
| 521 | out.paint( | |
| 522 | `The list is cut after ${rows.length} pulls; the forge has more.`, | |
| 523 | out.c.faint, | |
| 524 | ), | |
| 525 | ); | |
| 526 | return EXIT.ok; | |
| 527 | } | |
| 528 | ||
| 529 | // ── asks ───────────────────────────────────────────────────────────────── | |
| 530 | ||
| 531 | /** | |
| 532 | * What waits on you, one line each, in the order the forge ranks it: kind, | |
| 533 | * repository, title cut to the terminal, and the address to decide it at. | |
| 534 | */ | |
| 535 | export async function asks( | |
| 536 | out: Out, | |
| 537 | args: Args, | |
| 538 | argv: readonly string[] = [], | |
| 539 | confirm: Confirm = confirmOnTty, | |
| 540 | ): Promise<number> { | |
| 541 | const wish = parseWish(argv); | |
| 542 | if (typeof wish === "string") { | |
| 543 | out.err(`forge asks: ${wish}`); | |
| 544 | return EXIT.usage; | |
| 545 | } | |
| 546 | const only = args.value("repo"); | |
| 547 | const ref = only === undefined ? undefined : parseRepoRef(only); | |
| 548 | if (only !== undefined && ref === undefined) { | |
| 549 | out.err("forge asks: --repo expects owner/name"); | |
| 550 | return EXIT.usage; | |
| 551 | } | |
| 552 | const loaded = await requireConfig(out); | |
| 553 | if (typeof loaded === "number") return loaded; | |
| 554 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 555 | const answer = await api.asks(); | |
| 556 | if (!answer.ok) { | |
| 557 | out.err(`forge asks: ${answer.error.message}`); | |
| 558 | return answer.error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 559 | } | |
| 560 | const repo = ref === undefined ? undefined : `${ref.owner}/${ref.name}`; | |
| 561 | if (wish !== undefined && wish.action === "undo") | |
| 562 | return decide(out, api, wish, [], true, confirm); | |
| 563 | if (wish !== undefined) { | |
| 564 | const rows = answer.value.asks.filter( | |
| 565 | (a) => repo === undefined || a.repo === repo, | |
| 566 | ); | |
| 567 | return decide(out, api, wish, rows, args.flag("yes"), confirm); | |
| 568 | } | |
| 569 | if (args.flag("json")) { | |
| 570 | const raw = answer.value.raw; | |
| 571 | out.line(JSON.stringify(repo === undefined ? raw : onlyRepo(raw, repo))); | |
| 572 | return EXIT.ok; | |
| 573 | } | |
| 574 | const rows = answer.value.asks.filter( | |
| 575 | (a) => repo === undefined || a.repo === repo, | |
| 576 | ); | |
| 577 | if (rows.length === 0) { | |
| 578 | out.line("Nothing waits on you."); | |
| 579 | return EXIT.ok; | |
| 580 | } | |
| 581 | const numberW = String(rows.length).length; | |
| 582 | const lines = askLines(rows, out.width - numberW - 2, out); | |
| 583 | lines.forEach((line, i) => | |
| 584 | out.line(`${String(i + 1).padStart(numberW)} ${line}`), | |
| 585 | ); | |
| 586 | return EXIT.ok; | |
| 587 | } | |
| 588 | ||
| 589 | /** Whether to go ahead: undefined when there is no one to ask. */ | |
| 590 | export type Confirm = (question: string) => Promise<boolean | undefined>; | |
| 591 | ||
| 592 | const confirmOnTty: Confirm = async (question) => { | |
| 593 | if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) | |
| 594 | return undefined; | |
| 595 | process.stdout.write(question); | |
| 596 | const line = await new Promise<string>((resolve) => { | |
| 597 | const stdin = process.stdin; | |
| 598 | stdin.setEncoding("utf8"); | |
| 599 | stdin.once("data", (chunk) => { | |
| 600 | stdin.pause(); | |
| 601 | resolve(String(chunk)); | |
| 602 | }); | |
| 603 | stdin.resume(); | |
| 604 | }); | |
| 605 | return /^\s*y(es)?\s*$/i.test(line); | |
| 606 | }; | |
| 607 | ||
| 608 | /** | |
| 609 | * Decide asks by the numbers of the list just read: the plan first, then, | |
| 610 | * after a yes (or `--yes`), the deed and the line that undoes it. | |
| 611 | */ | |
| 612 | async function decide( | |
| 613 | out: Out, | |
| 614 | api: ForgeApi, | |
| 615 | wish: Wish, | |
| 616 | rows: readonly Ask[], | |
| 617 | yes: boolean, | |
| 618 | confirm: Confirm, | |
| 619 | ): Promise<number> { | |
| 620 | const fail = (e: { message: string; status: number }): number => { | |
| 621 | out.err(`forge asks: ${e.message}`); | |
| 622 | return e.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 623 | }; | |
| 624 | if (wish.action === "undo") { | |
| 625 | const back = await api.undo(wish.numbers[0]!); | |
| 626 | if (!back.ok) return fail(back.error); | |
| 627 | printBulk(out, back.value, "undo", true); | |
| 628 | return EXIT.ok; | |
| 629 | } | |
| 630 | const ids = pickAsks(wish.numbers, rows); | |
| 631 | if (!Array.isArray(ids)) { | |
| 632 | out.err(`forge asks: ${ids.refused}`); | |
| 633 | return EXIT.usage; | |
| 634 | } | |
| 635 | const plan = await api.bulk(wish.action, ids, wish.reason, true); | |
| 636 | if (!plan.ok) return fail(plan.error); | |
| 637 | printBulk(out, plan.value, wish.action, false); | |
| 638 | const changes = plan.value.rows.filter((r) => r.outcome === "change").length; | |
| 639 | if (changes === 0) { | |
| 640 | out.line("Nothing would change."); | |
| 641 | return EXIT.ok; | |
| 642 | } | |
| 643 | if (!yes) { | |
| 644 | const said = await confirm("Apply? [y/N] "); | |
| 645 | if (said === undefined) { | |
| 646 | out.err("forge asks: not applied — no terminal to ask on; add --yes"); | |
| 647 | return EXIT.usage; | |
| 648 | } | |
| 649 | if (!said) { | |
| 650 | out.line("Nothing applied."); | |
| 651 | return EXIT.ok; | |
| 652 | } | |
| 653 | } | |
| 654 | const done = await api.bulk(wish.action, ids, wish.reason, false); | |
| 655 | if (!done.ok) return fail(done.error); | |
| 656 | printBulk(out, done.value, wish.action, true); | |
| 657 | if (done.value.id !== undefined) { | |
| 658 | out.line(`Receipt ${inert(done.value.id)}`); | |
| 659 | out.line(`Undo: forge asks --undo ${inert(done.value.id)}`); | |
| 660 | } | |
| 661 | return EXIT.ok; | |
| 662 | } | |
| 663 | ||
| 664 | function printBulk(out: Out, bulk: Bulk, action: string, done: boolean): void { | |
| 665 | out.line(done ? `Applied: ${action}` : `Would ${action}:`); | |
| 666 | for (const r of bulk.rows) { | |
| 667 | const move = r.before === r.after ? "" : ` ${r.before} → ${r.after}`; | |
| 668 | const why = r.reason === "" ? "" : ` (${r.reason})`; | |
| 669 | out.line(` ${pad(inert(r.outcome), 9)} ${inert(r.label)}${inert(move)}${inert(why)}`); | |
| 670 | } | |
| 671 | for (const s of bulk.skipped) | |
| 672 | out.line(` skipped ${inert(s.item)} (${inert(s.reason)})`); | |
| 673 | } | |
| 674 | ||
| 675 | /** | |
| 676 | * The asks as lines no wider than `width - 1`, so none wraps on its own: | |
| 677 | * kind and repository padded to their widest, the address whole, and the | |
| 678 | * title cut to what those leave. Coloured only when `out` is given. | |
| 679 | */ | |
| 680 | export function askLines( | |
| 681 | rows: readonly Ask[], | |
| 682 | width: number, | |
| 683 | out?: Pick<Out, "paint" | "c">, | |
| 684 | ): string[] { | |
| 685 | const kindW = Math.max(...rows.map((a) => displayWidth(inert(a.kind)))); | |
| 686 | const repoW = Math.max(...rows.map((a) => displayWidth(inert(a.repo)))); | |
| 687 | return rows.map((a) => { | |
| 688 | const url = inert(a.url); | |
| 689 | // Three two-space gaps; the title gets what the other columns leave. | |
| 690 | const room = Math.max( | |
| 691 | 1, | |
| 692 | width - 1 - kindW - repoW - displayWidth(url) - 6, | |
| 693 | ); | |
| 694 | const kind = pad(inert(a.kind), kindW); | |
| 695 | return [ | |
| 696 | out === undefined ? kind : out.paint(kind, out.c.accent), | |
| 697 | pad(inert(a.repo), repoW), | |
| 698 | truncate(inert(a.title), room), | |
| 699 | out === undefined ? url : out.paint(url, out.c.faint), | |
| 700 | ].join(" "); | |
| 701 | }); | |
| 702 | } | |
| 703 | ||
| 704 | /** | |
| 705 | * The API's answer with only one repository's rows; the forge's own needs | |
| 706 | * belong to none. | |
| 707 | */ | |
| 708 | function onlyRepo(raw: unknown, repo: string): unknown { | |
| 709 | if (typeof raw !== "object" || raw === null) return raw; | |
| 710 | const body = raw as { rows?: unknown }; | |
| 711 | return { | |
| 712 | ...body, | |
| 713 | rows: Array.isArray(body.rows) | |
| 714 | ? body.rows.filter( | |
| 715 | (r) => (r as { repo?: unknown } | null)?.repo === repo, | |
| 716 | ) | |
| 717 | : body.rows, | |
| 718 | forge: [], | |
| 719 | }; | |
| 720 | } | |
| 721 | ||
| 722 | // ── search ─────────────────────────────────────────────────────────────── | |
| 723 | ||
| 724 | /** The forge's default and ceiling for `limit` (`src/actions/search.ts`). */ | |
| 725 | const SEARCH_API_LIMIT = 20; | |
| 726 | const SEARCH_API_LIMIT_MAX = 100; | |
| 727 | ||
| 728 | const SEARCH_KINDS = ["code", "issues", "pulls", "repos", "all"] as const; | |
| 729 | export type SearchFor = (typeof SEARCH_KINDS)[number]; | |
| 730 | ||
| 731 | /** | |
| 732 | * `GET /api/v1/search` from a shell: every hit one line, grouped by kind. | |
| 733 | * The API has no `pulls` kind — its `issues` answers both — so `--kind | |
| 734 | * issues` and `--kind pulls` ask for `issues` at the API's ceiling and | |
| 735 | * keep N of the one kind (`narrowConversations`): asking for N and then | |
| 736 | * filtering would print fewer than N, or none. | |
| 737 | */ | |
| 738 | async function search(out: Out, args: Args): Promise<number> { | |
| 739 | const words = args.positional.join(" ").trim(); | |
| 740 | if (words === "") { | |
| 741 | out.err("forge search: what for? e.g. forge search retry budget"); | |
| 742 | usage(out); | |
| 743 | return EXIT.usage; | |
| 744 | } | |
| 745 | const kindRaw = args.value("kind"); | |
| 746 | const kind = SEARCH_KINDS.find((k) => k === kindRaw); | |
| 747 | if (kindRaw !== undefined && kind === undefined) { | |
| 748 | out.err(`forge search: --kind is one of ${SEARCH_KINDS.join(", ")}`); | |
| 749 | return EXIT.usage; | |
| 750 | } | |
| 751 | const limitRaw = args.value("limit"); | |
| 752 | const limit = limitRaw === undefined ? undefined : Number(limitRaw); | |
| 753 | if (limit !== undefined && (!/^\d+$/.test(limitRaw!) || limit < 1)) { | |
| 754 | out.err("forge search: --limit expects a whole number above 0"); | |
| 755 | return EXIT.usage; | |
| 756 | } | |
| 757 | const loaded = await requireConfig(out); | |
| 758 | if (typeof loaded === "number") return loaded; | |
| 759 | const oneConversation = kind === "issues" || kind === "pulls"; | |
| 760 | const answer = await new ForgeApi(loaded.url, loaded.token).search(words, { | |
| 761 | ...(kind === undefined ? {} : { kind: oneConversation ? "issues" : kind }), | |
| 762 | ...(oneConversation | |
| 763 | ? { limit: SEARCH_API_LIMIT_MAX } | |
| 764 | : limit === undefined ? {} : { limit }), | |
| 765 | }); | |
| 766 | if (!answer.ok) { | |
| 767 | const { message, retryAfter, status } = answer.error; | |
| 768 | const when = | |
| 769 | retryAfter === undefined || new RegExp(`\\b${retryAfter}s\\b`).test(message) | |
| 770 | ? "" | |
| 771 | : ` (retry in ${retryAfter}s)`; | |
| 772 | out.err(`forge search: ${message}${when}`); | |
| 773 | if (status === 429) return EXIT.rateLimited; | |
| 774 | return status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 775 | } | |
| 776 | const found = oneConversation | |
| 777 | ? narrowConversations(answer.value, kind, limit ?? SEARCH_API_LIMIT) | |
| 778 | : answer.value; | |
| 779 | if (args.flag("json")) { | |
| 780 | out.line(JSON.stringify(found)); | |
| 781 | return EXIT.ok; | |
| 782 | } | |
| 783 | const lines = searchLines(found, kind ?? "all", out.width, out); | |
| 784 | if (lines.length === 0) { | |
| 785 | out.line(`Nothing found for "${inert(words)}".`); | |
| 786 | return EXIT.ok; | |
| 787 | } | |
| 788 | for (const line of lines) out.line(line); | |
| 789 | return EXIT.ok; | |
| 790 | } | |
| 791 | ||
| 792 | /** | |
| 793 | * The answer with `issues` kept to one kind (`issue` or `pull`) and cut to | |
| 794 | * `limit`; `truncated.issues` is set when the cut dropped any, or when the | |
| 795 | * forge had already cut its own window, so `--json` and the group header | |
| 796 | * both say the list is not whole. Everything else passes through. | |
| 797 | */ | |
| 798 | export function narrowConversations( | |
| 799 | raw: unknown, | |
| 800 | kind: "issues" | "pulls", | |
| 801 | limit: number, | |
| 802 | ): unknown { | |
| 803 | if (typeof raw !== "object" || raw === null) return raw; | |
| 804 | const body = raw as Record<string, unknown>; | |
| 805 | const want = kind === "pulls" ? "pull" : "issue"; | |
| 806 | const kept = rowsOf(body["issues"]).filter((r) => r["kind"] === want); | |
| 807 | const truncated = (typeof body["truncated"] === "object" && body["truncated"] !== null | |
| 808 | ? body["truncated"] | |
| 809 | : {}) as Record<string, unknown>; | |
| 810 | return { | |
| 811 | ...body, | |
| 812 | issues: kept.slice(0, limit), | |
| 813 | truncated: { ...truncated, issues: truncated["issues"] === true || kept.length > limit }, | |
| 814 | }; | |
| 815 | } | |
| 816 | ||
| 817 | /** | |
| 818 | * The search answer as lines no wider than `width - 1` where the text can | |
| 819 | * give way: each kind headed by its name and count, then one hit a line — | |
| 820 | * `owner/name:path:line text` for code, `owner/name#N title state` for | |
| 821 | * issues and pulls, `owner/name description` for repositories. Anything | |
| 822 | * the answer carries that is not the expected shape is skipped, not fatal. | |
| 823 | */ | |
| 824 | export function searchLines( | |
| 825 | raw: unknown, | |
| 826 | kind: SearchFor, | |
| 827 | width: number, | |
| 828 | out?: Pick<Out, "paint" | "c">, | |
| 829 | ): string[] { | |
| 830 | const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>; | |
| 831 | const truncated = (body["truncated"] ?? {}) as Record<string, unknown>; | |
| 832 | const conversations = rowsOf(body["issues"]).map(conversationRow); | |
| 833 | const groups: { kind: SearchFor; rows: [string, string, string][]; more: boolean }[] = [ | |
| 834 | { kind: "code", rows: rowsOf(body["code"]).map(codeRow), more: truncated["code"] === true }, | |
| 835 | { | |
| 836 | kind: "issues", | |
| 837 | rows: conversations.filter((r) => r[0] === "issue").map((r) => r.slice(1) as [string, string, string]), | |
| 838 | more: truncated["issues"] === true, | |
| 839 | }, | |
| 840 | { | |
| 841 | kind: "pulls", | |
| 842 | rows: conversations.filter((r) => r[0] === "pull").map((r) => r.slice(1) as [string, string, string]), | |
| 843 | more: truncated["issues"] === true, | |
| 844 | }, | |
| 845 | { kind: "repos", rows: rowsOf(body["repos"]).map(repoRow), more: truncated["repos"] === true }, | |
| 846 | ]; | |
| 847 | const lines: string[] = []; | |
| 848 | for (const group of groups) { | |
| 849 | const rows = group.rows.filter((r) => r[0] !== ""); | |
| 850 | if (rows.length === 0 || (kind !== "all" && kind !== group.kind)) continue; | |
| 851 | if (lines.length > 0) lines.push(""); | |
| 852 | const head = `${group.kind} (${rows.length}${group.more ? ", more not shown" : ""})`; | |
| 853 | lines.push(out === undefined ? head : out.paint(head, out.c.accent)); | |
| 854 | // Code's place differs in width line to line; the others line up. | |
| 855 | const leadW = group.kind === "code" ? 0 : Math.max(...rows.map((r) => displayWidth(r[0]))); | |
| 856 | const tails = rows.some((r) => r[2] !== ""); | |
| 857 | const tailW = tails ? Math.max(...rows.map((r) => displayWidth(r[2]))) + 2 : 0; | |
| 858 | const textW = Math.max(...rows.map((r) => displayWidth(r[1]))); | |
| 859 | for (const [lead, text, tail] of rows) { | |
| 860 | const first = leadW === 0 ? lead : pad(lead, leadW); | |
| 861 | const room = Math.max(8, width - 1 - displayWidth(first) - 2 - tailW); | |
| 862 | const cut = truncate(text, room); | |
| 863 | const shown = tails ? pad(cut, Math.min(room, textW)) : cut; | |
| 864 | lines.push( | |
| 865 | [ | |
| 866 | first, | |
| 867 | shown, | |
| 868 | ...(tail === "" ? [] : [out === undefined ? tail : out.paint(tail, out.c.faint)]), | |
| 869 | ].join(" ").trimEnd(), | |
| 870 | ); | |
| 871 | } | |
| 872 | if (group.kind === "code" && (body["moreCode"] === true || body["codeSkipped"] === true)) { | |
| 873 | const note = " some repositories were not searched in time; --json says which kinds are partial"; | |
| 874 | lines.push(out === undefined ? note : out.paint(note, out.c.faint)); | |
| 875 | } | |
| 876 | } | |
| 877 | return lines; | |
| 878 | } | |
| 879 | ||
| 880 | function rowsOf(value: unknown): Record<string, unknown>[] { | |
| 881 | return Array.isArray(value) | |
| 882 | ? value.filter((v): v is Record<string, unknown> => typeof v === "object" && v !== null) | |
| 883 | : []; | |
| 884 | } | |
| 885 | ||
| 886 | /** `owner/name` from a local hit's `repo`, or an origin hit's `fullName`. */ | |
| 887 | function repoName(r: Record<string, unknown>): string { | |
| 888 | const repo = r["repo"] as Record<string, unknown> | undefined; | |
| 889 | if (typeof repo?.["owner"] === "string" && typeof repo["name"] === "string") | |
| 890 | return `${repo["owner"]}/${repo["name"]}`; | |
| 891 | if (typeof r["owner"] === "string" && typeof r["name"] === "string") | |
| 892 | return `${r["owner"]}/${r["name"]}`; | |
| 893 | return typeof r["fullName"] === "string" ? r["fullName"] : ""; | |
| 894 | } | |
| 895 | ||
| 896 | function textOf(value: unknown): string { | |
| 897 | return typeof value === "string" ? inert(value) : ""; | |
| 898 | } | |
| 899 | ||
| 900 | function codeRow(r: Record<string, unknown>): [string, string, string] { | |
| 901 | const name = repoName(r); | |
| 902 | const path = textOf(r["path"]); | |
| 903 | const line = typeof r["line"] === "number" ? r["line"] : 0; | |
| 904 | if (name === "" || path === "") return ["", "", ""]; | |
| 905 | return [`${inert(name)}:${path}:${line}`, textOf(r["text"]).trim(), ""]; | |
| 906 | } | |
| 907 | ||
| 908 | function conversationRow(r: Record<string, unknown>): [string, string, string, string] { | |
| 909 | const name = repoName(r); | |
| 910 | const n = r["number"]; | |
| 911 | if (name === "" || typeof n !== "number") return ["", "", "", ""]; | |
| 912 | return [textOf(r["kind"]), `${inert(name)}#${n}`, textOf(r["title"]), textOf(r["state"])]; | |
| 913 | } | |
| 914 | ||
| 915 | function repoRow(r: Record<string, unknown>): [string, string, string] { | |
| 916 | const name = repoName(r); | |
| 917 | return [inert(name), textOf(r["description"]), r["archived"] === true ? "archived" : ""]; | |
| 918 | } | |
| 919 | ||
| 920 | // ── landing a pull ─────────────────────────────────────────────────────── | |
| 921 | ||
| 922 | /** | |
| 923 | * `forge ship 12`: read the pull, show it, ask, merge. The merge route's | |
| 924 | * rules stay the server's — this adds none and retries nothing; a refusal | |
| 925 | * prints the forge's own words. | |
| 926 | */ | |
| 927 | async function ship(out: Out, args: Args, io: Io): Promise<number> { | |
| 928 | const raw = args.positional[0]; | |
| 929 | const number = raw === undefined ? NaN : Number(raw.replace(/^#/, "")); | |
| 930 | if (!Number.isSafeInteger(number) || number < 1) { | |
| 931 | out.err("forge ship: which pull? e.g. forge ship 12"); | |
| 932 | return EXIT.usage; | |
| 933 | } | |
| 934 | const loaded = await requireConfig(out); | |
| 935 | if (typeof loaded === "number") return loaded; | |
| 936 | const ref = await resolveRepo(args.value("repo"), loaded.url); | |
| 937 | if (typeof ref === "string") { | |
| 938 | out.err(`forge ship: ${ref}`); | |
| 939 | return EXIT.usage; | |
| 940 | } | |
| 941 | const api = new ForgeApi(loaded.url, loaded.token); | |
| 942 | const pull = await api.pull(ref.owner, ref.name, number); | |
| 943 | if (!pull.ok) return shipError(out, pull.error); | |
| 944 | const check = await api.pullCheck(ref.owner, ref.name, number); | |
| 945 | const p = pull.value; | |
| 946 | out.line(`${ref.owner}/${ref.name}#${p.number} ${inert(p.title)}`); | |
| 947 | const facts = [ | |
| 948 | `check ${check.ok ? inert(check.value) : "unknown"}`, | |
| 949 | p.merged | |
| 950 | ? "already merged" | |
| 951 | : p.state !== "open" | |
| 952 | ? p.state | |
| 953 | : p.mergeable === false | |
| 954 | ? "not mergeable" | |
| 955 | : p.mergeable === true | |
| 956 | ? "mergeable" | |
| 957 | : "mergeable unknown", | |
| 958 | ...(p.held ? ["held"] : []), | |
| 959 | ]; | |
| 960 | out.line(out.paint(` ${facts.join(" · ")}`, out.c.faint)); | |
| 961 | ||
| 962 | if (!args.flag("yes")) { | |
| 963 | const answer = await io.ask(`Ship #${p.number}? [y/N] `); | |
| 964 | if (answer === null) { | |
| 965 | out.err("forge ship: not a terminal — pass --yes to ship without asking"); | |
| 966 | return EXIT.usage; | |
| 967 | } | |
| 968 | if (answer.trim().toLowerCase() !== "y") { | |
| 969 | out.line("not shipped"); | |
| 970 | return EXIT.ok; | |
| 971 | } | |
| 972 | } | |
| 973 | ||
| 974 | const merged = await api.merge(ref.owner, ref.name, number); | |
| 975 | if (!merged.ok) return shipError(out, merged.error); | |
| 976 | out.line( | |
| 977 | `${out.paint(G.check, out.c.success)} shipped #${p.number} as ${merged.value.mergeOid.slice(0, 7)}`, | |
| 978 | ); | |
| 979 | out.line(api.pullUrl(ref.owner, ref.name, number)); | |
| 980 | return EXIT.ok; | |
| 981 | } | |
| 982 | ||
| 983 | function shipError(out: Out, error: ApiError): number { | |
| 984 | out.err(`forge ship: ${inert(error.message)}`); | |
| 985 | if (error.status === 401) return EXIT.notConfigured; | |
| 986 | if (error.status === 403 || error.status === 409 || error.status === 422) | |
| 987 | return EXIT.refused; | |
| 988 | return EXIT.failed; | |
| 989 | } | |
| 990 | ||
| 991 | // ── the terminal UI ────────────────────────────────────────────────────── | |
| 992 | ||
| 993 | async function openTui(out: Out, args: Args): Promise<number> { | |
| 994 | if (process.stdout.isTTY !== true) { | |
| 995 | usage(out); | |
| 996 | return EXIT.usage; | |
| 997 | } | |
| 998 | const loaded = await loadConfig(); | |
| 999 | if (loaded === "plain_http") { | |
| 1000 | out.err(`forge: ${PLAIN_HTTP_REFUSAL}`); | |
| 1001 | return EXIT.usage; | |
| 1002 | } | |
| 1003 | const config = typeof loaded === "string" ? null : loaded.config; | |
| 1004 | const { runLocalTui } = await import("../tui/local/app.ts"); | |
| 1005 | await runLocalTui({ | |
| 1006 | root: rootDir(args.value("root")), | |
| 1007 | ...(config === null ? {} : { config }), | |
| 1008 | }); | |
| 1009 | return EXIT.ok; | |
| 1010 | } | |
| 1011 | ||
| 1012 | // ── plumbing ───────────────────────────────────────────────────────────── | |
| 1013 | ||
| 1014 | async function requireConfig(out: Out): Promise<Config | number> { | |
| 1015 | const loaded = await loadConfig(); | |
| 1016 | if (loaded === "no_config") { | |
| 1017 | out.err("forge: not signed in — run `forge login <url>`"); | |
| 1018 | return EXIT.notConfigured; | |
| 1019 | } | |
| 1020 | if (loaded === "no_token") { | |
| 1021 | out.err(`forge: ${configPath()} has no usable token — run \`forge login\``); | |
| 1022 | return EXIT.notConfigured; | |
| 1023 | } | |
| 1024 | if (loaded === "plain_http") { | |
| 1025 | out.err(`forge: ${PLAIN_HTTP_REFUSAL}`); | |
| 1026 | return EXIT.usage; | |
| 1027 | } | |
| 1028 | return loaded.config; | |
| 1029 | } | |
| 1030 | ||
| 1031 | /** One transient line, overwritten in place. Never printed to a pipe. */ | |
| 1032 | function status(out: Out, text: string): void { | |
| 1033 | if (!out.color) return; | |
| 1034 | const room = Math.max(0, out.width - 1); | |
| 1035 | process.stdout.write(`\r\x1b[K${text.slice(0, room)}`); | |
| 1036 | if (text === "") process.stdout.write("\r"); | |
| 1037 | } | |
| 1038 | ||
| 1039 | function sleep(ms: number): Promise<void> { | |
| 1040 | return new Promise((resolve) => setTimeout(resolve, ms)); | |
| 1041 | } | |
| 1042 | ||
| 1043 | /** One line from the terminal, echoed; null when stdin is not a terminal. */ | |
| 1044 | async function askLine(prompt: string): Promise<string | null> { | |
| 1045 | const stdin = process.stdin; | |
| 1046 | if (stdin.isTTY !== true || process.stdout.isTTY !== true) return null; | |
| 1047 | process.stdout.write(prompt); | |
| 1048 | stdin.resume(); | |
| 1049 | return new Promise<string>((resolve) => { | |
| 1050 | // ctrl+d or a closed terminal is an answer too, and the answer is N. | |
| 1051 | const finish = (answer: string): void => { | |
| 1052 | stdin.off("data", onData); | |
| 1053 | stdin.off("end", onEnd); | |
| 1054 | stdin.off("close", onEnd); | |
| 1055 | stdin.pause(); | |
| 1056 | resolve(answer); | |
| 1057 | }; | |
| 1058 | const onData = (chunk: Buffer): void => | |
| 1059 | finish(chunk.toString("utf8").split("\n")[0] ?? ""); | |
| 1060 | const onEnd = (): void => { | |
| 1061 | process.stdout.write("\n"); | |
| 1062 | finish(""); | |
| 1063 | }; | |
| 1064 | stdin.on("data", onData); | |
| 1065 | stdin.once("end", onEnd); | |
| 1066 | stdin.once("close", onEnd); | |
| 1067 | }); | |
| 1068 | } | |
| 1069 | ||
| 1070 | /** | |
| 1071 | * A secret, read without echo. Raw mode is the only way to stop the terminal | |
| 1072 | * printing it, and it is restored on every path — including ctrl+c, which | |
| 1073 | * here means "no token", not "leave the tty broken". | |
| 1074 | */ | |
| 1075 | async function askSecret(prompt: string): Promise<string> { | |
| 1076 | const stdin = process.stdin; | |
| 1077 | if (stdin.isTTY !== true) { | |
| 1078 | // Piped input: read one line. `echo $TOKEN | forge login url` is a | |
| 1079 | // legitimate way to do this from a script. | |
| 1080 | const text = await new Response(Bun.stdin.stream()).text(); | |
| 1081 | return text.split("\n")[0]?.trim() ?? ""; | |
| 1082 | } | |
| 1083 | process.stdout.write(prompt); | |
| 1084 | stdin.setRawMode(true); | |
| 1085 | stdin.resume(); | |
| 1086 | return new Promise<string>((resolve) => { | |
| 1087 | let value = ""; | |
| 1088 | const finish = (result: string): void => { | |
| 1089 | stdin.off("data", onData); | |
| 1090 | stdin.setRawMode(false); | |
| 1091 | stdin.pause(); | |
| 1092 | process.stdout.write("\n"); | |
| 1093 | resolve(result); | |
| 1094 | }; | |
| 1095 | const onData = (chunk: Buffer): void => { | |
| 1096 | for (const byte of chunk) { | |
| 1097 | if (byte === 3) return finish(""); // ctrl+c | |
| 1098 | if (byte === 13 || byte === 10) return finish(value.trim()); | |
| 1099 | if (byte === 127 || byte === 8) { | |
| 1100 | value = value.slice(0, -1); | |
| 1101 | continue; | |
| 1102 | } | |
| 1103 | if (byte >= 0x20) value += String.fromCharCode(byte); | |
| 1104 | } | |
| 1105 | }; | |
| 1106 | stdin.on("data", onData); | |
| 1107 | }); | |
| 61 | return command.run(out, args, printUsage, io, argv.slice(1)); | |
| 1108 | 62 | } |
| 1109 | 63 | |
| 1110 | 64 | // `import.meta.main` is false when a test imports `main` directly, which is |
test/cli-commands.test.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { EXIT, main } from "../src/cli/main.ts"; | |
| 6 | import { readdirSync } from "node:fs"; | |
| 7 | import { commands } from "../src/cli/commands/index.ts"; | |
| 8 | import { printUsage } from "../src/cli/help.ts"; | |
| 9 | import { createOut } from "../src/cli/out.ts"; | |
| 10 | ||
| 11 | const DIR = new URL("../src/cli/commands/", import.meta.url).pathname; | |
| 12 | const files = readdirSync(DIR) | |
| 13 | .filter((f) => f.endsWith(".ts") && f !== "index.ts" && f !== "types.ts") | |
| 14 | .map((f) => f.slice(0, -3)); | |
| 15 | ||
| 16 | test("every module in commands/ is in the table, named for its file", async () => { | |
| 17 | for (const file of files) { | |
| 18 | const mod = (await import(`${DIR}${file}.ts`)) as { command?: unknown }; | |
| 19 | expect(commands).toContain(mod.command as never); | |
| 20 | expect(commands.map((c) => c.name)).toContain(file); | |
| 21 | } | |
| 22 | expect(commands.length).toBe(files.length); | |
| 23 | expect(new Set(commands.map((c) => c.name)).size).toBe(commands.length); | |
| 24 | }); | |
| 25 | ||
| 26 | test("forge help lists every command", () => { | |
| 27 | const lines: string[] = []; | |
| 28 | const out = { ...createOut(), line: (t = "") => void lines.push(t) }; | |
outdated
Add dispatch tests: unknown command -> EXIT.usage, help/--help -> EXIT.ok, a known command reaches its run. |
||
| 29 | printUsage(out); | |
| 30 | const text = lines.join("\n").replace(/\x1b\[[0-9;]*m/g, ""); | |
| 31 | for (const c of commands) expect(text).toContain(` ${c.usage.synopsis}`); | |
| 32 | }); | |
| 33 | ||
| 34 | // ── dispatch, driven through main() as the shell does ──────────────────── | |
| 35 | ||
| 36 | describe("main() dispatch", () => { | |
| 37 | const ENV = [ | |
| 38 | "FORGE_CONFIG_DIR", | |
| 39 | "FORGE_KEYCHAIN", | |
| 40 | "FORGE_URL", | |
| 41 | "FORGE_TOKEN", | |
| 42 | ]; | |
| 43 | const saved = new Map<string, string | undefined>(); | |
| 44 | let dir = ""; | |
| 45 | const realErr = process.stderr.write.bind(process.stderr); | |
| 46 | const realOut = process.stdout.write.bind(process.stdout); | |
| 47 | ||
| 48 | beforeEach(async () => { | |
| 49 | for (const key of ENV) saved.set(key, process.env[key]); | |
| 50 | dir = await mkdtemp(join(tmpdir(), "forge-dispatch-")); | |
| 51 | process.env["FORGE_CONFIG_DIR"] = dir; | |
| 52 | process.env["FORGE_KEYCHAIN"] = "0"; | |
| 53 | delete process.env["FORGE_URL"]; | |
| 54 | delete process.env["FORGE_TOKEN"]; | |
| 55 | process.stderr.write = (() => true) as typeof process.stderr.write; | |
| 56 | process.stdout.write = (() => true) as typeof process.stdout.write; | |
| 57 | }); | |
| 58 | ||
| 59 | afterEach(async () => { | |
| 60 | process.stderr.write = realErr; | |
| 61 | process.stdout.write = realOut; | |
| 62 | for (const key of ENV) { | |
| 63 | const was = saved.get(key); | |
| 64 | if (was === undefined) delete process.env[key]; | |
| 65 | else process.env[key] = was; | |
| 66 | } | |
| 67 | await rm(dir, { recursive: true, force: true }); | |
| 68 | }); | |
| 69 | ||
| 70 | test("an unknown command is a usage error", async () => { | |
| 71 | expect(await main(["nope"])).toBe(EXIT.usage); | |
| 72 | }); | |
| 73 | ||
| 74 | test("help, --help and -h succeed", async () => { | |
| 75 | expect(await main(["help"])).toBe(EXIT.ok); | |
| 76 | expect(await main(["--help"])).toBe(EXIT.ok); | |
| 77 | expect(await main(["sync", "-h"])).toBe(EXIT.ok); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a known command reaches its run", async () => { | |
| 81 | expect(await main(["whoami"])).toBe(EXIT.notConfigured); | |
| 82 | expect(await main(["pin", "nonsense"])).toBe(EXIT.usage); | |
| 83 | }); | |
| 84 | }); |
new above
offline
Non-blocking: add a blank line before shipError and run prettier on this file.