Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 linux-x64 node-v24.14.0
$ ably --help [COMMAND]
USAGE
$ ably COMMAND
Expand Down Expand Up @@ -2913,7 +2913,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)_
Expand Down Expand Up @@ -3476,19 +3476,23 @@ _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 <value> | --client-id <value> | --recipient
<value>] [--title <value>] [--body <value>] [--sound <value>] [--icon <value>] [--badge <value>] [--data <value>]
[--collapse-key <value>] [--ttl <value>] [--payload <value>] [--apns <value>] [--fcm <value>] [--web <value>]
<value>] [--channel <value>] [--title <value>] [--body <value>] [--sound <value>] [--icon <value>] [--badge <value>]
[--data <value>] [--collapse-key <value>] [--ttl <value>] [--payload <value>] [--apns <value>] [--fcm <value>]
[--web <value>] [-f]

FLAGS
-f, --force Skip confirmation prompt when publishing to a channel
-v, --verbose Output verbose logs
--apns=<value> APNs-specific override as JSON
--badge=<value> Notification badge count
--body=<value> Notification body
--channel=<value> 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=<value> Target client ID
--collapse-key=<value> Collapse key for notification grouping
--data=<value> Custom data payload as JSON
Expand All @@ -3505,13 +3509,15 @@ FLAGS
--web=<value> Web push-specific override as JSON
Comment on lines 3482 to 3509
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push publish now includes a --force flag (and channel publishing prompts for confirmation when not using JSON), but the README section for ably push publish doesn’t list --force in USAGE/FLAGS. Please update the generated command docs so help/README stay in sync with the command’s actual flags and behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — regenerated the README in commit 38e102a. The ably push publish section now includes [-f] in the USAGE line and -f, --force Skip confirmation prompt when publishing to a channel in the FLAGS section.


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
Expand Down
78 changes: 67 additions & 11 deletions src/commands/push/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
}),
Expand Down Expand Up @@ -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<void> {
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<string, unknown>;
let recipient: Record<string, unknown> | 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,
);
Expand Down Expand Up @@ -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!;
Copy link
Copy Markdown
Contributor

@sacOO7 sacOO7 Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw I feel, if possible we should also introduce confirmation dialogue saying

Push notification will be sent to all users subscribed to this channel or channels, do you want to publish to all users present on the channel

using promptForConfirmation in the code.

Generally devs tend to test push notifications, if there are lots of users present on the channel in prod environment, all of them will receive test notifications. So, we can make it explicit in the prompt.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, goes for batch-publish on given set of channels

Copy link
Copy Markdown
Contributor Author

@maratal maratal Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Added promptForConfirmation before the channel publish in push publish and batch-publish — it warns that the notification will go to all devices subscribed to the channel, and is skipped in --json mode or with the new --force/-f flag for scripted use.


if (!this.shouldOutputJson(flags) && !flags.force) {
const confirmed = await promptForConfirmation(
`This will send a push notification to all devices subscribed to channel ${formatResource(channelName)}. You can check channel occupancy using \`ably channels occupancy get ${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");
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/push/publish-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
95 changes: 94 additions & 1 deletion test/unit/commands/push/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +30,7 @@ describe("push:publish command", () => {
"--json",
"--device-id",
"--client-id",
"--channel",
"--title",
"--body",
"--payload",
Expand Down Expand Up @@ -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"],
Expand All @@ -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", () => {
Expand All @@ -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"],
Expand Down