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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7424-each-forge-command-lives-in-its-own-modu → main 10 commits fork 4 threads · 4 unresolved

  • Merged. scout/7424-each-forge-command-lives-in-its-own-modu was merged into main as 09f7dd1.

Review

Sign in to review this change.
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.
55 README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, not a new README section.
66
77 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: add a blank line before shipError and run prettier on this file.

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 @@
1818 * an exit code — so a script can branch on the result without parsing text.
1919 */
2020
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";
4927
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 };
5532
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]));
6835
6936 const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo", "kind", "reason"];
7037
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
7738 export async function main(
7839 argv: readonly string[],
7940 io: Io = { ask: askLine },
@@ -85,1026 +46,19 @@ export async function main(
8546 out.line(`forge ${VERSION}`);
8647 return EXIT.ok;
8748 }
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);
9751 return EXIT.ok;
9852 }
9953
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);
18959 return EXIT.usage;
19060 }
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));
110862 }
110963
111064 // `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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs