Skip to content
Draft
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
18 changes: 15 additions & 3 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "./services/config-manager.js";
import { ControlApi } from "./services/control-api.js";
import { CommandError } from "./errors/command-error.js";
import { getFriendlyAblyErrorHint } from "./utils/errors.js";
import { coreGlobalFlags } from "./flags.js";
import { InteractiveHelper } from "./services/interactive-helper.js";
import { BaseFlags, CommandConfig } from "./types/cli.js";
Expand Down Expand Up @@ -936,9 +937,10 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
logData,
);
} else if (level <= 1) {
// Standard Non-JSON: Log only SDK ERRORS (level <= 1) clearly
// Use a format similar to logCliEvent's non-JSON output
this.log(`${chalk.red.bold(`[AblySDK Error]`)} ${message}`);
// SDK errors are handled by setupChannelStateLogging() and fail()
// Only show raw SDK errors in verbose mode (handled above)
// In non-verbose mode, log to stderr for debugging without polluting stdout
this.logToStderr(`${chalk.red.bold(`[AblySDK Error]`)} ${message}`);
}
// If not verbose non-JSON and level > 1, suppress non-error SDK logs
}
Expand Down Expand Up @@ -1503,6 +1505,16 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
}

let humanMessage = cmdError.message;
const friendlyHint = getFriendlyAblyErrorHint(
cmdError.code ??
(typeof cmdError.context.errorCode === "number"
? cmdError.context.errorCode
: undefined),
);
if (friendlyHint) {
humanMessage += `\n${friendlyHint}`;
}

const code = cmdError.code ?? cmdError.context.errorCode;
if (code !== undefined) {
const helpUrl = cmdError.context.helpUrl;
Expand Down
2 changes: 1 addition & 1 deletion src/commands/channels/occupancy/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand {
);
}

