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
19 changes: 13 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 darwin-arm64 node-v22.14.0
$ ably --help [COMMAND]
USAGE
$ ably COMMAND
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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)_
Expand Down Expand Up @@ -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 <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>]

FLAGS
-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 @@ -3534,13 +3539,15 @@ FLAGS
--web=<value> 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
Expand Down
60 changes: 49 additions & 11 deletions src/commands/push/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ import * as path from "node:path";
import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags } from "../../flags.js";
import { BaseFlags } from "../../types/cli.js";
import { formatProgress, formatSuccess } from "../../utils/output.js";
import {
formatProgress,
formatResource,
formatSuccess,
formatWarning,
} from "../../utils/output.js";

export default class PushPublish extends AblyBaseCommand {
static override description =
"Publish a push notification to a device or client";
"Publish a push notification to a device, client, or channel";

static override examples = [
"<%= config.bin %> <%= command.id %> --device-id device-123 --title Hello --body World",
"<%= config.bin %> <%= command.id %> --client-id client-1 --title Hello --body World",
"<%= config.bin %> <%= command.id %> --channel my-channel --title Hello --body World",
'<%= config.bin %> <%= command.id %> --device-id device-123 --payload \'{"notification":{"title":"Hello","body":"World"}}\'',
'<%= config.bin %> <%= command.id %> --recipient \'{"transportType":"apns","deviceToken":"token123"}\' --title Hello --body World',
"<%= config.bin %> <%= command.id %> --device-id device-123 --title Hello --body World --json",
Expand All @@ -33,6 +39,10 @@ export default class PushPublish extends AblyBaseCommand {
description: "Raw recipient JSON for advanced targeting",
exclusive: ["device-id", "client-id"],
}),
channel: Flags.string({
description:
"Target channel name (publishes push notification via the channel using extras.push; ignored if --device-id, --client-id, or --recipient is also provided)",
}),
title: Flags.string({
description: "Notification title",
}),
Expand Down Expand Up @@ -75,27 +85,38 @@ export default class PushPublish extends AblyBaseCommand {
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-name",
flags as BaseFlags,
"pushPublish",
);
}

if (hasDirectRecipient && flags.channel && !this.shouldOutputJson(flags)) {
this.log(
Copy link
Contributor

Choose a reason for hiding this comment

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

Per coderabbit, this would break json mode

Copy link
Contributor Author

@maratal maratal Mar 23, 2026

Choose a reason for hiding this comment

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

Fixed.

formatWarning(
"--channel is ignored when --device-id, --client-id, or --recipient is provided.",
),
);
}

try {
const rest = await this.createAblyRestClient(flags as BaseFlags);
if (!rest) return;

// Build recipient
let recipient: Record<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 +223,29 @@ export default class PushPublish extends AblyBaseCommand {
this.log(formatProgress("Publishing push notification"));
}

await rest.push.admin.publish(recipient!, payload);
if (recipient) {
await rest.push.admin.publish(recipient, payload);

if (this.shouldOutputJson(flags)) {
this.logJsonResult({ published: true, recipient: recipient! }, flags);
if (this.shouldOutputJson(flags)) {
this.logJsonResult({ published: true, recipient }, flags);
} else {
this.log(formatSuccess("Push notification published."));
}
} else {
this.log(formatSuccess("Push notification published."));
const channelName = flags.channel!;
await rest.channels
.get(channelName)
.publish({ extras: { push: payload } });

if (this.shouldOutputJson(flags)) {
this.logJsonResult({ published: true, channel: channelName }, flags);
} else {
this.log(
formatSuccess(
`Push notification published to channel: ${formatResource(channelName)}.`,
),
);
}
}
} catch (error) {
this.fail(error, flags as BaseFlags, "pushPublish");
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
87 changes: 86 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,65 @@ describe("push:publish command", () => {
);
});

it("should publish via channel wrapping payload in extras.push", async () => {
const mock = getMockAblyRest();
const channel = mock.channels._getChannel("my-channel");

const { stdout } = await runCommand(
[
"push:publish",
"--channel",
"my-channel",
"--title",
"Hello",
"--body",
"World",
],
import.meta.url,
);

expect(stdout).toContain("published");
expect(channel.publish).toHaveBeenCalledWith(
expect.objectContaining({
extras: {
push: expect.objectContaining({
notification: expect.objectContaining({
title: "Hello",
body: "World",
}),
}),
},
}),
);
expect(mock.push.admin.publish).not.toHaveBeenCalled();
});

it("should ignore --channel when --device-id is also provided", async () => {
const mock = getMockAblyRest();

const { stdout, stderr } = await runCommand(
[
"push:publish",
"--device-id",
"dev-1",
"--channel",
"my-channel",
"--title",
"Hello",
],
import.meta.url,
);

expect(stdout + stderr).toContain("ignored");
expect(mock.push.admin.publish).toHaveBeenCalledWith(
{ deviceId: "dev-1" },
expect.anything(),
);
expect(
mock.channels._getChannel("my-channel").publish,
).not.toHaveBeenCalled();
});

it("should output JSON when requested", async () => {
const { stdout } = await runCommand(
["push:publish", "--device-id", "dev-1", "--title", "Hi", "--json"],
Expand All @@ -112,6 +172,17 @@ describe("push:publish command", () => {
expect(result).toHaveProperty("success", true);
expect(result).toHaveProperty("published", true);
});

it("should output JSON with channel when publishing via channel", async () => {
const { stdout } = await runCommand(
["push:publish", "--channel", "my-channel", "--title", "Hi", "--json"],
import.meta.url,
);

const result = JSON.parse(stdout);
expect(result).toHaveProperty("published", true);
expect(result).toHaveProperty("channel", "my-channel");
});
});

describe("error handling", () => {
Expand All @@ -127,6 +198,20 @@ describe("push:publish command", () => {
expect(error).toBeDefined();
});

it("should handle channel publish errors", async () => {
const mock = getMockAblyRest();
mock.channels
._getChannel("err-channel")
.publish.mockRejectedValue(new Error("Channel error"));

const { error } = await runCommand(
["push:publish", "--channel", "err-channel", "--title", "Hi"],
import.meta.url,
);

expect(error).toBeDefined();
});

it("should handle invalid JSON in --payload", async () => {
const { error } = await runCommand(
["push:publish", "--device-id", "dev-1", "--payload", "not-json"],
Expand Down
Loading