From 9a17ff0100a51da6efc6451fcb1157caef4345aa Mon Sep 17 00:00:00 2001 From: umair Date: Sun, 8 Mar 2026 21:33:45 +0000 Subject: [PATCH 1/7] Adds a skill to assist in the upcoming work of adding new commands to the CLI --- .claude/skills/ably-new-command/SKILL.md | 607 +++++++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 .claude/skills/ably-new-command/SKILL.md diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md new file mode 100644 index 00000000..68bc55ea --- /dev/null +++ b/.claude/skills/ably-new-command/SKILL.md @@ -0,0 +1,607 @@ +--- +name: ably-new-command +description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a new subcommand, migrating/moving a command to a new group, or scaffolding a command with its test file. Triggers on: 'new command', 'add command', 'create command', 'scaffold command', 'add subcommand', 'implement command', or any request to build a new `ably ` command." +--- + +# Ably CLI New Command + +This skill helps you create new commands for the Ably CLI, including the command file, test file, and any needed index/topic files. The Ably CLI is built on oclif (TypeScript) and has strict conventions that every command must follow. + +## Step 1: Identify the command pattern + +Every command in this CLI falls into one of these patterns. Pick the right one based on what the command does: + +| Pattern | When to use | Base class | Client | Example | +|---------|------------|------------|--------|---------| +| **Subscribe** | Long-running event listener | `AblyBaseCommand` | Realtime | `channels subscribe`, `rooms messages subscribe` | +| **Publish/Send** | Send messages | `AblyBaseCommand` | REST or Realtime | `channels publish`, `rooms messages send` | +| **History** | Query past messages/events | `AblyBaseCommand` | REST | `channels history`, `rooms messages history` | +| **Get** | One-shot query for current state | `AblyBaseCommand` | REST | `channels occupancy get`, `rooms occupancy get` | +| **List** | Enumerate resources via REST API | `AblyBaseCommand` | REST | `channels list`, `rooms list` | +| **Enter** | Join presence/space then optionally listen | `AblyBaseCommand` | Realtime | `channels presence enter`, `spaces members enter` | +| **CRUD** | Create/read/update/delete via Control API | `ControlBaseCommand` | Control API (HTTP) | `integrations create`, `queues delete` | + +## Step 2: Create the command file + +### File location + +Commands map to the filesystem: `ably ` lives at `src/commands///.ts`. + +If the topic/subtopic doesn't exist yet, you also need an index file at `src/commands///index.ts` (or `src/commands//index.ts` for top-level topics). The index file is a simple topic descriptor — read an existing one like `src/commands/channels/index.ts` for the pattern. + +### Imports and base class + +**Product API commands** (channels, rooms, spaces, presence, pub/sub): +```typescript +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; +import chalk from "chalk"; + +import { AblyBaseCommand } from "../../base-command.js"; +import { productApiFlags, clientIdFlag } from "../../flags.js"; +import { resource, success, progress, listening, formatTimestamp } from "../../utils/output.js"; +``` + +**Control API commands** (apps, integrations, queues, keys, rules, push config): +```typescript +import { Args, Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { ControlBaseCommand } from "../../control-base-command.js"; +import { resource, success } from "../../utils/output.js"; +``` + +### Flag sets + +Choose the right combination — never add flags a command doesn't need: + +```typescript +// Product API command +static override flags = { + ...productApiFlags, // Always for product API commands + ...clientIdFlag, // See below for when to include this + // command-specific flags here +}; + +// Control API command — flags come from ControlBaseCommand.globalFlags automatically +static flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "The app ID or name (defaults to current app)", + required: false, + }), + // command-specific flags here +}; +``` + +**When to include `clientIdFlag`:** Add `...clientIdFlag` whenever the user might want to control which client identity performs the operation. This includes: presence enter/subscribe, spaces members, typing, cursors, publish, and any mutation where permissions may depend on the client (update, delete, annotate). The reason is that users may want to test auth scenarios — e.g., "can client B update client A's message?" — so they need the ability to set their client ID. + +For history commands, also use `timeRangeFlags`: +```typescript +import { productApiFlags, timeRangeFlags } from "../../flags.js"; +import { parseTimestamp } from "../../utils/time.js"; + +static override flags = { + ...productApiFlags, + ...timeRangeFlags, + // ... +}; +``` + +### Command metadata + +```typescript +export default class TopicAction extends AblyBaseCommand { + // Imperative mood, sentence case, no trailing period + static override description = "Subscribe to presence events on a channel"; + + static override examples = [ + "$ ably channels presence subscribe my-channel", + "$ ably channels presence subscribe my-channel --json", + ]; + + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + }; +``` + +### Flag naming conventions + +- All kebab-case: `--my-flag` (never camelCase) +- `--app`: `"The app ID or name (defaults to current app)"` +- `--limit`: `"Maximum number of results to return (default: N)"` +- `--duration`: `"Automatically exit after N seconds"`, alias `-D` +- `--rewind`: `"Number of messages to rewind when subscribing (default: 0)"` +- Channels use "publish", Rooms use "send" (matches SDK terminology) + +### Output patterns + +The CLI has specific output helpers in `src/utils/output.ts`. All human-readable output must be wrapped in a JSON guard: + +```typescript +// JSON guard — all human output goes through this +if (!this.shouldOutputJson(flags)) { + this.log(progress("Attaching to channel: " + resource(channelName))); +} + +// After success: +if (!this.shouldOutputJson(flags)) { + this.log(success("Subscribed to channel: " + resource(channelName) + ".")); + this.log(listening("Listening for messages.")); +} + +// JSON output: +if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput(data, flags)); +} +``` + +Rules: +- `progress("Action text")` — appends `...` automatically, never add it manually +- `success("Completed action.")` — green checkmark, always end with `.` (period, not `!`) +- `listening("Listening for X.")` — dim text, automatically appends "Press Ctrl+C to exit." +- `resource(name)` — cyan colored, never use quotes around resource names +- `formatTimestamp(ts)` — dim `[timestamp]` for event streams +- `chalk.blue(clientId)` for client IDs +- `chalk.yellow(eventType)` for event types +- `chalk.dim("Label:")` for secondary labels +- Use `this.error()` for fatal errors, never `this.log(chalk.red(...))` +- Never use `console.log` or `console.error` — always `this.log()` or `this.logToStderr()` + +### Pattern-specific implementation + +#### Subscribe pattern +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MySubscribeCommand); + + const client = await this.createAblyRealtimeClient(flags); + if (!client) return; + + this.setupConnectionStateLogging(client, flags); + + const channel = client.channels.get(args.channel, channelOptions); + this.setupChannelStateLogging(channel, flags); + + if (!this.shouldOutputJson(flags)) { + this.log(progress("Attaching to channel: " + resource(args.channel))); + } + + channel.once("attached", () => { + if (!this.shouldOutputJson(flags)) { + this.log(success("Attached to channel: " + resource(args.channel) + ".")); + this.log(listening("Listening for events.")); + } + }); + + let sequenceCounter = 0; + await channel.subscribe((message) => { + sequenceCounter++; + // Format and output the message + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ /* message data */ }, flags)); + } else { + // Human-readable output with formatTimestamp, resource, chalk colors + } + }); + + await waitUntilInterruptedOrTimeout(flags); +} +``` + +Import `waitUntilInterruptedOrTimeout` from `../../utils/long-running.js`. + +#### CRUD / Control API pattern +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyControlCommand); + + const controlApi = this.createControlApi(flags); + const appId = await this.resolveAppId(flags); + + if (!appId) { + this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); + return; + } + + try { + const result = await controlApi.someMethod(appId, data); + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ result }, flags)); + } else { + this.log(success("Resource created: " + resource(result.id) + ".")); + // Display additional fields + } + } catch (error) { + this.error(`Error creating resource: ${error instanceof Error ? error.message : String(error)}`); + } +} +``` + +#### List pattern (Control API or Product API) + +List commands query a collection and display results. They don't use `success()` because there's no action to confirm — they just display data. The output format depends on whether items are simple identifiers or structured multi-field records. + +**Simple identifier lists** (e.g., `channels list`, `rooms list`) — use `resource()` for each item: +```typescript +if (!this.shouldOutputJson(flags)) { + this.log(`Found ${chalk.cyan(items.length.toString())} active channels:`); + for (const item of items) { + this.log(`${resource(item.id)}`); + } +} +``` + +**Structured record lists** (e.g., `queues list`, `integrations list`, `push devices list`) — use `chalk.bold()` as a record heading with detail fields below: +```typescript +if (!this.shouldOutputJson(flags)) { + this.log(`Found ${items.length} devices:\n`); + for (const item of items) { + this.log(chalk.bold(`Device ID: ${item.id}`)); + this.log(` ${chalk.dim("Platform:")} ${item.platform}`); + this.log(` ${chalk.dim("Push State:")} ${item.pushState}`); + this.log(` ${chalk.dim("Client ID:")} ${item.clientId || "N/A"}`); + this.log(""); + } +} +``` + +Full Control API list command template: +```typescript +async run(): Promise { + const { flags } = await this.parse(MyListCommand); + + const controlApi = this.createControlApi(flags); + const appId = await this.resolveAppId(flags); + + if (!appId) { + this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); + return; + } + + try { + const items = await controlApi.listThings(appId); + const limited = flags.limit ? items.slice(0, flags.limit) : items; + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ items: limited, total: limited.length, appId }, flags)); + } else { + this.log(`Found ${limited.length} item${limited.length !== 1 ? "s" : ""}:\n`); + for (const item of limited) { + this.log(chalk.bold(`Item ID: ${item.id}`)); + this.log(` ${chalk.dim("Type:")} ${item.type}`); + this.log(` ${chalk.dim("Status:")} ${item.status}`); + this.log(""); + } + } + } catch (error) { + this.error(`Error listing items: ${error instanceof Error ? error.message : String(error)}`); + } +} +``` + +Key conventions for list output: +- `resource()` is for inline resource name references (e.g., in simple identifier lists or "Attaching to channel: " + resource(name)), not for record headings +- `chalk.bold()` is for record heading lines that act as visual separators between multi-field records +- `chalk.dim("Label:")` for field labels in detail lines +- `success()` is not used in list commands — it's for confirming an action completed + +#### Enter/Presence pattern +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyEnterCommand); + + const client = await this.createAblyRealtimeClient(flags); + if (!client) return; + + this.setupConnectionStateLogging(client, flags); + + const channel = client.channels.get(args.channel); + this.setupChannelStateLogging(channel, flags); + + // Parse optional JSON data (handle shell quote stripping) + let presenceData; + if (flags.data) { + try { + presenceData = JSON.parse(flags.data); + } catch { + this.error("Invalid JSON data provided"); + return; + } + } + + if (!this.shouldOutputJson(flags)) { + this.log(progress("Entering presence on channel: " + resource(args.channel))); + } + + // Optionally subscribe to other members' events before entering + if (flags["show-others"]) { + await channel.presence.subscribe((msg) => { + if (msg.clientId === client.auth.clientId) return; // filter self + // Display presence event + }); + } + + await channel.presence.enter(presenceData); + + if (!this.shouldOutputJson(flags)) { + this.log(success("Entered presence on channel: " + resource(args.channel) + ".")); + this.log(listening("Present on channel.")); + } + + await waitUntilInterruptedOrTimeout(flags); +} + +// Clean up in finally — leave presence +async finally(err: Error | undefined): Promise { + // Leave presence before closing connection + return super.finally(err); +} +``` + +Key flags for enter commands: `--data` (JSON), `--show-others` (boolean), `--duration` / `-D`, `--sequence-numbers`. + +#### History pattern +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyHistoryCommand); + + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channel = rest.channels.get(args.channel); + + const historyParams = { + direction: flags.direction, + limit: flags.limit, + ...(flags.start && { start: parseTimestamp(flags.start) }), + ...(flags.end && { end: parseTimestamp(flags.end) }), + }; + + const history = await channel.history(historyParams); + const messages = history.items; + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ messages }, flags)); + } else { + this.log(success(`Found ${messages.length} messages.`)); + // Display each message + } +} +``` + +## Step 3: Create the test file + +Test files go at `test/unit/commands/.test.ts`. + +### Product API test (Realtime mock) + +```typescript +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; + +describe("topic:action command", () => { + let mockCallback: ((event: unknown) => void) | null = null; + + beforeEach(() => { + mockCallback = null; + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + // Configure subscribe to capture the callback + channel.subscribe.mockImplementation((callback: (msg: unknown) => void) => { + mockCallback = callback; + }); + + // Auto-connect + mock.connection.once.mockImplementation((event: string, cb: () => void) => { + if (event === "connected") cb(); + }); + + // Auto-attach + channel.once.mockImplementation((event: string, cb: () => void) => { + if (event === "attached") { + channel.state = "attached"; + cb(); + } + }); + }); + + describe("help", () => { + it("should display help with --help flag", async () => { + const { stdout } = await runCommand(["topic:action", "--help"], import.meta.url); + expect(stdout).toContain("USAGE"); + }); + }); + + describe("argument validation", () => { + it("should require channel argument", async () => { + const { error } = await runCommand(["topic:action"], import.meta.url); + expect(error).toBeDefined(); + expect(error?.message).toMatch(/channel|required|argument/i); + }); + }); + + describe("functionality", () => { + it("should subscribe and display events", async () => { + const commandPromise = runCommand(["topic:action", "test-channel"], import.meta.url); + + await vi.waitFor(() => { expect(mockCallback).not.toBeNull(); }); + + mockCallback!({ + name: "test-event", + data: "hello", + timestamp: Date.now(), + }); + + const { stdout } = await commandPromise; + expect(stdout).toContain("test-channel"); + }); + }); +}); +``` + +### Product API test (REST mock) + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; + +describe("topic:action command", () => { + beforeEach(() => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.history.mockResolvedValue({ + items: [ + { id: "msg-1", name: "event", data: "hello", timestamp: 1700000000000 }, + ], + }); + }); + + it("should retrieve history", async () => { + const { stdout } = await runCommand( + ["topic:action", "test-channel"], + import.meta.url, + ); + expect(stdout).toContain("1"); + expect(stdout).toContain("messages"); + }); +}); +``` + +### Control API test (nock) + +```typescript +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("topic:action command", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("should create resource", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .post(`/v1/apps/${appId}/resources`) + .reply(201, { id: "res-123", appId }); + + const { stdout } = await runCommand( + ["topic:action", "--flag", "value"], + import.meta.url, + ); + + expect(stdout).toContain("created"); + }); + + it("should handle API errors", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .post(`/v1/apps/${appId}/resources`) + .reply(400, { error: "Bad request" }); + + const { error } = await runCommand( + ["topic:action", "--flag", "value"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); +}); +``` + +### Auth in tests + +- **Unit tests**: Auth is automatic via `MockConfigManager`. Never set `ABLY_API_KEY` env var unless testing env var override behavior. +- **Never pass auth flags**: No `--api-key`, `--token`, or `--access-token` in runCommand calls. +- Use `getMockConfigManager().getCurrentAppId()!` to get the test app ID for nock URLs. + +### Test structure + +Always include these describe blocks: +1. `help` — verify `--help` shows USAGE, key flags, and EXAMPLES +2. `argument validation` — verify required args produce errors when missing +3. `functionality` — core behavior tests +4. `flags` — verify key flags are accepted and configured +5. `error handling` — API errors, invalid input + +## Step 4: Create index files if needed + +If you're adding a new topic or subtopic, create an index file using `BaseTopicCommand`: + +```typescript +// src/commands/push/index.ts +import { BaseTopicCommand } from "../../base-topic-command.js"; + +export default class Push extends BaseTopicCommand { + protected topicName = "push"; + protected commandGroup = "Push notification"; + + static override description = "Manage push notifications"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> devices list", + "<%= config.bin %> <%= command.id %> config show", + ]; +} +``` + +For nested subtopics (e.g., `push devices`): +```typescript +// src/commands/push/devices/index.ts +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class PushDevices extends BaseTopicCommand { + protected topicName = "push:devices"; + protected commandGroup = "Push device"; + + static override description = "Manage push device registrations"; + + static override examples = [ + "<%= config.bin %> <%= command.id %> list", + "<%= config.bin %> <%= command.id %> get DEVICE_ID", + ]; +} +``` + +The `topicName` must match the oclif command ID prefix (colons for nesting). The `commandGroup` is used in the help display as "Ably {commandGroup} commands:". + +## Step 5: Web CLI restrictions + +If the new command shouldn't be available in the web CLI, add it to the appropriate array in `src/base-command.ts`: +- `WEB_CLI_RESTRICTED_COMMANDS` — for commands that don't make sense in a web context +- `WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS` — for commands that expose account/app data +- `INTERACTIVE_UNSUITABLE_COMMANDS` — for commands that don't work in interactive mode + +## Step 6: Validate + +After creating command and test files, always run: +```bash +pnpm prepare # Build + update manifest/README +pnpm exec eslint . # Lint (must be 0 errors) +pnpm test:unit # Run tests +``` + +## Checklist + +- [ ] Command file at correct path under `src/commands/` +- [ ] Correct base class (`AblyBaseCommand` vs `ControlBaseCommand`) +- [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`) +- [ ] `clientIdFlag` only if command needs client identity +- [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))` +- [ ] Output helpers used correctly (`progress`, `success`, `listening`, `resource`, `formatTimestamp`) +- [ ] `success()` messages end with `.` (period) +- [ ] Resource names use `resource(name)`, never quoted +- [ ] Test file at matching path under `test/unit/commands/` +- [ ] Tests use correct mock helper (`getMockAblyRealtime`, `getMockAblyRest`, `nock`) +- [ ] Tests don't pass auth flags — `MockConfigManager` handles auth +- [ ] Index file created if new topic/subtopic +- [ ] `pnpm prepare && pnpm exec eslint . && pnpm test:unit` passes From 78987fa446c42d8cccc698e8e6b1ca7b14517ab8 Mon Sep 17 00:00:00 2001 From: umair Date: Mon, 9 Mar 2026 11:08:11 +0000 Subject: [PATCH 2/7] improve new command skill --- .claude/skills/ably-new-command/SKILL.md | 468 ++++-------------- .../ably-new-command/references/patterns.md | 318 ++++++++++++ .../ably-new-command/references/testing.md | 275 ++++++++++ AGENTS.md | 6 +- src/utils/output.ts | 25 + 5 files changed, 715 insertions(+), 377 deletions(-) create mode 100644 .claude/skills/ably-new-command/references/patterns.md create mode 100644 .claude/skills/ably-new-command/references/testing.md diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index 68bc55ea..928fbc7d 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -1,6 +1,6 @@ --- name: ably-new-command -description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a new subcommand, migrating/moving a command to a new group, or scaffolding a command with its test file. Triggers on: 'new command', 'add command', 'create command', 'scaffold command', 'add subcommand', 'implement command', or any request to build a new `ably ` command." +description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a new subcommand, migrating/moving a command to a new group, or scaffolding a command with its test file. Triggers on: 'new command', 'add command', 'create command', 'scaffold command', 'add subcommand', 'implement command', or any request to build a new `ably ` command. IMPORTANT: This skill MUST be used any time the user wants to create, build, or implement ANY new CLI command or subcommand — even if they describe it casually (e.g., 'I need an ably X Y command', 'can you build ably rooms typing subscribe', 'we should add a purge command to queues'). Also use when moving or restructuring existing commands to new locations. Do NOT use for modifying existing commands, fixing bugs, debugging, adding tests to existing commands, or refactoring — only for creating net-new command files." --- # Ably CLI New Command @@ -21,6 +21,16 @@ Every command in this CLI falls into one of these patterns. Pick the right one b | **Enter** | Join presence/space then optionally listen | `AblyBaseCommand` | Realtime | `channels presence enter`, `spaces members enter` | | **CRUD** | Create/read/update/delete via Control API | `ControlBaseCommand` | Control API (HTTP) | `integrations create`, `queues delete` | +**Specialized base classes** — some command groups have dedicated base classes that handle common setup (client lifecycle, cleanup, shared flags): + +| Pattern | Base class | When to use | Source | +|---------|-----------|-------------|--------| +| Chat commands | `ChatBaseCommand` | `rooms messages`, `rooms reactions`, `rooms typing`, `rooms occupancy` | `src/chat-base-command.ts` | +| Spaces commands | `SpacesBaseCommand` | `spaces members`, `spaces locks`, `spaces cursors`, `spaces locations` | `src/spaces-base-command.ts` | +| Stats commands | `StatsBaseCommand` | `stats app`, `stats account` | `src/stats-base-command.ts` | + +If your command falls into one of these groups, extend the specialized base class instead of `AblyBaseCommand` or `ControlBaseCommand` directly. **Exception:** if your command only needs a REST client (e.g., history queries that don't enter a space or join a room), you may use `AblyBaseCommand` directly — the specialized base class is most valuable when the command needs realtime connections, cleanup lifecycle, or shared setup like `room.attach()` / `space.enter()`. + ## Step 2: Create the command file ### File location @@ -39,7 +49,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags, clientIdFlag } from "../../flags.js"; -import { resource, success, progress, listening, formatTimestamp } from "../../utils/output.js"; +import { resource, success, progress, listening, formatTimestamp, clientId, eventType, label, heading, index } from "../../utils/output.js"; ``` **Control API commands** (apps, integrations, queues, keys, rules, push config): @@ -48,9 +58,38 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { resource, success } from "../../utils/output.js"; +import { resource, success, label, heading } from "../../utils/output.js"; +``` + +**Chat commands** (rooms messages, reactions, typing, occupancy): +```typescript +import { Args, Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { ChatBaseCommand } from "../../chat-base-command.js"; +import { productApiFlags, clientIdFlag } from "../../flags.js"; +import { resource, success, progress, listening } from "../../utils/output.js"; ``` +**Spaces commands** (spaces members, locks, cursors, locations): +```typescript +import { Args, Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { SpacesBaseCommand } from "../../spaces-base-command.js"; +import { productApiFlags, clientIdFlag } from "../../flags.js"; +import { resource, success, progress, listening, clientId } from "../../utils/output.js"; +``` + +**Stats commands** (stats app, stats account): +```typescript +import { Args, Flags } from "@oclif/core"; + +import { StatsBaseCommand } from "../../stats-base-command.js"; +``` + +**Import depth:** These examples use `../../` which is correct for `src/commands//.ts`. For deeper nesting like `src/commands///.ts`, add one more `../` per level (e.g., `../../../base-command.js`). Always count the directory levels from your command file back to `src/`. + ### Flag sets Choose the right combination — never add flags a command doesn't need: @@ -139,399 +178,78 @@ if (this.shouldOutputJson(flags)) { } ``` +**Output helper reference** — all exported from `src/utils/output.ts`: + +| Helper | Usage | Example | +|--------|-------|---------| +| `progress(msg)` | Action in progress — appends `...` automatically | `progress("Attaching to channel")` | +| `success(msg)` | Green checkmark — always end with `.` (period, not `!`) | `success("Subscribed to channel " + resource(name) + ".")` | +| `listening(msg)` | Dim text — auto-appends "Press Ctrl+C to exit." | `listening("Listening for messages.")` | +| `resource(name)` | Cyan — for resource names, never use quotes | `resource(channelName)` | +| `formatTimestamp(ts)` | Dim `[timestamp]` — for event streams | `formatTimestamp(isoString)` | +| `formatMessageTimestamp(ts)` | Converts Ably message timestamp to ISO string | `formatMessageTimestamp(message.timestamp)` | +| `countLabel(n, singular, plural?)` | Cyan count + pluralized label | `countLabel(3, "message")` → "3 messages" | +| `limitWarning(count, limit, name)` | Yellow warning if results truncated, null otherwise | `limitWarning(items.length, flags.limit, "items")` | +| `formatPresenceAction(action)` | Returns `{ symbol, color }` for enter/leave/update | `formatPresenceAction("enter")` → `{ symbol: "✓", color: chalk.green }` | +| `clientId(id)` | Blue — for client identity display | `clientId(msg.clientId)` | +| `eventType(type)` | Yellow — for event/action type labels | `eventType("enter")` | +| `label(text)` | Dim with colon — for field labels in structured output | `label("Platform")` → dim "Platform:" | +| `heading(text)` | Bold — for record headings in list output | `heading("Device ID: " + id)` | +| `index(n)` | Dim bracketed number — for history ordering | `index(1)` → dim "[1]" | + Rules: - `progress("Action text")` — appends `...` automatically, never add it manually - `success("Completed action.")` — green checkmark, always end with `.` (period, not `!`) - `listening("Listening for X.")` — dim text, automatically appends "Press Ctrl+C to exit." - `resource(name)` — cyan colored, never use quotes around resource names - `formatTimestamp(ts)` — dim `[timestamp]` for event streams -- `chalk.blue(clientId)` for client IDs -- `chalk.yellow(eventType)` for event types -- `chalk.dim("Label:")` for secondary labels +- `clientId(id)` — blue, for client identity in events (replaces `chalk.blue(id)`) +- `eventType(type)` — yellow, for event/action labels (replaces `chalk.yellow(type)`) +- `label(text)` — dim with colon, for field labels (replaces `chalk.dim("Label:")`) +- `heading(text)` — bold, for record headings in lists (replaces `chalk.bold("Heading")`) +- `index(n)` — dim bracketed number, for history ordering (replaces `chalk.dim(\`[${n}]\`)`) - Use `this.error()` for fatal errors, never `this.log(chalk.red(...))` - Never use `console.log` or `console.error` — always `this.log()` or `this.logToStderr()` -### Pattern-specific implementation - -#### Subscribe pattern -```typescript -async run(): Promise { - const { args, flags } = await this.parse(MySubscribeCommand); - - const client = await this.createAblyRealtimeClient(flags); - if (!client) return; - - this.setupConnectionStateLogging(client, flags); - - const channel = client.channels.get(args.channel, channelOptions); - this.setupChannelStateLogging(channel, flags); - - if (!this.shouldOutputJson(flags)) { - this.log(progress("Attaching to channel: " + resource(args.channel))); - } - - channel.once("attached", () => { - if (!this.shouldOutputJson(flags)) { - this.log(success("Attached to channel: " + resource(args.channel) + ".")); - this.log(listening("Listening for events.")); - } - }); - - let sequenceCounter = 0; - await channel.subscribe((message) => { - sequenceCounter++; - // Format and output the message - if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonOutput({ /* message data */ }, flags)); - } else { - // Human-readable output with formatTimestamp, resource, chalk colors - } - }); - - await waitUntilInterruptedOrTimeout(flags); -} -``` - -Import `waitUntilInterruptedOrTimeout` from `../../utils/long-running.js`. +### Error handling -#### CRUD / Control API pattern -```typescript -async run(): Promise { - const { args, flags } = await this.parse(MyControlCommand); - - const controlApi = this.createControlApi(flags); - const appId = await this.resolveAppId(flags); - - if (!appId) { - this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); - return; - } - - try { - const result = await controlApi.someMethod(appId, data); - - if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonOutput({ result }, flags)); - } else { - this.log(success("Resource created: " + resource(result.id) + ".")); - // Display additional fields - } - } catch (error) { - this.error(`Error creating resource: ${error instanceof Error ? error.message : String(error)}`); - } -} -``` +Use these patterns for error handling in commands: -#### List pattern (Control API or Product API) +- **`this.error(message)`** — Fatal errors (oclif standard). Throws, so no `return` needed after it. +- **`this.handleCommandError(error, flags, component, context?)`** — Use in catch blocks. Logs the CLI event, emits JSON error when `--json` is active, and calls `this.error()` for human-readable output. +- **`this.jsonError(data, flags)`** — JSON-specific error output for non-standard error flows. -List commands query a collection and display results. They don't use `success()` because there's no action to confirm — they just display data. The output format depends on whether items are simple identifiers or structured multi-field records. - -**Simple identifier lists** (e.g., `channels list`, `rooms list`) — use `resource()` for each item: +Catch block template: ```typescript -if (!this.shouldOutputJson(flags)) { - this.log(`Found ${chalk.cyan(items.length.toString())} active channels:`); - for (const item of items) { - this.log(`${resource(item.id)}`); - } +try { + // command logic +} catch (error) { + this.handleCommandError( + error, + flags, + "ComponentName", // e.g., "ChannelPublish", "PresenceEnter" + { channel: args.channel }, // optional context for logging + ); } ``` -**Structured record lists** (e.g., `queues list`, `integrations list`, `push devices list`) — use `chalk.bold()` as a record heading with detail fields below: +For simple Control API errors where you don't need event logging: ```typescript -if (!this.shouldOutputJson(flags)) { - this.log(`Found ${items.length} devices:\n`); - for (const item of items) { - this.log(chalk.bold(`Device ID: ${item.id}`)); - this.log(` ${chalk.dim("Platform:")} ${item.platform}`); - this.log(` ${chalk.dim("Push State:")} ${item.pushState}`); - this.log(` ${chalk.dim("Client ID:")} ${item.clientId || "N/A"}`); - this.log(""); - } +try { + const result = await controlApi.someMethod(appId, data); + // handle result +} catch (error) { + this.error(`Error creating resource: ${error instanceof Error ? error.message : String(error)}`); } ``` -Full Control API list command template: -```typescript -async run(): Promise { - const { flags } = await this.parse(MyListCommand); - - const controlApi = this.createControlApi(flags); - const appId = await this.resolveAppId(flags); - - if (!appId) { - this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); - return; - } - - try { - const items = await controlApi.listThings(appId); - const limited = flags.limit ? items.slice(0, flags.limit) : items; - - if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonOutput({ items: limited, total: limited.length, appId }, flags)); - } else { - this.log(`Found ${limited.length} item${limited.length !== 1 ? "s" : ""}:\n`); - for (const item of limited) { - this.log(chalk.bold(`Item ID: ${item.id}`)); - this.log(` ${chalk.dim("Type:")} ${item.type}`); - this.log(` ${chalk.dim("Status:")} ${item.status}`); - this.log(""); - } - } - } catch (error) { - this.error(`Error listing items: ${error instanceof Error ? error.message : String(error)}`); - } -} -``` - -Key conventions for list output: -- `resource()` is for inline resource name references (e.g., in simple identifier lists or "Attaching to channel: " + resource(name)), not for record headings -- `chalk.bold()` is for record heading lines that act as visual separators between multi-field records -- `chalk.dim("Label:")` for field labels in detail lines -- `success()` is not used in list commands — it's for confirming an action completed - -#### Enter/Presence pattern -```typescript -async run(): Promise { - const { args, flags } = await this.parse(MyEnterCommand); - - const client = await this.createAblyRealtimeClient(flags); - if (!client) return; - - this.setupConnectionStateLogging(client, flags); - - const channel = client.channels.get(args.channel); - this.setupChannelStateLogging(channel, flags); - - // Parse optional JSON data (handle shell quote stripping) - let presenceData; - if (flags.data) { - try { - presenceData = JSON.parse(flags.data); - } catch { - this.error("Invalid JSON data provided"); - return; - } - } - - if (!this.shouldOutputJson(flags)) { - this.log(progress("Entering presence on channel: " + resource(args.channel))); - } - - // Optionally subscribe to other members' events before entering - if (flags["show-others"]) { - await channel.presence.subscribe((msg) => { - if (msg.clientId === client.auth.clientId) return; // filter self - // Display presence event - }); - } - - await channel.presence.enter(presenceData); - - if (!this.shouldOutputJson(flags)) { - this.log(success("Entered presence on channel: " + resource(args.channel) + ".")); - this.log(listening("Present on channel.")); - } - - await waitUntilInterruptedOrTimeout(flags); -} - -// Clean up in finally — leave presence -async finally(err: Error | undefined): Promise { - // Leave presence before closing connection - return super.finally(err); -} -``` - -Key flags for enter commands: `--data` (JSON), `--show-others` (boolean), `--duration` / `-D`, `--sequence-numbers`. - -#### History pattern -```typescript -async run(): Promise { - const { args, flags } = await this.parse(MyHistoryCommand); - - const rest = await this.createAblyRestClient(flags); - if (!rest) return; - - const channel = rest.channels.get(args.channel); - - const historyParams = { - direction: flags.direction, - limit: flags.limit, - ...(flags.start && { start: parseTimestamp(flags.start) }), - ...(flags.end && { end: parseTimestamp(flags.end) }), - }; - - const history = await channel.history(historyParams); - const messages = history.items; +### Pattern-specific implementation - if (this.shouldOutputJson(flags)) { - this.log(this.formatJsonOutput({ messages }, flags)); - } else { - this.log(success(`Found ${messages.length} messages.`)); - // Display each message - } -} -``` +Read `references/patterns.md` for the full implementation template matching your pattern (Subscribe, Publish/Send, History, Enter/Presence, List, CRUD/Control API). Each template includes the correct flags, `run()` method structure, and output conventions. ## Step 3: Create the test file -Test files go at `test/unit/commands/.test.ts`. - -### Product API test (Realtime mock) - -```typescript -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { runCommand } from "@oclif/test"; -import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; - -describe("topic:action command", () => { - let mockCallback: ((event: unknown) => void) | null = null; - - beforeEach(() => { - mockCallback = null; - const mock = getMockAblyRealtime(); - const channel = mock.channels._getChannel("test-channel"); - - // Configure subscribe to capture the callback - channel.subscribe.mockImplementation((callback: (msg: unknown) => void) => { - mockCallback = callback; - }); - - // Auto-connect - mock.connection.once.mockImplementation((event: string, cb: () => void) => { - if (event === "connected") cb(); - }); - - // Auto-attach - channel.once.mockImplementation((event: string, cb: () => void) => { - if (event === "attached") { - channel.state = "attached"; - cb(); - } - }); - }); - - describe("help", () => { - it("should display help with --help flag", async () => { - const { stdout } = await runCommand(["topic:action", "--help"], import.meta.url); - expect(stdout).toContain("USAGE"); - }); - }); - - describe("argument validation", () => { - it("should require channel argument", async () => { - const { error } = await runCommand(["topic:action"], import.meta.url); - expect(error).toBeDefined(); - expect(error?.message).toMatch(/channel|required|argument/i); - }); - }); - - describe("functionality", () => { - it("should subscribe and display events", async () => { - const commandPromise = runCommand(["topic:action", "test-channel"], import.meta.url); - - await vi.waitFor(() => { expect(mockCallback).not.toBeNull(); }); - - mockCallback!({ - name: "test-event", - data: "hello", - timestamp: Date.now(), - }); - - const { stdout } = await commandPromise; - expect(stdout).toContain("test-channel"); - }); - }); -}); -``` - -### Product API test (REST mock) - -```typescript -import { describe, it, expect, beforeEach } from "vitest"; -import { runCommand } from "@oclif/test"; -import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; - -describe("topic:action command", () => { - beforeEach(() => { - const mock = getMockAblyRest(); - const channel = mock.channels._getChannel("test-channel"); - channel.history.mockResolvedValue({ - items: [ - { id: "msg-1", name: "event", data: "hello", timestamp: 1700000000000 }, - ], - }); - }); - - it("should retrieve history", async () => { - const { stdout } = await runCommand( - ["topic:action", "test-channel"], - import.meta.url, - ); - expect(stdout).toContain("1"); - expect(stdout).toContain("messages"); - }); -}); -``` - -### Control API test (nock) - -```typescript -import { describe, it, expect, afterEach } from "vitest"; -import { runCommand } from "@oclif/test"; -import nock from "nock"; -import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; - -describe("topic:action command", () => { - afterEach(() => { - nock.cleanAll(); - }); - - it("should create resource", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nock("https://control.ably.net") - .post(`/v1/apps/${appId}/resources`) - .reply(201, { id: "res-123", appId }); - - const { stdout } = await runCommand( - ["topic:action", "--flag", "value"], - import.meta.url, - ); - - expect(stdout).toContain("created"); - }); - - it("should handle API errors", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nock("https://control.ably.net") - .post(`/v1/apps/${appId}/resources`) - .reply(400, { error: "Bad request" }); - - const { error } = await runCommand( - ["topic:action", "--flag", "value"], - import.meta.url, - ); - - expect(error).toBeDefined(); - }); -}); -``` - -### Auth in tests - -- **Unit tests**: Auth is automatic via `MockConfigManager`. Never set `ABLY_API_KEY` env var unless testing env var override behavior. -- **Never pass auth flags**: No `--api-key`, `--token`, or `--access-token` in runCommand calls. -- Use `getMockConfigManager().getCurrentAppId()!` to get the test app ID for nock URLs. - -### Test structure - -Always include these describe blocks: -1. `help` — verify `--help` shows USAGE, key flags, and EXAMPLES -2. `argument validation` — verify required args produce errors when missing -3. `functionality` — core behavior tests -4. `flags` — verify key flags are accepted and configured -5. `error handling` — API errors, invalid input +Read `references/testing.md` for the full test scaffold matching your command type (Realtime mock, REST mock, Control API with nock, E2E subscribe, E2E CRUD). Test files go at `test/unit/commands/.test.ts`. ## Step 4: Create index files if needed @@ -593,15 +311,17 @@ pnpm test:unit # Run tests ## Checklist - [ ] Command file at correct path under `src/commands/` -- [ ] Correct base class (`AblyBaseCommand` vs `ControlBaseCommand`) +- [ ] Correct base class (`AblyBaseCommand`, `ControlBaseCommand`, `ChatBaseCommand`, `SpacesBaseCommand`, or `StatsBaseCommand`) - [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`) - [ ] `clientIdFlag` only if command needs client identity - [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))` -- [ ] Output helpers used correctly (`progress`, `success`, `listening`, `resource`, `formatTimestamp`) +- [ ] Output helpers used correctly (`progress`, `success`, `listening`, `resource`, `formatTimestamp`, `clientId`, `eventType`, `label`, `heading`, `index`) - [ ] `success()` messages end with `.` (period) - [ ] Resource names use `resource(name)`, never quoted +- [ ] Error handling uses `this.handleCommandError()` or `this.error()`, not `this.log(chalk.red(...))` - [ ] Test file at matching path under `test/unit/commands/` - [ ] Tests use correct mock helper (`getMockAblyRealtime`, `getMockAblyRest`, `nock`) - [ ] Tests don't pass auth flags — `MockConfigManager` handles auth +- [ ] Subscribe tests use `--duration` flag to auto-exit - [ ] Index file created if new topic/subtopic - [ ] `pnpm prepare && pnpm exec eslint . && pnpm test:unit` passes diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md new file mode 100644 index 00000000..aa472b25 --- /dev/null +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -0,0 +1,318 @@ +# Command Implementation Patterns + +Pick the pattern that matches your command from Step 1 of the skill, then follow the template below. + +## Table of Contents +- [Subscribe Pattern](#subscribe-pattern) +- [Publish/Send Pattern](#publishsend-pattern) +- [History Pattern](#history-pattern) +- [Enter/Presence Pattern](#enterpresence-pattern) +- [List Pattern](#list-pattern) +- [CRUD / Control API Pattern](#crud--control-api-pattern) + +--- + +## Subscribe Pattern + +Flags for subscribe commands: +```typescript +static override flags = { + ...productApiFlags, + ...clientIdFlag, + ...durationFlag, + ...rewindFlag, + // command-specific flags here +}; +``` + +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MySubscribeCommand); + + const client = await this.createAblyRealtimeClient(flags); + if (!client) return; + + this.setupConnectionStateLogging(client, flags); + + const channelOptions: Ably.ChannelOptions = {}; + this.configureRewind(channelOptions, flags.rewind, flags, "MySubscribe", args.channel); + + const channel = client.channels.get(args.channel, channelOptions); + this.setupChannelStateLogging(channel, flags); + + if (!this.shouldOutputJson(flags)) { + this.log(progress("Attaching to channel: " + resource(args.channel))); + } + + channel.once("attached", () => { + if (!this.shouldOutputJson(flags)) { + this.log(success("Attached to channel: " + resource(args.channel) + ".")); + this.log(listening("Listening for events.")); + } + }); + + let sequenceCounter = 0; + await channel.subscribe((message) => { + sequenceCounter++; + // Format and output the message + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ /* message data */ }, flags)); + } else { + // Human-readable output with formatTimestamp, resource, chalk colors + } + }); + + await waitUntilInterruptedOrTimeout(flags); +} +``` + +Import `waitUntilInterruptedOrTimeout` from `../../utils/long-running.js`. + +--- + +## Publish/Send Pattern + +Flags for publish commands: +```typescript +static override flags = { + ...productApiFlags, + ...clientIdFlag, + // command-specific flags (e.g., --name, --encoding, --count, --delay) +}; +``` + +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyPublishCommand); + + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channel = rest.channels.get(args.channel); + + if (!this.shouldOutputJson(flags)) { + this.log(progress("Publishing to channel: " + resource(args.channel))); + } + + try { + const message: Partial = { + name: flags.name || args.eventName, + data: args.data, + }; + + await channel.publish(message as Ably.Message); + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ success: true, channel: args.channel }, flags)); + } else { + this.log(success("Message published to channel: " + resource(args.channel) + ".")); + } + } catch (error) { + this.handleCommandError(error, flags, "Publish", { channel: args.channel }); + } +} +``` + +For multi-message publish or realtime transport, see `src/commands/channels/publish.ts` as a reference. + +--- + +## History Pattern + +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyHistoryCommand); + + const rest = await this.createAblyRestClient(flags); + if (!rest) return; + + const channel = rest.channels.get(args.channel); + + const historyParams = { + direction: flags.direction, + limit: flags.limit, + ...(flags.start && { start: parseTimestamp(flags.start) }), + ...(flags.end && { end: parseTimestamp(flags.end) }), + }; + + const history = await channel.history(historyParams); + const messages = history.items; + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ messages }, flags)); + } else { + this.log(success(`Found ${messages.length} messages.`)); + // Display each message + } +} +``` + +--- + +## Enter/Presence Pattern + +Flags for enter commands: +```typescript +static override flags = { + ...productApiFlags, + ...clientIdFlag, + ...durationFlag, + data: Flags.string({ description: "Optional JSON data to associate with the presence" }), + "show-others": Flags.boolean({ default: false, description: "Show other presence events while present (default: false)" }), +}; +``` + +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyEnterCommand); + + const client = await this.createAblyRealtimeClient(flags); + if (!client) return; + + this.setupConnectionStateLogging(client, flags); + + const channel = client.channels.get(args.channel); + this.setupChannelStateLogging(channel, flags); + + // Parse optional JSON data (handle shell quote stripping) + let presenceData; + if (flags.data) { + try { + presenceData = JSON.parse(flags.data); + } catch { + this.error("Invalid JSON data provided"); + return; + } + } + + if (!this.shouldOutputJson(flags)) { + this.log(progress("Entering presence on channel: " + resource(args.channel))); + } + + // Optionally subscribe to other members' events before entering + if (flags["show-others"]) { + await channel.presence.subscribe((msg) => { + if (msg.clientId === client.auth.clientId) return; // filter self + // Display presence event + }); + } + + await channel.presence.enter(presenceData); + + if (!this.shouldOutputJson(flags)) { + this.log(success("Entered presence on channel: " + resource(args.channel) + ".")); + this.log(listening("Present on channel.")); + } + + await waitUntilInterruptedOrTimeout(flags); +} + +// Clean up in finally — leave presence before closing connection +async finally(err: Error | undefined): Promise { + if (this.channel) { + await this.channel.presence.leave(); + } + return super.finally(err); +} +``` + +--- + +## List Pattern + +List commands query a collection and display results. They don't use `success()` because there's no action to confirm — they just display data. + +**Simple identifier lists** (e.g., `channels list`, `rooms list`) — use `resource()` for each item: +```typescript +if (!this.shouldOutputJson(flags)) { + this.log(`Found ${chalk.cyan(items.length.toString())} active channels:`); + for (const item of items) { + this.log(`${resource(item.id)}`); + } +} +``` + +**Structured record lists** (e.g., `queues list`, `integrations list`, `push devices list`) — use `heading()` and `label()` helpers: +```typescript +if (!this.shouldOutputJson(flags)) { + this.log(`Found ${items.length} devices:\n`); + for (const item of items) { + this.log(heading(`Device ID: ${item.id}`)); + this.log(` ${label("Platform")} ${item.platform}`); + this.log(` ${label("Push State")} ${item.pushState}`); + this.log(` ${label("Client ID")} ${item.clientId || "N/A"}`); + this.log(""); + } +} +``` + +Full Control API list command template: +```typescript +async run(): Promise { + const { flags } = await this.parse(MyListCommand); + + const controlApi = this.createControlApi(flags); + const appId = await this.resolveAppId(flags); + + if (!appId) { + this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); + return; + } + + try { + const items = await controlApi.listThings(appId); + const limited = flags.limit ? items.slice(0, flags.limit) : items; + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ items: limited, total: limited.length, appId }, flags)); + } else { + this.log(`Found ${limited.length} item${limited.length !== 1 ? "s" : ""}:\n`); + for (const item of limited) { + this.log(heading(`Item ID: ${item.id}`)); + this.log(` ${label("Type")} ${item.type}`); + this.log(` ${label("Status")} ${item.status}`); + this.log(""); + } + } + } catch (error) { + this.error(`Error listing items: ${error instanceof Error ? error.message : String(error)}`); + } +} +``` + +Key conventions for list output: +- `resource()` is for inline resource name references, not for record headings +- `heading()` is for record heading lines that act as visual separators between multi-field records +- `label(text)` for field labels in detail lines (automatically appends `:`) +- `success()` is not used in list commands — it's for confirming an action completed + +--- + +## CRUD / Control API Pattern + +```typescript +async run(): Promise { + const { args, flags } = await this.parse(MyControlCommand); + + const controlApi = this.createControlApi(flags); + const appId = await this.resolveAppId(flags); + + if (!appId) { + this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); + return; + } + + try { + const result = await controlApi.someMethod(appId, data); + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ result }, flags)); + } else { + this.log(success("Resource created: " + resource(result.id) + ".")); + // Display additional fields + } + } catch (error) { + this.error(`Error creating resource: ${error instanceof Error ? error.message : String(error)}`); + } +} +``` diff --git a/.claude/skills/ably-new-command/references/testing.md b/.claude/skills/ably-new-command/references/testing.md new file mode 100644 index 00000000..b91e6090 --- /dev/null +++ b/.claude/skills/ably-new-command/references/testing.md @@ -0,0 +1,275 @@ +# Test Scaffolds + +Test files go at `test/unit/commands/.test.ts`. + +## Table of Contents +- [Product API Test (Realtime Mock)](#product-api-test-realtime-mock) +- [Product API Test (REST Mock)](#product-api-test-rest-mock) +- [Control API Test (nock)](#control-api-test-nock) +- [E2E Tests](#e2e-tests) +- [Test Structure](#test-structure) + +--- + +## Product API Test (Realtime Mock) + +```typescript +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRealtime } from "../../../helpers/mock-ably-realtime.js"; + +describe("topic:action command", () => { + let mockCallback: ((event: unknown) => void) | null = null; + + beforeEach(() => { + mockCallback = null; + const mock = getMockAblyRealtime(); + const channel = mock.channels._getChannel("test-channel"); + + // Configure subscribe to capture the callback + channel.subscribe.mockImplementation((callback: (msg: unknown) => void) => { + mockCallback = callback; + }); + + // Auto-connect + mock.connection.once.mockImplementation((event: string, cb: () => void) => { + if (event === "connected") cb(); + }); + + // Auto-attach + channel.once.mockImplementation((event: string, cb: () => void) => { + if (event === "attached") { + channel.state = "attached"; + cb(); + } + }); + }); + + describe("help", () => { + it("should display help with --help flag", async () => { + const { stdout } = await runCommand(["topic:action", "--help"], import.meta.url); + expect(stdout).toContain("USAGE"); + }); + }); + + describe("argument validation", () => { + it("should require channel argument", async () => { + const { error } = await runCommand(["topic:action"], import.meta.url); + expect(error).toBeDefined(); + expect(error?.message).toMatch(/channel|required|argument/i); + }); + }); + + describe("functionality", () => { + it("should subscribe and display events", async () => { + const commandPromise = runCommand(["topic:action", "test-channel", "--duration", "1"], import.meta.url); + + await vi.waitFor(() => { expect(mockCallback).not.toBeNull(); }); + + mockCallback!({ + name: "test-event", + data: "hello", + timestamp: Date.now(), + }); + + const { stdout } = await commandPromise; + expect(stdout).toContain("test-channel"); + }); + }); +}); +``` + +--- + +## Product API Test (REST Mock) + +```typescript +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; + +describe("topic:action command", () => { + beforeEach(() => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.history.mockResolvedValue({ + items: [ + { id: "msg-1", name: "event", data: "hello", timestamp: 1700000000000 }, + ], + }); + }); + + it("should retrieve history", async () => { + const { stdout } = await runCommand( + ["topic:action", "test-channel"], + import.meta.url, + ); + expect(stdout).toContain("1"); + expect(stdout).toContain("messages"); + }); +}); +``` + +--- + +## Control API Test (nock) + +```typescript +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import nock from "nock"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("topic:action command", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("should create resource", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .post(`/v1/apps/${appId}/resources`) + .reply(201, { id: "res-123", appId }); + + const { stdout } = await runCommand( + ["topic:action", "--flag", "value"], + import.meta.url, + ); + + expect(stdout).toContain("created"); + }); + + it("should handle API errors", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nock("https://control.ably.net") + .post(`/v1/apps/${appId}/resources`) + .reply(400, { error: "Bad request" }); + + const { error } = await runCommand( + ["topic:action", "--flag", "value"], + import.meta.url, + ); + + expect(error).toBeDefined(); + }); +}); +``` + +--- + +## E2E Tests + +E2E tests run against the real Ably service as subprocesses. They go in `test/e2e//`. + +Key differences from unit tests: +- Auth via env vars (`ABLY_API_KEY`, `ABLY_ACCESS_TOKEN`), not MockConfigManager +- Commands run as spawned child processes, not in-process +- Use helpers from `test/helpers/e2e-test-helper.ts` + +### Subscribe E2E scaffold + +```typescript +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; +import { + SHOULD_SKIP_E2E, + getUniqueChannelName, + createTempOutputFile, + runLongRunningBackgroundProcess, + readProcessOutput, + publishTestMessage, + killProcess, + cleanupTrackedResources, + setupTestFailureHandler, + resetTestTracking, +} from "../../helpers/e2e-test-helper.js"; + +describe("topic:action E2E", { timeout: 60_000 }, () => { + let subscribeChannel: string; + let outputPath: string; + let subscribeProcessInfo: { pid: number; outputPath: string } | null = null; + + beforeAll(() => { + if (SHOULD_SKIP_E2E) return; + const handler = () => { cleanupTrackedResources(); process.exit(1); }; + process.on("SIGINT", handler); + return () => { process.removeListener("SIGINT", handler); }; + }); + + afterAll(async () => { + await cleanupTrackedResources(); + }); + + beforeEach(() => { + resetTestTracking(); + subscribeChannel = getUniqueChannelName("topic-action-e2e"); + outputPath = createTempOutputFile("topic-action"); + }); + + afterEach(async () => { + if (subscribeProcessInfo?.pid) { + await killProcess(subscribeProcessInfo.pid); + } + await cleanupTrackedResources(); + }); + + it.skipIf(SHOULD_SKIP_E2E)("should subscribe and receive messages", async () => { + setupTestFailureHandler("topic:action subscribe"); + + subscribeProcessInfo = await runLongRunningBackgroundProcess( + ["topic:action", subscribeChannel], + outputPath, + "Listening", // ready signal to wait for + { env: { ABLY_API_KEY: process.env.ABLY_API_KEY! } }, + ); + + await publishTestMessage(subscribeChannel, "test-event", { hello: "world" }); + + const output = readProcessOutput(outputPath); + expect(output).toContain("test-event"); + }); +}); +``` + +### CRUD E2E scaffold + +```typescript +import { describe, it, expect, afterAll } from "vitest"; +import { execSync } from "node:child_process"; +import { + SHOULD_SKIP_E2E, + cleanupTrackedResources, +} from "../../helpers/e2e-test-helper.js"; + +describe("topic:action CRUD E2E", { timeout: 30_000 }, () => { + const cliPath = "./bin/run.js"; + + afterAll(async () => { + await cleanupTrackedResources(); + }); + + it.skipIf(SHOULD_SKIP_E2E)("should create and list resources", () => { + const createOutput = execSync( + `node ${cliPath} topic:action create --name test-resource`, + { env: { ...process.env, ABLY_ACCESS_TOKEN: process.env.ABLY_ACCESS_TOKEN! } }, + ).toString(); + expect(createOutput).toContain("created"); + + const listOutput = execSync( + `node ${cliPath} topic:action list`, + { env: { ...process.env, ABLY_ACCESS_TOKEN: process.env.ABLY_ACCESS_TOKEN! } }, + ).toString(); + expect(listOutput).toContain("test-resource"); + }); +}); +``` + +--- + +## Test Structure + +Always include these describe blocks: +1. `help` — verify `--help` shows USAGE, key flags, and EXAMPLES +2. `argument validation` — verify required args produce errors when missing +3. `functionality` — core behavior tests +4. `flags` — verify key flags are accepted and configured +5. `error handling` — API errors, invalid input diff --git a/AGENTS.md b/AGENTS.md index 4f3f8957..23c49d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,9 +116,9 @@ static override flags = { }; // Control API command (apps, keys, queues, etc.) -import { controlApiFlags } from "../../flags.js"; -static override flags = { - ...controlApiFlags, +// controlApiFlags come from ControlBaseCommand.globalFlags automatically +static flags = { + ...ControlBaseCommand.globalFlags, // command-specific flags... }; ``` diff --git a/src/utils/output.ts b/src/utils/output.ts index 6cd22839..18315d16 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -63,6 +63,31 @@ export function limitWarning( return null; } +/** Client identity display — cyan-blue for client IDs in event output */ +export function clientId(id: string): string { + return chalk.blue(id); +} + +/** Event type/action display — yellow for event type labels */ +export function eventType(type: string): string { + return chalk.yellow(type); +} + +/** Field label display — dim text with colon for structured output fields */ +export function label(text: string): string { + return chalk.dim(`${text}:`); +} + +/** Record heading — bold text for list item headings */ +export function heading(text: string): string { + return chalk.bold(text); +} + +/** Index number display — dim bracketed number for history/list ordering */ +export function index(n: number): string { + return chalk.dim(`[${n}]`); +} + export function formatPresenceAction(action: string): { symbol: string; color: ChalkInstance; From 92c949559bf18b9e580d33622b6b7d72e1c9ca16 Mon Sep 17 00:00:00 2001 From: umair Date: Mon, 9 Mar 2026 13:17:59 +0000 Subject: [PATCH 3/7] update helpers to prefix "format" to reduce import clashes. Also aligns with previous formatTimestamp pattern. Updated skill and agents.md to match --- .claude/skills/ably-new-command/SKILL.md | 56 +++++++++---------- .../ably-new-command/references/patterns.md | 52 ++++++++--------- AGENTS.md | 25 ++++++--- src/utils/output.ts | 22 ++++---- 4 files changed, 81 insertions(+), 74 deletions(-) diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index 928fbc7d..cd216134 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -49,7 +49,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags, clientIdFlag } from "../../flags.js"; -import { resource, success, progress, listening, formatTimestamp, clientId, eventType, label, heading, index } from "../../utils/output.js"; +import { formatResource, formatSuccess, formatProgress, formatListening, formatTimestamp, formatClientId, formatEventType, formatLabel, formatHeading, formatIndex } from "../../utils/output.js"; ``` **Control API commands** (apps, integrations, queues, keys, rules, push config): @@ -58,7 +58,7 @@ import { Args, Flags } from "@oclif/core"; import chalk from "chalk"; import { ControlBaseCommand } from "../../control-base-command.js"; -import { resource, success, label, heading } from "../../utils/output.js"; +import { formatResource, formatSuccess, formatLabel, formatHeading } from "../../utils/output.js"; ``` **Chat commands** (rooms messages, reactions, typing, occupancy): @@ -68,7 +68,7 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../chat-base-command.js"; import { productApiFlags, clientIdFlag } from "../../flags.js"; -import { resource, success, progress, listening } from "../../utils/output.js"; +import { formatResource, formatSuccess, formatProgress, formatListening } from "../../utils/output.js"; ``` **Spaces commands** (spaces members, locks, cursors, locations): @@ -78,7 +78,7 @@ import chalk from "chalk"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; import { productApiFlags, clientIdFlag } from "../../flags.js"; -import { resource, success, progress, listening, clientId } from "../../utils/output.js"; +import { formatResource, formatSuccess, formatProgress, formatListening, formatClientId } from "../../utils/output.js"; ``` **Stats commands** (stats app, stats account): @@ -163,13 +163,13 @@ The CLI has specific output helpers in `src/utils/output.ts`. All human-readable ```typescript // JSON guard — all human output goes through this if (!this.shouldOutputJson(flags)) { - this.log(progress("Attaching to channel: " + resource(channelName))); + this.log(formatProgress("Attaching to channel: " + formatResource(channelName))); } // After success: if (!this.shouldOutputJson(flags)) { - this.log(success("Subscribed to channel: " + resource(channelName) + ".")); - this.log(listening("Listening for messages.")); + this.log(formatSuccess("Subscribed to channel: " + formatResource(channelName) + ".")); + this.log(formatListening("Listening for messages.")); } // JSON output: @@ -182,32 +182,32 @@ if (this.shouldOutputJson(flags)) { | Helper | Usage | Example | |--------|-------|---------| -| `progress(msg)` | Action in progress — appends `...` automatically | `progress("Attaching to channel")` | -| `success(msg)` | Green checkmark — always end with `.` (period, not `!`) | `success("Subscribed to channel " + resource(name) + ".")` | -| `listening(msg)` | Dim text — auto-appends "Press Ctrl+C to exit." | `listening("Listening for messages.")` | -| `resource(name)` | Cyan — for resource names, never use quotes | `resource(channelName)` | +| `formatProgress(msg)` | Action in progress — appends `...` automatically | `formatProgress("Attaching to channel")` | +| `formatSuccess(msg)` | Green checkmark — always end with `.` (period, not `!`) | `formatSuccess("Subscribed to channel " + formatResource(name) + ".")` | +| `formatListening(msg)` | Dim text — auto-appends "Press Ctrl+C to exit." | `formatListening("Listening for messages.")` | +| `formatResource(name)` | Cyan — for resource names, never use quotes | `formatResource(channelName)` | | `formatTimestamp(ts)` | Dim `[timestamp]` — for event streams | `formatTimestamp(isoString)` | | `formatMessageTimestamp(ts)` | Converts Ably message timestamp to ISO string | `formatMessageTimestamp(message.timestamp)` | -| `countLabel(n, singular, plural?)` | Cyan count + pluralized label | `countLabel(3, "message")` → "3 messages" | -| `limitWarning(count, limit, name)` | Yellow warning if results truncated, null otherwise | `limitWarning(items.length, flags.limit, "items")` | +| `formatCountLabel(n, singular, plural?)` | Cyan count + pluralized label | `formatCountLabel(3, "message")` → "3 messages" | +| `formatLimitWarning(count, limit, name)` | Yellow warning if results truncated, null otherwise | `formatLimitWarning(items.length, flags.limit, "items")` | | `formatPresenceAction(action)` | Returns `{ symbol, color }` for enter/leave/update | `formatPresenceAction("enter")` → `{ symbol: "✓", color: chalk.green }` | -| `clientId(id)` | Blue — for client identity display | `clientId(msg.clientId)` | -| `eventType(type)` | Yellow — for event/action type labels | `eventType("enter")` | -| `label(text)` | Dim with colon — for field labels in structured output | `label("Platform")` → dim "Platform:" | -| `heading(text)` | Bold — for record headings in list output | `heading("Device ID: " + id)` | -| `index(n)` | Dim bracketed number — for history ordering | `index(1)` → dim "[1]" | +| `formatClientId(id)` | Blue — for client identity display | `formatClientId(msg.clientId)` | +| `formatEventType(type)` | Yellow — for event/action type labels | `formatEventType("enter")` | +| `formatLabel(text)` | Dim with colon — for field labels in structured output | `formatLabel("Platform")` → dim "Platform:" | +| `formatHeading(text)` | Bold — for record headings in list output | `formatHeading("Device ID: " + id)` | +| `formatIndex(n)` | Dim bracketed number — for history ordering | `formatIndex(1)` → dim "[1]" | Rules: -- `progress("Action text")` — appends `...` automatically, never add it manually -- `success("Completed action.")` — green checkmark, always end with `.` (period, not `!`) -- `listening("Listening for X.")` — dim text, automatically appends "Press Ctrl+C to exit." -- `resource(name)` — cyan colored, never use quotes around resource names +- `formatProgress("Action text")` — appends `...` automatically, never add it manually +- `formatSuccess("Completed action.")` — green checkmark, always end with `.` (period, not `!`) +- `formatListening("Listening for X.")` — dim text, automatically appends "Press Ctrl+C to exit." +- `formatResource(name)` — cyan colored, never use quotes around resource names - `formatTimestamp(ts)` — dim `[timestamp]` for event streams -- `clientId(id)` — blue, for client identity in events (replaces `chalk.blue(id)`) -- `eventType(type)` — yellow, for event/action labels (replaces `chalk.yellow(type)`) -- `label(text)` — dim with colon, for field labels (replaces `chalk.dim("Label:")`) -- `heading(text)` — bold, for record headings in lists (replaces `chalk.bold("Heading")`) -- `index(n)` — dim bracketed number, for history ordering (replaces `chalk.dim(\`[${n}]\`)`) +- `formatClientId(id)` — blue, for client identity in events +- `formatEventType(type)` — yellow, for event/action labels +- `formatLabel(text)` — dim with colon, for field labels +- `formatHeading(text)` — bold, for record headings in lists +- `formatIndex(n)` — dim bracketed number, for history ordering - Use `this.error()` for fatal errors, never `this.log(chalk.red(...))` - Never use `console.log` or `console.error` — always `this.log()` or `this.logToStderr()` @@ -315,7 +315,7 @@ pnpm test:unit # Run tests - [ ] Correct flag set (`productApiFlags` vs `ControlBaseCommand.globalFlags`) - [ ] `clientIdFlag` only if command needs client identity - [ ] All human output wrapped in `if (!this.shouldOutputJson(flags))` -- [ ] Output helpers used correctly (`progress`, `success`, `listening`, `resource`, `formatTimestamp`, `clientId`, `eventType`, `label`, `heading`, `index`) +- [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`) - [ ] `success()` messages end with `.` (period) - [ ] Resource names use `resource(name)`, never quoted - [ ] Error handling uses `this.handleCommandError()` or `this.error()`, not `this.log(chalk.red(...))` diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index aa472b25..43ac130f 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -41,13 +41,13 @@ async run(): Promise { this.setupChannelStateLogging(channel, flags); if (!this.shouldOutputJson(flags)) { - this.log(progress("Attaching to channel: " + resource(args.channel))); + this.log(formatProgress("Attaching to channel: " + formatResource(args.channel))); } channel.once("attached", () => { if (!this.shouldOutputJson(flags)) { - this.log(success("Attached to channel: " + resource(args.channel) + ".")); - this.log(listening("Listening for events.")); + this.log(formatSuccess("Attached to channel: " + formatResource(args.channel) + ".")); + this.log(formatListening("Listening for events.")); } }); @@ -58,7 +58,7 @@ async run(): Promise { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ /* message data */ }, flags)); } else { - // Human-readable output with formatTimestamp, resource, chalk colors + // Human-readable output with formatTimestamp, formatResource, chalk colors } }); @@ -91,7 +91,7 @@ async run(): Promise { const channel = rest.channels.get(args.channel); if (!this.shouldOutputJson(flags)) { - this.log(progress("Publishing to channel: " + resource(args.channel))); + this.log(formatProgress("Publishing to channel: " + formatResource(args.channel))); } try { @@ -105,7 +105,7 @@ async run(): Promise { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ success: true, channel: args.channel }, flags)); } else { - this.log(success("Message published to channel: " + resource(args.channel) + ".")); + this.log(formatSuccess("Message published to channel: " + formatResource(args.channel) + ".")); } } catch (error) { this.handleCommandError(error, flags, "Publish", { channel: args.channel }); @@ -141,7 +141,7 @@ async run(): Promise { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ messages }, flags)); } else { - this.log(success(`Found ${messages.length} messages.`)); + this.log(formatSuccess(`Found ${messages.length} messages.`)); // Display each message } } @@ -186,7 +186,7 @@ async run(): Promise { } if (!this.shouldOutputJson(flags)) { - this.log(progress("Entering presence on channel: " + resource(args.channel))); + this.log(formatProgress("Entering presence on channel: " + formatResource(args.channel))); } // Optionally subscribe to other members' events before entering @@ -200,8 +200,8 @@ async run(): Promise { await channel.presence.enter(presenceData); if (!this.shouldOutputJson(flags)) { - this.log(success("Entered presence on channel: " + resource(args.channel) + ".")); - this.log(listening("Present on channel.")); + this.log(formatSuccess("Entered presence on channel: " + formatResource(args.channel) + ".")); + this.log(formatListening("Present on channel.")); } await waitUntilInterruptedOrTimeout(flags); @@ -220,27 +220,27 @@ async finally(err: Error | undefined): Promise { ## List Pattern -List commands query a collection and display results. They don't use `success()` because there's no action to confirm — they just display data. +List commands query a collection and display results. They don't use `formatSuccess()` because there's no action to confirm — they just display data. -**Simple identifier lists** (e.g., `channels list`, `rooms list`) — use `resource()` for each item: +**Simple identifier lists** (e.g., `channels list`, `rooms list`) — use `formatResource()` for each item: ```typescript if (!this.shouldOutputJson(flags)) { this.log(`Found ${chalk.cyan(items.length.toString())} active channels:`); for (const item of items) { - this.log(`${resource(item.id)}`); + this.log(`${formatResource(item.id)}`); } } ``` -**Structured record lists** (e.g., `queues list`, `integrations list`, `push devices list`) — use `heading()` and `label()` helpers: +**Structured record lists** (e.g., `queues list`, `integrations list`, `push devices list`) — use `formatHeading()` and `formatLabel()` helpers: ```typescript if (!this.shouldOutputJson(flags)) { this.log(`Found ${items.length} devices:\n`); for (const item of items) { - this.log(heading(`Device ID: ${item.id}`)); - this.log(` ${label("Platform")} ${item.platform}`); - this.log(` ${label("Push State")} ${item.pushState}`); - this.log(` ${label("Client ID")} ${item.clientId || "N/A"}`); + this.log(formatHeading(`Device ID: ${item.id}`)); + this.log(` ${formatLabel("Platform")} ${item.platform}`); + this.log(` ${formatLabel("Push State")} ${item.pushState}`); + this.log(` ${formatLabel("Client ID")} ${item.clientId || "N/A"}`); this.log(""); } } @@ -268,9 +268,9 @@ async run(): Promise { } else { this.log(`Found ${limited.length} item${limited.length !== 1 ? "s" : ""}:\n`); for (const item of limited) { - this.log(heading(`Item ID: ${item.id}`)); - this.log(` ${label("Type")} ${item.type}`); - this.log(` ${label("Status")} ${item.status}`); + this.log(formatHeading(`Item ID: ${item.id}`)); + this.log(` ${formatLabel("Type")} ${item.type}`); + this.log(` ${formatLabel("Status")} ${item.status}`); this.log(""); } } @@ -281,10 +281,10 @@ async run(): Promise { ``` Key conventions for list output: -- `resource()` is for inline resource name references, not for record headings -- `heading()` is for record heading lines that act as visual separators between multi-field records -- `label(text)` for field labels in detail lines (automatically appends `:`) -- `success()` is not used in list commands — it's for confirming an action completed +- `formatResource()` is for inline resource name references, not for record headings +- `formatHeading()` is for record heading lines that act as visual separators between multi-field records +- `formatLabel(text)` for field labels in detail lines (automatically appends `:`) +- `formatSuccess()` is not used in list commands — it's for confirming an action completed --- @@ -308,7 +308,7 @@ async run(): Promise { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ result }, flags)); } else { - this.log(success("Resource created: " + resource(result.id) + ".")); + this.log(formatSuccess("Resource created: " + formatResource(result.id) + ".")); // Display additional fields } } catch (error) { diff --git a/AGENTS.md b/AGENTS.md index 23c49d4e..1e59bf16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,19 +181,26 @@ pnpm test test/unit/commands/foo.test.ts # Specific test ## CLI Output & Flag Conventions ### Output patterns (use helpers from src/utils/output.ts) -- **Progress**: `progress("Attaching to channel: " + resource(name))` — no color on action text, `progress()` appends `...` automatically. Never manually write `"Doing something..."` — always use `progress("Doing something")`. -- **Success**: `success("Message published to channel " + resource(name) + ".")` — green checkmark, **must** end with `.` (not `!`). Never use `chalk.green(...)` directly — always use the `success()` helper. -- **Listening**: `listening("Listening for messages.")` — dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `success()` call — use a separate `listening()` call. -- **Resource names**: Always `resource(name)` (cyan), never quoted — including in `logCliEvent` messages. -- **Timestamps**: `formatTimestamp(ts)` — dim `[timestamp]` for event streams. `formatMessageTimestamp(message.timestamp)` — converts Ably message timestamp (number|undefined) to ISO string. Both exported from `src/utils/output.ts`. + +All output helpers use the `format` prefix and are exported from `src/utils/output.ts`: + +- **Progress**: `formatProgress("Attaching to channel: " + formatResource(name))` — no color on action text, appends `...` automatically. Never manually write `"Doing something..."` — always use `formatProgress("Doing something")`. +- **Success**: `formatSuccess("Message published to channel " + formatResource(name) + ".")` — green checkmark, **must** end with `.` (not `!`). Never use `chalk.green(...)` directly — always use `formatSuccess()`. +- **Listening**: `formatListening("Listening for messages.")` — dim, includes "Press Ctrl+C to exit." Don't combine listening text inside a `formatSuccess()` call — use a separate `formatListening()` call. +- **Resource names**: Always `formatResource(name)` (cyan), never quoted — including in `logCliEvent` messages. +- **Timestamps**: `formatTimestamp(ts)` — dim `[timestamp]` for event streams. `formatMessageTimestamp(message.timestamp)` — converts Ably message timestamp (number|undefined) to ISO string. +- **Labels**: `formatLabel("Field Name")` — dim with colon appended, for field names in structured output. +- **Client IDs**: `formatClientId(id)` — blue, for user/client identifiers in events. +- **Event types**: `formatEventType(type)` — yellow, for action/event type labels. +- **Headings**: `formatHeading("Record ID: " + id)` — bold, for record headings in list output. +- **Index**: `formatIndex(n)` — dim bracketed number `[n]`, for history/list ordering. +- **Count labels**: `formatCountLabel(n, "message")` — cyan count + pluralized label. +- **Limit warnings**: `formatLimitWarning(count, limit, "items")` — yellow warning if results truncated. - **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, use `this.handleCommandError(error, flags, component, context?)` for consistent error handling. It logs the event, emits JSON error when `--json` is active, and calls `this.error()` for human-readable output. For non-standard error flows, use `this.jsonError()` directly. -- **History output**: Use `[index] timestamp` ordering: `` `${chalk.dim(`[${index + 1}]`)} ${formatTimestamp(timestamp)}` ``. Consistent across all history commands (channels, logs, connection-lifecycle, push). +- **History output**: Use `[index] timestamp` ordering: `` `${formatIndex(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}`) -- **Client IDs**: `chalk.blue(clientId)` — for user/client identifiers in events -- **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(...))` - **No app error**: `'No app specified. Use --app flag or select an app with "ably apps switch"'` diff --git a/src/utils/output.ts b/src/utils/output.ts index 18315d16..cafb45e0 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -1,18 +1,18 @@ import chalk, { type ChalkInstance } from "chalk"; -export function progress(message: string): string { +export function formatProgress(message: string): string { return `${message}...`; } -export function success(message: string): string { +export function formatSuccess(message: string): string { return `${chalk.green("✓")} ${message}`; } -export function listening(description: string): string { +export function formatListening(description: string): string { return chalk.dim(`${description} Press Ctrl+C to exit.`); } -export function resource(name: string): string { +export function formatResource(name: string): string { return chalk.cyan(name); } @@ -37,7 +37,7 @@ export function formatMessageTimestamp( * Format a count with a singular/plural label. * E.g. countLabel(3, "message") → "3 messages" (with cyan count) */ -export function countLabel( +export function formatCountLabel( count: number, singular: string, plural?: string, @@ -50,7 +50,7 @@ export function countLabel( * Show a limit warning when results may be truncated. * Returns null if count < limit. */ -export function limitWarning( +export function formatLimitWarning( count: number, limit: number, resourceName: string, @@ -64,27 +64,27 @@ export function limitWarning( } /** Client identity display — cyan-blue for client IDs in event output */ -export function clientId(id: string): string { +export function formatClientId(id: string): string { return chalk.blue(id); } /** Event type/action display — yellow for event type labels */ -export function eventType(type: string): string { +export function formatEventType(type: string): string { return chalk.yellow(type); } /** Field label display — dim text with colon for structured output fields */ -export function label(text: string): string { +export function formatLabel(text: string): string { return chalk.dim(`${text}:`); } /** Record heading — bold text for list item headings */ -export function heading(text: string): string { +export function formatHeading(text: string): string { return chalk.bold(text); } /** Index number display — dim bracketed number for history/list ordering */ -export function index(n: number): string { +export function formatIndex(n: number): string { return chalk.dim(`[${n}]`); } From 831b59cb9392fa2382c66468257e9160d1690b84 Mon Sep 17 00:00:00 2001 From: umair Date: Mon, 9 Mar 2026 13:20:42 +0000 Subject: [PATCH 4/7] makes changes to align with new helpers (prepends "format" to helpers) --- src/chat-base-command.ts | 6 +-- src/commands/accounts/login.ts | 34 ++++++++++------- src/commands/apps/channel-rules/create.ts | 4 +- src/commands/apps/channel-rules/list.ts | 5 +-- src/commands/apps/create.ts | 14 +++++-- src/commands/apps/delete.ts | 6 ++- src/commands/apps/set-apns-p12.ts | 12 ++++-- src/commands/apps/update.ts | 4 +- src/commands/auth/keys/create.ts | 12 ++++-- src/commands/bench/publisher.ts | 4 +- src/commands/bench/subscriber.ts | 16 ++++++-- src/commands/channels/batch-publish.ts | 16 +++++--- src/commands/channels/history.ts | 26 ++++++++----- src/commands/channels/list.ts | 30 ++++++++++----- src/commands/channels/occupancy/get.ts | 6 ++- src/commands/channels/occupancy/subscribe.ts | 26 ++++++------- src/commands/channels/presence/enter.ts | 28 ++++++++------ src/commands/channels/presence/subscribe.ts | 27 ++++++------- src/commands/channels/publish.ts | 22 +++++++---- src/commands/channels/subscribe.ts | 35 ++++++++++------- src/commands/connections/test.ts | 9 ++++- src/commands/integrations/create.ts | 6 +-- src/commands/integrations/delete.ts | 6 ++- src/commands/integrations/list.ts | 5 +-- src/commands/integrations/update.ts | 4 +- .../logs/channel-lifecycle/subscribe.ts | 12 +++--- .../logs/connection-lifecycle/history.ts | 15 +++++--- .../logs/connection-lifecycle/subscribe.ts | 10 +++-- src/commands/logs/history.ts | 17 ++++++--- src/commands/logs/push/history.ts | 17 ++++++--- src/commands/logs/push/subscribe.ts | 12 +++--- src/commands/logs/subscribe.ts | 12 +++--- src/commands/queues/create.ts | 6 ++- src/commands/queues/delete.ts | 6 ++- src/commands/queues/list.ts | 5 +-- src/commands/rooms/list.ts | 16 ++++++-- src/commands/rooms/messages/history.ts | 12 +++--- .../rooms/messages/reactions/remove.ts | 6 +-- src/commands/rooms/messages/reactions/send.ts | 6 +-- .../rooms/messages/reactions/subscribe.ts | 18 +++++---- src/commands/rooms/messages/send.ts | 20 +++++++--- src/commands/rooms/messages/subscribe.ts | 15 ++++---- src/commands/rooms/occupancy/get.ts | 4 +- src/commands/rooms/occupancy/subscribe.ts | 12 ++++-- src/commands/rooms/presence/enter.ts | 27 +++++++------ src/commands/rooms/presence/subscribe.ts | 38 ++++++++++--------- src/commands/rooms/reactions/send.ts | 6 ++- src/commands/rooms/reactions/subscribe.ts | 12 ++++-- src/commands/rooms/typing/keystroke.ts | 18 ++++++--- src/commands/rooms/typing/subscribe.ts | 4 +- src/commands/spaces/cursors/get-all.ts | 21 +++++++--- src/commands/spaces/cursors/set.ts | 18 +++++---- src/commands/spaces/cursors/subscribe.ts | 16 +++++--- src/commands/spaces/list.ts | 8 ++-- src/commands/spaces/locations/get-all.ts | 28 ++++++++++---- src/commands/spaces/locations/set.ts | 23 +++++++---- src/commands/spaces/locations/subscribe.ts | 16 ++++---- src/commands/spaces/locks/acquire.ts | 10 +++-- src/commands/spaces/locks/get-all.ts | 23 ++++++++--- src/commands/spaces/locks/get.ts | 6 +-- src/commands/spaces/locks/subscribe.ts | 21 ++++++---- src/commands/spaces/members/enter.ts | 15 ++++---- src/commands/spaces/members/subscribe.ts | 13 ++++--- src/commands/stats/account.ts | 4 +- src/commands/stats/app.ts | 4 +- src/commands/status.ts | 8 ++-- src/stats-base-command.ts | 6 +-- 67 files changed, 577 insertions(+), 352 deletions(-) diff --git a/src/chat-base-command.ts b/src/chat-base-command.ts index 3a842a51..79e9e95f 100644 --- a/src/chat-base-command.ts +++ b/src/chat-base-command.ts @@ -5,7 +5,7 @@ import { productApiFlags } from "./flags.js"; import { BaseFlags } from "./types/cli.js"; import chalk from "chalk"; -import { success, listening } from "./utils/output.js"; +import { formatSuccess, formatListening } from "./utils/output.js"; import isTestMode from "./utils/test-mode.js"; export abstract class ChatBaseCommand extends AblyBaseCommand { @@ -114,10 +114,10 @@ export abstract class ChatBaseCommand extends AblyBaseCommand { case RoomStatus.Attached: { if (!this.shouldOutputJson(flags)) { if (options.successMessage) { - this.log(success(options.successMessage)); + this.log(formatSuccess(options.successMessage)); } if (options.listeningMessage) { - this.log(listening(options.listeningMessage)); + this.log(formatListening(options.listeningMessage)); } } break; diff --git a/src/commands/accounts/login.ts b/src/commands/accounts/login.ts index 8198250e..d6c5c848 100644 --- a/src/commands/accounts/login.ts +++ b/src/commands/accounts/login.ts @@ -8,7 +8,11 @@ import { endpointFlag } from "../../flags.js"; import { ControlApi } from "../../services/control-api.js"; import { errorMessage } from "../../utils/errors.js"; import { displayLogo } from "../../utils/logo.js"; -import { progress, resource, success } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; // Moved function definition outside the class @@ -97,7 +101,7 @@ export default class AccountsLogin extends ControlBaseCommand { // Prompt the user to get a token if (!flags["no-browser"]) { if (!this.shouldOutputJson(flags)) { - this.log(progress("Opening browser to get an access token")); + this.log(formatProgress("Opening browser to get an access token")); } await this.openBrowser(obtainTokenPath); @@ -228,7 +232,9 @@ export default class AccountsLogin extends ControlBaseCommand { const appName = await this.promptForAppName(); try { - this.log(`\n${progress(`Creating app ${resource(appName)}`)}`); + this.log( + `\n${formatProgress(`Creating app ${formatResource(appName)}`)}`, + ); const app = await controlApi.createApp({ name: appName, @@ -242,7 +248,7 @@ export default class AccountsLogin extends ControlBaseCommand { this.configManager.setCurrentApp(app.id); this.configManager.storeAppInfo(app.id, { appName: app.name }); - this.log(success("App created successfully.")); + this.log(formatSuccess("App created successfully.")); } catch (createError) { this.warn( `Failed to create app: ${createError instanceof Error ? createError.message : String(createError)}`, @@ -349,32 +355,32 @@ export default class AccountsLogin extends ControlBaseCommand { this.log(this.formatJsonOutput(response, flags)); } else { this.log( - `Successfully logged in to ${resource(account.name)} (account ID: ${chalk.greenBright(account.id)})`, + `Successfully logged in to ${formatResource(account.name)} (account ID: ${chalk.greenBright(account.id)})`, ); if (alias !== "default") { this.log(`Account stored with alias: ${alias}`); } - this.log(`Account ${resource(alias)} is now the current account`); + this.log(`Account ${formatResource(alias)} is now the current account`); if (selectedApp) { const message = isAutoSelected - ? success( - `Automatically selected app: ${resource(selectedApp.name)} (${selectedApp.id})`, + ? formatSuccess( + `Automatically selected app: ${formatResource(selectedApp.name)} (${selectedApp.id})`, ) - : success( - `Selected app: ${resource(selectedApp.name)} (${selectedApp.id})`, + : formatSuccess( + `Selected app: ${formatResource(selectedApp.name)} (${selectedApp.id})`, ); this.log(message); } if (selectedKey) { const keyMessage = isKeyAutoSelected - ? success( - `Automatically selected API key: ${resource(selectedKey.name || "Unnamed key")} (${selectedKey.id})`, + ? formatSuccess( + `Automatically selected API key: ${formatResource(selectedKey.name || "Unnamed key")} (${selectedKey.id})`, ) - : success( - `Selected API key: ${resource(selectedKey.name || "Unnamed key")} (${selectedKey.id})`, + : formatSuccess( + `Selected API key: ${formatResource(selectedKey.name || "Unnamed key")} (${selectedKey.id})`, ); this.log(keyMessage); } diff --git a/src/commands/apps/channel-rules/create.ts b/src/commands/apps/channel-rules/create.ts index 2c4053a4..338a2ac7 100644 --- a/src/commands/apps/channel-rules/create.ts +++ b/src/commands/apps/channel-rules/create.ts @@ -3,7 +3,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; import { errorMessage } from "../../../utils/errors.js"; -import { success } from "../../../utils/output.js"; +import { formatSuccess } from "../../../utils/output.js"; export default class ChannelRulesCreateCommand extends ControlBaseCommand { static description = "Create a channel rule"; @@ -147,7 +147,7 @@ export default class ChannelRulesCreateCommand extends ControlBaseCommand { ), ); } else { - this.log(success("Channel rule created.")); + this.log(formatSuccess("Channel rule created.")); this.log(`ID: ${createdNamespace.id}`); for (const line of formatChannelRuleDetails(createdNamespace, { formatDate: (t) => this.formatDate(t), diff --git a/src/commands/apps/channel-rules/list.ts b/src/commands/apps/channel-rules/list.ts index 583cde1b..973af8e0 100644 --- a/src/commands/apps/channel-rules/list.ts +++ b/src/commands/apps/channel-rules/list.ts @@ -1,11 +1,10 @@ import { Flags } from "@oclif/core"; -import chalk from "chalk"; - import type { Namespace } from "../../../services/control-api.js"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; import { errorMessage } from "../../../utils/errors.js"; +import { formatHeading } from "../../../utils/output.js"; interface ChannelRuleOutput { authenticated: boolean; @@ -93,7 +92,7 @@ export default class ChannelRulesListCommand extends ControlBaseCommand { this.log(`Found ${namespaces.length} channel rules:\n`); namespaces.forEach((namespace: Namespace) => { - this.log(chalk.bold(`Channel Rule ID: ${namespace.id}`)); + this.log(formatHeading(`Channel Rule ID: ${namespace.id}`)); for (const line of formatChannelRuleDetails(namespace, { bold: true, formatDate: (t) => this.formatDate(t), diff --git a/src/commands/apps/create.ts b/src/commands/apps/create.ts index ebcbc3fe..8e0720c9 100644 --- a/src/commands/apps/create.ts +++ b/src/commands/apps/create.ts @@ -2,7 +2,11 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { progress, resource, success } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class AppsCreateCommand extends ControlBaseCommand { static description = "Create a new app"; @@ -32,7 +36,7 @@ export default class AppsCreateCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { - this.log(progress(`Creating app ${resource(flags.name)}`)); + this.log(formatProgress(`Creating app ${formatResource(flags.name)}`)); } const app = await controlApi.createApp({ @@ -60,7 +64,11 @@ export default class AppsCreateCommand extends ControlBaseCommand { ), ); } else { - this.log(success(`App created: ${resource(app.name)} (${app.id}).`)); + this.log( + formatSuccess( + `App created: ${formatResource(app.name)} (${app.id}).`, + ), + ); this.log(`App ID: ${app.id}`); this.log(`Name: ${app.name}`); this.log(`Status: ${app.status}`); diff --git a/src/commands/apps/delete.ts b/src/commands/apps/delete.ts index dcab6582..d342adfc 100644 --- a/src/commands/apps/delete.ts +++ b/src/commands/apps/delete.ts @@ -3,7 +3,7 @@ import * as readline from "node:readline"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { progress, resource } from "../../utils/output.js"; +import { formatProgress, formatResource } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import AppsSwitch from "./switch.js"; @@ -133,7 +133,9 @@ export default class AppsDeleteCommand extends ControlBaseCommand { } if (!this.shouldOutputJson(flags)) { - this.log(progress(`Deleting app ${resource(appIdToDelete)}`)); + this.log( + formatProgress(`Deleting app ${formatResource(appIdToDelete)}`), + ); } await controlApi.deleteApp(appIdToDelete); diff --git a/src/commands/apps/set-apns-p12.ts b/src/commands/apps/set-apns-p12.ts index 33c33a0a..9da9cd6e 100644 --- a/src/commands/apps/set-apns-p12.ts +++ b/src/commands/apps/set-apns-p12.ts @@ -4,7 +4,11 @@ import * as path from "node:path"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { progress, resource, success } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class AppsSetApnsP12Command extends ControlBaseCommand { static args = { @@ -56,7 +60,9 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { } this.log( - progress(`Uploading APNS P12 certificate for app ${resource(args.id)}`), + formatProgress( + `Uploading APNS P12 certificate for app ${formatResource(args.id)}`, + ), ); // Read certificate file and encode as base64 @@ -72,7 +78,7 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(result, flags)); } else { - this.log(success("APNS P12 certificate uploaded.")); + this.log(formatSuccess("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 4fc10f90..266e6ad2 100644 --- a/src/commands/apps/update.ts +++ b/src/commands/apps/update.ts @@ -2,7 +2,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { progress, resource } from "../../utils/output.js"; +import { formatProgress, formatResource } from "../../utils/output.js"; export default class AppsUpdateCommand extends ControlBaseCommand { static args = { @@ -61,7 +61,7 @@ export default class AppsUpdateCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { - this.log(progress(`Updating app ${resource(args.id)}`)); + this.log(formatProgress(`Updating app ${formatResource(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 b21618fd..417b2aae 100644 --- a/src/commands/auth/keys/create.ts +++ b/src/commands/auth/keys/create.ts @@ -3,7 +3,11 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../../control-base-command.js"; import { errorMessage } from "../../../utils/errors.js"; import { formatCapabilities } from "../../../utils/key-display.js"; -import { progress, resource, success } from "../../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; export default class KeysCreateCommand extends ControlBaseCommand { static description = "Create a new API key for an app"; @@ -87,8 +91,8 @@ export default class KeysCreateCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Creating key ${resource(flags.name)} for app ${resource(appId)}`, + formatProgress( + `Creating key ${formatResource(flags.name)} for app ${formatResource(appId)}`, ), ); } @@ -113,7 +117,7 @@ export default class KeysCreateCommand extends ControlBaseCommand { ); } else { const keyName = `${key.appId}.${key.id}`; - this.log(success(`Key created: ${resource(keyName)}.`)); + this.log(formatSuccess(`Key created: ${formatResource(keyName)}.`)); this.log(`Key Name: ${keyName}`); this.log(`Key Label: ${key.name || "Unnamed key"}`); diff --git a/src/commands/bench/publisher.ts b/src/commands/bench/publisher.ts index 4d5f163a..40203f60 100644 --- a/src/commands/bench/publisher.ts +++ b/src/commands/bench/publisher.ts @@ -6,7 +6,7 @@ import Table from "cli-table3"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import { errorMessage } from "../../utils/errors.js"; -import { success } from "../../utils/output.js"; +import { formatSuccess } from "../../utils/output.js"; interface TestMetrics { batchCount: number; @@ -528,7 +528,7 @@ export default class BenchPublisher extends AblyBaseCommand { process.stdout.write("\u001B[2J\u001B[0f"); } - this.log("\n\n" + success("Benchmark complete.") + "\n"); + this.log("\n\n" + formatSuccess("Benchmark complete.") + "\n"); const summaryTable = new Table({ head: [chalk.white("Metric"), chalk.white("Value")], style: { border: [], head: [] }, diff --git a/src/commands/bench/subscriber.ts b/src/commands/bench/subscriber.ts index def0ed54..97291e0e 100644 --- a/src/commands/bench/subscriber.ts +++ b/src/commands/bench/subscriber.ts @@ -7,7 +7,11 @@ import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import { errorMessage } from "../../utils/errors.js"; import { waitUntilInterruptedOrTimeout } from "../../utils/long-running.js"; -import { progress, resource, success } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; interface TestMetrics { endToEndLatencies: number[]; // Publisher -> Subscriber @@ -92,7 +96,11 @@ export default class BenchSubscriber extends AblyBaseCommand { // Show initial status if (!this.shouldOutputJson(flags)) { - this.log(progress(`Attaching to channel: ${resource(args.channel)}`)); + this.log( + formatProgress( + `Attaching to channel: ${formatResource(args.channel)}`, + ), + ); } await this.handlePresence(channel, metrics, flags); @@ -113,8 +121,8 @@ export default class BenchSubscriber extends AblyBaseCommand { // Show success message if (!this.shouldOutputJson(flags)) { this.log( - success( - `Subscribed to channel: ${resource(args.channel)}. Waiting for benchmark messages.`, + formatSuccess( + `Subscribed to channel: ${formatResource(args.channel)}. Waiting for benchmark messages.`, ), ); } diff --git a/src/commands/channels/batch-publish.ts b/src/commands/channels/batch-publish.ts index 4192dd4b..b00c94f4 100644 --- a/src/commands/channels/batch-publish.ts +++ b/src/commands/channels/batch-publish.ts @@ -4,7 +4,11 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; import { errorMessage } from "../../utils/errors.js"; -import { progress, resource, success } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; // Define interfaces for the batch-publish command interface BatchMessage { @@ -248,7 +252,7 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { } if (!this.shouldOutputJson(flags) && !this.shouldSuppressOutput(flags)) { - this.log(progress("Sending batch publish request")); + this.log(formatProgress("Sending batch publish request")); } // Make the batch publish request using the REST client's request method @@ -282,7 +286,7 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { ), ); } else { - this.log(success("Batch publish successful.")); + this.log(formatSuccess("Batch publish successful.")); this.log( `Response: ${this.formatJsonOutput({ responses: responseItems }, flags)}`, ); @@ -331,12 +335,12 @@ export default class ChannelsBatchPublish extends AblyBaseCommand { batchResponses.forEach((item: BatchResponseItem) => { if (item.error) { this.log( - `${chalk.red("✗")} Failed to publish to channel ${resource(item.channel)}: ${item.error.message} (${item.error.code})`, + `${chalk.red("✗")} Failed to publish to channel ${formatResource(item.channel)}: ${item.error.message} (${item.error.code})`, ); } else { this.log( - success( - `Published to channel ${resource(item.channel)} with messageId: ${item.messageId}.`, + formatSuccess( + `Published to channel ${formatResource(item.channel)} with messageId: ${item.messageId}.`, ), ); } diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 8944a928..c8d92f8a 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -8,11 +8,15 @@ import { formatMessageData } from "../../utils/json-formatter.js"; import { buildHistoryParams } from "../../utils/history.js"; import { errorMessage } from "../../utils/errors.js"; import { - countLabel, + formatCountLabel, formatTimestamp, formatMessageTimestamp, - limitWarning, - resource, + formatIndex, + formatLabel, + formatClientId, + formatEventType, + formatLimitWarning, + formatResource, } from "../../utils/output.js"; export default class ChannelsHistory extends AblyBaseCommand { @@ -95,7 +99,7 @@ export default class ChannelsHistory extends AblyBaseCommand { } this.log( - `Found ${countLabel(messages.length, "message")} in the history of channel: ${resource(channelName)}`, + `Found ${formatCountLabel(messages.length, "message")} in the history of channel: ${formatResource(channelName)}`, ); this.log(""); @@ -104,24 +108,28 @@ export default class ChannelsHistory extends AblyBaseCommand { ? formatTimestamp(formatMessageTimestamp(message.timestamp)) : chalk.dim("[Unknown timestamp]"); - this.log(`${chalk.dim(`[${index + 1}]`)} ${timestampDisplay}`); + this.log(`${formatIndex(index + 1)} ${timestampDisplay}`); this.log( - `${chalk.dim("Event:")} ${chalk.yellow(message.name || "(none)")}`, + `${formatLabel("Event")} ${formatEventType(message.name || "(none)")}`, ); if (message.clientId) { this.log( - `${chalk.dim("Client ID:")} ${chalk.blue(message.clientId)}`, + `${formatLabel("Client ID")} ${formatClientId(message.clientId)}`, ); } - this.log(chalk.dim("Data:")); + this.log(formatLabel("Data")); this.log(formatMessageData(message.data)); this.log(""); } - const warning = limitWarning(messages.length, flags.limit, "messages"); + const warning = formatLimitWarning( + messages.length, + flags.limit, + "messages", + ); if (warning) this.log(warning); } } catch (error) { diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index e76ee5f9..2c0ab839 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -1,9 +1,13 @@ import { Flags } from "@oclif/core"; import { AblyBaseCommand } from "../../base-command.js"; import { productApiFlags } from "../../flags.js"; -import chalk from "chalk"; import { errorMessage } from "../../utils/errors.js"; -import { countLabel, limitWarning, resource } from "../../utils/output.js"; +import { + formatCountLabel, + formatLabel, + formatLimitWarning, + formatResource, +} from "../../utils/output.js"; interface ChannelMetrics { connections?: number; @@ -112,33 +116,35 @@ export default class ChannelsList extends AblyBaseCommand { return; } - this.log(`Found ${countLabel(channels.length, "active channel")}:`); + this.log( + `Found ${formatCountLabel(channels.length, "active channel")}:`, + ); for (const channel of channels as ChannelItem[]) { - this.log(`${resource(channel.channelId)}`); + this.log(`${formatResource(channel.channelId)}`); // Show occupancy if available if (channel.status?.occupancy?.metrics) { const { metrics } = channel.status.occupancy; this.log( - ` ${chalk.dim("Connections:")} ${metrics.connections || 0}`, + ` ${formatLabel("Connections")} ${metrics.connections || 0}`, ); this.log( - ` ${chalk.dim("Publishers:")} ${metrics.publishers || 0}`, + ` ${formatLabel("Publishers")} ${metrics.publishers || 0}`, ); this.log( - ` ${chalk.dim("Subscribers:")} ${metrics.subscribers || 0}`, + ` ${formatLabel("Subscribers")} ${metrics.subscribers || 0}`, ); if (metrics.presenceConnections !== undefined) { this.log( - ` ${chalk.dim("Presence Connections:")} ${metrics.presenceConnections}`, + ` ${formatLabel("Presence Connections")} ${metrics.presenceConnections}`, ); } if (metrics.presenceMembers !== undefined) { this.log( - ` ${chalk.dim("Presence Members:")} ${metrics.presenceMembers}`, + ` ${formatLabel("Presence Members")} ${metrics.presenceMembers}`, ); } } @@ -146,7 +152,11 @@ export default class ChannelsList extends AblyBaseCommand { this.log(""); // Add a line break between channels } - const warning = limitWarning(channels.length, flags.limit, "channels"); + const warning = formatLimitWarning( + channels.length, + flags.limit, + "channels", + ); if (warning) this.log(warning); } } catch (error) { diff --git a/src/commands/channels/occupancy/get.ts b/src/commands/channels/occupancy/get.ts index 3493ee9f..27cad5d4 100644 --- a/src/commands/channels/occupancy/get.ts +++ b/src/commands/channels/occupancy/get.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; import { productApiFlags } from "../../../flags.js"; import { errorMessage } from "../../../utils/errors.js"; -import { resource } from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; interface OccupancyMetrics { connections: number; @@ -81,7 +81,9 @@ export default class ChannelsOccupancyGet extends AblyBaseCommand { ), ); } else { - this.log(`Occupancy metrics for channel ${resource(channelName)}:\n`); + this.log( + `Occupancy metrics for channel ${formatResource(channelName)}:\n`, + ); this.log( `${chalk.dim("Connections:")} ${occupancyMetrics.connections ?? 0}`, ); diff --git a/src/commands/channels/occupancy/subscribe.ts b/src/commands/channels/occupancy/subscribe.ts index 56fc76da..a21efd42 100644 --- a/src/commands/channels/occupancy/subscribe.ts +++ b/src/commands/channels/occupancy/subscribe.ts @@ -1,16 +1,16 @@ import { Args } from "@oclif/core"; import * as Ably from "ably"; -import chalk from "chalk"; - import { AblyBaseCommand } from "../../../base-command.js"; import { durationFlag, productApiFlags } from "../../../flags.js"; import { - listening, - progress, - resource, - success, + formatListening, + formatProgress, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, + formatLabel, + formatEventType, } from "../../../utils/output.js"; export default class ChannelsOccupancySubscribe extends AblyBaseCommand { @@ -79,8 +79,8 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Subscribing to occupancy events on channel: ${resource(channelName)}`, + formatProgress( + `Subscribing to occupancy events on channel: ${formatResource(channelName)}`, ), ); } @@ -105,12 +105,12 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { this.log(this.formatJsonOutput(event, flags)); } else { this.log( - `${formatTimestamp(timestamp)} ${chalk.cyan(`Channel: ${channelName}`)} | ${chalk.yellow("Occupancy Update")}`, + `${formatTimestamp(timestamp)} ${formatResource(`Channel: ${channelName}`)} | ${formatEventType("Occupancy Update")}`, ); if (message.data !== null && message.data !== undefined) { this.log( - `${chalk.dim("Occupancy Data:")} ${JSON.stringify(message.data, null, 2)}`, + `${formatLabel("Occupancy Data")} ${JSON.stringify(message.data, null, 2)}`, ); } @@ -120,11 +120,11 @@ export default class ChannelsOccupancySubscribe extends AblyBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - success( - `Subscribed to occupancy on channel: ${resource(channelName)}.`, + formatSuccess( + `Subscribed to occupancy on channel: ${formatResource(channelName)}.`, ), ); - this.log(listening("Listening for occupancy events.")); + this.log(formatListening("Listening for occupancy events.")); } this.logCliEvent( diff --git a/src/commands/channels/presence/enter.ts b/src/commands/channels/presence/enter.ts index 281ea7ef..c7709522 100644 --- a/src/commands/channels/presence/enter.ts +++ b/src/commands/channels/presence/enter.ts @@ -1,17 +1,19 @@ import { Args, Flags } from "@oclif/core"; import * as Ably from "ably"; -import chalk from "chalk"; - import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { errorMessage } from "../../../utils/errors.js"; import { isJsonData } from "../../../utils/json-formatter.js"; import { - listening, - resource, - success, + formatListening, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, + formatIndex, + formatLabel, + formatClientId, + formatEventType, } from "../../../utils/output.js"; export default class ChannelsPresenceEnter extends AblyBaseCommand { @@ -140,10 +142,10 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { this.log(this.formatJsonOutput(event, flags)); } else { const sequencePrefix = flags["sequence-numbers"] - ? `${chalk.dim(`[${this.sequenceCounter}]`)}` + ? `${formatIndex(this.sequenceCounter)}` : ""; this.log( - `${formatTimestamp(timestamp)}${sequencePrefix} ${chalk.cyan(`Channel: ${channelName}`)} | ${chalk.yellow(`Action: ${presenceMessage.action}`)} | ${chalk.blue(`Client: ${presenceMessage.clientId || "N/A"}`)}`, + `${formatTimestamp(timestamp)}${sequencePrefix} ${formatResource(`Channel: ${channelName}`)} | Action: ${formatEventType(String(presenceMessage.action))} | Client: ${formatClientId(presenceMessage.clientId || "N/A")}`, ); if ( @@ -151,10 +153,10 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { presenceMessage.data !== undefined ) { if (isJsonData(presenceMessage.data)) { - this.log(chalk.dim("Data:")); + this.log(formatLabel("Data")); this.log(JSON.stringify(presenceMessage.data, null, 2)); } else { - this.log(`${chalk.dim("Data:")} ${presenceMessage.data}`); + this.log(`${formatLabel("Data")} ${presenceMessage.data}`); } } @@ -193,13 +195,15 @@ export default class ChannelsPresenceEnter extends AblyBaseCommand { this.log(this.formatJsonOutput(enterEvent, flags)); } else { this.log( - success(`Entered presence on channel: ${resource(channelName)}.`), + formatSuccess( + `Entered presence on channel: ${formatResource(channelName)}.`, + ), ); if (flags["show-others"]) { - this.log(listening("Listening for presence events.")); + this.log(formatListening("Listening for presence events.")); } else { - this.log(listening("Staying present.")); + this.log(formatListening("Staying present.")); } } diff --git a/src/commands/channels/presence/subscribe.ts b/src/commands/channels/presence/subscribe.ts index fe6ab51b..b38d6b30 100644 --- a/src/commands/channels/presence/subscribe.ts +++ b/src/commands/channels/presence/subscribe.ts @@ -1,16 +1,17 @@ import { Args } from "@oclif/core"; import * as Ably from "ably"; -import chalk from "chalk"; - import { AblyBaseCommand } from "../../../base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; import { - listening, - progress, - resource, - success, + formatListening, + formatProgress, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, + formatLabel, + formatClientId, + formatEventType, } from "../../../utils/output.js"; export default class ChannelsPresenceSubscribe extends AblyBaseCommand { @@ -74,8 +75,8 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Subscribing to presence events on channel: ${resource(channelName)}`, + formatProgress( + `Subscribing to presence events on channel: ${formatResource(channelName)}`, ), ); } @@ -106,7 +107,7 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { const clientId = presenceMessage.clientId || "Unknown"; this.log( - `${formatTimestamp(timestamp)} ${chalk.cyan(`Channel: ${channelName}`)} | ${chalk.yellow(`Action: ${action}`)} | ${chalk.blue(`Client: ${clientId}`)}`, + `${formatTimestamp(timestamp)} ${formatResource(`Channel: ${channelName}`)} | Action: ${formatEventType(action)} | Client: ${formatClientId(clientId)}`, ); if ( @@ -114,7 +115,7 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { presenceMessage.data !== undefined ) { this.log( - `${chalk.dim("Data:")} ${JSON.stringify(presenceMessage.data, null, 2)}`, + `${formatLabel("Data")} ${JSON.stringify(presenceMessage.data, null, 2)}`, ); } @@ -124,11 +125,11 @@ export default class ChannelsPresenceSubscribe extends AblyBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - success( - `Subscribed to presence on channel: ${resource(channelName)}.`, + formatSuccess( + `Subscribed to presence on channel: ${formatResource(channelName)}.`, ), ); - this.log(listening("Listening for presence events.")); + this.log(formatListening("Listening for presence events.")); } this.logCliEvent( diff --git a/src/commands/channels/publish.ts b/src/commands/channels/publish.ts index dd324723..6cd8a690 100644 --- a/src/commands/channels/publish.ts +++ b/src/commands/channels/publish.ts @@ -7,7 +7,11 @@ import { clientIdFlag, productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; import { interpolateMessage } from "../../utils/message.js"; import { errorMessage } from "../../utils/errors.js"; -import { progress, resource, success } from "../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class ChannelsPublish extends AblyBaseCommand { static override args = { @@ -160,14 +164,14 @@ export default class ChannelsPublish extends AblyBaseCommand { this.log(this.formatJsonOutput(finalResult, flags)); } else if (total > 1) { this.log( - success( - `${published}/${total} messages published to channel: ${resource(args.channel as string)}${errors > 0 ? ` (${chalk.red(errors)} errors)` : ""}.`, + formatSuccess( + `${published}/${total} messages published to channel: ${formatResource(args.channel as string)}${errors > 0 ? ` (${chalk.red(errors)} errors)` : ""}.`, ), ); } else if (errors === 0) { this.log( - success( - `Message published to channel: ${resource(args.channel as string)}.`, + formatSuccess( + `Message published to channel: ${formatResource(args.channel as string)}.`, ), ); } else { @@ -249,7 +253,9 @@ export default class ChannelsPublish extends AblyBaseCommand { { count, delay }, ); if (count > 1 && !this.shouldOutputJson(flags)) { - this.log(progress(`Publishing ${count} messages with ${delay}ms delay`)); + this.log( + formatProgress(`Publishing ${count} messages with ${delay}ms delay`), + ); } let publishedCount = 0; @@ -299,8 +305,8 @@ export default class ChannelsPublish extends AblyBaseCommand { count > 1 // Only show individual success messages when publishing multiple messages ) { this.log( - success( - `Message ${messageIndex} published to channel: ${resource(args.channel as string)}.`, + formatSuccess( + `Message ${messageIndex} published to channel: ${formatResource(args.channel as string)}.`, ), ); } diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index 22e7c5b9..c1b1bb0d 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -1,7 +1,5 @@ import { Args, Flags } from "@oclif/core"; import * as Ably from "ably"; -import chalk from "chalk"; - import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, @@ -11,12 +9,15 @@ import { } from "../../flags.js"; import { formatMessageData } from "../../utils/json-formatter.js"; import { - listening, - progress, - resource, - success, + formatListening, + formatProgress, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, + formatIndex, + formatLabel, + formatEventType, } from "../../utils/output.js"; export default class ChannelsSubscribe extends AblyBaseCommand { @@ -173,7 +174,11 @@ export default class ChannelsSubscribe extends AblyBaseCommand { { channel: channel.name }, ); if (!this.shouldOutputJson(flags)) { - this.log(progress(`Attaching to channel: ${resource(channel.name)}`)); + this.log( + formatProgress( + `Attaching to channel: ${formatResource(channel.name)}`, + ), + ); } // Set up channel state logging @@ -222,16 +227,16 @@ export default class ChannelsSubscribe extends AblyBaseCommand { } else { const name = message.name || "(none)"; const sequencePrefix = flags["sequence-numbers"] - ? `${chalk.dim(`[${this.sequenceCounter}]`)}` + ? `${formatIndex(this.sequenceCounter)}` : ""; // Message header with timestamp and channel info this.log( - `${formatTimestamp(timestamp)}${sequencePrefix} ${chalk.cyan(`Channel: ${channel.name}`)} | ${chalk.yellow(`Event: ${name}`)}`, + `${formatTimestamp(timestamp)}${sequencePrefix} ${formatResource(`Channel: ${channel.name}`)} | Event: ${formatEventType(name)}`, ); // Message data with consistent formatting - this.log(chalk.dim("Data:")); + this.log(formatLabel("Data")); this.log(formatMessageData(message.data)); this.log(""); // Empty line for better readability @@ -251,13 +256,17 @@ export default class ChannelsSubscribe extends AblyBaseCommand { if (!this.shouldOutputJson(flags)) { if (channelNames.length === 1) { this.log( - success(`Subscribed to channel: ${resource(channelNames[0])}.`), + formatSuccess( + `Subscribed to channel: ${formatResource(channelNames[0])}.`, + ), ); } else { - this.log(success(`Subscribed to ${channelNames.length} channels.`)); + this.log( + formatSuccess(`Subscribed to ${channelNames.length} channels.`), + ); } - this.log(listening("Listening for messages.")); + this.log(formatListening("Listening for messages.")); } this.logCliEvent( diff --git a/src/commands/connections/test.ts b/src/commands/connections/test.ts index 73ad7d00..3be3e357 100644 --- a/src/commands/connections/test.ts +++ b/src/commands/connections/test.ts @@ -4,7 +4,10 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { progress, success as successMsg } from "../../utils/output.js"; +import { + formatProgress, + formatSuccess as successMsg, +} from "../../utils/output.js"; export default class ConnectionsTest extends AblyBaseCommand { static override description = "Test connection to Ably"; @@ -257,7 +260,9 @@ export default class ConnectionsTest extends AblyBaseCommand { `Testing ${config.displayName} connection...`, ); if (!this.shouldOutputJson(flags)) { - this.log(progress(`Testing ${config.displayName} connection to Ably`)); + this.log( + formatProgress(`Testing ${config.displayName} connection to Ably`), + ); } let client: Ably.Realtime | null = null; diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index 450cd900..cd88ed98 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -2,7 +2,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { resource, success } from "../../utils/output.js"; +import { formatResource, formatSuccess } from "../../utils/output.js"; // Interface for basic integration data structure interface IntegrationData { @@ -150,8 +150,8 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { ); } else { this.log( - success( - `Integration rule created: ${resource(createdIntegration.id)}.`, + formatSuccess( + `Integration rule created: ${formatResource(createdIntegration.id)}.`, ), ); this.log(`ID: ${createdIntegration.id}`); diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index a19e6685..b64a5005 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -2,7 +2,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { resource, success } from "../../utils/output.js"; +import { formatResource, formatSuccess } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class IntegrationsDeleteCommand extends ControlBaseCommand { @@ -101,7 +101,9 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { ); } else { this.log( - success(`Integration rule deleted: ${resource(integration.id)}.`), + formatSuccess( + `Integration rule deleted: ${formatResource(integration.id)}.`, + ), ); this.log(`ID: ${integration.id}`); this.log(`App ID: ${integration.appId}`); diff --git a/src/commands/integrations/list.ts b/src/commands/integrations/list.ts index e9edd298..3f9bec91 100644 --- a/src/commands/integrations/list.ts +++ b/src/commands/integrations/list.ts @@ -1,8 +1,7 @@ import { Flags } from "@oclif/core"; -import chalk from "chalk"; - import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; +import { formatHeading } from "../../utils/output.js"; export default class IntegrationsListCommand extends ControlBaseCommand { static description = "List all integrations"; @@ -71,7 +70,7 @@ export default class IntegrationsListCommand extends ControlBaseCommand { this.log(`Found ${integrations.length} integrations:\n`); for (const integration of integrations) { - this.log(chalk.bold(`Integration ID: ${integration.id}`)); + this.log(formatHeading(`Integration ID: ${integration.id}`)); this.log(` App ID: ${integration.appId}`); this.log(` Type: ${integration.ruleType}`); this.log(` Request Mode: ${integration.requestMode}`); diff --git a/src/commands/integrations/update.ts b/src/commands/integrations/update.ts index 3df16667..7168ddee 100644 --- a/src/commands/integrations/update.ts +++ b/src/commands/integrations/update.ts @@ -1,7 +1,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { success } from "../../utils/output.js"; +import { formatSuccess } from "../../utils/output.js"; // Interface for rule update data structure (most fields optional) interface PartialRuleData { @@ -111,7 +111,7 @@ export default class IntegrationsUpdateCommand extends ControlBaseCommand { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput({ rule: updatedRule }, flags)); } else { - this.log(success("Integration rule updated.")); + this.log(formatSuccess("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/channel-lifecycle/subscribe.ts b/src/commands/logs/channel-lifecycle/subscribe.ts index cfd3a10f..bfc2bea5 100644 --- a/src/commands/logs/channel-lifecycle/subscribe.ts +++ b/src/commands/logs/channel-lifecycle/subscribe.ts @@ -5,9 +5,9 @@ import { AblyBaseCommand } from "../../../base-command.js"; import { durationFlag, productApiFlags, rewindFlag } from "../../../flags.js"; import { formatMessageData } from "../../../utils/json-formatter.js"; import { - listening, - resource, - success, + formatListening, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, } from "../../../utils/output.js"; @@ -72,8 +72,10 @@ export default class LogsChannelLifecycleSubscribe extends AblyBaseCommand { `Subscribing to ${channelName}...`, ); if (!this.shouldOutputJson(flags)) { - this.log(success(`Subscribed to ${resource(channelName)}.`)); - this.log(listening("Listening for channel lifecycle logs.")); + this.log( + formatSuccess(`Subscribed to ${formatResource(channelName)}.`), + ); + this.log(formatListening("Listening for channel lifecycle logs.")); this.log(""); } diff --git a/src/commands/logs/connection-lifecycle/history.ts b/src/commands/logs/connection-lifecycle/history.ts index 144a36f3..891111f6 100644 --- a/src/commands/logs/connection-lifecycle/history.ts +++ b/src/commands/logs/connection-lifecycle/history.ts @@ -7,10 +7,11 @@ import { formatMessageData } from "../../../utils/json-formatter.js"; import { errorMessage } from "../../../utils/errors.js"; import { buildHistoryParams } from "../../../utils/history.js"; import { - countLabel, + formatCountLabel, + formatIndex, formatTimestamp, formatMessageTimestamp, - limitWarning, + formatLimitWarning, } from "../../../utils/output.js"; export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { @@ -86,7 +87,7 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { } this.log( - `Found ${countLabel(messages.length, "connection lifecycle log")}:`, + `Found ${formatCountLabel(messages.length, "connection lifecycle log")}:`, ); this.log(""); @@ -95,7 +96,7 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { ? formatTimestamp(formatMessageTimestamp(message.timestamp)) : chalk.dim("[Unknown timestamp]"); - this.log(`${chalk.dim(`[${index + 1}]`)} ${timestampDisplay}`); + this.log(`${formatIndex(index + 1)} ${timestampDisplay}`); // Event name if (message.name) { @@ -133,7 +134,11 @@ export default class LogsConnectionLifecycleHistory extends AblyBaseCommand { this.log(""); } - const warning = limitWarning(messages.length, flags.limit, "logs"); + const warning = formatLimitWarning( + messages.length, + flags.limit, + "logs", + ); if (warning) this.log(warning); } } catch (error) { diff --git a/src/commands/logs/connection-lifecycle/subscribe.ts b/src/commands/logs/connection-lifecycle/subscribe.ts index 6985ce2b..e15e17ed 100644 --- a/src/commands/logs/connection-lifecycle/subscribe.ts +++ b/src/commands/logs/connection-lifecycle/subscribe.ts @@ -4,8 +4,8 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../../base-command.js"; import { durationFlag, productApiFlags, rewindFlag } from "../../../flags.js"; import { - listening, - success, + formatListening, + formatSuccess, formatTimestamp, formatMessageTimestamp, } from "../../../utils/output.js"; @@ -74,7 +74,7 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { ); if (!this.shouldOutputJson(flags)) { - this.log(success("Subscribed to connection lifecycle logs.")); + this.log(formatSuccess("Subscribed to connection lifecycle logs.")); } // Subscribe to connection lifecycle logs @@ -118,7 +118,9 @@ export default class LogsConnectionLifecycleSubscribe extends AblyBaseCommand { "Listening for connection lifecycle log events. Press Ctrl+C to exit.", ); if (!this.shouldOutputJson(flags)) { - this.log(listening("Listening for connection lifecycle log events.")); + this.log( + formatListening("Listening for connection lifecycle log events."), + ); } // Wait until the user interrupts or the optional duration elapses diff --git a/src/commands/logs/history.ts b/src/commands/logs/history.ts index 57500f89..fb401af5 100644 --- a/src/commands/logs/history.ts +++ b/src/commands/logs/history.ts @@ -7,10 +7,11 @@ import { formatMessageData } from "../../utils/json-formatter.js"; import { errorMessage } from "../../utils/errors.js"; import { buildHistoryParams } from "../../utils/history.js"; import { - countLabel, + formatCountLabel, + formatIndex, formatTimestamp, formatMessageTimestamp, - limitWarning, + formatLimitWarning, } from "../../utils/output.js"; export default class LogsHistory extends AblyBaseCommand { @@ -85,7 +86,9 @@ export default class LogsHistory extends AblyBaseCommand { return; } - this.log(`Found ${countLabel(messages.length, "application log")}:`); + this.log( + `Found ${formatCountLabel(messages.length, "application log")}:`, + ); this.log(""); for (const [index, message] of messages.entries()) { @@ -93,7 +96,7 @@ export default class LogsHistory extends AblyBaseCommand { ? formatTimestamp(formatMessageTimestamp(message.timestamp)) : chalk.dim("[Unknown timestamp]"); - this.log(`${chalk.dim(`[${index + 1}]`)} ${timestampDisplay}`); + this.log(`${formatIndex(index + 1)} ${timestampDisplay}`); // Event name if (message.name) { @@ -110,7 +113,11 @@ export default class LogsHistory extends AblyBaseCommand { this.log(""); } - const warning = limitWarning(messages.length, flags.limit, "logs"); + const warning = formatLimitWarning( + messages.length, + flags.limit, + "logs", + ); if (warning) this.log(warning); } } catch (error) { diff --git a/src/commands/logs/push/history.ts b/src/commands/logs/push/history.ts index b6cf9a17..319da82d 100644 --- a/src/commands/logs/push/history.ts +++ b/src/commands/logs/push/history.ts @@ -7,10 +7,11 @@ import { errorMessage } from "../../../utils/errors.js"; import { formatMessageData } from "../../../utils/json-formatter.js"; import { buildHistoryParams } from "../../../utils/history.js"; import { - countLabel, + formatCountLabel, + formatIndex, formatTimestamp, formatMessageTimestamp, - limitWarning, + formatLimitWarning, } from "../../../utils/output.js"; export default class LogsPushHistory extends AblyBaseCommand { @@ -85,7 +86,9 @@ export default class LogsPushHistory extends AblyBaseCommand { return; } - this.log(`Found ${countLabel(messages.length, "push log message")}:`); + this.log( + `Found ${formatCountLabel(messages.length, "push log message")}:`, + ); this.log(""); for (const [index, message] of messages.entries()) { @@ -134,7 +137,7 @@ export default class LogsPushHistory extends AblyBaseCommand { // Format the log output this.log( - `${chalk.dim(`[${index + 1}]`)} ${timestampDisplay} Channel: ${chalk.cyan(channelName)} | Event: ${eventColor(event)}`, + `${formatIndex(index + 1)} ${timestampDisplay} Channel: ${chalk.cyan(channelName)} | Event: ${eventColor(event)}`, ); if (message.data) { this.log(chalk.dim("Data:")); @@ -144,7 +147,11 @@ export default class LogsPushHistory extends AblyBaseCommand { this.log(""); } - const warning = limitWarning(messages.length, flags.limit, "logs"); + const warning = formatLimitWarning( + messages.length, + flags.limit, + "logs", + ); if (warning) this.log(warning); } } catch (error) { diff --git a/src/commands/logs/push/subscribe.ts b/src/commands/logs/push/subscribe.ts index e97a6716..8eac7a43 100644 --- a/src/commands/logs/push/subscribe.ts +++ b/src/commands/logs/push/subscribe.ts @@ -5,9 +5,9 @@ import { AblyBaseCommand } from "../../../base-command.js"; import { durationFlag, productApiFlags, rewindFlag } from "../../../flags.js"; import { formatMessageData } from "../../../utils/json-formatter.js"; import { - listening, - resource, - success, + formatListening, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, } from "../../../utils/output.js"; @@ -145,8 +145,10 @@ export default class LogsPushSubscribe extends AblyBaseCommand { }); if (!this.shouldOutputJson(flags)) { - this.log(success(`Subscribed to ${resource(channelName)}.`)); - this.log(listening("Listening for push logs.")); + this.log( + formatSuccess(`Subscribed to ${formatResource(channelName)}.`), + ); + this.log(formatListening("Listening for push logs.")); this.log(""); } diff --git a/src/commands/logs/subscribe.ts b/src/commands/logs/subscribe.ts index d369219e..2165c65c 100644 --- a/src/commands/logs/subscribe.ts +++ b/src/commands/logs/subscribe.ts @@ -5,9 +5,9 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { durationFlag, productApiFlags, rewindFlag } from "../../flags.js"; import { - listening, - resource, - success, + formatListening, + formatResource, + formatSuccess, formatTimestamp, formatMessageTimestamp, } from "../../utils/output.js"; @@ -103,7 +103,9 @@ export default class LogsSubscribe extends AblyBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - success(`Subscribed to app logs: ${resource(logTypes.join(", "))}.`), + formatSuccess( + `Subscribed to app logs: ${formatResource(logTypes.join(", "))}.`, + ), ); } @@ -150,7 +152,7 @@ export default class LogsSubscribe extends AblyBaseCommand { "Listening for log events. Press Ctrl+C to exit.", ); if (!this.shouldOutputJson(flags)) { - this.log(listening("Listening for log events.")); + this.log(formatListening("Listening for log events.")); } // Wait until the user interrupts or the optional duration elapses diff --git a/src/commands/queues/create.ts b/src/commands/queues/create.ts index b817fa13..3b31fb9c 100644 --- a/src/commands/queues/create.ts +++ b/src/commands/queues/create.ts @@ -2,7 +2,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { resource, success } from "../../utils/output.js"; +import { formatResource, formatSuccess } from "../../utils/output.js"; export default class QueuesCreateCommand extends ControlBaseCommand { static description = "Create a queue"; @@ -66,7 +66,9 @@ export default class QueuesCreateCommand extends ControlBaseCommand { ), ); } else { - this.log(success(`Queue created: ${resource(createdQueue.name)}.`)); + this.log( + formatSuccess(`Queue created: ${formatResource(createdQueue.name)}.`), + ); this.log(`Queue ID: ${createdQueue.id}`); this.log(`Name: ${createdQueue.name}`); this.log(`Region: ${createdQueue.region}`); diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index a2dd9da3..16334c11 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -2,7 +2,7 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { resource, success } from "../../utils/output.js"; +import { formatResource, formatSuccess } from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class QueuesDeleteCommand extends ControlBaseCommand { @@ -105,7 +105,9 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { ); } else { this.log( - success(`Queue deleted: ${resource(queue.name)} (${queue.id}).`), + formatSuccess( + `Queue deleted: ${formatResource(queue.name)} (${queue.id}).`, + ), ); } } catch (error) { diff --git a/src/commands/queues/list.ts b/src/commands/queues/list.ts index feebf744..59b7bfb6 100644 --- a/src/commands/queues/list.ts +++ b/src/commands/queues/list.ts @@ -1,8 +1,7 @@ import { Flags } from "@oclif/core"; -import chalk from "chalk"; - import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; +import { formatHeading } from "../../utils/output.js"; interface QueueStats { acknowledgementRate: null | number; @@ -106,7 +105,7 @@ export default class QueuesListCommand extends ControlBaseCommand { this.log(`Found ${queues.length} queues:\n`); queues.forEach((queue: Queue) => { - this.log(chalk.bold(`Queue ID: ${queue.id}`)); + this.log(formatHeading(`Queue ID: ${queue.id}`)); this.log(` Name: ${queue.name}`); this.log(` Region: ${queue.region}`); this.log(` State: ${queue.state}`); diff --git a/src/commands/rooms/list.ts b/src/commands/rooms/list.ts index 017ef052..12cb2b48 100644 --- a/src/commands/rooms/list.ts +++ b/src/commands/rooms/list.ts @@ -1,7 +1,11 @@ import { Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../chat-base-command.js"; import { productApiFlags } from "../../flags.js"; -import { countLabel, limitWarning, resource } from "../../utils/output.js"; +import { + formatCountLabel, + formatLimitWarning, + formatResource, +} from "../../utils/output.js"; import chalk from "chalk"; // Add interface definitions at the beginning of the file @@ -134,11 +138,11 @@ export default class RoomsList extends ChatBaseCommand { } this.log( - `Found ${countLabel(limitedRooms.length, "active chat room")}:`, + `Found ${formatCountLabel(limitedRooms.length, "active chat room")}:`, ); for (const room of limitedRooms) { - this.log(`${resource(room.room)}`); + this.log(`${formatResource(room.room)}`); // Show occupancy if available if (room.status?.occupancy?.metrics) { @@ -169,7 +173,11 @@ export default class RoomsList extends ChatBaseCommand { this.log(""); // Add a line break between rooms } - const warning = limitWarning(limitedRooms.length, flags.limit, "rooms"); + const warning = formatLimitWarning( + limitedRooms.length, + flags.limit, + "rooms", + ); if (warning) this.log(warning); } } catch (error) { diff --git a/src/commands/rooms/messages/history.ts b/src/commands/rooms/messages/history.ts index f669c02d..59ca5c4b 100644 --- a/src/commands/rooms/messages/history.ts +++ b/src/commands/rooms/messages/history.ts @@ -5,9 +5,9 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { productApiFlags, timeRangeFlags } from "../../../flags.js"; import { - progress, - success, - resource, + formatProgress, + formatSuccess, + formatResource, formatTimestamp, formatMessageTimestamp, } from "../../../utils/output.js"; @@ -90,8 +90,8 @@ export default class MessagesHistory extends ChatBaseCommand { ); } else { this.log( - progress( - `Fetching ${flags.limit} most recent messages from room ${resource(args.room)}`, + formatProgress( + `Fetching ${flags.limit} most recent messages from room ${formatResource(args.room)}`, ), ); } @@ -152,7 +152,7 @@ export default class MessagesHistory extends ChatBaseCommand { ); } else { // Display messages count - this.log(success(`Retrieved ${items.length} messages.`)); + this.log(formatSuccess(`Retrieved ${items.length} messages.`)); if (items.length === 0) { this.log(chalk.dim("No messages found in this room.")); diff --git a/src/commands/rooms/messages/reactions/remove.ts b/src/commands/rooms/messages/reactions/remove.ts index b2394e55..347422e3 100644 --- a/src/commands/rooms/messages/reactions/remove.ts +++ b/src/commands/rooms/messages/reactions/remove.ts @@ -3,7 +3,7 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; import { clientIdFlag, productApiFlags } from "../../../../flags.js"; -import { resource, success } from "../../../../utils/output.js"; +import { formatResource, formatSuccess } from "../../../../utils/output.js"; import { REACTION_TYPE_MAP } from "../../../../utils/chat-constants.js"; interface MessageReactionResult { @@ -129,8 +129,8 @@ export default class MessagesReactionsRemove extends ChatBaseCommand { this.log(this.formatJsonOutput(resultData, flags)); } else { this.log( - success( - `Removed reaction ${chalk.yellow(reaction)} from message ${resource(messageSerial)} in room ${resource(room)}.`, + formatSuccess( + `Removed reaction ${chalk.yellow(reaction)} from message ${formatResource(messageSerial)} in room ${formatResource(room)}.`, ), ); } diff --git a/src/commands/rooms/messages/reactions/send.ts b/src/commands/rooms/messages/reactions/send.ts index 357b9b7f..5187a965 100644 --- a/src/commands/rooms/messages/reactions/send.ts +++ b/src/commands/rooms/messages/reactions/send.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../../chat-base-command.js"; import { clientIdFlag, productApiFlags } from "../../../../flags.js"; -import { resource, success } from "../../../../utils/output.js"; +import { formatResource, formatSuccess } from "../../../../utils/output.js"; import { REACTION_TYPE_MAP } from "../../../../utils/chat-constants.js"; interface MessageReactionResult { @@ -170,8 +170,8 @@ export default class MessagesReactionsSend extends ChatBaseCommand { this.log(this.formatJsonOutput(resultData, flags)); } else { this.log( - success( - `Sent reaction ${chalk.yellow(reaction)} to message ${resource(messageSerial)} in room ${resource(room)}.`, + formatSuccess( + `Sent reaction ${chalk.yellow(reaction)} to message ${formatResource(messageSerial)} in room ${formatResource(room)}.`, ), ); } diff --git a/src/commands/rooms/messages/reactions/subscribe.ts b/src/commands/rooms/messages/reactions/subscribe.ts index 5ec8bf53..4f8da7d5 100644 --- a/src/commands/rooms/messages/reactions/subscribe.ts +++ b/src/commands/rooms/messages/reactions/subscribe.ts @@ -14,9 +14,11 @@ import { productApiFlags, } from "../../../../flags.js"; import { - progress, - resource, + formatProgress, + formatResource, formatTimestamp, + formatClientId, + formatEventType, } from "../../../../utils/output.js"; export default class MessagesReactionsSubscribe extends ChatBaseCommand { @@ -76,8 +78,8 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { ); if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Connecting to Ably and subscribing to message reactions in room ${resource(room)}`, + formatProgress( + `Connecting to Ably and subscribing to message reactions in room ${formatResource(room)}`, ), ); } @@ -106,7 +108,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { this.setupRoomStatusHandler(chatRoom, flags, { roomName: room, successMessage: "Connected to Ably.", - listeningMessage: `Listening for message reactions in room ${resource(room)}.`, + listeningMessage: `Listening for message reactions in room ${formatResource(room)}.`, }); // Attach to the room @@ -147,7 +149,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { ); } else { this.log( - `${formatTimestamp(timestamp)} ${chalk.green("⚡")} ${chalk.blue(event.reaction.clientId || "Unknown")} [${event.reaction.type}] ${event.type}: ${chalk.yellow(event.reaction.name || "unknown")} to message ${chalk.cyan(event.reaction.messageSerial)}`, + `${formatTimestamp(timestamp)} ${chalk.green("⚡")} ${formatClientId(event.reaction.clientId || "Unknown")} [${event.reaction.type}] ${event.type}: ${formatEventType(event.reaction.name || "unknown")} to message ${chalk.cyan(event.reaction.messageSerial)}`, ); } }, @@ -256,7 +258,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { ): void { for (const [reactionName, details] of Object.entries(summary)) { this.log( - ` ${chalk.yellow(reactionName)}: ${details.total} (${details.clientIds.join(", ")})`, + ` ${formatEventType(reactionName)}: ${details.total} (${details.clientIds.join(", ")})`, ); } } @@ -272,7 +274,7 @@ export default class MessagesReactionsSubscribe extends ChatBaseCommand { .map(([clientId, count]) => `${clientId}(${count})`) .join(", "); this.log( - ` ${chalk.yellow(reactionName)}: ${details.total} (${clientList})`, + ` ${formatEventType(reactionName)}: ${details.total} (${clientList})`, ); } } diff --git a/src/commands/rooms/messages/send.ts b/src/commands/rooms/messages/send.ts index 727411e5..7120dcac 100644 --- a/src/commands/rooms/messages/send.ts +++ b/src/commands/rooms/messages/send.ts @@ -5,7 +5,11 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { interpolateMessage } from "../../../utils/message.js"; -import { progress, success, resource } from "../../../utils/output.js"; +import { + formatProgress, + formatSuccess, + formatResource, +} from "../../../utils/output.js"; // Define interfaces for the message send command interface MessageToSend { @@ -185,7 +189,9 @@ export default class MessagesSend extends ChatBaseCommand { { count, delay }, ); if (count > 1 && !this.shouldOutputJson(flags)) { - this.log(progress(`Sending ${count} messages with ${delay}ms delay`)); + this.log( + formatProgress(`Sending ${count} messages with ${delay}ms delay`), + ); } // Track send progress @@ -337,8 +343,8 @@ export default class MessagesSend extends ChatBaseCommand { ); } this.log( - success( - `${sentCount}/${count} messages sent to room ${resource(args.room)} (${errorCount} errors).`, + formatSuccess( + `${sentCount}/${count} messages sent to room ${formatResource(args.room)} (${errorCount} errors).`, ), ); } @@ -379,7 +385,11 @@ export default class MessagesSend extends ChatBaseCommand { if (this.shouldOutputJson(flags)) { this.log(this.formatJsonOutput(result, flags)); } else { - this.log(success(`Message sent to room ${resource(args.room)}.`)); + this.log( + formatSuccess( + `Message sent to room ${formatResource(args.room)}.`, + ), + ); } } } catch (error) { diff --git a/src/commands/rooms/messages/subscribe.ts b/src/commands/rooms/messages/subscribe.ts index 21e0c421..9cc8f50c 100644 --- a/src/commands/rooms/messages/subscribe.ts +++ b/src/commands/rooms/messages/subscribe.ts @@ -5,10 +5,11 @@ import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { - progress, - resource, + formatProgress, + formatResource, formatTimestamp, formatMessageTimestamp, + formatIndex, } from "../../../utils/output.js"; // Define message interface @@ -129,7 +130,7 @@ export default class MessagesSubscribe extends ChatBaseCommand { this.roomNames.length > 1 ? `${chalk.magenta(`[${roomName}]`)} ` : ""; const sequencePrefix = flags["sequence-numbers"] - ? `${chalk.dim(`[${this.sequenceCounter}]`)}` + ? `${formatIndex(this.sequenceCounter)}` : ""; // Message content with consistent formatting @@ -156,7 +157,7 @@ export default class MessagesSubscribe extends ChatBaseCommand { this.setupRoomStatusHandler(room, flags, { roomName, - successMessage: `Subscribed to room: ${resource(roomName)}.`, + successMessage: `Subscribed to room: ${formatResource(roomName)}.`, listeningMessage: "Listening for messages.", }); @@ -223,12 +224,12 @@ export default class MessagesSubscribe extends ChatBaseCommand { const roomList = this.roomNames.length > 1 - ? this.roomNames.map((r) => resource(r)).join(", ") - : resource(this.roomNames[0]); + ? this.roomNames.map((r) => formatResource(r)).join(", ") + : formatResource(this.roomNames[0]); if (!this.shouldOutputJson(flags)) { this.log( - progress( + formatProgress( `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 7c5c1cec..aa76c477 100644 --- a/src/commands/rooms/occupancy/get.ts +++ b/src/commands/rooms/occupancy/get.ts @@ -2,7 +2,7 @@ import { Args } from "@oclif/core"; import { ChatClient, Room, OccupancyData } from "@ably/chat"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, productApiFlags } from "../../../flags.js"; -import { resource } from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class RoomsOccupancyGet extends ChatBaseCommand { static override args = { @@ -86,7 +86,7 @@ export default class RoomsOccupancyGet extends ChatBaseCommand { ), ); } else { - this.log(`Occupancy metrics for room ${resource(roomName)}:\n`); + this.log(`Occupancy metrics for room ${formatResource(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 603c5b5a..3e784e38 100644 --- a/src/commands/rooms/occupancy/subscribe.ts +++ b/src/commands/rooms/occupancy/subscribe.ts @@ -5,7 +5,11 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { errorMessage } from "../../../utils/errors.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; -import { progress, resource, formatTimestamp } from "../../../utils/output.js"; +import { + formatProgress, + formatResource, + formatTimestamp, +} from "../../../utils/output.js"; export interface OccupancyMetrics { connections?: number; @@ -50,7 +54,7 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { "Connecting to Ably...", ); if (!this.shouldOutputJson(flags)) { - this.log(progress("Connecting to Ably")); + this.log(formatProgress("Connecting to Ably")); } // Create Chat client @@ -86,7 +90,7 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { // Subscribe to room status changes this.setupRoomStatusHandler(room, flags, { roomName: this.roomName!, - successMessage: `Subscribed to occupancy in room: ${resource(this.roomName!)}.`, + successMessage: `Subscribed to occupancy in room: ${formatResource(this.roomName!)}.`, listeningMessage: "Listening for occupancy updates.", }); @@ -195,7 +199,7 @@ export default class RoomsOccupancySubscribe extends ChatBaseCommand { } else { const prefix = isInitial ? "Initial occupancy" : "Occupancy update"; this.log( - `${formatTimestamp(timestamp)} ${prefix} for room ${resource(roomName)}`, + `${formatTimestamp(timestamp)} ${prefix} for room ${formatResource(roomName)}`, ); // Type guard to handle both OccupancyMetrics and OccupancyEvent const connections = diff --git a/src/commands/rooms/presence/enter.ts b/src/commands/rooms/presence/enter.ts index 192297de..d7aa7fdf 100644 --- a/src/commands/rooms/presence/enter.ts +++ b/src/commands/rooms/presence/enter.ts @@ -4,11 +4,14 @@ import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { - success, - listening, - resource, + formatSuccess, + formatListening, + formatResource, formatTimestamp, formatPresenceAction, + formatIndex, + formatClientId, + formatLabel, } from "../../../utils/output.js"; export default class RoomsPresenceEnter extends ChatBaseCommand { @@ -97,7 +100,7 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { // Subscribe to room status changes only when showing others this.setupRoomStatusHandler(currentRoom, flags, { roomName: this.roomName, - successMessage: `Connected to room: ${resource(this.roomName)}.`, + successMessage: `Connected to room: ${formatResource(this.roomName)}.`, }); currentRoom.presence.subscribe((event: PresenceEvent) => { @@ -129,10 +132,10 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { const { symbol: actionSymbol, color: actionColor } = formatPresenceAction(event.type); const sequencePrefix = flags["sequence-numbers"] - ? `${chalk.dim(`[${this.sequenceCounter}]`)}` + ? `${formatIndex(this.sequenceCounter)}` : ""; this.log( - `${formatTimestamp(timestamp)}${sequencePrefix} ${actionColor(actionSymbol)} ${chalk.blue(member.clientId || "Unknown")} ${actionColor(event.type)}`, + `${formatTimestamp(timestamp)}${sequencePrefix} ${actionColor(actionSymbol)} ${formatClientId(member.clientId || "Unknown")} ${actionColor(event.type)}`, ); if ( member.data && @@ -141,10 +144,10 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { ) { const profile = member.data as { name?: string }; if (profile.name) { - this.log(` ${chalk.dim("Name:")} ${profile.name}`); + this.log(` ${formatLabel("Name")} ${profile.name}`); } this.log( - ` ${chalk.dim("Full Data:")} ${this.formatJsonOutput({ data: member.data }, flags)}`, + ` ${formatLabel("Full Data")} ${this.formatJsonOutput({ data: member.data }, flags)}`, ); } } @@ -161,12 +164,14 @@ export default class RoomsPresenceEnter extends ChatBaseCommand { if (!this.shouldOutputJson(flags) && this.roomName) { this.log( - success(`Entered presence in room: ${resource(this.roomName)}.`), + formatSuccess( + `Entered presence in room: ${formatResource(this.roomName)}.`, + ), ); if (flags["show-others"]) { - this.log(`\n${listening("Listening for presence events.")}`); + this.log(`\n${formatListening("Listening for presence events.")}`); } else { - this.log(`\n${listening("Staying present.")}`); + this.log(`\n${formatListening("Staying present.")}`); } } diff --git a/src/commands/rooms/presence/subscribe.ts b/src/commands/rooms/presence/subscribe.ts index eef17c82..a5c8761d 100644 --- a/src/commands/rooms/presence/subscribe.ts +++ b/src/commands/rooms/presence/subscribe.ts @@ -5,12 +5,14 @@ import chalk from "chalk"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { - progress, - success, - listening, - resource, + formatProgress, + formatSuccess, + formatListening, + formatResource, formatTimestamp, formatPresenceAction, + formatClientId, + formatLabel, } from "../../../utils/output.js"; export default class RoomsPresenceSubscribe extends ChatBaseCommand { @@ -51,8 +53,8 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { // Show a progress signal early so E2E harnesses know the command is running if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Subscribing to presence in room: ${resource(this.roomName!)}`, + formatProgress( + `Subscribing to presence in room: ${formatResource(this.roomName!)}`, ), ); } @@ -113,7 +115,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { this.setupRoomStatusHandler(currentRoom, flags, { roomName: this.roomName!, - successMessage: `Connected to room: ${resource(this.roomName!)}.`, + successMessage: `Connected to room: ${formatResource(this.roomName!)}.`, listeningMessage: undefined, }); @@ -121,8 +123,8 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { if (!this.shouldOutputJson(flags) && this.roomName) { this.log( - progress( - `Fetching current presence members for room ${resource(this.roomName)}`, + formatProgress( + `Fetching current presence members for room ${formatResource(this.roomName)}`, ), ); const members: PresenceMember[] = await currentRoom.presence.get(); @@ -135,7 +137,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { `\n${chalk.cyan("Current presence members")} (${chalk.bold(members.length.toString())}):\n`, ); for (const member of members) { - this.log(`- ${chalk.blue(member.clientId || "Unknown")}`); + this.log(`- ${formatClientId(member.clientId || "Unknown")}`); if ( member.data && typeof member.data === "object" && @@ -143,10 +145,10 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { ) { const profile = member.data as { name?: string }; if (profile.name) { - this.log(` ${chalk.dim("Name:")} ${profile.name}`); + this.log(` ${formatLabel("Name")} ${profile.name}`); } this.log( - ` ${chalk.dim("Full Profile Data:")} ${this.formatJsonOutput({ data: member.data }, flags)}`, + ` ${formatLabel("Full Profile Data")} ${this.formatJsonOutput({ data: member.data }, flags)}`, ); } } @@ -183,7 +185,7 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { const { symbol: actionSymbol, color: actionColor } = formatPresenceAction(event.type); this.log( - `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${chalk.blue(member.clientId || "Unknown")} ${actionColor(event.type)}`, + `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${formatClientId(member.clientId || "Unknown")} ${actionColor(event.type)}`, ); if ( member.data && @@ -192,10 +194,10 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { ) { const profile = member.data as { name?: string }; if (profile.name) { - this.log(` ${chalk.dim("Name:")} ${profile.name}`); + this.log(` ${formatLabel("Name")} ${profile.name}`); } this.log( - ` ${chalk.dim("Full Data:")} ${this.formatJsonOutput({ data: member.data }, flags)}`, + ` ${formatLabel("Full Data")} ${this.formatJsonOutput({ data: member.data }, flags)}`, ); } } @@ -209,11 +211,11 @@ export default class RoomsPresenceSubscribe extends ChatBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - success( - `Subscribed to presence in room: ${resource(this.roomName!)}.`, + formatSuccess( + `Subscribed to presence in room: ${formatResource(this.roomName!)}.`, ), ); - this.log(listening("Listening for presence events.")); + this.log(formatListening("Listening for presence events.")); } // Wait until the user interrupts or the optional duration elapses diff --git a/src/commands/rooms/reactions/send.ts b/src/commands/rooms/reactions/send.ts index b14bdc39..915666d8 100644 --- a/src/commands/rooms/reactions/send.ts +++ b/src/commands/rooms/reactions/send.ts @@ -4,7 +4,7 @@ import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { errorMessage } from "../../../utils/errors.js"; import { clientIdFlag, productApiFlags } from "../../../flags.js"; -import { resource, success } from "../../../utils/output.js"; +import { formatResource, formatSuccess } from "../../../utils/output.js"; export default class RoomsReactionsSend extends ChatBaseCommand { static override args = { @@ -150,7 +150,9 @@ export default class RoomsReactionsSend extends ChatBaseCommand { this.log(this.formatJsonOutput(resultData, flags)); } else { this.log( - success(`Sent reaction ${emoji} in room ${resource(roomName)}.`), + formatSuccess( + `Sent reaction ${emoji} in room ${formatResource(roomName)}.`, + ), ); } } catch (error) { diff --git a/src/commands/rooms/reactions/subscribe.ts b/src/commands/rooms/reactions/subscribe.ts index b4a3d087..0f47684f 100644 --- a/src/commands/rooms/reactions/subscribe.ts +++ b/src/commands/rooms/reactions/subscribe.ts @@ -4,7 +4,11 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; -import { progress, resource, formatTimestamp } from "../../../utils/output.js"; +import { + formatProgress, + formatResource, + formatTimestamp, +} from "../../../utils/output.js"; export default class RoomsReactionsSubscribe extends ChatBaseCommand { static override args = { @@ -57,8 +61,8 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { ); if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Connecting to Ably and subscribing to reactions in room ${resource(roomName)}`, + formatProgress( + `Connecting to Ably and subscribing to reactions in room ${formatResource(roomName)}`, ), ); } @@ -81,7 +85,7 @@ export default class RoomsReactionsSubscribe extends ChatBaseCommand { // Subscribe to room status changes this.setupRoomStatusHandler(room, flags, { roomName, - successMessage: `Subscribed to reactions in room: ${resource(roomName)}.`, + successMessage: `Subscribed to reactions in room: ${formatResource(roomName)}.`, listeningMessage: "Listening for reactions.", }); diff --git a/src/commands/rooms/typing/keystroke.ts b/src/commands/rooms/typing/keystroke.ts index 2d50d0d4..9699d852 100644 --- a/src/commands/rooms/typing/keystroke.ts +++ b/src/commands/rooms/typing/keystroke.ts @@ -3,7 +3,11 @@ import { Args, Flags } from "@oclif/core"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; -import { listening, resource, success } from "../../../utils/output.js"; +import { + formatListening, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; // The heartbeats are throttled to one every 10 seconds. There's a 2 second // leeway to send a keystroke/heartbeat after the 10 second mark so the @@ -106,7 +110,9 @@ export default class TypingKeystroke extends ChatBaseCommand { if (statusChange.current === RoomStatus.Attached) { if (!this.shouldOutputJson(flags)) { - this.log(success(`Connected to room: ${resource(roomName)}.`)); + this.log( + formatSuccess(`Connected to room: ${formatResource(roomName)}.`), + ); } // Start typing immediately @@ -122,17 +128,19 @@ export default class TypingKeystroke extends ChatBaseCommand { this.logCliEvent(flags, "typing", "started", "Started typing"); if (!this.shouldOutputJson(flags)) { this.log( - success(`Started typing in room: ${resource(roomName)}.`), + formatSuccess( + `Started typing in room: ${formatResource(roomName)}.`, + ), ); if (flags["auto-type"]) { this.log( - listening( + formatListening( "Will automatically remain typing until terminated.", ), ); } else { this.log( - listening( + formatListening( "Sent a single typing indicator. Use --auto-type to keep typing automatically.", ), ); diff --git a/src/commands/rooms/typing/subscribe.ts b/src/commands/rooms/typing/subscribe.ts index 75ec99f2..aa6121b2 100644 --- a/src/commands/rooms/typing/subscribe.ts +++ b/src/commands/rooms/typing/subscribe.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import { ChatBaseCommand } from "../../../chat-base-command.js"; import { clientIdFlag, durationFlag, productApiFlags } from "../../../flags.js"; -import { resource } from "../../../utils/output.js"; +import { formatResource } from "../../../utils/output.js"; export default class TypingSubscribe extends ChatBaseCommand { static override args = { @@ -68,7 +68,7 @@ export default class TypingSubscribe extends ChatBaseCommand { // Subscribe to room status changes this.setupRoomStatusHandler(room, flags, { roomName, - successMessage: `Subscribed to typing in room: ${resource(roomName)}.`, + successMessage: `Subscribed to typing in room: ${formatResource(roomName)}.`, listeningMessage: "Listening for typing indicators.", }); diff --git a/src/commands/spaces/cursors/get-all.ts b/src/commands/spaces/cursors/get-all.ts index 2b5fbd60..3e332df0 100644 --- a/src/commands/spaces/cursors/get-all.ts +++ b/src/commands/spaces/cursors/get-all.ts @@ -5,7 +5,12 @@ import { errorMessage } from "../../../utils/errors.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"; +import { + formatProgress, + formatSuccess, + formatResource, + formatClientId, +} from "../../../utils/output.js"; interface CursorPosition { x: number; @@ -52,7 +57,9 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { // Get the space if (!this.shouldOutputJson(flags)) { - this.log(progress(`Connecting to space: ${resource(spaceName)}`)); + this.log( + formatProgress(`Connecting to space: ${formatResource(spaceName)}`), + ); } // Enter the space @@ -83,7 +90,9 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { ), ); } else { - this.log(success(`Entered space: ${resource(spaceName)}.`)); + this.log( + formatSuccess(`Entered space: ${formatResource(spaceName)}.`), + ); } resolve(); @@ -139,7 +148,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { const y = cursor.position.y; this.log( - `${chalk.gray("►")} ${chalk.blue(clientDisplay)}: (${chalk.yellow(x)}, ${chalk.yellow(y)})`, + `${chalk.gray("►")} ${formatClientId(clientDisplay)}: (${chalk.yellow(x)}, ${chalk.yellow(y)})`, ); } } @@ -321,7 +330,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { this.log( chalk.gray("│ ") + - chalk.blue(clientId.padEnd(colWidths.client)) + + formatClientId(clientId.padEnd(colWidths.client)) + chalk.gray(" │ ") + chalk.yellow(x.padEnd(colWidths.x)) + chalk.gray(" │ ") + @@ -352,7 +361,7 @@ export default class SpacesCursorsGetAll extends SpacesBaseCommand { this.log(`\n${chalk.bold("Additional Data:")}`); cursorsWithData.forEach((cursor: CursorUpdate) => { this.log( - ` ${chalk.blue(cursor.clientId || "Unknown")}: ${JSON.stringify(cursor.data)}`, + ` ${formatClientId(cursor.clientId || "Unknown")}: ${JSON.stringify(cursor.data)}`, ); }); } diff --git a/src/commands/spaces/cursors/set.ts b/src/commands/spaces/cursors/set.ts index 8c73facc..e00e8d98 100644 --- a/src/commands/spaces/cursors/set.ts +++ b/src/commands/spaces/cursors/set.ts @@ -5,10 +5,10 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - listening, - progress, - resource, - success, + formatListening, + formatProgress, + formatResource, + formatSuccess, } from "../../../utils/output.js"; // Define cursor types based on Ably documentation @@ -202,8 +202,8 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { ); } else { this.log( - success( - `Set cursor in space ${resource(spaceName)} with data: ${chalk.blue(JSON.stringify(cursorForOutput))}.`, + formatSuccess( + `Set cursor in space ${formatResource(spaceName)} with data: ${chalk.blue(JSON.stringify(cursorForOutput))}.`, ), ); } @@ -228,7 +228,9 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { ); if (!this.shouldOutputJson(flags)) { - this.log(progress("Starting cursor movement simulation every 250ms")); + this.log( + formatProgress("Starting cursor movement simulation every 250ms"), + ); } this.simulationIntervalId = setInterval(async () => { @@ -283,7 +285,7 @@ export default class SpacesCursorsSet extends SpacesBaseCommand { this.log( flags.duration ? `Waiting ${flags.duration}s before exiting… Press Ctrl+C to exit sooner.` - : listening("Cursor set."), + : formatListening("Cursor set."), ); } diff --git a/src/commands/spaces/cursors/subscribe.ts b/src/commands/spaces/cursors/subscribe.ts index 0d9c73a7..a0b490bc 100644 --- a/src/commands/spaces/cursors/subscribe.ts +++ b/src/commands/spaces/cursors/subscribe.ts @@ -6,10 +6,12 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - listening, - resource, - success, + formatListening, + formatResource, + formatSuccess, formatTimestamp, + formatClientId, + formatLabel, } from "../../../utils/output.js"; export default class SpacesCursorsSubscribe extends SpacesBaseCommand { @@ -86,7 +88,7 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { ? ` data: ${JSON.stringify(cursorUpdate.data)}` : ""; this.log( - `${formatTimestamp(timestamp)} ${chalk.blue(cursorUpdate.clientId)} ${chalk.dim("position:")} ${JSON.stringify(cursorUpdate.position)}${dataString}`, + `${formatTimestamp(timestamp)} ${formatClientId(cursorUpdate.clientId)} ${formatLabel("position")} ${JSON.stringify(cursorUpdate.position)}${dataString}`, ); } } catch (error) { @@ -157,8 +159,10 @@ export default class SpacesCursorsSubscribe extends SpacesBaseCommand { // Print success message if (!this.shouldOutputJson(flags)) { - this.log(success(`Subscribed to space: ${resource(spaceName)}.`)); - this.log(listening("Listening for cursor movements.")); + this.log( + formatSuccess(`Subscribed to space: ${formatResource(spaceName)}.`), + ); + this.log(formatListening("Listening for cursor movements.")); } // Wait until the user interrupts or the optional duration elapses diff --git a/src/commands/spaces/list.ts b/src/commands/spaces/list.ts index 232cd298..70e8e63b 100644 --- a/src/commands/spaces/list.ts +++ b/src/commands/spaces/list.ts @@ -3,7 +3,7 @@ import chalk from "chalk"; import { errorMessage } from "../../utils/errors.js"; import { productApiFlags } from "../../flags.js"; -import { countLabel, limitWarning } from "../../utils/output.js"; +import { formatCountLabel, formatLimitWarning } from "../../utils/output.js"; import { SpacesBaseCommand } from "../../spaces-base-command.js"; interface SpaceMetrics { @@ -146,7 +146,9 @@ export default class SpacesList extends SpacesBaseCommand { return; } - this.log(`Found ${countLabel(limitedSpaces.length, "active space")}:`); + this.log( + `Found ${formatCountLabel(limitedSpaces.length, "active space")}:`, + ); limitedSpaces.forEach((space: SpaceItem) => { this.log(`${chalk.green(space.spaceName)}`); @@ -180,7 +182,7 @@ export default class SpacesList extends SpacesBaseCommand { this.log(""); // Add a line break between spaces }); - const warning = limitWarning( + const warning = formatLimitWarning( limitedSpaces.length, flags.limit, "spaces", diff --git a/src/commands/spaces/locations/get-all.ts b/src/commands/spaces/locations/get-all.ts index d7a5e702..60f10f27 100644 --- a/src/commands/spaces/locations/get-all.ts +++ b/src/commands/spaces/locations/get-all.ts @@ -4,7 +4,13 @@ import chalk from "chalk"; import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { progress, resource, success } from "../../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, + formatLabel, + formatClientId, +} from "../../../utils/output.js"; interface LocationData { [key: string]: unknown; @@ -69,7 +75,9 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { }); if (!this.shouldOutputJson(flags)) { - this.log(progress(`Connecting to space: ${resource(spaceName)}`)); + this.log( + formatProgress(`Connecting to space: ${formatResource(spaceName)}`), + ); } await this.space!.enter(); @@ -85,7 +93,9 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { clearTimeout(timeout); if (!this.shouldOutputJson(flags)) { this.log( - success(`Connected to space: ${resource(spaceName)}.`), + formatSuccess( + `Connected to space: ${formatResource(spaceName)}.`, + ), ); } @@ -115,7 +125,9 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { if (!this.shouldOutputJson(flags)) { this.log( - progress(`Fetching locations for space ${resource(spaceName)}`), + formatProgress( + `Fetching locations for space ${formatResource(spaceName)}`, + ), ); } @@ -234,10 +246,10 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { const locationData = extractLocationData(location); this.log( - `- ${chalk.blue(member.memberId || member.clientId)}:`, + `- ${formatClientId(member.memberId || member.clientId || "Unknown")}:`, ); this.log( - ` ${chalk.dim("Location:")} ${JSON.stringify(locationData, null, 2)}`, + ` ${formatLabel("Location")} ${JSON.stringify(locationData, null, 2)}`, ); if (member.isCurrentMember) { @@ -250,9 +262,9 @@ export default class SpacesLocationsGetAll extends SpacesBaseCommand { } } else { // Simpler display if location doesn't have expected structure - this.log(`- ${chalk.blue("Member")}:`); + this.log(`- ${formatClientId("Member")}:`); this.log( - ` ${chalk.dim("Location:")} ${JSON.stringify(location, null, 2)}`, + ` ${formatLabel("Location")} ${JSON.stringify(location, null, 2)}`, ); } } diff --git a/src/commands/spaces/locations/set.ts b/src/commands/spaces/locations/set.ts index e6cf5977..d66f634a 100644 --- a/src/commands/spaces/locations/set.ts +++ b/src/commands/spaces/locations/set.ts @@ -6,10 +6,11 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - success, - listening, - resource, + formatSuccess, + formatListening, + formatResource, formatTimestamp, + formatClientId, } from "../../../utils/output.js"; // Define the type for location subscription @@ -139,7 +140,11 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { ), ); } else { - this.log(success(`Location set in space: ${resource(spaceName)}.`)); + this.log( + formatSuccess( + `Location set in space: ${formatResource(spaceName)}.`, + ), + ); } } catch { // If an error occurs in E2E mode, just exit cleanly after showing what we can @@ -171,7 +176,9 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { location, }); if (!this.shouldOutputJson(flags)) { - this.log(success(`Location set in space: ${resource(spaceName)}.`)); + this.log( + formatSuccess(`Location set in space: ${formatResource(spaceName)}.`), + ); } // Subscribe to location updates from other users @@ -182,7 +189,9 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { "Watching for other location changes...", ); if (!this.shouldOutputJson(flags)) { - this.log(`\n${listening("Watching for other location changes.")}\n`); + this.log( + `\n${formatListening("Watching for other location changes.")}\n`, + ); } // Store subscription handlers @@ -225,7 +234,7 @@ export default class SpacesLocationsSet extends SpacesBaseCommand { const action = "update"; this.log( - `${formatTimestamp(timestamp)} ${chalk.blue(member.clientId || "Unknown")} ${actionColor(action)}d location:`, + `${formatTimestamp(timestamp)} ${formatClientId(member.clientId || "Unknown")} ${actionColor(action)}d location:`, ); this.log( ` ${chalk.dim("Location:")} ${JSON.stringify(currentLocation, null, 2)}`, diff --git a/src/commands/spaces/locations/subscribe.ts b/src/commands/spaces/locations/subscribe.ts index 8d0352af..93fb9897 100644 --- a/src/commands/spaces/locations/subscribe.ts +++ b/src/commands/spaces/locations/subscribe.ts @@ -6,10 +6,12 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - listening, - progress, - resource, + formatListening, + formatProgress, + formatResource, formatTimestamp, + formatClientId, + formatEventType, } from "../../../utils/output.js"; // Define interfaces for location types @@ -88,8 +90,8 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { ); if (!this.shouldOutputJson(flags)) { this.log( - progress( - `Fetching current locations for space ${resource(spaceName)}`, + formatProgress( + `Fetching current locations for space ${formatResource(spaceName)}`, ), ); } @@ -195,7 +197,7 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { "Subscribing to location updates", ); if (!this.shouldOutputJson(flags)) { - this.log(listening("Subscribing to location updates.")); + this.log(formatListening("Subscribing to location updates.")); } this.logCliEvent( flags, @@ -241,7 +243,7 @@ export default class SpacesLocationsSubscribe extends SpacesBaseCommand { ); } else { this.log( - `${formatTimestamp(timestamp)} ${chalk.blue(update.member.clientId)} ${chalk.yellow("updated")} location:`, + `${formatTimestamp(timestamp)} ${formatClientId(update.member.clientId)} ${formatEventType("updated")} location:`, ); this.log( ` ${chalk.dim("Current:")} ${JSON.stringify(update.currentLocation)}`, diff --git a/src/commands/spaces/locks/acquire.ts b/src/commands/spaces/locks/acquire.ts index d359f0c6..8f777e1b 100644 --- a/src/commands/spaces/locks/acquire.ts +++ b/src/commands/spaces/locks/acquire.ts @@ -5,7 +5,11 @@ import chalk from "chalk"; import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { success, listening, resource } from "../../../utils/output.js"; +import { + formatSuccess, + formatListening, + formatResource, +} from "../../../utils/output.js"; export default class SpacesLocksAcquire extends SpacesBaseCommand { static override args = { @@ -130,11 +134,11 @@ export default class SpacesLocksAcquire extends SpacesBaseCommand { this.formatJsonOutput({ lock: lockDetails, success: true }, flags), ); } else { - this.log(success(`Lock acquired: ${resource(lockId)}.`)); + this.log(formatSuccess(`Lock acquired: ${formatResource(lockId)}.`)); this.log( `${chalk.dim("Lock details:")} ${this.formatJsonOutput(lockDetails, { ...flags, "pretty-json": true })}`, ); - this.log(`\n${listening("Holding lock.")}`); + this.log(`\n${formatListening("Holding lock.")}`); } } catch (error) { const errorMsg = `Failed to acquire lock: ${errorMessage(error)}`; diff --git a/src/commands/spaces/locks/get-all.ts b/src/commands/spaces/locks/get-all.ts index ea9e0ca5..2b9f0660 100644 --- a/src/commands/spaces/locks/get-all.ts +++ b/src/commands/spaces/locks/get-all.ts @@ -4,7 +4,12 @@ import chalk from "chalk"; import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { progress, resource, success } from "../../../utils/output.js"; +import { + formatProgress, + formatResource, + formatSuccess, + formatLabel, +} from "../../../utils/output.js"; interface LockItem { attributes?: Record; @@ -48,7 +53,9 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { // Get the space if (!this.shouldOutputJson(flags)) { - this.log(progress(`Connecting to space: ${resource(spaceName)}`)); + this.log( + formatProgress(`Connecting to space: ${formatResource(spaceName)}`), + ); } await this.space!.enter(); @@ -65,7 +72,9 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { clearTimeout(timeout); if (!this.shouldOutputJson(flags)) { this.log( - success(`Connected to space: ${resource(spaceName)}.`), + formatSuccess( + `Connected to space: ${formatResource(spaceName)}.`, + ), ); } @@ -95,7 +104,11 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { // Get all locks if (!this.shouldOutputJson(flags)) { - this.log(progress(`Fetching locks for space ${resource(spaceName)}`)); + this.log( + formatProgress( + `Fetching locks for space ${formatResource(spaceName)}`, + ), + ); } let locks: LockItem[] = []; @@ -135,7 +148,7 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { validLocks.forEach((lock: LockItem) => { try { this.log(`- ${chalk.blue(lock.id)}:`); - this.log(` ${chalk.dim("Status:")} ${lock.status || "unknown"}`); + this.log(` ${formatLabel("Status")} ${lock.status || "unknown"}`); this.log( ` ${chalk.dim("Holder:")} ${lock.member?.clientId || "None"}`, ); diff --git a/src/commands/spaces/locks/get.ts b/src/commands/spaces/locks/get.ts index 1f9ea415..2ac36ee3 100644 --- a/src/commands/spaces/locks/get.ts +++ b/src/commands/spaces/locks/get.ts @@ -4,7 +4,7 @@ import chalk from "chalk"; import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; -import { resource, success } from "../../../utils/output.js"; +import { formatResource, formatSuccess } from "../../../utils/output.js"; export default class SpacesLocksGet extends SpacesBaseCommand { static override args = { @@ -44,7 +44,7 @@ export default class SpacesLocksGet extends SpacesBaseCommand { await this.space!.enter(); if (!this.shouldOutputJson(flags)) { - this.log(success(`Entered space: ${resource(spaceName)}.`)); + this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); } try { @@ -58,7 +58,7 @@ export default class SpacesLocksGet extends SpacesBaseCommand { } else { this.log( chalk.yellow( - `Lock ${resource(lockId)} not found in space ${resource(spaceName)}`, + `Lock ${formatResource(lockId)} not found in space ${formatResource(spaceName)}`, ), ); } diff --git a/src/commands/spaces/locks/subscribe.ts b/src/commands/spaces/locks/subscribe.ts index c280fe87..9f3741e8 100644 --- a/src/commands/spaces/locks/subscribe.ts +++ b/src/commands/spaces/locks/subscribe.ts @@ -6,10 +6,11 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - listening, - progress, - resource, + formatListening, + formatProgress, + formatResource, formatTimestamp, + formatLabel, } from "../../../utils/output.js"; export default class SpacesLocksSubscribe extends SpacesBaseCommand { @@ -62,7 +63,9 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { await this.initializeSpace(flags, spaceName, { enterSpace: true }); if (!this.shouldOutputJson(flags)) { - this.log(progress(`Connecting to space: ${resource(spaceName)}`)); + this.log( + formatProgress(`Connecting to space: ${formatResource(spaceName)}`), + ); } // Get current locks @@ -74,7 +77,9 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { ); if (!this.shouldOutputJson(flags)) { this.log( - progress(`Fetching current locks for space ${resource(spaceName)}`), + formatProgress( + `Fetching current locks for space ${formatResource(spaceName)}`, + ), ); } @@ -117,7 +122,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { for (const lock of locks) { this.log(`- Lock ID: ${chalk.blue(lock.id)}`); - this.log(` ${chalk.dim("Status:")} ${lock.status}`); + this.log(` ${formatLabel("Status")} ${lock.status}`); this.log( ` ${chalk.dim("Member:")} ${lock.member?.clientId || "Unknown"}`, ); @@ -138,7 +143,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { "Subscribing to lock events", ); if (!this.shouldOutputJson(flags)) { - this.log(listening("Subscribing to lock events.")); + this.log(formatListening("Subscribing to lock events.")); } this.logCliEvent( flags, @@ -178,7 +183,7 @@ export default class SpacesLocksSubscribe extends SpacesBaseCommand { this.log( `${formatTimestamp(timestamp)} Lock ${chalk.blue(lock.id)} updated`, ); - this.log(` ${chalk.dim("Status:")} ${lock.status}`); + this.log(` ${formatLabel("Status")} ${lock.status}`); this.log( ` ${chalk.dim("Member:")} ${lock.member?.clientId || "Unknown"}`, ); diff --git a/src/commands/spaces/members/enter.ts b/src/commands/spaces/members/enter.ts index 485f14a7..da96d2fb 100644 --- a/src/commands/spaces/members/enter.ts +++ b/src/commands/spaces/members/enter.ts @@ -6,11 +6,12 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - success, - listening, - resource, + formatSuccess, + formatListening, + formatResource, formatTimestamp, formatPresenceAction, + formatClientId, } from "../../../utils/output.js"; export default class SpacesMembersEnter extends SpacesBaseCommand { @@ -54,7 +55,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { try { // Always show the readiness signal first, before attempting auth if (!this.shouldOutputJson(flags)) { - this.log(listening("Entering space.")); + this.log(formatListening("Entering space.")); } await this.initializeSpace(flags, spaceName, { enterSpace: false }); @@ -102,7 +103,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { this.formatJsonOutput({ success: true, ...enteredEventData }, flags), ); } else { - this.log(success(`Entered space: ${resource(spaceName)}.`)); + this.log(formatSuccess(`Entered space: ${formatResource(spaceName)}.`)); if (profileData) { this.log( `${chalk.dim("Profile:")} ${JSON.stringify(profileData, null, 2)}`, @@ -126,7 +127,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { "Subscribing to member updates", ); if (!this.shouldOutputJson(flags)) { - this.log(`\n${listening("Watching for other members.")}\n`); + this.log(`\n${formatListening("Watching for other members.")}\n`); } // Define the listener function @@ -202,7 +203,7 @@ export default class SpacesMembersEnter extends SpacesBaseCommand { formatPresenceAction(action); this.log( - `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${chalk.blue(clientId)} ${actionColor(action)}`, + `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${formatClientId(clientId)} ${actionColor(action)}`, ); const hasProfileData = diff --git a/src/commands/spaces/members/subscribe.ts b/src/commands/spaces/members/subscribe.ts index d0ab23d0..2112abd0 100644 --- a/src/commands/spaces/members/subscribe.ts +++ b/src/commands/spaces/members/subscribe.ts @@ -6,10 +6,11 @@ import { errorMessage } from "../../../utils/errors.js"; import { productApiFlags, clientIdFlag, durationFlag } from "../../../flags.js"; import { SpacesBaseCommand } from "../../../spaces-base-command.js"; import { - listening, - progress, + formatListening, + formatProgress, formatTimestamp, formatPresenceAction, + formatClientId, } from "../../../utils/output.js"; export default class SpacesMembersSubscribe extends SpacesBaseCommand { @@ -51,7 +52,7 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { try { // Always show the readiness signal first, before attempting auth if (!this.shouldOutputJson(flags)) { - this.log(progress("Subscribing to member updates")); + this.log(formatProgress("Subscribing to member updates")); } await this.initializeSpace(flags, spaceName, { enterSpace: true }); @@ -103,7 +104,7 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { ); for (const member of members) { - this.log(`- ${chalk.blue(member.clientId || "Unknown")}`); + this.log(`- ${formatClientId(member.clientId || "Unknown")}`); if ( member.profileData && @@ -125,7 +126,7 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { } if (!this.shouldOutputJson(flags)) { - this.log(`\n${listening("Listening for member events.")}\n`); + this.log(`\n${formatListening("Listening for member events.")}\n`); } // Subscribe to member presence events @@ -207,7 +208,7 @@ export default class SpacesMembersSubscribe extends SpacesBaseCommand { formatPresenceAction(action); this.log( - `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${chalk.blue(clientId)} ${actionColor(action)}`, + `${formatTimestamp(timestamp)} ${actionColor(actionSymbol)} ${formatClientId(clientId)} ${actionColor(action)}`, ); if ( diff --git a/src/commands/stats/account.ts b/src/commands/stats/account.ts index 9b1809ca..e0579cf7 100644 --- a/src/commands/stats/account.ts +++ b/src/commands/stats/account.ts @@ -2,7 +2,7 @@ import { StatsBaseCommand } from "../../stats-base-command.js"; import type { StatsDisplayData } from "../../services/stats-display.js"; import type { BaseFlags } from "../../types/cli.js"; import type { ControlApi } from "../../services/control-api.js"; -import { resource } from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class StatsAccountCommand extends StatsBaseCommand { static description = "Get account stats with optional live updates"; @@ -40,7 +40,7 @@ export default class StatsAccountCommand extends StatsBaseCommand { ): Promise { if (!this.accountLabel) { const { account } = await controlApi.getMe(); - this.accountLabel = `account ${resource(account.name)} (${account.id})`; + this.accountLabel = `account ${formatResource(account.name)} (${account.id})`; } return this.accountLabel; diff --git a/src/commands/stats/app.ts b/src/commands/stats/app.ts index 7f688790..7e3bfc73 100644 --- a/src/commands/stats/app.ts +++ b/src/commands/stats/app.ts @@ -3,7 +3,7 @@ import { Args } from "@oclif/core"; import { StatsBaseCommand } from "../../stats-base-command.js"; import type { StatsDisplayData } from "../../services/stats-display.js"; import type { ControlApi } from "../../services/control-api.js"; -import { resource } from "../../utils/output.js"; +import { formatResource } from "../../utils/output.js"; export default class StatsAppCommand extends StatsBaseCommand { static args = { @@ -42,7 +42,7 @@ export default class StatsAppCommand extends StatsBaseCommand { } protected async getStatsLabel(): Promise { - return `app ${resource(this.appId)}`; + return `app ${formatResource(this.appId)}`; } async run(): Promise { diff --git a/src/commands/status.ts b/src/commands/status.ts index b93b9ce4..8cd158ea 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -3,7 +3,7 @@ import chalk from "chalk"; import fetch from "node-fetch"; import ora from "ora"; import openUrl from "../utils/open-url.js"; -import { progress, success } from "../utils/output.js"; +import { formatProgress, formatSuccess } from "../utils/output.js"; import { getCliVersion } from "../utils/version.js"; interface StatusResponse { @@ -32,7 +32,7 @@ export default class StatusCommand extends Command { ? null : ora("Checking Ably service status...").start(); if (isInteractive) { - this.log(progress("Checking Ably service status")); + this.log(formatProgress("Checking Ably service status")); } try { @@ -49,7 +49,9 @@ export default class StatusCommand extends Command { "Invalid response from status endpoint: status attribute is missing", ); } else if (data.status) { - this.log(success(`Ably services are ${chalk.green("operational")}.`)); + this.log( + formatSuccess(`Ably services are ${chalk.green("operational")}.`), + ); this.log("No incidents currently reported"); } else { this.log( diff --git a/src/stats-base-command.ts b/src/stats-base-command.ts index 13036852..64bca9ff 100644 --- a/src/stats-base-command.ts +++ b/src/stats-base-command.ts @@ -7,7 +7,7 @@ import { StatsDisplay, StatsDisplayData } from "./services/stats-display.js"; import type { BaseFlags } from "./types/cli.js"; import type { ControlApi } from "./services/control-api.js"; import { errorMessage } from "./utils/errors.js"; -import { progress } from "./utils/output.js"; +import { formatProgress } from "./utils/output.js"; import { parseTimestamp } from "./utils/time.js"; export abstract class StatsBaseCommand extends ControlBaseCommand { @@ -136,7 +136,7 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { const label = await this.getStatsLabel(flags, controlApi); - this.log(progress(`Subscribing to live stats for ${label}`)); + this.log(formatProgress(`Subscribing to live stats for ${label}`)); } const cleanup = () => { @@ -187,7 +187,7 @@ export abstract class StatsBaseCommand extends ControlBaseCommand { try { if (!this.shouldOutputJson(flags)) { const label = await this.getStatsLabel(flags, controlApi); - this.log(progress(`Fetching stats for ${label}`)); + this.log(formatProgress(`Fetching stats for ${label}`)); } let startMs: number | undefined; From 52cfa6635ba53badd668f6211edf1b8fe35684cf Mon Sep 17 00:00:00 2001 From: umair Date: Mon, 9 Mar 2026 16:20:04 +0000 Subject: [PATCH 5/7] Fix flaky test --- test/e2e/web-cli/reconnection.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/e2e/web-cli/reconnection.test.ts b/test/e2e/web-cli/reconnection.test.ts index d99eb12b..af42f88c 100644 --- a/test/e2e/web-cli/reconnection.test.ts +++ b/test/e2e/web-cli/reconnection.test.ts @@ -16,6 +16,7 @@ import { waitForSessionActive, waitForTerminalStable, executeCommandWithRetry, + getTerminalContent, } from "./wait-helpers.js"; import { waitForRateLimitLock } from "./rate-limit-lock"; import { createSignedConfig } from "./helpers/signing-helper"; @@ -79,7 +80,7 @@ async function _waitForPrompt( console.log("--- Terminal Debug Info ---"); console.log("Debug state:", JSON.stringify(debugInfo, null, 2)); - const terminalContent = await page.locator(terminalSelector).textContent(); + const terminalContent = await getTerminalContent(page); console.log( "Terminal content:", terminalContent?.slice(0, 500) || "No content", @@ -275,7 +276,7 @@ test.describe("Web CLI Reconnection E2E Tests", () => { await executeCommandWithRetry(page, "help", "COMMON COMMANDS"); // 11. Verify session continuity - should see both commands - const terminalText = await page.locator(terminalSelector).textContent(); + const terminalText = await getTerminalContent(page); expect(terminalText).toContain("browser-based interactive CLI"); expect(terminalText).toContain("COMMON COMMANDS"); @@ -464,7 +465,7 @@ test.describe("Web CLI Reconnection E2E Tests", () => { await executeCommandWithRetry(page, "help", "COMMON COMMANDS"); // Session should be maintained - const terminalText = await page.locator(terminalSelector).textContent(); + const terminalText = await getTerminalContent(page); expect(terminalText).toContain("browser-based interactive CLI"); expect(terminalText).toContain("COMMON COMMANDS"); }); @@ -545,7 +546,7 @@ test.describe("Web CLI Reconnection E2E Tests", () => { await waitForTerminalStable(page, 500); // Check the terminal content - const terminalText = await page.locator(terminalSelector).textContent(); + const terminalText = await getTerminalContent(page); console.log( "Terminal content during reconnection:", terminalText?.slice(0, 500), From 27aa28bc7f81ed107f9ad4f37aed9cd1fc73afc9 Mon Sep 17 00:00:00 2001 From: umair Date: Mon, 9 Mar 2026 18:43:29 +0000 Subject: [PATCH 6/7] PR comments --- .claude/skills/ably-new-command/SKILL.md | 11 +- .../ably-new-command/references/patterns.md | 9 + .../ably-new-command/references/testing.md | 9 +- src/commands/apps/create.ts | 15 +- src/commands/apps/delete.ts | 16 +- src/commands/apps/set-apns-p12.ts | 7 +- src/commands/apps/update.ts | 22 +- src/commands/auth/keys/create.ts | 11 +- src/commands/connections/test.ts | 17 +- src/commands/integrations/create.ts | 24 +- src/commands/integrations/delete.ts | 24 +- src/commands/integrations/get.ts | 26 +- src/commands/queues/create.ts | 32 ++- src/commands/queues/delete.ts | 14 +- src/commands/spaces/locks/get-all.ts | 4 +- test/unit/utils/output.test.ts | 231 ++++++++++++++++++ 16 files changed, 373 insertions(+), 99 deletions(-) create mode 100644 test/unit/utils/output.test.ts diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index cd216134..ba5540b5 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -1,6 +1,6 @@ --- name: ably-new-command -description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a new subcommand, migrating/moving a command to a new group, or scaffolding a command with its test file. Triggers on: 'new command', 'add command', 'create command', 'scaffold command', 'add subcommand', 'implement command', or any request to build a new `ably ` command. IMPORTANT: This skill MUST be used any time the user wants to create, build, or implement ANY new CLI command or subcommand — even if they describe it casually (e.g., 'I need an ably X Y command', 'can you build ably rooms typing subscribe', 'we should add a purge command to queues'). Also use when moving or restructuring existing commands to new locations. Do NOT use for modifying existing commands, fixing bugs, debugging, adding tests to existing commands, or refactoring — only for creating net-new command files." +description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use when creating, adding, or migrating any command or subcommand." --- # Ably CLI New Command @@ -213,13 +213,8 @@ Rules: ### Error handling -Use these patterns for error handling in commands: +**Always use `handleCommandError` in catch blocks** — it's the single canonical pattern for error handling in commands. It logs the CLI event, emits JSON error when `--json` is active, and calls `this.error()` for human-readable output. -- **`this.error(message)`** — Fatal errors (oclif standard). Throws, so no `return` needed after it. -- **`this.handleCommandError(error, flags, component, context?)`** — Use in catch blocks. Logs the CLI event, emits JSON error when `--json` is active, and calls `this.error()` for human-readable output. -- **`this.jsonError(data, flags)`** — JSON-specific error output for non-standard error flows. - -Catch block template: ```typescript try { // command logic @@ -243,6 +238,8 @@ try { } ``` +**`this.jsonError(data, flags)`** exists as an escape hatch for non-standard error flows where `handleCommandError` doesn't fit (e.g., `set-apns-p12.ts` where the error format differs). New commands should not need it — use `handleCommandError` instead. Existing uses of `jsonError` should be migrated to `handleCommandError` over time. + ### Pattern-specific implementation Read `references/patterns.md` for the full implementation template matching your pattern (Subscribe, Publish/Send, History, Enter/Presence, List, CRUD/Control API). Each template includes the correct flags, `run()` method structure, and output conventions. diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index 43ac130f..fce6ccf6 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -38,6 +38,8 @@ async run(): Promise { this.configureRewind(channelOptions, flags.rewind, flags, "MySubscribe", args.channel); const channel = client.channels.get(args.channel, channelOptions); + // Shared helper that monitors channel state changes and logs them (verbose mode). + // Returns a cleanup function, but cleanup is handled automatically by base command. this.setupChannelStateLogging(channel, flags); if (!this.shouldOutputJson(flags)) { @@ -115,6 +117,13 @@ async run(): Promise { For multi-message publish or realtime transport, see `src/commands/channels/publish.ts` as a reference. +**When to use Realtime instead of REST for publishing:** +- When publishing multiple messages with a count/repeat loop (continuous publishing with delays between messages) +- When the command also subscribes to the same channel (publish + subscribe in one command) +- When the command needs to maintain a persistent connection for other reasons + +For single-shot publish, REST is preferred (simpler, no connection overhead). See `src/commands/channels/publish.ts` which supports both via a `--transport` flag. + --- ## History Pattern diff --git a/.claude/skills/ably-new-command/references/testing.md b/.claude/skills/ably-new-command/references/testing.md index b91e6090..f71bd6ca 100644 --- a/.claude/skills/ably-new-command/references/testing.md +++ b/.claude/skills/ably-new-command/references/testing.md @@ -183,13 +183,12 @@ import { resetTestTracking, } from "../../helpers/e2e-test-helper.js"; -describe("topic:action E2E", { timeout: 60_000 }, () => { +describe.skipIf(SHOULD_SKIP_E2E)("topic:action E2E", { timeout: 60_000 }, () => { let subscribeChannel: string; let outputPath: string; let subscribeProcessInfo: { pid: number; outputPath: string } | null = null; beforeAll(() => { - if (SHOULD_SKIP_E2E) return; const handler = () => { cleanupTrackedResources(); process.exit(1); }; process.on("SIGINT", handler); return () => { process.removeListener("SIGINT", handler); }; @@ -212,7 +211,7 @@ describe("topic:action E2E", { timeout: 60_000 }, () => { await cleanupTrackedResources(); }); - it.skipIf(SHOULD_SKIP_E2E)("should subscribe and receive messages", async () => { + it("should subscribe and receive messages", async () => { setupTestFailureHandler("topic:action subscribe"); subscribeProcessInfo = await runLongRunningBackgroundProcess( @@ -240,14 +239,14 @@ import { cleanupTrackedResources, } from "../../helpers/e2e-test-helper.js"; -describe("topic:action CRUD E2E", { timeout: 30_000 }, () => { +describe.skipIf(SHOULD_SKIP_E2E)("topic:action CRUD E2E", { timeout: 30_000 }, () => { const cliPath = "./bin/run.js"; afterAll(async () => { await cleanupTrackedResources(); }); - it.skipIf(SHOULD_SKIP_E2E)("should create and list resources", () => { + it("should create and list resources", () => { const createOutput = execSync( `node ${cliPath} topic:action create --name test-resource`, { env: { ...process.env, ABLY_ACCESS_TOKEN: process.env.ABLY_ACCESS_TOKEN! } }, diff --git a/src/commands/apps/create.ts b/src/commands/apps/create.ts index 8e0720c9..3532991d 100644 --- a/src/commands/apps/create.ts +++ b/src/commands/apps/create.ts @@ -3,6 +3,7 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; import { + formatLabel, formatProgress, formatResource, formatSuccess, @@ -69,13 +70,13 @@ export default class AppsCreateCommand extends ControlBaseCommand { `App created: ${formatResource(app.name)} (${app.id}).`, ), ); - this.log(`App ID: ${app.id}`); - this.log(`Name: ${app.name}`); - this.log(`Status: ${app.status}`); - this.log(`Account ID: ${app.accountId}`); - this.log(`TLS Only: ${app.tlsOnly ? "Yes" : "No"}`); - this.log(`Created: ${this.formatDate(app.created)}`); - this.log(`Updated: ${this.formatDate(app.modified)}`); + this.log(`${formatLabel("App ID")} ${app.id}`); + this.log(`${formatLabel("Name")} ${app.name}`); + this.log(`${formatLabel("Status")} ${app.status}`); + this.log(`${formatLabel("Account ID")} ${app.accountId}`); + this.log(`${formatLabel("TLS Only")} ${app.tlsOnly ? "Yes" : "No"}`); + this.log(`${formatLabel("Created")} ${this.formatDate(app.created)}`); + this.log(`${formatLabel("Updated")} ${this.formatDate(app.modified)}`); } // Automatically switch to the newly created app diff --git a/src/commands/apps/delete.ts b/src/commands/apps/delete.ts index d342adfc..1da3b724 100644 --- a/src/commands/apps/delete.ts +++ b/src/commands/apps/delete.ts @@ -3,7 +3,11 @@ import * as readline from "node:readline"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatProgress, formatResource } from "../../utils/output.js"; +import { + formatLabel, + formatProgress, + formatResource, +} from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; import AppsSwitch from "./switch.js"; @@ -81,11 +85,11 @@ export default class AppsDeleteCommand extends ControlBaseCommand { // If not using force flag or JSON mode, get app details and prompt for confirmation if (!flags.force && !this.shouldOutputJson(flags)) { this.log(`\nYou are about to delete the following app:`); - this.log(`App ID: ${app.id}`); - this.log(`Name: ${app.name}`); - this.log(`Status: ${app.status}`); - this.log(`Account ID: ${app.accountId}`); - this.log(`Created: ${this.formatDate(app.created)}`); + this.log(`${formatLabel("App ID")} ${app.id}`); + this.log(`${formatLabel("Name")} ${app.name}`); + this.log(`${formatLabel("Status")} ${app.status}`); + this.log(`${formatLabel("Account ID")} ${app.accountId}`); + this.log(`${formatLabel("Created")} ${this.formatDate(app.created)}`); // For additional confirmation, prompt user to enter the app name const nameConfirmed = await this.promptForAppName(app.name); diff --git a/src/commands/apps/set-apns-p12.ts b/src/commands/apps/set-apns-p12.ts index 9da9cd6e..4a3838e7 100644 --- a/src/commands/apps/set-apns-p12.ts +++ b/src/commands/apps/set-apns-p12.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; import { + formatLabel, formatProgress, formatResource, formatSuccess, @@ -79,11 +80,11 @@ export default class AppsSetApnsP12Command extends ControlBaseCommand { this.log(this.formatJsonOutput(result, flags)); } else { this.log(formatSuccess("APNS P12 certificate uploaded.")); - this.log(`Certificate ID: ${result.id}`); + this.log(`${formatLabel("Certificate ID")} ${result.id}`); if (flags["use-for-sandbox"]) { - this.log(`Environment: Sandbox`); + this.log(`${formatLabel("Environment")} Sandbox`); } else { - this.log(`Environment: Production`); + this.log(`${formatLabel("Environment")} Production`); } } } catch (error) { diff --git a/src/commands/apps/update.ts b/src/commands/apps/update.ts index 266e6ad2..4f5f6e6d 100644 --- a/src/commands/apps/update.ts +++ b/src/commands/apps/update.ts @@ -2,7 +2,11 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatProgress, formatResource } from "../../utils/output.js"; +import { + formatLabel, + formatProgress, + formatResource, +} from "../../utils/output.js"; export default class AppsUpdateCommand extends ControlBaseCommand { static args = { @@ -100,16 +104,16 @@ export default class AppsUpdateCommand extends ControlBaseCommand { ); } else { this.log(`\nApp updated successfully!`); - this.log(`App ID: ${app.id}`); - this.log(`Name: ${app.name}`); - this.log(`Status: ${app.status}`); - this.log(`Account ID: ${app.accountId}`); - this.log(`TLS Only: ${app.tlsOnly ? "Yes" : "No"}`); - this.log(`Created: ${this.formatDate(app.created)}`); - this.log(`Updated: ${this.formatDate(app.modified)}`); + this.log(`${formatLabel("App ID")} ${app.id}`); + this.log(`${formatLabel("Name")} ${app.name}`); + this.log(`${formatLabel("Status")} ${app.status}`); + this.log(`${formatLabel("Account ID")} ${app.accountId}`); + this.log(`${formatLabel("TLS Only")} ${app.tlsOnly ? "Yes" : "No"}`); + this.log(`${formatLabel("Created")} ${this.formatDate(app.created)}`); + this.log(`${formatLabel("Updated")} ${this.formatDate(app.modified)}`); if (app.apnsUsesSandboxCert !== undefined) { this.log( - `APNS Uses Sandbox Cert: ${app.apnsUsesSandboxCert ? "Yes" : "No"}`, + `${formatLabel("APNS Uses Sandbox Cert")} ${app.apnsUsesSandboxCert ? "Yes" : "No"}`, ); } } diff --git a/src/commands/auth/keys/create.ts b/src/commands/auth/keys/create.ts index 417b2aae..cbaa1d42 100644 --- a/src/commands/auth/keys/create.ts +++ b/src/commands/auth/keys/create.ts @@ -4,6 +4,7 @@ import { ControlBaseCommand } from "../../../control-base-command.js"; import { errorMessage } from "../../../utils/errors.js"; import { formatCapabilities } from "../../../utils/key-display.js"; import { + formatLabel, formatProgress, formatResource, formatSuccess, @@ -118,8 +119,8 @@ export default class KeysCreateCommand extends ControlBaseCommand { } else { const keyName = `${key.appId}.${key.id}`; this.log(formatSuccess(`Key created: ${formatResource(keyName)}.`)); - this.log(`Key Name: ${keyName}`); - this.log(`Key Label: ${key.name || "Unnamed key"}`); + this.log(`${formatLabel("Key Name")} ${keyName}`); + this.log(`${formatLabel("Key Label")} ${key.name || "Unnamed key"}`); for (const line of formatCapabilities( key.capability as Record, @@ -127,9 +128,9 @@ export default class KeysCreateCommand extends ControlBaseCommand { this.log(line); } - this.log(`Created: ${this.formatDate(key.created)}`); - this.log(`Updated: ${this.formatDate(key.modified)}`); - this.log(`Full key: ${key.key}`); + this.log(`${formatLabel("Created")} ${this.formatDate(key.created)}`); + this.log(`${formatLabel("Updated")} ${this.formatDate(key.modified)}`); + this.log(`${formatLabel("Full key")} ${key.key}`); // Tell the user how to switch to this key instead of doing it automatically this.log( diff --git a/src/commands/connections/test.ts b/src/commands/connections/test.ts index 3be3e357..04f3dce5 100644 --- a/src/commands/connections/test.ts +++ b/src/commands/connections/test.ts @@ -4,10 +4,7 @@ import chalk from "chalk"; import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; -import { - formatProgress, - formatSuccess as successMsg, -} from "../../utils/output.js"; +import { formatProgress, formatSuccess } from "../../utils/output.js"; export default class ConnectionsTest extends AblyBaseCommand { static override description = "Test connection to Ably"; @@ -173,7 +170,9 @@ export default class ConnectionsTest extends AblyBaseCommand { const partialSuccess = wsSuccess || xhrSuccess; if (allSuccess) { - this.log(successMsg("All connection tests passed successfully.")); + this.log( + formatSuccess("All connection tests passed successfully."), + ); } else if (partialSuccess) { this.log( `${chalk.yellow("!")} Some connection tests succeeded, but others failed`, @@ -188,7 +187,7 @@ export default class ConnectionsTest extends AblyBaseCommand { case "ws": { if (wsSuccess) { this.log( - successMsg("WebSocket connection test passed successfully."), + formatSuccess("WebSocket connection test passed successfully."), ); } else { this.log(`${chalk.red("✗")} WebSocket connection test failed`); @@ -199,7 +198,9 @@ export default class ConnectionsTest extends AblyBaseCommand { case "xhr": { if (xhrSuccess) { - this.log(successMsg("HTTP connection test passed successfully.")); + this.log( + formatSuccess("HTTP connection test passed successfully."), + ); } else { this.log(`${chalk.red("✗")} HTTP connection test failed`); } @@ -309,7 +310,7 @@ export default class ConnectionsTest extends AblyBaseCommand { ); if (!this.shouldOutputJson(flags)) { this.log( - successMsg(`${config.displayName} connection successful.`), + formatSuccess(`${config.displayName} connection successful.`), ); this.log( ` Connection ID: ${chalk.cyan(client!.connection.id || "unknown")}`, diff --git a/src/commands/integrations/create.ts b/src/commands/integrations/create.ts index cd88ed98..314514b8 100644 --- a/src/commands/integrations/create.ts +++ b/src/commands/integrations/create.ts @@ -2,7 +2,11 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatResource, formatSuccess } from "../../utils/output.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../utils/output.js"; // Interface for basic integration data structure interface IntegrationData { @@ -154,16 +158,20 @@ export default class IntegrationsCreateCommand extends ControlBaseCommand { `Integration rule created: ${formatResource(createdIntegration.id)}.`, ), ); - this.log(`ID: ${createdIntegration.id}`); - this.log(`App ID: ${createdIntegration.appId}`); - this.log(`Type: ${createdIntegration.ruleType}`); - this.log(`Request Mode: ${createdIntegration.requestMode}`); + this.log(`${formatLabel("ID")} ${createdIntegration.id}`); + this.log(`${formatLabel("App ID")} ${createdIntegration.appId}`); + this.log(`${formatLabel("Type")} ${createdIntegration.ruleType}`); this.log( - `Source Channel Filter: ${createdIntegration.source.channelFilter}`, + `${formatLabel("Request Mode")} ${createdIntegration.requestMode}`, ); - this.log(`Source Type: ${createdIntegration.source.type}`); this.log( - `Target: ${this.formatJsonOutput(createdIntegration.target as Record, flags)}`, + `${formatLabel("Source Channel Filter")} ${createdIntegration.source.channelFilter}`, + ); + this.log( + `${formatLabel("Source Type")} ${createdIntegration.source.type}`, + ); + this.log( + `${formatLabel("Target")} ${this.formatJsonOutput(createdIntegration.target as Record, flags)}`, ); } } catch (error) { diff --git a/src/commands/integrations/delete.ts b/src/commands/integrations/delete.ts index b64a5005..3fc86312 100644 --- a/src/commands/integrations/delete.ts +++ b/src/commands/integrations/delete.ts @@ -2,7 +2,11 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatResource, formatSuccess } from "../../utils/output.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class IntegrationsDeleteCommand extends ControlBaseCommand { @@ -63,12 +67,12 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { // If not using force flag, prompt for confirmation 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}`); - this.log(`Request Mode: ${integration.requestMode}`); - this.log(`Source Type: ${integration.source.type}`); + this.log(`${formatLabel("Integration ID")} ${integration.id}`); + this.log(`${formatLabel("Type")} ${integration.ruleType}`); + this.log(`${formatLabel("Request Mode")} ${integration.requestMode}`); + this.log(`${formatLabel("Source Type")} ${integration.source.type}`); this.log( - `Channel Filter: ${integration.source.channelFilter || "(none)"}`, + `${formatLabel("Channel Filter")} ${integration.source.channelFilter || "(none)"}`, ); const confirmed = await promptForConfirmation( @@ -105,10 +109,10 @@ export default class IntegrationsDeleteCommand extends ControlBaseCommand { `Integration rule deleted: ${formatResource(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}`); + this.log(`${formatLabel("ID")} ${integration.id}`); + this.log(`${formatLabel("App ID")} ${integration.appId}`); + this.log(`${formatLabel("Type")} ${integration.ruleType}`); + this.log(`${formatLabel("Source Type")} ${integration.source.type}`); } } catch (error) { const errorMsg = `Error deleting integration: ${errorMessage(error)}`; diff --git a/src/commands/integrations/get.ts b/src/commands/integrations/get.ts index 3102d790..497d29af 100644 --- a/src/commands/integrations/get.ts +++ b/src/commands/integrations/get.ts @@ -1,8 +1,8 @@ import { Args, Flags } from "@oclif/core"; -import chalk from "chalk"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; +import { formatHeading, formatLabel } from "../../utils/output.js"; export default class IntegrationsGetCommand extends ControlBaseCommand { static args = { @@ -51,19 +51,21 @@ export default class IntegrationsGetCommand extends ControlBaseCommand { ), ); } else { - this.log(chalk.green("Integration Rule Details:")); - this.log(`ID: ${rule.id}`); - this.log(`App ID: ${rule.appId}`); - this.log(`Rule Type: ${rule.ruleType}`); - this.log(`Request Mode: ${rule.requestMode}`); - this.log(`Source Channel Filter: ${rule.source.channelFilter}`); - this.log(`Source Type: ${rule.source.type}`); + this.log(formatHeading("Integration Rule Details")); + this.log(`${formatLabel("ID")} ${rule.id}`); + this.log(`${formatLabel("App ID")} ${rule.appId}`); + this.log(`${formatLabel("Rule Type")} ${rule.ruleType}`); + this.log(`${formatLabel("Request Mode")} ${rule.requestMode}`); this.log( - `Target: ${this.formatJsonOutput(structuredClone(rule.target) as unknown as Record, flags).replaceAll("\n", "\n ")}`, + `${formatLabel("Source Channel Filter")} ${rule.source.channelFilter}`, ); - this.log(`Version: ${rule.version}`); - this.log(`Created: ${this.formatDate(rule.created)}`); - this.log(`Updated: ${this.formatDate(rule.modified)}`); + this.log(`${formatLabel("Source Type")} ${rule.source.type}`); + this.log( + `${formatLabel("Target")} ${this.formatJsonOutput(structuredClone(rule.target) as unknown as Record, flags).replaceAll("\n", "\n ")}`, + ); + this.log(`${formatLabel("Version")} ${rule.version}`); + this.log(`${formatLabel("Created")} ${this.formatDate(rule.created)}`); + this.log(`${formatLabel("Updated")} ${this.formatDate(rule.modified)}`); } } catch (error) { this.error(`Error getting integration rule: ${errorMessage(error)}`); diff --git a/src/commands/queues/create.ts b/src/commands/queues/create.ts index 3b31fb9c..e08b29c7 100644 --- a/src/commands/queues/create.ts +++ b/src/commands/queues/create.ts @@ -2,7 +2,11 @@ import { Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatResource, formatSuccess } from "../../utils/output.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class QueuesCreateCommand extends ControlBaseCommand { static description = "Create a queue"; @@ -69,21 +73,25 @@ export default class QueuesCreateCommand extends ControlBaseCommand { this.log( formatSuccess(`Queue created: ${formatResource(createdQueue.name)}.`), ); - this.log(`Queue ID: ${createdQueue.id}`); - this.log(`Name: ${createdQueue.name}`); - this.log(`Region: ${createdQueue.region}`); - this.log(`TTL: ${createdQueue.ttl} seconds`); - this.log(`Max Length: ${createdQueue.maxLength} messages`); - this.log(`State: ${createdQueue.state}`); + this.log(`${formatLabel("Queue ID")} ${createdQueue.id}`); + this.log(`${formatLabel("Name")} ${createdQueue.name}`); + this.log(`${formatLabel("Region")} ${createdQueue.region}`); + this.log(`${formatLabel("TTL")} ${createdQueue.ttl} seconds`); + this.log( + `${formatLabel("Max Length")} ${createdQueue.maxLength} messages`, + ); + this.log(`${formatLabel("State")} ${createdQueue.state}`); this.log(`\nAMQP Connection Details:`); - this.log(`URI: ${createdQueue.amqp.uri}`); - this.log(`Queue Name: ${createdQueue.amqp.queueName}`); + this.log(`${formatLabel("URI")} ${createdQueue.amqp.uri}`); + this.log(`${formatLabel("Queue Name")} ${createdQueue.amqp.queueName}`); this.log(`\nSTOMP Connection Details:`); - this.log(`URI: ${createdQueue.stomp.uri}`); - this.log(`Host: ${createdQueue.stomp.host}`); - this.log(`Destination: ${createdQueue.stomp.destination}`); + this.log(`${formatLabel("URI")} ${createdQueue.stomp.uri}`); + this.log(`${formatLabel("Host")} ${createdQueue.stomp.host}`); + this.log( + `${formatLabel("Destination")} ${createdQueue.stomp.destination}`, + ); } } catch (error) { this.error(`Error creating queue: ${errorMessage(error)}`); diff --git a/src/commands/queues/delete.ts b/src/commands/queues/delete.ts index 16334c11..11b73737 100644 --- a/src/commands/queues/delete.ts +++ b/src/commands/queues/delete.ts @@ -2,7 +2,11 @@ import { Args, Flags } from "@oclif/core"; import { ControlBaseCommand } from "../../control-base-command.js"; import { errorMessage } from "../../utils/errors.js"; -import { formatResource, formatSuccess } from "../../utils/output.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../utils/output.js"; import { promptForConfirmation } from "../../utils/prompt-confirmation.js"; export default class QueuesDeleteCommand extends ControlBaseCommand { @@ -69,10 +73,10 @@ export default class QueuesDeleteCommand extends ControlBaseCommand { // If not using force flag, prompt for confirmation 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}`); - this.log(`Region: ${queue.region}`); - this.log(`State: ${queue.state}`); + this.log(`${formatLabel("Queue ID")} ${queue.id}`); + this.log(`${formatLabel("Name")} ${queue.name}`); + this.log(`${formatLabel("Region")} ${queue.region}`); + this.log(`${formatLabel("State")} ${queue.state}`); this.log( `Messages: ${queue.messages.total} total (${queue.messages.ready} ready, ${queue.messages.unacknowledged} unacknowledged)`, ); diff --git a/src/commands/spaces/locks/get-all.ts b/src/commands/spaces/locks/get-all.ts index 2b9f0660..950cb772 100644 --- a/src/commands/spaces/locks/get-all.ts +++ b/src/commands/spaces/locks/get-all.ts @@ -150,12 +150,12 @@ export default class SpacesLocksGetAll extends SpacesBaseCommand { this.log(`- ${chalk.blue(lock.id)}:`); this.log(` ${formatLabel("Status")} ${lock.status || "unknown"}`); this.log( - ` ${chalk.dim("Holder:")} ${lock.member?.clientId || "None"}`, + ` ${formatLabel("Holder")} ${lock.member?.clientId || "None"}`, ); if (lock.attributes && Object.keys(lock.attributes).length > 0) { this.log( - ` ${chalk.dim("Attributes:")} ${JSON.stringify(lock.attributes, null, 2)}`, + ` ${formatLabel("Attributes")} ${JSON.stringify(lock.attributes, null, 2)}`, ); } } catch (error) { diff --git a/test/unit/utils/output.test.ts b/test/unit/utils/output.test.ts new file mode 100644 index 00000000..92e0676f --- /dev/null +++ b/test/unit/utils/output.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from "vitest"; +import chalk from "chalk"; +import { + formatProgress, + formatSuccess, + formatListening, + formatLabel, + formatResource, + formatTimestamp, + formatClientId, + formatEventType, + formatHeading, + formatIndex, + formatCountLabel, + formatLimitWarning, + formatMessageTimestamp, + formatPresenceAction, +} from "../../../src/utils/output.js"; + +describe("formatProgress", () => { + it("appends ... to the message", () => { + expect(formatProgress("Loading")).toBe("Loading..."); + }); + + it("works with an empty string", () => { + expect(formatProgress("")).toBe("..."); + }); +}); + +describe("formatSuccess", () => { + it("prepends a green checkmark", () => { + expect(formatSuccess("Done")).toBe(`${chalk.green("✓")} Done`); + }); + + it("includes the message text", () => { + expect(formatSuccess("Published.")).toContain("Published."); + }); +}); + +describe("formatListening", () => { + it("includes the description text", () => { + expect(formatListening("Listening for messages.")).toContain( + "Listening for messages.", + ); + }); + + it('includes "Press Ctrl+C to exit."', () => { + expect(formatListening("Listening.")).toContain("Press Ctrl+C to exit."); + }); + + it("wraps in dim styling", () => { + expect(formatListening("Listening.")).toBe( + chalk.dim("Listening. Press Ctrl+C to exit."), + ); + }); +}); + +describe("formatLabel", () => { + it("appends a colon", () => { + const result = formatLabel("Field Name"); + expect(result).toBe(chalk.dim("Field Name:")); + }); + + it("contains the label text", () => { + expect(formatLabel("Status")).toContain("Status"); + }); +}); + +describe("formatResource", () => { + it("wraps the name in cyan", () => { + expect(formatResource("my-channel")).toBe(chalk.cyan("my-channel")); + }); +}); + +describe("formatTimestamp", () => { + it("wraps the timestamp in brackets", () => { + const result = formatTimestamp("2025-01-01T00:00:00Z"); + expect(result).toBe(chalk.dim("[2025-01-01T00:00:00Z]")); + }); + + it("contains [ and ]", () => { + const result = formatTimestamp("now"); + expect(result).toContain("["); + expect(result).toContain("]"); + }); +}); + +describe("formatClientId", () => { + it("wraps the id in blue", () => { + expect(formatClientId("user-123")).toBe(chalk.blue("user-123")); + }); +}); + +describe("formatEventType", () => { + it("wraps the type in yellow", () => { + expect(formatEventType("message")).toBe(chalk.yellow("message")); + }); +}); + +describe("formatHeading", () => { + it("wraps the text in bold", () => { + expect(formatHeading("Record ID: abc")).toBe(chalk.bold("Record ID: abc")); + }); +}); + +describe("formatIndex", () => { + it("wraps the number in dim brackets", () => { + expect(formatIndex(1)).toBe(chalk.dim("[1]")); + }); + + it("contains [ and ] around the number", () => { + const result = formatIndex(42); + expect(result).toContain("["); + expect(result).toContain("42"); + expect(result).toContain("]"); + }); +}); + +describe("formatCountLabel", () => { + it("uses singular for count of 1", () => { + const result = formatCountLabel(1, "message"); + expect(result).toBe(`${chalk.cyan("1")} message`); + }); + + it("auto-pluralizes by appending s for count != 1", () => { + const result = formatCountLabel(3, "message"); + expect(result).toBe(`${chalk.cyan("3")} messages`); + }); + + it("auto-pluralizes for count of 0", () => { + const result = formatCountLabel(0, "message"); + expect(result).toBe(`${chalk.cyan("0")} messages`); + }); + + it("uses custom plural when provided", () => { + const result = formatCountLabel(2, "entry", "entries"); + expect(result).toBe(`${chalk.cyan("2")} entries`); + }); + + it("uses singular even with custom plural when count is 1", () => { + const result = formatCountLabel(1, "entry", "entries"); + expect(result).toBe(`${chalk.cyan("1")} entry`); + }); +}); + +describe("formatLimitWarning", () => { + it("returns null when count is under the limit", () => { + expect(formatLimitWarning(5, 10, "items")).toBeNull(); + }); + + it("returns a warning string when count equals the limit", () => { + const result = formatLimitWarning(10, 10, "items"); + expect(result).not.toBeNull(); + expect(result).toBe( + chalk.yellow("Showing maximum of 10 items. Use --limit to show more."), + ); + }); + + it("returns a warning string when count exceeds the limit", () => { + const result = formatLimitWarning(15, 10, "records"); + expect(result).not.toBeNull(); + expect(result).toContain("10"); + expect(result).toContain("records"); + }); +}); + +describe("formatMessageTimestamp", () => { + it("converts a number (Unix ms) to an ISO string", () => { + const ts = new Date("2025-06-15T12:00:00Z").getTime(); + expect(formatMessageTimestamp(ts)).toBe("2025-06-15T12:00:00.000Z"); + }); + + it("handles Date objects", () => { + const date = new Date("2025-01-01T00:00:00Z"); + expect(formatMessageTimestamp(date)).toBe("2025-01-01T00:00:00.000Z"); + }); + + it("falls back to current time for undefined", () => { + const before = Date.now(); + const result = formatMessageTimestamp(); + const after = Date.now(); + const resultTime = new Date(result).getTime(); + expect(resultTime).toBeGreaterThanOrEqual(before); + expect(resultTime).toBeLessThanOrEqual(after); + }); + + it("falls back to current time for null", () => { + const before = Date.now(); + const result = formatMessageTimestamp(null); + const after = Date.now(); + const resultTime = new Date(result).getTime(); + expect(resultTime).toBeGreaterThanOrEqual(before); + expect(resultTime).toBeLessThanOrEqual(after); + }); + + it("handles timestamp of 0 (epoch)", () => { + expect(formatMessageTimestamp(0)).toBe("1970-01-01T00:00:00.000Z"); + }); +}); + +describe("formatPresenceAction", () => { + it('returns green checkmark for "enter"', () => { + const result = formatPresenceAction("enter"); + expect(result.symbol).toBe("✓"); + expect(result.color).toBe(chalk.green); + }); + + it('returns red cross for "leave"', () => { + const result = formatPresenceAction("leave"); + expect(result.symbol).toBe("✗"); + expect(result.color).toBe(chalk.red); + }); + + it('returns yellow cycle symbol for "update"', () => { + const result = formatPresenceAction("update"); + expect(result.symbol).toBe("⟲"); + expect(result.color).toBe(chalk.yellow); + }); + + it("returns white bullet for unknown actions", () => { + const result = formatPresenceAction("something-else"); + expect(result.symbol).toBe("•"); + expect(result.color).toBe(chalk.white); + }); + + it("is case-insensitive", () => { + expect(formatPresenceAction("ENTER").symbol).toBe("✓"); + expect(formatPresenceAction("Leave").symbol).toBe("✗"); + expect(formatPresenceAction("UPDATE").symbol).toBe("⟲"); + }); +}); From 9a7978f1e304c9bc6a932ccc0bbe40f58c605a6e Mon Sep 17 00:00:00 2001 From: umair Date: Mon, 9 Mar 2026 18:54:10 +0000 Subject: [PATCH 7/7] update skill around errors --- .claude/skills/ably-new-command/SKILL.md | 27 ++++++++++--------- .../ably-new-command/references/patterns.md | 18 +++++++++---- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index ba5540b5..096de00f 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -1,6 +1,6 @@ --- name: ably-new-command -description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use when creating, adding, or migrating any command or subcommand." +description: "Scaffold new CLI commands with tests for the Ably CLI (oclif + TypeScript). Use this skill whenever creating a new command, adding a subcommand, migrating a command, or scaffolding a command with its test file — even if described casually (e.g., 'I need an ably X Y command', 'can you build ably rooms typing subscribe', 'we should add a purge command to queues'). Do NOT use for modifying existing commands, fixing bugs, or adding tests to existing commands." --- # Ably CLI New Command @@ -208,14 +208,15 @@ Rules: - `formatLabel(text)` — dim with colon, for field labels - `formatHeading(text)` — bold, for record headings in lists - `formatIndex(n)` — dim bracketed number, for history ordering -- Use `this.error()` for fatal errors, never `this.log(chalk.red(...))` +- Use `this.handleCommandError()` for all errors (see Error handling below), never `this.log(chalk.red(...))` - Never use `console.log` or `console.error` — always `this.log()` or `this.logToStderr()` ### Error handling -**Always use `handleCommandError` in catch blocks** — it's the single canonical pattern for error handling in commands. It logs the CLI event, emits JSON error when `--json` is active, and calls `this.error()` for human-readable output. +**Use `handleCommandError` for all errors.** It's the single error function for commands — it logs the CLI event, emits JSON error when `--json` is active, and calls `this.error()` for human-readable output. It accepts an `Error` object or a plain string message. ```typescript +// In catch blocks — pass the error object try { // command logic } catch (error) { @@ -226,19 +227,19 @@ try { { channel: args.channel }, // optional context for logging ); } -``` -For simple Control API errors where you don't need event logging: -```typescript -try { - const result = await controlApi.someMethod(appId, data); - // handle result -} catch (error) { - this.error(`Error creating resource: ${error instanceof Error ? error.message : String(error)}`); +// For validation / early exit — pass a string message +if (!appId) { + this.handleCommandError( + 'No app specified. Use --app flag or select an app with "ably apps switch"', + flags, + "AppResolve", + ); + return; } ``` -**`this.jsonError(data, flags)`** exists as an escape hatch for non-standard error flows where `handleCommandError` doesn't fit (e.g., `set-apns-p12.ts` where the error format differs). New commands should not need it — use `handleCommandError` instead. Existing uses of `jsonError` should be migrated to `handleCommandError` over time. +**Do NOT use `this.error()` or `this.jsonError()` directly** — they are internal implementation details. Calling `this.error()` directly skips event logging and doesn't respect `--json` mode. Calling `this.jsonError()` directly skips event logging and doesn't handle the non-JSON case. ### Pattern-specific implementation @@ -315,7 +316,7 @@ pnpm test:unit # Run tests - [ ] Output helpers used correctly (`formatProgress`, `formatSuccess`, `formatListening`, `formatResource`, `formatTimestamp`, `formatClientId`, `formatEventType`, `formatLabel`, `formatHeading`, `formatIndex`) - [ ] `success()` messages end with `.` (period) - [ ] Resource names use `resource(name)`, never quoted -- [ ] Error handling uses `this.handleCommandError()` or `this.error()`, not `this.log(chalk.red(...))` +- [ ] Error handling uses `this.handleCommandError()` exclusively, not `this.error()`, `this.jsonError()`, or `this.log(chalk.red(...))` - [ ] Test file at matching path under `test/unit/commands/` - [ ] Tests use correct mock helper (`getMockAblyRealtime`, `getMockAblyRest`, `nock`) - [ ] Tests don't pass auth flags — `MockConfigManager` handles auth diff --git a/.claude/skills/ably-new-command/references/patterns.md b/.claude/skills/ably-new-command/references/patterns.md index fce6ccf6..3bfca480 100644 --- a/.claude/skills/ably-new-command/references/patterns.md +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -189,7 +189,7 @@ async run(): Promise { try { presenceData = JSON.parse(flags.data); } catch { - this.error("Invalid JSON data provided"); + this.handleCommandError("Invalid JSON data provided", flags, "PresenceEnter"); return; } } @@ -264,7 +264,11 @@ async run(): Promise { const appId = await this.resolveAppId(flags); if (!appId) { - this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); + this.handleCommandError( + 'No app specified. Use --app flag or select an app with "ably apps switch"', + flags, + "ListItems", + ); return; } @@ -284,7 +288,7 @@ async run(): Promise { } } } catch (error) { - this.error(`Error listing items: ${error instanceof Error ? error.message : String(error)}`); + this.handleCommandError(error, flags, "ListItems"); } } ``` @@ -307,7 +311,11 @@ async run(): Promise { const appId = await this.resolveAppId(flags); if (!appId) { - this.error('No app specified. Use --app flag or select an app with "ably apps switch"'); + this.handleCommandError( + 'No app specified. Use --app flag or select an app with "ably apps switch"', + flags, + "CreateResource", + ); return; } @@ -321,7 +329,7 @@ async run(): Promise { // Display additional fields } } catch (error) { - this.error(`Error creating resource: ${error instanceof Error ? error.message : String(error)}`); + this.handleCommandError(error, flags, "CreateResource"); } } ```