diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md new file mode 100644 index 00000000..096de00f --- /dev/null +++ b/.claude/skills/ably-new-command/SKILL.md @@ -0,0 +1,325 @@ +--- +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 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 + +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` | + +**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 + +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 { formatResource, formatSuccess, formatProgress, formatListening, formatTimestamp, formatClientId, formatEventType, formatLabel, formatHeading, formatIndex } 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 { formatResource, formatSuccess, formatLabel, formatHeading } 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 { formatResource, formatSuccess, formatProgress, formatListening } 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 { formatResource, formatSuccess, formatProgress, formatListening, formatClientId } 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: + +```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(formatProgress("Attaching to channel: " + formatResource(channelName))); +} + +// After success: +if (!this.shouldOutputJson(flags)) { + this.log(formatSuccess("Subscribed to channel: " + formatResource(channelName) + ".")); + this.log(formatListening("Listening for messages.")); +} + +// JSON output: +if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput(data, flags)); +} +``` + +**Output helper reference** — all exported from `src/utils/output.ts`: + +| Helper | Usage | Example | +|--------|-------|---------| +| `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)` | +| `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 }` | +| `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: +- `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 +- `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.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 + +**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) { + this.handleCommandError( + error, + flags, + "ComponentName", // e.g., "ChannelPublish", "PresenceEnter" + { channel: args.channel }, // optional context for logging + ); +} + +// 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; +} +``` + +**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 + +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 + +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 + +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`, `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 (`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()` 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 +- [ ] 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..3bfca480 --- /dev/null +++ b/.claude/skills/ably-new-command/references/patterns.md @@ -0,0 +1,335 @@ +# 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); + // 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)) { + this.log(formatProgress("Attaching to channel: " + formatResource(args.channel))); + } + + channel.once("attached", () => { + if (!this.shouldOutputJson(flags)) { + this.log(formatSuccess("Attached to channel: " + formatResource(args.channel) + ".")); + this.log(formatListening("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, formatResource, 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(formatProgress("Publishing to channel: " + formatResource(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(formatSuccess("Message published to channel: " + formatResource(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. + +**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 + +```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(formatSuccess(`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.handleCommandError("Invalid JSON data provided", flags, "PresenceEnter"); + return; + } + } + + if (!this.shouldOutputJson(flags)) { + this.log(formatProgress("Entering presence on channel: " + formatResource(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(formatSuccess("Entered presence on channel: " + formatResource(args.channel) + ".")); + this.log(formatListening("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 `formatSuccess()` because there's no action to confirm — they just display data. + +**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(`${formatResource(item.id)}`); + } +} +``` + +**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(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(""); + } +} +``` + +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.handleCommandError( + 'No app specified. Use --app flag or select an app with "ably apps switch"', + flags, + "ListItems", + ); + 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(formatHeading(`Item ID: ${item.id}`)); + this.log(` ${formatLabel("Type")} ${item.type}`); + this.log(` ${formatLabel("Status")} ${item.status}`); + this.log(""); + } + } + } catch (error) { + this.handleCommandError(error, flags, "ListItems"); + } +} +``` + +Key conventions for list output: +- `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 + +--- + +## 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.handleCommandError( + 'No app specified. Use --app flag or select an app with "ably apps switch"', + flags, + "CreateResource", + ); + return; + } + + try { + const result = await controlApi.someMethod(appId, data); + + if (this.shouldOutputJson(flags)) { + this.log(this.formatJsonOutput({ result }, flags)); + } else { + this.log(formatSuccess("Resource created: " + formatResource(result.id) + ".")); + // Display additional fields + } + } catch (error) { + this.handleCommandError(error, flags, "CreateResource"); + } +} +``` 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..f71bd6ca --- /dev/null +++ b/.claude/skills/ably-new-command/references/testing.md @@ -0,0 +1,274 @@ +# 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.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(() => { + 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("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.skipIf(SHOULD_SKIP_E2E)("topic:action CRUD E2E", { timeout: 30_000 }, () => { + const cliPath = "./bin/run.js"; + + afterAll(async () => { + await cleanupTrackedResources(); + }); + + 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! } }, + ).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..1e59bf16 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... }; ``` @@ -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/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..3532991d 100644 --- a/src/commands/apps/create.ts +++ b/src/commands/apps/create.ts @@ -2,7 +2,12 @@ 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 { + formatLabel, + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class AppsCreateCommand extends ControlBaseCommand { static description = "Create a new app"; @@ -32,7 +37,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,14 +65,18 @@ export default class AppsCreateCommand extends ControlBaseCommand { ), ); } else { - this.log(success(`App created: ${resource(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( + formatSuccess( + `App created: ${formatResource(app.name)} (${app.id}).`, + ), + ); + 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 dcab6582..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 { progress, resource } 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); @@ -133,7 +137,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..4a3838e7 100644 --- a/src/commands/apps/set-apns-p12.ts +++ b/src/commands/apps/set-apns-p12.ts @@ -4,7 +4,12 @@ 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 { + formatLabel, + formatProgress, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class AppsSetApnsP12Command extends ControlBaseCommand { static args = { @@ -56,7 +61,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,12 +79,12 @@ 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(`Certificate ID: ${result.id}`); + this.log(formatSuccess("APNS P12 certificate uploaded.")); + 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 4fc10f90..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 { progress, resource } from "../../utils/output.js"; +import { + formatLabel, + formatProgress, + formatResource, +} from "../../utils/output.js"; export default class AppsUpdateCommand extends ControlBaseCommand { static args = { @@ -61,7 +65,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 } = {}; @@ -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 b21618fd..cbaa1d42 100644 --- a/src/commands/auth/keys/create.ts +++ b/src/commands/auth/keys/create.ts @@ -3,7 +3,12 @@ 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 { + formatLabel, + 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 +92,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,9 +118,9 @@ export default class KeysCreateCommand extends ControlBaseCommand { ); } else { const keyName = `${key.appId}.${key.id}`; - this.log(success(`Key created: ${resource(keyName)}.`)); - this.log(`Key Name: ${keyName}`); - this.log(`Key Label: ${key.name || "Unnamed key"}`); + this.log(formatSuccess(`Key created: ${formatResource(keyName)}.`)); + this.log(`${formatLabel("Key Name")} ${keyName}`); + this.log(`${formatLabel("Key Label")} ${key.name || "Unnamed key"}`); for (const line of formatCapabilities( key.capability as Record, @@ -123,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/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..04f3dce5 100644 --- a/src/commands/connections/test.ts +++ b/src/commands/connections/test.ts @@ -4,7 +4,7 @@ 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 } from "../../utils/output.js"; export default class ConnectionsTest extends AblyBaseCommand { static override description = "Test connection to Ably"; @@ -170,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`, @@ -185,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`); @@ -196,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`); } @@ -257,7 +261,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; @@ -304,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 450cd900..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 { resource, success } from "../../utils/output.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../utils/output.js"; // Interface for basic integration data structure interface IntegrationData { @@ -150,20 +154,24 @@ 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}`); - 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 a19e6685..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 { resource, success } 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( @@ -101,12 +105,14 @@ 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}`); - 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/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..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 { resource, success } from "../../utils/output.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../utils/output.js"; export default class QueuesCreateCommand extends ControlBaseCommand { static description = "Create a queue"; @@ -66,22 +70,28 @@ export default class QueuesCreateCommand extends ControlBaseCommand { ), ); } else { - this.log(success(`Queue created: ${resource(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( + formatSuccess(`Queue created: ${formatResource(createdQueue.name)}.`), + ); + 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 a2dd9da3..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 { resource, success } 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)`, ); @@ -105,7 +109,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..950cb772 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,14 +148,14 @@ 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"}`, + ` ${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/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; diff --git a/src/utils/output.ts b/src/utils/output.ts index 6cd22839..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, @@ -63,6 +63,31 @@ export function limitWarning( return null; } +/** Client identity display — cyan-blue for client IDs in event output */ +export function formatClientId(id: string): string { + return chalk.blue(id); +} + +/** Event type/action display — yellow for event type labels */ +export function formatEventType(type: string): string { + return chalk.yellow(type); +} + +/** Field label display — dim text with colon for structured output fields */ +export function formatLabel(text: string): string { + return chalk.dim(`${text}:`); +} + +/** Record heading — bold text for list item headings */ +export function formatHeading(text: string): string { + return chalk.bold(text); +} + +/** Index number display — dim bracketed number for history/list ordering */ +export function formatIndex(n: number): string { + return chalk.dim(`[${n}]`); +} + export function formatPresenceAction(action: string): { symbol: string; color: ChalkInstance; 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), 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("⟲"); + }); +});