Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d788a00
feat(push): Add device registration commands for push notifications
mattheworiordan Jan 6, 2026
638b905
feat(push): Add channel subscription commands for push notifications
mattheworiordan Jan 6, 2026
19bdfba
feat(push): add publish and batch-publish commands
mattheworiordan Jan 6, 2026
c9e69ff
feat(push): add APNs configuration commands for push notifications
mattheworiordan Jan 6, 2026
e65949f
feat(push): add FCM configuration commands
mattheworiordan Jan 6, 2026
03d70cb
Docs update + push fixtures
mattheworiordan Jan 6, 2026
f363506
fix(push): remove duplicate confirm() and fix JSON error exit codes
mattheworiordan Jan 6, 2026
b631503
test(push): add unit tests for push devices commands
mattheworiordan Jan 6, 2026
b272332
test(push): add unit tests for push channels commands
mattheworiordan Jan 6, 2026
af4f4e0
test(push): add unit tests for push publish commands
mattheworiordan Jan 6, 2026
7b45f22
test(push): add unit tests for push config commands
mattheworiordan Jan 6, 2026
a565487
feat(push): update push config to use new Control API field names
mattheworiordan Jan 6, 2026
5d412fa
fix(push): address PR #126 review feedback
mattheworiordan Jan 6, 2026
25d77f7
feat(push): add --use-sandbox flag for APNs configuration
mattheworiordan Jan 7, 2026
6ced92f
Update README to remove redundant apns apps command
mattheworiordan Jan 7, 2026
0c44a3e
fix(push): address PR #126 review feedback for JSON mode and validation
mattheworiordan Jan 7, 2026
f08faff
fix(push): fix JSON output corruption and validate --payload flag con…
mattheworiordan Jan 7, 2026
273ec14
fix(push): rename --client-id to --recipient-client-id and fix legacy…
mattheworiordan Jan 7, 2026
c3e5099
docs: add pattern discovery guidance to CLAUDE.md
mattheworiordan Jan 9, 2026
73ce711
fix(push): address PR #126 review feedback for patterns and conventions
mattheworiordan Jan 9, 2026
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
26 changes: 26 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,32 @@ cat .cursor/rules/AI-Assistance.mdc
5. **Remove tests without asking** - Always get permission first
6. **NODE_ENV** - To check if the CLI is in test mode, use the `isTestMode()` helper function.
7. **`process.exit`** - When creating a command, use `this.exit()` for consistent test mode handling.
8. **Implement common functionality without searching first** - See "Discover Patterns" below.

## 🔍 Discover Patterns Before Implementing (CRITICAL)

This codebase has 50+ commands with established patterns. **Before implementing ANY common functionality, search for how existing code handles it.**

### Before You Write Code

1. **Find a similar command** - If adding `push foo`, look at `channels foo` or `apps foo` first
2. **Search for the problem** - Before writing error handling, confirmation prompts, or any reusable logic: `grep -r "keyword" src/`
3. **Check utilities** - Browse `src/utils/` and `src/base-command.ts` for existing helpers

### Search Examples

| If you need... | Search for... |
|----------------|---------------|
| Error handling in JSON mode | `grep -r "Error.*json\|jsonError" src/` |
| User confirmation prompts | `grep -r "confirm\|prompt" src/utils/` |
| Common flag patterns | Look at 2-3 similar commands |
| Test patterns | Look at existing tests in same directory |

### The Rule

**If you're solving a problem that other commands probably solve, spend 2 minutes searching before implementing.** Finding an existing pattern is faster than inventing one and having it rejected in review.

When in doubt: `grep -r "what-you-need" src/` → read how others did it → follow that pattern.

## ✅ Correct Practices

