Skip to content
705 changes: 661 additions & 44 deletions README.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion docs/Project-Structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ This document outlines the directory structure of the Ably CLI project.
│ │ ├── connections/ # Client connections (test)
│ │ ├── integrations/ # Integration rules
│ │ ├── logs/ # Log streams (subscribe, history, push subscribe)
│ │ ├── push/ # Push notifications
│ │ │ ├── config/ # Push config (show, set-apns, set-fcm, clear-apns, clear-fcm)
│ │ │ ├── devices/ # Device registrations (list, get, save, remove, remove-where)
│ │ │ └── channels/ # Channel subscriptions (list, list-channels, save, remove, remove-where)
│ │ ├── queues/ # Queue management
│ │ ├── rooms/ # Ably Chat rooms (send, subscribe, presence, reactions, typing, etc.)
│ │ ├── spaces/ # Ably Spaces (members, cursors, locations, locks)
Expand Down Expand Up @@ -112,7 +116,8 @@ This document outlines the directory structure of the Ably CLI project.
│ │ ├── standard-tests.ts # Standard test generators for the 5 required describe blocks
│ │ └── control-api-test-helpers.ts # Shared helpers for Control API nock tests (nockControl, getControlApiContext)
│ ├── fixtures/ # Mock data factories
│ │ └── control-api.ts # Mock factory functions for Control API response bodies (mockApp, mockKey, mockRule, mockQueue, mockNamespace, mockStats)
│ │ ├── control-api.ts # Mock factory functions for Control API response bodies (mockApp, mockKey, mockRule, mockQueue, mockNamespace, mockStats)
│ │ └── push/ # Push notification test fixtures (P8 key, FCM service account)
│ ├── unit/ # Fast, mocked tests
│ │ ├── setup.ts # Unit test setup
│ │ ├── base/ # Base command class tests
Expand All @@ -139,6 +144,7 @@ This document outlines the directory structure of the Ably CLI project.
│ │ ├── interactive/ # Interactive mode E2E tests
│ │ ├── rooms/ # Chat rooms E2E tests
│ │ ├── spaces/ # Spaces E2E tests
│ │ ├── push/ # Push notification E2E tests
│ │ ├── stats/ # Stats E2E tests
│ │ └── web-cli/ # Playwright browser tests for Web CLI
│ └── manual/ # Manual test scripts
Expand Down
3 changes: 3 additions & 0 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export const WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS = [
"integrations*",
"queues*",

// Push notification management is not available to anonymous users
"push*",

// Stats commands expose account/app usage data
"stats*",
];
Expand Down
1 change: 0 additions & 1 deletion src/chat-base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { ChatClient, Room, RoomStatus } from "@ably/chat";
import { AblyBaseCommand } from "./base-command.js";
import { productApiFlags } from "./flags.js";
import { BaseFlags } from "./types/cli.js";
import chalk from "chalk";

import {
formatSuccess,
Expand Down
11 changes: 11 additions & 0 deletions src/commands/apps/set-apns-p12.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
formatProgress,
formatResource,
formatSuccess,
formatWarning,
} from "../../utils/output.js";

