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
2 changes: 1 addition & 1 deletion src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@
}

protected formatJsonOutput(
data: Record<string, unknown>,
data: Record<string, unknown> | Record<string, unknown>[],
flags: BaseFlags,
): string {
if (this.isPrettyJsonOutput(flags)) {
Expand Down Expand Up @@ -1275,7 +1275,7 @@
* 3. Explicit access token is provided
* 4. Environment variables are used for auth
*/
protected shouldHideAccountInfo(flags: BaseFlags): boolean {

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

View workflow job for this annotation

GitHub Actions / test

'flags' is defined but never used. Allowed unused args must match /^_/u

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

View workflow job for this annotation

GitHub Actions / e2e-cli

'flags' is defined but never used. Allowed unused args must match /^_/u

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

View workflow job for this annotation

GitHub Actions / setup

'flags' is defined but never used. Allowed unused args must match /^_/u
// Check if there's no account configured
const currentAccount = this.configManager.getCurrentAccount();
if (!currentAccount) {
Expand Down
60 changes: 25 additions & 35 deletions src/commands/channels/history.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { Args, Flags } from "@oclif/core";
import * as Ably from "ably";
import chalk from "chalk";

import { AblyBaseCommand } from "../../base-command.js";
import { productApiFlags, timeRangeFlags } from "../../flags.js";
import { formatMessageData } from "../../utils/json-formatter.js";
import { buildHistoryParams } from "../../utils/history.js";
import { errorMessage } from "../../utils/errors.js";
import {
countLabel,
formatMessagesOutput,
formatTimestamp,
formatMessageTimestamp,
limitWarning,
resource,
toMessageJson,
} from "../../utils/output.js";
import type { MessageDisplayFields } from "../../utils/output.js";

export default class ChannelsHistory extends AblyBaseCommand {
static override args = {
Expand Down Expand Up @@ -85,41 +84,32 @@ export default class ChannelsHistory extends AblyBaseCommand {
const history = await channel.history(historyParams);
const messages = history.items;

// Build display fields from history results
const displayMessages: MessageDisplayFields[] = messages.map(
(message, index) => ({
channel: channelName,
clientId: message.clientId,
data: message.data,
event: message.name || "(none)",
id: message.id,
indexPrefix: `[${index + 1}] ${formatTimestamp(formatMessageTimestamp(message.timestamp))}`,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Dim the history index marker to match the standard format.

Line 95 leaves [${index + 1}] in normal intensity, so the prefix no longer matches the repo’s required [index] timestamp styling. Use chalk.dim() on the index segment before formatTimestamp(...).

As per coding guidelines, "Use [index] timestamp ordering format: ${chalk.dim([${index + 1}])} ${formatTimestamp(timestamp)} for history output".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/channels/history.ts` at line 95, The history prefix currently
builds indexPrefix without dimming the index; update the construction of
indexPrefix to wrap the `[${index + 1}]` portion with chalk.dim so it matches
the repo style. Locate where indexPrefix is assigned (uses index,
formatTimestamp, formatMessageTimestamp, and message.timestamp) and change it to
use chalk.dim for the bracketed index only, keeping the existing spacing and
calling formatTimestamp(formatMessageTimestamp(message.timestamp)) after the
dimmed index.

serial: message.serial,
timestamp: message.timestamp ?? Date.now(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

History shows redundant timestamp in two different formats

Low Severity

History messages display the timestamp twice in different formats. The indexPrefix includes a formatted ISO timestamp via formatTimestamp(formatMessageTimestamp(message.timestamp)), and formatMessagesOutput also renders a Timestamp: line with raw milliseconds from msg.timestamp. Each history entry shows something like [1] [2023-11-14T22:13:20.000Z] followed by Timestamp: 1700000000000, which is redundant and inconsistent with the subscribe output that only shows the raw millisecond timestamp.

Additional Locations (1)

Fix in Cursor Fix in Web

version: message.version,
annotations: message.annotations,
}),
);

// Display results based on format
if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput({ messages }, flags));
} else {
if (messages.length === 0) {
this.log("No messages found in the channel history.");
return;
}

this.log(
`Found ${countLabel(messages.length, "message")} in the history of channel: ${resource(channelName)}`,
this.formatJsonOutput(
displayMessages.map((msg) => toMessageJson(msg)),
flags,
),
);
this.log("");

for (const [index, message] of messages.entries()) {
const timestampDisplay = message.timestamp
? formatTimestamp(formatMessageTimestamp(message.timestamp))
: chalk.dim("[Unknown timestamp]");

this.log(`${chalk.dim(`[${index + 1}]`)} ${timestampDisplay}`);
this.log(
`${chalk.dim("Event:")} ${chalk.yellow(message.name || "(none)")}`,
);

if (message.clientId) {
this.log(
`${chalk.dim("Client ID:")} ${chalk.blue(message.clientId)}`,
);
}

this.log(chalk.dim("Data:"));
this.log(formatMessageData(message.data));

this.log("");
}
} else {
this.log(formatMessagesOutput(displayMessages));

const warning = limitWarning(messages.length, flags.limit, "messages");
if (warning) this.log(warning);
Expand Down
51 changes: 19 additions & 32 deletions src/commands/channels/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import {
productApiFlags,
rewindFlag,
} from "../../flags.js";
import { formatMessageData } from "../../utils/json-formatter.js";
import {
formatMessagesOutput,
listening,
progress,
resource,
success,
formatTimestamp,
formatMessageTimestamp,
toMessageJson,
} from "../../utils/output.js";
import type { MessageDisplayFields } from "../../utils/output.js";

export default class ChannelsSubscribe extends AblyBaseCommand {
static override args = {
Expand Down Expand Up @@ -195,46 +195,32 @@ export default class ChannelsSubscribe extends AblyBaseCommand {

channel.subscribe((message: Ably.Message) => {
this.sequenceCounter++;
const timestamp = formatMessageTimestamp(message.timestamp);
const messageEvent = {

const msgFields: MessageDisplayFields = {
channel: channel.name,
clientId: message.clientId,
connectionId: message.connectionId,
data: message.data,
encoding: message.encoding,
event: message.name || "(none)",
id: message.id,
timestamp,
serial: message.serial,
timestamp: message.timestamp ?? Date.now(),
version: message.version,
annotations: message.annotations,
...(flags["sequence-numbers"]
? { sequence: this.sequenceCounter }
? { sequencePrefix: `${chalk.dim(`[${this.sequenceCounter}]`)} ` }
: {}),
};
this.logCliEvent(
flags,
"subscribe",
"messageReceived",
`Received message on channel ${channel.name}`,
messageEvent,
);

if (this.shouldOutputJson(flags)) {
this.log(this.formatJsonOutput(messageEvent, flags));
} else {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verbose logging removed for received messages in subscribe

Low Severity

The logCliEvent call for the messageReceived event was removed during the refactoring. Previously, each received message triggered a logCliEvent(flags, "subscribe", "messageReceived", ...) call, which provided diagnostic output in --verbose mode. This was dropped when the old messageEvent variable was replaced with msgFields, causing a silent regression for users relying on verbose logging to see per-message CLI events.

Fix in Cursor Fix in Web

const name = message.name || "(none)";
const sequencePrefix = flags["sequence-numbers"]
? `${chalk.dim(`[${this.sequenceCounter}]`)}`
: "";

// Message header with timestamp and channel info
this.log(
`${formatTimestamp(timestamp)}${sequencePrefix} ${chalk.cyan(`Channel: ${channel.name}`)} | ${chalk.yellow(`Event: ${name}`)}`,
);

// Message data with consistent formatting
this.log(chalk.dim("Data:"));
this.log(formatMessageData(message.data));
const jsonMsg = toMessageJson(msgFields);
if (flags["sequence-numbers"]) {
jsonMsg.sequence = this.sequenceCounter;
}

this.log(""); // Empty line for better readability
this.log(this.formatJsonOutput(jsonMsg, flags));
} else {
this.log(formatMessagesOutput([msgFields]));
this.log(""); // Empty line for better readability between messages
}
});
}
Expand All @@ -258,6 +244,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand {
}

this.log(listening("Listening for messages."));
this.log("");
}

this.logCliEvent(
Expand Down
125 changes: 125 additions & 0 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import chalk, { type ChalkInstance } from "chalk";
import type * as Ably from "ably";
import { formatMessageData, isJsonData } from "./json-formatter.js";

export function progress(message: string): string {
return `${message}...`;
Expand Down Expand Up @@ -63,6 +65,129 @@ export function limitWarning(
return null;
}

/**
* Fields for consistent message display across subscribe and history commands.
* All fields use the same names and format for both human-readable and JSON output.
* Timestamp is raw milliseconds (Unix epoch) — not converted to ISO string.
*/
export interface MessageDisplayFields {
channel: string;
clientId?: string;
data: unknown;
event: string;
id?: string;
indexPrefix?: string;
sequencePrefix?: string;
serial?: string;
timestamp: number;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Normalize timestamps in the shared message shape.

Line 81 stores timestamp as raw epoch milliseconds, and Lines 101-105 / 146 emit that value unchanged. That makes the new shared subscribe/history formatter surface Unix numbers instead of the formatted message timestamps the CLI uses elsewhere, so both the human and JSON outputs drift from the intended contract.

Also applies to: 101-105, 146-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/output.ts` at line 81, The shared message shape currently stores
timestamp as raw epoch milliseconds (the timestamp property) and emits it
unchanged; update the code that constructs/emits the shared message (where
timestamp is set and in the shared subscribe/history formatter functions—e.g.,
the functions that build/emit shared messages and history entries) to normalize
the value to a canonical string (use new Date(timestamp).toISOString() or the
existing formatter helper if one exists) so subscribers and history emit the
same formatted timestamp the CLI expects instead of a Unix number. Ensure every
place that sets or returns the timestamp property (the shared message
constructor/serializer and the history/subscribe emit paths) performs this
conversion.

version?: Ably.MessageVersion;
annotations?: Ably.MessageAnnotations;
}

/**
* Format an array of messages for human-readable console output.
* Each message shows all fields on separate lines, messages separated by blank lines.
* Returns "No messages found." for empty arrays.
*/
export function formatMessagesOutput(messages: MessageDisplayFields[]): string {
if (messages.length === 0) {
return "No messages found.";
}

const formatted = messages.map((msg) => {
const lines: string[] = [];

if (msg.indexPrefix) {
lines.push(chalk.dim(msg.indexPrefix));
}

const timestampLine = `${chalk.dim("Timestamp:")} ${msg.timestamp}`;
lines.push(
msg.sequencePrefix
? `${msg.sequencePrefix}${timestampLine}`
: timestampLine,
`${chalk.dim("Channel:")} ${resource(msg.channel)}`,
`${chalk.dim("Event:")} ${chalk.yellow(msg.event)}`,
);

if (msg.id) {
lines.push(`${chalk.dim("ID:")} ${msg.id}`);
}

if (msg.clientId) {
lines.push(`${chalk.dim("Client ID:")} ${chalk.blue(msg.clientId)}`);
}

if (msg.serial) {
lines.push(`${chalk.dim("Serial:")} ${msg.serial}`);
}

if (
msg.version &&
Object.keys(msg.version).length > 0 &&
msg.version.serial &&
msg.version.serial !== msg.serial
) {
lines.push(`${chalk.dim("Version:")}`);
if (msg.version.serial) {
lines.push(` ${chalk.dim("Serial:")} ${msg.version.serial}`);
}
if (msg.version.timestamp !== undefined) {
lines.push(` ${chalk.dim("Timestamp:")} ${msg.version.timestamp}`);
}
if (msg.version.clientId) {
lines.push(
` ${chalk.dim("Client ID:")} ${chalk.blue(msg.version.clientId)}`,
);
}
}

if (msg.annotations && Object.keys(msg.annotations.summary).length > 0) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing annotations.summary without null check crashes at runtime

High Severity

Object.keys(msg.annotations.summary) will throw a TypeError if msg.annotations exists but msg.annotations.summary is undefined or null. The guard only checks for msg.annotations being truthy, not for the presence of .summary. This can crash the CLI when a message has an annotations object without a populated summary field.

Fix in Cursor Fix in Web

lines.push(`${chalk.dim("Annotations:")}`);
for (const [type, entry] of Object.entries(msg.annotations.summary)) {
lines.push(
` ${chalk.dim(`${type}:`)}`,
` ${formatMessageData(entry)}`,
);
}
}

if (isJsonData(msg.data)) {
lines.push(`${chalk.dim("Data:")}\n${formatMessageData(msg.data)}`);
} else {
lines.push(`${chalk.dim("Data:")} ${String(msg.data)}`);
}

return lines.join("\n");
});

return formatted.join("\n\n");
}

/**
* Convert a single MessageDisplayFields to a plain object for JSON output.
* Includes all required fields, omits undefined optional fields.
*
* Usage:
* Single message (subscribe): toMessageJson(msg)
* Array of messages (history): messages.map(toMessageJson)
*/
export function toMessageJson(
msg: MessageDisplayFields,
): Record<string, unknown> {
return {
timestamp: msg.timestamp,
channel: msg.channel,
event: msg.event,
...(msg.id ? { id: msg.id } : {}),
...(msg.clientId ? { clientId: msg.clientId } : {}),
...(msg.serial ? { serial: msg.serial } : {}),
...(msg.version ? { version: msg.version } : {}),
...(msg.annotations ? { annotations: msg.annotations } : {}),
data: msg.data,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep data present for name-only messages.

If msg.data is undefined, JSON.stringify() will drop the data key entirely, so the new shared JSON shape becomes inconsistent for messages that only carry name. Normalizing missing data to null here keeps the output stable.

💡 Proposed fix
-    data: msg.data,
+    data: msg.data ?? null,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
data: msg.data,
data: msg.data ?? null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/output.ts` at line 152, The output builder currently assigns "data:
msg.data" which allows JSON.stringify to drop the data key when msg.data is
undefined; change the assignment in the output construction (where "data:
msg.data" appears) to normalize missing payloads to null (e.g., use msg.data ??
null) so the serialized JSON always contains a data key for name-only messages
and preserves a stable shared JSON shape.

};
}

export function formatPresenceAction(action: string): {
symbol: string;
color: ChalkInstance;
Expand Down
11 changes: 6 additions & 5 deletions test/e2e/channels/channels-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,8 @@ describe("Channel E2E Tests", () => {
);
}

expect(historyResult.stdout).toContain("Found");
// New format outputs messages directly with field labels (no "Found" prefix)
expect(historyResult.stdout).toContain("Data:");
expect(historyResult.stdout).toContain("E2E History Test");

// Now verify with SDK in a separate step outside of Oclif's callback
Expand Down Expand Up @@ -345,11 +346,11 @@ describe("Channel E2E Tests", () => {
`Failed to parse JSON history output. Parse error: ${parseError}. Exit code: ${historyResult.exitCode}, Stderr: ${historyResult.stderr}, Stdout: ${historyResult.stdout}`,
);
}
expect(result).toHaveProperty("messages");
expect(Array.isArray(result.messages)).toBe(true);
expect(result.messages.length).toBeGreaterThanOrEqual(1);
// JSON output is now a plain array of message objects
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThanOrEqual(1);

const testMsg = result.messages.find(
const testMsg = result.find(
(msg: any) =>
msg.data &&
typeof msg.data === "object" &&
Expand Down
Loading
Loading