From b25c868978aba17653763e86f62804c2c30410bf Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Mon, 23 Mar 2026 19:47:01 +0100 Subject: [PATCH 1/4] feat(push): add --channel-name flag to push publish command When --channel-name is provided without a direct recipient, the push notification is published to the channel with the payload wrapped in extras.push, routing it to push-subscribed devices via the channel. If a direct recipient (--device-id, --client-id, --recipient) is also present, --channel-name is ignored with a warning. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 +++++--- src/commands/push/publish.ts | 58 ++++++++++++++++++++----- test/unit/commands/push/publish.test.ts | 57 +++++++++++++++++++++++- 3 files changed, 115 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 22f37a3c..cf08ddee 100644 --- a/README.md +++ b/README.md @@ -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-name ] [--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-name= 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-name 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..75630517 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-name 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-name": 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-name"]) { 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-name"]) { + this.log( + formatWarning( + "--channel-name 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,27 @@ 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-name"]!; + 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/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index 7f015302..c945415b 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-name", "--title", "--body", "--payload", @@ -101,6 +102,37 @@ 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-name", "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-name when --device-id is also provided", async () => { + const mock = getMockAblyRest(); + + const { stdout, stderr } = await runCommand( + ["push:publish", "--device-id", "dev-1", "--channel-name", "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 +144,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-name", "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 +170,18 @@ 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-name", "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"], From a771afbbcbf7f31082ed50df8084b3d2c3e1e7a9 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Mon, 23 Mar 2026 19:51:56 +0100 Subject: [PATCH 2/4] fix(lint): fix prettier formatting in push publish command and tests Co-Authored-By: Claude Sonnet 4.6 --- src/commands/push/publish.ts | 4 +- test/unit/commands/push/publish.test.ts | 51 +++++++++++++++++++++---- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index 75630517..b008f3d3 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -233,7 +233,9 @@ export default class PushPublish extends AblyBaseCommand { } } else { const channelName = flags["channel-name"]!; - await rest.channels.get(channelName).publish({ extras: { push: payload } }); + await rest.channels + .get(channelName) + .publish({ extras: { push: payload } }); if (this.shouldOutputJson(flags)) { this.logJsonResult({ published: true, channel: channelName }, flags); diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index c945415b..4720b831 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -107,14 +107,29 @@ describe("push:publish command", () => { const channel = mock.channels._getChannel("my-channel"); const { stdout } = await runCommand( - ["push:publish", "--channel-name", "my-channel", "--title", "Hello", "--body", "World"], + [ + "push:publish", + "--channel-name", + "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" }) }) }, + extras: { + push: expect.objectContaining({ + notification: expect.objectContaining({ + title: "Hello", + body: "World", + }), + }), + }, }), ); expect(mock.push.admin.publish).not.toHaveBeenCalled(); @@ -124,13 +139,26 @@ describe("push:publish command", () => { const mock = getMockAblyRest(); const { stdout, stderr } = await runCommand( - ["push:publish", "--device-id", "dev-1", "--channel-name", "my-channel", "--title", "Hello"], + [ + "push:publish", + "--device-id", + "dev-1", + "--channel-name", + "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(); + 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 () => { @@ -147,7 +175,14 @@ describe("push:publish command", () => { it("should output JSON with channel when publishing via channel", async () => { const { stdout } = await runCommand( - ["push:publish", "--channel-name", "my-channel", "--title", "Hi", "--json"], + [ + "push:publish", + "--channel-name", + "my-channel", + "--title", + "Hi", + "--json", + ], import.meta.url, ); @@ -172,7 +207,9 @@ describe("push:publish command", () => { it("should handle channel publish errors", async () => { const mock = getMockAblyRest(); - mock.channels._getChannel("err-channel").publish.mockRejectedValue(new Error("Channel error")); + mock.channels + ._getChannel("err-channel") + .publish.mockRejectedValue(new Error("Channel error")); const { error } = await runCommand( ["push:publish", "--channel-name", "err-channel", "--title", "Hi"], From d12466ec812c6949f9099cd029f87a36c34f6be7 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Mon, 23 Mar 2026 20:04:19 +0100 Subject: [PATCH 3/4] fix(e2e): update push publish e2e test to match new error message Co-Authored-By: Claude Sonnet 4.6 --- test/e2e/push/publish-e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 () => { From 27c385118f7536146ae43b02d11e02a2902033e3 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Mon, 23 Mar 2026 23:13:37 +0100 Subject: [PATCH 4/4] fix(push): rename --channel-name to --channel and guard warning for --json - Rename flag to --channel for consistency with other commands (per Andy) - Wrap --channel-ignored warning in !shouldOutputJson() guard to avoid polluting machine-readable output (per CodeRabbit/Andy) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 12 ++++++------ src/commands/push/publish.ts | 12 ++++++------ test/unit/commands/push/publish.test.ts | 19 ++++++------------- 3 files changed, 18 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index cf08ddee..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 @@ -3512,16 +3512,16 @@ Publish a push notification to a device, client, or channel ``` USAGE $ ably push publish [-v] [--json | --pretty-json] [--device-id | --client-id | --recipient - ] [--channel-name ] [--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-name= Target channel name (publishes push notification via the channel using extras.push; + --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 @@ -3546,7 +3546,7 @@ EXAMPLES $ ably push publish --client-id client-1 --title Hello --body World - $ ably push publish --channel-name my-channel --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"}}' diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index b008f3d3..728403ce 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -19,7 +19,7 @@ export default class PushPublish extends AblyBaseCommand { 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-name my-channel --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", @@ -39,7 +39,7 @@ export default class PushPublish extends AblyBaseCommand { description: "Raw recipient JSON for advanced targeting", exclusive: ["device-id", "client-id"], }), - "channel-name": Flags.string({ + 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)", }), @@ -88,7 +88,7 @@ export default class PushPublish extends AblyBaseCommand { const hasDirectRecipient = flags["device-id"] || flags["client-id"] || flags.recipient; - if (!hasDirectRecipient && !flags["channel-name"]) { + if (!hasDirectRecipient && !flags.channel) { this.fail( "A target is required: --device-id, --client-id, --recipient, or --channel-name", flags as BaseFlags, @@ -96,10 +96,10 @@ export default class PushPublish extends AblyBaseCommand { ); } - if (hasDirectRecipient && flags["channel-name"]) { + if (hasDirectRecipient && flags.channel && !this.shouldOutputJson(flags)) { this.log( formatWarning( - "--channel-name is ignored when --device-id, --client-id, or --recipient is provided.", + "--channel is ignored when --device-id, --client-id, or --recipient is provided.", ), ); } @@ -232,7 +232,7 @@ export default class PushPublish extends AblyBaseCommand { this.log(formatSuccess("Push notification published.")); } } else { - const channelName = flags["channel-name"]!; + const channelName = flags.channel!; await rest.channels .get(channelName) .publish({ extras: { push: payload } }); diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index 4720b831..01f8a39a 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -30,7 +30,7 @@ describe("push:publish command", () => { "--json", "--device-id", "--client-id", - "--channel-name", + "--channel", "--title", "--body", "--payload", @@ -109,7 +109,7 @@ describe("push:publish command", () => { const { stdout } = await runCommand( [ "push:publish", - "--channel-name", + "--channel", "my-channel", "--title", "Hello", @@ -135,7 +135,7 @@ describe("push:publish command", () => { expect(mock.push.admin.publish).not.toHaveBeenCalled(); }); - it("should ignore --channel-name when --device-id is also provided", async () => { + it("should ignore --channel when --device-id is also provided", async () => { const mock = getMockAblyRest(); const { stdout, stderr } = await runCommand( @@ -143,7 +143,7 @@ describe("push:publish command", () => { "push:publish", "--device-id", "dev-1", - "--channel-name", + "--channel", "my-channel", "--title", "Hello", @@ -175,14 +175,7 @@ describe("push:publish command", () => { it("should output JSON with channel when publishing via channel", async () => { const { stdout } = await runCommand( - [ - "push:publish", - "--channel-name", - "my-channel", - "--title", - "Hi", - "--json", - ], + ["push:publish", "--channel", "my-channel", "--title", "Hi", "--json"], import.meta.url, ); @@ -212,7 +205,7 @@ describe("push:publish command", () => { .publish.mockRejectedValue(new Error("Channel error")); const { error } = await runCommand( - ["push:publish", "--channel-name", "err-channel", "--title", "Hi"], + ["push:publish", "--channel", "err-channel", "--title", "Hi"], import.meta.url, );