From 3f04ce98684a8e681ab7493ca941eedb028addea Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 5 Mar 2026 21:37:43 +0000 Subject: [PATCH 1/6] - Unifies start and end params for history commands - Further reinforces rules introduced in previous unification efforts --- .claude/CLAUDE.md | 9 +- .cursor/rules/AI-Assistance.mdc | 6 +- eslint.config.js | 4 +- src/commands/accounts/login.ts | 6 +- src/commands/apps/channel-rules/create.ts | 4 +- src/commands/apps/channel-rules/delete.ts | 4 +- src/commands/apps/channel-rules/list.ts | 2 +- src/commands/apps/create.ts | 4 +- src/commands/apps/delete.ts | 3 +- src/commands/apps/set-apns-p12.ts | 7 +- src/commands/apps/update.ts | 3 +- src/commands/auth/keys/create.ts | 8 +- src/commands/bench/subscriber.ts | 2 +- src/commands/channels/batch-publish.ts | 15 ++- src/commands/channels/history.ts | 21 ++-- src/commands/channels/list.ts | 3 +- src/commands/channels/occupancy.ts | 4 +- src/commands/channels/occupancy/get.ts | 32 +++-- src/commands/channels/occupancy/subscribe.ts | 4 +- src/commands/channels/presence/enter.ts | 6 +- src/commands/channels/presence/subscribe.ts | 4 +- src/commands/channels/publish.ts | 4 +- src/commands/channels/subscribe.ts | 8 +- src/commands/integrations/delete.ts | 34 ++++-- src/commands/interactive.ts | 49 -------- .../logs/channel-lifecycle/subscribe.ts | 7 +- .../logs/connection-lifecycle/history.ts | 20 +++- .../logs/connection-lifecycle/subscribe.ts | 6 +- src/commands/logs/history.ts | 20 +++- src/commands/logs/push/history.ts | 17 ++- src/commands/logs/push/subscribe.ts | 44 ++----- src/commands/logs/subscribe.ts | 6 +- src/commands/queues/delete.ts | 23 +++- src/commands/rooms/list.ts | 8 +- src/commands/rooms/messages/history.ts | 33 +++--- .../rooms/messages/reactions/remove.ts | 5 +- src/commands/rooms/messages/reactions/send.ts | 5 +- .../rooms/messages/reactions/subscribe.ts | 16 +-- src/commands/rooms/messages/send.ts | 8 +- src/commands/rooms/messages/subscribe.ts | 17 +-- src/commands/rooms/occupancy/get.ts | 14 ++- src/commands/rooms/occupancy/subscribe.ts | 14 ++- src/commands/rooms/presence/enter.ts | 11 +- src/commands/rooms/presence/subscribe.ts | 6 +- src/commands/rooms/reactions/send.ts | 3 +- src/commands/rooms/reactions/subscribe.ts | 5 +- src/commands/rooms/typing/keystroke.ts | 8 +- src/commands/rooms/typing/subscribe.ts | 5 +- src/commands/spaces/cursors/get-all.ts | 18 +-- src/commands/spaces/cursors/set.ts | 6 +- src/commands/spaces/cursors/subscribe.ts | 18 +-- src/commands/spaces/list.ts | 6 +- src/commands/spaces/locations.ts | 9 +- src/commands/spaces/locations/get-all.ts | 102 ++++++---------- src/commands/spaces/locations/set.ts | 6 +- src/commands/spaces/locations/subscribe.ts | 14 +-- src/commands/spaces/locks/acquire.ts | 9 +- src/commands/spaces/locks/get-all.ts | 4 +- src/commands/spaces/locks/get.ts | 30 +---- src/commands/spaces/locks/subscribe.ts | 20 ++-- src/commands/spaces/members/enter.ts | 6 +- src/commands/spaces/members/subscribe.ts | 18 +-- src/commands/stats/account.ts | 62 +++++++--- src/commands/stats/app.ts | 54 ++++++--- src/commands/support/ask.ts | 110 ++++++++++-------- src/flags.ts | 16 +++ src/services/history-manager.ts | 17 +-- src/utils/time.ts | 36 ++++++ test/e2e/interactive/ctrl-c-behavior.test.ts | 11 -- test/e2e/spaces/spaces-e2e.test.ts | 9 -- test/unit/commands/apps/set-apns-p12.test.ts | 6 +- .../commands/channels/batch-publish.test.ts | 36 ++---- test/unit/commands/channels/history.test.ts | 48 ++++++++ test/unit/commands/did-you-mean.test.ts | 33 ++---- test/unit/commands/interactive-sigint.test.ts | 24 ++-- test/unit/commands/interactive.test.ts | 5 +- test/unit/commands/queues/delete.test.ts | 8 +- test/unit/commands/rooms/messages.test.ts | 57 +++++++++ test/unit/commands/stats/account.test.ts | 92 +++++++++++++++ test/unit/commands/stats/app.test.ts | 83 +++++++++++++ test/unit/utils/time.test.ts | 101 ++++++++++++++++ 81 files changed, 1030 insertions(+), 591 deletions(-) create mode 100644 src/utils/time.ts create mode 100644 test/unit/commands/stats/account.test.ts create mode 100644 test/unit/commands/stats/app.test.ts create mode 100644 test/unit/utils/time.test.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d75cf666..e5b5fa57 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -123,7 +123,8 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v - **`coreGlobalFlags`** — `--verbose`, `--json`, `--pretty-json`, `--web-cli-help` (hidden) (on every command via `AblyBaseCommand.globalFlags`) - **`productApiFlags`** — core + hidden product API flags (`port`, `tlsPort`, `tls`). Use for commands that talk to the Ably product API. - **`controlApiFlags`** — core + hidden control API flags (`control-host`, `dashboard-host`). Use for commands that talk to the Control API. -- **`clientIdFlag`** — `--client-id`. Add only to commands that create a realtime connection where client identity matters (presence, spaces members, cursors, locks, publish, etc.). Do NOT add globally. +- **`clientIdFlag`** — `--client-id`. Add to any command that creates a realtime connection (publish, subscribe, presence enter/subscribe, spaces enter/get/subscribe, locks acquire/get/subscribe, cursors set/get/subscribe, locations set/get/subscribe, etc.). The rule: if the command calls `space.enter()`, creates a realtime client, or joins a channel, include `clientIdFlag`. Do NOT add globally. +- **`timeRangeFlags`** — `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). - **`endpointFlag`** — `--endpoint`. Hidden, only on `accounts login` and `accounts switch`. **When creating a new command:** @@ -214,6 +215,8 @@ But focus on THIS project unless specifically asked about others. - **Resource names**: Always `resource(name)` (cyan), never quoted — including in `logCliEvent` messages. - **Timestamps**: `formatTimestamp(ts)` — dim `[timestamp]` for event streams. Exported as `formatTimestamp` to avoid clashing with local `timestamp` variables. - **JSON guard**: All human-readable output (progress, success, listening messages) must be wrapped in `if (!this.shouldOutputJson(flags))` so it doesn't pollute `--json` output. Only JSON payloads should be emitted when `--json` is active. +- **JSON errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode — always emit a JSON error object or use `this.jsonError()`. +- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` ``. Consistent across all history commands (channels, logs, connection-lifecycle, push). ### Additional output patterns (direct chalk, not helpers) - **Secondary labels**: `chalk.dim("Label:")` — for field names in structured output (e.g., `${chalk.dim("Profile:")} ${value}`) @@ -240,8 +243,10 @@ When adding COMMANDS sections in `src/help.ts`, use `chalk.bold()` for headers, - All flags kebab-case: `--my-flag` (never camelCase) - `--app`: `"The app ID or name (defaults to current app)"` (for commands with `resolveAppId`), `"The app ID (defaults to current app)"` (for commands without) - `--limit`: `"Maximum number of results to return (default: N)"` -- `--duration`: `"Automatically exit after N seconds (0 = run indefinitely)"`, alias `-D` +- `--duration`: `"Automatically exit after N seconds"`, alias `-D` - `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"` +- `--start`/`--end`: Use `timeRangeFlags` from `src/flags.ts` and parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). +- `--direction`: `"Direction of message retrieval (default: backwards)"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`. - Channels use "publish", Rooms use "send" (matches SDK terminology) - Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`) diff --git a/.cursor/rules/AI-Assistance.mdc b/.cursor/rules/AI-Assistance.mdc index 846ad710..a010a62b 100644 --- a/.cursor/rules/AI-Assistance.mdc +++ b/.cursor/rules/AI-Assistance.mdc @@ -124,14 +124,18 @@ This document provides guidance for AI assistants working with the Ably CLI code - **Event types**: `chalk.yellow(eventType)` — for action/event type labels - **Warnings**: `chalk.yellow("Warning: ...")` — for non-fatal warnings - **Errors**: Use `this.error()` (oclif standard) for fatal errors, not `this.log(chalk.red(...))` +- **JSON errors**: In catch blocks, emit structured JSON when `--json` is active: `this.formatJsonOutput({ error: errorMsg, success: false }, flags)`. Never silently swallow errors in JSON mode. +- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` `` - **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'` ### Flag conventions - All flags kebab-case: `--my-flag` (never camelCase) - `--app`: `"The app ID or name (defaults to current app)"` (for commands with `resolveAppId`), `"The app ID (defaults to current app)"` (for commands without) - `--limit`: `"Maximum number of results to return (default: N)"` -- `--duration`: `"Automatically exit after N seconds (0 = run indefinitely)"`, alias `-D` +- `--duration`: `"Automatically exit after N seconds"`, alias `-D` - `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"` +- `--start`/`--end`: Use `timeRangeFlags` from `src/flags.ts` and parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). +- `--direction`: `"Direction of message retrieval (default: backwards)"` or `"Direction of log retrieval"`, options `["backwards", "forwards"]`. - Channels use "publish", Rooms use "send" (matches SDK terminology) - Command descriptions: imperative mood, sentence case, no trailing period (e.g., `"Subscribe to presence events on a channel"`) diff --git a/eslint.config.js b/eslint.config.js index e1356967..58e41f19 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,7 +32,9 @@ export default [ "packages/react-web-cli/dist/index.mjs", "bin/", // Added from .eslintrc.cjs "playwright-report/**", // Ignore Playwright report files - "vitest.config.ts" + "vitest.config.ts", + ".claude/worktrees/**", + ".claude/skills/**" ], // Updated to match all ignorePatterns from .eslintrc.json }, { diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index 512a9da8..22f21dc8 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -7,7 +7,7 @@ import { ControlBaseCommand } from "../../control-base-command.js"; import { endpointFlag } from "../../flags.js"; import { ControlApi } from "../../services/control-api.js"; import { displayLogo } from "../../utils/logo.js"; -import { resource, success } from "../../utils/output.js"; +import { progress, resource, success } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; // Moved function definition outside the class @@ -96,7 +96,7 @@ export default class AccountsLogin extends ControlBaseCommand { // Prompt the user to get a token if (!flags["no-browser"]) { if (!this.shouldOutputJson(flags)) { - this.log("Opening browser to get an access token..."); + this.log(progress("Opening browser to get an access token")); } await this.openBrowser(obtainTokenPath); @@ -227,7 +227,7 @@ export default class AccountsLogin extends ControlBaseCommand { const appName = await this.promptForAppName(); try { - this.log(`\nCreating app "${appName}"...`); + this.log(`\n${progress(`Creating app ${resource(appName)}`)}`); const app = await controlApi.createApp({ name: appName, diff --git a/src/commands/apps/channel-rules/create.ts b/src/commands/apps/channel-rules/create.ts index 5421b1a2..4cb8a8c8 100644 --- a/src/commands/apps/channel-rules/create.ts +++ b/src/commands/apps/channel-rules/create.ts @@ -90,10 +90,10 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { const { flags } = await this.parse(ChannelRulesCreateCommand); const controlApi = this.createControlApi(flags); - let appId: string | undefined; + + let appId: string | undefined = flags.app; try { - let appId = flags.app; if (!appId) { appId = await this.resolveAppId(flags); } diff --git a/src/commands/apps/channel-rules/delete.ts b/src/commands/apps/channel-rules/delete.ts index 5ec1f4b4..60cc3d86 100644 --- a/src/commands/apps/channel-rules/delete.ts +++ b/src/commands/apps/channel-rules/delete.ts @@ -40,10 +40,10 @@ export default class ChannelRulesDeleteCommand extends ControlBaseCommand { const { args, flags } = await this.parse(ChannelRulesDeleteCommand); const controlApi = this.createControlApi(flags); - let appId: string | undefined; + + let appId: string | undefined = flags.app; try { - let appId = flags.app; if (!appId) { appId = await this.resolveAppId(flags); } diff --git a/src/commands/apps/channel-rules/list.ts b/src/commands/apps/channel-rules/list.ts index 699574b4..a0e7e32a 100644 --- a/src/commands/apps/channel-rules/list.ts +++ b/src/commands/apps/channel-rules/list.ts @@ -34,7 +34,7 @@ export default class ChannelRulesListCommand extends ControlBaseCommand { ]; static flags = { - ...ControlBaseCommand.flags, + ...ControlBaseCommand.globalFlags, app: Flags.string({ description: "The app ID or name (defaults to current app)", required: false, diff --git a/src/commands/apps/create.ts b/src/commands/apps/create.ts index dd5e9b2d..2ccd1ee7 100644 --- a/src/commands/apps/create.ts +++ b/src/commands/apps/create.ts @@ -1,7 +1,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { resource, success } from "../../utils/output.js"; +import { progress, resource, success } from "../../utils/output.js"; export default class AppsCreateCommand extends ControlBaseCommand { static description = "Create a new app"; @@ -31,7 +31,7 @@ export default class AppsCreateCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { - this.log(`Creating app "${flags.name}"...`); + this.log(progress(`Creating app ${resource(flags.name)}`)); } const app = await controlApi.createApp({ diff --git a/src/commands/apps/delete.ts b/src/commands/apps/delete.ts index eba370a0..07105848 100644 --- a/src/commands/apps/delete.ts +++ b/src/commands/apps/delete.ts @@ -2,6 +2,7 @@ import { Args, Flags } from "@oclif/core"; import * as readline from "node:readline"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { progress, resource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import AppsSwitch from "./switch.js"; @@ -131,7 +132,7 @@ export default class AppsDeleteCommand extends ControlBaseCommand { } if (!this.shouldOutputJson(flags)) { - this.log(`Deleting app ${appIdToDelete}...`); + this.log(progress(`Deleting app ${resource(appIdToDelete)}`)); } await controlApi.deleteApp(appIdToDelete); diff --git a/src/commands/apps/set-apns-p12.ts b/src/commands/apps/set-apns-p12.ts index 4fc1c15e..fcd8407a 100644 --- a/src/commands/apps/set-apns-p12.ts +++ b/src/commands/apps/set-apns-p12.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { progress, resource, success } from "../../utils/output.js"; export default class AppsSetApnsP12Command extends ControlBaseCommand { static args = { @@ -53,7 +54,9 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { return; } - this.log(`Uploading APNS P12 certificate for app ${args.id}...`); + this.log( + progress(`Uploading APNS P12 certificate for app ${resource(args.id)}`), + ); // Read certificate file and encode as base64 const certificateData = fs @@ -68,7 +71,7 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(result, flags)); } else { - this.log(`\nAPNS P12 certificate uploaded successfully!`); + this.log(success("APNS P12 certificate uploaded.")); this.log(`Certificate ID: ${result.id}`); if (flags["use-for-sandbox"]) { this.log(`Environment: Sandbox`); diff --git a/src/commands/apps/update.ts b/src/commands/apps/update.ts index 4c00af2c..a8089b49 100644 --- a/src/commands/apps/update.ts +++ b/src/commands/apps/update.ts @@ -1,6 +1,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { progress, resource } from "../../utils/output.js"; export default class AppsUpdateCommand extends ControlBaseCommand { static args = { @@ -59,7 +60,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { - this.log(`Updating app ${args.id}...`); + this.log(progress(`Updating app ${resource(args.id)}`)); } const updateData: { name?: string; tlsOnly?: boolean } = {}; diff --git a/src/commands/auth/keys/create.ts b/src/commands/auth/keys/create.ts index 57a8e405..82ea446c 100644 --- a/src/commands/auth/keys/create.ts +++ b/src/commands/auth/keys/create.ts @@ -1,7 +1,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; -import { resource, success } from "../../../utils/output.js"; +import { progress, resource, success } from "../../../utils/output.js"; export default class KeysCreateCommand extends ControlBaseCommand { static description = "Create a new API key for an app"; @@ -84,7 +84,11 @@ export default class KeysCreateCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { - this.log(`Creating key "${flags.name}" for app ${appId}...`); + this.log( + progress( + `Creating key ${resource(flags.name)} for app ${resource(appId)}`, + ), + ); } const key = await controlApi.createKey(appId, { diff --git a/src/commands/bench/subscriber.ts b/src/commands/bench/subscriber.ts index 25e58d70..fb499707 100644 --- a/src/commands/bench/subscriber.ts +++ b/src/commands/bench/subscriber.ts @@ -35,7 +35,7 @@ export default class BenchSubscriber extends AblyBaseCommand { ...productApiFlags, duration: Flags.integer({ char: "D", - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", }), }; diff --git a/src/commands/channels/batch-publish.ts b/src/commands/channels/batch-publish.ts index 6320e4e4..dae775c1 100644 --- a/src/commands/channels/batch-publish.ts +++ b/src/commands/channels/batch-publish.ts @@ -1,6 +1,9 @@ import { Args, Flags } from "@oclif/core"; +import chalk from "chalk"; + import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; +import { progress, resource, success } from "../../utils/output.js"; // Define interfaces for the batch-publish command interface BatchMessage { @@ -245,8 +248,8 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { } as BatchContent; } - if (!this.shouldSuppressOutput(flags)) { - this.log("Sending batch publish request..."); + if (!this.shouldOutputJson(flags)) { + this.log(progress("Sending batch publish request")); } // Make the batch publish request using the REST client's request method @@ -280,7 +283,7 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { ), ); } else { - this.log("Batch publish successful!"); + this.log(success("Batch publish successful.")); this.log( `Response: ${this.formatJsonOutput({ responses: responseItems }, flags)}`, ); @@ -329,11 +332,13 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { batchResponses.forEach((item: BatchResponseItem) => { if (item.error) { this.log( - `Failed to publish to channel '${item.channel}': ${item.error.message} (${item.error.code})`, + `${chalk.red("✗")} Failed to publish to channel ${resource(item.channel)}: ${item.error.message} (${item.error.code})`, ); } else { this.log( - `Published to channel '${item.channel}' with messageId: ${item.messageId}`, + success( + `Published to channel ${resource(item.channel)} with messageId: ${item.messageId}.`, + ), ); } }); diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 55dfa15a..6a36003c 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -3,9 +3,10 @@ import * as Ably from "ably"; import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; -import { productApiFlags } from "../../flags.js"; +import { productApiFlags, timeRangeFlags } from "../../flags.js"; import { formatJson, isJsonData } from "../../utils/json-formatter.js"; -import { resource } from "../../utils/output.js"; +import { formatTimestamp, resource } from "../../utils/output.js"; +import { parseTimestamp } from "../../utils/time.js"; export default class ChannelsHistory extends AblyBaseCommand { static override args = { @@ -22,6 +23,7 @@ export default class ChannelsHistory extends AblyBaseCommand { "$ ably channels history my-channel --json", "$ ably channels history my-channel --pretty-json", '$ ably channels history my-channel --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', + "$ ably channels history my-channel --start 1h", "$ ably channels history my-channel --limit 100", "$ ably channels history my-channel --direction forward", ]; @@ -37,16 +39,11 @@ export default class ChannelsHistory extends AblyBaseCommand { options: ["backwards", "forwards"], }), - end: Flags.string({ - description: "End time for the history query (ISO 8601 format)", - }), + ...timeRangeFlags, limit: Flags.integer({ default: 50, description: "Maximum number of results to return (default: 50)", }), - start: Flags.string({ - description: "Start time for the history query (ISO 8601 format)", - }), }; async run(): Promise { @@ -82,11 +79,11 @@ export default class ChannelsHistory extends AblyBaseCommand { // Add time range if specified if (flags.start) { - historyParams.start = new Date(flags.start).getTime(); + historyParams.start = parseTimestamp(flags.start, "start"); } if (flags.end) { - historyParams.end = new Date(flags.end).getTime(); + historyParams.end = parseTimestamp(flags.end, "end"); } // Get history @@ -112,7 +109,9 @@ export default class ChannelsHistory extends AblyBaseCommand { ? new Date(message.timestamp).toISOString() : "Unknown timestamp"; - this.log(chalk.dim(`[${index + 1}] ${timestamp}`)); + this.log( + `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}`, + ); this.log( `${chalk.dim("Event:")} ${chalk.yellow(message.name || "(none)")}`, ); diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index cf5e6bce..8ebe65a1 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -2,6 +2,7 @@ import { Flags } from "@oclif/core"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import chalk from "chalk"; +import { resource } from "../../utils/output.js"; interface ChannelMetrics { connections?: number; @@ -115,7 +116,7 @@ export default class ChannelsList extends AblyBaseCommand { ); for (const channel of channels as ChannelItem[]) { - this.log(`${chalk.green(channel.channelId)}`); + this.log(`${resource(channel.channelId)}`); // Show occupancy if available if (channel.status?.occupancy?.metrics) { diff --git a/src/commands/channels/occupancy.ts b/src/commands/channels/occupancy.ts index cec95281..29a28f67 100644 --- a/src/commands/channels/occupancy.ts +++ b/src/commands/channels/occupancy.ts @@ -4,9 +4,9 @@ export default class ChannelsOccupancy extends BaseTopicCommand { protected topicName = "channels:occupancy"; protected commandGroup = "channel occupancy"; - static description = "Get occupancy metrics for a channel"; + static override description = "Get occupancy metrics for a channel"; - static examples = [ + static override examples = [ "$ ably channels occupancy get my-channel", "$ ably channels occupancy subscribe my-channel", ]; diff --git a/src/commands/channels/occupancy/get.ts b/src/commands/channels/occupancy/get.ts index 84e04471..0dc1df33 100644 --- a/src/commands/channels/occupancy/get.ts +++ b/src/commands/channels/occupancy/get.ts @@ -1,7 +1,9 @@ import { Args } from "@oclif/core"; +import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; import { productApiFlags } from "../../../flags.js"; +import { resource } from "../../../utils/output.js"; interface OccupancyMetrics { connections: number; @@ -13,23 +15,23 @@ interface OccupancyMetrics { } export default class ChannelsOccupancyGet extends AblyBaseCommand { - static args = { + static override args = { channel: Args.string({ description: "Channel name to get occupancy for", required: true, }), }; - static description = "Get current occupancy metrics for a channel"; + static override description = "Get current occupancy metrics for a channel"; - static examples = [ + static override examples = [ "$ ably channels occupancy get my-channel", "$ ably channels occupancy get my-channel --json", "$ ably channels occupancy get my-channel --pretty-json", '$ ABLY_API_KEY="YOUR_API_KEY" ably channels occupancy get my-channel', ]; - static flags = { + static override flags = { ...productApiFlags, }; @@ -78,24 +80,32 @@ export default class ChannelsOccupancyGet extends AblyBaseCommand { ), ); } else { - this.log(`Occupancy metrics for channel '${channelName}':\n`); - this.log(`Connections: ${occupancyMetrics.connections ?? 0}`); - this.log(`Publishers: ${occupancyMetrics.publishers ?? 0}`); - this.log(`Subscribers: ${occupancyMetrics.subscribers ?? 0}`); + this.log(`Occupancy metrics for channel ${resource(channelName)}:\n`); + this.log( + `${chalk.dim("Connections:")} ${occupancyMetrics.connections ?? 0}`, + ); + this.log( + `${chalk.dim("Publishers:")} ${occupancyMetrics.publishers ?? 0}`, + ); + this.log( + `${chalk.dim("Subscribers:")} ${occupancyMetrics.subscribers ?? 0}`, + ); if (occupancyMetrics.presenceConnections !== undefined) { this.log( - `Presence Connections: ${occupancyMetrics.presenceConnections}`, + `${chalk.dim("Presence Connections:")} ${occupancyMetrics.presenceConnections}`, ); } if (occupancyMetrics.presenceMembers !== undefined) { - this.log(`Presence Members: ${occupancyMetrics.presenceMembers}`); + this.log( + `${chalk.dim("Presence Members:")} ${occupancyMetrics.presenceMembers}`, + ); } if (occupancyMetrics.presenceSubscribers !== undefined) { this.log( - `Presence Subscribers: ${occupancyMetrics.presenceSubscribers}`, + `${chalk.dim("Presence Subscribers:")} ${occupancyMetrics.presenceSubscribers}`, ); } } diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index 4b6ad568..f861c0dd 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -34,7 +34,7 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { static override flags = { ...productApiFlags, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -117,7 +117,7 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { if (message.data !== null && message.data !== undefined) { this.log( - `${chalk.green("Occupancy Data:")} ${JSON.stringify(message.data, null, 2)}`, + `${chalk.dim("Occupancy Data:")} ${JSON.stringify(message.data, null, 2)}`, ); } diff --git a/src/commands/channels/presence/enter.ts b/src/commands/channels/presence/enter.ts index 95dfe48f..e884cd98 100644 --- a/src/commands/channels/presence/enter.ts +++ b/src/commands/channels/presence/enter.ts @@ -41,7 +41,7 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { description: "Optional JSON data to associate with the presence", }), duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -157,10 +157,10 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { presenceMessage.data !== undefined ) { if (isJsonData(presenceMessage.data)) { - this.log(chalk.green("Data:")); + this.log(chalk.dim("Data:")); this.log(JSON.stringify(presenceMessage.data, null, 2)); } else { - this.log(`${chalk.green("Data:")} ${presenceMessage.data}`); + this.log(`${chalk.dim("Data:")} ${presenceMessage.data}`); } } diff --git a/src/commands/channels/presence/subscribe.ts b/src/commands/channels/presence/subscribe.ts index 0e864afb..c6605d29 100644 --- a/src/commands/channels/presence/subscribe.ts +++ b/src/commands/channels/presence/subscribe.ts @@ -36,7 +36,7 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { ...productApiFlags, ...clientIdFlag, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -121,7 +121,7 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { presenceMessage.data !== undefined ) { this.log( - `${chalk.green("Data:")} ${JSON.stringify(presenceMessage.data, null, 2)}`, + `${chalk.dim("Data:")} ${JSON.stringify(presenceMessage.data, null, 2)}`, ); } diff --git a/src/commands/channels/publish.ts b/src/commands/channels/publish.ts index d7389f5f..713cb703 100644 --- a/src/commands/channels/publish.ts +++ b/src/commands/channels/publish.ts @@ -5,7 +5,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; -import { resource, success } from "../../utils/output.js"; +import { progress, resource, success } from "../../utils/output.js"; export default class ChannelsPublish extends AblyBaseCommand { static override args = { @@ -257,7 +257,7 @@ export default class ChannelsPublish extends AblyBaseCommand { { count, delay }, ); if (count > 1 && !this.shouldOutputJson(flags)) { - this.log(`Publishing ${count} messages with ${delay}ms delay...`); + this.log(progress(`Publishing ${count} messages with ${delay}ms delay`)); } let publishedCount = 0; diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index e149c4a5..f307e8c2 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -61,7 +61,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand { description: "Enable delta compression for messages", }), duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -83,10 +83,10 @@ export default class ChannelsSubscribe extends AblyBaseCommand { async run(): Promise { const { flags } = await this.parse(ChannelsSubscribe); - const _args = await this.parse(ChannelsSubscribe); + const parseResult = await this.parse(ChannelsSubscribe); // Get all channel names from argv - const channelNames = _args.argv as string[]; + const channelNames = parseResult.argv as string[]; let channels: Ably.RealtimeChannel[] = []; try { @@ -258,7 +258,7 @@ export default class ChannelsSubscribe extends AblyBaseCommand { await Promise.all(attachPromises); // Log the ready signal for E2E tests - if (channelNames.length === 1) { + if (channelNames.length === 1 && !this.shouldOutputJson(flags)) { this.log(`Successfully attached to channel: ${channelNames[0]}`); } diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index f6c0f530..3e166b8c 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -54,7 +54,7 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { const integration = await controlApi.getRule(appId, args.integrationId); // If not using force flag, prompt for confirmation - if (!flags.force) { + if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following integration:`); this.log(`Integration ID: ${integration.id}`); this.log(`Type: ${integration.ruleType}`); @@ -76,13 +76,31 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { await controlApi.deleteRule(appId, args.integrationId); - this.log( - success(`Integration rule deleted: ${resource(integration.id)}.`), - ); - this.log(`ID: ${integration.id}`); - this.log(`App ID: ${integration.appId}`); - this.log(`Type: ${integration.ruleType}`); - this.log(`Source Type: ${integration.source.type}`); + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + integration: { + appId: integration.appId, + id: integration.id, + ruleType: integration.ruleType, + sourceType: integration.source.type, + }, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + success(`Integration rule deleted: ${resource(integration.id)}.`), + ); + this.log(`ID: ${integration.id}`); + this.log(`App ID: ${integration.appId}`); + this.log(`Type: ${integration.ruleType}`); + this.log(`Source Type: ${integration.source.type}`); + } } catch (error) { this.error( `Error deleting integration: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 0bad5285..82fe6cd2 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -260,21 +260,6 @@ export default class Interactive extends Command { } private async setupReadline() { - // Debug terminal capabilities - if (process.env.ABLY_DEBUG_KEYS === "true") { - console.error("[DEBUG] Terminal capabilities:"); - console.error(` - process.stdin.isTTY: ${process.stdin.isTTY}`); - console.error(` - process.stdout.isTTY: ${process.stdout.isTTY}`); - console.error(` - TERM env: ${process.env.TERM}`); - console.error(` - COLORTERM env: ${process.env.COLORTERM}`); - console.error( - ` - terminal mode: ${process.stdin.isTTY ? "TTY" : "pipe"}`, - ); - console.error( - ` - setRawMode available: ${typeof (process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => void }).setRawMode === "function"}`, - ); - } - this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -784,11 +769,6 @@ export default class Interactive extends Command { line: string, callback?: (err: Error | null, result: [string[], string]) => void, ): [string[], string] | void { - // Debug logging - if (process.env.ABLY_DEBUG_KEYS === "true") { - console.error(`[DEBUG] Completer called with line: "${line}"`); - } - // Don't provide completions during history search if (this.historySearch.active) { const emptyResult: [string[], string] = [[], line]; @@ -803,11 +783,6 @@ export default class Interactive extends Command { // Support both sync and async patterns const result = this.getCompletions(line); - // Debug logging - if (process.env.ABLY_DEBUG_KEYS === "true") { - console.error(`[DEBUG] Completer returning:`, result); - } - if (callback) { // Async mode - used by readline for custom display callback(null, result); @@ -1165,30 +1140,6 @@ export default class Interactive extends Command { // Note: We don't call setRawMode(true) here because readline manages it // The keypress event handler will still work process.stdin.on("keypress", (str, key) => { - // Debug logging for all keypresses - if (process.env.ABLY_DEBUG_KEYS === "true") { - const keyInfo = key - ? { - name: key.name, - ctrl: key.ctrl, - meta: key.meta, - shift: key.shift, - sequence: key.sequence - ? [...key.sequence] - .map( - (c) => - `\\x${(c as string).codePointAt(0)?.toString(16).padStart(2, "0") ?? "00"}`, - ) - .join("") - : undefined, - } - : null; - console.error( - `[DEBUG] Keypress event - str: "${str}", key:`, - JSON.stringify(keyInfo), - ); - } - if (!key) return; // Ctrl+R: Start or cycle through history search diff --git a/src/commands/logs/channel-lifecycle/subscribe.ts b/src/commands/logs/channel-lifecycle/subscribe.ts index d2e17a7b..da42f371 100644 --- a/src/commands/logs/channel-lifecycle/subscribe.ts +++ b/src/commands/logs/channel-lifecycle/subscribe.ts @@ -24,6 +24,11 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { static override flags = { ...productApiFlags, + duration: Flags.integer({ + description: "Automatically exit after N seconds", + char: "D", + required: false, + }), rewind: Flags.integer({ default: 0, description: "Number of messages to rewind when subscribing (default: 0)", @@ -149,7 +154,7 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { ); this.logCliEvent(flags, "logs", "listening", "Listening for logs..."); - await waitUntilInterruptedOrTimeout(); + await waitUntilInterruptedOrTimeout(flags.duration); } catch (error: unknown) { const err = error as Error; this.logCliEvent( diff --git a/src/commands/logs/connection-lifecycle/history.ts b/src/commands/logs/connection-lifecycle/history.ts index 2574d412..d6b571f4 100644 --- a/src/commands/logs/connection-lifecycle/history.ts +++ b/src/commands/logs/connection-lifecycle/history.ts @@ -3,8 +3,10 @@ import * as Ably from "ably"; import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { productApiFlags, timeRangeFlags } from "../../../flags.js"; import { formatJson, isJsonData } from "../../../utils/json-formatter.js"; +import { formatTimestamp } from "../../../utils/output.js"; +import { parseTimestamp } from "../../../utils/time.js"; export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { static override description = "Retrieve connection lifecycle log history"; @@ -15,10 +17,13 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { "$ ably logs connection-lifecycle history --direction forwards", "$ ably logs connection-lifecycle history --json", "$ ably logs connection-lifecycle history --pretty-json", + '$ ably logs connection-lifecycle history --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', + "$ ably logs connection-lifecycle history --start 1h", ]; static override flags = { ...productApiFlags, + ...timeRangeFlags, direction: Flags.string({ default: "backwards", description: "Direction of log retrieval", @@ -49,6 +54,15 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { limit: flags.limit, }; + // Add time range if specified + if (flags.start) { + historyParams.start = parseTimestamp(flags.start, "start"); + } + + if (flags.end) { + historyParams.end = parseTimestamp(flags.end, "end"); + } + // Get history const history = await channel.history(historyParams); const messages = history.items; @@ -90,7 +104,9 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { ? new Date(message.timestamp).toISOString() : "Unknown timestamp"; - this.log(chalk.dim(`[${index + 1}] ${timestamp}`)); + this.log( + `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}`, + ); // Event name if (message.name) { diff --git a/src/commands/logs/connection-lifecycle/subscribe.ts b/src/commands/logs/connection-lifecycle/subscribe.ts index caa76368..72ee4a52 100644 --- a/src/commands/logs/connection-lifecycle/subscribe.ts +++ b/src/commands/logs/connection-lifecycle/subscribe.ts @@ -20,18 +20,17 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { static override flags = { ...productApiFlags, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), rewind: Flags.integer({ - description: "Number of messages to replay from history when subscribing", + description: "Number of messages to rewind when subscribing (default: 0)", default: 0, required: false, }), }; - private cleanupInProgress = false; private client: Ably.Realtime | null = null; private cleanupChannelStateLogging: (() => void) | null = null; @@ -129,7 +128,6 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { this.logCliEvent(flags, "logs", "runComplete", "Exiting wait loop", { exitReason, }); - this.cleanupInProgress = exitReason === "signal"; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent( diff --git a/src/commands/logs/history.ts b/src/commands/logs/history.ts index a1a39f3d..f70f1a8c 100644 --- a/src/commands/logs/history.ts +++ b/src/commands/logs/history.ts @@ -3,8 +3,10 @@ import * as Ably from "ably"; import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; -import { productApiFlags } from "../../flags.js"; +import { productApiFlags, timeRangeFlags } from "../../flags.js"; import { formatJson, isJsonData } from "../../utils/json-formatter.js"; +import { formatTimestamp } from "../../utils/output.js"; +import { parseTimestamp } from "../../utils/time.js"; export default class LogsHistory extends AblyBaseCommand { static override description = "Retrieve application log history"; @@ -15,10 +17,13 @@ export default class LogsHistory extends AblyBaseCommand { "$ ably logs history --direction forwards", "$ ably logs history --json", "$ ably logs history --pretty-json", + '$ ably logs history --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', + "$ ably logs history --start 1h", ]; static override flags = { ...productApiFlags, + ...timeRangeFlags, direction: Flags.string({ default: "backwards", description: "Direction of log retrieval", @@ -49,6 +54,15 @@ export default class LogsHistory extends AblyBaseCommand { limit: flags.limit, }; + // Add time range if specified + if (flags.start) { + historyParams.start = parseTimestamp(flags.start, "start"); + } + + if (flags.end) { + historyParams.end = parseTimestamp(flags.end, "end"); + } + // Get history const history = await channel.history(historyParams); const messages = history.items; @@ -90,7 +104,9 @@ export default class LogsHistory extends AblyBaseCommand { ? new Date(message.timestamp).toISOString() : "Unknown timestamp"; - this.log(chalk.dim(`[${index + 1}] ${timestamp}`)); + this.log( + `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}`, + ); // Event name if (message.name) { diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index 37f200f1..ce024813 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -2,9 +2,10 @@ import { Flags } from "@oclif/core"; import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { productApiFlags, timeRangeFlags } from "../../../flags.js"; import { formatJson, isJsonData } from "../../../utils/json-formatter.js"; import { formatTimestamp } from "../../../utils/output.js"; +import { parseTimestamp } from "../../../utils/time.js"; export default class LogsPushHistory extends AblyBaseCommand { static override description = "Retrieve push notification log history"; @@ -15,10 +16,13 @@ export default class LogsPushHistory extends AblyBaseCommand { "$ ably logs push history --direction forwards", "$ ably logs push history --json", "$ ably logs push history --pretty-json", + '$ ably logs push history --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', + "$ ably logs push history --start 1h", ]; static override flags = { ...productApiFlags, + ...timeRangeFlags, direction: Flags.string({ default: "backwards", description: "Direction of log retrieval", @@ -44,11 +48,20 @@ export default class LogsPushHistory extends AblyBaseCommand { const channel = client.channels.get(channelName); // Get message history - const historyOptions = { + const historyOptions: Record = { direction: flags.direction as "backwards" | "forwards", limit: flags.limit, }; + // Add time range if specified + if (flags.start) { + historyOptions.start = parseTimestamp(flags.start, "start"); + } + + if (flags.end) { + historyOptions.end = parseTimestamp(flags.end, "end"); + } + const historyPage = await channel.history(historyOptions); const messages = historyPage.items; diff --git a/src/commands/logs/push/subscribe.ts b/src/commands/logs/push/subscribe.ts index b95f8945..3a286387 100644 --- a/src/commands/logs/push/subscribe.ts +++ b/src/commands/logs/push/subscribe.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; import { productApiFlags } from "../../../flags.js"; import { formatJson, isJsonData } from "../../../utils/json-formatter.js"; +import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { listening, resource, @@ -23,6 +24,11 @@ export default class LogsPushSubscribe extends AblyBaseCommand { static override flags = { ...productApiFlags, + duration: Flags.integer({ + description: "Automatically exit after N seconds", + char: "D", + required: false, + }), rewind: Flags.integer({ default: 0, description: "Number of messages to rewind when subscribing (default: 0)", @@ -171,42 +177,10 @@ export default class LogsPushSubscribe extends AblyBaseCommand { `Subscribed to ${channelName}`, ); - // Set up cleanup for when the process is terminated - const cleanup = () => { - this.logCliEvent( - flags, - "logs", - "cleanupInitiated", - "Cleanup initiated (Ctrl+C pressed)", - ); - // Client cleanup is handled by command finally() method - this.logCliEvent( - flags, - "connection", - "cleanup", - "Client cleanup will be handled by base class.", - ); - }; - - // Handle process termination - process.on("SIGINT", () => { - if (!this.shouldOutputJson(flags)) { - this.log("\nSubscription ended"); - } - - cleanup(); - - process.exit(0); // Reinstated: Explicit exit on signal - }); - process.on("SIGTERM", () => { - cleanup(); - - process.exit(0); // Reinstated: Explicit exit on signal - }); - this.logCliEvent(flags, "logs", "listening", "Listening for logs..."); - // Wait indefinitely - await new Promise(() => {}); + + // Wait until the user interrupts or the optional duration elapses + await waitUntilInterruptedOrTimeout(flags.duration); } catch (error: unknown) { const err = error as Error; this.logCliEvent( diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index 5a908b0b..800b0640 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -27,7 +27,7 @@ export default class LogsSubscribe extends AblyBaseCommand { static override flags = { ...productApiFlags, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -47,13 +47,11 @@ export default class LogsSubscribe extends AblyBaseCommand { }), }; - private cleanupInProgress = false; private client: Ably.Realtime | null = null; async run(): Promise { const { flags } = await this.parse(LogsSubscribe); let channel: Ably.RealtimeChannel | null = null; - let subscribedEvents: string[] = []; try { this.client = await this.createAblyRealtimeClient(flags); @@ -158,7 +156,6 @@ export default class LogsSubscribe extends AblyBaseCommand { this.log(""); // Empty line for better readability } }); - subscribedEvents.push(logType); } this.logCliEvent( @@ -176,7 +173,6 @@ export default class LogsSubscribe extends AblyBaseCommand { this.logCliEvent(flags, "logs", "runComplete", "Exiting wait loop", { exitReason, }); - this.cleanupInProgress = exitReason === "signal"; } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); this.logCliEvent( diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index 3349b7a1..e74e6478 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -1,6 +1,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { resource, success } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class QueuesDeleteCommand extends ControlBaseCommand { @@ -59,7 +60,7 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { } // If not using force flag, prompt for confirmation - if (!flags.force) { + if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following queue:`); this.log(`Queue ID: ${queue.id}`); this.log(`Name: ${queue.name}`); @@ -81,7 +82,25 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { await controlApi.deleteQueue(appId, queue.id); - this.log(`Queue "${queue.name}" (ID: ${queue.id}) deleted successfully`); + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + queue: { + id: queue.id, + name: queue.name, + }, + success: true, + timestamp: new Date().toISOString(), + }, + flags, + ), + ); + } else { + this.log( + success(`Queue deleted: ${resource(queue.name)} (${queue.id}).`), + ); + } } catch (error) { this.error( `Error deleting queue: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index a87790ac..1e2cfb0f 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -1,5 +1,7 @@ import { Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../chat-base-command.js"; +import { productApiFlags } from "../../flags.js"; +import { resource } from "../../utils/output.js"; import chalk from "chalk"; // Add interface definitions at the beginning of the file @@ -41,7 +43,7 @@ export default class RoomsList extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, limit: Flags.integer({ default: 100, description: "Maximum number of results to return (default: 100)", @@ -132,11 +134,11 @@ export default class RoomsList extends ChatBaseCommand { } this.log( - `Found ${chalk.cyan(limitedRooms.length.toString())} active chat rooms:`, + `Found ${resource(limitedRooms.length.toString())} active chat rooms:`, ); for (const room of limitedRooms) { - this.log(`${chalk.green(room.room)}`); + this.log(`${resource(room.room)}`); // Show occupancy if available if (room.status?.occupancy?.metrics) { diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index fb415356..9549057a 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -3,7 +3,14 @@ import { OrderBy } from "@ably/chat"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { formatTimestamp } from "../../../utils/output.js"; +import { productApiFlags, timeRangeFlags } from "../../../flags.js"; +import { + progress, + success, + resource, + formatTimestamp, +} from "../../../utils/output.js"; +import { parseTimestamp } from "../../../utils/time.js"; export default class MessagesHistory extends ChatBaseCommand { static override args = { @@ -23,16 +30,15 @@ export default class MessagesHistory extends ChatBaseCommand { "$ ably rooms messages history --show-metadata my-room", '$ ably rooms messages history my-room --start "2025-01-01T00:00:00Z"', '$ ably rooms messages history my-room --start "2025-01-01T00:00:00Z" --end "2025-01-02T00:00:00Z"', + "$ ably rooms messages history my-room --start 1h", "$ ably rooms messages history my-room --order newestFirst", "$ ably rooms messages history my-room --json", "$ ably rooms messages history my-room --pretty-json", ]; static override flags = { - ...ChatBaseCommand.globalFlags, - end: Flags.string({ - description: "End time for the history query (ISO 8601 format)", - }), + ...productApiFlags, + ...timeRangeFlags, limit: Flags.integer({ char: "l", default: 50, @@ -48,9 +54,6 @@ export default class MessagesHistory extends ChatBaseCommand { default: false, description: "Display message metadata if available", }), - start: Flags.string({ - description: "Start time for the history query (ISO 8601 format)", - }), }; async run(): Promise { @@ -86,7 +89,9 @@ export default class MessagesHistory extends ChatBaseCommand { ); } else { this.log( - `${chalk.green("Fetching")} ${chalk.yellow(flags.limit.toString())} ${chalk.green("most recent messages from room:")} ${chalk.bold(args.room)}`, + progress( + `Fetching ${flags.limit} most recent messages from room ${resource(args.room)}`, + ), ); } } @@ -107,11 +112,11 @@ export default class MessagesHistory extends ChatBaseCommand { // Add time range if specified if (flags.start) { - historyParams.start = new Date(flags.start).getTime(); + historyParams.start = parseTimestamp(flags.start, "start"); } if (flags.end) { - historyParams.end = new Date(flags.end).getTime(); + historyParams.end = parseTimestamp(flags.end, "end"); } // Get historical messages @@ -138,9 +143,7 @@ export default class MessagesHistory extends ChatBaseCommand { ); } else { // Display messages count - this.log( - `${chalk.green("Retrieved")} ${chalk.yellow(items.length.toString())} ${chalk.green("messages.")}`, - ); + this.log(success(`Retrieved ${items.length} messages.`)); if (items.length === 0) { this.log(chalk.dim("No messages found in this room.")); @@ -155,7 +158,7 @@ export default class MessagesHistory extends ChatBaseCommand { const author = message.clientId || "Unknown"; this.log( - `${formatTimestamp(timestamp)} ${chalk.cyan(`${author}:`)} ${message.text}`, + `${formatTimestamp(timestamp)} ${chalk.blue(`${author}:`)} ${message.text}`, ); // Show metadata if enabled and available diff --git a/src/commands/rooms/messages/reactions/remove.ts b/src/commands/rooms/messages/reactions/remove.ts index 711cbf08..84b71a29 100644 --- a/src/commands/rooms/messages/reactions/remove.ts +++ b/src/commands/rooms/messages/reactions/remove.ts @@ -8,6 +8,7 @@ import { import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; +import { productApiFlags } from "../../../../flags.js"; import { resource, success } from "../../../../utils/output.js"; // Map CLI-friendly type names to SDK MessageReactionType values @@ -54,7 +55,7 @@ export default class MessagesReactionsRemove extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, type: Flags.string({ description: "The type of reaction (unique, distinct, or multiple)", options: Object.keys(REACTION_TYPE_MAP), @@ -185,7 +186,7 @@ export default class MessagesReactionsRemove extends ChatBaseCommand { } else { this.log( success( - `Removed reaction ${chalk.yellow(reaction)} from message ${resource(messageSerial)} in room ${resource(room)}`, + `Removed reaction ${chalk.yellow(reaction)} from message ${resource(messageSerial)} in room ${resource(room)}.`, ), ); } diff --git a/src/commands/rooms/messages/reactions/send.ts b/src/commands/rooms/messages/reactions/send.ts index 63af3fa3..eb67b192 100644 --- a/src/commands/rooms/messages/reactions/send.ts +++ b/src/commands/rooms/messages/reactions/send.ts @@ -9,6 +9,7 @@ import { import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; +import { productApiFlags } from "../../../../flags.js"; import { resource, success } from "../../../../utils/output.js"; // Map CLI-friendly type names to SDK MessageReactionType values @@ -56,7 +57,7 @@ export default class MessagesReactionsSend extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, type: Flags.string({ description: "The type of reaction (unique, distinct, or multiple)", options: Object.keys(REACTION_TYPE_MAP), @@ -226,7 +227,7 @@ export default class MessagesReactionsSend extends ChatBaseCommand { } else { this.log( success( - `Sent reaction ${chalk.yellow(reaction)} to message ${resource(messageSerial)} in room ${resource(room)}`, + `Sent reaction ${chalk.yellow(reaction)} to message ${resource(messageSerial)} in room ${resource(room)}.`, ), ); } diff --git a/src/commands/rooms/messages/reactions/subscribe.ts b/src/commands/rooms/messages/reactions/subscribe.ts index 7d77b4c0..bd5fa3d8 100644 --- a/src/commands/rooms/messages/reactions/subscribe.ts +++ b/src/commands/rooms/messages/reactions/subscribe.ts @@ -9,6 +9,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; +import { productApiFlags } from "../../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../../utils/long-running.js"; import { listening, @@ -36,14 +37,14 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, raw: Flags.boolean({ description: "Subscribe to raw individual reaction events instead of summaries", default: false, }), duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -266,7 +267,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { Object.keys(event.reactions.unique).length > 0 ) { this.log(` ${chalk.blue("Unique reactions:")}`); - this.displayReactionSummary(event.reactions.unique, flags); + this.displayReactionSummary(event.reactions.unique); } if ( @@ -274,7 +275,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { Object.keys(event.reactions.distinct).length > 0 ) { this.log(` ${chalk.blue("Distinct reactions:")}`); - this.displayReactionSummary(event.reactions.distinct, flags); + this.displayReactionSummary(event.reactions.distinct); } if ( @@ -282,10 +283,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { Object.keys(event.reactions.multiple).length > 0 ) { this.log(` ${chalk.blue("Multiple reactions:")}`); - this.displayMultipleReactionSummary( - event.reactions.multiple, - flags, - ); + this.displayMultipleReactionSummary(event.reactions.multiple); } } }, @@ -326,7 +324,6 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { private displayReactionSummary( summary: Record, - _flags: { json?: boolean; "pretty-json"?: boolean }, ): void { for (const [reactionName, details] of Object.entries(summary)) { this.log( @@ -340,7 +337,6 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { string, { total: number; clientIds: Record } >, - _flags: { json?: boolean; "pretty-json"?: boolean }, ): void { for (const [reactionName, details] of Object.entries(summary)) { const clientList = Object.entries(details.clientIds) diff --git a/src/commands/rooms/messages/send.ts b/src/commands/rooms/messages/send.ts index 068595a6..0901e661 100644 --- a/src/commands/rooms/messages/send.ts +++ b/src/commands/rooms/messages/send.ts @@ -1,9 +1,9 @@ import { Args, Flags } from "@oclif/core"; import { ChatClient, ConnectionStatusChange, JsonObject } from "@ably/chat"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { success, resource } from "../../../utils/output.js"; +import { progress, success, resource } from "../../../utils/output.js"; // Define interfaces for the message send command interface MessageToSend { @@ -55,7 +55,7 @@ export default class MessagesSend extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, count: Flags.integer({ char: "c", @@ -197,7 +197,7 @@ export default class MessagesSend extends ChatBaseCommand { { count, delay }, ); if (count > 1 && !this.shouldOutputJson(flags)) { - this.log(`Sending ${count} messages with ${delay}ms delay...`); + this.log(progress(`Sending ${count} messages with ${delay}ms delay`)); } // Track send progress diff --git a/src/commands/rooms/messages/subscribe.ts b/src/commands/rooms/messages/subscribe.ts index 605fbb8e..2a319f61 100644 --- a/src/commands/rooms/messages/subscribe.ts +++ b/src/commands/rooms/messages/subscribe.ts @@ -2,10 +2,11 @@ import { Args, Flags } from "@oclif/core"; import { ChatMessageEvent, ChatClient } from "@ably/chat"; // Import ChatClient and StatusSubscription import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { + progress, success, listening, resource, @@ -55,14 +56,14 @@ export default class MessagesSubscribe extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, "show-metadata": Flags.boolean({ default: false, description: "Display message metadata if available", }), duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -151,7 +152,7 @@ export default class MessagesSubscribe extends ChatBaseCommand { // Message content with consistent formatting this.log( - `${roomPrefix}${formatTimestamp(timestamp)}${sequencePrefix} ${chalk.cyan(`${author}:`)} ${message.text}`, + `${roomPrefix}${formatTimestamp(timestamp)}${sequencePrefix} ${chalk.blue(`${author}:`)} ${message.text}`, ); // Show metadata if enabled and available @@ -279,12 +280,14 @@ export default class MessagesSubscribe extends ChatBaseCommand { const roomList = this.roomNames.length > 1 - ? this.roomNames.map((r) => chalk.cyan(r)).join(", ") - : chalk.cyan(this.roomNames[0]); + ? this.roomNames.map((r) => resource(r)).join(", ") + : resource(this.roomNames[0]); if (!this.shouldOutputJson(flags)) { this.log( - `Attaching to room${this.roomNames.length > 1 ? "s" : ""}: ${roomList}...`, + progress( + `Attaching to room${this.roomNames.length > 1 ? "s" : ""}: ${roomList}`, + ), ); } diff --git a/src/commands/rooms/occupancy/get.ts b/src/commands/rooms/occupancy/get.ts index 23330380..47661c3a 100644 --- a/src/commands/rooms/occupancy/get.ts +++ b/src/commands/rooms/occupancy/get.ts @@ -1,26 +1,28 @@ import { Args } from "@oclif/core"; import { ChatClient, Room, OccupancyData } from "@ably/chat"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { productApiFlags } from "../../../flags.js"; +import { resource } from "../../../utils/output.js"; export default class RoomsOccupancyGet extends ChatBaseCommand { - static args = { + static override args = { room: Args.string({ description: "Room to get occupancy for", required: true, }), }; - static description = "Get current occupancy metrics for a room"; + static override description = "Get current occupancy metrics for a room"; - static examples = [ + static override examples = [ "$ ably rooms occupancy get my-room", '$ ABLY_API_KEY="YOUR_API_KEY" ably rooms occupancy get my-room', "$ ably rooms occupancy get my-room --json", "$ ably rooms occupancy get my-room --pretty-json", ]; - static flags = { - ...ChatBaseCommand.globalFlags, + static override flags = { + ...productApiFlags, }; private chatClient: ChatClient | null = null; @@ -83,7 +85,7 @@ export default class RoomsOccupancyGet extends ChatBaseCommand { ), ); } else { - this.log(`Occupancy metrics for room '${roomName}':\n`); + this.log(`Occupancy metrics for room ${resource(roomName)}:\n`); this.log(`Connections: ${occupancyMetrics.connections ?? 0}`); this.log(`Presence Members: ${occupancyMetrics.presenceMembers ?? 0}`); diff --git a/src/commands/rooms/occupancy/subscribe.ts b/src/commands/rooms/occupancy/subscribe.ts index bd0157a7..29dee00d 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -8,6 +8,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { success, @@ -23,25 +24,26 @@ export interface OccupancyMetrics { } export default class RoomsOccupancySubscribe extends ChatBaseCommand { - static args = { + static override args = { room: Args.string({ description: "Room to subscribe to occupancy for", required: true, }), }; - static description = "Subscribe to real-time occupancy metrics for a room"; + static override description = + "Subscribe to real-time occupancy metrics for a room"; - static examples = [ + static override examples = [ "$ ably rooms occupancy subscribe my-room", "$ ably rooms occupancy subscribe my-room --json", "$ ably rooms occupancy subscribe --pretty-json my-room", ]; - static flags = { - ...ChatBaseCommand.globalFlags, + static override flags = { + ...productApiFlags, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index f25b3208..967df552 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -9,7 +9,7 @@ import { } from "@ably/chat"; import { Args, Flags, Interfaces } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -36,7 +36,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { "$ ably rooms presence enter my-room --duration 30", ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, "show-others": Flags.boolean({ @@ -44,7 +44,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { description: "Show other presence events while present (default: false)", }), duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -95,11 +95,6 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { } try { - // Always show the readiness signal first, before attempting auth - if (!this.shouldOutputJson(flags)) { - this.log(listening("Staying present.")); - } - // Create clients this.chatClient = await this.createChatClient(flags); diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index 7cbb5eed..cc231fb2 100644 --- a/src/commands/rooms/presence/subscribe.ts +++ b/src/commands/rooms/presence/subscribe.ts @@ -11,7 +11,7 @@ import { Args, Interfaces, Flags } from "@oclif/core"; import * as Ably from "ably"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -39,10 +39,10 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), diff --git a/src/commands/rooms/reactions/send.ts b/src/commands/rooms/reactions/send.ts index 3e31c2c2..63decba8 100644 --- a/src/commands/rooms/reactions/send.ts +++ b/src/commands/rooms/reactions/send.ts @@ -8,6 +8,7 @@ import { import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { productApiFlags } from "../../../flags.js"; import { resource, success } from "../../../utils/output.js"; export default class RoomsReactionsSend extends ChatBaseCommand { @@ -32,7 +33,7 @@ export default class RoomsReactionsSend extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, metadata: Flags.string({ description: "Additional metadata to send with the reaction (as JSON string)", diff --git a/src/commands/rooms/reactions/subscribe.ts b/src/commands/rooms/reactions/subscribe.ts index 5635c20d..eca9553a 100644 --- a/src/commands/rooms/reactions/subscribe.ts +++ b/src/commands/rooms/reactions/subscribe.ts @@ -3,6 +3,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { progress, @@ -29,9 +30,9 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index 1f8dce61..dc117832 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -2,6 +2,7 @@ import { RoomStatus, ChatClient, RoomStatusChange } from "@ably/chat"; import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { listening, resource, success } from "../../../utils/output.js"; @@ -34,11 +35,16 @@ export default class TypingKeystroke extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, "auto-type": Flags.boolean({ description: "Automatically keep typing indicator active", default: false, }), + duration: Flags.integer({ + description: "Automatically exit after N seconds", + char: "D", + required: false, + }), }; private chatClient: ChatClient | null = null; diff --git a/src/commands/rooms/typing/subscribe.ts b/src/commands/rooms/typing/subscribe.ts index 5a3453fa..4df2c9b3 100644 --- a/src/commands/rooms/typing/subscribe.ts +++ b/src/commands/rooms/typing/subscribe.ts @@ -3,6 +3,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; +import { productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { success, listening, resource } from "../../../utils/output.js"; @@ -25,9 +26,9 @@ export default class TypingSubscribe extends ChatBaseCommand { ]; static override flags = { - ...ChatBaseCommand.globalFlags, + ...productApiFlags, duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), diff --git a/src/commands/spaces/cursors/get-all.ts b/src/commands/spaces/cursors/get-all.ts index 40082d5c..1711cf95 100644 --- a/src/commands/spaces/cursors/get-all.ts +++ b/src/commands/spaces/cursors/get-all.ts @@ -1,7 +1,7 @@ import { Args } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import isTestMode from "../../../utils/test-mode.js"; import { progress, success, resource } from "../../../utils/output.js"; @@ -35,7 +35,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, }; @@ -163,9 +163,8 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { const x = cursor.position.x; const y = cursor.position.y; - // Clear the line and write the update - process.stdout.write( - `\r${chalk.gray("►")} ${chalk.blue(clientDisplay)}: (${chalk.yellow(x)}, ${chalk.yellow(y)})${" ".repeat(30)}`, + this.log( + `${chalk.gray("►")} ${chalk.blue(clientDisplay)}: (${chalk.yellow(x)}, ${chalk.yellow(y)})`, ); } } @@ -184,13 +183,6 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { const waitTime = isTestMode() ? 500 : 5000; await new Promise((resolve) => { setTimeout(() => { - if ( - !this.shouldOutputJson(flags) && - this.shouldUseTerminalUpdates() - ) { - // Clear the last update line - process.stdout.write("\r" + " ".repeat(60) + "\r"); - } resolve(); }, waitTime); }); @@ -416,7 +408,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { const message = isConnectionError ? "Connection was closed before operation completed. Please try again." : `Error getting cursors: ${errorMessage}`; - this.log(chalk.red(message)); + this.error(message); } } } diff --git a/src/commands/spaces/cursors/set.ts b/src/commands/spaces/cursors/set.ts index 411b9c7f..5dec3b16 100644 --- a/src/commands/spaces/cursors/set.ts +++ b/src/commands/spaces/cursors/set.ts @@ -2,7 +2,7 @@ import { Args, Flags } from "@oclif/core"; import * as Ably from "ably"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -47,7 +47,7 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, data: Flags.string({ description: "The cursor data to set (as JSON string)", @@ -373,7 +373,7 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { } else { this.log( success( - `Set cursor in space ${resource(spaceName)} with data: ${chalk.blue(JSON.stringify(cursorForOutput))}`, + `Set cursor in space ${resource(spaceName)} with data: ${chalk.blue(JSON.stringify(cursorForOutput))}.`, ), ); } diff --git a/src/commands/spaces/cursors/subscribe.ts b/src/commands/spaces/cursors/subscribe.ts index 9847df1a..3ea1aae2 100644 --- a/src/commands/spaces/cursors/subscribe.ts +++ b/src/commands/spaces/cursors/subscribe.ts @@ -1,9 +1,9 @@ import { type CursorUpdate } from "@ably/spaces"; -import { Args, Flags as _Flags } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import * as Ably from "ably"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -31,10 +31,10 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, - duration: _Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + duration: Flags.integer({ + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -187,7 +187,7 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { flags, ); } else { - this.log(chalk.red(errorMsg)); + this.logToStderr(errorMsg); } } }; @@ -333,8 +333,10 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { "Listening for cursor updates...", ); - // Log the ready signal for E2E tests - this.log("Subscribing to cursor movements"); + if (!this.shouldOutputJson(flags)) { + // Log the ready signal for E2E tests + this.log("Subscribing to cursor movements"); + } // Print success message if (!this.shouldOutputJson(flags)) { diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 7d5d644a..1712b57b 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -1,7 +1,9 @@ import { Flags } from "@oclif/core"; -import { SpacesBaseCommand } from "../../spaces-base-command.js"; import chalk from "chalk"; +import { productApiFlags } from "../../flags.js"; +import { SpacesBaseCommand } from "../../spaces-base-command.js"; + interface SpaceMetrics { connections?: number; presenceConnections?: number; @@ -35,7 +37,7 @@ export default class SpacesList extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, limit: Flags.integer({ default: 100, description: "Maximum number of results to return (default: 100)", diff --git a/src/commands/spaces/locations.ts b/src/commands/spaces/locations.ts index e54b19d8..d82a53e9 100644 --- a/src/commands/spaces/locations.ts +++ b/src/commands/spaces/locations.ts @@ -1,17 +1,18 @@ import chalk from "chalk"; +import { productApiFlags } from "../../flags.js"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; export default class SpacesLocations extends SpacesBaseCommand { - static description = + static override description = "Spaces Locations API commands (Ably Spaces client-to-client location sharing)"; - static flags = { - ...SpacesBaseCommand.globalFlags, + static override flags = { + ...productApiFlags, }; async run(): Promise { - const _flags = await this.parse(SpacesLocations); + await this.parse(SpacesLocations); this.log(chalk.bold.cyan("Spaces Locations API Commands:")); this.log("\nAvailable commands:"); this.log( diff --git a/src/commands/spaces/locations/get-all.ts b/src/commands/spaces/locations/get-all.ts index de254671..0ba1acd1 100644 --- a/src/commands/spaces/locations/get-all.ts +++ b/src/commands/spaces/locations/get-all.ts @@ -1,6 +1,7 @@ -import { Args, Flags } from "@oclif/core"; +import { Args } from "@oclif/core"; import chalk from "chalk"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { progress, resource, success } from "../../../utils/output.js"; @@ -52,13 +53,8 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, - format: Flags.string({ - char: "f", - default: "text", - description: "Output format", - options: ["text", "json"], - }), + ...productApiFlags, + ...clientIdFlag, }; async run(): Promise { @@ -163,27 +159,31 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { } } + const knownMetaKeys = new Set([ + "clientId", + "connectionId", + "id", + "member", + "memberId", + "userId", + ]); + + const extractLocationData = (item: LocationItem): unknown => { + if (item.location !== undefined) return item.location; + if (item.data !== undefined) return item.data; + const rest: Record = {}; + for (const [key, value] of Object.entries(item)) { + if (!knownMetaKeys.has(key)) { + rest[key] = value; + } + } + return Object.keys(rest).length > 0 ? rest : null; + }; + const validLocations = locations.filter((item: LocationItem) => { if (item === null || item === undefined) return false; - let locationData: unknown; - if (item.location !== undefined) { - locationData = item.location; - } else if (item.data === undefined) { - const { - clientId: _clientId, - connectionId: _connectionId, - id: _id, - member: _member, - memberId: _memberId, - userId: _userId, - ...rest - } = item; - if (Object.keys(rest).length === 0) return false; - locationData = rest; - } else { - locationData = item.data; - } + const locationData = extractLocationData(item); if (locationData === null || locationData === undefined) return false; if ( @@ -207,21 +207,7 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { item.id || item.userId || "Unknown"; - const locationData = - item.location || - item.data || - (() => { - const { - clientId: _clientId, - connectionId: _connectionId, - id: _id, - member: _member, - memberId: _memberId, - userId: _userId, - ...rest - } = item; - return rest; - })(); + const locationData = extractLocationData(item); return { isCurrentMember: item.member?.isCurrentMember || false, location: locationData, @@ -259,21 +245,7 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { `Member ID: ${chalk.cyan(member.memberId || member.clientId)}`, ); try { - const locationData = - location.location || - location.data || - (() => { - const { - clientId: _clientId, - connectionId: _connectionId, - id: _id, - member: _member, - memberId: _memberId, - userId: _userId, - ...rest - } = location; - return rest; - })(); + const locationData = extractLocationData(location); this.log( `- ${chalk.blue(member.memberId || member.clientId)}:`, @@ -317,19 +289,11 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { } } } catch (error) { - if (error === undefined || error === null) { - this.log( - chalk.red( - "An unknown error occurred (error object is undefined or null)", - ), - ); - } else { - const errorMessage = - error instanceof Error - ? error.message - : String(error || "Unknown error"); - this.log(chalk.red(`Error: ${errorMessage}`)); - } + const errorMessage = + error instanceof Error + ? error.message + : String(error || "Unknown error"); + this.error(`Error: ${errorMessage}`); } } } diff --git a/src/commands/spaces/locations/set.ts b/src/commands/spaces/locations/set.ts index b3bc703b..05e3cae6 100644 --- a/src/commands/spaces/locations/set.ts +++ b/src/commands/spaces/locations/set.ts @@ -3,7 +3,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { success, @@ -33,7 +33,7 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, location: Flags.string({ description: "Location data to set (JSON format)", @@ -174,7 +174,7 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { } // For E2E tests, force immediate exit regardless of any errors - process.exit(0); + this.exit(0); } // Original path for interactive use diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index c3b956fc..66c49c35 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -1,8 +1,8 @@ import type { LocationsEvents } from "@ably/spaces"; -import { Args, Flags as _Flags } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -50,10 +50,10 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, - duration: _Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + duration: Flags.integer({ + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -373,7 +373,7 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { flags, ); } else { - this.log(chalk.red(errorMsg)); + this.logToStderr(errorMsg); } } }; @@ -399,7 +399,7 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { flags, ); } else { - this.log(chalk.red(errorMsg)); + this.logToStderr(errorMsg); } } diff --git a/src/commands/spaces/locks/acquire.ts b/src/commands/spaces/locks/acquire.ts index 5ee5333e..30cbcf68 100644 --- a/src/commands/spaces/locks/acquire.ts +++ b/src/commands/spaces/locks/acquire.ts @@ -2,7 +2,7 @@ import { type LockOptions } from "@ably/spaces"; import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { success, listening, resource } from "../../../utils/output.js"; @@ -27,12 +27,17 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, data: Flags.string({ description: "Optional data to associate with the lock (JSON format)", required: false, }), + duration: Flags.integer({ + description: "Automatically exit after N seconds", + char: "D", + required: false, + }), }; private lockId: null | string = null; diff --git a/src/commands/spaces/locks/get-all.ts b/src/commands/spaces/locks/get-all.ts index 59a6b059..19682847 100644 --- a/src/commands/spaces/locks/get-all.ts +++ b/src/commands/spaces/locks/get-all.ts @@ -1,6 +1,7 @@ import { Args } from "@oclif/core"; import chalk from "chalk"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { progress, resource, success } from "../../../utils/output.js"; @@ -30,7 +31,8 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, + ...clientIdFlag, }; async run(): Promise { diff --git a/src/commands/spaces/locks/get.ts b/src/commands/spaces/locks/get.ts index 53eeaf9f..633d8d78 100644 --- a/src/commands/spaces/locks/get.ts +++ b/src/commands/spaces/locks/get.ts @@ -1,14 +1,10 @@ import { Args } from "@oclif/core"; import chalk from "chalk"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { resource, success } from "../../../utils/output.js"; -// interface SpacesClients { // Remove interface -// realtimeClient: any; -// spacesClient: any; -// } - export default class SpacesLocksGet extends SpacesBaseCommand { static override args = { space: Args.string({ @@ -30,46 +26,32 @@ export default class SpacesLocksGet extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, + ...clientIdFlag, }; async run(): Promise { const { args, flags } = await this.parse(SpacesLocksGet); this.parsedFlags = flags; - // let clients: SpacesClients | null = null // Remove local variable - const { space: spaceName } = args; // Get spaceName earlier + const { space: spaceName } = args; const { lockId } = args; try { - // Create Spaces client using setupSpacesClient - // clients = await this.createSpacesClient(flags) // Replace with setupSpacesClient const setupResult = await this.setupSpacesClient(flags, spaceName); this.realtimeClient = setupResult.realtimeClient; this.space = setupResult.space; - // if (!clients) return // Check properties if (!this.realtimeClient || !this.space) { this.error("Failed to initialize clients or space"); return; } - // const { spacesClient } = clients // Remove deconstruction - // const {spaceName} = args // Moved earlier - // const {lockId} = args // Moved earlier - - // Get the space - // const space = await spacesClient.get(spaceName) // Already got this.space - - // Enter the space first - // await space.enter() // Use this.space await this.space.enter(); if (!this.shouldOutputJson(flags)) { this.log(success(`Entered space: ${resource(spaceName)}.`)); } - // Try to get the lock try { - // const lock = await space.locks.get(lockId) // Use this.space const lock = await this.space.locks.get(lockId); if (!lock) { @@ -80,7 +62,7 @@ export default class SpacesLocksGet extends SpacesBaseCommand { } else { this.log( chalk.yellow( - `Lock '${lockId}' not found in space '${spaceName}'`, + `Lock ${resource(lockId)} not found in space ${resource(spaceName)}`, ), ); } @@ -89,10 +71,8 @@ export default class SpacesLocksGet extends SpacesBaseCommand { } if (this.shouldOutputJson(flags)) { - // Use structuredClone or similar for formatJsonOutput this.log(this.formatJsonOutput(structuredClone(lock), flags)); } else { - // Use structuredClone or similar for formatJsonOutput this.log( `${chalk.dim("Lock details:")} ${this.formatJsonOutput(structuredClone(lock), flags)}`, ); diff --git a/src/commands/spaces/locks/subscribe.ts b/src/commands/spaces/locks/subscribe.ts index 057dd51d..fff2e770 100644 --- a/src/commands/spaces/locks/subscribe.ts +++ b/src/commands/spaces/locks/subscribe.ts @@ -1,8 +1,8 @@ import { type Lock } from "@ably/spaces"; -import { Args, Flags as _Flags } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -30,10 +30,10 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, - duration: _Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + duration: Flags.integer({ + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -274,7 +274,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { ); } else { this.log( - `${formatTimestamp(timestamp)} 🔒 Lock ${chalk.blue(lock.id)} updated`, + `${formatTimestamp(timestamp)} Lock ${chalk.blue(lock.id)} updated`, ); this.log(` ${chalk.dim("Status:")} ${lock.status}`); this.log( @@ -313,8 +313,12 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { this.logCliEvent(flags, "lock", "executionError", errorMsg, { error: errorMsg, }); - if (!this.shouldOutputJson(flags)) { - this.log(chalk.red(errorMsg)); + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput({ error: errorMsg, success: false }, flags), + ); + } else { + this.error(errorMsg); } } finally { // Cleanup is now handled by base class finally() method diff --git a/src/commands/spaces/members/enter.ts b/src/commands/spaces/members/enter.ts index 3f4f74d3..a6f9c264 100644 --- a/src/commands/spaces/members/enter.ts +++ b/src/commands/spaces/members/enter.ts @@ -2,7 +2,7 @@ import type { ProfileData, SpaceMember } from "@ably/spaces"; import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { @@ -30,7 +30,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, profile: Flags.string({ description: @@ -38,7 +38,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { required: false, }), duration: Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + description: "Automatically exit after N seconds", char: "D", required: false, }), diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 92cdd793..71205d2e 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -1,8 +1,8 @@ import type { SpaceMember } from "@ably/spaces"; -import { Args, Flags as _Flags } from "@oclif/core"; +import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; -import { clientIdFlag } from "../../../flags.js"; +import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { listening, progress, formatTimestamp } from "../../../utils/output.js"; @@ -26,10 +26,10 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { ]; static override flags = { - ...SpacesBaseCommand.globalFlags, + ...productApiFlags, ...clientIdFlag, - duration: _Flags.integer({ - description: "Automatically exit after N seconds (0 = run indefinitely)", + duration: Flags.integer({ + description: "Automatically exit after N seconds", char: "D", required: false, }), @@ -319,8 +319,12 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { this.logCliEvent(flags, "member", "executionError", errorMsg, { error: errorMsg, }); - if (!this.shouldOutputJson(flags)) { - this.log(chalk.red(errorMsg)); + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput({ error: errorMsg, success: false }, flags), + ); + } else { + this.error(errorMsg); } } finally { // Cleanup is now handled by base class finally() method diff --git a/src/commands/stats/account.ts b/src/commands/stats/account.ts index f6e1d9a1..f77c4b95 100644 --- a/src/commands/stats/account.ts +++ b/src/commands/stats/account.ts @@ -2,9 +2,12 @@ import { Flags } from "@oclif/core"; import chalk from "chalk"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { timeRangeFlags } from "../../flags.js"; import { StatsDisplay } from "../../services/stats-display.js"; import type { BaseFlags } from "../../types/cli.js"; import type { ControlApi } from "../../services/control-api.js"; +import { progress, resource } from "../../utils/output.js"; +import { parseTimestamp } from "../../utils/time.js"; export default class StatsAccountCommand extends ControlBaseCommand { static description = "Get account stats with optional live updates"; @@ -12,7 +15,8 @@ export default class StatsAccountCommand extends ControlBaseCommand { static examples = [ "$ ably stats account", "$ ably stats account --unit hour", - "$ ably stats account --start 1618005600000 --end 1618091999999", + '$ ably stats account --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', + "$ ably stats account --start 1h", "$ ably stats account --limit 10", "$ ably stats account --json", "$ ably stats account --pretty-json", @@ -26,9 +30,7 @@ export default class StatsAccountCommand extends ControlBaseCommand { default: false, description: "Show debug information for live stats polling", }), - end: Flags.integer({ - description: "End time in milliseconds since epoch", - }), + ...timeRangeFlags, interval: Flags.integer({ default: 6, description: "Polling interval in seconds (only used with --live)", @@ -42,9 +44,6 @@ export default class StatsAccountCommand extends ControlBaseCommand { default: false, description: "Subscribe to live stats updates (uses minute interval)", }), - start: Flags.integer({ - description: "Start time in milliseconds since epoch", - }), unit: Flags.string({ default: "minute", description: "Time unit for stats", @@ -145,9 +144,13 @@ export default class StatsAccountCommand extends ControlBaseCommand { try { // Get account info to display the name const { account } = await controlApi.getMe(); - this.log( - `Subscribing to live stats for account ${account.name} (${account.id})...`, - ); + if (!this.shouldOutputJson(flags)) { + this.log( + progress( + `Subscribing to live stats for account ${resource(account.name)} (${account.id})`, + ), + ); + } // Setup graceful shutdown const cleanup = () => { @@ -204,24 +207,45 @@ export default class StatsAccountCommand extends ControlBaseCommand { ): Promise { try { const { account } = await controlApi.getMe(); - this.log(`Fetching stats for account ${account.name} (${account.id})...`); + if (!this.shouldOutputJson(flags)) { + this.log( + progress( + `Fetching stats for account ${resource(account.name)} (${account.id})`, + ), + ); + } + + // Parse start/end if provided, otherwise default to last 24 hours + let startMs: number | undefined; + let endMs: number | undefined; + + if (flags.start) { + startMs = parseTimestamp(flags.start as string, "start"); + } + + if (flags.end) { + endMs = parseTimestamp(flags.end as string, "end"); + } - // If no start/end time provided, use the last 24 hours - if (!flags.start && !flags.end) { - const now = new Date(); - flags.end = now.getTime(); - flags.start = now.getTime() - 24 * 60 * 60 * 1000; // 24 hours ago + if (startMs === undefined && endMs === undefined) { + const now = Date.now(); + endMs = now; + startMs = now - 24 * 60 * 60 * 1000; // 24 hours ago } const stats = await controlApi.getAccountStats({ - end: flags.end as number, + end: endMs, limit: flags.limit as number, - start: flags.start as number, + start: startMs, unit: flags.unit as string, }); if (stats.length === 0) { - this.log("No stats found for the specified period"); + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ stats: [], success: true }, flags)); + } else { + this.log("No stats found for the specified period"); + } return; } diff --git a/src/commands/stats/app.ts b/src/commands/stats/app.ts index e95538b0..c43774af 100644 --- a/src/commands/stats/app.ts +++ b/src/commands/stats/app.ts @@ -2,9 +2,12 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ControlBaseCommand } from "../../control-base-command.js"; +import { timeRangeFlags } from "../../flags.js"; import { StatsDisplay } from "../../services/stats-display.js"; import type { BaseFlags } from "../../types/cli.js"; import type { ControlApi } from "../../services/control-api.js"; +import { progress, resource } from "../../utils/output.js"; +import { parseTimestamp } from "../../utils/time.js"; export default class StatsAppCommand extends ControlBaseCommand { static args = { @@ -21,7 +24,8 @@ export default class StatsAppCommand extends ControlBaseCommand { "$ ably stats app app-id", "$ ably stats app --unit hour", "$ ably stats app app-id --unit hour", - "$ ably stats app app-id --start 1618005600000 --end 1618091999999", + '$ ably stats app app-id --start "2023-01-01T00:00:00Z" --end "2023-01-02T00:00:00Z"', + "$ ably stats app app-id --start 1h", "$ ably stats app app-id --limit 10", "$ ably stats app app-id --json", "$ ably stats app app-id --pretty-json", @@ -36,9 +40,7 @@ export default class StatsAppCommand extends ControlBaseCommand { default: false, description: "Show debug information for live stats polling", }), - end: Flags.integer({ - description: "End time in milliseconds since epoch", - }), + ...timeRangeFlags, interval: Flags.integer({ default: 6, description: "Polling interval in seconds (only used with --live)", @@ -52,9 +54,6 @@ export default class StatsAppCommand extends ControlBaseCommand { default: false, description: "Subscribe to live stats updates (uses minute interval)", }), - start: Flags.integer({ - description: "Start time in milliseconds since epoch", - }), unit: Flags.string({ default: "minute", description: "Time unit for stats", @@ -165,7 +164,11 @@ export default class StatsAppCommand extends ControlBaseCommand { controlApi: ControlApi, ): Promise { try { - this.log(`Subscribing to live stats for app ${appId}...`); + if (!this.shouldOutputJson(flags)) { + this.log( + progress(`Subscribing to live stats for app ${resource(appId)}`), + ); + } // Setup graceful shutdown const cleanup = () => { @@ -222,24 +225,41 @@ export default class StatsAppCommand extends ControlBaseCommand { controlApi: ControlApi, ): Promise { try { - this.log(`Fetching stats for app ${appId}...`); + if (!this.shouldOutputJson(flags)) { + this.log(progress(`Fetching stats for app ${resource(appId)}`)); + } - // If no start/end time provided, use the last 24 hours - if (!flags.start && !flags.end) { - const now = new Date(); - flags.end = now.getTime(); - flags.start = now.getTime() - 24 * 60 * 60 * 1000; // 24 hours ago + // Parse start/end if provided, otherwise default to last 24 hours + let startMs: number | undefined; + let endMs: number | undefined; + + if (flags.start) { + startMs = parseTimestamp(flags.start as string, "start"); + } + + if (flags.end) { + endMs = parseTimestamp(flags.end as string, "end"); + } + + if (startMs === undefined && endMs === undefined) { + const now = Date.now(); + endMs = now; + startMs = now - 24 * 60 * 60 * 1000; // 24 hours ago } const stats = await controlApi.getAppStats(appId, { - end: flags.end as number, + end: endMs, limit: flags.limit as number, - start: flags.start as number, + start: startMs, unit: flags.unit as string, }); if (stats.length === 0) { - this.log("No stats found for the specified period"); + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ stats: [], success: true }, flags)); + } else { + this.log("No stats found for the specified period"); + } return; } diff --git a/src/commands/support/ask.ts b/src/commands/support/ask.ts index 5c72b694..f0a2ccf2 100644 --- a/src/commands/support/ask.ts +++ b/src/commands/support/ask.ts @@ -35,8 +35,11 @@ export default class AskCommand extends ControlBaseCommand { const controlApi = this.createControlApi(flags); const isInteractive = process.env.ABLY_INTERACTIVE_MODE === "true"; - const spinner = isInteractive ? null : ora("Thinking...").start(); - if (isInteractive) { + const spinner = + isInteractive || this.shouldOutputJson(flags) + ? null + : ora("Thinking...").start(); + if (isInteractive && !this.shouldOutputJson(flags)) { this.log("Thinking..."); } @@ -69,56 +72,69 @@ export default class AskCommand extends ControlBaseCommand { if (spinner) spinner.stop(); - // Display the AI agent's answer - // Convert markdown to styled terminal output - // Process code blocks first - const processedWithCodeBlocks = response.answer.replaceAll( - /```(?:javascript|js|html)?\n([\S\s]*?)```/g, - (_, codeContent) => - // Return the code block with each line highlighted in cyan - codeContent - .split("\n") - .map((line: string) => chalk.green(` ${line}`)) - .join("\n"), - ); - - // Then apply other markdown formatting - const formattedAnswer = processedWithCodeBlocks - .replaceAll(/\*\*(.*?)\*\*/g, (_, text) => chalk.bold(text)) - .replaceAll(/\*(.*?)\*/g, (_, text) => chalk.italic(text)) - .replaceAll(/`(.*?)`/g, (_, text) => chalk.green(text)) - .replaceAll( - /\[(.*?)]\((.*?)\)/g, - (_, text, url) => `${text} (${chalk.blueBright(url)})`, - ) - .replaceAll(/^# (.*?)$/gm, (_, text) => chalk.bold.underline(text)) - .replaceAll(/^## (.*?)$/gm, (_, text) => chalk.bold(text)) - .replaceAll(/^### (.*?)$/gm, (_, text) => chalk.yellow(text)); - - this.log(formattedAnswer); - - // Display the links section if there are links - if (response.links && response.links.length > 0) { - this.log(""); - this.log(chalk.bold("Helpful Links:")); - for (const [index, link] of response.links.entries()) { - this.log( - `${index + 1}. ${chalk.cyan(link.title)} - ${chalk.blue(link.url)}`, - ); + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput( + { + answer: response.answer, + links: response.links, + success: true, + }, + flags, + ), + ); + } else { + // Display the AI agent's answer + // Convert markdown to styled terminal output + // Process code blocks first + const processedWithCodeBlocks = response.answer.replaceAll( + /```(?:javascript|js|html)?\n([\S\s]*?)```/g, + (_, codeContent) => + // Return the code block with each line highlighted in cyan + codeContent + .split("\n") + .map((line: string) => chalk.green(` ${line}`)) + .join("\n"), + ); + + // Then apply other markdown formatting + const formattedAnswer = processedWithCodeBlocks + .replaceAll(/\*\*(.*?)\*\*/g, (_, text) => chalk.bold(text)) + .replaceAll(/\*(.*?)\*/g, (_, text) => chalk.italic(text)) + .replaceAll(/`(.*?)`/g, (_, text) => chalk.green(text)) + .replaceAll( + /\[(.*?)]\((.*?)\)/g, + (_, text, url) => `${text} (${chalk.blueBright(url)})`, + ) + .replaceAll(/^# (.*?)$/gm, (_, text) => chalk.bold.underline(text)) + .replaceAll(/^## (.*?)$/gm, (_, text) => chalk.bold(text)) + .replaceAll(/^### (.*?)$/gm, (_, text) => chalk.yellow(text)); + + this.log(formattedAnswer); + + // Display the links section if there are links + if (response.links && response.links.length > 0) { + this.log(""); + this.log(chalk.bold("Helpful Links:")); + for (const [index, link] of response.links.entries()) { + this.log( + `${index + 1}. ${chalk.cyan(link.title)} - ${chalk.blue(link.url)}`, + ); + } } + + // Suggest continuing the conversation + this.log(""); + this.log(chalk.italic("To ask a follow-up question, run:")); + this.log( + chalk.yellow.italic( + ` $ ${this.config.bin} help ask --continue "Your follow-up question"`, + ), + ); } // Store the conversation for future reference this.configManager.storeHelpContext(args.question, response.answer); - - // Suggest continuing the conversation - this.log(""); - this.log(chalk.italic("To ask a follow-up question, run:")); - this.log( - chalk.yellow.italic( - ` $ ${this.config.bin} help ask --continue "Your follow-up question"`, - ), - ); } catch (error) { if (spinner) { spinner.fail("Failed to get a response from the Ably AI agent"); diff --git a/src/flags.ts b/src/flags.ts index 09e6c602..45cefe40 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -81,6 +81,22 @@ export const endpointFlag = { }), }; +/** + * Shared start/end time range flags. + * Accepts ISO 8601, Unix ms, or relative shorthand (e.g., "1h", "30m", "2d"). + * Parse values with `parseTimestamp()` from `src/utils/time.ts`. + */ +export const timeRangeFlags = { + end: Flags.string({ + description: + 'End time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d")', + }), + start: Flags.string({ + description: + 'Start time as ISO 8601, Unix ms, or relative (e.g., "1h", "30m", "2d")', + }), +}; + /** * Composite: core + hidden product API flags. * Use for product API commands (channels, connections, logs, bench, etc.) diff --git a/src/services/history-manager.ts b/src/services/history-manager.ts index ce552165..62639cf5 100644 --- a/src/services/history-manager.ts +++ b/src/services/history-manager.ts @@ -23,11 +23,6 @@ export class HistoryManager { } if (!fs.existsSync(this.historyFile)) { - if (process.env.ABLY_DEBUG_KEYS === "true") { - console.error( - `[DEBUG] History file does not exist: ${this.historyFile}`, - ); - } return; } @@ -42,17 +37,7 @@ export class HistoryManager { // to populate history in Node.js readline const internalRl = rl as readline.Interface & { history?: string[] }; internalRl.history = history.reverse(); - - if (process.env.ABLY_DEBUG_KEYS === "true") { - console.error( - `[DEBUG] Loaded ${history.length} history items from ${this.historyFile}`, - ); - console.error(`[DEBUG] First few history items:`, history.slice(0, 3)); - } - } catch (error) { - if (process.env.ABLY_DEBUG_KEYS === "true") { - console.error(`[DEBUG] Error loading history:`, error); - } + } catch { // Silently ignore history load errors // History is a nice-to-have feature, shouldn't break the shell } diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 00000000..71d7b7ab --- /dev/null +++ b/src/utils/time.ts @@ -0,0 +1,36 @@ +/** + * Parse a flexible timestamp string into milliseconds since epoch. + * + * Accepts: + * - Unix milliseconds as a numeric string (e.g., "1700000000000") + * - Relative time shorthand (e.g., "30s", "5m", "1h", "2d", "1w") + * - ISO 8601 or any Date-parseable string (e.g., "2023-01-01T00:00:00Z") + */ +export function parseTimestamp(input: string, label = "timestamp"): number { + // Pure numeric → ms since epoch + if (/^\d+$/.test(input)) return Number.parseInt(input, 10); + + // Relative time: 30s, 5m, 1h, 2d, 1w + const match = /^(\d+)([smhdw])$/.exec(input); + if (match) { + const multipliers: Record = { + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + w: 604_800_000, + }; + return Date.now() - Number.parseInt(match[1], 10) * multipliers[match[2]]; + } + + // ISO 8601 or other parseable date string + const ms = new Date(input).getTime(); + if (Number.isNaN(ms)) { + throw new TypeError( + `Invalid ${label}: "${input}". ` + + `Use ISO 8601 (e.g., "2023-01-01T00:00:00Z"), Unix ms (e.g., "1700000000000"), or relative (e.g., "1h", "30m", "2d").`, + ); + } + + return ms; +} diff --git a/test/e2e/interactive/ctrl-c-behavior.test.ts b/test/e2e/interactive/ctrl-c-behavior.test.ts index ae176064..66aca552 100644 --- a/test/e2e/interactive/ctrl-c-behavior.test.ts +++ b/test/e2e/interactive/ctrl-c-behavior.test.ts @@ -156,11 +156,9 @@ describe("E2E: Interactive Mode - Ctrl+C Behavior", () => { proc.stdout?.on("data", (data) => { output += data.toString(); - if (process.env.DEBUG_TEST) console.log("STDOUT:", data.toString()); }); proc.stderr?.on("data", (data) => { output += data.toString(); - if (process.env.DEBUG_TEST) console.log("STDERR:", data.toString()); }); // Wait for prompt using simple polling @@ -679,17 +677,12 @@ describe("E2E: Interactive Mode - Ctrl+C Behavior", () => { const text = data.toString(); output += text; - if (process.env.DEBUG_TEST) { - console.log("STDOUT:", text); - } - // Check for the specific error if ( text.includes("setRawMode EIO") || text.includes("Failed to start interactive mode") ) { hasError = true; - console.error("DETECTED ERROR:", text); } }); @@ -697,10 +690,6 @@ describe("E2E: Interactive Mode - Ctrl+C Behavior", () => { const text = data.toString(); output += text; - if (process.env.DEBUG_TEST) { - console.log("STDERR:", text); - } - if ( text.includes("setRawMode EIO") || text.includes("Failed to start interactive mode") diff --git a/test/e2e/spaces/spaces-e2e.test.ts b/test/e2e/spaces/spaces-e2e.test.ts index 126cf4e3..d48a0ae8 100644 --- a/test/e2e/spaces/spaces-e2e.test.ts +++ b/test/e2e/spaces/spaces-e2e.test.ts @@ -586,15 +586,6 @@ describe("Spaces E2E Tests", () => { ) { lockReleasedReceived = true; break; - } else if ( - i % 5 === 0 && - output.length > 0 && - process.env.E2E_DEBUG - ) { - // Debug output for troubleshooting - console.log( - `[DEBUG] Lock release check attempt ${i}: ${output.slice(-200)}`, - ); } await new Promise((resolve) => setTimeout(resolve, 300)); } diff --git a/test/unit/commands/apps/set-apns-p12.test.ts b/test/unit/commands/apps/set-apns-p12.test.ts index 5ceb4bab..edc41ddf 100644 --- a/test/unit/commands/apps/set-apns-p12.test.ts +++ b/test/unit/commands/apps/set-apns-p12.test.ts @@ -43,7 +43,7 @@ describe("apps:set-apns-p12 command", () => { import.meta.url, ); - expect(stdout).toContain("APNS P12 certificate uploaded successfully"); + expect(stdout).toContain("APNS P12 certificate uploaded."); }); it("should upload certificate with password", async () => { @@ -67,7 +67,7 @@ describe("apps:set-apns-p12 command", () => { import.meta.url, ); - expect(stdout).toContain("APNS P12 certificate uploaded successfully"); + expect(stdout).toContain("APNS P12 certificate uploaded."); }); it("should upload certificate for sandbox environment", async () => { @@ -90,7 +90,7 @@ describe("apps:set-apns-p12 command", () => { import.meta.url, ); - expect(stdout).toContain("APNS P12 certificate uploaded successfully"); + expect(stdout).toContain("APNS P12 certificate uploaded."); expect(stdout).toContain("Sandbox"); }); }); diff --git a/test/unit/commands/channels/batch-publish.test.ts b/test/unit/commands/channels/batch-publish.test.ts index f34f5053..29f448c7 100644 --- a/test/unit/commands/channels/batch-publish.test.ts +++ b/test/unit/commands/channels/batch-publish.test.ts @@ -210,15 +210,8 @@ describe("channels:batch-publish command", () => { expect(error).toBeUndefined(); - // stdout contains "Sending batch publish request..." before JSON - expect(stdout).toContain("Sending batch publish request"); - - // JSON is pretty-printed across multiple lines - extract it after the first line - const lines = stdout.split("\n"); - const jsonStartIndex = lines.findIndex((line) => line.trim() === "{"); - expect(jsonStartIndex).toBeGreaterThan(-1); - const jsonContent = lines.slice(jsonStartIndex).join("\n"); - const result = JSON.parse(jsonContent); + // In JSON mode, progress messages are suppressed by JSON guard + const result = JSON.parse(stdout); expect(result).toHaveProperty("success", true); expect(result).toHaveProperty("channels"); expect(result.channels).toEqual(["channel1", "channel2"]); @@ -265,14 +258,14 @@ describe("channels:batch-publish command", () => { ); expect(stdout).toContain("partially successful"); - // Verify successful channel output - expect(stdout).toContain( - "Published to channel 'channel1' with messageId: msg-1", - ); + // Verify successful channel output (resource() uses cyan, not quotes) + expect(stdout).toContain("Published to channel"); + expect(stdout).toContain("channel1"); + expect(stdout).toContain("msg-1"); // Verify failed channel output with error message and code - expect(stdout).toContain( - "Failed to publish to channel 'channel2': Invalid channel name (40000)", - ); + expect(stdout).toContain("Failed to publish to channel"); + expect(stdout).toContain("channel2"); + expect(stdout).toContain("Invalid channel name (40000)"); }); it("should handle API errors in JSON mode", async () => { @@ -293,15 +286,8 @@ describe("channels:batch-publish command", () => { // In JSON mode, errors are returned as JSON, not thrown expect(error).toBeUndefined(); - // stdout contains "Sending batch publish request..." before JSON - expect(stdout).toContain("Sending batch publish request"); - - // JSON is pretty-printed across multiple lines - extract it after the first line - const lines = stdout.split("\n"); - const jsonStartIndex = lines.findIndex((line) => line.trim() === "{"); - expect(jsonStartIndex).toBeGreaterThan(-1); - const jsonContent = lines.slice(jsonStartIndex).join("\n"); - const result = JSON.parse(jsonContent); + // In JSON mode, progress messages are suppressed by JSON guard + const result = JSON.parse(stdout); expect(result).toHaveProperty("success", false); expect(result).toHaveProperty("error"); expect(result.error).toContain("Network error"); diff --git a/test/unit/commands/channels/history.test.ts b/test/unit/commands/channels/history.test.ts index 652968c0..8cdf49fe 100644 --- a/test/unit/commands/channels/history.test.ts +++ b/test/unit/commands/channels/history.test.ts @@ -163,6 +163,54 @@ describe("channels:history command", () => { ); }); + it("should accept Unix ms string for --start", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:history", "test-channel", "--start", "1700000000000"], + import.meta.url, + ); + + expect(channel.history).toHaveBeenCalledWith( + expect.objectContaining({ + start: 1700000000000, + }), + ); + }); + + it("should accept relative time for --start", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:history", "test-channel", "--start", "1h"], + import.meta.url, + ); + + // The start value should be approximately 1 hour ago + const callArgs = channel.history.mock.calls[0][0]; + const oneHourAgo = Date.now() - 3_600_000; + expect(callArgs.start).toBeGreaterThan(oneHourAgo - 5000); + expect(callArgs.start).toBeLessThanOrEqual(oneHourAgo + 5000); + }); + + it("should accept relative time for --end", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:history", "test-channel", "--end", "30m"], + import.meta.url, + ); + + // The end value should be approximately 30 minutes ago + const callArgs = channel.history.mock.calls[0][0]; + const thirtyMinAgo = Date.now() - 30 * 60_000; + expect(callArgs.end).toBeGreaterThan(thirtyMinAgo - 5000); + expect(callArgs.end).toBeLessThanOrEqual(thirtyMinAgo + 5000); + }); + it("should handle API errors gracefully", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); diff --git a/test/unit/commands/did-you-mean.test.ts b/test/unit/commands/did-you-mean.test.ts index f6dd8adc..58e42a8b 100644 --- a/test/unit/commands/did-you-mean.test.ts +++ b/test/unit/commands/did-you-mean.test.ts @@ -193,12 +193,12 @@ describe("Did You Mean Functionality", () => { }, }); - let _output = ""; + let output = ""; let foundPrompt = false; let executedCommand = false; child.stdout.on("data", (data) => { - _output += data.toString(); + output += data.toString(); if ( data.toString().includes("Did you mean accounts current?") || @@ -211,16 +211,16 @@ describe("Did You Mean Functionality", () => { } // Check for various outputs that indicate the command was executed - const output = data.toString(); + const chunk = data.toString(); if ( - output.includes("Account:") || - output.includes("Show the current Ably account") || - output.includes("No access token provided") || - output.includes("accounts current") || - output.includes("No account currently selected") || - output.includes("You are not logged in") || - output.includes("Authentication required") || - output.includes("Error:") + chunk.includes("Account:") || + chunk.includes("Show the current Ably account") || + chunk.includes("No access token provided") || + chunk.includes("accounts current") || + chunk.includes("No account currently selected") || + chunk.includes("You are not logged in") || + chunk.includes("Authentication required") || + chunk.includes("Error:") ) { executedCommand = true; @@ -232,7 +232,7 @@ describe("Did You Mean Functionality", () => { }); child.stderr.on("data", (data) => { - _output += data.toString(); + output += data.toString(); const errorOutput = data.toString(); if ( @@ -257,15 +257,6 @@ describe("Did You Mean Functionality", () => { }, 1000); child.on("exit", (code) => { - // Debug output for CI failures - if (!foundPrompt || !executedCommand) { - console.error("Test failed - Debug output:"); - console.error("foundPrompt:", foundPrompt); - console.error("executedCommand:", executedCommand); - console.error("Exit code:", code); - console.error("Output received:", _output); - } - expect(foundPrompt).toBe(true); expect(executedCommand).toBe(true); resolve(); diff --git a/test/unit/commands/interactive-sigint.test.ts b/test/unit/commands/interactive-sigint.test.ts index 9b0946f0..822554ed 100644 --- a/test/unit/commands/interactive-sigint.test.ts +++ b/test/unit/commands/interactive-sigint.test.ts @@ -27,7 +27,7 @@ describe("Interactive Mode - SIGINT Handling", () => { }, }); - let _output = ""; + let capturedOutput = ""; let errorOutput = ""; let commandStarted = false; let promptSeen = false; @@ -35,7 +35,7 @@ describe("Interactive Mode - SIGINT Handling", () => { child.stdout.on("data", (data) => { const output = data.toString(); - _output += output; + capturedOutput += output; // Count prompts const promptMatches = output.match(/ably> /g); @@ -85,7 +85,7 @@ describe("Interactive Mode - SIGINT Handling", () => { // Should have returned to prompt (at least 2 prompts) expect(promptCount).toBeGreaterThanOrEqual(2); // Should show interrupt feedback - expect(_output + errorOutput).toContain("↓ Stopping"); + expect(capturedOutput + errorOutput).toContain("↓ Stopping"); // Should not have EIO errors expect(errorOutput).not.toContain("Error: read EIO"); expect(errorOutput).not.toContain("setRawMode EIO"); @@ -95,9 +95,8 @@ describe("Interactive Mode - SIGINT Handling", () => { // Timeout fallback setTimeout(() => { if (!commandStarted) { - console.error("Test timeout: command never started"); + child.stdin.write("exit\n"); } - child.stdin.write("exit\n"); }, timeout - 1000); }, timeout, @@ -122,12 +121,12 @@ describe("Interactive Mode - SIGINT Handling", () => { }, }); - let _output = ""; + let capturedOutput = ""; let sigintSent = false; let exitSent = false; child.stdout.on("data", (data) => { - _output += data.toString(); + capturedOutput += data.toString(); // Wait for initial prompt if ( @@ -160,8 +159,8 @@ describe("Interactive Mode - SIGINT Handling", () => { child.on("exit", (code) => { expect([0, 42]).toContain(code); // We should either see ^C or the signal message - const hasCtrlC = _output.includes("^C"); - const hasSignalMessage = _output.includes("Signal received"); + const hasCtrlC = capturedOutput.includes("^C"); + const hasSignalMessage = capturedOutput.includes("Signal received"); expect(hasCtrlC || hasSignalMessage).toBe(true); done(); }); @@ -169,7 +168,6 @@ describe("Interactive Mode - SIGINT Handling", () => { // Timeout fallback setTimeout(() => { if (!exitSent) { - console.error("Test timeout: no SIGINT response detected"); child.stdin.write("exit\n"); } }, timeout - 1000); @@ -196,11 +194,11 @@ describe("Interactive Mode - SIGINT Handling", () => { }, }); - let _output = ""; + let capturedOutput = ""; let commandTyped = false; child.stdout.on("data", (data) => { - _output += data.toString(); + capturedOutput += data.toString(); if ( !commandTyped && @@ -229,7 +227,7 @@ describe("Interactive Mode - SIGINT Handling", () => { child.on("exit", (code) => { expect([0, 42]).toContain(code); // Should see ^C when canceling partial input - expect(_output).toContain("^C"); + expect(capturedOutput).toContain("^C"); done(); }); diff --git a/test/unit/commands/interactive.test.ts b/test/unit/commands/interactive.test.ts index 13400802..9e25402b 100644 --- a/test/unit/commands/interactive.test.ts +++ b/test/unit/commands/interactive.test.ts @@ -186,9 +186,8 @@ describe("Interactive Command", () => { } }); - child.stderr.on("data", (data) => { - // Log any errors for debugging - console.error("STDERR:", data.toString()); + child.stderr.on("data", () => { + // stderr captured but not logged }); child.on("exit", (code, signal) => { diff --git a/test/unit/commands/queues/delete.test.ts b/test/unit/commands/queues/delete.test.ts index bd2263ad..279355d0 100644 --- a/test/unit/commands/queues/delete.test.ts +++ b/test/unit/commands/queues/delete.test.ts @@ -62,9 +62,7 @@ describe("queues:delete command", () => { import.meta.url, ); - expect(stdout).toContain( - `Queue "test-queue" (ID: ${mockQueueId}) deleted successfully`, - ); + expect(stdout).toContain("Queue deleted:"); }); it("should delete a queue with custom app ID", async () => { @@ -96,9 +94,7 @@ describe("queues:delete command", () => { import.meta.url, ); - expect(stdout).toContain( - `Queue "test-queue" (ID: ${mockQueueId}) deleted successfully`, - ); + expect(stdout).toContain("Queue deleted:"); }); it("should use ABLY_ACCESS_TOKEN environment variable when provided", async () => { diff --git a/test/unit/commands/rooms/messages.test.ts b/test/unit/commands/rooms/messages.test.ts index 354cdf15..4bb462f9 100644 --- a/test/unit/commands/rooms/messages.test.ts +++ b/test/unit/commands/rooms/messages.test.ts @@ -382,5 +382,62 @@ describe("rooms messages commands", function () { }), ); }); + + it("should respect --start and --end flags with ISO 8601", async function () { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + + const start = "2025-01-01T00:00:00Z"; + const end = "2025-01-02T00:00:00Z"; + + await runCommand( + ["rooms:messages:history", "test-room", "--start", start, "--end", end], + import.meta.url, + ); + + expect(room.messages.history).toHaveBeenCalledWith( + expect.objectContaining({ + start: new Date(start).getTime(), + end: new Date(end).getTime(), + }), + ); + }); + + it("should accept Unix ms string for --start", async function () { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + + await runCommand( + ["rooms:messages:history", "test-room", "--start", "1700000000000"], + import.meta.url, + ); + + expect(room.messages.history).toHaveBeenCalledWith( + expect.objectContaining({ + start: 1700000000000, + }), + ); + }); + + it("should accept relative time for --start", async function () { + const chatMock = getMockAblyChat(); + const room = chatMock.rooms._getRoom("test-room"); + + room.messages.history = vi.fn().mockResolvedValue({ items: [] }); + + await runCommand( + ["rooms:messages:history", "test-room", "--start", "1h"], + import.meta.url, + ); + + const callArgs = room.messages.history.mock.calls[0][0]; + const oneHourAgo = Date.now() - 3_600_000; + expect(callArgs.start).toBeGreaterThan(oneHourAgo - 5000); + expect(callArgs.start).toBeLessThanOrEqual(oneHourAgo + 5000); + }); }); }); diff --git a/test/unit/commands/stats/account.test.ts b/test/unit/commands/stats/account.test.ts new file mode 100644 index 00000000..7355a041 --- /dev/null +++ b/test/unit/commands/stats/account.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { runCommand } from "@oclif/test"; + +describe("stats:account command", () => { + const mockAccessToken = "fake_access_token"; + const mockAccountId = "test-account-id"; + const mockStats = [ + { + intervalId: "2023-01-01:00:00", + unit: "minute", + all: { + messages: { count: 200, data: 10000 }, + all: { count: 200, data: 10000 }, + }, + }, + ]; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + }); + + function mockMeEndpoint() { + // Called once for showAuthInfoIfNeeded, once for runOneTimeStats + nock("https://control.ably.net") + .get("/v1/me") + .times(2) + .reply(200, { + account: { id: mockAccountId, name: "Test Account" }, + user: { email: "test@example.com" }, + }); + } + + it("should accept ISO 8601 for --start and --end", async () => { + mockMeEndpoint(); + const scope = nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/stats`) + .query(true) + .reply(200, mockStats); + + const { stdout } = await runCommand( + [ + "stats:account", + "--start", + "2023-01-01T00:00:00Z", + "--end", + "2023-01-02T00:00:00Z", + ], + import.meta.url, + ); + + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); + + it("should accept relative time for --start", async () => { + mockMeEndpoint(); + const scope = nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/stats`) + .query(true) + .reply(200, mockStats); + + const { stdout } = await runCommand( + ["stats:account", "--start", "1h"], + import.meta.url, + ); + + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); + + it("should accept Unix ms for --start", async () => { + mockMeEndpoint(); + const scope = nock("https://control.ably.net") + .get(`/v1/accounts/${mockAccountId}/stats`) + .query(true) + .reply(200, mockStats); + + const { stdout } = await runCommand( + ["stats:account", "--start", "1672531200000"], + import.meta.url, + ); + + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); +}); diff --git a/test/unit/commands/stats/app.test.ts b/test/unit/commands/stats/app.test.ts new file mode 100644 index 00000000..ff835731 --- /dev/null +++ b/test/unit/commands/stats/app.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import nock from "nock"; +import { runCommand } from "@oclif/test"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("stats:app command", () => { + const mockAccessToken = "fake_access_token"; + const mockStats = [ + { + intervalId: "2023-01-01:00:00", + unit: "minute", + all: { + messages: { count: 100, data: 5000 }, + all: { count: 100, data: 5000 }, + }, + }, + ]; + + let appId: string; + + beforeEach(() => { + process.env.ABLY_ACCESS_TOKEN = mockAccessToken; + const mockConfig = getMockConfigManager(); + appId = mockConfig.getCurrentAppId()!; + }); + + afterEach(() => { + nock.cleanAll(); + delete process.env.ABLY_ACCESS_TOKEN; + }); + + it("should accept ISO 8601 for --start and --end", async () => { + const scope = nock("https://control.ably.net") + .get(`/v1/apps/${appId}/stats`) + .query(true) + .reply(200, mockStats); + + const { stdout } = await runCommand( + [ + "stats:app", + appId, + "--start", + "2023-01-01T00:00:00Z", + "--end", + "2023-01-02T00:00:00Z", + ], + import.meta.url, + ); + + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); + + it("should accept relative time for --start", async () => { + const scope = nock("https://control.ably.net") + .get(`/v1/apps/${appId}/stats`) + .query(true) + .reply(200, mockStats); + + const { stdout } = await runCommand( + ["stats:app", appId, "--start", "1h"], + import.meta.url, + ); + + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); + + it("should accept Unix ms for --start", async () => { + const scope = nock("https://control.ably.net") + .get(`/v1/apps/${appId}/stats`) + .query(true) + .reply(200, mockStats); + + const { stdout } = await runCommand( + ["stats:app", appId, "--start", "1672531200000"], + import.meta.url, + ); + + expect(scope.isDone()).toBe(true); + expect(stdout).toContain("2023-01-01"); + }); +}); diff --git a/test/unit/utils/time.test.ts b/test/unit/utils/time.test.ts new file mode 100644 index 00000000..e8477c8d --- /dev/null +++ b/test/unit/utils/time.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { parseTimestamp } from "../../../src/utils/time.js"; + +describe("parseTimestamp", () => { + describe("Unix milliseconds", () => { + it("should parse a numeric string as ms since epoch", () => { + expect(parseTimestamp("1700000000000")).toBe(1700000000000); + }); + + it("should parse zero", () => { + expect(parseTimestamp("0")).toBe(0); + }); + }); + + describe("ISO 8601", () => { + it("should parse a UTC ISO 8601 string", () => { + expect(parseTimestamp("2023-01-01T00:00:00Z")).toBe( + new Date("2023-01-01T00:00:00Z").getTime(), + ); + }); + + it("should parse a date-only string", () => { + const result = parseTimestamp("2023-06-15"); + expect(result).toBe(new Date("2023-06-15").getTime()); + }); + }); + + describe("relative time", () => { + const NOW = 1700000000000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should parse seconds (30s)", () => { + expect(parseTimestamp("30s")).toBe(NOW - 30 * 1000); + }); + + it("should parse minutes (5m)", () => { + expect(parseTimestamp("5m")).toBe(NOW - 5 * 60_000); + }); + + it("should parse hours (1h)", () => { + expect(parseTimestamp("1h")).toBe(NOW - 3_600_000); + }); + + it("should parse days (2d)", () => { + expect(parseTimestamp("2d")).toBe(NOW - 2 * 86_400_000); + }); + + it("should parse weeks (1w)", () => { + expect(parseTimestamp("1w")).toBe(NOW - 604_800_000); + }); + }); + + describe("relative time used as --end", () => { + const NOW = 1700000000000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should parse relative time with end label (30m)", () => { + expect(parseTimestamp("30m", "end")).toBe(NOW - 30 * 60_000); + }); + }); + + describe("error cases", () => { + it("should throw on invalid input", () => { + expect(() => parseTimestamp("not-a-date")).toThrow( + 'Invalid timestamp: "not-a-date"', + ); + }); + + it("should throw on empty string", () => { + expect(() => parseTimestamp("")).toThrow('Invalid timestamp: ""'); + }); + + it("should include the label in error messages", () => { + expect(() => parseTimestamp("garbage", "start")).toThrow( + 'Invalid start: "garbage"', + ); + }); + + it("should include format hints in error message", () => { + expect(() => parseTimestamp("xyz")).toThrow("ISO 8601"); + expect(() => parseTimestamp("xyz")).toThrow("Unix ms"); + expect(() => parseTimestamp("xyz")).toThrow("relative"); + }); + }); +}); From 61427f0d5ad2d274fe8786c2b3c2c3077375254e Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 5 Mar 2026 22:37:31 +0000 Subject: [PATCH 2/6] copilot review flagged some inconsistent json error formatting - fixed --- src/commands/apps/set-apns-p12.ts | 9 ++++-- src/commands/channels/batch-publish.ts | 2 +- src/commands/channels/history.ts | 10 ++++--- src/commands/channels/subscribe.ts | 2 +- src/commands/integrations/delete.ts | 9 ++++-- .../logs/channel-lifecycle/subscribe.ts | 6 +++- src/commands/logs/push/subscribe.ts | 6 +++- src/commands/queues/delete.ts | 9 ++++-- src/commands/rooms/messages/subscribe.ts | 6 ++-- src/commands/rooms/presence/enter.ts | 9 ++++-- src/commands/spaces/cursors/set.ts | 30 +++++++++++++------ src/commands/spaces/locations/get-all.ts | 9 +++++- src/commands/spaces/locations/set.ts | 6 +++- src/commands/spaces/locks/acquire.ts | 13 ++++++-- src/commands/spaces/locks/get.ts | 18 +++++++---- src/commands/spaces/locks/subscribe.ts | 4 +-- src/commands/spaces/members/subscribe.ts | 4 +-- src/commands/stats/account.ts | 27 +++++++++++------ src/commands/stats/app.ts | 27 +++++++++++------ src/commands/support/ask.ts | 2 +- test/unit/commands/interactive-sigint.test.ts | 6 ++-- 21 files changed, 143 insertions(+), 71 deletions(-) diff --git a/src/commands/apps/set-apns-p12.ts b/src/commands/apps/set-apns-p12.ts index fcd8407a..a6873ba4 100644 --- a/src/commands/apps/set-apns-p12.ts +++ b/src/commands/apps/set-apns-p12.ts @@ -80,9 +80,12 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { } } } catch (error) { - this.error( - `Error uploading APNS P12 certificate: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error uploading APNS P12 certificate: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/channels/batch-publish.ts b/src/commands/channels/batch-publish.ts index dae775c1..48d644fc 100644 --- a/src/commands/channels/batch-publish.ts +++ b/src/commands/channels/batch-publish.ts @@ -248,7 +248,7 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { } as BatchContent; } - if (!this.shouldOutputJson(flags)) { + if (!this.shouldOutputJson(flags) && !this.shouldSuppressOutput(flags)) { this.log(progress("Sending batch publish request")); } diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 6a36003c..6a5c5818 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -141,10 +141,12 @@ export default class ChannelsHistory extends AblyBaseCommand { } } } catch (error) { - // Restore standard error handling - this.error( - `Error retrieving channel history: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error retrieving channel history: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index f307e8c2..c346d14b 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -82,8 +82,8 @@ export default class ChannelsSubscribe extends AblyBaseCommand { private sequenceCounter = 0; async run(): Promise { - const { flags } = await this.parse(ChannelsSubscribe); const parseResult = await this.parse(ChannelsSubscribe); + const { flags } = parseResult; // Get all channel names from argv const channelNames = parseResult.argv as string[]; diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index 3e166b8c..3015903b 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -102,9 +102,12 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { this.log(`Source Type: ${integration.source.type}`); } } catch (error) { - this.error( - `Error deleting integration: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error deleting integration: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/logs/channel-lifecycle/subscribe.ts b/src/commands/logs/channel-lifecycle/subscribe.ts index da42f371..28ae2df8 100644 --- a/src/commands/logs/channel-lifecycle/subscribe.ts +++ b/src/commands/logs/channel-lifecycle/subscribe.ts @@ -164,7 +164,11 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { `Error during log subscription: ${err.message}`, { channel: channelName, error: err.message }, ); - this.error(err.message); + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: err.message, success: false }, flags); + } else { + this.error(err.message); + } } // Client cleanup is handled by command finally() method } diff --git a/src/commands/logs/push/subscribe.ts b/src/commands/logs/push/subscribe.ts index 3a286387..dd9f2148 100644 --- a/src/commands/logs/push/subscribe.ts +++ b/src/commands/logs/push/subscribe.ts @@ -190,7 +190,11 @@ export default class LogsPushSubscribe extends AblyBaseCommand { `Error during log subscription: ${err.message}`, { error: err.message }, ); - this.error(err.message); + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: err.message, success: false }, flags); + } else { + this.error(err.message); + } } // Client cleanup is handled by command finally() method } diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index e74e6478..3e635ca2 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -102,9 +102,12 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { ); } } catch (error) { - this.error( - `Error deleting queue: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error deleting queue: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/rooms/messages/subscribe.ts b/src/commands/rooms/messages/subscribe.ts index 2a319f61..b4dc0d86 100644 --- a/src/commands/rooms/messages/subscribe.ts +++ b/src/commands/rooms/messages/subscribe.ts @@ -234,11 +234,11 @@ export default class MessagesSubscribe extends ChatBaseCommand { } async run(): Promise { - const { flags } = await this.parse(MessagesSubscribe); - const _args = await this.parse(MessagesSubscribe); + const parseResult = await this.parse(MessagesSubscribe); + const { flags } = parseResult; // Get all room names from argv - this.roomNames = _args.argv as string[]; + this.roomNames = parseResult.argv as string[]; if (this.roomNames.length === 0) { const errorMsg = "At least one room name is required"; diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 967df552..0fb55832 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -87,9 +87,12 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { } this.data = JSON.parse(trimmed); } catch (error) { - this.error( - `Invalid data JSON: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Invalid data JSON: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } return; // Exit early if JSON is invalid } } diff --git a/src/commands/spaces/cursors/set.ts b/src/commands/spaces/cursors/set.ts index 5dec3b16..53a6681e 100644 --- a/src/commands/spaces/cursors/set.ts +++ b/src/commands/spaces/cursors/set.ts @@ -107,9 +107,13 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { const additionalData = JSON.parse(flags.data); cursorData.data = additionalData; } catch { - this.error( - 'Invalid JSON in --data flag. Expected format: {"name":"value",...}', - ); + const errorMsg = + 'Invalid JSON in --data flag. Expected format: {"name":"value",...}'; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } return; } } @@ -125,9 +129,13 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { const additionalData = JSON.parse(flags.data); cursorData.data = additionalData; } catch { - this.error( - 'Invalid JSON in --data flag when used with --x and --y. Expected format: {"name":"value",...}', - ); + const errorMsg = + 'Invalid JSON in --data flag when used with --x and --y. Expected format: {"name":"value",...}'; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } return; } } @@ -136,9 +144,13 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { try { cursorData = JSON.parse(flags.data); } catch { - this.error( - 'Invalid JSON in --data flag. Expected format: {"position":{"x":number,"y":number},"data":{...}}', - ); + const errorMsg = + 'Invalid JSON in --data flag. Expected format: {"position":{"x":number,"y":number},"data":{...}}'; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } return; } diff --git a/src/commands/spaces/locations/get-all.ts b/src/commands/spaces/locations/get-all.ts index 0ba1acd1..35b21dda 100644 --- a/src/commands/spaces/locations/get-all.ts +++ b/src/commands/spaces/locations/get-all.ts @@ -293,7 +293,14 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { error instanceof Error ? error.message : String(error || "Unknown error"); - this.error(`Error: ${errorMessage}`); + if (this.shouldOutputJson(flags)) { + this.jsonError( + { error: errorMessage, spaceName, status: "error", success: false }, + flags, + ); + } else { + this.error(`Error: ${errorMessage}`); + } } } } diff --git a/src/commands/spaces/locations/set.ts b/src/commands/spaces/locations/set.ts index 05e3cae6..b4fecf03 100644 --- a/src/commands/spaces/locations/set.ts +++ b/src/commands/spaces/locations/set.ts @@ -95,7 +95,11 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { this.logCliEvent(flags, "location", "dataParseError", errorMsg, { error: errorMsg, }); - this.error(errorMsg); + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } return; } diff --git a/src/commands/spaces/locks/acquire.ts b/src/commands/spaces/locks/acquire.ts index 30cbcf68..73585a13 100644 --- a/src/commands/spaces/locks/acquire.ts +++ b/src/commands/spaces/locks/acquire.ts @@ -104,7 +104,11 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { this.logCliEvent(flags, "lock", "dataParseError", errorMsg, { error: errorMsg, }); - this.error(errorMsg); + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } return; } } @@ -198,7 +202,12 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { // Decide how long to remain connected await waitUntilInterruptedOrTimeout(flags.duration); } catch (error) { - this.error(error instanceof Error ? error.message : String(error)); + const errorMsg = error instanceof Error ? error.message : String(error); + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/spaces/locks/get.ts b/src/commands/spaces/locks/get.ts index 633d8d78..959e021f 100644 --- a/src/commands/spaces/locks/get.ts +++ b/src/commands/spaces/locks/get.ts @@ -78,14 +78,20 @@ export default class SpacesLocksGet extends SpacesBaseCommand { ); } } catch (error) { - this.error( - `Failed to get lock: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Failed to get lock: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } catch (error) { - this.error( - `Error: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/spaces/locks/subscribe.ts b/src/commands/spaces/locks/subscribe.ts index fff2e770..b54ef7e4 100644 --- a/src/commands/spaces/locks/subscribe.ts +++ b/src/commands/spaces/locks/subscribe.ts @@ -314,9 +314,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { error: errorMsg, }); if (this.shouldOutputJson(flags)) { - this.log( - this.formatJsonOutput({ error: errorMsg, success: false }, flags), - ); + this.jsonError({ error: errorMsg, success: false }, flags); } else { this.error(errorMsg); } diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index 71205d2e..fef71d07 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -320,9 +320,7 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { error: errorMsg, }); if (this.shouldOutputJson(flags)) { - this.log( - this.formatJsonOutput({ error: errorMsg, success: false }, flags), - ); + this.jsonError({ error: errorMsg, success: false }, flags); } else { this.error(errorMsg); } diff --git a/src/commands/stats/account.ts b/src/commands/stats/account.ts index f77c4b95..97bec761 100644 --- a/src/commands/stats/account.ts +++ b/src/commands/stats/account.ts @@ -105,9 +105,12 @@ export default class StatsAccountCommand extends ControlBaseCommand { this.statsDisplay!.display(stats[0]); } } catch (error) { - this.error( - `Error fetching stats: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error fetching stats: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } @@ -192,9 +195,12 @@ export default class StatsAccountCommand extends ControlBaseCommand { // The process will exit via the SIGINT/SIGTERM handlers }); } catch (error) { - this.error( - `Error setting up live stats: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error setting up live stats: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } if (this.pollInterval) { clearInterval(this.pollInterval); } @@ -254,9 +260,12 @@ export default class StatsAccountCommand extends ControlBaseCommand { this.statsDisplay!.display(stat); } } catch (error) { - this.error( - `Error fetching account stats: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error fetching account stats: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/stats/app.ts b/src/commands/stats/app.ts index c43774af..55100407 100644 --- a/src/commands/stats/app.ts +++ b/src/commands/stats/app.ts @@ -125,9 +125,12 @@ export default class StatsAppCommand extends ControlBaseCommand { this.statsDisplay!.display(stats[0]); } } catch (error) { - this.error( - `Error fetching stats: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error fetching stats: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } @@ -210,9 +213,12 @@ export default class StatsAppCommand extends ControlBaseCommand { // The process will exit via the SIGINT/SIGTERM handlers }); } catch (error) { - this.error( - `Error setting up live stats: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error setting up live stats: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } if (this.pollInterval) { clearInterval(this.pollInterval); } @@ -268,9 +274,12 @@ export default class StatsAppCommand extends ControlBaseCommand { this.statsDisplay!.display(stat); } } catch (error) { - this.error( - `Error fetching app stats: ${error instanceof Error ? error.message : String(error)}`, - ); + const errorMsg = `Error fetching app stats: ${error instanceof Error ? error.message : String(error)}`; + if (this.shouldOutputJson(flags)) { + this.jsonError({ error: errorMsg, success: false }, flags); + } else { + this.error(errorMsg); + } } } } diff --git a/src/commands/support/ask.ts b/src/commands/support/ask.ts index f0a2ccf2..ee052cb8 100644 --- a/src/commands/support/ask.ts +++ b/src/commands/support/ask.ts @@ -90,7 +90,7 @@ export default class AskCommand extends ControlBaseCommand { const processedWithCodeBlocks = response.answer.replaceAll( /```(?:javascript|js|html)?\n([\S\s]*?)```/g, (_, codeContent) => - // Return the code block with each line highlighted in cyan + // Return the code block with each line highlighted in green codeContent .split("\n") .map((line: string) => chalk.green(` ${line}`)) diff --git a/test/unit/commands/interactive-sigint.test.ts b/test/unit/commands/interactive-sigint.test.ts index 822554ed..bace437b 100644 --- a/test/unit/commands/interactive-sigint.test.ts +++ b/test/unit/commands/interactive-sigint.test.ts @@ -92,11 +92,9 @@ describe("Interactive Mode - SIGINT Handling", () => { done(); }); - // Timeout fallback + // Timeout fallback - always send exit to prevent hanging setTimeout(() => { - if (!commandStarted) { - child.stdin.write("exit\n"); - } + child.stdin.write("exit\n"); }, timeout - 1000); }, timeout, From e017270d9b101d76032578adce69c69b320d06f3 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 5 Mar 2026 22:43:01 +0000 Subject: [PATCH 3/6] coderabbit fixes --- src/commands/rooms/messages/reactions/send.ts | 3 ++- src/commands/rooms/reactions/send.ts | 3 ++- src/utils/time.ts | 17 +++++++++++++++-- test/unit/commands/did-you-mean.test.ts | 7 +------ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/commands/rooms/messages/reactions/send.ts b/src/commands/rooms/messages/reactions/send.ts index eb67b192..5a04e0f6 100644 --- a/src/commands/rooms/messages/reactions/send.ts +++ b/src/commands/rooms/messages/reactions/send.ts @@ -9,7 +9,7 @@ import { import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; -import { productApiFlags } from "../../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../../flags.js"; import { resource, success } from "../../../../utils/output.js"; // Map CLI-friendly type names to SDK MessageReactionType values @@ -58,6 +58,7 @@ export default class MessagesReactionsSend extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, type: Flags.string({ description: "The type of reaction (unique, distinct, or multiple)", options: Object.keys(REACTION_TYPE_MAP), diff --git a/src/commands/rooms/reactions/send.ts b/src/commands/rooms/reactions/send.ts index 63decba8..4f843b77 100644 --- a/src/commands/rooms/reactions/send.ts +++ b/src/commands/rooms/reactions/send.ts @@ -8,7 +8,7 @@ import { import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; import { resource, success } from "../../../utils/output.js"; export default class RoomsReactionsSend extends ChatBaseCommand { @@ -34,6 +34,7 @@ export default class RoomsReactionsSend extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, metadata: Flags.string({ description: "Additional metadata to send with the reaction (as JSON string)", diff --git a/src/utils/time.ts b/src/utils/time.ts index 71d7b7ab..f49123fc 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -23,8 +23,21 @@ export function parseTimestamp(input: string, label = "timestamp"): number { return Date.now() - Number.parseInt(match[1], 10) * multipliers[match[2]]; } - // ISO 8601 or other parseable date string - const ms = new Date(input).getTime(); + // Strict ISO 8601 validation: date-only or date-time with optional fractional seconds and timezone + const trimmed = input.trim(); + const isIso = + /^\d{4}-\d{2}-\d{2}$/.test(trimmed) || + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test( + trimmed, + ); + if (!isIso) { + throw new TypeError( + `Invalid ${label}: "${input}". ` + + `Use ISO 8601 (e.g., "2023-01-01T00:00:00Z"), Unix ms (e.g., "1700000000000"), or relative (e.g., "1h", "30m", "2d").`, + ); + } + + const ms = new Date(trimmed).getTime(); if (Number.isNaN(ms)) { throw new TypeError( `Invalid ${label}: "${input}". ` + diff --git a/test/unit/commands/did-you-mean.test.ts b/test/unit/commands/did-you-mean.test.ts index 58e42a8b..9dbd2a92 100644 --- a/test/unit/commands/did-you-mean.test.ts +++ b/test/unit/commands/did-you-mean.test.ts @@ -193,13 +193,10 @@ describe("Did You Mean Functionality", () => { }, }); - let output = ""; let foundPrompt = false; let executedCommand = false; child.stdout.on("data", (data) => { - output += data.toString(); - if ( data.toString().includes("Did you mean accounts current?") || data.toString().includes("(Y/n)") @@ -232,8 +229,6 @@ describe("Did You Mean Functionality", () => { }); child.stderr.on("data", (data) => { - output += data.toString(); - const errorOutput = data.toString(); if ( errorOutput.includes("No access token provided") || @@ -256,7 +251,7 @@ describe("Did You Mean Functionality", () => { child.stdin.write("accounts curren\n"); }, 1000); - child.on("exit", (code) => { + child.on("exit", () => { expect(foundPrompt).toBe(true); expect(executedCommand).toBe(true); resolve(); From ca4c3c72e8e778853ede59e4231579773fe2e829 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 5 Mar 2026 22:52:47 +0000 Subject: [PATCH 4/6] latest copilot review --- src/commands/integrations/delete.ts | 13 ++++++++++++ src/commands/logs/push/history.ts | 3 ++- src/commands/queues/delete.ts | 13 ++++++++++++ src/utils/time.ts | 13 ++++++------ test/unit/utils/time.test.ts | 33 +++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index 3015903b..2e6f7661 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -53,6 +53,19 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { // Get integration details for confirmation const integration = await controlApi.getRule(appId, args.integrationId); + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.jsonError( + { + error: + "The --force flag is required when using --json to confirm deletion", + success: false, + }, + flags, + ); + return; + } + // If not using force flag, prompt for confirmation if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following integration:`); diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index ce024813..510f8409 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -1,4 +1,5 @@ import { Flags } from "@oclif/core"; +import * as Ably from "ably"; import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; @@ -48,7 +49,7 @@ export default class LogsPushHistory extends AblyBaseCommand { const channel = client.channels.get(channelName); // Get message history - const historyOptions: Record = { + const historyOptions: Ably.RealtimeHistoryParams = { direction: flags.direction as "backwards" | "forwards", limit: flags.limit, }; diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index 3e635ca2..de0a2bfa 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -59,6 +59,19 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { return; } + // In JSON mode, require --force to prevent accidental destructive actions + if (!flags.force && this.shouldOutputJson(flags)) { + this.jsonError( + { + error: + "The --force flag is required when using --json to confirm deletion", + success: false, + }, + flags, + ); + return; + } + // If not using force flag, prompt for confirmation if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following queue:`); diff --git a/src/utils/time.ts b/src/utils/time.ts index f49123fc..d33befb5 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -4,14 +4,16 @@ * Accepts: * - Unix milliseconds as a numeric string (e.g., "1700000000000") * - Relative time shorthand (e.g., "30s", "5m", "1h", "2d", "1w") - * - ISO 8601 or any Date-parseable string (e.g., "2023-01-01T00:00:00Z") + * - ISO 8601 date or datetime string (e.g., "2023-01-01", "2023-01-01T00:00:00Z") */ export function parseTimestamp(input: string, label = "timestamp"): number { + const trimmed = input.trim(); + // Pure numeric → ms since epoch - if (/^\d+$/.test(input)) return Number.parseInt(input, 10); + if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10); // Relative time: 30s, 5m, 1h, 2d, 1w - const match = /^(\d+)([smhdw])$/.exec(input); + const match = /^(\d+)([smhdw])$/.exec(trimmed); if (match) { const multipliers: Record = { s: 1000, @@ -23,11 +25,10 @@ export function parseTimestamp(input: string, label = "timestamp"): number { return Date.now() - Number.parseInt(match[1], 10) * multipliers[match[2]]; } - // Strict ISO 8601 validation: date-only or date-time with optional fractional seconds and timezone - const trimmed = input.trim(); + // Strict ISO 8601 validation: date-only (interpreted as UTC) or date-time with required timezone const isIso = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) || - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.test( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test( trimmed, ); if (!isIso) { diff --git a/test/unit/utils/time.test.ts b/test/unit/utils/time.test.ts index e8477c8d..c4ffc7dc 100644 --- a/test/unit/utils/time.test.ts +++ b/test/unit/utils/time.test.ts @@ -75,6 +75,26 @@ describe("parseTimestamp", () => { }); }); + describe("whitespace handling", () => { + it("should trim whitespace from numeric input", () => { + expect(parseTimestamp(" 1700000000000 ")).toBe(1700000000000); + }); + + it("should trim whitespace from relative input", () => { + const NOW = 1700000000000; + vi.useFakeTimers(); + vi.setSystemTime(NOW); + expect(parseTimestamp(" 1h ")).toBe(NOW - 3_600_000); + vi.useRealTimers(); + }); + + it("should trim whitespace from ISO input", () => { + expect(parseTimestamp(" 2023-01-01T00:00:00Z ")).toBe( + new Date("2023-01-01T00:00:00Z").getTime(), + ); + }); + }); + describe("error cases", () => { it("should throw on invalid input", () => { expect(() => parseTimestamp("not-a-date")).toThrow( @@ -86,6 +106,19 @@ describe("parseTimestamp", () => { expect(() => parseTimestamp("")).toThrow('Invalid timestamp: ""'); }); + it("should reject date-time without timezone", () => { + expect(() => parseTimestamp("2023-01-01T00:00:00")).toThrow( + 'Invalid timestamp: "2023-01-01T00:00:00"', + ); + }); + + it("should reject non-ISO date formats", () => { + expect(() => parseTimestamp("03/05/2026")).toThrow("Invalid timestamp"); + expect(() => parseTimestamp("March 5, 2026")).toThrow( + "Invalid timestamp", + ); + }); + it("should include the label in error messages", () => { expect(() => parseTimestamp("garbage", "start")).toThrow( 'Invalid start: "garbage"', From 695ceccc890e5157a837fa796f7d81d41db9c9a0 Mon Sep 17 00:00:00 2001 From: umair Date: Fri, 6 Mar 2026 05:12:05 +0000 Subject: [PATCH 5/6] Address CodeRabbit review comments - Add inverted time range validation (--start > --end) to all history commands - Add clientIdFlag to rooms/occupancy/get, rooms/reactions/subscribe, rooms/typing/keystroke - Fix current.member serialization in spaces/locations/get-all JSON output - Handle partial time ranges in stats/account (default missing bound) - Fix misleading comments in interactive test and cursors/get-all Co-Authored-By: Claude Opus 4.6 --- src/commands/channels/history.ts | 8 ++++++++ src/commands/logs/connection-lifecycle/history.ts | 8 ++++++++ src/commands/logs/history.ts | 8 ++++++++ src/commands/logs/push/history.ts | 8 ++++++++ src/commands/rooms/occupancy/get.ts | 3 ++- src/commands/rooms/reactions/subscribe.ts | 3 ++- src/commands/rooms/typing/keystroke.ts | 3 ++- src/commands/spaces/cursors/get-all.ts | 2 +- src/commands/spaces/locations/get-all.ts | 13 +++++++++++-- src/commands/stats/account.ts | 8 ++++++++ test/unit/commands/interactive.test.ts | 2 +- 11 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index ee783300..be8b4b17 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -86,6 +86,14 @@ export default class ChannelsHistory extends AblyBaseCommand { historyParams.end = parseTimestamp(flags.end, "end"); } + if ( + historyParams.start !== undefined && + historyParams.end !== undefined && + historyParams.start > historyParams.end + ) { + this.error("--start must be earlier than or equal to --end"); + } + // Get history const history = await channel.history(historyParams); const messages = history.items; diff --git a/src/commands/logs/connection-lifecycle/history.ts b/src/commands/logs/connection-lifecycle/history.ts index 6b33cdf1..4c12b8bd 100644 --- a/src/commands/logs/connection-lifecycle/history.ts +++ b/src/commands/logs/connection-lifecycle/history.ts @@ -63,6 +63,14 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { historyParams.end = parseTimestamp(flags.end, "end"); } + if ( + historyParams.start !== undefined && + historyParams.end !== undefined && + historyParams.start > historyParams.end + ) { + this.error("--start must be earlier than or equal to --end"); + } + // Get history const history = await channel.history(historyParams); const messages = history.items; diff --git a/src/commands/logs/history.ts b/src/commands/logs/history.ts index 9cee7841..0c57dff2 100644 --- a/src/commands/logs/history.ts +++ b/src/commands/logs/history.ts @@ -63,6 +63,14 @@ export default class LogsHistory extends AblyBaseCommand { historyParams.end = parseTimestamp(flags.end, "end"); } + if ( + historyParams.start !== undefined && + historyParams.end !== undefined && + historyParams.start > historyParams.end + ) { + this.error("--start must be earlier than or equal to --end"); + } + // Get history const history = await channel.history(historyParams); const messages = history.items; diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index 8ee1225f..19360dab 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -63,6 +63,14 @@ export default class LogsPushHistory extends AblyBaseCommand { historyOptions.end = parseTimestamp(flags.end, "end"); } + if ( + historyOptions.start !== undefined && + historyOptions.end !== undefined && + historyOptions.start > historyOptions.end + ) { + this.error("--start must be earlier than or equal to --end"); + } + const historyPage = await channel.history(historyOptions); const messages = historyPage.items; diff --git a/src/commands/rooms/occupancy/get.ts b/src/commands/rooms/occupancy/get.ts index 47661c3a..8b2ebc10 100644 --- a/src/commands/rooms/occupancy/get.ts +++ b/src/commands/rooms/occupancy/get.ts @@ -1,7 +1,7 @@ import { Args } from "@oclif/core"; import { ChatClient, Room, OccupancyData } from "@ably/chat"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; import { resource } from "../../../utils/output.js"; export default class RoomsOccupancyGet extends ChatBaseCommand { @@ -23,6 +23,7 @@ export default class RoomsOccupancyGet extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, }; private chatClient: ChatClient | null = null; diff --git a/src/commands/rooms/reactions/subscribe.ts b/src/commands/rooms/reactions/subscribe.ts index eca9553a..e0434c9c 100644 --- a/src/commands/rooms/reactions/subscribe.ts +++ b/src/commands/rooms/reactions/subscribe.ts @@ -3,7 +3,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { progress, @@ -31,6 +31,7 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, duration: Flags.integer({ description: "Automatically exit after N seconds", char: "D", diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index dc117832..bfbc0dd3 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -2,7 +2,7 @@ import { RoomStatus, ChatClient, RoomStatusChange } from "@ably/chat"; import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { listening, resource, success } from "../../../utils/output.js"; @@ -36,6 +36,7 @@ export default class TypingKeystroke extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, "auto-type": Flags.boolean({ description: "Automatically keep typing indicator active", default: false, diff --git a/src/commands/spaces/cursors/get-all.ts b/src/commands/spaces/cursors/get-all.ts index 1711cf95..7b295cbb 100644 --- a/src/commands/spaces/cursors/get-all.ts +++ b/src/commands/spaces/cursors/get-all.ts @@ -154,7 +154,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { if (cursor.connectionId) { cursorMap.set(cursor.connectionId, cursor); - // Show live update on one line + // Show live cursor position updates if ( !this.shouldOutputJson(flags) && this.shouldUseTerminalUpdates() diff --git a/src/commands/spaces/locations/get-all.ts b/src/commands/spaces/locations/get-all.ts index 35b21dda..2028e4a9 100644 --- a/src/commands/spaces/locations/get-all.ts +++ b/src/commands/spaces/locations/get-all.ts @@ -162,6 +162,7 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { const knownMetaKeys = new Set([ "clientId", "connectionId", + "current", "id", "member", "memberId", @@ -200,16 +201,24 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { this.formatJsonOutput( { locations: validLocations.map((item: LocationItem) => { + const currentMember = + "current" in item && + item.current && + typeof item.current === "object" + ? (item.current as LocationWithCurrent["current"]).member + : undefined; + const member = item.member || currentMember; const memberId = item.memberId || - item.member?.clientId || + member?.memberId || + member?.clientId || item.clientId || item.id || item.userId || "Unknown"; const locationData = extractLocationData(item); return { - isCurrentMember: item.member?.isCurrentMember || false, + isCurrentMember: member?.isCurrentMember || false, location: locationData, memberId, }; diff --git a/src/commands/stats/account.ts b/src/commands/stats/account.ts index 97bec761..7fd27098 100644 --- a/src/commands/stats/account.ts +++ b/src/commands/stats/account.ts @@ -237,6 +237,14 @@ export default class StatsAccountCommand extends ControlBaseCommand { const now = Date.now(); endMs = now; startMs = now - 24 * 60 * 60 * 1000; // 24 hours ago + } else if (startMs !== undefined && endMs === undefined) { + endMs = Date.now(); + } else if (startMs === undefined && endMs !== undefined) { + startMs = endMs - 24 * 60 * 60 * 1000; + } + + if (startMs! > endMs!) { + this.error("--start must be earlier than or equal to --end"); } const stats = await controlApi.getAccountStats({ diff --git a/test/unit/commands/interactive.test.ts b/test/unit/commands/interactive.test.ts index 9e25402b..e042ec43 100644 --- a/test/unit/commands/interactive.test.ts +++ b/test/unit/commands/interactive.test.ts @@ -187,7 +187,7 @@ describe("Interactive Command", () => { }); child.stderr.on("data", () => { - // stderr captured but not logged + // Intentionally discarded — stderr is expected but irrelevant to this test }); child.on("exit", (code, signal) => { From 8083e60d2356a2237681b9e9f6eb6b446632964b Mon Sep 17 00:00:00 2001 From: umair Date: Fri, 6 Mar 2026 06:34:58 +0000 Subject: [PATCH 6/6] further improvements --- src/commands/integrations/update.ts | 5 ++--- src/commands/logs/connection-lifecycle/subscribe.ts | 2 +- src/commands/logs/push/history.ts | 6 +++--- src/commands/logs/subscribe.ts | 2 +- src/commands/rooms/messages/history.ts | 8 ++++++++ src/commands/rooms/messages/reactions/remove.ts | 3 ++- src/commands/rooms/messages/reactions/subscribe.ts | 3 ++- src/commands/rooms/occupancy/subscribe.ts | 3 ++- src/commands/rooms/presence/enter.ts | 6 +++++- src/commands/rooms/presence/subscribe.ts | 6 +++++- src/commands/rooms/typing/subscribe.ts | 3 ++- src/commands/stats/app.ts | 8 ++++++++ src/services/stats-display.ts | 2 -- test/unit/commands/integrations/update.test.ts | 6 +++--- 14 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/commands/integrations/update.ts b/src/commands/integrations/update.ts index c70a745d..fb9c7a25 100644 --- a/src/commands/integrations/update.ts +++ b/src/commands/integrations/update.ts @@ -1,7 +1,6 @@ import { Args, Flags } from "@oclif/core"; -import chalk from "chalk"; - import { ControlBaseCommand } from "../../control-base-command.js"; +import { success } from "../../utils/output.js"; // Interface for rule update data structure (most fields optional) interface PartialRuleData { @@ -118,7 +117,7 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ rule: updatedRule }, flags)); } else { - this.log(chalk.green("Integration Rule Updated Successfully:")); + this.log(success("Integration rule updated.")); this.log(`ID: ${updatedRule.id}`); this.log(`App ID: ${updatedRule.appId}`); this.log(`Rule Type: ${updatedRule.ruleType}`); diff --git a/src/commands/logs/connection-lifecycle/subscribe.ts b/src/commands/logs/connection-lifecycle/subscribe.ts index 72ee4a52..1a1effd2 100644 --- a/src/commands/logs/connection-lifecycle/subscribe.ts +++ b/src/commands/logs/connection-lifecycle/subscribe.ts @@ -105,7 +105,7 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { if (message.data !== null && message.data !== undefined) { this.log( - `${chalk.green("Data:")} ${JSON.stringify(message.data, null, 2)}`, + `${chalk.dim("Data:")} ${JSON.stringify(message.data, null, 2)}`, ); } diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index 19360dab..0af7270a 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -107,7 +107,7 @@ export default class LogsPushHistory extends AblyBaseCommand { ); this.log(""); - for (const message of messages) { + for (const [index, message] of messages.entries()) { const timestampDisplay = message.timestamp ? formatTimestamp(new Date(message.timestamp).toISOString()) : chalk.dim("[Unknown timestamp]"); @@ -153,10 +153,10 @@ export default class LogsPushHistory extends AblyBaseCommand { // Format the log output this.log( - `${timestampDisplay} Channel: ${chalk.cyan(channelName)} | Event: ${eventColor(event)}`, + `${chalk.dim(`[${index + 1}]`)} ${timestampDisplay} Channel: ${chalk.cyan(channelName)} | Event: ${eventColor(event)}`, ); if (message.data) { - this.log("Data:"); + this.log(chalk.dim("Data:")); if (isJsonData(message.data)) { this.log(formatJson(message.data)); } else { diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index 800b0640..590389de 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -149,7 +149,7 @@ export default class LogsSubscribe extends AblyBaseCommand { if (message.data !== null && message.data !== undefined) { this.log( - `${chalk.green("Data:")} ${JSON.stringify(message.data, null, 2)}`, + `${chalk.dim("Data:")} ${JSON.stringify(message.data, null, 2)}`, ); } diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index 9549057a..fe65b9b4 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -119,6 +119,14 @@ export default class MessagesHistory extends ChatBaseCommand { historyParams.end = parseTimestamp(flags.end, "end"); } + if ( + historyParams.start !== undefined && + historyParams.end !== undefined && + historyParams.start > historyParams.end + ) { + this.error("--start must be earlier than or equal to --end"); + } + // Get historical messages const messagesResult = await room.messages.history(historyParams); const { items } = messagesResult; diff --git a/src/commands/rooms/messages/reactions/remove.ts b/src/commands/rooms/messages/reactions/remove.ts index 84b71a29..2fb12288 100644 --- a/src/commands/rooms/messages/reactions/remove.ts +++ b/src/commands/rooms/messages/reactions/remove.ts @@ -8,7 +8,7 @@ import { import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; -import { productApiFlags } from "../../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../../flags.js"; import { resource, success } from "../../../../utils/output.js"; // Map CLI-friendly type names to SDK MessageReactionType values @@ -56,6 +56,7 @@ export default class MessagesReactionsRemove extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, type: Flags.string({ description: "The type of reaction (unique, distinct, or multiple)", options: Object.keys(REACTION_TYPE_MAP), diff --git a/src/commands/rooms/messages/reactions/subscribe.ts b/src/commands/rooms/messages/reactions/subscribe.ts index bd5fa3d8..4f912304 100644 --- a/src/commands/rooms/messages/reactions/subscribe.ts +++ b/src/commands/rooms/messages/reactions/subscribe.ts @@ -9,7 +9,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; -import { productApiFlags } from "../../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../../utils/long-running.js"; import { listening, @@ -38,6 +38,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, raw: Flags.boolean({ description: "Subscribe to raw individual reaction events instead of summaries", diff --git a/src/commands/rooms/occupancy/subscribe.ts b/src/commands/rooms/occupancy/subscribe.ts index 29dee00d..5fcaa26b 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -8,7 +8,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { success, @@ -42,6 +42,7 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, duration: Flags.integer({ description: "Automatically exit after N seconds", char: "D", diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 0fb55832..b37407c9 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -248,7 +248,11 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { `Error during command execution: ${errorMsg}`, { errorDetails: error }, ); - if (!this.shouldOutputJson(flags)) { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput({ error: errorMsg, success: false }, flags), + ); + } else { this.error(`Execution Error: ${errorMsg}`); } diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index cc231fb2..5a22cf96 100644 --- a/src/commands/rooms/presence/subscribe.ts +++ b/src/commands/rooms/presence/subscribe.ts @@ -293,7 +293,11 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { this.logCliEvent(flags, "presence", "runError", `Error: ${errorMsg}`, { room: this.roomName, }); - if (!this.shouldOutputJson(flags)) { + if (this.shouldOutputJson(flags)) { + this.log( + this.formatJsonOutput({ error: errorMsg, success: false }, flags), + ); + } else { this.error(`Error: ${errorMsg}`); } } finally { diff --git a/src/commands/rooms/typing/subscribe.ts b/src/commands/rooms/typing/subscribe.ts index 4df2c9b3..0aa7d47e 100644 --- a/src/commands/rooms/typing/subscribe.ts +++ b/src/commands/rooms/typing/subscribe.ts @@ -3,7 +3,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; -import { productApiFlags } from "../../../flags.js"; +import { clientIdFlag, productApiFlags } from "../../../flags.js"; import { waitUntilInterruptedOrTimeout } from "../../../utils/long-running.js"; import { success, listening, resource } from "../../../utils/output.js"; @@ -27,6 +27,7 @@ export default class TypingSubscribe extends ChatBaseCommand { static override flags = { ...productApiFlags, + ...clientIdFlag, duration: Flags.integer({ description: "Automatically exit after N seconds", char: "D", diff --git a/src/commands/stats/app.ts b/src/commands/stats/app.ts index 55100407..2639f985 100644 --- a/src/commands/stats/app.ts +++ b/src/commands/stats/app.ts @@ -251,6 +251,14 @@ export default class StatsAppCommand extends ControlBaseCommand { const now = Date.now(); endMs = now; startMs = now - 24 * 60 * 60 * 1000; // 24 hours ago + } else if (startMs !== undefined && endMs === undefined) { + endMs = Date.now(); + } else if (startMs === undefined && endMs !== undefined) { + startMs = endMs - 24 * 60 * 60 * 1000; + } + + if (startMs! > endMs!) { + this.error("--start must be earlier than or equal to --end"); } const stats = await controlApi.getAppStats(appId, { diff --git a/src/services/stats-display.ts b/src/services/stats-display.ts index bebb0bce..83e392d9 100644 --- a/src/services/stats-display.ts +++ b/src/services/stats-display.ts @@ -321,8 +321,6 @@ export class StatsDisplay { } private displayConnectionCumulativeStats(): void { - const _avgRates = this.calculateAverageRates(); - // Connections stats - simplified console.log( chalk.yellow("Connections:"), diff --git a/test/unit/commands/integrations/update.test.ts b/test/unit/commands/integrations/update.test.ts index 3dda85c7..595d36f5 100644 --- a/test/unit/commands/integrations/update.test.ts +++ b/test/unit/commands/integrations/update.test.ts @@ -55,7 +55,7 @@ describe("integrations:update command", () => { import.meta.url, ); - expect(stdout).toContain("Integration Rule Updated Successfully"); + expect(stdout).toContain("Integration rule updated."); expect(stdout).toContain(mockRuleId); }); @@ -102,7 +102,7 @@ describe("integrations:update command", () => { import.meta.url, ); - expect(stdout).toContain("Integration Rule Updated Successfully"); + expect(stdout).toContain("Integration rule updated."); }); it("should output JSON format when --json flag is used", async () => { @@ -349,7 +349,7 @@ describe("integrations:update command", () => { import.meta.url, ); - expect(stdout).toContain("Integration Rule Updated Successfully"); + expect(stdout).toContain("Integration rule updated."); }); }); });