Public: anyone can read this; the crew can change it.
`forge asks` prints what waits on you, most important first, one line each, as /api/v1/asks answers #7381
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7368-forge-asks-prints-what-waits-on-you-most → main 2 commits fork
5 files
changed +385
−4
README.md +2
| @@ -282,6 +282,8 @@ forge sync --ff # fast-forward clean repos, and only those | ||
| 282 | 282 | forge pin owner/name # ask the workbench to keep this one here |
| 283 | 283 | forge ls # what is here, and how stale |
| 284 | 284 | forge open owner/name --web # the page, in a browser |
| 285 | forge asks # what waits on you, most important first | |
| 286 | forge asks --repo owner/name # only that repository's; --json for the API's answer | |
| 285 | 287 | |
| 286 | 288 | |
| 287 | 289 | `sync` never merges, rebases, resets or checks anything out. `--ff` is the one |
src/cli/api.ts +71
| @@ -36,6 +36,24 @@ export interface Whoami { | ||
| 36 | 36 | readonly displayName: string; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** One thing waiting on the user, as `forge asks` prints it. */ | |
| 40 | export interface Ask { | |
| 41 | /** The API's kind (`look`, `parked`, `proposal`, …), or `forge` for its own needs. */ | |
| 42 | readonly kind: string; | |
| 43 | /** `owner/name`, or "" for a need of the forge itself. */ | |
| 44 | readonly repo: string; | |
| 45 | readonly title: string; | |
| 46 | /** Absolute: where a person decides it on the workbench. */ | |
| 47 | readonly url: string; | |
| 48 | } | |
| 49 | ||
| 50 | export interface Asks { | |
| 51 | /** The answer exactly as the forge gave it, for `--json`. */ | |
| 52 | readonly raw: unknown; | |
| 53 | /** The rows in the order the forge ranks them, then the forge's own needs. */ | |
| 54 | readonly asks: Ask[]; | |
| 55 | } | |
| 56 | ||
| 39 | 57 | export class ForgeApi { |
| 40 | 58 | /** True once `pins()` has had to fall back, so the CLI can say it once. */ |
| 41 | 59 | pinsUnavailable = false; |
| @@ -173,6 +191,47 @@ export class ForgeApi { | ||
| 173 | 191 | ); |
| 174 | 192 | } |
| 175 | 193 | |
| 194 | /** | |
| 195 | * What waits on the user, most consequential first (`GET /api/v1/asks`). | |
| 196 | * A row of a kind this client has never heard of is kept, not dropped: it | |
| 197 | * prints with its kind and whatever title it carries. | |
| 198 | */ | |
| 199 | async asks(): Promise<ApiResult<Asks>> { | |
| 200 | const out = await this.call<unknown>("/api/v1/asks"); | |
| 201 | if (!out.ok) return out; | |
| 202 | const body = (out.value ?? {}) as { rows?: unknown; forge?: unknown }; | |
| 203 | const asks: Ask[] = []; | |
| 204 | for (const item of Array.isArray(body.rows) ? body.rows : []) { | |
| 205 | if (typeof item !== "object" || item === null) continue; | |
| 206 | const r = item as Record<string, unknown>; | |
| 207 | const kind = str(r["kind"]) ?? "ask"; | |
| 208 | const repo = str(r["repo"]) ?? ""; | |
| 209 | const proposal = r["proposal"] as Record<string, unknown> | undefined; | |
| 210 | asks.push({ | |
| 211 | kind, | |
| 212 | repo, | |
| 213 | title: titleOf( | |
| 214 | str(r["line"]) ?? str(proposal?.["title"]) ?? kind, | |
| 215 | repo, | |
| 216 | ), | |
| 217 | url: this.absolute(str(r["link"]) ?? "/"), | |
| 218 | }); | |
| 219 | } | |
| 220 | for (const need of Array.isArray(body.forge) ? body.forge : []) { | |
| 221 | const title = str(need); | |
| 222 | if (title !== undefined) | |
| 223 | asks.push({ kind: "forge", repo: "", title, url: this.absolute("/") }); | |
| 224 | } | |
| 225 | return ok({ raw: out.value, asks }); | |
| 226 | } | |
| 227 | ||
| 228 | /** A workbench path as a full address; anything not a path is the home. */ | |
| 229 | private absolute(link: string): string { | |
| 230 | return link.startsWith("/") && !link.startsWith("//") | |
| 231 | ? `${this.url}${link}` | |
| 232 | : `${this.url}/`; | |
| 233 | } | |
| 234 | ||
| 176 | 235 | /** The page a person would open in a browser. */ |
| 177 | 236 | webUrl(owner: string, name: string): string { |
| 178 | 237 | return `${this.url}/${encode(owner)}/${encode(name)}`; |
| @@ -216,6 +275,18 @@ function toOrigin(value: unknown): Pin["origin"] { | ||
| 216 | 275 | return { htmlUrl, fullName }; |
| 217 | 276 | } |
| 218 | 277 | |
| 278 | /** | |
| 279 | * An ask's line without the repo name it leads with (`app: Pull #3 …`, or | |
| 280 | * `app paused …`): the CLI prints `owner/name` in its own column. | |
| 281 | */ | |
| 282 | function titleOf(line: string, repo: string): string { | |
| 283 | const name = repo.slice(repo.indexOf("/") + 1); | |
| 284 | if (name === "") return line; | |
| 285 | if (line.startsWith(`${name}: `)) return line.slice(name.length + 2); | |
| 286 | if (line.startsWith(`${name} `)) return line.slice(name.length + 1); | |
| 287 | return line; | |
| 288 | } | |
| 289 | ||
| 219 | 290 | function str(value: unknown): string | undefined { |
| 220 | 291 | return typeof value === "string" && value !== "" ? value : undefined; |
| 221 | 292 | } |
src/cli/main.ts +94 −3
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * forge login <url> remember a workbench and a token |
| 7 | 7 | * forge sync clone what is missing, fetch the rest, report |
| 8 | 8 | * forge status the same report without touching the network |
| 9 | * forge asks what waits on you, most important first | |
| 9 | 10 | * |
| 10 | 11 | * Everything here is a thin shell around three modules: `api.ts` talks to the |
| 11 | 12 | * workbench, `sync.ts` decides what should be on disk, `report.ts` prints it. |
| @@ -14,7 +15,7 @@ | ||
| 14 | 15 | */ |
| 15 | 16 | |
| 16 | 17 | import { parseArgs, parseDuration, parseRepoRef, type Args } from "./args.ts"; |
| 17 | import { ForgeApi } from "./api.ts"; | |
| 18 | import { ForgeApi, type Ask } from "./api.ts"; | |
| 18 | 19 | import { |
| 19 | 20 | clearConfig, |
| 20 | 21 | configPath, |
| @@ -25,7 +26,7 @@ import { | ||
| 25 | 26 | type Config, |
| 26 | 27 | } from "./config.ts"; |
| 27 | 28 | import { systemNotifier } from "./notify.ts"; |
| 28 | import { cell, createOut, type Out } from "./out.ts"; | |
| 29 | import { cell, createOut, inert, type Out } from "./out.ts"; | |
| 29 | 30 | import { dirtyCount, printReport, settled, since } from "./report.ts"; |
| 30 | 31 | import { |
| 31 | 32 | changesBetween, |
| @@ -35,6 +36,7 @@ import { | ||
| 35 | 36 | } from "./sync.ts"; |
| 36 | 37 | import { G } from "../tui/term/screen.ts"; |
| 37 | 38 | import { trackText } from "../tui/local/common.ts"; |
| 39 | import { displayWidth, pad, truncate } from "../tui/term/width.ts"; | |
| 38 | 40 | |
| 39 | 41 | /** |
| 40 | 42 | * The CLI's own number, kept by hand: it ships compiled, to machines no |
| @@ -51,7 +53,7 @@ export const EXIT = { | ||
| 51 | 53 | partial: 4, |
| 52 | 54 | } as const; |
| 53 | 55 | |
| 54 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit"]; | |
| 56 | const VALUE_FLAGS = ["token", "interval", "root", "url", "limit", "repo"]; | |
| 55 | 57 | |
| 56 | 58 | export async function main(argv: readonly string[]): Promise<number> { |
| 57 | 59 | const args = parseArgs(argv, { values: VALUE_FLAGS }); |
| @@ -95,6 +97,8 @@ export async function main(argv: readonly string[]): Promise<number> { | ||
| 95 | 97 | return setPin(out, args, false); |
| 96 | 98 | case "open": |
| 97 | 99 | return openRepo(out, args); |
| 100 | case "asks": | |
| 101 | return asks(out, args); | |
| 98 | 102 | case "ui": |
| 99 | 103 | return openTui(out, args); |
| 100 | 104 | default: |
| @@ -121,6 +125,9 @@ function usage(out: Out): void { | ||
| 121 | 125 | out.line(b(" forge pin <owner/name>")); |
| 122 | 126 | out.line(b(" forge unpin <owner/name>")); |
| 123 | 127 | out.line(b(" forge open <owner/name>") + " [--web]"); |
| 128 | out.line( | |
| 129 | b(" forge asks") + " [--repo owner/name] [--json]", | |
| 130 | ); | |
| 124 | 131 | out.line(); |
| 125 | 132 | out.line( |
| 126 | 133 | out.paint(` config ${configPath()} root ${rootDir()}`, out.c.faint), |
| @@ -394,6 +401,90 @@ async function openRepo(out: Out, args: Args): Promise<number> { | ||
| 394 | 401 | return EXIT.ok; |
| 395 | 402 | } |
| 396 | 403 | |
| 404 | // ── asks ───────────────────────────────────────────────────────────────── | |
| 405 | ||
| 406 | /** | |
| 407 | * What waits on you, one line each, in the order the forge ranks it: kind, | |
| 408 | * repository, title cut to the terminal, and the address to decide it at. | |
| 409 | */ | |
| 410 | async function asks(out: Out, args: Args): Promise<number> { | |
| 411 | const only = args.value("repo"); | |
| 412 | const ref = only === undefined ? undefined : parseRepoRef(only); | |
| 413 | if (only !== undefined && ref === undefined) { | |
| 414 | out.err("forge asks: --repo expects owner/name"); | |
| 415 | return EXIT.usage; | |
| 416 | } | |
| 417 | const loaded = await requireConfig(out); | |
| 418 | if (typeof loaded === "number") return loaded; | |
| 419 | const answer = await new ForgeApi(loaded.url, loaded.token).asks(); | |
| 420 | if (!answer.ok) { | |
| 421 | out.err(`forge asks: ${answer.error.message}`); | |
| 422 | return answer.error.status === 401 ? EXIT.notConfigured : EXIT.failed; | |
| 423 | } | |
| 424 | const repo = ref === undefined ? undefined : `${ref.owner}/${ref.name}`; | |
| 425 | if (args.flag("json")) { | |
| 426 | const raw = answer.value.raw; | |
| 427 | out.line(JSON.stringify(repo === undefined ? raw : onlyRepo(raw, repo))); | |
| 428 | return EXIT.ok; | |
| 429 | } | |
| 430 | const rows = answer.value.asks.filter( | |
| 431 | (a) => repo === undefined || a.repo === repo, | |
| 432 | ); | |
| 433 | if (rows.length === 0) { | |
| 434 | out.line("Nothing waits on you."); | |
| 435 | return EXIT.ok; | |
| 436 | } | |
| 437 | for (const line of askLines(rows, out.width, out)) out.line(line); | |
| 438 | return EXIT.ok; | |
| 439 | } | |
| 440 | ||
| 441 | /** | |
| 442 | * The asks as lines no wider than `width - 1`, so none wraps on its own: | |
| 443 | * kind and repository padded to their widest, the address whole, and the | |
| 444 | * title cut to what those leave. Coloured only when `out` is given. | |
| 445 | */ | |
| 446 | export function askLines( | |
| 447 | rows: readonly Ask[], | |
| 448 | width: number, | |
| 449 | out?: Pick<Out, "paint" | "c">, | |
| 450 | ): string[] { | |
| 451 | const kindW = Math.max(...rows.map((a) => displayWidth(inert(a.kind)))); | |
| 452 | const repoW = Math.max(...rows.map((a) => displayWidth(inert(a.repo)))); | |
| 453 | return rows.map((a) => { | |
| 454 | const url = inert(a.url); | |
| 455 | // Three two-space gaps; the title gets what the other columns leave. | |
| 456 | const room = Math.max( | |
| 457 | 8, | |
| 458 | width - 1 - kindW - repoW - displayWidth(url) - 6, | |
| 459 | ); | |
| 460 | const kind = pad(inert(a.kind), kindW); | |
| 461 | return [ | |
| 462 | out === undefined ? kind : out.paint(kind, out.c.accent), | |
| 463 | pad(inert(a.repo), repoW), | |
| 464 | truncate(inert(a.title), room), | |
| 465 | out === undefined ? url : out.paint(url, out.c.faint), | |
| 466 | ].join(" "); | |
| 467 | }); | |
| 468 | } | |
| 469 | ||
| 470 | /** | |
| 471 | * The API's answer with only one repository's rows; the forge's own needs | |
| 472 | * belong to none. | |
| 473 | */ | |
| 474 | function onlyRepo(raw: unknown, repo: string): unknown { | |
| 475 | if (typeof raw !== "object" || raw === null) return raw; | |
| 476 | const body = raw as { rows?: unknown }; | |
| 477 | return { | |
| 478 | ...body, | |
| 479 | rows: Array.isArray(body.rows) | |
| 480 | ? body.rows.filter( | |
| 481 | (r) => (r as { repo?: unknown } | null)?.repo === repo, | |
| 482 | ) | |
| 483 | : body.rows, | |
| 484 | forge: [], | |
| 485 | }; | |
| 486 | } | |
| 487 | ||
| 397 | 488 | // ── the terminal UI ────────────────────────────────────────────────────── |
| 398 | 489 | |
| 399 | 490 | async function openTui(out: Out, args: Args): Promise<number> { |
src/cli/out.ts +1 −1
| @@ -46,7 +46,7 @@ export function cell(text: string, fg?: Color, attr?: number): Cell { | ||
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 48 | /** A cell is data, not instructions: control characters become U+FFFD. */ |
| 49 | function inert(text: string): string { | |
| 49 | export function inert(text: string): string { | |
| 50 | 50 | return text.replace(/[\x00-\x1f\x7f-\x9f]/g, "\ufffd"); |
| 51 | 51 | } |
| 52 | 52 |
test/cli-asks.test.ts added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | /** | |
| 2 | * `forge asks` against a fake `/api/v1/asks`: the CLI run as a shell runs | |
| 3 | * it, a process reading only its environment, so the exit codes are real. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import type { Ask } from "../src/cli/api.ts"; | |
| 11 | import { askLines, EXIT } from "../src/cli/main.ts"; | |
| 12 | ||
| 13 | const CLI = new URL("../src/cli/main.ts", import.meta.url).pathname; | |
| 14 | ||
| 15 | const ANSWER = { | |
| 16 | waiting: { count: 4, stale: 0 }, | |
| 17 | rows: [ | |
| 18 | { | |
| 19 | id: "look:1", | |
| 20 | kind: "look", | |
| 21 | repo: "alice/shop", | |
| 22 | line: "shop: Pull #3 is green — look and merge", | |
| 23 | link: "/alice/shop/pulls/3", | |
| 24 | items: ["look:1"], | |
| 25 | }, | |
| 26 | { | |
| 27 | id: "parked:2", | |
| 28 | kind: "parked", | |
| 29 | repo: "alice/notes", | |
| 30 | line: `notes: Issue #9 waits on a decision ${"about the long tail of things ".repeat(4)}end`, | |
| 31 | link: "/alice/notes/issues/9", | |
| 32 | items: ["parked:2"], | |
| 33 | }, | |
| 34 | // A kind this client has never heard of, with no line: it still prints. | |
| 35 | { id: "x:3", kind: "brand-new", repo: "bob/lab", link: "/#ask-x-3", items: [] }, | |
| 36 | ], | |
| 37 | forge: ["The check lane is stuck."], | |
| 38 | measure: { days: 7, raised: 1 }, | |
| 39 | }; | |
| 40 | ||
| 41 | let server: ReturnType<typeof Bun.serve>; | |
| 42 | let base = ""; | |
| 43 | let configDir = ""; | |
| 44 | let reply: () => Response = () => Response.json(ANSWER); | |
| 45 | const seen: string[] = []; | |
| 46 | ||
| 47 | beforeAll(async () => { | |
| 48 | configDir = await mkdtemp(join(tmpdir(), "forge-asks-")); | |
| 49 | server = Bun.serve({ | |
| 50 | port: 0, | |
| 51 | fetch(req) { | |
| 52 | const url = new URL(req.url); | |
| 53 | seen.push(`${url.pathname} ${req.headers.get("authorization")}`); | |
| 54 | return url.pathname === "/api/v1/asks" | |
| 55 | ? reply() | |
| 56 | : Response.json({ error: "not_found", message: "no" }, { status: 404 }); | |
| 57 | }, | |
| 58 | }); | |
| 59 | base = `http://127.0.0.1:${server.port}`; | |
| 60 | }); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | server.stop(true); | |
| 64 | await rm(configDir, { recursive: true, force: true }); | |
| 65 | }); | |
| 66 | ||
| 67 | async function forge( | |
| 68 | args: string[], | |
| 69 | extra: Record<string, string> = {}, | |
| 70 | ): Promise<{ code: number; out: string; err: string }> { | |
| 71 | const proc = Bun.spawn(["bun", CLI, ...args], { | |
| 72 | env: { | |
| 73 | PATH: process.env["PATH"] ?? "", | |
| 74 | HOME: process.env["HOME"] ?? "", | |
| 75 | FORGE_URL: base, | |
| 76 | FORGE_TOKEN: "tok", | |
| 77 | FORGE_CONFIG_DIR: configDir, | |
| 78 | FORGE_KEYCHAIN: "0", | |
| 79 | NO_COLOR: "1", | |
| 80 | ...extra, | |
| 81 | }, | |
| 82 | stdout: "pipe", | |
| 83 | stderr: "pipe", | |
| 84 | }); | |
| 85 | const [out, err, code] = await Promise.all([ | |
| 86 | new Response(proc.stdout).text(), | |
| 87 | new Response(proc.stderr).text(), | |
| 88 | proc.exited, | |
| 89 | ]); | |
| 90 | return { code, out, err }; | |
| 91 | } | |
| 92 | ||
| 93 | test("prints one line per ask, in the API's order, every kind kept", async () => { | |
| 94 | reply = () => Response.json(ANSWER); | |
| 95 | const run = await forge(["asks"]); | |
| 96 | expect(run.code).toBe(EXIT.ok); | |
| 97 | expect(seen.at(-1)).toBe("/api/v1/asks Bearer tok"); | |
| 98 | const lines = run.out.trimEnd().split("\n"); | |
| 99 | expect(lines).toHaveLength(4); | |
| 100 | expect(lines[0]!.startsWith("look alice/shop Pull #3")).toBe(true); | |
| 101 | expect(lines[0]!.endsWith(` ${base}/alice/shop/pulls/3`)).toBe(true); | |
| 102 | expect(lines[1]!.startsWith("parked alice/notes Issue #9")).toBe(true); | |
| 103 | expect(lines[1]!.endsWith(`… ${base}/alice/notes/issues/9`)).toBe(true); | |
| 104 | // An unknown kind with no line prints its kind as its title. | |
| 105 | expect(lines[2]).toBe(`brand-new bob/lab brand-new ${base}/#ask-x-3`); | |
| 106 | expect(lines[3]!.startsWith("forge The check")).toBe(true); | |
| 107 | expect(lines[3]!.endsWith(` ${base}/`)).toBe(true); | |
| 108 | // A pipe has no width; the CLI takes 80 and leaves the last column free. | |
| 109 | for (const line of lines) expect(line.length).toBeLessThanOrEqual(79); | |
| 110 | }); | |
| 111 | ||
| 112 | const ASKS: Ask[] = [ | |
| 113 | { kind: "look", repo: "alice/shop", title: "Pull #3 is green", url: "https://f.example/alice/shop/pulls/3" }, | |
| 114 | { kind: "parked", repo: "alice/notes", title: "x".repeat(200), url: "https://f.example/alice/notes/issues/9" }, | |
| 115 | { kind: "forge", repo: "", title: "The check lane is stuck.", url: "https://f.example/" }, | |
| 116 | ]; | |
| 117 | ||
| 118 | test("askLines pads kind and repository and keeps short titles whole", () => { | |
| 119 | const lines = askLines(ASKS, 120); | |
| 120 | expect(lines[0]).toBe( | |
| 121 | "look alice/shop Pull #3 is green https://f.example/alice/shop/pulls/3", | |
| 122 | ); | |
| 123 | expect(lines[2]).toBe( | |
| 124 | "forge The check lane is stuck. https://f.example/", | |
| 125 | ); | |
| 126 | }); | |
| 127 | ||
| 128 | test("askLines cuts a long title so the line fits the width", () => { | |
| 129 | for (const width of [80, 100, 120]) { | |
| 130 | const line = askLines(ASKS, width)[1]!; | |
| 131 | expect(line.length).toBe(width - 1); | |
| 132 | expect(line.endsWith("… https://f.example/alice/notes/issues/9")).toBe(true); | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 136 | test("askLines neutralises control characters a title carries", () => { | |
| 137 | const [line] = askLines( | |
| 138 | [{ kind: "look", repo: "a/b", title: "evil\x1b[2Jtitle", url: "https://f.example/" }], | |
| 139 | 120, | |
| 140 | ); | |
| 141 | expect(line).not.toContain("\x1b"); | |
| 142 | expect(line).toContain("evil\ufffd[2Jtitle"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("--repo keeps only that repository's asks", async () => { | |
| 146 | reply = () => Response.json(ANSWER); | |
| 147 | const run = await forge(["asks", "--repo", "alice/notes"]); | |
| 148 | expect(run.code).toBe(EXIT.ok); | |
| 149 | const lines = run.out.trimEnd().split("\n"); | |
| 150 | expect(lines).toHaveLength(1); | |
| 151 | expect(lines[0]!.startsWith("parked alice/notes Issue #9")).toBe(true); | |
| 152 | ||
| 153 | const none = await forge(["asks", "--repo", "carol/elsewhere"]); | |
| 154 | expect(none.out).toBe("Nothing waits on you.\n"); | |
| 155 | expect(none.code).toBe(EXIT.ok); | |
| 156 | ||
| 157 | const bad = await forge(["asks", "--repo", "nope"]); | |
| 158 | expect(bad.code).toBe(EXIT.usage); | |
| 159 | expect(bad.err).toContain("owner/name"); | |
| 160 | }); | |
| 161 | ||
| 162 | test("--json prints the API's answer unchanged", async () => { | |
| 163 | reply = () => Response.json(ANSWER); | |
| 164 | const run = await forge(["asks", "--json"]); | |
| 165 | expect(run.code).toBe(EXIT.ok); | |
| 166 | expect(JSON.parse(run.out)).toEqual(ANSWER); | |
| 167 | }); | |
| 168 | ||
| 169 | test("--json --repo prints the API's answer with only that repository's rows", async () => { | |
| 170 | reply = () => Response.json(ANSWER); | |
| 171 | const run = await forge(["asks", "--json", "--repo", "alice/notes"]); | |
| 172 | expect(run.code).toBe(EXIT.ok); | |
| 173 | const body = JSON.parse(run.out); | |
| 174 | expect(body.rows).toEqual([ANSWER.rows[1]]); | |
| 175 | expect(body.forge).toEqual([]); | |
| 176 | expect(body.waiting).toEqual(ANSWER.waiting); | |
| 177 | expect(body.measure).toEqual(ANSWER.measure); | |
| 178 | }); | |
| 179 | ||
| 180 | test("nothing waiting prints one sentence and exits 0", async () => { | |
| 181 | reply = () => Response.json({ waiting: { count: 0, stale: 0 }, rows: [], forge: [] }); | |
| 182 | const run = await forge(["asks"]); | |
| 183 | expect(run.out).toBe("Nothing waits on you.\n"); | |
| 184 | expect(run.code).toBe(EXIT.ok); | |
| 185 | }); | |
| 186 | ||
| 187 | test("a refusal prints the API's words and exits with the matching code", async () => { | |
| 188 | reply = () => | |
| 189 | Response.json({ error: "unauthorized", message: "Sign in first." }, { status: 401 }); | |
| 190 | const signedOut = await forge(["asks"]); | |
| 191 | expect(signedOut.code).toBe(EXIT.notConfigured); | |
| 192 | expect(signedOut.err).toBe("forge asks: Sign in first.\n"); | |
| 193 | ||
| 194 | reply = () => | |
| 195 | Response.json({ error: "forbidden", message: "This token cannot read asks." }, { status: 403 }); | |
| 196 | const refused = await forge(["asks"]); | |
| 197 | expect(refused.code).toBe(EXIT.failed); | |
| 198 | expect(refused.err).toBe("forge asks: This token cannot read asks.\n"); | |
| 199 | }); | |
| 200 | ||
| 201 | test("not logged in exits notConfigured without calling the forge", async () => { | |
| 202 | const before = seen.length; | |
| 203 | const run = await forge(["asks"], { | |
| 204 | FORGE_URL: "", | |
| 205 | FORGE_TOKEN: "", | |
| 206 | FORGE_CONFIG_DIR: join(configDir, "empty"), | |
| 207 | }); | |
| 208 | expect(run.code).toBe(EXIT.notConfigured); | |
| 209 | expect(run.err).toContain("forge login"); | |
| 210 | expect(seen.length).toBe(before); | |
| 211 | }); | |
| 212 | ||
| 213 | test("forge help lists the command", async () => { | |
| 214 | const run = await forge(["help"]); | |
| 215 | expect(run.out).toContain("forge asks"); | |
| 216 | expect(run.out).toContain("--repo owner/name"); | |
| 217 | }); |
new above
offline