From b25c868978aba17653763e86f62804c2c30410bf Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Mon, 23 Mar 2026 19:47:01 +0100 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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, ); From f7d934824a55f3d20b4bba3293b28fe6dd7a3d02 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 26 Mar 2026 14:50:20 +0100 Subject: [PATCH 5/6] =?UTF-8?q?fix(push):=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20fix=20error=20message=20flag=20name=20and=20emit=20?= =?UTF-8?q?channel-ignored=20warning=20in=20JSON=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/commands/push/publish.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index 728403ce..4fa43c32 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -90,18 +90,20 @@ export default class PushPublish extends AblyBaseCommand { if (!hasDirectRecipient && !flags.channel) { this.fail( - "A target is required: --device-id, --client-id, --recipient, or --channel-name", + "A target is required: --device-id, --client-id, --recipient, or --channel", 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.", - ), - ); + if (hasDirectRecipient && flags.channel) { + const channelIgnoredWarning = + "--channel is ignored when --device-id, --client-id, or --recipient is provided."; + if (this.shouldOutputJson(flags)) { + this.logJsonStatus("warning", channelIgnoredWarning, flags); + } else { + this.log(formatWarning(channelIgnoredWarning)); + } } try { From 2b2d0228cc994e8f76c211e711e472bf6bd1e5fe Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Thu, 26 Mar 2026 17:31:53 +0100 Subject: [PATCH 6/6] feat(push): add channel support to batch-publish and confirmation prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch-publish now accepts items with a "channel" field as an alternative to "recipient"; channel items are published via extras.push on the channel (the /push/batch/publish API only accepts recipient-based items) - If both "channel" and "recipient" are present in a batch item, "channel" is ignored with a warning (consistent with push publish behaviour) - Added promptForConfirmation before publishing to channels in both push publish and push batch-publish, as channel publishes reach all subscribed devices — skipped in --json mode and with new --force/-f flag - Added --force flag to push publish and push batch-publish to skip the confirmation prompt in scripts/CI Co-Authored-By: Claude Sonnet 4.6 --- src/commands/push/batch-publish.ts | 151 ++++++++++++++---- src/commands/push/publish.ts | 16 ++ test/unit/commands/push/batch-publish.test.ts | 90 ++++++++++- test/unit/commands/push/publish.test.ts | 10 +- 4 files changed, 236 insertions(+), 31 deletions(-) diff --git a/src/commands/push/batch-publish.ts b/src/commands/push/batch-publish.ts index 493b3f08..c7d9f174 100644 --- a/src/commands/push/batch-publish.ts +++ b/src/commands/push/batch-publish.ts @@ -8,8 +8,11 @@ import { BaseFlags } from "../../types/cli.js"; import { formatCountLabel, formatProgress, + formatResource, formatSuccess, + formatWarning, } from "../../utils/output.js"; +import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class PushBatchPublish extends AblyBaseCommand { static override description = @@ -17,6 +20,7 @@ export default class PushBatchPublish extends AblyBaseCommand { static override examples = [ '<%= config.bin %> <%= command.id %> --payload \'[{"recipient":{"deviceId":"dev1"},"payload":{"notification":{"title":"Hello","body":"World"}}}]\'', + '<%= config.bin %> <%= command.id %> --payload \'[{"channel":"my-channel","payload":{"notification":{"title":"Hello","body":"World"}}}]\'', "<%= config.bin %> <%= command.id %> --payload @batch.json", "cat batch.json | <%= config.bin %> <%= command.id %> --payload -", "<%= config.bin %> <%= command.id %> --payload @batch.json --json", @@ -28,6 +32,10 @@ export default class PushBatchPublish extends AblyBaseCommand { description: "Batch payload as JSON array, @filepath, or - for stdin", required: true, }), + force: Flags.boolean({ + char: "f", + description: "Skip confirmation prompt when publishing to channels", + }), }; async run(): Promise { @@ -97,11 +105,31 @@ export default class PushBatchPublish extends AblyBaseCommand { ); } + type RecipientItem = { + recipient: Record; + payload: Record; + }; + type ChannelItem = { channel: string; payload: Record }; + + const recipientItems: RecipientItem[] = []; + const channelItemsList: ChannelItem[] = []; + for (const [index, item] of batchPayload.entries()) { const entry = item as Record; - if (!entry.recipient) { + + if (entry.recipient && entry.channel) { + // Both present — channel is ignored, warn the user + const msg = `Item at index ${index}: "channel" is ignored when "recipient" is also provided.`; + if (this.shouldOutputJson(flags)) { + this.logJsonStatus("warning", msg, flags); + } else { + this.log(formatWarning(msg)); + } + } + + if (!entry.recipient && !entry.channel) { this.fail( - `Item at index ${index} is missing required "recipient" field`, + `Item at index ${index} is missing required "recipient" or "channel" field`, flags as BaseFlags, "pushBatchPublish", ); @@ -117,6 +145,39 @@ export default class PushBatchPublish extends AblyBaseCommand { "pushBatchPublish", ); } + + if (entry.recipient) { + recipientItems.push({ + recipient: entry.recipient as Record, + payload: itemPayload!, + }); + } else { + channelItemsList.push({ + channel: entry.channel as string, + payload: itemPayload!, + }); + } + } + + // Prompt for confirmation when publishing to channels (non-JSON, non-force mode only) + if ( + channelItemsList.length > 0 && + !this.shouldOutputJson(flags) && + !flags.force + ) { + const uniqueChannels = [ + ...new Set(channelItemsList.map((i) => i.channel)), + ]; + const channelList = uniqueChannels + .map((c) => formatResource(c)) + .join(", "); + const confirmed = await promptForConfirmation( + `This will publish push notifications to ${formatCountLabel(channelItemsList.length, "item")} targeting ${channelList}. Continue?`, + ); + if (!confirmed) { + this.log("Batch publish cancelled."); + return; + } } if (!this.shouldOutputJson(flags)) { @@ -127,50 +188,84 @@ export default class PushBatchPublish extends AblyBaseCommand { ); } - const response = await rest.request( - "post", - "/push/batch/publish", - 2, - null, - batchPayload, - ); - - // Parse response items for success/failure counts - const items = (response.items ?? []) as Record[]; - const failed = items.filter( - (item) => item.error || (item.statusCode && item.statusCode !== 200), - ); - const succeeded = - items.length > 0 ? items.length - failed.length : batchPayload.length; + let succeeded = 0; + const failedItems: { index: number; error: string }[] = []; + + // Publish recipient-based items via /push/batch/publish + if (recipientItems.length > 0) { + const response = await rest.request( + "post", + "/push/batch/publish", + 2, + null, + recipientItems, + ); + const responseItems = (response.items ?? []) as Record< + string, + unknown + >[]; + for (const [i, result] of responseItems.entries()) { + if ( + result.error || + (result.statusCode && result.statusCode !== 200) + ) { + const err = result.error as Record | undefined; + failedItems.push({ + index: i, + error: String(err?.message ?? "Unknown error"), + }); + } else { + succeeded++; + } + } + if (responseItems.length === 0) { + succeeded += recipientItems.length; + } + } + + // Publish channel-based items via channel extras.push + for (const [i, item] of channelItemsList.entries()) { + try { + await rest.channels + .get(item.channel) + .publish({ extras: { push: item.payload } }); + succeeded++; + } catch (error) { + failedItems.push({ + index: recipientItems.length + i, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const total = batchPayload.length; + const failedCount = failedItems.length; if (this.shouldOutputJson(flags)) { this.logJsonResult( { published: true, - total: batchPayload.length, + total, succeeded, - failed: failed.length, - ...(failed.length > 0 ? { failedItems: failed } : {}), + failed: failedCount, + ...(failedCount > 0 ? { failedItems } : {}), }, flags, ); } else { - if (failed.length > 0) { + if (failedCount > 0) { this.log( formatSuccess( - `Batch published: ${succeeded} succeeded, ${failed.length} failed out of ${formatCountLabel(batchPayload.length, "notification")}.`, + `Batch published: ${succeeded} succeeded, ${failedCount} failed out of ${formatCountLabel(total, "notification")}.`, ), ); - for (const item of failed) { - const error = item.error as Record | undefined; - const message = error?.message ?? "Unknown error"; - const code = error?.code ? ` (code: ${error.code})` : ""; - this.logToStderr(` Failed: ${message}${code}`); + for (const item of failedItems) { + this.logToStderr(` Failed (index ${item.index}): ${item.error}`); } } else { this.log( formatSuccess( - `Batch of ${formatCountLabel(batchPayload.length, "notification")} published.`, + `Batch of ${formatCountLabel(total, "notification")} published.`, ), ); } diff --git a/src/commands/push/publish.ts b/src/commands/push/publish.ts index 4fa43c32..150f4ab2 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -11,6 +11,7 @@ import { formatSuccess, formatWarning, } from "../../utils/output.js"; +import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class PushPublish extends AblyBaseCommand { static override description = @@ -80,6 +81,10 @@ export default class PushPublish extends AblyBaseCommand { web: Flags.string({ description: "Web push-specific override as JSON", }), + force: Flags.boolean({ + char: "f", + description: "Skip confirmation prompt when publishing to a channel", + }), }; async run(): Promise { @@ -235,6 +240,17 @@ export default class PushPublish extends AblyBaseCommand { } } else { const channelName = flags.channel!; + + if (!this.shouldOutputJson(flags) && !flags.force) { + const confirmed = await promptForConfirmation( + `This will send a push notification to all devices subscribed to channel ${formatResource(channelName)}. Continue?`, + ); + if (!confirmed) { + this.log("Publish cancelled."); + return; + } + } + await rest.channels .get(channelName) .publish({ extras: { push: payload } }); diff --git a/test/unit/commands/push/batch-publish.test.ts b/test/unit/commands/push/batch-publish.test.ts index 064ee686..22fd3e79 100644 --- a/test/unit/commands/push/batch-publish.test.ts +++ b/test/unit/commands/push/batch-publish.test.ts @@ -20,7 +20,7 @@ describe("push:batch-publish command", () => { ]); describe("functionality", () => { - it("should batch publish notifications", async () => { + it("should batch publish notifications via recipient", async () => { const mock = getMockAblyRest(); const payload = '[{"recipient":{"deviceId":"dev-1"},"payload":{"notification":{"title":"Hello"}}}]'; @@ -40,6 +40,52 @@ describe("push:batch-publish command", () => { ); }); + it("should batch publish notifications via channel with --force", async () => { + const mock = getMockAblyRest(); + const payload = + '[{"channel":"my-channel","payload":{"notification":{"title":"Hello"}}}]'; + + const { stdout } = await runCommand( + ["push:batch-publish", "--payload", payload, "--force"], + import.meta.url, + ); + + expect(stdout).toContain("published"); + const channel = mock.channels._getChannel("my-channel"); + expect(channel.publish).toHaveBeenCalledWith( + expect.objectContaining({ + extras: { push: { notification: { title: "Hello" } } }, + }), + ); + }); + + it("should skip confirmation for channel items when --json is used", async () => { + getMockAblyRest(); + const payload = + '[{"channel":"my-channel","payload":{"notification":{"title":"Hello"}}}]'; + + const { stdout } = await runCommand( + ["push:batch-publish", "--payload", payload, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("published", true); + }); + + it("should warn and ignore channel when both recipient and channel are provided", async () => { + getMockAblyRest(); + const payload = + '[{"recipient":{"deviceId":"dev-1"},"channel":"my-channel","payload":{"notification":{"title":"Hello"}}}]'; + + const { stdout } = await runCommand( + ["push:batch-publish", "--payload", payload], + import.meta.url, + ); + + expect(stdout).toContain("ignored"); + }); + it("should output JSON when requested", async () => { getMockAblyRest(); const payload = @@ -88,7 +134,7 @@ describe("push:batch-publish command", () => { expect(error).toBeDefined(); }); - it("should reject items missing recipient", async () => { + it("should reject items missing both recipient and channel", async () => { const payload = '[{"notification":{"title":"Hello"}}]'; const { error } = await runCommand( @@ -109,6 +155,46 @@ describe("push:batch-publish command", () => { expect(error).toBeDefined(); }); + + it("should reject channel items missing notification and data", async () => { + const payload = '[{"channel":"my-channel","payload":{}}]'; + + const { error } = await runCommand( + ["push:batch-publish", "--payload", payload, "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); + }); + + describe("flags", () => { + it("should have --payload flag", async () => { + const { stdout } = await runCommand( + ["push:batch-publish", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("--payload"); + }); + + it("should have --json flag", async () => { + const { stdout } = await runCommand( + ["push:batch-publish", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("--json"); + }); + + it("should have --force flag", async () => { + const { stdout } = await runCommand( + ["push:batch-publish", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("--force"); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/push/publish.test.ts b/test/unit/commands/push/publish.test.ts index 01f8a39a..1f019707 100644 --- a/test/unit/commands/push/publish.test.ts +++ b/test/unit/commands/push/publish.test.ts @@ -115,6 +115,7 @@ describe("push:publish command", () => { "Hello", "--body", "World", + "--force", ], import.meta.url, ); @@ -205,7 +206,14 @@ describe("push:publish command", () => { .publish.mockRejectedValue(new Error("Channel error")); const { error } = await runCommand( - ["push:publish", "--channel", "err-channel", "--title", "Hi"], + [ + "push:publish", + "--channel", + "err-channel", + "--title", + "Hi", + "--force", + ], import.meta.url, );