Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .claude/skills/ably-new-command/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` |
Expand Down Expand Up @@ -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()`
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -229,7 +230,6 @@ this.error() ← oclif exit (ONLY inside fail, nowhere else)
- **`runControlCommand<T>`** returns `Promise<T>` (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
Expand Down
8 changes: 4 additions & 4 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion src/base-topic-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
8 changes: 6 additions & 2 deletions src/chat-base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
import { AblyBaseCommand } from "./base-command.js";
import { productApiFlags } from "./flags.js";
import { BaseFlags } from "./types/cli.js";
import chalk from "chalk";

Check warning on line 6 in src/chat-base-command.ts

View workflow job for this annotation

GitHub Actions / test

'chalk' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 6 in src/chat-base-command.ts

View workflow job for this annotation

GitHub Actions / setup

'chalk' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 6 in src/chat-base-command.ts

View workflow job for this annotation

GitHub Actions / e2e-cli

'chalk' is defined but never used. Allowed unused vars must match /^_/u

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 {
Expand Down Expand Up @@ -128,7 +132,7 @@
}
case RoomStatus.Detached: {
if (!this.shouldOutputJson(flags)) {
this.log(chalk.yellow("Disconnected from Ably"));
this.log(formatWarning("Disconnected from Ably"));
}
break;
}
Expand Down
34 changes: 31 additions & 3 deletions src/commands/apps/channel-rules/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand All @@ -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"],
Expand All @@ -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,
Expand All @@ -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),
})) {
Expand Down
1 change: 1 addition & 0 deletions src/commands/apps/channel-rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down
2 changes: 2 additions & 0 deletions src/commands/apps/channel-rules/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface ChannelRuleOutput {
exposeTimeSerial: boolean;
id: string;
modified: string;
mutableMessages: boolean;
persistLast: boolean;
persisted: boolean;
populateChannelRegistry: boolean;
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 44 additions & 2 deletions src/commands/apps/channel-rules/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -120,10 +132,39 @@ export default class ChannelRulesUpdateCommand extends ControlBaseCommand {
const updateData: Record<string, boolean | number | string | undefined> =
{};

// Validation for mutable-messages flag, checks with supplied/existing mutableMessages flag
if (
flags.persisted === false &&
(flags["mutable-messages"] === true ||
(flags["mutable-messages"] === undefined &&
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"];
}
Expand Down Expand Up @@ -199,6 +240,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,
Expand All @@ -210,8 +252,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,
Expand Down
5 changes: 4 additions & 1 deletion src/commands/connections/test.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 ConnectionsTest extends AblyBaseCommand {
Expand Down Expand Up @@ -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`);
Expand Down
5 changes: 2 additions & 3 deletions src/commands/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/command_not_found/did-you-mean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 0 additions & 12 deletions src/hooks/command_not_found/prompt-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading
Loading