export default class AppsSetApnsP12Command extends ControlBaseCommand {
Expand All @@ -21,6 +22,8 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand {
static description =
"Upload Apple Push Notification Service P12 certificate for an app";

static hidden = true;

static examples = [
"$ ably apps set-apns-p12 app-id --certificate /path/to/certificate.p12",
'$ ably apps set-apns-p12 app-id --certificate /path/to/certificate.p12 --password "YOUR_CERTIFICATE_PASSWORD"',
Expand All @@ -47,6 +50,14 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand {
async run(): Promise<void> {
const { args, flags } = await this.parse(AppsSetApnsP12Command);

if (!this.shouldOutputJson(flags)) {
this.logToStderr(
formatWarning(
'This command is deprecated. Use "ably push config set-apns" instead.',
),
);
}

// Display authentication information
this.showAuthInfoIfNeeded(flags);

Expand Down
196 changes: 196 additions & 0 deletions src/commands/push/batch-publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Flags } from "@oclif/core";
import * as fs from "node:fs";
import * as path from "node:path";

import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags } from "../../flags.js";
import { BaseFlags } from "../../types/cli.js";
import {
formatCountLabel,
formatProgress,
formatSuccess,
} from "../../utils/output.js";

export default class PushBatchPublish extends AblyBaseCommand {
static override description =
"Publish push notifications to multiple recipients in a batch";

static override examples = [
'<%= config.bin %> <%= command.id %> --payload \'[{"recipient":{"deviceId":"dev1"},"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",
];

static override flags = {
...productApiFlags,
payload: Flags.string({
description: "Batch payload as JSON array, @filepath, or - for stdin",
required: true,
}),
};

async run(): Promise<void> {
const { flags } = await this.parse(PushBatchPublish);

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

let jsonString: string;

if (flags.payload === "-") {
// Read from stdin
jsonString = await this.readStdin();
} else if (flags.payload.startsWith("@")) {
const filePath = path.resolve(flags.payload.slice(1));
if (!fs.existsSync(filePath)) {
this.fail(
`File not found: ${filePath}`,
flags as BaseFlags,
"pushBatchPublish",
);
}
jsonString = fs.readFileSync(filePath, "utf8");
} else if (
flags.payload.startsWith("/") ||
flags.payload.startsWith("./") ||
flags.payload.startsWith("../")
) {
const filePath = path.resolve(flags.payload);
if (!fs.existsSync(filePath)) {
this.fail(
`File not found: ${filePath}`,
flags as BaseFlags,
"pushBatchPublish",
);
}
jsonString = fs.readFileSync(filePath, "utf8");
} else {
jsonString = flags.payload;
}

let batchPayload: unknown[];
try {
batchPayload = JSON.parse(jsonString) as unknown[];
} catch {
this.fail(
"Payload must be a valid JSON array",
flags as BaseFlags,
"pushBatchPublish",
);
}

if (!Array.isArray(batchPayload)) {
this.fail(
"Payload must be a JSON array",
flags as BaseFlags,
"pushBatchPublish",
);
}

if (batchPayload.length > 10000) {
this.fail(
"Batch payload cannot exceed 10,000 items",
flags as BaseFlags,
"pushBatchPublish",
);
}

for (const [index, item] of batchPayload.entries()) {
const entry = item as Record<string, unknown>;
if (!entry.recipient) {
this.fail(
`Item at index ${index} is missing required "recipient" field`,
flags as BaseFlags,
"pushBatchPublish",
);
}

const itemPayload = entry.payload as
| Record<string, unknown>
| undefined;
if (!itemPayload?.notification && !itemPayload?.data) {
this.fail(
`Item at index ${index} must have a "payload.notification" or "payload.data" field`,
flags as BaseFlags,
"pushBatchPublish",
);
}
}

if (!this.shouldOutputJson(flags)) {
this.log(
formatProgress(
`Publishing batch of ${formatCountLabel(batchPayload.length, "notification")}`,
),
);
}

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<string, unknown>[];
const failed = items.filter(
(item) => item.error || (item.statusCode && item.statusCode !== 200),
);
const succeeded =
items.length > 0 ? items.length - failed.length : batchPayload.length;

if (this.shouldOutputJson(flags)) {
this.logJsonResult(
{
published: true,
total: batchPayload.length,
succeeded,
failed: failed.length,
...(failed.length > 0 ? { failedItems: failed } : {}),
},
flags,
);
} else {
if (failed.length > 0) {
this.log(
formatSuccess(
`Batch published: ${succeeded} succeeded, ${failed.length} failed out of ${formatCountLabel(batchPayload.length, "notification")}.`,
),
);
for (const item of failed) {
const error = item.error as Record<string, unknown> | undefined;
const message = error?.message ?? "Unknown error";
const code = error?.code ? ` (code: ${error.code})` : "";
this.logToStderr(` Failed: ${message}${code}`);
}
} else {
this.log(
formatSuccess(
`Batch of ${formatCountLabel(batchPayload.length, "notification")} published.`,
),
);
}
}
} catch (error) {
this.fail(error, flags as BaseFlags, "pushBatchPublish");
}
}

private async readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => {
resolve(data);
});
process.stdin.on("error", reject);
});
}
}
15 changes: 15 additions & 0 deletions src/commands/push/channels/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BaseTopicCommand } from "../../../base-topic-command.js";

export default class PushChannels extends BaseTopicCommand {
protected topicName = "push:channels";
protected commandGroup = "Push channel subscription";

static override description =
"Manage push notification channel subscriptions";

static override examples = [
"<%= config.bin %> <%= command.id %> list --channel my-channel",
"<%= config.bin %> <%= command.id %> save --channel my-channel --device-id device-123",
"<%= config.bin %> <%= command.id %> list-channels",
];
}
77 changes: 77 additions & 0 deletions src/commands/push/channels/list-channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Flags } from "@oclif/core";

import { AblyBaseCommand } from "../../../base-command.js";
import { productApiFlags } from "../../../flags.js";
import { BaseFlags } from "../../../types/cli.js";
import {
formatCountLabel,
formatLimitWarning,
formatProgress,
formatResource,
formatSuccess,
} from "../../../utils/output.js";

export default class PushChannelsListChannels extends AblyBaseCommand {
static override description = "List channels with push subscriptions";

static override examples = [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --limit 50",
"<%= config.bin %> <%= command.id %> --json",
];

static override flags = {
...productApiFlags,
limit: Flags.integer({
description: "Maximum number of results to return (default: 100)",
default: 100,
}),
};

async run(): Promise<void> {
const { flags } = await this.parse(PushChannelsListChannels);

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

if (!this.shouldOutputJson(flags)) {
this.log(formatProgress("Fetching channels with push subscriptions"));
}

const result = await rest.push.admin.channelSubscriptions.listChannels({
limit: flags.limit,
});
const channels = result.items;

if (this.shouldOutputJson(flags)) {
this.logJsonResult({ channels }, flags);
return;
}

if (channels.length === 0) {
this.log("No channels with push subscriptions found.");
return;
}

this.log(
formatSuccess(`Found ${formatCountLabel(channels.length, "channel")}.`),
);
this.log("");

for (const channel of channels) {
this.log(` ${formatResource(channel)}`);
}
this.log("");

const limitWarning = formatLimitWarning(
channels.length,
flags.limit,
"channels",
);
if (limitWarning) this.log(limitWarning);
} catch (error) {
this.fail(error, flags as BaseFlags, "pushChannelListChannels");
}
}
}
Loading
Loading