From 89c0f0ae45451bc5906c31a88749dd128655e883 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Feb 2026 09:34:14 -0500 Subject: [PATCH 1/2] feat: add notifications command to CLI Adds `recoup notifications` command that wraps POST /api/notifications to send email notifications to the authenticated account's email address. Supports --subject, --text, --html, --cc (repeatable), --room-id, and --json flags. Co-Authored-By: Claude Opus 4.6 --- __tests__/commands/notifications.test.ts | 148 +++++++++++++++++++++++ src/bin.ts | 2 + src/commands/notifications.ts | 33 +++++ 3 files changed, 183 insertions(+) create mode 100644 __tests__/commands/notifications.test.ts create mode 100644 src/commands/notifications.ts diff --git a/__tests__/commands/notifications.test.ts b/__tests__/commands/notifications.test.ts new file mode 100644 index 0000000..974f61b --- /dev/null +++ b/__tests__/commands/notifications.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("../../src/client.js", () => ({ + get: vi.fn(), + post: vi.fn(), +})); + +import { notificationsCommand } from "../../src/commands/notifications.js"; +import { post } from "../../src/client.js"; + +let logSpy: ReturnType; +let errorSpy: ReturnType; +let exitSpy: ReturnType; + +beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("notifications command", () => { + it("sends notification with subject and text", async () => { + vi.mocked(post).mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "email-123", + }); + + await notificationsCommand.parseAsync( + ["--subject", "Test Subject", "--text", "Hello world"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/notifications", { + subject: "Test Subject", + text: "Hello world", + }); + expect(logSpy).toHaveBeenCalledWith("Email sent successfully."); + }); + + it("sends notification with html body", async () => { + vi.mocked(post).mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "email-456", + }); + + await notificationsCommand.parseAsync( + ["--subject", "Weekly Pulse", "--html", "

Report

"], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/notifications", { + subject: "Weekly Pulse", + html: "

Report

", + }); + }); + + it("passes cc and room-id options", async () => { + vi.mocked(post).mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "email-789", + }); + + await notificationsCommand.parseAsync( + [ + "--subject", + "Update", + "--text", + "Hello", + "--cc", + "cc@example.com", + "--room-id", + "room-abc", + ], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/notifications", { + subject: "Update", + text: "Hello", + cc: ["cc@example.com"], + room_id: "room-abc", + }); + }); + + it("supports multiple cc recipients", async () => { + vi.mocked(post).mockResolvedValue({ + success: true, + message: "Email sent successfully.", + id: "email-multi", + }); + + await notificationsCommand.parseAsync( + [ + "--subject", + "Update", + "--cc", + "a@example.com", + "--cc", + "b@example.com", + ], + { from: "user" }, + ); + + expect(post).toHaveBeenCalledWith("/api/notifications", { + subject: "Update", + cc: ["a@example.com", "b@example.com"], + }); + }); + + it("prints JSON with --json flag", async () => { + const response = { + success: true, + message: "Email sent successfully.", + id: "email-123", + }; + vi.mocked(post).mockResolvedValue(response); + + await notificationsCommand.parseAsync( + ["--subject", "Test", "--json"], + { from: "user" }, + ); + + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify(response, null, 2), + ); + }); + + it("prints error on failure", async () => { + vi.mocked(post).mockRejectedValue(new Error("No email address found")); + + await notificationsCommand.parseAsync( + ["--subject", "Test"], + { from: "user" }, + ); + + expect(errorSpy).toHaveBeenCalledWith("Error: No email address found"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/bin.ts b/src/bin.ts index 552816a..88057fb 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,6 +3,7 @@ import { whoamiCommand } from "./commands/whoami.js"; import { artistsCommand } from "./commands/artists.js"; import { chatsCommand } from "./commands/chats.js"; import { sandboxesCommand } from "./commands/sandboxes.js"; +import { notificationsCommand } from "./commands/notifications.js"; import { orgsCommand } from "./commands/orgs.js"; const program = new Command(); @@ -15,6 +16,7 @@ program program.addCommand(whoamiCommand); program.addCommand(artistsCommand); program.addCommand(chatsCommand); +program.addCommand(notificationsCommand); program.addCommand(sandboxesCommand); program.addCommand(orgsCommand); diff --git a/src/commands/notifications.ts b/src/commands/notifications.ts new file mode 100644 index 0000000..3eedc0e --- /dev/null +++ b/src/commands/notifications.ts @@ -0,0 +1,33 @@ +import { Command } from "commander"; +import { post } from "../client.js"; +import { printJson, printError } from "../output.js"; + +export const notificationsCommand = new Command("notifications") + .description("Send a notification email to the authenticated account") + .requiredOption("--subject ", "Email subject line") + .option("--text ", "Plain text or Markdown body") + .option("--html ", "Raw HTML body (takes precedence over --text)") + .option("--cc ", "CC recipient (repeatable)", (val: string, prev: string[]) => prev.concat(val), [] as string[]) + .option("--room-id ", "Room ID for chat link in footer") + .option("--json", "Output as JSON") + .action(async (opts) => { + try { + const body: Record = { + subject: opts.subject, + }; + if (opts.text) body.text = opts.text; + if (opts.html) body.html = opts.html; + if (opts.cc && opts.cc.length > 0) body.cc = opts.cc; + if (opts.roomId) body.room_id = opts.roomId; + + const data = await post("/api/notifications", body); + + if (opts.json) { + printJson(data); + } else { + console.log(data.message || "Notification sent."); + } + } catch (err) { + printError((err as Error).message); + } + }); From f07e84bcd2498a4dc442a8eb76496d24d306b893 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 25 Feb 2026 09:35:05 -0500 Subject: [PATCH 2/2] ci: add test workflow for pull requests Runs unit tests and build on all PRs targeting main. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f62e8ca --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: pnpm install --frozen-lockfile + + - run: pnpm test + + - run: pnpm build