From 661aad711b2f129b2e1a488d910f3cfbc392c7b4 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 11 Mar 2026 13:59:16 +0000 Subject: [PATCH 1/2] Add formatWarning output helper and adopt across codebase Replace ad-hoc chalk.yellow(...) warning patterns with a consistent formatWarning() helper from src/utils/output.ts. Adopted in base commands, connections test, interactive mode, and command-not-found hook. --- .claude/skills/ably-new-command/SKILL.md | 3 ++- AGENTS.md | 2 +- src/base-command.ts | 8 ++++---- src/base-topic-command.ts | 3 ++- src/chat-base-command.ts | 8 ++++++-- src/commands/connections/test.ts | 5 ++++- src/commands/interactive.ts | 5 ++--- src/hooks/command_not_found/did-you-mean.ts | 3 ++- src/hooks/command_not_found/prompt-utils.ts | 12 ------------ src/utils/output.ts | 4 ++++ .../commands/interactive-enhanced-simple.test.ts | 2 +- test/unit/utils/output.test.ts | 13 +++++++++++++ 12 files changed, 41 insertions(+), 27 deletions(-) diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index f4799571..c88a8ab8 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -189,6 +189,7 @@ if (this.shouldOutputJson(flags)) { |--------|-------|---------| | `formatProgress(msg)` | Action in progress — appends `...` automatically | `formatProgress("Attaching to channel")` | | `formatSuccess(msg)` | Green checkmark — always end with `.` (period, not `!`) | `formatSuccess("Subscribed to channel " + formatResource(name) + ".")` | +| `formatWarning(msg)` | Yellow `⚠` — for non-fatal warnings. Don't prefix with "Warning:" | `formatWarning("Persistence is automatically enabled.")` | | `formatListening(msg)` | Dim text — auto-appends "Press Ctrl+C to exit." | `formatListening("Listening for messages.")` | | `formatResource(name)` | Cyan — for resource names, never use quotes | `formatResource(channelName)` | | `formatTimestamp(ts)` | Dim `[timestamp]` — for event streams | `formatTimestamp(isoString)` | @@ -370,7 +371,7 @@ pnpm test:unit # Run tests - [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`) - [ ] `clientIdFlag` only if command needs client identity - [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))` -- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`) +- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatWarning`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`) - [ ] `success()` messages end with `.` (period) - [ ] Resource names use `resource(name)`, never quoted - [ ] JSON output uses `logJsonResult()` (one-shot) or `logJsonEvent()` (streaming), not direct `formatJsonRecord()` diff --git a/AGENTS.md b/AGENTS.md index bfa6830e..53eb407c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,7 @@ All output helpers use the `format` prefix and are exported from `src/utils/outp - **Progress**: `formatProgress("Attaching to channel: " + formatResource(name))` — no color on action text, appends `...` automatically. Never manually write `"Doing something..."` — always use `formatProgress("Doing something")`. - **Success**: `formatSuccess("Message published to channel " + formatResource(name) + ".")` — green checkmark, **must** end with `.` (not `!`). Never use `chalk.green(...)` directly — always use `formatSuccess()`. +- **Warnings**: `formatWarning("Message text here.")` — yellow `⚠` symbol. Never use `chalk.yellow("Warning: ...")` directly — always use `formatWarning()`. Don't include "Warning:" prefix in the message — the symbol conveys it. - **Listening**: `formatListening("Listening for messages.")` — dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `formatSuccess()` call — use a separate `formatListening()` call. - **Resource names**: Always `formatResource(name)` (cyan), never quoted — including in `logCliEvent` messages. - **Timestamps**: `formatTimestamp(ts)` — dim `[timestamp]` for event streams. `formatMessageTimestamp(message.timestamp)` — converts Ably message timestamp (number|undefined) to ISO string. @@ -229,7 +230,6 @@ this.error() ← oclif exit (ONLY inside fail, nowhere else) - **`runControlCommand`** returns `Promise` (not nullable) — calls `this.fail()` internally on error. ### Additional output patterns (direct chalk, not helpers) -- **Warnings**: `chalk.yellow("Warning: ...")` — for non-fatal warnings - **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'` ### Help output theme diff --git a/src/base-command.ts b/src/base-command.ts index dbd76ace..38980b03 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -13,7 +13,7 @@ import { CommandError } from "./errors/command-error.js"; import { coreGlobalFlags } from "./flags.js"; import { InteractiveHelper } from "./services/interactive-helper.js"; import { BaseFlags, CommandConfig } from "./types/cli.js"; -import { buildJsonRecord } from "./utils/output.js"; +import { buildJsonRecord, formatWarning } from "./utils/output.js"; import { getCliVersion } from "./utils/version.js"; import Spaces from "@ably/spaces"; import { ChatClient } from "@ably/chat"; @@ -1265,7 +1265,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { // Log timeout only if not in JSON mode if (!this.shouldOutputJson({})) { // TODO: Pass actual flags here - this.log(chalk.yellow("Cleanup operation timed out.")); + this.log(formatWarning("Cleanup operation timed out.")); } reject(new Error("Cleanup timed out")); // Reject promise on timeout }, effectiveTimeout); @@ -1352,7 +1352,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { break; } case "disconnected": { - this.log(chalk.yellow("! Disconnected from Ably")); + this.log(formatWarning("Disconnected from Ably")); break; } case "failed": { @@ -1364,7 +1364,7 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { break; } case "suspended": { - this.log(chalk.yellow("! Connection suspended")); + this.log(formatWarning("Connection suspended")); break; } case "connecting": { diff --git a/src/base-topic-command.ts b/src/base-topic-command.ts index b1c8c183..84ea7a4e 100644 --- a/src/base-topic-command.ts +++ b/src/base-topic-command.ts @@ -3,6 +3,7 @@ import inquirer from "inquirer"; import pkg from "fast-levenshtein"; import { InteractiveBaseCommand } from "./interactive-base-command.js"; import { runInquirerWithReadlineRestore } from "./utils/readline-helper.js"; +import { formatWarning } from "./utils/output.js"; import * as readline from "node:readline"; import { WEB_CLI_RESTRICTED_COMMANDS, @@ -104,7 +105,7 @@ export abstract class BaseTopicCommand extends InteractiveBaseCommand { // In interactive mode, we need to ensure the message is visible // Write directly to stderr to avoid readline interference if (isInteractiveMode) { - process.stderr.write(chalk.yellow(`Warning: ${warningMessage}\n`)); + process.stderr.write(`${formatWarning(warningMessage)}\n`); } else { this.warn(warningMessage); } diff --git a/src/chat-base-command.ts b/src/chat-base-command.ts index 93709048..a4dace6d 100644 --- a/src/chat-base-command.ts +++ b/src/chat-base-command.ts @@ -5,7 +5,11 @@ import { productApiFlags } from "./flags.js"; import { BaseFlags } from "./types/cli.js"; import chalk from "chalk"; -import { formatSuccess, formatListening } from "./utils/output.js"; +import { + formatSuccess, + formatListening, + formatWarning, +} from "./utils/output.js"; import isTestMode from "./utils/test-mode.js"; export abstract class ChatBaseCommand extends AblyBaseCommand { @@ -128,7 +132,7 @@ export abstract class ChatBaseCommand extends AblyBaseCommand { } case RoomStatus.Detached: { if (!this.shouldOutputJson(flags)) { - this.log(chalk.yellow("Disconnected from Ably")); + this.log(formatWarning("Disconnected from Ably")); } break; } diff --git a/src/commands/connections/test.ts b/src/commands/connections/test.ts index 841c5db3..ba78cc77 100644 --- a/src/commands/connections/test.ts +++ b/src/commands/connections/test.ts @@ -8,6 +8,7 @@ import { formatProgress, formatResource, formatSuccess, + formatWarning, } from "../../utils/output.js"; export default class ConnectionsTest extends AblyBaseCommand { @@ -172,7 +173,9 @@ export default class ConnectionsTest extends AblyBaseCommand { ); } else if (partialSuccess) { this.log( - `${chalk.yellow("!")} Some connection tests succeeded, but others failed`, + formatWarning( + "Some connection tests succeeded, but others failed", + ), ); } else { this.log(`${chalk.red("✗")} All connection tests failed`); diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 82fe6cd2..25277120 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -13,6 +13,7 @@ import { INTERACTIVE_UNSUITABLE_COMMANDS, } from "../base-command.js"; import { TerminalDiagnostics } from "../utils/terminal-diagnostics.js"; +import { formatWarning } from "../utils/output.js"; import "../utils/sigint-exit.js"; import isWebCliMode from "../utils/web-mode.js"; @@ -697,9 +698,7 @@ export default class Interactive extends Command { // Warn about unclosed quotes if (inDoubleQuote || inSingleQuote) { const quoteType = inDoubleQuote ? "double" : "single"; - console.error( - chalk.yellow(`Warning: Unclosed ${quoteType} quote in command`), - ); + console.error(formatWarning(`Unclosed ${quoteType} quote in command`)); } return args; diff --git a/src/hooks/command_not_found/did-you-mean.ts b/src/hooks/command_not_found/did-you-mean.ts index 3ad40b1d..05855580 100644 --- a/src/hooks/command_not_found/did-you-mean.ts +++ b/src/hooks/command_not_found/did-you-mean.ts @@ -3,6 +3,7 @@ import chalk from "chalk"; import inquirer from "inquirer"; import pkg from "fast-levenshtein"; import { runInquirerWithReadlineRestore } from "../../utils/readline-helper.js"; +import { formatWarning } from "../../utils/output.js"; import * as readline from "node:readline"; const { get: levenshteinDistance } = pkg; @@ -94,7 +95,7 @@ const hook: Hook<"command_not_found"> = async function (opts) { // Warn about command not found and suggest alternative with colored command names const warningMessage = `${chalk.cyan(displayOriginal.replaceAll(":", " "))} is not an ably command.`; if (isInteractiveMode) { - console.log(chalk.yellow(`Warning: ${warningMessage}`)); + console.log(formatWarning(warningMessage)); } else { this.warn(warningMessage); } diff --git a/src/hooks/command_not_found/prompt-utils.ts b/src/hooks/command_not_found/prompt-utils.ts index 9c4d4902..c75e9a7b 100644 --- a/src/hooks/command_not_found/prompt-utils.ts +++ b/src/hooks/command_not_found/prompt-utils.ts @@ -55,18 +55,6 @@ export function formatPromptMessage( // This file now only contains utility functions if needed, // or can be removed if formatPromptMessage is moved/inlined. -// Example of how you might use chalk for other styling if needed: -// export function formatError(text: string): string { -// return chalk.red(text); -// } - -// export function formatWarning(text: string): string { -// return chalk.yellow(text); -// } - -// Example utility function using chalk (can be adapted or removed) export function formatSuggestion(suggestion: string): string { return chalk.blueBright(suggestion); } - -// You can add other prompt-related utility functions here if needed. diff --git a/src/utils/output.ts b/src/utils/output.ts index e32e8bfa..65430085 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -8,6 +8,10 @@ export function formatSuccess(message: string): string { return `${chalk.green("✓")} ${message}`; } +export function formatWarning(message: string): string { + return `${chalk.yellow("⚠")} ${message}`; +} + export function formatListening(description: string): string { return chalk.dim(`${description} Press Ctrl+C to exit.`); } diff --git a/test/unit/commands/interactive-enhanced-simple.test.ts b/test/unit/commands/interactive-enhanced-simple.test.ts index 068b4a85..82a0cbcf 100644 --- a/test/unit/commands/interactive-enhanced-simple.test.ts +++ b/test/unit/commands/interactive-enhanced-simple.test.ts @@ -97,7 +97,7 @@ describe("Interactive Command - Enhanced Features (Simplified)", () => { parseCommand('echo "unclosed'); expect(stubs.consoleError).toHaveBeenCalledWith( - chalk.yellow("Warning: Unclosed double quote in command"), + `${chalk.yellow("⚠")} Unclosed double quote in command`, ); }); diff --git a/test/unit/utils/output.test.ts b/test/unit/utils/output.test.ts index 610785b8..a29bcf05 100644 --- a/test/unit/utils/output.test.ts +++ b/test/unit/utils/output.test.ts @@ -16,6 +16,7 @@ import { formatMessageTimestamp, formatPresenceAction, buildJsonRecord, + formatWarning, } from "../../../src/utils/output.js"; describe("formatProgress", () => { @@ -38,6 +39,18 @@ describe("formatSuccess", () => { }); }); +describe("formatWarning", () => { + it("prepends a yellow warning symbol", () => { + expect(formatWarning("Something happened")).toBe( + `${chalk.yellow("⚠")} Something happened`, + ); + }); + + it("includes the message text", () => { + expect(formatWarning("Check this.")).toContain("Check this."); + }); +}); + describe("formatListening", () => { it("includes the description text", () => { expect(formatListening("Listening for messages.")).toContain( From cb8fa5407aa791301beb795547e58622c0722975 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Wed, 11 Mar 2026 13:59:27 +0000 Subject: [PATCH 2/2] Add mutable-messages flag to channel rules Add --mutable-messages flag to channel-rules create and update commands, enabling message editing and deletion on channels. Persistence is automatically enabled when mutable messages is set. Validation prevents disabling persistence while mutable messages is active. --- src/commands/apps/channel-rules/create.ts | 34 +++++- src/commands/apps/channel-rules/index.ts | 1 + src/commands/apps/channel-rules/list.ts | 2 + src/commands/apps/channel-rules/update.ts | 44 ++++++- src/services/control-api.ts | 3 + src/utils/channel-rule-display.ts | 4 + .../apps/channel-rules/create.test.ts | 67 ++++++++++ .../commands/apps/channel-rules/list.test.ts | 63 ++++++++++ .../apps/channel-rules/update.test.ts | 115 +++++++++++++++++- .../unit/commands/channel-rule/update.test.ts | 2 +- 10 files changed, 327 insertions(+), 8 deletions(-) diff --git a/src/commands/apps/channel-rules/create.ts b/src/commands/apps/channel-rules/create.ts index 7836ef03..f48a1395 100644 --- a/src/commands/apps/channel-rules/create.ts +++ b/src/commands/apps/channel-rules/create.ts @@ -2,13 +2,18 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { formatSuccess } from "../../../utils/output.js"; +import { + formatLabel, + formatSuccess, + formatWarning, +} from "../../../utils/output.js"; export default class ChannelRulesCreateCommand extends ControlBaseCommand { static description = "Create a channel rule"; static examples = [ '$ ably apps channel-rules create --name "chat" --persisted', + '$ ably apps channel-rules create --name "chat" --mutable-messages', '$ ably apps channel-rules create --name "events" --push-enabled', '$ ably apps channel-rules create --name "notifications" --persisted --push-enabled --app "My App"', '$ ably apps channel-rules create --name "chat" --persisted --json', @@ -55,6 +60,11 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { "Whether to expose the time serial for messages on channels matching this rule", required: false, }), + "mutable-messages": Flags.boolean({ + description: + "Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence", + required: false, + }), name: Flags.string({ description: "Name of the channel rule", required: true, @@ -94,6 +104,22 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { try { const controlApi = this.createControlApi(flags); + + // When mutableMessages is enabled, persisted must also be enabled + const mutableMessages = flags["mutable-messages"]; + let persisted = flags.persisted; + + if (mutableMessages) { + persisted = true; + if (!this.shouldOutputJson(flags)) { + this.logToStderr( + formatWarning( + "Message persistence is automatically enabled when mutable messages is enabled.", + ), + ); + } + } + const namespaceData = { authenticated: flags.authenticated, batchingEnabled: flags["batching-enabled"], @@ -103,8 +129,9 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { conflationInterval: flags["conflation-interval"], conflationKey: flags["conflation-key"], exposeTimeSerial: flags["expose-time-serial"], + mutableMessages, persistLast: flags["persist-last"], - persisted: flags.persisted, + persisted, populateChannelRegistry: flags["populate-channel-registry"], pushEnabled: flags["push-enabled"], tlsOnly: flags["tls-only"], @@ -129,6 +156,7 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { created: new Date(createdNamespace.created).toISOString(), exposeTimeSerial: createdNamespace.exposeTimeSerial, id: createdNamespace.id, + mutableMessages: createdNamespace.mutableMessages, name: flags.name, persistLast: createdNamespace.persistLast, persisted: createdNamespace.persisted, @@ -142,7 +170,7 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { ); } else { this.log(formatSuccess("Channel rule created.")); - this.log(`ID: ${createdNamespace.id}`); + this.log(`${formatLabel("ID")} ${createdNamespace.id}`); for (const line of formatChannelRuleDetails(createdNamespace, { formatDate: (t) => this.formatDate(t), })) { diff --git a/src/commands/apps/channel-rules/index.ts b/src/commands/apps/channel-rules/index.ts index 6dfa8b48..8c38da2e 100644 --- a/src/commands/apps/channel-rules/index.ts +++ b/src/commands/apps/channel-rules/index.ts @@ -9,6 +9,7 @@ export default class ChannelRulesIndexCommand extends BaseTopicCommand { static examples = [ "ably apps channel-rules list", 'ably apps channel-rules create --name "chat" --persisted', + "ably apps channel-rules update chat --mutable-messages", "ably apps channel-rules update chat --push-enabled", "ably apps channel-rules delete chat", ]; diff --git a/src/commands/apps/channel-rules/list.ts b/src/commands/apps/channel-rules/list.ts index 8d06863e..55423e3b 100644 --- a/src/commands/apps/channel-rules/list.ts +++ b/src/commands/apps/channel-rules/list.ts @@ -16,6 +16,7 @@ interface ChannelRuleOutput { exposeTimeSerial: boolean; id: string; modified: string; + mutableMessages: boolean; persistLast: boolean; persisted: boolean; populateChannelRegistry: boolean; @@ -65,6 +66,7 @@ export default class ChannelRulesListCommand extends ControlBaseCommand { exposeTimeSerial: rule.exposeTimeSerial || false, id: rule.id, modified: new Date(rule.modified).toISOString(), + mutableMessages: rule.mutableMessages || false, persistLast: rule.persistLast || false, persisted: rule.persisted || false, populateChannelRegistry: rule.populateChannelRegistry || false, diff --git a/src/commands/apps/channel-rules/update.ts b/src/commands/apps/channel-rules/update.ts index f5d064e0..f660a7c2 100644 --- a/src/commands/apps/channel-rules/update.ts +++ b/src/commands/apps/channel-rules/update.ts @@ -2,6 +2,11 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; +import { + formatLabel, + formatSuccess, + formatWarning, +} from "../../../utils/output.js"; export default class ChannelRulesUpdateCommand extends ControlBaseCommand { static args = { @@ -15,6 +20,7 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand { static examples = [ "$ ably apps channel-rules update chat --persisted", + "$ ably apps channel-rules update chat --mutable-messages", "$ ably apps channel-rules update events --push-enabled=false", '$ ably apps channel-rules update notifications --persisted --push-enabled --app "My App"', "$ ably apps channel-rules update chat --persisted --json", @@ -65,6 +71,12 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand { "Whether to expose the time serial for messages on channels matching this rule", required: false, }), + "mutable-messages": Flags.boolean({ + allowNo: true, + description: + "Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence", + required: false, + }), "persist-last": Flags.boolean({ allowNo: true, description: @@ -120,10 +132,37 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand { const updateData: Record = {}; + // Validation for mutable-messages flag, checks with supplied/existing mutableMessages flag + if ( + flags.persisted === false && + (flags["mutable-messages"] || namespace.mutableMessages) + ) { + this.fail( + "Cannot disable persistence when mutable messages is enabled. Mutable messages requires message persistence.", + flags, + "channelRuleUpdate", + { appId, ruleId: namespace.id }, + ); + } + if (flags.persisted !== undefined) { updateData.persisted = flags.persisted; } + if (flags["mutable-messages"] !== undefined) { + updateData.mutableMessages = flags["mutable-messages"]; + if (flags["mutable-messages"]) { + updateData.persisted = true; + if (!this.shouldOutputJson(flags)) { + this.logToStderr( + formatWarning( + "Message persistence is automatically enabled when mutable messages is enabled.", + ), + ); + } + } + } + if (flags["push-enabled"] !== undefined) { updateData.pushEnabled = flags["push-enabled"]; } @@ -199,6 +238,7 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand { exposeTimeSerial: updatedNamespace.exposeTimeSerial, id: updatedNamespace.id, modified: new Date(updatedNamespace.modified).toISOString(), + mutableMessages: updatedNamespace.mutableMessages, persistLast: updatedNamespace.persistLast, persisted: updatedNamespace.persisted, populateChannelRegistry: updatedNamespace.populateChannelRegistry, @@ -210,8 +250,8 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand { flags, ); } else { - this.log("Channel rule updated successfully:"); - this.log(`ID: ${updatedNamespace.id}`); + this.log(formatSuccess("Channel rule updated.")); + this.log(`${formatLabel("ID")} ${updatedNamespace.id}`); for (const line of formatChannelRuleDetails(updatedNamespace, { formatDate: (t) => this.formatDate(t), showTimestamps: true, diff --git a/src/services/control-api.ts b/src/services/control-api.ts index 478ea261..4da1c8c3 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -57,6 +57,7 @@ export interface Namespace { exposeTimeSerial?: boolean; id: string; modified: number; + mutableMessages?: boolean; persistLast?: boolean; persisted: boolean; populateChannelRegistry?: boolean; @@ -231,6 +232,7 @@ export class ControlApi { conflationInterval?: number; conflationKey?: string; exposeTimeSerial?: boolean; + mutableMessages?: boolean; persistLast?: boolean; persisted?: boolean; populateChannelRegistry?: boolean; @@ -457,6 +459,7 @@ export class ControlApi { conflationInterval?: number; conflationKey?: string; exposeTimeSerial?: boolean; + mutableMessages?: boolean; persistLast?: boolean; persisted?: boolean; populateChannelRegistry?: boolean; diff --git a/src/utils/channel-rule-display.ts b/src/utils/channel-rule-display.ts index 6dca2b47..bb56c0ad 100644 --- a/src/utils/channel-rule-display.ts +++ b/src/utils/channel-rule-display.ts @@ -43,6 +43,10 @@ export function formatChannelRuleDetails( `${indent}Push Enabled: ${bool(rule.pushEnabled)}`, ); + if (rule.mutableMessages !== undefined) { + lines.push(`${indent}Mutable Messages: ${bool(rule.mutableMessages)}`); + } + if (rule.authenticated !== undefined) { lines.push(`${indent}Authenticated: ${bool(rule.authenticated)}`); } diff --git a/test/unit/commands/apps/channel-rules/create.test.ts b/test/unit/commands/apps/channel-rules/create.test.ts index 9e79775d..e382c41b 100644 --- a/test/unit/commands/apps/channel-rules/create.test.ts +++ b/test/unit/commands/apps/channel-rules/create.test.ts @@ -79,6 +79,73 @@ describe("apps:channel-rules:create command", () => { expect(stdout).toContain("Push Enabled: Yes"); }); + it("should create a channel rule with mutable-messages flag and auto-enable persisted", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.mutableMessages === true && body.persisted === true; + }) + .reply(201, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + mutableMessages: true, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout, stderr } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--mutable-messages", + ], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule created."); + expect(stdout).toContain("Persisted: Yes"); + expect(stdout).toContain("Mutable Messages: Yes"); + expect(stderr).toContain( + "Message persistence is automatically enabled when mutable messages is enabled.", + ); + }); + + it("should include mutableMessages in JSON output when --mutable-messages is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.mutableMessages === true && body.persisted === true; + }) + .reply(201, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + mutableMessages: true, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout, stderr } = await runCommand( + [ + "apps:channel-rules:create", + "--name", + mockRuleName, + "--mutable-messages", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result.rule).toHaveProperty("mutableMessages", true); + expect(result.rule).toHaveProperty("persisted", true); + // Warning should not appear in JSON mode + expect(stderr).not.toContain("Warning"); + }); + it("should output JSON format when --json flag is used", async () => { const appId = getMockConfigManager().getCurrentAppId()!; const mockRule = { diff --git a/test/unit/commands/apps/channel-rules/list.test.ts b/test/unit/commands/apps/channel-rules/list.test.ts index e4817fd0..61ea4732 100644 --- a/test/unit/commands/apps/channel-rules/list.test.ts +++ b/test/unit/commands/apps/channel-rules/list.test.ts @@ -84,6 +84,69 @@ describe("apps:channel-rules:list command", () => { expect(stdout).toContain("Persisted: ✓ Yes"); expect(stdout).toContain("Push Enabled: ✓ Yes"); }); + + it("should display mutableMessages in rule details", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockRules = [ + { + id: "mutable-chat", + persisted: true, + pushEnabled: false, + mutableMessages: true, + created: Date.now(), + modified: Date.now(), + }, + ]; + + nock("https://control.ably.net") + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, mockRules); + + const { stdout } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); + + expect(stdout).toContain("Found 1 channel rules"); + expect(stdout).toContain("mutable-chat"); + expect(stdout).toContain("Mutable Messages: ✓ Yes"); + }); + + it("should include mutableMessages in JSON output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + const mockRules = [ + { + id: "mutable-chat", + persisted: true, + pushEnabled: false, + mutableMessages: true, + created: Date.now(), + modified: Date.now(), + }, + { + id: "regular-chat", + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]; + + nock("https://control.ably.net") + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, mockRules); + + const { stdout } = await runCommand( + ["apps:channel-rules:list", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result.rules).toHaveLength(2); + expect(result.rules[0]).toHaveProperty("mutableMessages", true); + expect(result.rules[1]).toHaveProperty("mutableMessages", false); + }); }); describe("error handling", () => { diff --git a/test/unit/commands/apps/channel-rules/update.test.ts b/test/unit/commands/apps/channel-rules/update.test.ts index 7326b9d5..f01e755b 100644 --- a/test/unit/commands/apps/channel-rules/update.test.ts +++ b/test/unit/commands/apps/channel-rules/update.test.ts @@ -42,7 +42,7 @@ describe("apps:channel-rules:update command", () => { import.meta.url, ); - expect(stdout).toContain("Channel rule updated successfully"); + expect(stdout).toContain("Channel rule updated."); expect(stdout).toContain("Persisted: Yes"); }); @@ -75,10 +75,121 @@ describe("apps:channel-rules:update command", () => { import.meta.url, ); - expect(stdout).toContain("Channel rule updated successfully"); + expect(stdout).toContain("Channel rule updated."); expect(stdout).toContain("Push Enabled: Yes"); }); + it("should update a channel rule with mutable-messages flag and auto-enable persisted", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => { + return body.mutableMessages === true && body.persisted === true; + }) + .reply(200, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + mutableMessages: true, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout, stderr } = await runCommand( + ["apps:channel-rules:update", mockRuleId, "--mutable-messages"], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule updated."); + expect(stdout).toContain("Persisted: Yes"); + expect(stdout).toContain("Mutable Messages: Yes"); + expect(stderr).toContain( + "Message persistence is automatically enabled when mutable messages is enabled.", + ); + }); + + it("should error when --mutable-messages is used with --no-persisted", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + const { error } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--mutable-messages", + "--no-persisted", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch( + /Cannot disable persistence when mutable messages is enabled/, + ); + }); + + it("should include mutableMessages in JSON output when updating", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + { + id: mockRuleId, + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nock("https://control.ably.net") + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(200, { + id: mockRuleId, + persisted: true, + pushEnabled: false, + mutableMessages: true, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + [ + "apps:channel-rules:update", + mockRuleId, + "--mutable-messages", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result.rule).toHaveProperty("mutableMessages", true); + expect(result.rule).toHaveProperty("persisted", true); + }); + it("should output JSON format when --json flag is used", async () => { const appId = getMockConfigManager().getCurrentAppId()!; nock("https://control.ably.net") diff --git a/test/unit/commands/channel-rule/update.test.ts b/test/unit/commands/channel-rule/update.test.ts index c944c3c4..a64783c1 100644 --- a/test/unit/commands/channel-rule/update.test.ts +++ b/test/unit/commands/channel-rule/update.test.ts @@ -40,7 +40,7 @@ describe("channel-rule:update command (alias)", () => { import.meta.url, ); - expect(stdout).toContain("Channel rule updated successfully"); + expect(stdout).toContain("Channel rule updated."); }); it("should require nameOrId argument", async () => {