diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index fab9441..b8e8d76 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -32,6 +32,8 @@ jobs: - name: Setup Pages uses: actions/configure-pages@v5 + with: + enablement: true - name: Upload Artifact uses: actions/upload-pages-artifact@v3 diff --git a/README.md b/README.md index 307621b..78ba793 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ bun run dev -- --help Output controls: ```bash -bluebubbles contact list +bluebubbles contacts list bluebubbles chats list -o wide +bluebubbles messages list --chat 'iMessage;+;chat123' -o wide bluebubbles chats list -o json ``` @@ -57,6 +58,14 @@ GUID FROM TEXT AGE CHAT 9aa1...77c +1555... sounds good, see you soon 8m iMessage;+;chat123 ``` +`messages list -o wide` includes extra columns: + +```text +GUID FROM TEXT AGE CHAT FROM_ME ATTACHMENTS CREATED_AT CHAT_NAME +4b2f...e91 me hello from bluebubbles 2m iMessage;+;chat123 yes 0 2026-04-12 21:08:14 Weekend Plans +9aa1...77c +1555... sounds good, see you soon 8m iMessage;+;chat123 no 1 2026-04-12 21:02:07 Weekend Plans +``` + JSON output is available with `-o json` or `--json`: ```json @@ -76,9 +85,12 @@ For messages: ```bash bluebubbles messages list --chat 'iMessage;+;chat123' +bluebubbles messages list --chat 'iMessage;+;chat123' -o wide bluebubbles messages list --chat 'iMessage;+;chat123' --json ``` +`-o wide` affects table output only. JSON output remains the full payload (`-o json` / `--json`). + Pagination defaults are conservative for message-heavy commands: - `bluebubbles messages list` defaults to `--limit 50` diff --git a/package.json b/package.json index 4cfdb3b..cb038e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bluebubbles-cli", - "version": "0.1.4", + "version": "0.1.6", "description": "Curated BlueBubbles CLI organized around terminal-friendly resources", "type": "module", "bin": { @@ -33,7 +33,7 @@ "yaml": "^2.8.3" }, "dependencies": { - "@anmho/bluebubbles-sdk": "^0.1.0", + "@anmho/bluebubbles-sdk": "0.1.1", "columnify": "^1.6.0", "commander": "^14.0.3", "zod": "^4.3.6" diff --git a/scripts/test-commands.ts b/scripts/test-commands.ts index 44aa837..8f4e5ee 100644 --- a/scripts/test-commands.ts +++ b/scripts/test-commands.ts @@ -353,32 +353,32 @@ async function main(): Promise { { name: "server theme set", argv: ["server", "theme", "set", "test-theme", "--file", themeFile], ok: [0], requiresApi: true, destructive: true }, { name: "server theme delete", argv: ["server", "theme", "delete", "test-theme", "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat list", argv: ["chat", "list"], ok: [0], requiresApi: true }, - { name: "chat get", argv: ["chat", "get", chatGuid], ok: [0, 6], requiresApi: true }, - { name: "chat messages", argv: ["chat", "messages", chatGuid], ok: [0, 6], requiresApi: true }, - { name: "chat update", argv: ["chat", "update", chatGuid, "--name", "Updated Name"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat delete", argv: ["chat", "delete", chatGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat group leave", argv: ["chat", "group", "leave", chatGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat group participant add", argv: ["chat", "group", "participant", "add", chatGuid, address], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat group participant remove", argv: ["chat", "group", "participant", "remove", chatGuid, address, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat group icon set", argv: ["chat", "group", "icon", "set", chatGuid], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat group icon remove", argv: ["chat", "group", "icon", "remove", chatGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "chat typing start", argv: ["chat", "typing", "start", chatGuid], ok: [0, 6], requiresApi: true }, - { name: "chat typing stop", argv: ["chat", "typing", "stop", chatGuid], ok: [0, 6], requiresApi: true }, - - { name: "message list", argv: ["message", "list"], ok: [0], requiresApi: true }, - { name: "message list common filters", argv: ["message", "list", "--chat", chatGuid, "--text", "hello", "--not-from-me", "--limit", "20"], ok: [0, 6], requiresApi: true }, - { name: "message list raw where", argv: ["message", "list", "--where", '[{"statement":"message.text LIKE :q","args":{"q":"%hello%"}}]'], ok: [0], requiresApi: true }, - { name: "message get", argv: ["message", "get", messageGuid], ok: [0, 6], requiresApi: true }, - { name: "message send", argv: ["message", "send", "--chat", chatGuid, "--message", "hello"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "message react", argv: ["message", "react", messageGuid, "--chat", chatGuid, "--reaction", "love"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "message edit", argv: ["message", "edit", messageGuid, "--message", "updated"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "message unsend", argv: ["message", "unsend", messageGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "message schedule list", argv: ["message", "schedule", "list"], ok: [0], requiresApi: true }, - { name: "message schedule get", argv: ["message", "schedule", "get", scheduleId], ok: [0, 6], requiresApi: true }, - { name: "message schedule create", argv: ["message", "schedule", "create", "--chat", chatGuid, "--message", "later", "--date", String(Date.now() + 60000)], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "message schedule update", argv: ["message", "schedule", "update", scheduleId, "--message", "new value"], ok: [0, 6], requiresApi: true, destructive: true }, - { name: "message schedule delete", argv: ["message", "schedule", "delete", scheduleId, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat list", argv: ["chats", "list"], ok: [0], requiresApi: true }, + { name: "chat get", argv: ["chats", "get", chatGuid], ok: [0, 6], requiresApi: true }, + { name: "chat messages", argv: ["chats", "messages", chatGuid], ok: [0, 6], requiresApi: true }, + { name: "chat update", argv: ["chats", "update", chatGuid, "--name", "Updated Name"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat delete", argv: ["chats", "delete", chatGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat group leave", argv: ["chats", "group", "leave", chatGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat group participant add", argv: ["chats", "group", "participant", "add", chatGuid, address], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat group participant remove", argv: ["chats", "group", "participant", "remove", chatGuid, address, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat group icon set", argv: ["chats", "group", "icon", "set", chatGuid], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat group icon remove", argv: ["chats", "group", "icon", "remove", chatGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "chat typing start", argv: ["chats", "typing", "start", chatGuid], ok: [0, 6], requiresApi: true }, + { name: "chat typing stop", argv: ["chats", "typing", "stop", chatGuid], ok: [0, 6], requiresApi: true }, + + { name: "message list", argv: ["messages", "list"], ok: [0], requiresApi: true }, + { name: "message list common filters", argv: ["messages", "list", "--chat", chatGuid, "--text", "hello", "--not-from-me", "--limit", "20"], ok: [0, 6], requiresApi: true }, + { name: "message list raw where", argv: ["messages", "list", "--where", '[{"statement":"message.text LIKE :q","args":{"q":"%hello%"}}]'], ok: [0], requiresApi: true }, + { name: "message get", argv: ["messages", "get", messageGuid], ok: [0, 6], requiresApi: true }, + { name: "message send", argv: ["messages", "send", "--chat", chatGuid, "--message", "hello"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "message react", argv: ["messages", "react", messageGuid, "--chat", chatGuid, "--reaction", "love"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "message edit", argv: ["messages", "edit", messageGuid, "--message", "updated"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "message unsend", argv: ["messages", "unsend", messageGuid, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "message schedule list", argv: ["messages", "schedule", "list"], ok: [0], requiresApi: true }, + { name: "message schedule get", argv: ["messages", "schedule", "get", scheduleId], ok: [0, 6], requiresApi: true }, + { name: "message schedule create", argv: ["messages", "schedule", "create", "--chat", chatGuid, "--message", "later", "--date", String(Date.now() + 60000)], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "message schedule update", argv: ["messages", "schedule", "update", scheduleId, "--message", "new value"], ok: [0, 6], requiresApi: true, destructive: true }, + { name: "message schedule delete", argv: ["messages", "schedule", "delete", scheduleId, "--yes"], ok: [0, 6], requiresApi: true, destructive: true }, { name: "handle list", argv: ["handle", "list"], ok: [0, 4], requiresApi: true }, { name: "handle availability", argv: ["handle", "availability", address], ok: [0, 6], requiresApi: true }, diff --git a/src/commands/chats.ts b/src/commands/chats.ts index b80b78c..258b0d2 100644 --- a/src/commands/chats.ts +++ b/src/commands/chats.ts @@ -4,6 +4,7 @@ import { addDangerousOption, maybePrint, requireConfirmation, + isWideOutput, withBlueBubblesDeps, withPaging, } from "~/lib/cli-helpers.js"; @@ -30,7 +31,7 @@ import { DEFAULT_CHAT_WITH, DEFAULT_MESSAGE_WITH } from "~/lib/constants.js"; import type { CommandOverrides, OutputOptions } from "~/lib/types.js"; export function registerChatCommands(program: Command): void { - const chatCommand = program.command("chat").description("Chat resource operations"); + const chatCommand = program.command("chats").description("Chat resource operations"); addConnectionOptions( withPaging(chatCommand.command("list").description("List chats (POST /api/v1/chat/query)")), @@ -51,7 +52,7 @@ export function registerChatCommands(program: Command): void { with: options.with.length > 0 ? options.with : [...DEFAULT_CHAT_WITH], }); const chats = result.data ?? []; - maybePrint(chats, options, () => printChats(chats)); + maybePrint(chats, options, () => printChats(chats, isWideOutput(options))); }), ); @@ -97,7 +98,7 @@ export function registerChatCommands(program: Command): void { before: options.before, with: options.with.length > 0 ? options.with : [...DEFAULT_MESSAGE_WITH], }); - maybePrint(result.data ?? [], options, () => printMessages(result.data ?? [])); + maybePrint(result.data ?? [], options, () => printMessages(result.data ?? [], isWideOutput(options))); }), ); diff --git a/src/commands/contacts.ts b/src/commands/contacts.ts index 05c9e56..b1356ed 100644 --- a/src/commands/contacts.ts +++ b/src/commands/contacts.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { addConnectionOptions, collect, + isWideOutput, maybePrint, withBlueBubblesDeps, } from "~/lib/cli-helpers.js"; @@ -24,7 +25,7 @@ export function registerContactCommands(program: Command): void { console.log("No contacts found."); return; } - printContacts(result.data); + printContacts(result.data, isWideOutput(options)); }); })); @@ -41,7 +42,7 @@ export function registerContactCommands(program: Command): void { console.log("No contacts found."); return; } - printContacts(result.data); + printContacts(result.data, isWideOutput(options)); }); })); } diff --git a/src/commands/handles.ts b/src/commands/handles.ts index 482e828..36aca04 100644 --- a/src/commands/handles.ts +++ b/src/commands/handles.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { addConnectionOptions, + isWideOutput, maybePrint, withBlueBubblesDeps, withPaging, @@ -33,7 +34,7 @@ export function registerHandleCommands(program: Command): void { console.log("No handles found."); return; } - printHandles(result.data); + printHandles(result.data, isWideOutput(options)); }); }), ); diff --git a/src/commands/icloud.ts b/src/commands/icloud.ts index 5b6aa49..9c4ae23 100644 --- a/src/commands/icloud.ts +++ b/src/commands/icloud.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { addConnectionOptions, + isWideOutput, maybePrint, withBlueBubblesDeps, } from "~/lib/cli-helpers.js"; @@ -53,7 +54,7 @@ export function registerICloudCommands(program: Command): void { console.log("No devices found or FindMy not enabled."); return; } - printFindMyDevices(result.data); + printFindMyDevices(result.data, isWideOutput(options)); }); })); @@ -75,7 +76,7 @@ export function registerICloudCommands(program: Command): void { console.log("No friends found or FindMy not enabled."); return; } - printFindMyFriends(result.data); + printFindMyFriends(result.data, isWideOutput(options)); }); })); diff --git a/src/commands/local-server.ts b/src/commands/local-server.ts index 34eaac6..98b3b25 100644 --- a/src/commands/local-server.ts +++ b/src/commands/local-server.ts @@ -15,6 +15,7 @@ import { } from "~/lib/output.js"; import { getRemoteLogs } from "~/lib/bluebubbles/server.js"; import { + openServerApp, restartServer, serverStatus, showLogs, @@ -24,6 +25,24 @@ import { import type { CommandOverrides, OutputOptions } from "~/lib/types.js"; export function registerServerLifecycleCommands(serverCommand: Command): void { + addConnectionOptions( + serverCommand.command("open").description("Open the local BlueBubbles desktop app (local process manager, no API endpoint)"), + ) + .option("--app-path ", "Override the BlueBubbles app bundle or executable path") + .action( + async (options: CommandOverrides & OutputOptions & { appPath?: string; config?: string }) => { + const context = await withConfig({ + configPath: options.config, + appPath: options.appPath, + }); + const result = await openServerApp({ + config: context.config, + appPath: options.appPath, + }); + maybePrint(result, options, () => printSuccess(`Opened BlueBubbles app: ${result.appPath}`, false)); + }, + ); + addConnectionOptions( serverCommand.command("start").description("Start the local BlueBubbles app (local process manager, no API endpoint)"), ) diff --git a/src/commands/messages.ts b/src/commands/messages.ts index 4f249f1..d17604a 100644 --- a/src/commands/messages.ts +++ b/src/commands/messages.ts @@ -4,6 +4,7 @@ import { addDangerousOption, maybePrint, requireConfirmation, + isWideOutput, withBlueBubblesDeps, withPaging, } from "~/lib/cli-helpers.js"; @@ -31,7 +32,7 @@ import type { EditMessageInput, SendReactInput } from "~/lib/bluebubbles/message import type { CommandOverrides, MessageSummary, OutputOptions } from "~/lib/types.js"; export function registerMessageCommands(program: Command): void { - const messageCommand = program.command("message").description("Message resource operations"); + const messageCommand = program.command("messages").description("Message resource operations"); addConnectionOptions( withPaging(messageCommand.command("list").description("List messages (POST /api/v1/message/query)"), 50), @@ -175,7 +176,7 @@ export function registerMessageCommands(program: Command): void { from: options.from, hasAttachments: options.hasAttachments, }); - maybePrint(filtered, options, () => printMessages(filtered)); + maybePrint(filtered, options, () => printMessages(filtered, isWideOutput(options))); }), ); @@ -311,7 +312,7 @@ export function registerMessageCommands(program: Command): void { console.log("No scheduled messages."); return; } - printScheduledMessages(result.data); + printScheduledMessages(result.data, isWideOutput(options)); }); })); diff --git a/src/commands/server.ts b/src/commands/server.ts index 7d1fa32..a87b8cb 100644 --- a/src/commands/server.ts +++ b/src/commands/server.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { addConnectionOptions, addDangerousOption, + isWideOutput, maybePrint, requireConfirmation, withBlueBubblesDeps, @@ -51,7 +52,7 @@ export function registerServerCommands(program: Command): void { console.log("No alerts."); return; } - printAlerts(result.data); + printAlerts(result.data, isWideOutput(options)); }); })); diff --git a/src/index.test.ts b/src/index.test.ts index 442625b..a2bb263 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -302,8 +302,8 @@ describe("help surface", () => { const r = await cli(["--help"]); expect(r.exitCode).toBe(0); expect(r.stdout).toContain("server"); - expect(r.stdout).toContain("chat"); - expect(r.stdout).toContain("message"); + expect(r.stdout).toContain("chats"); + expect(r.stdout).toContain("messages"); expect(r.stdout).toContain("contact"); expect(r.stdout).toContain("webhook"); expect(r.stdout).not.toContain("fcm"); @@ -362,31 +362,31 @@ describe("server flows", () => { describe("chat and message flows", () => { test("chat commands work", async () => { - expect((await cli(["chat", "list"])).exitCode).toBe(0); - expect((await cli(["chat", "get", CHAT_GUID])).exitCode).toBe(0); - expect((await cli(["chat", "messages", CHAT_GUID])).exitCode).toBe(0); - expect((await cli(["chat", "update", CHAT_GUID, "--name", "Renamed"])).exitCode).toBe(0); - expect((await cli(["chat", "group", "participant", "add", CHAT_GUID, "user@example.com"])).exitCode).toBe(0); - expect((await cli(["chat", "group", "icon", "set", CHAT_GUID])).exitCode).toBe(0); - expect((await cli(["chat", "typing", "start", CHAT_GUID])).exitCode).toBe(0); - expect((await cli(["chat", "typing", "stop", CHAT_GUID])).exitCode).toBe(0); + expect((await cli(["chats", "list"])).exitCode).toBe(0); + expect((await cli(["chats", "get", CHAT_GUID])).exitCode).toBe(0); + expect((await cli(["chats", "messages", CHAT_GUID])).exitCode).toBe(0); + expect((await cli(["chats", "update", CHAT_GUID, "--name", "Renamed"])).exitCode).toBe(0); + expect((await cli(["chats", "group", "participant", "add", CHAT_GUID, "user@example.com"])).exitCode).toBe(0); + expect((await cli(["chats", "group", "icon", "set", CHAT_GUID])).exitCode).toBe(0); + expect((await cli(["chats", "typing", "start", CHAT_GUID])).exitCode).toBe(0); + expect((await cli(["chats", "typing", "stop", CHAT_GUID])).exitCode).toBe(0); }); test("message commands work", async () => { - expect((await cli(["message", "list"])).exitCode).toBe(0); - expect((await cli(["message", "get", MSG_GUID])).exitCode).toBe(0); - expect((await cli(["message", "send", "--chat", CHAT_GUID, "--message", "hello"])).exitCode).toBe(0); - expect((await cli(["message", "react", MSG_GUID, "--chat", CHAT_GUID, "--reaction", "love"])).exitCode).toBe(0); - expect((await cli(["message", "edit", MSG_GUID, "--message", "updated"])).exitCode).toBe(0); - expect((await cli(["message", "unsend", MSG_GUID, "--yes"])).exitCode).toBe(0); + expect((await cli(["messages", "list"])).exitCode).toBe(0); + expect((await cli(["messages", "get", MSG_GUID])).exitCode).toBe(0); + expect((await cli(["messages", "send", "--chat", CHAT_GUID, "--message", "hello"])).exitCode).toBe(0); + expect((await cli(["messages", "react", MSG_GUID, "--chat", CHAT_GUID, "--reaction", "love"])).exitCode).toBe(0); + expect((await cli(["messages", "edit", MSG_GUID, "--message", "updated"])).exitCode).toBe(0); + expect((await cli(["messages", "unsend", MSG_GUID, "--yes"])).exitCode).toBe(0); }); test("message schedule commands work", async () => { - expect((await cli(["message", "schedule", "list"])).exitCode).toBe(0); - expect((await cli(["message", "schedule", "get", "1"])).exitCode).toBe(0); - expect((await cli(["message", "schedule", "create", "--chat", CHAT_GUID, "--message", "later", "--date", "1893456000000"])).exitCode).toBe(0); - expect((await cli(["message", "schedule", "update", "1", "--message", "updated"])).exitCode).toBe(0); - expect((await cli(["message", "schedule", "delete", "1", "--yes"])).exitCode).toBe(0); + expect((await cli(["messages", "schedule", "list"])).exitCode).toBe(0); + expect((await cli(["messages", "schedule", "get", "1"])).exitCode).toBe(0); + expect((await cli(["messages", "schedule", "create", "--chat", CHAT_GUID, "--message", "later", "--date", "1893456000000"])).exitCode).toBe(0); + expect((await cli(["messages", "schedule", "update", "1", "--message", "updated"])).exitCode).toBe(0); + expect((await cli(["messages", "schedule", "delete", "1", "--yes"])).exitCode).toBe(0); }); }); @@ -417,7 +417,7 @@ describe("resource sidecars", () => { describe("confirmation and diagnostics", () => { test("destructive commands require --yes in non-interactive mode", async () => { - const r = await cli(["chat", "delete", CHAT_GUID]); + const r = await cli(["chats", "delete", CHAT_GUID]); expect(r.exitCode).toBe(2); expect(r.stderr).toContain("--yes"); }); diff --git a/src/lib/bluebubbles/client.ts b/src/lib/bluebubbles/client.ts index 009ac24..5c15da2 100644 --- a/src/lib/bluebubbles/client.ts +++ b/src/lib/bluebubbles/client.ts @@ -5,6 +5,8 @@ export interface ApiConfig { baseUrl: string; password: string; fetchImpl?: typeof fetch; + requestTimeoutMs?: number; + verbose?: boolean; } export interface ApiEnvelope { @@ -25,14 +27,18 @@ export class BlueBubblesClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; private readonly sdkClient: SdkClient; + private readonly requestTimeoutMs: number; + private readonly verbose: boolean; constructor(private readonly config: ApiConfig) { this.baseUrl = this.normalizeBaseUrl(config.baseUrl); this.fetchImpl = config.fetchImpl ?? fetch; + this.requestTimeoutMs = this.resolveRequestTimeout(config.requestTimeoutMs); + this.verbose = config.verbose ?? process.env.BLUEBUBBLES_VERBOSE === "1"; this.sdkClient = new SdkClient({ baseUrl: this.baseUrl, password: this.config.password, - fetch: this.fetchImpl, + fetch: this.fetchWithTimeout.bind(this), }); } @@ -115,13 +121,13 @@ export class BlueBubblesClient { ...query, }); - const response = await this.fetchImpl(url, { + const response = await this.fetchWithTimeout(url, { method, ...(body !== undefined && { headers: { "content-type": "application/json" }, body: JSON.stringify(body), }), - }); + }, { method, endpoint: pathName }); if (method === "GET" && !response.headers.get("content-type")?.includes("application/json")) { return response as T; @@ -137,7 +143,7 @@ export class BlueBubblesClient { async fetchDownload(pathTemplate: string, replacements: Record): Promise { const pathName = this.interpolatePath(pathTemplate, replacements); const url = this.buildUrl(pathName, { password: this.config.password }); - const response = await this.fetchImpl(url); + const response = await this.fetchWithTimeout(url, undefined, { method: "GET", endpoint: pathName }); if (!response.ok) { const payload = await this.parseResponse(response); @@ -151,10 +157,19 @@ export class BlueBubblesClient { resultPromise: Promise, context: { method: string; endpoint: string }, ): Promise { - const result = await resultPromise; + let result: SdkFieldsResult; + try { + result = await resultPromise; + } catch (error) { + throw this.transportError(error, context); + } const response = result.response; if (!response) { - throw new CliError(`${context.method} ${context.endpoint}: No response from SDK request`, "network", result); + throw new CliError( + `${context.method} ${context.endpoint}: ${this.extractTransportError(result.error) ?? "No response from SDK request"}`, + "network", + result, + ); } if (!response.ok || result.error) { throw this.responseError(response, result.error ?? result, context); @@ -173,13 +188,13 @@ export class BlueBubblesClient { ...query, }); - const response = await this.fetchImpl(url, { + const response = await this.fetchWithTimeout(url, { method, ...(body !== undefined && { headers: { "content-type": "application/json" }, body: JSON.stringify(body), }), - }); + }, { method, endpoint: pathName }); const payload = await this.parseResponse(response); if (!response.ok) { @@ -209,6 +224,17 @@ export class BlueBubblesClient { return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; } + private resolveRequestTimeout(configured?: number): number { + if (configured && Number.isFinite(configured) && configured > 0) { + return Math.floor(configured); + } + const fromEnv = Number.parseInt(process.env.BLUEBUBBLES_REQUEST_TIMEOUT_MS ?? "", 10); + if (Number.isFinite(fromEnv) && fromEnv > 0) { + return fromEnv; + } + return 10_000; + } + private buildUrl(pathName: string, query: QueryParams): URL { const url = new URL(pathName.replace(/^\//, ""), this.baseUrl); for (const [key, value] of Object.entries(query)) { @@ -249,4 +275,114 @@ export class BlueBubblesClient { } return undefined; } + + private async fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + context?: { method: string; endpoint: string }, + ): Promise { + const method = context?.method ?? init.method ?? "GET"; + const endpoint = context?.endpoint ?? this.describeInput(input); + this.debug(`${method} ${endpoint} request started (timeout=${this.requestTimeoutMs}ms)`); + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(`timeout:${this.requestTimeoutMs}`); + }, this.requestTimeoutMs); + + if (init.signal) { + if (init.signal.aborted) { + controller.abort((init.signal as { reason?: unknown }).reason); + } else { + init.signal.addEventListener( + "abort", + () => controller.abort((init.signal as { reason?: unknown }).reason), + { once: true }, + ); + } + } + + try { + const response = await this.fetchImpl(input, { ...init, signal: controller.signal }); + this.debug(`${method} ${endpoint} response status=${response.status}`); + return response; + } catch (error) { + throw this.transportError(error, context); + } finally { + clearTimeout(timeout); + } + } + + private transportError(error: unknown, context?: { method: string; endpoint: string }): CliError { + const base = + this.extractTransportError(error) ?? + this.extractMessage(error) ?? + (error instanceof Error && error.message ? error.message : undefined) ?? + "Failed to reach BlueBubbles API"; + const prefix = context ? `${context.method} ${context.endpoint}: ` : ""; + this.debug(`${prefix}${base}`); + return new CliError(`${prefix}${base}`, "network", error); + } + + private extractTransportError(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const candidate = error as { + code?: unknown; + name?: unknown; + path?: unknown; + message?: unknown; + cause?: unknown; + details?: unknown; + }; + + if (typeof candidate.code === "string" && candidate.code === "FailedToOpenSocket") { + const target = typeof candidate.path === "string" ? candidate.path : this.baseUrl; + return `Unable to connect to BlueBubbles API at ${target}`; + } + + if (typeof candidate.name === "string" && candidate.name === "AbortError") { + return `Request timed out after ${this.requestTimeoutMs}ms`; + } + + if (typeof candidate.message === "string" && candidate.message.includes("timeout")) { + return `Request timed out after ${this.requestTimeoutMs}ms`; + } + + if (candidate.details && typeof candidate.details === "object") { + const details = candidate.details as { code?: unknown; path?: unknown; message?: unknown }; + if (typeof details.code === "string" && details.code === "FailedToOpenSocket") { + const target = typeof details.path === "string" ? details.path : this.baseUrl; + return `Unable to connect to BlueBubbles API at ${target}`; + } + if (typeof details.message === "string" && details.message.trim()) { + return details.message; + } + } + + if (candidate.cause && typeof candidate.cause === "object") { + const cause = candidate.cause as { code?: unknown; message?: unknown }; + if (typeof cause.code === "string" && cause.code === "FailedToOpenSocket") { + return `Unable to connect to BlueBubbles API at ${this.baseUrl}`; + } + if (typeof cause.message === "string" && cause.message.trim()) { + return cause.message; + } + } + + if (typeof candidate.message === "string" && candidate.message.trim()) { + return candidate.message; + } + + return undefined; + } + + private describeInput(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; + } + + private debug(message: string): void { + if (!this.verbose) return; + console.error(`[verbose] ${message}`); + } } diff --git a/src/lib/cli-helpers.ts b/src/lib/cli-helpers.ts index cd3a5f7..06d1f8c 100644 --- a/src/lib/cli-helpers.ts +++ b/src/lib/cli-helpers.ts @@ -20,18 +20,26 @@ export function addConnectionOptions(command: Command): Command { .option("--config ", "Override the config file location") .option("--base-url ", "BlueBubbles API base URL") .option("--password ", "BlueBubbles server password") - .option("-o, --output ", "Output format (table|json)", parseOutputFormat) + .option("-v, --verbose", "Enable verbose API diagnostics to stderr") + .option("-o, --output ", "Output format (table|wide|json)", parseOutputFormat) .option("--json", "Alias for -o json"); } function parseOutputFormat(value: string): OutputFormat { const normalized = value.trim().toLowerCase(); - if (normalized !== "table" && normalized !== "json") { - throw new CliError(`Unsupported output format "${value}". Use: table, json`, "validation"); + if (normalized !== "table" && normalized !== "wide" && normalized !== "json") { + throw new CliError(`Unsupported output format "${value}". Use: table, wide, json`, "validation"); } return normalized as OutputFormat; } +export function isWideOutput(output: OutputOptions): boolean { + if (output.json || output.output === "json") { + return false; + } + return output.output === "wide"; +} + export function getConfigOverride(options: { config?: string }): Pick { return { configPath: options.config }; } @@ -56,6 +64,7 @@ export async function apiConfigFromOptions( return { baseUrl: config.baseUrl, password: config.password, + verbose: options.verbose ?? process.env.BLUEBUBBLES_VERBOSE === "1", }; } diff --git a/src/lib/local-server.ts b/src/lib/local-server.ts index 51e6510..31c3cb6 100644 --- a/src/lib/local-server.ts +++ b/src/lib/local-server.ts @@ -157,6 +157,38 @@ export async function startServer(input: { return state; } +export async function openServerApp(input: { + config: CliConfig; + appPath?: string; +}): Promise<{ appPath: string }> { + ensureMacOS(); + const appPath = input.appPath ?? discoverAppPath({ ...input.config, appPath: input.appPath }); + if (!appPath) { + throw new CliError( + "Unable to find an installed BlueBubbles app. Set one with `bluebubbles config set appPath /Applications/BlueBubbles.app`.", + "process", + ); + } + + if (appPath.endsWith(".app")) { + const child = spawn("open", [appPath], { + detached: true, + stdio: "ignore", + }); + child.unref(); + return { appPath }; + } + + const executablePath = await resolveBundleExecutable(appPath); + const child = spawn(executablePath, [], { + cwd: path.dirname(executablePath), + detached: true, + stdio: "ignore", + }); + child.unref(); + return { appPath }; +} + export async function stopServer(statePath: string): Promise { ensureMacOS(); const state = await readRuntimeState(statePath); diff --git a/src/lib/output.ts b/src/lib/output.ts index 0bb72aa..84c0c7b 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -190,8 +190,8 @@ export function printKeyValue(data: Record): void { console.log(renderRows(rows, ["key", "value"])); } -export function printChats(chats: ChatSummary[]): void { - printTableRows(chats, [ +export function printChats(chats: ChatSummary[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "guid", value: (chat) => chat.guid, maxWidth: 28 }, { name: "name", value: (chat) => chat.displayName, maxWidth: 28 }, { name: "identifier", value: (chat) => chat.chatIdentifier, maxWidth: 28 }, @@ -206,17 +206,41 @@ export function printChats(chats: ChatSummary[]): void { }, { name: "last_message", value: (chat) => chat.lastMessage?.text, maxWidth: 40 }, { name: "age", value: (chat) => formatAge(chat.lastMessage?.dateCreated) }, - ]); + ]; + if (wide) { + columns.push( + { + name: "participant_count", + value: (chat) => chat.participants?.length ?? 0, + maxWidth: 16, + }, + { + name: "last_timestamp", + value: (chat) => formatTimestamp(chat.lastMessage?.dateCreated), + maxWidth: 24, + }, + ); + } + printTableRows(chats, columns); } -export function printMessages(messages: MessageSummary[]): void { - printTableRows(messages, [ +export function printMessages(messages: MessageSummary[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "guid", value: (message) => message.guid, maxWidth: 28 }, { name: "from", value: (message) => (message.isFromMe ? "me" : message.handle?.address), maxWidth: 20 }, { name: "text", value: (message) => message.text, maxWidth: 48 }, { name: "age", value: (message) => formatAge(message.dateCreated) }, { name: "chat", value: (message) => message.chats?.[0]?.guid, maxWidth: 28 }, - ]); + ]; + if (wide) { + columns.push( + { name: "from_me", value: (message) => (message.isFromMe ? "yes" : "no"), maxWidth: 8 }, + { name: "attachments", value: (message) => message.attachments?.length ?? 0, maxWidth: 12 }, + { name: "created_at", value: (message) => formatTimestamp(message.dateCreated), maxWidth: 24 }, + { name: "chat_name", value: (message) => message.chats?.[0]?.displayName, maxWidth: 28 }, + ); + } + printTableRows(messages, columns); } export function printDoctorChecks(checks: DoctorCheck[]): void { @@ -227,14 +251,21 @@ export function printDoctorChecks(checks: DoctorCheck[]): void { ]); } -export function printAlerts(alerts: unknown[]): void { - printTableRows(alerts, [ +export function printAlerts(alerts: unknown[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "id", value: (alert) => firstPath(alert, ["id"]) }, { name: "type", value: (alert) => firstPath(alert, ["type"]), maxWidth: 12 }, { name: "message", value: (alert) => firstPath(alert, ["value", "message"]), maxWidth: 80 }, { name: "age", value: (alert) => formatAge(firstPath(alert, ["created", "createdAt", "dateCreated"])) }, { name: "read", value: (alert) => (firstPath(alert, ["isRead"]) ? "yes" : "no") }, - ]); + ]; + if (wide) { + columns.push( + { name: "created_at", value: (alert) => formatTimestamp(firstPath(alert, ["created", "createdAt", "dateCreated"])), maxWidth: 24 }, + { name: "data", value: (alert) => firstPath(alert, ["value"]), maxWidth: 50 }, + ); + } + printTableRows(alerts, columns); } function extractPrimary(list: unknown, path = "address"): string { @@ -245,21 +276,36 @@ function extractPrimary(list: unknown, path = "address"): string { return `${firstText} (+${list.length - 1})`; } -export function printContacts(contacts: unknown[]): void { - printTableRows(contacts, [ +export function printContacts(contacts: unknown[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "name", value: (contact) => firstPath(contact, ["displayName", "firstName"]), maxWidth: 32 }, { name: "phone", value: (contact) => extractPrimary(firstPath(contact, ["phoneNumbers"])), maxWidth: 24 }, { name: "email", value: (contact) => extractPrimary(firstPath(contact, ["emails"])), maxWidth: 32 }, { name: "source", value: (contact) => firstPath(contact, ["sourceType"]), maxWidth: 10 }, - ]); + ]; + if (wide) { + columns.push( + { name: "phones", value: (contact) => firstPath(contact, ["phoneNumbers"]), maxWidth: 40 }, + { name: "emails", value: (contact) => firstPath(contact, ["emails"]), maxWidth: 48 }, + { name: "id", value: (contact) => firstPath(contact, ["id", "identifier", "originalROWID"]), maxWidth: 18 }, + ); + } + printTableRows(contacts, columns); } -export function printHandles(handles: unknown[]): void { - printTableRows(handles, [ +export function printHandles(handles: unknown[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "address", value: (handle) => firstPath(handle, ["address", "handle.address"]), maxWidth: 32 }, { name: "country", value: (handle) => firstPath(handle, ["country"]), maxWidth: 12 }, { name: "id", value: (handle) => firstPath(handle, ["id", "originalROWID"]), maxWidth: 10 }, - ]); + ]; + if (wide) { + columns.push( + { name: "service", value: (handle) => firstPath(handle, ["service"]), maxWidth: 14 }, + { name: "uncanonicalized", value: (handle) => firstPath(handle, ["uncanonicalizedId"]), maxWidth: 28 }, + ); + } + printTableRows(handles, columns); } export function printThemes(themes: unknown[]): void { @@ -269,15 +315,23 @@ export function printThemes(themes: unknown[]): void { ]); } -export function printScheduledMessages(items: unknown[]): void { - printTableRows(items, [ +export function printScheduledMessages(items: unknown[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "id", value: (item) => firstPath(item, ["id"]), maxWidth: 8 }, { name: "type", value: (item) => firstPath(item, ["type"]), maxWidth: 16 }, { name: "status", value: (item) => firstPath(item, ["status", "schedule.type"]), maxWidth: 14 }, { name: "message", value: (item) => firstPath(item, ["payload.message"]), maxWidth: 40 }, { name: "when", value: (item) => formatTimestamp(firstPath(item, ["scheduledFor"])), maxWidth: 28 }, { name: "age", value: (item) => formatAge(firstPath(item, ["scheduledFor"])) }, - ]); + ]; + if (wide) { + columns.push( + { name: "chat", value: (item) => firstPath(item, ["payload.chatGuid"]), maxWidth: 28 }, + { name: "created_at", value: (item) => formatTimestamp(firstPath(item, ["createdAt", "created"])), maxWidth: 24 }, + { name: "updated_at", value: (item) => formatTimestamp(firstPath(item, ["updatedAt", "updated"])), maxWidth: 24 }, + ); + } + printTableRows(items, columns); } function formatCoordinates(value: unknown): string { @@ -294,22 +348,33 @@ function formatBattery(value: unknown): string { return `${Math.round(batteryLevel)}%`; } -export function printFindMyDevices(devices: unknown[]): void { - printTableRows(devices, [ +export function printFindMyDevices(devices: unknown[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "name", value: (device) => firstPath(device, ["name", "displayName", "title"]), maxWidth: 28 }, { name: "model", value: (device) => firstPath(device, ["model", "deviceClass", "deviceModel"]), maxWidth: 20 }, { name: "battery", value: (device) => formatBattery(device), maxWidth: 8 }, { name: "location", value: (device) => formatCoordinates(device), maxWidth: 24 }, { name: "age", value: (device) => formatAge(firstPath(device, ["location.timeStamp", "updated", "updatedAt"])) }, - ]); + ]; + if (wide) { + columns.push( + { name: "id", value: (device) => firstPath(device, ["id", "identifier"]), maxWidth: 20 }, + { name: "raw", value: (device) => device, maxWidth: 50 }, + ); + } + printTableRows(devices, columns); } -export function printFindMyFriends(friends: unknown[]): void { - printTableRows(friends, [ +export function printFindMyFriends(friends: unknown[], wide = false): void { + const columns: ColumnSpec[] = [ { name: "name", value: (friend) => firstPath(friend, ["name", "displayName", "title"]), maxWidth: 28 }, { name: "location", value: (friend) => formatCoordinates(friend), maxWidth: 24 }, { name: "age", value: (friend) => formatAge(firstPath(friend, ["location.timeStamp", "updated", "updatedAt"])) }, - ]); + ]; + if (wide) { + columns.push({ name: "raw", value: (friend) => friend, maxWidth: 50 }); + } + printTableRows(friends, columns); } export function tailLines(contents: string, count: number): string { diff --git a/src/lib/types.ts b/src/lib/types.ts index 75f36c2..50316dc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -23,7 +23,7 @@ export interface RuntimeState { args: string[]; } -export type OutputFormat = "table" | "json"; +export type OutputFormat = "table" | "wide" | "json"; export interface OutputOptions { json?: boolean; @@ -32,6 +32,7 @@ export interface OutputOptions { export interface CommandOverrides extends CliConfig { configPath?: string; + verbose?: boolean; } export interface ChatSummary {