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
714 changes: 665 additions & 49 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
18 changes: 18 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 Expand Up @@ -1465,6 +1468,21 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
}
}

/**
* Parse a flag value as a JSON object. Rejects arrays and primitives.
*/
protected parseJsonObjectFlag(
value: string,
flagName: string,
flags: BaseFlags = {},
): Record<string, unknown> {
const parsed = this.parseJsonFlag(value, flagName, flags);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
this.fail(`${flagName} must be a JSON object`, flags, "parse");
}
return parsed as Record<string, unknown>;
}

/**
* Unified error handler for command catch blocks.
* Logs the error event, preserves structured error data (Ably codes, HTTP status),
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