channel.subscribe(occupancyEventName, (message: Ably.Message) => {
await channel.subscribe(occupancyEventName, (message: Ably.Message) => {
const timestamp = formatMessageTimestamp(message.timestamp);
const event = {
channel: channelName,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/channels/presence/enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand {

// Subscribe to presence events before entering (if show-others is enabled)
if (flags["show-others"]) {
this.channel.presence.subscribe((presenceMessage) => {
await this.channel.presence.subscribe((presenceMessage) => {
// Filter out own presence events
if (presenceMessage.clientId === client.auth.clientId) {
return;
Expand Down
60 changes: 31 additions & 29 deletions src/commands/channels/presence/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,41 +79,43 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand {
);
}

channel.presence.subscribe((presenceMessage: Ably.PresenceMessage) => {
const timestamp = formatMessageTimestamp(presenceMessage.timestamp);
const presenceData = {
id: presenceMessage.id,
timestamp,
action: presenceMessage.action,
channel: channelName,
clientId: presenceMessage.clientId,
connectionId: presenceMessage.connectionId,
data: presenceMessage.data,
};
this.logCliEvent(
flags,
"presence",
presenceMessage.action!,
`Presence event: ${presenceMessage.action} by ${presenceMessage.clientId}`,
presenceData,
);

if (this.shouldOutputJson(flags)) {
this.logJsonEvent({ presenceMessage: presenceData }, flags);
} else {
const displayFields: PresenceDisplayFields = {
await channel.presence.subscribe(
(presenceMessage: Ably.PresenceMessage) => {
const timestamp = formatMessageTimestamp(presenceMessage.timestamp);
const presenceData = {
id: presenceMessage.id,
timestamp: presenceMessage.timestamp ?? Date.now(),
action: presenceMessage.action || "unknown",
timestamp,
action: presenceMessage.action,
channel: channelName,
clientId: presenceMessage.clientId,
connectionId: presenceMessage.connectionId,
data: presenceMessage.data,
};
this.log(formatPresenceOutput([displayFields]));
this.log(""); // Empty line for better readability
}
});
this.logCliEvent(
flags,
"presence",
presenceMessage.action!,
`Presence event: ${presenceMessage.action} by ${presenceMessage.clientId}`,
presenceData,
);

if (this.shouldOutputJson(flags)) {
this.logJsonEvent({ presenceMessage: presenceData }, flags);
} else {
const displayFields: PresenceDisplayFields = {
id: presenceMessage.id,
timestamp: presenceMessage.timestamp ?? Date.now(),
action: presenceMessage.action || "unknown",
channel: channelName,
clientId: presenceMessage.clientId,
connectionId: presenceMessage.connectionId,
data: presenceMessage.data,
};
this.log(formatPresenceOutput([displayFields]));
this.log(""); // Empty line for better readability
}
},
);

if (!this.shouldOutputJson(flags)) {
this.log(
Expand Down
22 changes: 6 additions & 16 deletions src/commands/channels/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand {
});

// Subscribe to messages on all channels
const attachPromises: Promise<void>[] = [];
const subscribePromises: Promise<unknown>[] = [];

for (const channel of channels) {
this.logCliEvent(
Expand All @@ -177,19 +177,8 @@ export default class ChannelsSubscribe extends AblyBaseCommand {
includeUserFriendlyMessages: true,
});

// Track attachment promise
const attachPromise = new Promise<void>((resolve) => {
const checkAttached = () => {
if (channel.state === "attached") {
resolve();
}
};
channel.once("attached", checkAttached);
checkAttached(); // Check if already attached
});
attachPromises.push(attachPromise);

channel.subscribe((message: Ably.Message) => {
// Subscribe and collect promise (rejects on capability/auth errors)
const subscribePromise = channel.subscribe((message: Ably.Message) => {
this.sequenceCounter++;
const timestamp = formatMessageTimestamp(message.timestamp);
const messageData = {
Expand Down Expand Up @@ -251,10 +240,11 @@ export default class ChannelsSubscribe extends AblyBaseCommand {
this.log(""); // Empty line for readability between messages
}
});
subscribePromises.push(subscribePromise);
}

// Wait for all channels to attach
await Promise.all(attachPromises);
// Wait for all channels to attach via subscribe
await Promise.all(subscribePromises);

// Log the ready signal for E2E tests
if (channelNames.length === 1 && !this.shouldOutputJson(flags)) {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/logs/channel-lifecycle/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand {
}

// Subscribe to the channel
channel.subscribe((message) => {
await channel.subscribe((message) => {
const timestamp = formatMessageTimestamp(message.timestamp);
const event = message.name || "unknown";
const logEvent = {
Expand Down
2 changes: 1 addition & 1 deletion src/commands/logs/connection-lifecycle/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand {
}

// Subscribe to connection lifecycle logs
channel.subscribe((message: Ably.Message) => {
await channel.subscribe((message: Ably.Message) => {
const timestamp = formatMessageTimestamp(message.timestamp);
const event = {
timestamp,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/logs/push/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export default class LogsPushSubscribe extends AblyBaseCommand {
);

// Subscribe to the channel
channel.subscribe((message) => {
await channel.subscribe((message) => {
const timestamp = formatMessageTimestamp(message.timestamp);
const event = message.name || "unknown";
const logEvent = {
Expand Down
59 changes: 32 additions & 27 deletions src/commands/logs/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,41 +120,46 @@ export default class LogsSubscribe extends AblyBaseCommand {
}

// Subscribe to specified log types
const subscribePromises: Promise<unknown>[] = [];
for (const logType of logTypes) {
channel.subscribe(logType, (message: Ably.Message) => {
const timestamp = formatMessageTimestamp(message.timestamp);
const event = {
logType,
timestamp,
data: message.data,
id: message.id,
};
this.logCliEvent(
flags,
"logs",
"logReceived",
`Log received: ${logType}`,
event,
);

if (this.shouldOutputJson(flags)) {
this.logJsonEvent(event, flags);
} else {
this.log(
`${formatTimestamp(timestamp)} Type: ${formatEventType(logType)}`,
subscribePromises.push(
channel.subscribe(logType, (message: Ably.Message) => {
const timestamp = formatMessageTimestamp(message.timestamp);
const event = {
logType,
timestamp,
data: message.data,
id: message.id,
};
this.logCliEvent(
flags,
"logs",
"logReceived",
`Log received: ${logType}`,
event,
);

if (message.data !== null && message.data !== undefined) {
if (this.shouldOutputJson(flags)) {
this.logJsonEvent(event, flags);
} else {
this.log(
`${formatLabel("Data")} ${JSON.stringify(message.data, null, 2)}`,
`${formatTimestamp(timestamp)} Type: ${formatEventType(logType)}`,
);
}

this.log(""); // Empty line for better readability
}
});
if (message.data !== null && message.data !== undefined) {
this.log(
`${formatLabel("Data")} ${JSON.stringify(message.data, null, 2)}`,
);
}

this.log(""); // Empty line for better readability
}
}),
);
}

await Promise.all(subscribePromises);

this.logCliEvent(
flags,
"logs",
Expand Down
23 changes: 23 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,26 @@
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/**
* Return a friendly, actionable hint for known Ably error codes.
* Returns undefined for unknown codes.
*/
const hints: Record<number, string> = {
40101:
'The credentials provided are not valid. Check your API key or token, or re-authenticate with "ably login".',
40103: 'The token has expired. Please re-authenticate with "ably login".',
40110:
'Unable to authorize. Check your authentication configuration or re-authenticate with "ably login".',
40160:
"The current credentials do not have the capability to perform this operation on the requested resource. Check the token or key capability configuration in the Ably dashboard.",
40161:
"The current credentials do not have publish capability on this resource. Check the token or key capability configuration in the Ably dashboard.",
40171:
"The requested operation is not permitted by the current credentials' capabilities. Check the token or key capability configuration in the Ably dashboard.",
};

export function getFriendlyAblyErrorHint(code?: number): string | undefined {
if (code === undefined) return undefined;
return hints[code];
}
26 changes: 26 additions & 0 deletions test/unit/commands/channels/occupancy/subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,32 @@ describe("channels:occupancy:subscribe command", () => {
expect(error).toBeDefined();
expect(error?.message).toMatch(/No mock|client/i);
});

it("should handle capability error gracefully", async () => {
const mock = getMockAblyRealtime();
const channel = mock.channels._getChannel("test-channel");

channel.subscribe.mockRejectedValue(
Object.assign(
new Error("Channel denied access based on given capability"),
{
code: 40160,
statusCode: 401,
href: "https://help.ably.io/error/40160",
},
),
);

const { error } = await runCommand(
["channels:occupancy:subscribe", "test-channel"],
import.meta.url,
);

expect(error).toBeDefined();
expect(error?.message).toContain("Channel denied access");
expect(error?.message).toContain("capability");
expect(error?.message).toContain("Ably dashboard");
});
});

describe("output formats", () => {
Expand Down
26 changes: 26 additions & 0 deletions test/unit/commands/channels/presence/subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,5 +183,31 @@ describe("channels:presence:subscribe command", () => {
expect(error).toBeDefined();
expect(error?.message).toMatch(/No mock|client/i);
});

it("should handle capability error gracefully", async () => {
const mock = getMockAblyRealtime();
const channel = mock.channels._getChannel("test-channel");

channel.presence.subscribe.mockRejectedValue(
Object.assign(
new Error("Channel denied access based on given capability"),
{
code: 40160,
statusCode: 401,
href: "https://help.ably.io/error/40160",
},
),
);

const { error } = await runCommand(
["channels:presence:subscribe", "test-channel"],
import.meta.url,
);

expect(error).toBeDefined();
expect(error?.message).toContain("Channel denied access");
expect(error?.message).toContain("capability");
expect(error?.message).toContain("Ably dashboard");
});
});
});
26 changes: 26 additions & 0 deletions test/unit/commands/channels/subscribe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,5 +239,31 @@ describe("channels:subscribe command", () => {
expect(error).toBeDefined();
expect(error?.message).toMatch(/No mock|client/i);
});

it("should handle capability error gracefully", async () => {
const mock = getMockAblyRealtime();
const channel = mock.channels._getChannel("test-channel");

channel.subscribe.mockRejectedValue(
Object.assign(
new Error("Channel denied access based on given capability"),
{
code: 40160,
statusCode: 401,
href: "https://help.ably.io/error/40160",
},
),
);

const { error } = await runCommand(
["channels:subscribe", "test-channel"],
import.meta.url,
);

expect(error).toBeDefined();
expect(error?.message).toContain("Channel denied access");
expect(error?.message).toContain("capability");
expect(error?.message).toContain("Ably dashboard");
});
});
});
Loading
Loading