Expand Down
1,347 changes: 1,116 additions & 231 deletions README.md

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion src/commands/apps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ export default class AppsCommand extends BaseTopicCommand {
"$ ably apps create",
"$ ably apps update",
"$ ably apps delete",
"$ ably apps set-apns-p12",
"$ ably apps stats",
"$ ably apps channel-rules list",
"$ ably apps switch my-app",
Expand Down
85 changes: 0 additions & 85 deletions src/commands/apps/set-apns-p12.ts

This file was deleted.

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

interface BatchItem {
recipient: {
deviceId?: string;
clientId?: string;
};
payload: Record<string, unknown>;
}

export default class PushBatchPublish extends AblyBaseCommand {
static override description =
"Publish push notifications to multiple recipients in a single request (up to 10,000 notifications)";

static override examples = [
// From JSON file
"$ ably push batch-publish --payload ./batch-notifications.json",
// From inline JSON
'$ ably push batch-publish --payload \'[{"recipient":{"deviceId":"abc"},"payload":{"notification":{"title":"Hi"}}}]\'',
// From stdin
"$ cat batch.json | ably push batch-publish --payload -",
];

static override flags = {
...AblyBaseCommand.globalFlags,
payload: Flags.string({
description:
"Batch payload as JSON array, file path, or - for stdin (required)",
required: true,
}),
};

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

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

// Parse batch payload
const batchItems = await this.parseBatchPayload(flags.payload);

// Validate batch items
this.validateBatchItems(batchItems);

if (batchItems.length > 10000) {
this.error("Batch size exceeds maximum of 10,000 notifications");
}

if (batchItems.length === 0) {
this.error("Batch payload is empty");
}

// Publish batch using REST API directly
// The SDK's push.admin.publish doesn't have a batch method,
// so we use the REST API directly
const response = await rest.request(
"POST",
"/push/batch/publish",
4, // API version
{},
batchItems,
{},
);

// Process response
const results = response.items || [];
const successful = results.filter(
(r: Record<string, unknown>) => !r.error,
).length;
const failed = results.filter(
(r: Record<string, unknown>) => r.error,
).length;

if (this.shouldOutputJson(flags)) {
this.log(
this.formatJsonOutput(
{
total: batchItems.length,
successful,
failed,
results: results.map((r: Record<string, unknown>, i: number) => ({
index: i,
success: !r.error,
error: r.error,
})),
success: failed === 0,
timestamp: new Date().toISOString(),
},
flags,
),
);
} else {
this.log(chalk.bold("Batch Push Results\n"));
this.log(`${chalk.dim("Total:")} ${batchItems.length}`);
this.log(
`${chalk.dim("Successful:")} ${chalk.green(successful.toString())}`,
);

if (failed > 0) {
this.log(
`${chalk.dim("Failed:")} ${chalk.red(failed.toString())}`,
);
this.log("");
this.log(chalk.yellow("Failed Notifications:"));

results.forEach((r: Record<string, unknown>, i: number) => {
if (r.error) {
const error = r.error as Record<string, unknown>;
const recipient = batchItems[i]?.recipient;
const recipientStr = recipient?.deviceId
? `deviceId=${recipient.deviceId}`
: recipient?.clientId
? `clientId=${recipient.clientId}`
: "unknown";

this.log(` - Recipient: ${recipientStr}`);
this.log(
` Error: ${error.message || "Unknown error"} (code: ${error.code || "unknown"})`,
);
}
});
} else {
this.log("");
this.log(chalk.green("All notifications sent successfully!"));
}
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorCode = (error as { code?: number }).code;

if (this.shouldOutputJson(flags)) {
this.jsonError(
{
error: errorMessage,
code: errorCode,
success: false,
},
flags,
);
} else {
this.error(`Error publishing batch notifications: ${errorMessage}`);
}
}
}

private async parseBatchPayload(payload: string): Promise<BatchItem[]> {
let jsonString: string;

if (payload === "-") {
// Read from stdin
jsonString = await this.readStdin();
} else if (
!payload.trim().startsWith("[") &&
!payload.trim().startsWith("{")
) {
// It's a file path
const filePath = path.resolve(payload);

if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}

jsonString = fs.readFileSync(filePath, "utf8");
} else {
jsonString = payload;
}

try {
const parsed = JSON.parse(jsonString);

// Ensure it's an array
if (!Array.isArray(parsed)) {
throw new TypeError("Batch payload must be a JSON array");
}

return parsed as BatchItem[];
} catch (error) {
if (error instanceof SyntaxError) {
throw new TypeError("Invalid JSON format in batch payload");
}

throw error;
}
}

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", (err) => {
reject(err);
});

// Set a timeout for stdin reading
setTimeout(() => {
if (data === "") {
reject(new Error("No data received from stdin"));
}
}, 5000);
});
}

private validateBatchItems(items: BatchItem[]): void {
items.forEach((item, index) => {
if (!item) {
throw new Error(`Item ${index}: invalid entry (null or undefined)`);
}

if (!item.recipient) {
throw new Error(`Item ${index}: missing 'recipient' field`);
}

if (!item.recipient.deviceId && !item.recipient.clientId) {
throw new Error(
`Item ${index}: recipient must have either 'deviceId' or 'clientId'`,
);
}

if (!item.payload) {
throw new Error(`Item ${index}: missing 'payload' field`);
}
});
}
}
16 changes: 16 additions & 0 deletions src/commands/push/channels/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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 (maps to push.admin.channelSubscriptions)";

static override examples = [
"<%= config.bin %> <%= command.id %> save --channel alerts --device-id my-device",
"<%= config.bin %> <%= command.id %> list --channel alerts",
"<%= config.bin %> <%= command.id %> list-channels",
"<%= config.bin %> <%= command.id %> remove --channel alerts --device-id my-device",
];
}
Loading
Loading