diff --git a/README.md b/README.md index 425661d1..1978184c 100644 --- a/README.md +++ b/README.md @@ -2907,7 +2907,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)_ @@ -3470,19 +3470,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 @@ -3499,13 +3502,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/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 32268452..150f4ab2 100644 --- a/src/commands/push/publish.ts +++ b/src/commands/push/publish.ts @@ -5,15 +5,22 @@ 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"; +import { promptForConfirmation } from "../../utils/prompt-confirmation.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 +40,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", }), @@ -70,32 +81,49 @@ 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 { 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", flags as BaseFlags, "pushPublish", ); } + 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 { 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 +230,40 @@ 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!; + + 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 } }); + + 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/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 7f015302..1f019707 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,66 @@ 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", + "--force", + ], + 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 +173,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 +199,27 @@ 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", + "--force", + ], + 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"],