diff --git a/README.md b/README.md index 22f37a3c..8a8be34a 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ npm install -g @ably/cli $ ably COMMAND running command... $ ably (--version) -@ably/cli/0.17.0 darwin-arm64 node-v25.3.0 +@ably/cli/0.17.0 darwin-arm64 node-v22.14.0 $ ably --help [COMMAND] USAGE $ ably COMMAND @@ -2037,6 +2037,8 @@ EXAMPLES $ ably channels presence update my-channel --data '{"status":"busy"}' --json + $ ably channels presence update my-channel --data '{"status":"busy"}' --pretty-json + $ ably channels presence update my-channel --data '{"status":"online"}' --duration 60 ``` @@ -2942,7 +2944,7 @@ COMMANDS ably push channels Manage push notification channel subscriptions ably push config Manage push notification configuration (APNs, FCM) ably push devices Manage push notification device registrations - ably push publish Publish a push notification to a device or client + ably push publish Publish a push notification to a device, client, or channel ``` _See code: [src/commands/push/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/push/index.ts)_ @@ -3505,19 +3507,22 @@ _See code: [src/commands/push/devices/save.ts](https://github.com/ably/ably-cli/ ## `ably push publish` -Publish a push notification to a device or client +Publish a push notification to a device, client, or channel ``` USAGE $ ably push publish [-v] [--json | --pretty-json] [--device-id | --client-id | --recipient - ] [--title ] [--body ] [--sound ] [--icon ] [--badge ] [--data ] - [--collapse-key ] [--ttl ] [--payload ] [--apns ] [--fcm ] [--web ] + ] [--channel ] [--title ] [--body ] [--sound ] [--icon ] [--badge ] + [--data ] [--collapse-key ] [--ttl ] [--payload ] [--apns ] [--fcm ] + [--web ] FLAGS -v, --verbose Output verbose logs --apns= APNs-specific override as JSON --badge= Notification badge count --body= Notification body + --channel= Target channel name (publishes push notification via the channel using extras.push; + ignored if --device-id, --client-id, or --recipient is also provided) --client-id= Target client ID --collapse-key= Collapse key for notification grouping --data= Custom data payload as JSON @@ -3534,13 +3539,15 @@ FLAGS --web= Web push-specific override as JSON DESCRIPTION - Publish a push notification to a device or client + Publish a push notification to a device, client, or channel EXAMPLES $ ably push publish --device-id device-123 --title Hello --body World $ ably push publish --client-id client-1 --title Hello --body World + $ ably push publish --channel my-channel --title Hello --body World + $ ably push publish --device-id device-123 --payload '{"notification":{"title":"Hello","body":"World"}}' $ ably push publish --recipient '{"transportType":"apns","deviceToken":"token123"}' --title Hello --body World diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index 32268452..728403ce 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -5,15 +5,21 @@ import * as path from "node:path"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; -import { formatProgress, formatSuccess } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, + formatWarning, +} from "../../utils/output.js"; export default class PushPublish extends AblyBaseCommand { static override description = - "Publish a push notification to a device or client"; + "Publish a push notification to a device, client, or channel"; static override examples = [ "<%= config.bin %> <%= command.id %> --device-id device-123 --title Hello --body World", "<%= config.bin %> <%= command.id %> --client-id client-1 --title Hello --body World", + "<%= config.bin %> <%= command.id %> --channel my-channel --title Hello --body World", '<%= config.bin %> <%= command.id %> --device-id device-123 --payload \'{"notification":{"title":"Hello","body":"World"}}\'', '<%= config.bin %> <%= command.id %> --recipient \'{"transportType":"apns","deviceToken":"token123"}\' --title Hello --body World', "<%= config.bin %> <%= command.id %> --device-id device-123 --title Hello --body World --json", @@ -33,6 +39,10 @@ export default class PushPublish extends AblyBaseCommand { description: "Raw recipient JSON for advanced targeting", exclusive: ["device-id", "client-id"], }), + channel: Flags.string({ + description: + "Target channel name (publishes push notification via the channel using extras.push; ignored if --device-id, --client-id, or --recipient is also provided)", + }), title: Flags.string({ description: "Notification title", }), @@ -75,27 +85,38 @@ export default class PushPublish extends AblyBaseCommand { async run(): Promise { const { flags } = await this.parse(PushPublish); - if (!flags["device-id"] && !flags["client-id"] && !flags.recipient) { + const hasDirectRecipient = + flags["device-id"] || flags["client-id"] || flags.recipient; + + if (!hasDirectRecipient && !flags.channel) { this.fail( - "A recipient is required: --device-id, --client-id, or --recipient", + "A target is required: --device-id, --client-id, --recipient, or --channel-name", flags as BaseFlags, "pushPublish", ); } + if (hasDirectRecipient && flags.channel && !this.shouldOutputJson(flags)) { + this.log( + formatWarning( + "--channel is ignored when --device-id, --client-id, or --recipient is provided.", + ), + ); + } + try { const rest = await this.createAblyRestClient(flags as BaseFlags); if (!rest) return; // Build recipient - let recipient: Record; + let recipient: Record | undefined; if (flags["device-id"]) { recipient = { deviceId: flags["device-id"] }; } else if (flags["client-id"]) { recipient = { clientId: flags["client-id"] }; - } else { + } else if (flags.recipient) { recipient = this.parseJsonObjectFlag( - flags.recipient!, + flags.recipient, "--recipient", flags as BaseFlags, ); @@ -202,12 +223,29 @@ export default class PushPublish extends AblyBaseCommand { this.log(formatProgress("Publishing push notification")); } - await rest.push.admin.publish(recipient!, payload); + if (recipient) { + await rest.push.admin.publish(recipient, payload); - if (this.shouldOutputJson(flags)) { - this.logJsonResult({ published: true, recipient: recipient! }, flags); + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ published: true, recipient }, flags); + } else { + this.log(formatSuccess("Push notification published.")); + } } else { - this.log(formatSuccess("Push notification published.")); + const channelName = flags.channel!; + await rest.channels + .get(channelName) + .publish({ extras: { push: payload } }); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ published: true, channel: channelName }, flags); + } else { + this.log( + formatSuccess( + `Push notification published to channel: ${formatResource(channelName)}.`, + ), + ); + } } } catch (error) { this.fail(error, flags as BaseFlags, "pushPublish"); diff --git a/test/e2e/push/publish-e2e.test.ts b/test/e2e/push/publish-e2e.test.ts index 27296e31..e4b256fd 100644 --- a/test/e2e/push/publish-e2e.test.ts +++ b/test/e2e/push/publish-e2e.test.ts @@ -196,7 +196,7 @@ describe.skipIf(SHOULD_SKIP_E2E)("Push Publish E2E Tests", () => { ); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("A recipient is required"); + expect(result.stderr).toContain("A target is required"); }); it("should error when both device-id and client-id provided", async () => { diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index 7f015302..01f8a39a 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -16,7 +16,7 @@ describe("push:publish command", () => { standardArgValidationTests("push:publish", import.meta.url); describe("argument validation", () => { - it("should require a recipient", async () => { + it("should require a recipient or channel name", async () => { const { error } = await runCommand( ["push:publish", "--title", "Hello"], import.meta.url, @@ -30,6 +30,7 @@ describe("push:publish command", () => { "--json", "--device-id", "--client-id", + "--channel", "--title", "--body", "--payload", @@ -101,6 +102,65 @@ describe("push:publish command", () => { ); }); + it("should publish via channel wrapping payload in extras.push", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("my-channel"); + + const { stdout } = await runCommand( + [ + "push:publish", + "--channel", + "my-channel", + "--title", + "Hello", + "--body", + "World", + ], + import.meta.url, + ); + + expect(stdout).toContain("published"); + expect(channel.publish).toHaveBeenCalledWith( + expect.objectContaining({ + extras: { + push: expect.objectContaining({ + notification: expect.objectContaining({ + title: "Hello", + body: "World", + }), + }), + }, + }), + ); + expect(mock.push.admin.publish).not.toHaveBeenCalled(); + }); + + it("should ignore --channel when --device-id is also provided", async () => { + const mock = getMockAblyRest(); + + const { stdout, stderr } = await runCommand( + [ + "push:publish", + "--device-id", + "dev-1", + "--channel", + "my-channel", + "--title", + "Hello", + ], + import.meta.url, + ); + + expect(stdout + stderr).toContain("ignored"); + expect(mock.push.admin.publish).toHaveBeenCalledWith( + { deviceId: "dev-1" }, + expect.anything(), + ); + expect( + mock.channels._getChannel("my-channel").publish, + ).not.toHaveBeenCalled(); + }); + it("should output JSON when requested", async () => { const { stdout } = await runCommand( ["push:publish", "--device-id", "dev-1", "--title", "Hi", "--json"], @@ -112,6 +172,17 @@ describe("push:publish command", () => { expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("published", true); }); + + it("should output JSON with channel when publishing via channel", async () => { + const { stdout } = await runCommand( + ["push:publish", "--channel", "my-channel", "--title", "Hi", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("published", true); + expect(result).toHaveProperty("channel", "my-channel"); + }); }); describe("error handling", () => { @@ -127,6 +198,20 @@ describe("push:publish command", () => { expect(error).toBeDefined(); }); + it("should handle channel publish errors", async () => { + const mock = getMockAblyRest(); + mock.channels + ._getChannel("err-channel") + .publish.mockRejectedValue(new Error("Channel error")); + + const { error } = await runCommand( + ["push:publish", "--channel", "err-channel", "--title", "Hi"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + it("should handle invalid JSON in --payload", async () => { const { error } = await runCommand( ["push:publish", "--device-id", "dev-1", "--payload", "not-json"],