diff --git a/.claude/skills/ably-codebase-review/SKILL.md b/.claude/skills/ably-codebase-review/SKILL.md index 8521fbde..422cdb2f 100644 --- a/.claude/skills/ably-codebase-review/SKILL.md +++ b/.claude/skills/ably-codebase-review/SKILL.md @@ -82,6 +82,7 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses 3. For any ambiguous matches, use **LSP** `hover` to confirm they're the oclif `this.error()` and not something else 4. **Grep** for `this\.fail\(` and check component strings are camelCase — single-word lowercase (`"room"`, `"auth"`), multi-word camelCase (`"channelPublish"`, `"roomPresenceSubscribe"`). Flag PascalCase like `"ChannelPublish"` or kebab-case like `"web-cli"`. 5. **Grep** for `this\.fail\(\s*new Error\(` — `this.fail()` accepts plain strings, so `new Error(...)` wrapping is unnecessary. Flag as a simplification opportunity. +6. **Check** that catch blocks calling `this.fail()` do NOT include manual oclif error re-throw guards (`if (error instanceof Error && "oclif" in error) throw error`). The base class `fail()` method handles this automatically — manual guards are unnecessary boilerplate. **Reasoning guidance:** - Base class files (`*-base-command.ts`) using `this.error()` are deviations — they should use `this.fail()` instead diff --git a/.claude/skills/ably-new-command/SKILL.md b/.claude/skills/ably-new-command/SKILL.md index ca310e3a..5c40bcbe 100644 --- a/.claude/skills/ably-new-command/SKILL.md +++ b/.claude/skills/ably-new-command/SKILL.md @@ -211,7 +211,7 @@ 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 +- `formatResource(name)` — cyan colored, never use quotes around resource names. **Exception:** do not use `formatResource()` or any ANSI-producing helper inside `this.fail()` message strings — `fail()` passes the message into the JSON error envelope, where ANSI codes would corrupt the output. Use plain quoted strings in error messages instead. - `formatTimestamp(ts)` — dim `[timestamp]` for event streams - `formatClientId(id)` — blue, for client identity in events - `formatEventType(type)` — yellow, for event/action labels @@ -303,6 +303,8 @@ if (!appId) { **Do NOT use `this.error()` directly** — it is an internal implementation detail of `fail`. Calling `this.error()` directly skips event logging and doesn't respect `--json` mode. +**Safe to use `this.fail()` in both try and catch** — `this.fail()` automatically detects if the error was already processed by a prior `this.fail()` call (by checking for the `oclif` property) and re-throws it instead of double-processing. This means you can freely call `this.fail()` for validation inside a `try` block without worrying about the `catch` block calling `this.fail()` again. + ### Pattern-specific implementation Read `references/patterns.md` for the full implementation template matching your pattern (Subscribe, Publish/Send, History, Enter/Presence, List, CRUD/Control API). Each template includes the correct flags, `run()` method structure, and output conventions. diff --git a/.claude/skills/ably-review/SKILL.md b/.claude/skills/ably-review/SKILL.md index 96f1458f..145f710b 100644 --- a/.claude/skills/ably-review/SKILL.md +++ b/.claude/skills/ably-review/SKILL.md @@ -89,6 +89,7 @@ For each changed command file, run the relevant checks. Spawn agents for paralle 2. If found, use **LSP** `hover` on the call to confirm it's the oclif `this.error()` and not something else 3. **Grep** for `this\.fail\(` and check component strings are camelCase — single-word lowercase (`"room"`, `"auth"`), multi-word camelCase (`"channelPublish"`, `"roomPresenceSubscribe"`). Flag PascalCase like `"ChannelPublish"` or kebab-case like `"web-cli"`. 4. **Grep** for `this\.fail\(\s*new Error\(` — `this.fail()` accepts plain strings, so `new Error(...)` wrapping is unnecessary. Flag as a simplification opportunity. +5. **Check** that catch blocks calling `this.fail()` do NOT include manual oclif error re-throw guards (`if (error instanceof Error && "oclif" in error) throw error`). The base class `fail()` method handles this automatically — manual guards are unnecessary boilerplate. **Output formatting check (grep/read — text patterns):** 1. **Grep** for `chalk\.cyan\(` — should use `formatResource()` instead diff --git a/README.md b/README.md index e2bb1dbb..6c3061ea 100644 --- a/README.md +++ b/README.md @@ -77,15 +77,15 @@ $ ably-interactive * [`ably accounts logout [ALIAS]`](#ably-accounts-logout-alias) * [`ably accounts switch [ALIAS]`](#ably-accounts-switch-alias) * [`ably apps`](#ably-apps) -* [`ably apps channel-rules`](#ably-apps-channel-rules) -* [`ably apps channel-rules create`](#ably-apps-channel-rules-create) -* [`ably apps channel-rules delete NAMEORID`](#ably-apps-channel-rules-delete-nameorid) -* [`ably apps channel-rules list`](#ably-apps-channel-rules-list) -* [`ably apps channel-rules update NAMEORID`](#ably-apps-channel-rules-update-nameorid) * [`ably apps create`](#ably-apps-create) * [`ably apps current`](#ably-apps-current) * [`ably apps delete [APPID]`](#ably-apps-delete-appid) * [`ably apps list`](#ably-apps-list) +* [`ably apps rules`](#ably-apps-rules) +* [`ably apps rules create`](#ably-apps-rules-create) +* [`ably apps rules delete NAMEORID`](#ably-apps-rules-delete-nameorid) +* [`ably apps rules list`](#ably-apps-rules-list) +* [`ably apps rules update NAMEORID`](#ably-apps-rules-update-nameorid) * [`ably apps set-apns-p12 ID`](#ably-apps-set-apns-p12-id) * [`ably apps switch [APPID]`](#ably-apps-switch-appid) * [`ably apps update ID`](#ably-apps-update-id) @@ -396,332 +396,330 @@ EXAMPLES $ ably apps set-apns-p12 - $ ably apps channel-rules list + $ ably apps rules list $ ably apps switch my-app COMMANDS - ably apps channel-rules Manage Ably channel rules (namespaces) - ably apps create Create a new app - ably apps current Show the currently selected app - ably apps delete Delete an app - ably apps list List all apps in the current account - ably apps set-apns-p12 Upload Apple Push Notification Service P12 certificate for an app - ably apps switch Switch to a different Ably app - ably apps update Update an app + ably apps create Create a new app + ably apps current Show the currently selected app + ably apps delete Delete an app + ably apps list List all apps in the current account + ably apps rules Manage Ably channel rules (namespaces) + ably apps set-apns-p12 Upload Apple Push Notification Service P12 certificate for an app + ably apps switch Switch to a different Ably app + ably apps update Update an app ``` _See code: [src/commands/apps/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/index.ts)_ -## `ably apps channel-rules` +## `ably apps create` -Manage Ably channel rules (namespaces) +Create a new app ``` USAGE - $ ably apps channel-rules + $ ably apps create --name [-v] [--json | --pretty-json] [--tls-only] + +FLAGS + -v, --verbose Output verbose logs + --json Output in JSON format + --name= (required) Name of the app + --pretty-json Output in colorized JSON format + --tls-only Whether the app should accept TLS connections only DESCRIPTION - Manage Ably channel rules (namespaces) + Create a new app EXAMPLES - $ ably apps channel-rules list - - $ ably apps channel-rules create --name "chat" --persisted + $ ably apps create --name "My New App" - $ ably apps channel-rules update chat --mutable-messages + $ ably apps create --name "My New App" --tls-only - $ ably apps channel-rules update chat --push-enabled + $ ably apps create --name "My New App" --json - $ ably apps channel-rules delete chat + $ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps create --name "My New App" ``` -_See code: [src/commands/apps/channel-rules/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/channel-rules/index.ts)_ +_See code: [src/commands/apps/create.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/create.ts)_ -## `ably apps channel-rules create` +## `ably apps current` -Create a channel rule +Show the currently selected app ``` USAGE - $ ably apps channel-rules create --name [-v] [--json | --pretty-json] [--app ] [--authenticated] - [--batching-enabled] [--batching-interval ] [--conflation-enabled] [--conflation-interval ] - [--conflation-key ] [--expose-time-serial] [--mutable-messages] [--persist-last] [--persisted] - [--populate-channel-registry] [--push-enabled] [--tls-only] + $ ably apps current [-v] [--json | --pretty-json] FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --authenticated Whether channels matching this rule require clients to be authenticated - --batching-enabled Whether to enable batching for messages on channels matching this rule - --batching-interval= The batching interval for messages on channels matching this rule - --conflation-enabled Whether to enable conflation for messages on channels matching this rule - --conflation-interval= The conflation interval for messages on channels matching this rule - --conflation-key= The conflation key for messages on channels matching this rule - --expose-time-serial Whether to expose the time serial for messages on channels matching this rule - --json Output in JSON format - --mutable-messages Whether messages on channels matching this rule can be updated or deleted after - publishing. Automatically enables message persistence. - --name= (required) Name of the channel rule - --persist-last Whether to persist only the last message on channels matching this rule - --persisted Whether messages on channels matching this rule should be persisted - --populate-channel-registry Whether to populate the channel registry for channels matching this rule - --pretty-json Output in colorized JSON format - --push-enabled Whether push notifications should be enabled for channels matching this rule - --tls-only Whether to enforce TLS for channels matching this rule + -v, --verbose Output verbose logs + --json Output in JSON format + --pretty-json Output in colorized JSON format DESCRIPTION - Create a channel rule + Show the currently selected app EXAMPLES - $ ably apps channel-rules create --name "chat" --persisted - - $ ably apps channel-rules create --name "chat" --mutable-messages - - $ ably apps channel-rules create --name "events" --push-enabled + $ ably apps current - $ ably apps channel-rules create --name "notifications" --persisted --push-enabled --app "My App" + $ ably apps current --json - $ ably apps channel-rules create --name "chat" --persisted --json + $ ably apps current --pretty-json ``` -_See code: [src/commands/apps/channel-rules/create.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/channel-rules/create.ts)_ +_See code: [src/commands/apps/current.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/current.ts)_ -## `ably apps channel-rules delete NAMEORID` +## `ably apps delete [APPID]` -Delete a channel rule +Delete an app ``` USAGE - $ ably apps channel-rules delete NAMEORID [-v] [--json | --pretty-json] [--app ] [-f] + $ ably apps delete [APPID] [-v] [--json | --pretty-json] [-f] [--app ] ARGUMENTS - NAMEORID Name or ID of the channel rule to delete + APPID App ID to delete (uses current app if not specified) FLAGS - -f, --force Force deletion without confirmation + -f, --force Skip confirmation prompt -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format --pretty-json Output in colorized JSON format DESCRIPTION - Delete a channel rule + Delete an app EXAMPLES - $ ably apps channel-rules delete chat + $ ably apps delete - $ ably apps channel-rules delete events --app "My App" + $ ably apps delete app-id + + $ ably apps delete --app app-id - $ ably apps channel-rules delete notifications --force + $ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps delete app-id - $ ably apps channel-rules delete chat --json + $ ably apps delete app-id --force + + $ ably apps delete app-id --json - $ ably apps channel-rules delete chat --pretty-json + $ ably apps delete app-id --pretty-json ``` -_See code: [src/commands/apps/channel-rules/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/channel-rules/delete.ts)_ +_See code: [src/commands/apps/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/delete.ts)_ -## `ably apps channel-rules list` +## `ably apps list` -List channel rules for an app +List all apps in the current account ``` USAGE - $ ably apps channel-rules list [-v] [--json | --pretty-json] [--app ] + $ ably apps list [-v] [--json | --pretty-json] FLAGS -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) --json Output in JSON format --pretty-json Output in colorized JSON format DESCRIPTION - List channel rules for an app + List all apps in the current account EXAMPLES - $ ably apps:channel-rules:list - - $ ably apps:channel-rules:list --app my-app-id + $ ably apps list - $ ably apps:channel-rules:list --json + $ ably apps list --json - $ ably apps:channel-rules:list --pretty-json + $ ably apps list --pretty-json ``` -_See code: [src/commands/apps/channel-rules/list.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/channel-rules/list.ts)_ +_See code: [src/commands/apps/list.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/list.ts)_ -## `ably apps channel-rules update NAMEORID` +## `ably apps rules` -Update a channel rule +Manage Ably channel rules (namespaces) ``` USAGE - $ ably apps channel-rules update NAMEORID [-v] [--json | --pretty-json] [--app ] [--authenticated] - [--batching-enabled] [--batching-interval ] [--conflation-enabled] [--conflation-interval ] - [--conflation-key ] [--expose-time-serial] [--mutable-messages] [--persist-last] [--persisted] - [--populate-channel-registry] [--push-enabled] [--tls-only] - -ARGUMENTS - NAMEORID Name or ID of the channel rule to update - -FLAGS - -v, --verbose Output verbose logs - --app= The app ID or name (defaults to current app) - --[no-]authenticated Whether channels matching this rule require clients to be authenticated - --[no-]batching-enabled Whether to enable batching for messages on channels matching this rule - --batching-interval= The batching interval for messages on channels matching this rule - --[no-]conflation-enabled Whether to enable conflation for messages on channels matching this rule - --conflation-interval= The conflation interval for messages on channels matching this rule - --conflation-key= The conflation key for messages on channels matching this rule - --[no-]expose-time-serial Whether to expose the time serial for messages on channels matching this rule - --json Output in JSON format - --[no-]mutable-messages Whether messages on channels matching this rule can be updated or deleted after - publishing. Automatically enables message persistence. - --[no-]persist-last Whether to persist only the last message on channels matching this rule - --[no-]persisted Whether messages on channels matching this rule should be persisted - --[no-]populate-channel-registry Whether to populate the channel registry for channels matching this rule - --pretty-json Output in colorized JSON format - --[no-]push-enabled Whether push notifications should be enabled for channels matching this rule - --[no-]tls-only Whether to enforce TLS for channels matching this rule + $ ably apps rules DESCRIPTION - Update a channel rule + Manage Ably channel rules (namespaces) EXAMPLES - $ ably apps channel-rules update chat --persisted + $ ably apps rules list - $ ably apps channel-rules update chat --mutable-messages + $ ably apps rules create --name "chat" --persisted - $ ably apps channel-rules update events --push-enabled=false + $ ably apps rules update chat --push-enabled - $ ably apps channel-rules update notifications --persisted --push-enabled --app "My App" - - $ ably apps channel-rules update chat --persisted --json + $ ably apps rules delete chat ``` -_See code: [src/commands/apps/channel-rules/update.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/channel-rules/update.ts)_ +_See code: [src/commands/apps/rules/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/rules/index.ts)_ -## `ably apps create` +## `ably apps rules create` -Create a new app +Create a channel rule ``` USAGE - $ ably apps create --name [-v] [--json | --pretty-json] [--tls-only] + $ ably apps rules create --name [-v] [--json | --pretty-json] [--app ] [--authenticated] + [--batching-enabled] [--batching-interval ] [--conflation-enabled] [--conflation-interval ] + [--conflation-key ] [--expose-time-serial] [--mutable-messages] [--persist-last] [--persisted] + [--populate-channel-registry] [--push-enabled] [--tls-only] FLAGS - -v, --verbose Output verbose logs - --json Output in JSON format - --name= (required) Name of the app - --pretty-json Output in colorized JSON format - --tls-only Whether the app should accept TLS connections only + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --authenticated Whether channels matching this rule require clients to be authenticated + --batching-enabled Whether to enable batching for messages on channels matching this rule + --batching-interval= The batching interval for messages on channels matching this rule + --conflation-enabled Whether to enable conflation for messages on channels matching this rule + --conflation-interval= The conflation interval for messages on channels matching this rule + --conflation-key= The conflation key for messages on channels matching this rule + --expose-time-serial Whether to expose the time serial for messages on channels matching this rule + --json Output in JSON format + --mutable-messages Whether messages on channels matching this rule can be updated or deleted after + publishing. Automatically enables message persistence. + --name= (required) Name of the channel rule + --persist-last Whether to persist only the last message on channels matching this rule + --persisted Whether messages on channels matching this rule should be persisted + --populate-channel-registry Whether to populate the channel registry for channels matching this rule + --pretty-json Output in colorized JSON format + --push-enabled Whether push notifications should be enabled for channels matching this rule + --tls-only Whether to enforce TLS for channels matching this rule DESCRIPTION - Create a new app + Create a channel rule EXAMPLES - $ ably apps create --name "My New App" + $ ably apps rules create --name "chat" --persisted - $ ably apps create --name "My New App" --tls-only + $ ably apps rules create --name "chat" --mutable-messages - $ ably apps create --name "My New App" --json + $ ably apps rules create --name "events" --push-enabled - $ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps create --name "My New App" + $ ably apps rules create --name "notifications" --persisted --push-enabled --app "My App" + + $ ably apps rules create --name "chat" --persisted --json ``` -_See code: [src/commands/apps/create.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/create.ts)_ +_See code: [src/commands/apps/rules/create.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/rules/create.ts)_ -## `ably apps current` +## `ably apps rules delete NAMEORID` -Show the currently selected app +Delete a channel rule ``` USAGE - $ ably apps current [-v] [--json | --pretty-json] + $ ably apps rules delete NAMEORID [-v] [--json | --pretty-json] [--app ] [-f] + +ARGUMENTS + NAMEORID Name or ID of the channel rule to delete FLAGS + -f, --force Force deletion without confirmation -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) --json Output in JSON format --pretty-json Output in colorized JSON format DESCRIPTION - Show the currently selected app + Delete a channel rule EXAMPLES - $ ably apps current + $ ably apps rules delete chat - $ ably apps current --json + $ ably apps rules delete events --app "My App" - $ ably apps current --pretty-json + $ ably apps rules delete notifications --force + + $ ably apps rules delete chat --json + + $ ably apps rules delete chat --pretty-json ``` -_See code: [src/commands/apps/current.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/current.ts)_ +_See code: [src/commands/apps/rules/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/rules/delete.ts)_ -## `ably apps delete [APPID]` +## `ably apps rules list` -Delete an app +List channel rules for an app ``` USAGE - $ ably apps delete [APPID] [-v] [--json | --pretty-json] [-f] [--app ] - -ARGUMENTS - APPID App ID to delete (uses current app if not specified) + $ ably apps rules list [-v] [--json | --pretty-json] [--app ] FLAGS - -f, --force Skip confirmation prompt -v, --verbose Output verbose logs --app= The app ID or name (defaults to current app) --json Output in JSON format --pretty-json Output in colorized JSON format DESCRIPTION - Delete an app + List channel rules for an app EXAMPLES - $ ably apps delete - - $ ably apps delete app-id + $ ably apps rules list - $ ably apps delete --app app-id + $ ably apps rules list --app my-app-id - $ ABLY_ACCESS_TOKEN="YOUR_ACCESS_TOKEN" ably apps delete app-id - - $ ably apps delete app-id --force + $ ably apps rules list --json - $ ably apps delete app-id --json - - $ ably apps delete app-id --pretty-json + $ ably apps rules list --pretty-json ``` -_See code: [src/commands/apps/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/delete.ts)_ +_See code: [src/commands/apps/rules/list.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/rules/list.ts)_ -## `ably apps list` +## `ably apps rules update NAMEORID` -List all apps in the current account +Update a channel rule ``` USAGE - $ ably apps list [-v] [--json | --pretty-json] + $ ably apps rules update NAMEORID [-v] [--json | --pretty-json] [--app ] [--authenticated] + [--batching-enabled] [--batching-interval ] [--conflation-enabled] [--conflation-interval ] + [--conflation-key ] [--expose-time-serial] [--mutable-messages] [--persist-last] [--persisted] + [--populate-channel-registry] [--push-enabled] [--tls-only] + +ARGUMENTS + NAMEORID Name or ID of the channel rule to update FLAGS - -v, --verbose Output verbose logs - --json Output in JSON format - --pretty-json Output in colorized JSON format + -v, --verbose Output verbose logs + --app= The app ID or name (defaults to current app) + --[no-]authenticated Whether channels matching this rule require clients to be authenticated + --[no-]batching-enabled Whether to enable batching for messages on channels matching this rule + --batching-interval= The batching interval for messages on channels matching this rule + --[no-]conflation-enabled Whether to enable conflation for messages on channels matching this rule + --conflation-interval= The conflation interval for messages on channels matching this rule + --conflation-key= The conflation key for messages on channels matching this rule + --[no-]expose-time-serial Whether to expose the time serial for messages on channels matching this rule + --json Output in JSON format + --[no-]mutable-messages Whether messages on channels matching this rule can be updated or deleted after + publishing. Automatically enables message persistence. + --[no-]persist-last Whether to persist only the last message on channels matching this rule + --[no-]persisted Whether messages on channels matching this rule should be persisted + --[no-]populate-channel-registry Whether to populate the channel registry for channels matching this rule + --pretty-json Output in colorized JSON format + --[no-]push-enabled Whether push notifications should be enabled for channels matching this rule + --[no-]tls-only Whether to enforce TLS for channels matching this rule DESCRIPTION - List all apps in the current account + Update a channel rule EXAMPLES - $ ably apps list + $ ably apps rules update chat --persisted - $ ably apps list --json + $ ably apps rules update chat --mutable-messages - $ ably apps list --pretty-json + $ ably apps rules update events --push-enabled=false + + $ ably apps rules update notifications --persisted --push-enabled --app "My App" + + $ ably apps rules update chat --persisted --json ``` -_See code: [src/commands/apps/list.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/list.ts)_ +_See code: [src/commands/apps/rules/update.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/apps/rules/update.ts)_ ## `ably apps set-apns-p12 ID` diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index a369eb17..564463e4 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -38,9 +38,11 @@ This document outlines the directory structure of the Ably CLI project. │ ├── commands/ # CLI commands (oclif) │ │ ├── accounts/ # Account management (login, logout, list, switch, current) │ │ ├── apps/ # App management (create, list, delete, switch, current, etc.) +│ │ │ ├── rules/ # Channel rules / namespaces (primary path) +│ │ │ └── channel-rules/ # Hidden alias → apps/rules/ │ │ ├── auth/ # Authentication (keys, tokens) │ │ ├── bench/ # Benchmarking (publisher, subscriber) -│ │ ├── channel-rule/ # Channel rules / namespaces +│ │ ├── channel-rule/ # Hidden alias → apps/rules/ │ │ ├── channels/ # Pub/Sub channels (publish, subscribe, presence, history, etc.) │ │ ├── config/ # CLI config management (show, path) │ │ ├── connections/ # Client connections (test) diff --git a/src/base-command.ts b/src/base-command.ts index 38980b03..9d96ba79 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -918,8 +918,8 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { message, timestamp: new Date().toISOString(), }; - // Log directly using console.error for SDK operational errors - console.error(this.formatJsonOutput(errorData, flags)); + // Log to stderr with standard JSON envelope for consistency + this.logToStderr(this.formatJsonRecord("log", errorData, flags)); } // If not verbose JSON and level > 1, suppress non-error SDK logs } else { @@ -1478,6 +1478,11 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { component: string, context?: Record, ): never { + // If error was already handled by a prior fail() call, re-throw it. + // This prevents double error output when fail() is called inside a try + // block (e.g., for validation) and the catch block also calls fail(). + if (error instanceof Error && "oclif" in error) throw error; + const cmdError = CommandError.from(error, context); this.logCliEvent( @@ -1497,7 +1502,16 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { this.exit(1); } - this.error(cmdError.message); + let humanMessage = cmdError.message; + const code = cmdError.code ?? cmdError.context.errorCode; + if (code !== undefined) { + const helpUrl = cmdError.context.helpUrl; + humanMessage += helpUrl + ? `\nAbly error code: ${code} (${helpUrl})` + : `\nAbly error code: ${code}`; + } + + this.error(humanMessage); } /** diff --git a/src/commands/apps/channel-rules/create.ts b/src/commands/apps/channel-rules/create.ts index 6bd2e89a..f83eadc7 100644 --- a/src/commands/apps/channel-rules/create.ts +++ b/src/commands/apps/channel-rules/create.ts @@ -1,184 +1,19 @@ -import { Flags } from "@oclif/core"; +import { Command } from "@oclif/core"; -import { ControlBaseCommand } from "../../../control-base-command.js"; -import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { - formatLabel, - formatSuccess, - formatWarning, -} from "../../../utils/output.js"; +import RulesCreate from "../rules/create.js"; -export default class ChannelRulesCreateCommand extends ControlBaseCommand { - static description = "Create a channel rule"; - - static examples = [ - '$ ably apps channel-rules create --name "chat" --persisted', - '$ ably apps channel-rules create --name "chat" --mutable-messages', - '$ ably apps channel-rules create --name "events" --push-enabled', - '$ ably apps channel-rules create --name "notifications" --persisted --push-enabled --app "My App"', - '$ ably apps channel-rules create --name "chat" --persisted --json', - ]; - - static flags = { - ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - required: false, - }), - authenticated: Flags.boolean({ - description: - "Whether channels matching this rule require clients to be authenticated", - required: false, - }), - "batching-enabled": Flags.boolean({ - description: - "Whether to enable batching for messages on channels matching this rule", - required: false, - }), - "batching-interval": Flags.integer({ - description: - "The batching interval for messages on channels matching this rule", - required: false, - }), - "conflation-enabled": Flags.boolean({ - description: - "Whether to enable conflation for messages on channels matching this rule", - required: false, - }), - "conflation-interval": Flags.integer({ - description: - "The conflation interval for messages on channels matching this rule", - required: false, - }), - "conflation-key": Flags.string({ - description: - "The conflation key for messages on channels matching this rule", - required: false, - }), - "expose-time-serial": Flags.boolean({ - description: - "Whether to expose the time serial for messages on channels matching this rule", - required: false, - }), - "mutable-messages": Flags.boolean({ - description: - "Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.", - required: false, - }), - name: Flags.string({ - description: "Name of the channel rule", - required: true, - }), - "persist-last": Flags.boolean({ - description: - "Whether to persist only the last message on channels matching this rule", - required: false, - }), - persisted: Flags.boolean({ - default: false, - description: - "Whether messages on channels matching this rule should be persisted", - required: false, - }), - "populate-channel-registry": Flags.boolean({ - description: - "Whether to populate the channel registry for channels matching this rule", - required: false, - }), - "push-enabled": Flags.boolean({ - default: false, - description: - "Whether push notifications should be enabled for channels matching this rule", - required: false, - }), - "tls-only": Flags.boolean({ - description: "Whether to enforce TLS for channels matching this rule", - required: false, - }), - }; +export default class ChannelRulesCreateCommand extends Command { + static override args = RulesCreate.args; + static override description = 'Alias for "ably apps rules create"'; + static override flags = RulesCreate.flags; + static override hidden = true; + static isAlias = true; async run(): Promise { - const { flags } = await this.parse(ChannelRulesCreateCommand); - - const appId = await this.requireAppId(flags); - - try { - const controlApi = this.createControlApi(flags); - - // When mutableMessages is enabled, persisted must also be enabled - const mutableMessages = flags["mutable-messages"]; - let persisted = flags.persisted; - - if (mutableMessages) { - persisted = true; - if (!this.shouldOutputJson(flags)) { - this.logToStderr( - formatWarning( - "Message persistence is automatically enabled when mutable messages is enabled.", - ), - ); - } - } - - const namespaceData = { - authenticated: flags.authenticated, - batchingEnabled: flags["batching-enabled"], - batchingInterval: flags["batching-interval"], - id: flags.name, - conflationEnabled: flags["conflation-enabled"], - conflationInterval: flags["conflation-interval"], - conflationKey: flags["conflation-key"], - exposeTimeSerial: flags["expose-time-serial"], - mutableMessages, - persistLast: flags["persist-last"], - persisted, - populateChannelRegistry: flags["populate-channel-registry"], - pushEnabled: flags["push-enabled"], - tlsOnly: flags["tls-only"], - }; - - const createdNamespace = await controlApi.createNamespace( - appId, - namespaceData, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - appId, - rule: { - authenticated: createdNamespace.authenticated, - batchingEnabled: createdNamespace.batchingEnabled, - batchingInterval: createdNamespace.batchingInterval, - conflationEnabled: createdNamespace.conflationEnabled, - conflationInterval: createdNamespace.conflationInterval, - conflationKey: createdNamespace.conflationKey, - created: new Date(createdNamespace.created).toISOString(), - exposeTimeSerial: createdNamespace.exposeTimeSerial, - id: createdNamespace.id, - mutableMessages: createdNamespace.mutableMessages, - name: flags.name, - persistLast: createdNamespace.persistLast, - persisted: createdNamespace.persisted, - populateChannelRegistry: createdNamespace.populateChannelRegistry, - pushEnabled: createdNamespace.pushEnabled, - tlsOnly: createdNamespace.tlsOnly, - }, - timestamp: new Date().toISOString(), - }, - flags, - ); - } else { - this.log(formatSuccess("Channel rule created.")); - this.log(`${formatLabel("ID")} ${createdNamespace.id}`); - for (const line of formatChannelRuleDetails(createdNamespace, { - formatDate: (t) => this.formatDate(t), - })) { - this.log(line); - } - } - } catch (error) { - this.fail(error, flags, "channelRuleCreate", { appId }); - } + this.warn( + '"apps channel-rules create" is deprecated. Use "apps rules create" instead.', + ); + const command = new RulesCreate(this.argv, this.config); + await command.run(); } } diff --git a/src/commands/apps/channel-rules/delete.ts b/src/commands/apps/channel-rules/delete.ts index 431585af..2b372c3a 100644 --- a/src/commands/apps/channel-rules/delete.ts +++ b/src/commands/apps/channel-rules/delete.ts @@ -1,107 +1,19 @@ -import { Args, Flags } from "@oclif/core"; +import { Command } from "@oclif/core"; -import { ControlBaseCommand } from "../../../control-base-command.js"; -import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { formatResource, formatSuccess } from "../../../utils/output.js"; -import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; +import RulesDelete from "../rules/delete.js"; -export default class ChannelRulesDeleteCommand extends ControlBaseCommand { - static args = { - nameOrId: Args.string({ - description: "Name or ID of the channel rule to delete", - required: true, - }), - }; - - static description = "Delete a channel rule"; - - static examples = [ - "$ ably apps channel-rules delete chat", - '$ ably apps channel-rules delete events --app "My App"', - "$ ably apps channel-rules delete notifications --force", - "$ ably apps channel-rules delete chat --json", - "$ ably apps channel-rules delete chat --pretty-json", - ]; - - static flags = { - ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - required: false, - }), - force: Flags.boolean({ - char: "f", - default: false, - description: "Force deletion without confirmation", - required: false, - }), - }; +export default class ChannelRulesDeleteCommand extends Command { + static override args = RulesDelete.args; + static override description = 'Alias for "ably apps rules delete"'; + static override flags = RulesDelete.flags; + static override hidden = true; + static isAlias = true; async run(): Promise { - const { args, flags } = await this.parse(ChannelRulesDeleteCommand); - - const appId = await this.requireAppId(flags); - - try { - const controlApi = this.createControlApi(flags); - // Find the namespace by name or ID - const namespaces = await controlApi.listNamespaces(appId); - const namespace = namespaces.find((n) => n.id === args.nameOrId); - - if (!namespace) { - this.fail( - `Channel rule "${args.nameOrId}" not found`, - flags, - "channelRuleDelete", - { appId }, - ); - } - - // If not using force flag or JSON mode, prompt for confirmation - if (!flags.force && !this.shouldOutputJson(flags)) { - this.log(`\nYou are about to delete the following channel rule:`); - this.log(`ID: ${namespace.id}`); - for (const line of formatChannelRuleDetails(namespace, { - formatDate: (t) => this.formatDate(t), - showTimestamps: true, - })) { - this.log(line); - } - - const confirmed = await promptForConfirmation( - `\nAre you sure you want to delete channel rule with ID "${namespace.id}"?`, - ); - - if (!confirmed) { - // This branch is only reachable when !shouldOutputJson (see outer condition), - // so only human-readable output is needed here. - this.log("Deletion cancelled"); - return; - } - } - - await controlApi.deleteNamespace(appId, namespace.id); - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - appId, - rule: { - id: namespace.id, - }, - timestamp: new Date().toISOString(), - }, - flags, - ); - } else { - this.log( - formatSuccess( - `Channel rule ${formatResource(namespace.id)} deleted.`, - ), - ); - } - } catch (error) { - this.fail(error, flags, "channelRuleDelete", { appId }); - } + this.warn( + '"apps channel-rules delete" is deprecated. Use "apps rules delete" instead.', + ); + const command = new RulesDelete(this.argv, this.config); + await command.run(); } } diff --git a/src/commands/apps/channel-rules/index.ts b/src/commands/apps/channel-rules/index.ts index 8c38da2e..11c3e920 100644 --- a/src/commands/apps/channel-rules/index.ts +++ b/src/commands/apps/channel-rules/index.ts @@ -1,16 +1,17 @@ -import { BaseTopicCommand } from "../../../base-topic-command.js"; +import { Command } from "@oclif/core"; -export default class ChannelRulesIndexCommand extends BaseTopicCommand { - protected topicName = "apps:channel-rules"; - protected commandGroup = "channel rules"; +import RulesIndex from "../rules/index.js"; - static description = "Manage Ably channel rules (namespaces)"; +export default class ChannelRulesIndexCommand extends Command { + static override args = RulesIndex.args; + static override description = 'Alias for "ably apps rules"'; + static override flags = RulesIndex.flags; + static override hidden = true; + static isAlias = true; - static examples = [ - "ably apps channel-rules list", - 'ably apps channel-rules create --name "chat" --persisted', - "ably apps channel-rules update chat --mutable-messages", - "ably apps channel-rules update chat --push-enabled", - "ably apps channel-rules delete chat", - ]; + async run(): Promise { + this.warn('"apps channel-rules" is deprecated. Use "apps rules" instead.'); + const command = new RulesIndex(this.argv, this.config); + await command.run(); + } } diff --git a/src/commands/apps/channel-rules/list.ts b/src/commands/apps/channel-rules/list.ts index 55423e3b..be74395a 100644 --- a/src/commands/apps/channel-rules/list.ts +++ b/src/commands/apps/channel-rules/list.ts @@ -1,108 +1,19 @@ -import { Flags } from "@oclif/core"; -import type { Namespace } from "../../../services/control-api.js"; +import { Command } from "@oclif/core"; -import { ControlBaseCommand } from "../../../control-base-command.js"; -import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { formatHeading } from "../../../utils/output.js"; +import RulesList from "../rules/list.js"; -interface ChannelRuleOutput { - authenticated: boolean; - batchingEnabled: boolean; - batchingInterval: null | number; - conflationEnabled: boolean; - conflationInterval: null | number; - conflationKey: null | string; - created: string; - exposeTimeSerial: boolean; - id: string; - modified: string; - mutableMessages: boolean; - persistLast: boolean; - persisted: boolean; - populateChannelRegistry: boolean; - pushEnabled: boolean; - tlsOnly: boolean; -} - -export default class ChannelRulesListCommand extends ControlBaseCommand { - static description = "List channel rules for an app"; - - static examples = [ - "$ ably apps:channel-rules:list", - "$ ably apps:channel-rules:list --app my-app-id", - "$ ably apps:channel-rules:list --json", - "$ ably apps:channel-rules:list --pretty-json", - ]; - - static flags = { - ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - required: false, - }), - }; +export default class ChannelRulesListCommand extends Command { + static override args = RulesList.args; + static override description = 'Alias for "ably apps rules list"'; + static override flags = RulesList.flags; + static override hidden = true; + static isAlias = true; async run(): Promise { - const { flags } = await this.parse(ChannelRulesListCommand); - const appId = await this.requireAppId(flags); - - try { - const controlApi = this.createControlApi(flags); - const namespaces = await controlApi.listNamespaces(appId); - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - appId, - rules: namespaces.map( - (rule: Namespace): ChannelRuleOutput => ({ - authenticated: rule.authenticated || false, - batchingEnabled: rule.batchingEnabled || false, - batchingInterval: rule.batchingInterval || null, - conflationEnabled: rule.conflationEnabled || false, - conflationInterval: rule.conflationInterval || null, - conflationKey: rule.conflationKey || null, - created: new Date(rule.created).toISOString(), - exposeTimeSerial: rule.exposeTimeSerial || false, - id: rule.id, - modified: new Date(rule.modified).toISOString(), - mutableMessages: rule.mutableMessages || false, - persistLast: rule.persistLast || false, - persisted: rule.persisted || false, - populateChannelRegistry: rule.populateChannelRegistry || false, - pushEnabled: rule.pushEnabled || false, - tlsOnly: rule.tlsOnly || false, - }), - ), - timestamp: new Date().toISOString(), - total: namespaces.length, - }, - flags, - ); - } else { - if (namespaces.length === 0) { - this.log("No channel rules found"); - return; - } - - this.log(`Found ${namespaces.length} channel rules:\n`); - - namespaces.forEach((namespace: Namespace) => { - this.log(formatHeading(`Channel Rule ID: ${namespace.id}`)); - for (const line of formatChannelRuleDetails(namespace, { - bold: true, - formatDate: (t) => this.formatDate(t), - indent: " ", - showTimestamps: true, - })) { - this.log(line); - } - - this.log(""); // Add a blank line between rules - }); - } - } catch (error) { - this.fail(error, flags, "channelRuleList", { appId }); - } + this.warn( + '"apps channel-rules list" is deprecated. Use "apps rules list" instead.', + ); + const command = new RulesList(this.argv, this.config); + await command.run(); } } diff --git a/src/commands/apps/channel-rules/update.ts b/src/commands/apps/channel-rules/update.ts index 5f5c8904..79fd98f0 100644 --- a/src/commands/apps/channel-rules/update.ts +++ b/src/commands/apps/channel-rules/update.ts @@ -1,268 +1,19 @@ -import { Args, Flags } from "@oclif/core"; +import { Command } from "@oclif/core"; -import { ControlBaseCommand } from "../../../control-base-command.js"; -import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; -import { - formatLabel, - formatSuccess, - formatWarning, -} from "../../../utils/output.js"; +import RulesUpdate from "../rules/update.js"; -export default class ChannelRulesUpdateCommand extends ControlBaseCommand { - static args = { - nameOrId: Args.string({ - description: "Name or ID of the channel rule to update", - required: true, - }), - }; - - static description = "Update a channel rule"; - - static examples = [ - "$ ably apps channel-rules update chat --persisted", - "$ ably apps channel-rules update chat --mutable-messages", - "$ ably apps channel-rules update events --push-enabled=false", - '$ ably apps channel-rules update notifications --persisted --push-enabled --app "My App"', - "$ ably apps channel-rules update chat --persisted --json", - ]; - - static flags = { - ...ControlBaseCommand.globalFlags, - app: Flags.string({ - description: "The app ID or name (defaults to current app)", - required: false, - }), - authenticated: Flags.boolean({ - allowNo: true, - description: - "Whether channels matching this rule require clients to be authenticated", - required: false, - }), - "batching-enabled": Flags.boolean({ - allowNo: true, - description: - "Whether to enable batching for messages on channels matching this rule", - required: false, - }), - "batching-interval": Flags.integer({ - description: - "The batching interval for messages on channels matching this rule", - required: false, - }), - "conflation-enabled": Flags.boolean({ - allowNo: true, - description: - "Whether to enable conflation for messages on channels matching this rule", - required: false, - }), - "conflation-interval": Flags.integer({ - description: - "The conflation interval for messages on channels matching this rule", - required: false, - }), - "conflation-key": Flags.string({ - description: - "The conflation key for messages on channels matching this rule", - required: false, - }), - "expose-time-serial": Flags.boolean({ - allowNo: true, - description: - "Whether to expose the time serial for messages on channels matching this rule", - required: false, - }), - "mutable-messages": Flags.boolean({ - allowNo: true, - description: - "Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.", - required: false, - }), - "persist-last": Flags.boolean({ - allowNo: true, - description: - "Whether to persist only the last message on channels matching this rule", - required: false, - }), - persisted: Flags.boolean({ - allowNo: true, - description: - "Whether messages on channels matching this rule should be persisted", - required: false, - }), - "populate-channel-registry": Flags.boolean({ - allowNo: true, - description: - "Whether to populate the channel registry for channels matching this rule", - required: false, - }), - "push-enabled": Flags.boolean({ - allowNo: true, - description: - "Whether push notifications should be enabled for channels matching this rule", - required: false, - }), - "tls-only": Flags.boolean({ - allowNo: true, - description: "Whether to enforce TLS for channels matching this rule", - required: false, - }), - }; +export default class ChannelRulesUpdateCommand extends Command { + static override args = RulesUpdate.args; + static override description = 'Alias for "ably apps rules update"'; + static override flags = RulesUpdate.flags; + static override hidden = true; + static isAlias = true; async run(): Promise { - const { args, flags } = await this.parse(ChannelRulesUpdateCommand); - - const appId = await this.requireAppId(flags); - - try { - const controlApi = this.createControlApi(flags); - // Find the namespace by name or ID - const namespaces = await controlApi.listNamespaces(appId); - const namespace = namespaces.find((n) => n.id === args.nameOrId); - - if (!namespace) { - this.fail( - `Channel rule "${args.nameOrId}" not found`, - flags, - "channelRuleUpdate", - { appId }, - ); - } - - // Prepare update data - const updateData: Record = - {}; - - // Validation for mutable-messages flag, checks with supplied/existing mutableMessages flag - if ( - flags.persisted === false && - (flags["mutable-messages"] === true || - (flags["mutable-messages"] === undefined && - namespace.mutableMessages)) - ) { - this.fail( - "Cannot disable persistence when mutable messages is enabled. Mutable messages requires message persistence.", - flags, - "channelRuleUpdate", - { appId, ruleId: namespace.id }, - ); - } - - if (flags.persisted !== undefined) { - updateData.persisted = flags.persisted; - } - - if (flags["mutable-messages"] !== undefined) { - updateData.mutableMessages = flags["mutable-messages"]; - if (flags["mutable-messages"]) { - updateData.persisted = true; - if (!this.shouldOutputJson(flags)) { - this.logToStderr( - formatWarning( - "Message persistence is automatically enabled when mutable messages is enabled.", - ), - ); - } - } - } - - if (flags["push-enabled"] !== undefined) { - updateData.pushEnabled = flags["push-enabled"]; - } - - if (flags.authenticated !== undefined) { - updateData.authenticated = flags.authenticated; - } - - if (flags["persist-last"] !== undefined) { - updateData.persistLast = flags["persist-last"]; - } - - if (flags["expose-time-serial"] !== undefined) { - updateData.exposeTimeSerial = flags["expose-time-serial"]; - } - - if (flags["populate-channel-registry"] !== undefined) { - updateData.populateChannelRegistry = flags["populate-channel-registry"]; - } - - if (flags["batching-enabled"] !== undefined) { - updateData.batchingEnabled = flags["batching-enabled"]; - } - - if (flags["batching-interval"] !== undefined) { - updateData.batchingInterval = flags["batching-interval"]; - } - - if (flags["conflation-enabled"] !== undefined) { - updateData.conflationEnabled = flags["conflation-enabled"]; - } - - if (flags["conflation-interval"] !== undefined) { - updateData.conflationInterval = flags["conflation-interval"]; - } - - if (flags["conflation-key"] !== undefined) { - updateData.conflationKey = flags["conflation-key"]; - } - - if (flags["tls-only"] !== undefined) { - updateData.tlsOnly = flags["tls-only"]; - } - - // Check if there's anything to update - if (Object.keys(updateData).length === 0) { - this.fail( - "No update parameters provided. Use one of the flag options to update the channel rule.", - flags, - "channelRuleUpdate", - { appId, ruleId: namespace.id }, - ); - } - - const updatedNamespace = await controlApi.updateNamespace( - appId, - namespace.id, - updateData, - ); - - if (this.shouldOutputJson(flags)) { - this.logJsonResult( - { - appId, - rule: { - authenticated: updatedNamespace.authenticated, - batchingEnabled: updatedNamespace.batchingEnabled, - batchingInterval: updatedNamespace.batchingInterval, - conflationEnabled: updatedNamespace.conflationEnabled, - conflationInterval: updatedNamespace.conflationInterval, - conflationKey: updatedNamespace.conflationKey, - created: new Date(updatedNamespace.created).toISOString(), - exposeTimeSerial: updatedNamespace.exposeTimeSerial, - id: updatedNamespace.id, - modified: new Date(updatedNamespace.modified).toISOString(), - mutableMessages: updatedNamespace.mutableMessages, - persistLast: updatedNamespace.persistLast, - persisted: updatedNamespace.persisted, - populateChannelRegistry: updatedNamespace.populateChannelRegistry, - pushEnabled: updatedNamespace.pushEnabled, - tlsOnly: updatedNamespace.tlsOnly, - }, - timestamp: new Date().toISOString(), - }, - flags, - ); - } else { - this.log(formatSuccess("Channel rule updated.")); - this.log(`${formatLabel("ID")} ${updatedNamespace.id}`); - for (const line of formatChannelRuleDetails(updatedNamespace, { - formatDate: (t) => this.formatDate(t), - showTimestamps: true, - })) { - this.log(line); - } - } - } catch (error) { - this.fail(error, flags, "channelRuleUpdate", { appId }); - } + this.warn( + '"apps channel-rules update" is deprecated. Use "apps rules update" instead.', + ); + const command = new RulesUpdate(this.argv, this.config); + await command.run(); } } diff --git a/src/commands/apps/index.ts b/src/commands/apps/index.ts index 4427ffc4..143bac14 100644 --- a/src/commands/apps/index.ts +++ b/src/commands/apps/index.ts @@ -12,7 +12,7 @@ export default class AppsCommand extends BaseTopicCommand { "$ ably apps update", "$ ably apps delete", "$ ably apps set-apns-p12", - "$ ably apps channel-rules list", + "$ ably apps rules list", "$ ably apps switch my-app", ]; } diff --git a/src/commands/apps/rules/create.ts b/src/commands/apps/rules/create.ts new file mode 100644 index 00000000..94178807 --- /dev/null +++ b/src/commands/apps/rules/create.ts @@ -0,0 +1,189 @@ +import { Flags } from "@oclif/core"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; +import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; +import { + formatLabel, + formatResource, + formatSuccess, + formatWarning, +} from "../../../utils/output.js"; + +export default class RulesCreateCommand extends ControlBaseCommand { + static description = "Create a channel rule"; + + static examples = [ + '$ ably apps rules create --name "chat" --persisted', + '$ ably apps rules create --name "chat" --mutable-messages', + '$ ably apps rules create --name "events" --push-enabled', + '$ ably apps rules create --name "notifications" --persisted --push-enabled --app "My App"', + '$ ably apps rules create --name "chat" --persisted --json', + ]; + + static flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "The app ID or name (defaults to current app)", + required: false, + }), + authenticated: Flags.boolean({ + description: + "Whether channels matching this rule require clients to be authenticated", + required: false, + }), + "batching-enabled": Flags.boolean({ + description: + "Whether to enable batching for messages on channels matching this rule", + required: false, + }), + "batching-interval": Flags.integer({ + description: + "The batching interval for messages on channels matching this rule", + required: false, + }), + "conflation-enabled": Flags.boolean({ + description: + "Whether to enable conflation for messages on channels matching this rule", + required: false, + }), + "conflation-interval": Flags.integer({ + description: + "The conflation interval for messages on channels matching this rule", + required: false, + }), + "conflation-key": Flags.string({ + description: + "The conflation key for messages on channels matching this rule", + required: false, + }), + "expose-time-serial": Flags.boolean({ + description: + "Whether to expose the time serial for messages on channels matching this rule", + required: false, + }), + "mutable-messages": Flags.boolean({ + description: + "Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.", + required: false, + }), + name: Flags.string({ + description: "Name of the channel rule", + required: true, + }), + "persist-last": Flags.boolean({ + description: + "Whether to persist only the last message on channels matching this rule", + required: false, + }), + persisted: Flags.boolean({ + default: false, + description: + "Whether messages on channels matching this rule should be persisted", + required: false, + }), + "populate-channel-registry": Flags.boolean({ + description: + "Whether to populate the channel registry for channels matching this rule", + required: false, + }), + "push-enabled": Flags.boolean({ + default: false, + description: + "Whether push notifications should be enabled for channels matching this rule", + required: false, + }), + "tls-only": Flags.boolean({ + description: "Whether to enforce TLS for channels matching this rule", + required: false, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(RulesCreateCommand); + + const appId = await this.requireAppId(flags); + + try { + const controlApi = this.createControlApi(flags); + + // When mutableMessages is enabled, persisted must also be enabled + const mutableMessages = flags["mutable-messages"]; + let persisted = flags.persisted; + + if (mutableMessages) { + persisted = true; + if (!this.shouldOutputJson(flags)) { + this.logToStderr( + formatWarning( + "Message persistence is automatically enabled when mutable messages is enabled.", + ), + ); + } + } + + const namespaceData = { + authenticated: flags.authenticated, + batchingEnabled: flags["batching-enabled"], + batchingInterval: flags["batching-interval"], + id: flags.name, + conflationEnabled: flags["conflation-enabled"], + conflationInterval: flags["conflation-interval"], + conflationKey: flags["conflation-key"], + exposeTimeSerial: flags["expose-time-serial"], + mutableMessages, + persistLast: flags["persist-last"], + persisted, + populateChannelRegistry: flags["populate-channel-registry"], + pushEnabled: flags["push-enabled"], + tlsOnly: flags["tls-only"], + }; + + const createdNamespace = await controlApi.createNamespace( + appId, + namespaceData, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + appId, + rule: { + authenticated: createdNamespace.authenticated, + batchingEnabled: createdNamespace.batchingEnabled, + batchingInterval: createdNamespace.batchingInterval, + conflationEnabled: createdNamespace.conflationEnabled, + conflationInterval: createdNamespace.conflationInterval, + conflationKey: createdNamespace.conflationKey, + created: new Date(createdNamespace.created).toISOString(), + exposeTimeSerial: createdNamespace.exposeTimeSerial, + id: createdNamespace.id, + mutableMessages: createdNamespace.mutableMessages, + name: flags.name, + persistLast: createdNamespace.persistLast, + persisted: createdNamespace.persisted, + populateChannelRegistry: createdNamespace.populateChannelRegistry, + pushEnabled: createdNamespace.pushEnabled, + tlsOnly: createdNamespace.tlsOnly, + }, + timestamp: new Date().toISOString(), + }, + flags, + ); + } else { + this.log( + formatSuccess( + "Channel rule " + formatResource(createdNamespace.id) + " created.", + ), + ); + this.log(`${formatLabel("ID")} ${formatResource(createdNamespace.id)}`); + for (const line of formatChannelRuleDetails(createdNamespace, { + formatDate: (t) => this.formatDate(t), + })) { + this.log(line); + } + } + } catch (error) { + this.fail(error, flags, "ruleCreate", { appId }); + } + } +} diff --git a/src/commands/apps/rules/delete.ts b/src/commands/apps/rules/delete.ts new file mode 100644 index 00000000..9c05b38e --- /dev/null +++ b/src/commands/apps/rules/delete.ts @@ -0,0 +1,111 @@ +import { Args, Flags } from "@oclif/core"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; +import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; +import { + formatLabel, + formatResource, + formatSuccess, +} from "../../../utils/output.js"; +import { promptForConfirmation } from "../../../utils/prompt-confirmation.js"; + +export default class RulesDeleteCommand extends ControlBaseCommand { + static args = { + nameOrId: Args.string({ + description: "Name or ID of the channel rule to delete", + required: true, + }), + }; + + static description = "Delete a channel rule"; + + static examples = [ + "$ ably apps rules delete chat", + '$ ably apps rules delete events --app "My App"', + "$ ably apps rules delete notifications --force", + "$ ably apps rules delete chat --json", + "$ ably apps rules delete chat --pretty-json", + ]; + + static flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "The app ID or name (defaults to current app)", + required: false, + }), + force: Flags.boolean({ + char: "f", + default: false, + description: "Force deletion without confirmation", + required: false, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(RulesDeleteCommand); + + const appId = await this.requireAppId(flags); + + try { + const controlApi = this.createControlApi(flags); + // Find the namespace by name or ID + const namespaces = await controlApi.listNamespaces(appId); + const namespace = namespaces.find((n) => n.id === args.nameOrId); + + if (!namespace) { + this.fail( + `Channel rule "${args.nameOrId}" not found`, + flags, + "ruleDelete", + { appId }, + ); + } + + // If not using force flag or JSON mode, prompt for confirmation + if (!flags.force && !this.shouldOutputJson(flags)) { + this.log(`\nYou are about to delete the following channel rule:`); + this.log(`${formatLabel("ID")} ${formatResource(namespace.id)}`); + for (const line of formatChannelRuleDetails(namespace, { + formatDate: (t) => this.formatDate(t), + showTimestamps: true, + })) { + this.log(line); + } + + const confirmed = await promptForConfirmation( + `\nAre you sure you want to delete channel rule with ID "${namespace.id}"?`, + ); + + if (!confirmed) { + // This branch is only reachable when !shouldOutputJson (see outer condition), + // so only human-readable output is needed here. + this.log("Deletion cancelled"); + return; + } + } + + await controlApi.deleteNamespace(appId, namespace.id); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + appId, + rule: { + id: namespace.id, + }, + timestamp: new Date().toISOString(), + }, + flags, + ); + } else { + this.log( + formatSuccess( + `Channel rule ${formatResource(namespace.id)} deleted.`, + ), + ); + } + } catch (error) { + this.fail(error, flags, "ruleDelete", { appId }); + } + } +} diff --git a/src/commands/apps/rules/index.ts b/src/commands/apps/rules/index.ts new file mode 100644 index 00000000..7b15c952 --- /dev/null +++ b/src/commands/apps/rules/index.ts @@ -0,0 +1,15 @@ +import { BaseTopicCommand } from "../../../base-topic-command.js"; + +export default class RulesIndexCommand extends BaseTopicCommand { + protected topicName = "apps:rules"; + protected commandGroup = "channel rules"; + + static description = "Manage Ably channel rules (namespaces)"; + + static examples = [ + "$ ably apps rules list", + '$ ably apps rules create --name "chat" --persisted', + "$ ably apps rules update chat --push-enabled", + "$ ably apps rules delete chat", + ]; +} diff --git a/src/commands/apps/rules/list.ts b/src/commands/apps/rules/list.ts new file mode 100644 index 00000000..4c0b798e --- /dev/null +++ b/src/commands/apps/rules/list.ts @@ -0,0 +1,110 @@ +import { Flags } from "@oclif/core"; +import type { Namespace } from "../../../services/control-api.js"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; +import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; +import { formatCountLabel, formatHeading } from "../../../utils/output.js"; + +interface ChannelRuleOutput { + authenticated: boolean; + batchingEnabled: boolean; + batchingInterval: null | number; + conflationEnabled: boolean; + conflationInterval: null | number; + conflationKey: null | string; + created: string; + exposeTimeSerial: boolean; + id: string; + modified: string; + mutableMessages: boolean; + persistLast: boolean; + persisted: boolean; + populateChannelRegistry: boolean; + pushEnabled: boolean; + tlsOnly: boolean; +} + +export default class RulesListCommand extends ControlBaseCommand { + static description = "List channel rules for an app"; + + static examples = [ + "$ ably apps rules list", + "$ ably apps rules list --app my-app-id", + "$ ably apps rules list --json", + "$ ably apps rules list --pretty-json", + ]; + + static flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "The app ID or name (defaults to current app)", + required: false, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(RulesListCommand); + const appId = await this.requireAppId(flags); + + try { + const controlApi = this.createControlApi(flags); + const namespaces = await controlApi.listNamespaces(appId); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + appId, + rules: namespaces.map( + (rule: Namespace): ChannelRuleOutput => ({ + authenticated: rule.authenticated || false, + batchingEnabled: rule.batchingEnabled || false, + batchingInterval: rule.batchingInterval ?? null, + conflationEnabled: rule.conflationEnabled || false, + conflationInterval: rule.conflationInterval ?? null, + conflationKey: rule.conflationKey ?? null, + created: new Date(rule.created).toISOString(), + exposeTimeSerial: rule.exposeTimeSerial || false, + id: rule.id, + modified: new Date(rule.modified).toISOString(), + mutableMessages: rule.mutableMessages || false, + persistLast: rule.persistLast || false, + persisted: rule.persisted || false, + populateChannelRegistry: rule.populateChannelRegistry || false, + pushEnabled: rule.pushEnabled || false, + tlsOnly: rule.tlsOnly || false, + }), + ), + timestamp: new Date().toISOString(), + total: namespaces.length, + }, + flags, + ); + } else { + if (namespaces.length === 0) { + this.log("No channel rules found"); + return; + } + + this.log( + `Found ${formatCountLabel(namespaces.length, "channel rule")}:\n`, + ); + + namespaces.forEach((namespace: Namespace) => { + this.log(formatHeading(`Channel Rule ID: ${namespace.id}`)); + for (const line of formatChannelRuleDetails(namespace, { + bold: true, + formatDate: (t) => this.formatDate(t), + indent: " ", + showTimestamps: true, + })) { + this.log(line); + } + + this.log(""); // Add a blank line between rules + }); + } + } catch (error) { + this.fail(error, flags, "ruleList", { appId }); + } + } +} diff --git a/src/commands/apps/rules/update.ts b/src/commands/apps/rules/update.ts new file mode 100644 index 00000000..8be33ad6 --- /dev/null +++ b/src/commands/apps/rules/update.ts @@ -0,0 +1,273 @@ +import { Args, Flags } from "@oclif/core"; + +import { ControlBaseCommand } from "../../../control-base-command.js"; +import { formatChannelRuleDetails } from "../../../utils/channel-rule-display.js"; +import { + formatLabel, + formatResource, + formatSuccess, + formatWarning, +} from "../../../utils/output.js"; + +export default class RulesUpdateCommand extends ControlBaseCommand { + static args = { + nameOrId: Args.string({ + description: "Name or ID of the channel rule to update", + required: true, + }), + }; + + static description = "Update a channel rule"; + + static examples = [ + "$ ably apps rules update chat --persisted", + "$ ably apps rules update chat --mutable-messages", + "$ ably apps rules update events --push-enabled=false", + '$ ably apps rules update notifications --persisted --push-enabled --app "My App"', + "$ ably apps rules update chat --persisted --json", + ]; + + static flags = { + ...ControlBaseCommand.globalFlags, + app: Flags.string({ + description: "The app ID or name (defaults to current app)", + required: false, + }), + authenticated: Flags.boolean({ + allowNo: true, + description: + "Whether channels matching this rule require clients to be authenticated", + required: false, + }), + "batching-enabled": Flags.boolean({ + allowNo: true, + description: + "Whether to enable batching for messages on channels matching this rule", + required: false, + }), + "batching-interval": Flags.integer({ + description: + "The batching interval for messages on channels matching this rule", + required: false, + }), + "conflation-enabled": Flags.boolean({ + allowNo: true, + description: + "Whether to enable conflation for messages on channels matching this rule", + required: false, + }), + "conflation-interval": Flags.integer({ + description: + "The conflation interval for messages on channels matching this rule", + required: false, + }), + "conflation-key": Flags.string({ + description: + "The conflation key for messages on channels matching this rule", + required: false, + }), + "expose-time-serial": Flags.boolean({ + allowNo: true, + description: + "Whether to expose the time serial for messages on channels matching this rule", + required: false, + }), + "mutable-messages": Flags.boolean({ + allowNo: true, + description: + "Whether messages on channels matching this rule can be updated or deleted after publishing. Automatically enables message persistence.", + required: false, + }), + "persist-last": Flags.boolean({ + allowNo: true, + description: + "Whether to persist only the last message on channels matching this rule", + required: false, + }), + persisted: Flags.boolean({ + allowNo: true, + description: + "Whether messages on channels matching this rule should be persisted", + required: false, + }), + "populate-channel-registry": Flags.boolean({ + allowNo: true, + description: + "Whether to populate the channel registry for channels matching this rule", + required: false, + }), + "push-enabled": Flags.boolean({ + allowNo: true, + description: + "Whether push notifications should be enabled for channels matching this rule", + required: false, + }), + "tls-only": Flags.boolean({ + allowNo: true, + description: "Whether to enforce TLS for channels matching this rule", + required: false, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(RulesUpdateCommand); + + const appId = await this.requireAppId(flags); + + try { + const controlApi = this.createControlApi(flags); + // Find the namespace by name or ID + const namespaces = await controlApi.listNamespaces(appId); + const namespace = namespaces.find((n) => n.id === args.nameOrId); + + if (!namespace) { + this.fail( + `Channel rule "${args.nameOrId}" not found`, + flags, + "ruleUpdate", + { appId }, + ); + } + + // Prepare update data + const updateData: Record = + {}; + + // Validation for mutable-messages flag, checks with supplied/existing mutableMessages flag + if ( + flags.persisted === false && + (flags["mutable-messages"] === true || + (flags["mutable-messages"] === undefined && + namespace.mutableMessages)) + ) { + this.fail( + "Cannot disable persistence when mutable messages is enabled. Mutable messages requires message persistence.", + flags, + "ruleUpdate", + { appId, ruleId: namespace.id }, + ); + } + + if (flags.persisted !== undefined) { + updateData.persisted = flags.persisted; + } + + if (flags["mutable-messages"] !== undefined) { + updateData.mutableMessages = flags["mutable-messages"]; + if (flags["mutable-messages"]) { + updateData.persisted = true; + if (!this.shouldOutputJson(flags)) { + this.logToStderr( + formatWarning( + "Message persistence is automatically enabled when mutable messages is enabled.", + ), + ); + } + } + } + + if (flags["push-enabled"] !== undefined) { + updateData.pushEnabled = flags["push-enabled"]; + } + + if (flags.authenticated !== undefined) { + updateData.authenticated = flags.authenticated; + } + + if (flags["persist-last"] !== undefined) { + updateData.persistLast = flags["persist-last"]; + } + + if (flags["expose-time-serial"] !== undefined) { + updateData.exposeTimeSerial = flags["expose-time-serial"]; + } + + if (flags["populate-channel-registry"] !== undefined) { + updateData.populateChannelRegistry = flags["populate-channel-registry"]; + } + + if (flags["batching-enabled"] !== undefined) { + updateData.batchingEnabled = flags["batching-enabled"]; + } + + if (flags["batching-interval"] !== undefined) { + updateData.batchingInterval = flags["batching-interval"]; + } + + if (flags["conflation-enabled"] !== undefined) { + updateData.conflationEnabled = flags["conflation-enabled"]; + } + + if (flags["conflation-interval"] !== undefined) { + updateData.conflationInterval = flags["conflation-interval"]; + } + + if (flags["conflation-key"] !== undefined) { + updateData.conflationKey = flags["conflation-key"]; + } + + if (flags["tls-only"] !== undefined) { + updateData.tlsOnly = flags["tls-only"]; + } + + // Check if there's anything to update + if (Object.keys(updateData).length === 0) { + this.fail( + "No update parameters provided. Use one of the flag options to update the channel rule.", + flags, + "ruleUpdate", + { appId, ruleId: namespace.id }, + ); + } + + const updatedNamespace = await controlApi.updateNamespace( + appId, + namespace.id, + updateData, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + appId, + rule: { + authenticated: updatedNamespace.authenticated, + batchingEnabled: updatedNamespace.batchingEnabled, + batchingInterval: updatedNamespace.batchingInterval, + conflationEnabled: updatedNamespace.conflationEnabled, + conflationInterval: updatedNamespace.conflationInterval, + conflationKey: updatedNamespace.conflationKey, + created: new Date(updatedNamespace.created).toISOString(), + exposeTimeSerial: updatedNamespace.exposeTimeSerial, + id: updatedNamespace.id, + modified: new Date(updatedNamespace.modified).toISOString(), + mutableMessages: updatedNamespace.mutableMessages, + persistLast: updatedNamespace.persistLast, + persisted: updatedNamespace.persisted, + populateChannelRegistry: updatedNamespace.populateChannelRegistry, + pushEnabled: updatedNamespace.pushEnabled, + tlsOnly: updatedNamespace.tlsOnly, + }, + timestamp: new Date().toISOString(), + }, + flags, + ); + } else { + this.log( + formatSuccess( + `Channel rule ${formatResource(updatedNamespace.id)} updated.`, + ), + ); + this.log(`${formatLabel("ID")} ${formatResource(updatedNamespace.id)}`); + for (const line of formatChannelRuleDetails(updatedNamespace, { + formatDate: (t) => this.formatDate(t), + showTimestamps: true, + })) { + this.log(line); + } + } + } catch (error) { + this.fail(error, flags, "ruleUpdate", { appId }); + } + } +} diff --git a/src/commands/channel-rule/create.ts b/src/commands/channel-rule/create.ts index f0933932..e724c19a 100644 --- a/src/commands/channel-rule/create.ts +++ b/src/commands/channel-rule/create.ts @@ -1,19 +1,19 @@ import { Command } from "@oclif/core"; -import ChannelRulesCreate from "../apps/channel-rules/create.js"; +import RulesCreate from "../apps/rules/create.js"; export default class ChannelRuleCreate extends Command { - static override args = ChannelRulesCreate.args; - static override description = 'Alias for "ably apps channel-rules create"'; - static override flags = ChannelRulesCreate.flags; + static override args = RulesCreate.args; + static override description = 'Alias for "ably apps rules create"'; + static override flags = RulesCreate.flags; static override hidden = true; - - // Special property to identify this as an alias command static isAlias = true; async run(): Promise { - // Forward to the channel-rules create command - const command = new ChannelRulesCreate(this.argv, this.config); + this.warn( + '"channel-rule create" is deprecated. Use "apps rules create" instead.', + ); + const command = new RulesCreate(this.argv, this.config); await command.run(); } } diff --git a/src/commands/channel-rule/delete.ts b/src/commands/channel-rule/delete.ts index acd7a8b2..0acb1582 100644 --- a/src/commands/channel-rule/delete.ts +++ b/src/commands/channel-rule/delete.ts @@ -1,19 +1,19 @@ import { Command } from "@oclif/core"; -import ChannelRulesDelete from "../apps/channel-rules/delete.js"; +import RulesDelete from "../apps/rules/delete.js"; export default class ChannelRuleDelete extends Command { - static override args = ChannelRulesDelete.args; - static override description = 'Alias for "ably apps channel-rules delete"'; - static override flags = ChannelRulesDelete.flags; + static override args = RulesDelete.args; + static override description = 'Alias for "ably apps rules delete"'; + static override flags = RulesDelete.flags; static override hidden = true; - - // Special property to identify this as an alias command static isAlias = true; async run(): Promise { - // Forward to the channel-rules delete command - const command = new ChannelRulesDelete(this.argv, this.config); + this.warn( + '"channel-rule delete" is deprecated. Use "apps rules delete" instead.', + ); + const command = new RulesDelete(this.argv, this.config); await command.run(); } } diff --git a/src/commands/channel-rule/index.ts b/src/commands/channel-rule/index.ts index f04042c3..11c860de 100644 --- a/src/commands/channel-rule/index.ts +++ b/src/commands/channel-rule/index.ts @@ -1,19 +1,17 @@ import { Command } from "@oclif/core"; -import ChannelRules from "../apps/channel-rules/index.js"; +import RulesIndex from "../apps/rules/index.js"; export default class ChannelRule extends Command { - static override args = ChannelRules.args; - static override description = 'Alias for "ably apps channel-rules"'; - static override flags = ChannelRules.flags; + static override args = RulesIndex.args; + static override description = 'Alias for "ably apps rules"'; + static override flags = RulesIndex.flags; static override hidden = true; - - // Special property to identify this as an alias command static isAlias = true; async run(): Promise { - // Forward to the channel-rules command using static run method - // Direct forward since it's an alias - await ChannelRules.run(this.argv, this.config); + this.warn('"channel-rule" is deprecated. Use "apps rules" instead.'); + const command = new RulesIndex(this.argv, this.config); + await command.run(); } } diff --git a/src/commands/channel-rule/list.ts b/src/commands/channel-rule/list.ts index b4d67bbc..4e75dc77 100644 --- a/src/commands/channel-rule/list.ts +++ b/src/commands/channel-rule/list.ts @@ -1,19 +1,19 @@ import { Command } from "@oclif/core"; -import ChannelRulesList from "../apps/channel-rules/list.js"; +import RulesList from "../apps/rules/list.js"; export default class ChannelRuleList extends Command { - static override args = ChannelRulesList.args; - static override description = 'Alias for "ably apps channel-rules list"'; - static override flags = ChannelRulesList.flags; + static override args = RulesList.args; + static override description = 'Alias for "ably apps rules list"'; + static override flags = RulesList.flags; static override hidden = true; - - // Special property to identify this as an alias command static isAlias = true; async run(): Promise { - // Forward to the channel-rules list command - const command = new ChannelRulesList(this.argv, this.config); + this.warn( + '"channel-rule list" is deprecated. Use "apps rules list" instead.', + ); + const command = new RulesList(this.argv, this.config); await command.run(); } } diff --git a/src/commands/channel-rule/update.ts b/src/commands/channel-rule/update.ts index be9d6be3..12121c8f 100644 --- a/src/commands/channel-rule/update.ts +++ b/src/commands/channel-rule/update.ts @@ -1,19 +1,19 @@ import { Command } from "@oclif/core"; -import ChannelRulesUpdate from "../apps/channel-rules/update.js"; +import RulesUpdate from "../apps/rules/update.js"; export default class ChannelRuleUpdate extends Command { - static override args = ChannelRulesUpdate.args; - static override description = 'Alias for "ably apps channel-rules update"'; - static override flags = ChannelRulesUpdate.flags; + static override args = RulesUpdate.args; + static override description = 'Alias for "ably apps rules update"'; + static override flags = RulesUpdate.flags; static override hidden = true; - - // Special property to identify this as an alias command static isAlias = true; async run(): Promise { - // Forward to the channel-rules update command - const command = new ChannelRulesUpdate(this.argv, this.config); + this.warn( + '"channel-rule update" is deprecated. Use "apps rules update" instead.', + ); + const command = new RulesUpdate(this.argv, this.config); await command.run(); } } diff --git a/src/errors/command-error.ts b/src/errors/command-error.ts index cfe4592d..c95a9ab4 100644 --- a/src/errors/command-error.ts +++ b/src/errors/command-error.ts @@ -50,6 +50,7 @@ export class CommandError extends Error { const errWithCode = error as Error & { code?: number | string; statusCode?: number; + href?: string; }; // Duck-type Ably ErrorInfo: has numeric code and statusCode @@ -57,19 +58,27 @@ export class CommandError extends Error { typeof errWithCode.code === "number" && typeof errWithCode.statusCode === "number" ) { + const errorContext: Record = { ...context }; + if (typeof errWithCode.href === "string") { + errorContext.helpUrl = errWithCode.href; + } return new CommandError(error.message, { code: errWithCode.code, statusCode: errWithCode.statusCode, - context, + context: errorContext, cause: error, }); } // Error with numeric .code only if (typeof errWithCode.code === "number") { + const errorContext: Record = { ...context }; + if (typeof errWithCode.href === "string") { + errorContext.helpUrl = errWithCode.href; + } return new CommandError(error.message, { code: errWithCode.code, - context, + context: errorContext, cause: error, }); } diff --git a/src/services/control-api.ts b/src/services/control-api.ts index 4da1c8c3..804c7b81 100644 --- a/src/services/control-api.ts +++ b/src/services/control-api.ts @@ -1,6 +1,6 @@ import fetch, { type RequestInit } from "node-fetch"; +import { CommandError } from "../errors/command-error.js"; import { getCliVersion } from "../utils/version.js"; -import isTestMode from "../utils/test-mode.js"; export interface ControlApiOptions { accessToken: string; @@ -167,24 +167,10 @@ export interface MeResponse { export class ControlApi { private accessToken: string; private controlHost: string; - private logErrors: boolean; constructor(options: ControlApiOptions) { this.accessToken = options.accessToken; this.controlHost = options.controlHost || "control.ably.net"; - // Respect SUPPRESS_CONTROL_API_ERRORS env var for default behavior - // Explicit options.logErrors will override the env var. - // eslint-disable-next-line unicorn/no-negated-condition - if (options.logErrors !== undefined) { - this.logErrors = options.logErrors; - } else { - // Determine logErrors based on environment variables - const suppressErrors = - process.env.SUPPRESS_CONTROL_API_ERRORS === "true" || - process.env.CI === "true" || - isTestMode(); - this.logErrors = !suppressErrors; - } } // Ask a question to the Ably AI agent @@ -545,24 +531,7 @@ export class ControlApi { /* Ignore parsing errors, keep as string */ } - const errorDetails = { - message: `API request failed with status ${response.status}: ${response.statusText}`, - response: responseData, // Assign unknown type - statusCode: response.status, - }; - - // Log the error for debugging purposes, but not during tests - if (this.logErrors) { - console.error("Control API Request Error:", { - message: errorDetails.message, - method, - response: errorDetails.response || "No response body", - statusCode: errorDetails.statusCode, - url, - }); - } - - // Throw a more user-friendly error, including the message from the response if available + // Build a user-friendly error message, including the message from the response if available let errorMessage = `API request failed (${response.status} ${response.statusText})`; if ( typeof responseData === "object" && @@ -578,7 +547,23 @@ export class ControlApi { // Include short string responses directly errorMessage += `: ${responseData}`; } - throw new Error(errorMessage); + + // Extract structured error fields from the API response + const errorContext: Record = {}; + if (typeof responseData === "object" && responseData !== null) { + const data = responseData as Record; + if (typeof data.code === "number") { + errorContext.errorCode = data.code; + } + if (typeof data.href === "string") { + errorContext.helpUrl = data.href; + } + } + + throw new CommandError(errorMessage, { + statusCode: response.status, + context: errorContext, + }); } if (response.status === 204) { diff --git a/src/utils/channel-rule-display.ts b/src/utils/channel-rule-display.ts index bb56c0ad..07d5e72b 100644 --- a/src/utils/channel-rule-display.ts +++ b/src/utils/channel-rule-display.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import type { Namespace } from "../services/control-api.js"; +import { formatLabel } from "./output.js"; function boolField(value: boolean | undefined): string { return value ? chalk.green("Yes") : "No"; @@ -39,36 +40,44 @@ export function formatChannelRuleDetails( const lines: string[] = []; lines.push( - `${indent}Persisted: ${bool(rule.persisted)}`, - `${indent}Push Enabled: ${bool(rule.pushEnabled)}`, + `${indent}${formatLabel("Persisted")} ${bool(rule.persisted)}`, + `${indent}${formatLabel("Push Enabled")} ${bool(rule.pushEnabled)}`, ); if (rule.mutableMessages !== undefined) { - lines.push(`${indent}Mutable Messages: ${bool(rule.mutableMessages)}`); + lines.push( + `${indent}${formatLabel("Mutable Messages")} ${bool(rule.mutableMessages)}`, + ); } if (rule.authenticated !== undefined) { - lines.push(`${indent}Authenticated: ${bool(rule.authenticated)}`); + lines.push( + `${indent}${formatLabel("Authenticated")} ${bool(rule.authenticated)}`, + ); } if (rule.persistLast !== undefined) { lines.push( - `${indent}Persist Last${bold ? " Message" : ""}: ${bool(rule.persistLast)}`, + `${indent}${formatLabel(`Persist Last${bold ? " Message" : ""}`)} ${bool(rule.persistLast)}`, ); } if (rule.exposeTimeSerial !== undefined) { - lines.push(`${indent}Expose Time Serial: ${bool(rule.exposeTimeSerial)}`); + lines.push( + `${indent}${formatLabel("Expose Time Serial")} ${bool(rule.exposeTimeSerial)}`, + ); } if (rule.populateChannelRegistry !== undefined) { lines.push( - `${indent}Populate Channel Registry: ${bool(rule.populateChannelRegistry)}`, + `${indent}${formatLabel("Populate Channel Registry")} ${bool(rule.populateChannelRegistry)}`, ); } if (rule.batchingEnabled !== undefined) { - lines.push(`${indent}Batching Enabled: ${bool(rule.batchingEnabled)}`); + lines.push( + `${indent}${formatLabel("Batching Enabled")} ${bool(rule.batchingEnabled)}`, + ); } if ( @@ -76,12 +85,14 @@ export function formatChannelRuleDetails( rule.batchingInterval !== 0 ) { lines.push( - `${indent}Batching Interval: ${bold ? chalk.bold.green(`✓ ${rule.batchingInterval}`) : chalk.green(rule.batchingInterval.toString())}`, + `${indent}${formatLabel("Batching Interval")} ${bold ? chalk.bold.green(`✓ ${rule.batchingInterval}`) : chalk.green(rule.batchingInterval.toString())}`, ); } if (rule.conflationEnabled !== undefined) { - lines.push(`${indent}Conflation Enabled: ${bool(rule.conflationEnabled)}`); + lines.push( + `${indent}${formatLabel("Conflation Enabled")} ${bool(rule.conflationEnabled)}`, + ); } if ( @@ -89,24 +100,28 @@ export function formatChannelRuleDetails( rule.conflationInterval !== 0 ) { lines.push( - `${indent}Conflation Interval: ${bold ? chalk.bold.green(`✓ ${rule.conflationInterval}`) : chalk.green(rule.conflationInterval.toString())}`, + `${indent}${formatLabel("Conflation Interval")} ${bold ? chalk.bold.green(`✓ ${rule.conflationInterval}`) : chalk.green(rule.conflationInterval.toString())}`, ); } if (rule.conflationKey && rule.conflationKey !== "") { lines.push( - `${indent}Conflation Key: ${bold ? chalk.bold.green(`✓ ${rule.conflationKey}`) : chalk.green(rule.conflationKey)}`, + `${indent}${formatLabel("Conflation Key")} ${bold ? chalk.bold.green(`✓ ${rule.conflationKey}`) : chalk.green(rule.conflationKey)}`, ); } if (rule.tlsOnly !== undefined) { - lines.push(`${indent}TLS Only: ${bool(rule.tlsOnly)}`); + lines.push(`${indent}${formatLabel("TLS Only")} ${bool(rule.tlsOnly)}`); } if (showTimestamps && formatDate) { - lines.push(`${indent}Created: ${formatDate(rule.created)}`); + lines.push( + `${indent}${formatLabel("Created")} ${formatDate(rule.created)}`, + ); if (rule.modified) { - lines.push(`${indent}Updated: ${formatDate(rule.modified)}`); + lines.push( + `${indent}${formatLabel("Updated")} ${formatDate(rule.modified)}`, + ); } } diff --git a/test/fixtures/control-api.ts b/test/fixtures/control-api.ts index f8c599be..d2f50824 100644 --- a/test/fixtures/control-api.ts +++ b/test/fixtures/control-api.ts @@ -133,6 +133,7 @@ export interface MockNamespace { id: string; persisted: boolean; pushEnabled: boolean; + mutableMessages?: boolean; created: number; modified: number; } diff --git a/test/unit/commands/apps/channel-rules/create.test.ts b/test/unit/commands/apps/channel-rules/create.test.ts index 486fa7cc..b181f7ab 100644 --- a/test/unit/commands/apps/channel-rules/create.test.ts +++ b/test/unit/commands/apps/channel-rules/create.test.ts @@ -5,202 +5,27 @@ import { controlApiCleanup, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardFlagTests, - standardControlApiErrorTests, -} from "../../../../helpers/standard-tests.js"; -import { mockNamespace } from "../../../../fixtures/control-api.js"; - -describe("apps:channel-rules:create command", () => { - const mockRuleName = "chat"; - const mockRuleId = "chat"; +describe("apps:channel-rules:create alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should create a channel rule successfully", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .post(`/v1/apps/${appId}/namespaces`) - .reply(201, mockNamespace()); - - const { stdout } = await runCommand( - ["apps:channel-rules:create", "--name", mockRuleName], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule created."); - expect(stdout).toContain(mockRuleId); - }); - - it("should create a channel rule with persisted flag", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .post(`/v1/apps/${appId}/namespaces`, (body) => { - return body.persisted === true; - }) - .reply(201, mockNamespace({ persisted: true })); - - const { stdout } = await runCommand( - ["apps:channel-rules:create", "--name", mockRuleName, "--persisted"], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule created."); - expect(stdout).toContain("Persisted: Yes"); - }); - - it("should create a channel rule with push-enabled flag", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .post(`/v1/apps/${appId}/namespaces`, (body) => { - return body.pushEnabled === true; - }) - .reply(201, mockNamespace({ pushEnabled: true })); - - const { stdout } = await runCommand( - ["apps:channel-rules:create", "--name", mockRuleName, "--push-enabled"], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule created."); - expect(stdout).toContain("Push Enabled: Yes"); - }); - - it("should create a channel rule with mutable-messages flag and auto-enable persisted", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .post(`/v1/apps/${appId}/namespaces`, (body) => { - return body.mutableMessages === true && body.persisted === true; - }) - .reply(201, { - ...mockNamespace({ persisted: true }), - mutableMessages: true, - }); - - const { stdout, stderr } = await runCommand( - [ - "apps:channel-rules:create", - "--name", - mockRuleName, - "--mutable-messages", - ], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule created."); - expect(stdout).toContain("Persisted: Yes"); - expect(stdout).toContain("Mutable Messages: Yes"); - expect(stderr).toContain( - "Message persistence is automatically enabled when mutable messages is enabled.", - ); - }); - - it("should include mutableMessages in JSON output when --mutable-messages is used", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .post(`/v1/apps/${appId}/namespaces`, (body) => { - return body.mutableMessages === true && body.persisted === true; - }) - .reply(201, { - ...mockNamespace({ persisted: true }), - mutableMessages: true, - }); - - const { stdout, stderr } = await runCommand( - [ - "apps:channel-rules:create", - "--name", - mockRuleName, - "--mutable-messages", - "--json", - ], - import.meta.url, - ); - - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", true); - expect(result.rule).toHaveProperty("mutableMessages", true); - expect(result.rule).toHaveProperty("persisted", true); - // Warning should not appear in JSON mode - expect(stderr).not.toContain("Warning"); + it("should forward to apps:rules:create and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl().post(`/v1/apps/${appId}/namespaces`).reply(201, { + id: "chat", + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), }); - it("should output JSON format when --json flag is used", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; + const { stdout } = await runCommand( + ["apps:channel-rules:create", "--name", "chat"], + import.meta.url, + ); - nockControl() - .post(`/v1/apps/${appId}/namespaces`) - .reply(201, mockNamespace()); - - const { stdout } = await runCommand( - ["apps:channel-rules:create", "--name", mockRuleName, "--json"], - import.meta.url, - ); - - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("rule"); - expect(result.rule).toHaveProperty("id", mockRuleId); - }); - }); - - standardHelpTests("apps:channel-rules:create", import.meta.url); - - describe("argument validation", () => { - it("should require --name flag", async () => { - const { error } = await runCommand( - ["apps:channel-rules:create"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing required flag.*name/); - }); - }); - - standardFlagTests("apps:channel-rules:create", import.meta.url, ["--json"]); - - describe("error handling", () => { - standardControlApiErrorTests({ - commandArgs: ["apps:channel-rules:create", "--name", "chat"], - importMetaUrl: import.meta.url, - setupNock: (scenario) => { - const appId = getMockConfigManager().getCurrentAppId()!; - const scope = nockControl().post(`/v1/apps/${appId}/namespaces`); - if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); - else if (scenario === "500") - scope.reply(500, { error: "Internal Server Error" }); - else scope.replyWithError("Network error"); - }, - }); - - it("should require name parameter", async () => { - const { error } = await runCommand( - ["apps:channel-rules:create"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing required flag.*name/); - }); - - it("should handle 400 validation error", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .post(`/v1/apps/${appId}/namespaces`) - .reply(400, { error: "Validation failed" }); - - const { error } = await runCommand( - ["apps:channel-rules:create", "--name", mockRuleName], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/400/); - }); + expect(stdout).toContain("Channel rule chat created."); }); }); diff --git a/test/unit/commands/apps/channel-rules/delete.test.ts b/test/unit/commands/apps/channel-rules/delete.test.ts index 6d627f3e..0772bc70 100644 --- a/test/unit/commands/apps/channel-rules/delete.test.ts +++ b/test/unit/commands/apps/channel-rules/delete.test.ts @@ -5,105 +5,33 @@ import { controlApiCleanup, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, - standardControlApiErrorTests, -} from "../../../../helpers/standard-tests.js"; -import { mockNamespace } from "../../../../fixtures/control-api.js"; - -describe("apps:channel-rules:delete command", () => { - const mockRuleId = "chat"; +describe("apps:channel-rules:delete alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should delete a channel rule with force flag", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - // Mock listing namespaces to find the rule - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - // Mock delete endpoint - nockControl() - .delete(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(204); - - const { stdout } = await runCommand( - ["apps:channel-rules:delete", mockRuleId, "--force"], - import.meta.url, - ); - - expect(stdout).toContain("deleted"); - }); - - it("should output JSON format when --json flag is used", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - nockControl() - .delete(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(204); - - const { stdout } = await runCommand( - ["apps:channel-rules:delete", mockRuleId, "--force", "--json"], - import.meta.url, - ); - - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("rule"); - expect(result.rule).toHaveProperty("id", mockRuleId); - }); - }); - - standardHelpTests("apps:channel-rules:delete", import.meta.url); - standardArgValidationTests("apps:channel-rules:delete", import.meta.url, { - requiredArgs: ["test-rule"], - }); - standardFlagTests("apps:channel-rules:delete", import.meta.url, ["--json"]); - - describe("error handling", () => { - standardControlApiErrorTests({ - commandArgs: ["apps:channel-rules:delete", "chat", "--force"], - importMetaUrl: import.meta.url, - setupNock: (scenario) => { - const appId = getMockConfigManager().getCurrentAppId()!; - const scope = nockControl().get(`/v1/apps/${appId}/namespaces`); - if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); - else if (scenario === "500") - scope.reply(500, { error: "Internal Server Error" }); - else scope.replyWithError("Network error"); - }, - }); - - it("should require nameOrId argument", async () => { - const { error } = await runCommand( - ["apps:channel-rules:delete"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing 1 required arg/); - }); - - it("should handle channel rule not found", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); - - const { error } = await runCommand( - ["apps:channel-rules:delete", "nonexistent", "--force"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/not found/); - }); + it("should forward to apps:rules:delete and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + { + id: "chat", + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nockControl().delete(`/v1/apps/${appId}/namespaces/chat`).reply(204); + + const { stdout } = await runCommand( + ["apps:channel-rules:delete", "chat", "--force"], + import.meta.url, + ); + + expect(stdout).toContain("deleted"); }); }); diff --git a/test/unit/commands/apps/channel-rules/list.test.ts b/test/unit/commands/apps/channel-rules/list.test.ts index 8a99fab8..b6e83e9c 100644 --- a/test/unit/commands/apps/channel-rules/list.test.ts +++ b/test/unit/commands/apps/channel-rules/list.test.ts @@ -5,151 +5,32 @@ import { controlApiCleanup, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, - standardControlApiErrorTests, -} from "../../../../helpers/standard-tests.js"; -import { mockNamespace } from "../../../../fixtures/control-api.js"; -describe("apps:channel-rules:list command", () => { +describe("apps:channel-rules:list alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should list channel rules successfully", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - const mockRules = [ - mockNamespace({ persisted: true }), - mockNamespace({ id: "events", pushEnabled: true }), - ]; - - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, mockRules); - - const { stdout } = await runCommand( - ["apps:channel-rules:list"], - import.meta.url, - ); - - expect(stdout).toContain("Found 2 channel rules"); - expect(stdout).toContain("chat"); - expect(stdout).toContain("events"); - }); - - it("should handle empty rules list", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); - - const { stdout } = await runCommand( - ["apps:channel-rules:list"], - import.meta.url, - ); - - expect(stdout).toContain("No channel rules found"); - }); - - it("should display rule details correctly", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - const mockRules = [ - { - ...mockNamespace({ persisted: true, pushEnabled: true }), - authenticated: true, - tlsOnly: true, - }, - ]; - - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, mockRules); - - const { stdout } = await runCommand( - ["apps:channel-rules:list"], - import.meta.url, - ); - - expect(stdout).toContain("Found 1 channel rules"); - expect(stdout).toContain("chat"); - expect(stdout).toContain("Persisted: ✓ Yes"); - expect(stdout).toContain("Push Enabled: ✓ Yes"); - }); - - it("should display mutableMessages in rule details", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - const mockRules = [ + it("should forward to apps:rules:list and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ { - ...mockNamespace({ id: "mutable-chat", persisted: true }), - mutableMessages: true, + id: "chat", + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), }, - ]; - - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, mockRules); - - const { stdout } = await runCommand( - ["apps:channel-rules:list"], - import.meta.url, - ); - - expect(stdout).toContain("Found 1 channel rules"); - expect(stdout).toContain("mutable-chat"); - expect(stdout).toContain("Mutable Messages: ✓ Yes"); - }); - - it("should include mutableMessages in JSON output", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - const mockRules = [ - { - ...mockNamespace({ id: "mutable-chat", persisted: true }), - mutableMessages: true, - }, - mockNamespace({ id: "regular-chat" }), - ]; - - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, mockRules); - - const { stdout } = await runCommand( - ["apps:channel-rules:list", "--json"], - import.meta.url, - ); - - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", true); - expect(result.rules).toHaveLength(2); - expect(result.rules[0]).toHaveProperty("mutableMessages", true); - expect(result.rules[1]).toHaveProperty("mutableMessages", false); - }); - }); - - standardHelpTests("apps:channel-rules:list", import.meta.url); - standardArgValidationTests("apps:channel-rules:list", import.meta.url); - standardFlagTests("apps:channel-rules:list", import.meta.url, ["--json"]); - - describe("error handling", () => { - standardControlApiErrorTests({ - commandArgs: ["apps:channel-rules:list"], - importMetaUrl: import.meta.url, - setupNock: (scenario) => { - const appId = getMockConfigManager().getCurrentAppId()!; - const scope = nockControl().get(`/v1/apps/${appId}/namespaces`); - if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); - else if (scenario === "500") - scope.reply(500, { error: "Internal Server Error" }); - else scope.replyWithError("Network error"); - }, - }); - - it("should handle 404 not found error", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(404, { error: "App not found" }); + ]); - const { error } = await runCommand( - ["apps:channel-rules:list"], - import.meta.url, - ); + const { stdout } = await runCommand( + ["apps:channel-rules:list"], + import.meta.url, + ); - expect(error).toBeDefined(); - expect(error?.message).toMatch(/404/); - }); + expect(stdout).toContain("Found 1 channel rule"); + expect(stdout).toContain("chat"); }); }); diff --git a/test/unit/commands/apps/channel-rules/update.test.ts b/test/unit/commands/apps/channel-rules/update.test.ts index dcea7f3e..30bfbb8a 100644 --- a/test/unit/commands/apps/channel-rules/update.test.ts +++ b/test/unit/commands/apps/channel-rules/update.test.ts @@ -5,267 +5,40 @@ import { controlApiCleanup, } from "../../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, - standardControlApiErrorTests, -} from "../../../../helpers/standard-tests.js"; -import { mockNamespace } from "../../../../fixtures/control-api.js"; - -describe("apps:channel-rules:update command", () => { - const mockRuleId = "chat"; +describe("apps:channel-rules:update alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should update a channel rule with persisted flag", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - // Mock listing namespaces to find the rule - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - // Mock update endpoint - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(200, mockNamespace({ persisted: true })); - - const { stdout } = await runCommand( - ["apps:channel-rules:update", mockRuleId, "--persisted"], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule updated."); - expect(stdout).toContain("Persisted: Yes"); - }); - - it("should update a channel rule with push-enabled flag", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(200, mockNamespace({ pushEnabled: true })); - - const { stdout } = await runCommand( - ["apps:channel-rules:update", mockRuleId, "--push-enabled"], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule updated."); - expect(stdout).toContain("Push Enabled: Yes"); - }); - - it("should update a channel rule with mutable-messages flag and auto-enable persisted", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => { - return body.mutableMessages === true && body.persisted === true; - }) - .reply(200, { - ...mockNamespace({ persisted: true }), - mutableMessages: true, - }); - - const { stdout, stderr } = await runCommand( - ["apps:channel-rules:update", mockRuleId, "--mutable-messages"], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule updated."); - expect(stdout).toContain("Persisted: Yes"); - expect(stdout).toContain("Mutable Messages: Yes"); - expect(stderr).toContain( - "Message persistence is automatically enabled when mutable messages is enabled.", - ); - }); - - it("should error when --mutable-messages is used with --no-persisted", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace({ persisted: true })]); - - const { error } = await runCommand( - [ - "apps:channel-rules:update", - mockRuleId, - "--mutable-messages", - "--no-persisted", - ], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch( - /Cannot disable persistence when mutable messages is enabled/, - ); - }); - - it("should allow --no-mutable-messages --no-persisted to disable both", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [ - { ...mockNamespace({ persisted: true }), mutableMessages: true }, - ]); - - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => { - return body.mutableMessages === false && body.persisted === false; - }) - .reply(200, { ...mockNamespace(), mutableMessages: false }); - - const { stdout } = await runCommand( - [ - "apps:channel-rules:update", - mockRuleId, - "--no-mutable-messages", - "--no-persisted", - ], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule updated."); - expect(stdout).toContain("Persisted: No"); - }); - - it("should error when --no-persisted is used while existing rule has mutable messages", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [ - { ...mockNamespace({ persisted: true }), mutableMessages: true }, - ]); - - const { error } = await runCommand( - ["apps:channel-rules:update", mockRuleId, "--no-persisted"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch( - /Cannot disable persistence when mutable messages is enabled/, - ); - }); - - it("should include mutableMessages in JSON output when updating", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(200, { - ...mockNamespace({ persisted: true }), - mutableMessages: true, - }); - - const { stdout } = await runCommand( - [ - "apps:channel-rules:update", - mockRuleId, - "--mutable-messages", - "--json", - ], - import.meta.url, - ); - - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", true); - expect(result.rule).toHaveProperty("mutableMessages", true); - expect(result.rule).toHaveProperty("persisted", true); - }); - - it("should output JSON format when --json flag is used", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(200, mockNamespace({ persisted: true })); - - const { stdout } = await runCommand( - ["apps:channel-rules:update", mockRuleId, "--persisted", "--json"], - import.meta.url, - ); - - const result = JSON.parse(stdout); - expect(result).toHaveProperty("success", true); - expect(result).toHaveProperty("rule"); - expect(result.rule).toHaveProperty("id", mockRuleId); - expect(result.rule).toHaveProperty("persisted", true); - }); - }); - - standardHelpTests("apps:channel-rules:update", import.meta.url); - standardArgValidationTests("apps:channel-rules:update", import.meta.url, { - requiredArgs: ["test-rule"], - }); - standardFlagTests("apps:channel-rules:update", import.meta.url, ["--json"]); - - describe("error handling", () => { - standardControlApiErrorTests({ - commandArgs: ["apps:channel-rules:update", "chat", "--persisted"], - importMetaUrl: import.meta.url, - setupNock: (scenario) => { - const appId = getMockConfigManager().getCurrentAppId()!; - const scope = nockControl().get(`/v1/apps/${appId}/namespaces`); - if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); - else if (scenario === "500") - scope.reply(500, { error: "Internal Server Error" }); - else scope.replyWithError("Network error"); - }, - }); - - it("should require nameOrId argument", async () => { - const { error } = await runCommand( - ["apps:channel-rules:update", "--persisted"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing 1 required arg/); - }); - - it("should require at least one update parameter", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [mockNamespace()]); - - const { error } = await runCommand( - ["apps:channel-rules:update", mockRuleId], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/No update parameters provided/); - }); - - it("should handle channel rule not found", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); - - const { error } = await runCommand( - ["apps:channel-rules:update", "nonexistent", "--persisted"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/not found/); - }); + it("should forward to apps:rules:update and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + { + id: "chat", + persisted: false, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }, + ]); + + nockControl().patch(`/v1/apps/${appId}/namespaces/chat`).reply(200, { + id: "chat", + persisted: true, + pushEnabled: false, + created: Date.now(), + modified: Date.now(), + }); + + const { stdout } = await runCommand( + ["apps:channel-rules:update", "chat", "--persisted"], + import.meta.url, + ); + + expect(stdout).toContain("updated"); + expect(stdout).toContain("Persisted: Yes"); }); }); diff --git a/test/unit/commands/apps/rules/create.test.ts b/test/unit/commands/apps/rules/create.test.ts new file mode 100644 index 00000000..9c4516dc --- /dev/null +++ b/test/unit/commands/apps/rules/create.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { + nockControl, + controlApiCleanup, +} from "../../../../helpers/control-api-test-helpers.js"; +import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, + standardControlApiErrorTests, +} from "../../../../helpers/standard-tests.js"; +import { mockNamespace } from "../../../../fixtures/control-api.js"; + +describe("apps:rules:create command", () => { + const mockRuleName = "chat"; + const mockRuleId = "chat"; + + afterEach(() => { + controlApiCleanup(); + }); + + standardHelpTests("apps:rules:create", import.meta.url); + standardArgValidationTests("apps:rules:create", import.meta.url); + standardFlagTests("apps:rules:create", import.meta.url, [ + "--json", + "--app", + "--name", + "--persisted", + "--push-enabled", + "--mutable-messages", + ]); + + describe("functionality", () => { + it("should create a channel rule successfully", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`) + .reply(201, mockNamespace({ id: mockRuleId })); + + const { stdout } = await runCommand( + ["apps:rules:create", "--name", mockRuleName], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule chat created."); + }); + + it("should create a channel rule with persisted flag", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.persisted === true; + }) + .reply(201, mockNamespace({ id: mockRuleId, persisted: true })); + + const { stdout } = await runCommand( + ["apps:rules:create", "--name", mockRuleName, "--persisted"], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule chat created."); + expect(stdout).toContain("Persisted: Yes"); + }); + + it("should create a channel rule with mutable-messages flag and auto-enable persistence", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.mutableMessages === true && body.persisted === true; + }) + .reply( + 201, + mockNamespace({ + id: mockRuleId, + persisted: true, + mutableMessages: true, + }), + ); + + const { stdout, stderr } = await runCommand( + ["apps:rules:create", "--name", mockRuleName, "--mutable-messages"], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule chat created."); + expect(stdout).toContain("Persisted: Yes"); + expect(stdout).toContain("Mutable Messages: Yes"); + expect(stderr).toContain("persistence is automatically enabled"); + }); + + it("should create a channel rule with push-enabled flag", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.pushEnabled === true; + }) + .reply(201, mockNamespace({ id: mockRuleId, pushEnabled: true })); + + const { stdout } = await runCommand( + ["apps:rules:create", "--name", mockRuleName, "--push-enabled"], + import.meta.url, + ); + + expect(stdout).toContain("Channel rule chat created."); + expect(stdout).toContain("Push Enabled: Yes"); + }); + + it("should output JSON format when --json flag is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`) + .reply(201, mockNamespace({ id: mockRuleId })); + + const { stdout } = await runCommand( + ["apps:rules:create", "--name", mockRuleName, "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + }); + + it("should include mutableMessages in JSON output when --mutable-messages is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`, (body) => { + return body.mutableMessages === true && body.persisted === true; + }) + .reply( + 201, + mockNamespace({ + id: mockRuleId, + persisted: true, + mutableMessages: true, + }), + ); + + const { stdout } = await runCommand( + [ + "apps:rules:create", + "--name", + mockRuleName, + "--mutable-messages", + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result.rule).toHaveProperty("persisted", true); + expect(result.rule).toHaveProperty("mutableMessages", true); + }); + }); + + describe("error handling", () => { + standardControlApiErrorTests({ + commandArgs: ["apps:rules:create", "--name", mockRuleName], + importMetaUrl: import.meta.url, + setupNock: (scenario) => { + const appId = getMockConfigManager().getCurrentAppId()!; + const scope = nockControl().post(`/v1/apps/${appId}/namespaces`); + if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); + else if (scenario === "500") + scope.reply(500, { error: "Internal Server Error" }); + else scope.replyWithError("Network error"); + }, + }); + + it("should handle 400 validation error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`) + .reply(400, { error: "Validation failed" }); + + const { error } = await runCommand( + ["apps:rules:create", "--name", mockRuleName], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/400/); + }); + }); +}); diff --git a/test/unit/commands/apps/rules/delete.test.ts b/test/unit/commands/apps/rules/delete.test.ts new file mode 100644 index 00000000..441a27a7 --- /dev/null +++ b/test/unit/commands/apps/rules/delete.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { + nockControl, + controlApiCleanup, +} from "../../../../helpers/control-api-test-helpers.js"; +import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, + standardControlApiErrorTests, +} from "../../../../helpers/standard-tests.js"; +import { mockNamespace } from "../../../../fixtures/control-api.js"; + +describe("apps:rules:delete command", () => { + const mockRuleId = "chat"; + + afterEach(() => { + controlApiCleanup(); + }); + + standardHelpTests("apps:rules:delete", import.meta.url); + standardArgValidationTests("apps:rules:delete", import.meta.url, { + requiredArgs: [mockRuleId], + }); + standardFlagTests("apps:rules:delete", import.meta.url, [ + "--json", + "--app", + "--force", + ]); + + describe("functionality", () => { + it("should delete a channel rule with force flag", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .delete(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + ["apps:rules:delete", mockRuleId, "--force"], + import.meta.url, + ); + + expect(stdout).toContain("deleted"); + }); + + it("should output JSON format when --json flag is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .delete(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(204); + + const { stdout } = await runCommand( + ["apps:rules:delete", mockRuleId, "--force", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + }); + }); + + describe("error handling", () => { + standardControlApiErrorTests({ + commandArgs: ["apps:rules:delete", mockRuleId, "--force"], + importMetaUrl: import.meta.url, + setupNock: (scenario) => { + const appId = getMockConfigManager().getCurrentAppId()!; + const scope = nockControl().get(`/v1/apps/${appId}/namespaces`); + if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); + else if (scenario === "500") + scope.reply(500, { error: "Internal Server Error" }); + else scope.replyWithError("Network error"); + }, + }); + + it("should handle channel rule not found", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); + + const { error } = await runCommand( + ["apps:rules:delete", "nonexistent", "--force"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/not found/); + }); + }); +}); diff --git a/test/unit/commands/apps/rules/index.test.ts b/test/unit/commands/apps/rules/index.test.ts new file mode 100644 index 00000000..e4e1b40b --- /dev/null +++ b/test/unit/commands/apps/rules/index.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { runCommand } from "@oclif/test"; +import { standardHelpTests } from "../../../../helpers/standard-tests.js"; + +describe("apps:rules topic command", () => { + standardHelpTests("apps:rules", import.meta.url); + + describe("argument validation", () => { + it("should handle unknown subcommand gracefully", async () => { + const { stdout } = await runCommand( + ["apps:rules", "nonexistent"], + import.meta.url, + ); + expect(stdout).toBeDefined(); + }); + }); + + describe("functionality", () => { + it("should list available subcommands", async () => { + const { stdout } = await runCommand( + ["apps:rules", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("create"); + expect(stdout).toContain("list"); + expect(stdout).toContain("update"); + expect(stdout).toContain("delete"); + }); + + it("should not show hidden channel-rules alias in apps help", async () => { + const { stdout } = await runCommand(["apps", "--help"], import.meta.url); + expect(stdout).not.toContain("channel-rules"); + }); + + it("should not show hidden channel-rule alias in top-level help", async () => { + const { stdout } = await runCommand(["--help"], import.meta.url); + expect(stdout).not.toContain("channel-rule"); + }); + }); + + describe("flags", () => { + it("should show usage information in help", async () => { + const { stdout } = await runCommand( + ["apps:rules", "--help"], + import.meta.url, + ); + expect(stdout).toContain("USAGE"); + }); + }); + + describe("error handling", () => { + it("should not crash with no arguments", async () => { + const { stdout } = await runCommand(["apps:rules"], import.meta.url); + expect(stdout).toBeDefined(); + }); + }); +}); diff --git a/test/unit/commands/apps/rules/list.test.ts b/test/unit/commands/apps/rules/list.test.ts new file mode 100644 index 00000000..57e06499 --- /dev/null +++ b/test/unit/commands/apps/rules/list.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { + nockControl, + controlApiCleanup, +} from "../../../../helpers/control-api-test-helpers.js"; +import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, + standardControlApiErrorTests, +} from "../../../../helpers/standard-tests.js"; +import { mockNamespace } from "../../../../fixtures/control-api.js"; + +describe("apps:rules:list command", () => { + afterEach(() => { + controlApiCleanup(); + }); + + standardHelpTests("apps:rules:list", import.meta.url); + standardArgValidationTests("apps:rules:list", import.meta.url); + standardFlagTests("apps:rules:list", import.meta.url, ["--json", "--app"]); + + describe("functionality", () => { + it("should list channel rules successfully", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + mockNamespace({ id: "chat", persisted: true }), + mockNamespace({ id: "events", pushEnabled: true }), + ]); + + const { stdout } = await runCommand(["apps:rules:list"], import.meta.url); + + expect(stdout).toContain("Found 2 channel rules"); + expect(stdout).toContain("chat"); + expect(stdout).toContain("events"); + }); + + it("should handle empty rules list", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); + + const { stdout } = await runCommand(["apps:rules:list"], import.meta.url); + + expect(stdout).toContain("No channel rules found"); + }); + + it("should display rule details correctly", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + mockNamespace({ + id: "chat", + persisted: true, + pushEnabled: true, + }), + ]); + + const { stdout } = await runCommand(["apps:rules:list"], import.meta.url); + + expect(stdout).toContain("Found 1 channel rule"); + expect(stdout).toContain("chat"); + expect(stdout).toContain("Persisted: ✓ Yes"); + expect(stdout).toContain("Push Enabled: ✓ Yes"); + }); + + it("should display mutableMessages in rule details", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + mockNamespace({ + id: "mutable-chat", + persisted: true, + mutableMessages: true, + }), + ]); + + const { stdout } = await runCommand(["apps:rules:list"], import.meta.url); + + expect(stdout).toContain("mutable-chat"); + expect(stdout).toContain("Mutable Messages:"); + }); + + it("should output JSON format when --json flag is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: "chat", persisted: true })]); + + const { stdout } = await runCommand( + ["apps:rules:list", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rules"); + expect(result.rules).toHaveLength(1); + }); + + it("should include mutableMessages in JSON output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + mockNamespace({ id: "mutable-chat", persisted: true }), + mockNamespace({ id: "regular-chat" }), + ]); + + const { stdout } = await runCommand( + ["apps:rules:list", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result.rules).toHaveLength(2); + }); + }); + + describe("error handling", () => { + standardControlApiErrorTests({ + commandArgs: ["apps:rules:list"], + importMetaUrl: import.meta.url, + setupNock: (scenario) => { + const appId = getMockConfigManager().getCurrentAppId()!; + const scope = nockControl().get(`/v1/apps/${appId}/namespaces`); + if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); + else if (scenario === "500") + scope.reply(500, { error: "Internal Server Error" }); + else scope.replyWithError("Network error"); + }, + }); + + it("should handle 404 not found error", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(404, { error: "App not found" }); + + const { error } = await runCommand(["apps:rules:list"], import.meta.url); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/404/); + }); + }); +}); diff --git a/test/unit/commands/apps/rules/update.test.ts b/test/unit/commands/apps/rules/update.test.ts new file mode 100644 index 00000000..82834d16 --- /dev/null +++ b/test/unit/commands/apps/rules/update.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { + nockControl, + controlApiCleanup, +} from "../../../../helpers/control-api-test-helpers.js"; +import { getMockConfigManager } from "../../../../helpers/mock-config-manager.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, + standardControlApiErrorTests, +} from "../../../../helpers/standard-tests.js"; +import { mockNamespace } from "../../../../fixtures/control-api.js"; + +describe("apps:rules:update command", () => { + const mockRuleId = "chat"; + + afterEach(() => { + controlApiCleanup(); + }); + + standardHelpTests("apps:rules:update", import.meta.url); + standardArgValidationTests("apps:rules:update", import.meta.url, { + requiredArgs: [mockRuleId], + }); + standardFlagTests("apps:rules:update", import.meta.url, [ + "--json", + "--app", + "--persisted", + "--push-enabled", + "--mutable-messages", + ]); + + describe("functionality", () => { + it("should update a channel rule with persisted flag", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(200, mockNamespace({ id: mockRuleId, persisted: true })); + + const { stdout } = await runCommand( + ["apps:rules:update", mockRuleId, "--persisted"], + import.meta.url, + ); + + expect(stdout).toContain("updated"); + expect(stdout).toContain("Persisted: Yes"); + }); + + it("should update a channel rule with mutable-messages flag and auto-enable persistence", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => { + return body.mutableMessages === true && body.persisted === true; + }) + .reply(200, mockNamespace({ id: mockRuleId, persisted: true })); + + const { stdout, stderr } = await runCommand( + ["apps:rules:update", mockRuleId, "--mutable-messages"], + import.meta.url, + ); + + expect(stdout).toContain("updated"); + expect(stderr).toContain("persistence is automatically enabled"); + }); + + it("should fail when disabling persistence with mutable messages enabled", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [ + { + ...mockNamespace({ id: mockRuleId, persisted: true }), + mutableMessages: true, + }, + ]); + + const { error } = await runCommand( + ["apps:rules:update", mockRuleId, "--no-persisted"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/Cannot disable persistence/); + }); + + it("should error when --mutable-messages is used with --no-persisted", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId, persisted: true })]); + + const { error } = await runCommand( + [ + "apps:rules:update", + mockRuleId, + "--mutable-messages", + "--no-persisted", + ], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch( + /Cannot disable persistence when mutable messages is enabled/, + ); + }); + + it("should allow --no-mutable-messages --no-persisted to disable both", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId, persisted: true })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`, (body) => { + return body.mutableMessages === false && body.persisted === false; + }) + .reply(200, mockNamespace({ id: mockRuleId })); + + const { stdout } = await runCommand( + [ + "apps:rules:update", + mockRuleId, + "--no-mutable-messages", + "--no-persisted", + ], + import.meta.url, + ); + + expect(stdout).toContain("updated"); + expect(stdout).toContain("Persisted: No"); + }); + + it("should update a channel rule with push-enabled flag", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(200, mockNamespace({ id: mockRuleId, pushEnabled: true })); + + const { stdout } = await runCommand( + ["apps:rules:update", mockRuleId, "--push-enabled"], + import.meta.url, + ); + + expect(stdout).toContain("updated"); + expect(stdout).toContain("Push Enabled: Yes"); + }); + + it("should output JSON format when --json flag is used", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(200, mockNamespace({ id: mockRuleId, persisted: true })); + + const { stdout } = await runCommand( + ["apps:rules:update", mockRuleId, "--persisted", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("rule"); + expect(result.rule).toHaveProperty("id", mockRuleId); + expect(result.rule).toHaveProperty("persisted", true); + }); + + it("should include mutableMessages in JSON output when updating", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + nockControl() + .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) + .reply(200, mockNamespace({ id: mockRuleId, persisted: true })); + + const { stdout } = await runCommand( + ["apps:rules:update", mockRuleId, "--mutable-messages", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("success", true); + expect(result.rule).toHaveProperty("persisted", true); + }); + }); + + describe("error handling", () => { + standardControlApiErrorTests({ + commandArgs: ["apps:rules:update", mockRuleId, "--persisted"], + importMetaUrl: import.meta.url, + setupNock: (scenario) => { + const appId = getMockConfigManager().getCurrentAppId()!; + const scope = nockControl().get(`/v1/apps/${appId}/namespaces`); + if (scenario === "401") scope.reply(401, { error: "Unauthorized" }); + else if (scenario === "500") + scope.reply(500, { error: "Internal Server Error" }); + else scope.replyWithError("Network error"); + }, + }); + + it("should handle channel rule not found", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); + + const { error } = await runCommand( + ["apps:rules:update", "nonexistent", "--persisted"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/not found/); + }); + + it("should require at least one update parameter", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: mockRuleId })]); + + const { error } = await runCommand( + ["apps:rules:update", mockRuleId], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error!.message).toMatch(/No update parameters provided/); + }); + }); +}); diff --git a/test/unit/commands/channel-rule/create.test.ts b/test/unit/commands/channel-rule/create.test.ts index 17ee0345..9e6efdd3 100644 --- a/test/unit/commands/channel-rule/create.test.ts +++ b/test/unit/commands/channel-rule/create.test.ts @@ -5,58 +5,24 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, -} from "../../../helpers/standard-tests.js"; +import { mockNamespace } from "../../../fixtures/control-api.js"; -describe("channel-rule:create command (alias)", () => { +describe("channel-rule:create alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should execute the same as apps:channel-rules:create", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl().post(`/v1/apps/${appId}/namespaces`).reply(200, { - id: "test-rule", - persisted: true, - pushEnabled: false, - created: Date.now(), - modified: Date.now(), - }); - - const { stdout } = await runCommand( - ["channel-rule:create", "--name=test-rule", "--persisted"], - import.meta.url, - ); - - expect(stdout).toContain("Channel rule created."); - }); - - it("should require name flag", async () => { - const { error } = await runCommand( - ["channel-rule:create"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing required flag.*name/); - }); - }); + it("should forward to apps:rules:create and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .post(`/v1/apps/${appId}/namespaces`) + .reply(201, mockNamespace({ id: "test-rule", persisted: true })); - standardHelpTests("channel-rule:create", import.meta.url); - standardArgValidationTests("channel-rule:create", import.meta.url); - standardFlagTests("channel-rule:create", import.meta.url, ["--json"]); + const { stdout } = await runCommand( + ["channel-rule:create", "--name=test-rule", "--persisted"], + import.meta.url, + ); - describe("error handling", () => { - it("should require name flag", async () => { - const { error } = await runCommand( - ["channel-rule:create"], - import.meta.url, - ); - expect(error).toBeDefined(); - }); + expect(stdout).toContain("Channel rule test-rule created."); }); }); diff --git a/test/unit/commands/channel-rule/delete.test.ts b/test/unit/commands/channel-rule/delete.test.ts index a95a8f84..442b62d7 100644 --- a/test/unit/commands/channel-rule/delete.test.ts +++ b/test/unit/commands/channel-rule/delete.test.ts @@ -5,70 +5,26 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, -} from "../../../helpers/standard-tests.js"; - -describe("channel-rule:delete command (alias)", () => { - const mockRuleId = "test-rule"; +import { mockNamespace } from "../../../fixtures/control-api.js"; +describe("channel-rule:delete alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should execute the same as apps:channel-rules:delete", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [ - { - id: mockRuleId, - persisted: false, - pushEnabled: false, - created: Date.now(), - modified: Date.now(), - }, - ]); - - nockControl() - .delete(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(200, {}); - - const { stdout } = await runCommand( - ["channel-rule:delete", mockRuleId, "--force"], - import.meta.url, - ); + it("should forward to apps:rules:delete and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: "test-rule" })]); - expect(stdout).toContain("deleted"); - }); + nockControl().delete(`/v1/apps/${appId}/namespaces/test-rule`).reply(204); - it("should require nameOrId argument", async () => { - const { error } = await runCommand( - ["channel-rule:delete", "--force"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing 1 required arg/); - }); - }); - - standardHelpTests("channel-rule:delete", import.meta.url); - standardArgValidationTests("channel-rule:delete", import.meta.url, { - requiredArgs: ["nameOrId"], - }); - standardFlagTests("channel-rule:delete", import.meta.url, ["--json"]); + const { stdout } = await runCommand( + ["channel-rule:delete", "test-rule", "--force"], + import.meta.url, + ); - describe("error handling", () => { - it("should require nameOrId argument", async () => { - const { error } = await runCommand( - ["channel-rule:delete", "--force"], - import.meta.url, - ); - expect(error).toBeDefined(); - }); + expect(stdout).toContain("deleted"); }); }); diff --git a/test/unit/commands/channel-rule/list.test.ts b/test/unit/commands/channel-rule/list.test.ts index 3382e56a..bb75d86f 100644 --- a/test/unit/commands/channel-rule/list.test.ts +++ b/test/unit/commands/channel-rule/list.test.ts @@ -5,77 +5,22 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, -} from "../../../helpers/standard-tests.js"; +import { mockNamespace } from "../../../fixtures/control-api.js"; -describe("channel-rule:list command (alias)", () => { +describe("channel-rule:list alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should execute the same as apps:channel-rules:list", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [ - { - id: "rule1", - persisted: true, - pushEnabled: false, - created: Date.now(), - modified: Date.now(), - }, - { - id: "rule2", - persisted: false, - pushEnabled: true, - created: Date.now(), - modified: Date.now(), - }, - ]); - - const { stdout } = await runCommand( - ["channel-rule:list"], - import.meta.url, - ); - - expect(stdout).toContain("rule1"); - expect(stdout).toContain("rule2"); - }); - - it("should show message when no rules found", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl().get(`/v1/apps/${appId}/namespaces`).reply(200, []); - - const { stdout } = await runCommand( - ["channel-rule:list"], - import.meta.url, - ); - - expect(stdout).toContain("No channel rules found"); - }); - }); - - standardHelpTests("channel-rule:list", import.meta.url); - standardArgValidationTests("channel-rule:list", import.meta.url); - standardFlagTests("channel-rule:list", import.meta.url, ["--json"]); + it("should forward to apps:rules:list and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: "rule1", persisted: true })]); - describe("error handling", () => { - it("should handle API errors gracefully", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(401, { error: "Unauthorized" }); + const { stdout } = await runCommand(["channel-rule:list"], import.meta.url); - const { error } = await runCommand( - ["channel-rule:list"], - import.meta.url, - ); - expect(error).toBeDefined(); - }); + expect(stdout).toContain("Found 1 channel rule"); + expect(stdout).toContain("rule1"); }); }); diff --git a/test/unit/commands/channel-rule/update.test.ts b/test/unit/commands/channel-rule/update.test.ts index 9e86a7c6..40b55e05 100644 --- a/test/unit/commands/channel-rule/update.test.ts +++ b/test/unit/commands/channel-rule/update.test.ts @@ -5,76 +5,29 @@ import { controlApiCleanup, } from "../../../helpers/control-api-test-helpers.js"; import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; -import { - standardHelpTests, - standardArgValidationTests, - standardFlagTests, -} from "../../../helpers/standard-tests.js"; - -describe("channel-rule:update command (alias)", () => { - const mockRuleId = "test-rule"; +import { mockNamespace } from "../../../fixtures/control-api.js"; +describe("channel-rule:update alias", () => { afterEach(() => { controlApiCleanup(); }); - describe("functionality", () => { - it("should execute the same as apps:channel-rules:update", async () => { - const appId = getMockConfigManager().getCurrentAppId()!; - nockControl() - .get(`/v1/apps/${appId}/namespaces`) - .reply(200, [ - { - id: mockRuleId, - persisted: false, - pushEnabled: false, - created: Date.now(), - modified: Date.now(), - }, - ]); - - nockControl() - .patch(`/v1/apps/${appId}/namespaces/${mockRuleId}`) - .reply(200, { - id: mockRuleId, - persisted: true, - pushEnabled: false, - created: Date.now(), - modified: Date.now(), - }); - - const { stdout } = await runCommand( - ["channel-rule:update", mockRuleId, "--persisted"], - import.meta.url, - ); + it("should forward to apps:rules:update and produce the same output", async () => { + const appId = getMockConfigManager().getCurrentAppId()!; + nockControl() + .get(`/v1/apps/${appId}/namespaces`) + .reply(200, [mockNamespace({ id: "test-rule" })]); - expect(stdout).toContain("Channel rule updated."); - }); + nockControl() + .patch(`/v1/apps/${appId}/namespaces/test-rule`) + .reply(200, mockNamespace({ id: "test-rule", persisted: true })); - it("should require nameOrId argument", async () => { - const { error } = await runCommand( - ["channel-rule:update", "--persisted"], - import.meta.url, - ); - - expect(error).toBeDefined(); - expect(error?.message).toMatch(/Missing 1 required arg/); - }); - }); - - standardHelpTests("channel-rule:update", import.meta.url); - standardArgValidationTests("channel-rule:update", import.meta.url, { - requiredArgs: ["nameOrId"], - }); - standardFlagTests("channel-rule:update", import.meta.url, ["--json"]); + const { stdout } = await runCommand( + ["channel-rule:update", "test-rule", "--persisted"], + import.meta.url, + ); - describe("error handling", () => { - it("should require nameOrId argument", async () => { - const { error } = await runCommand( - ["channel-rule:update", "--persisted"], - import.meta.url, - ); - expect(error).toBeDefined(); - }); + expect(stdout).toContain("updated"); + expect(stdout).toContain("Persisted: Yes"); }); }); diff --git a/test/unit/errors/command-error.test.ts b/test/unit/errors/command-error.test.ts index 800dc94b..d4ffe8bd 100644 --- a/test/unit/errors/command-error.test.ts +++ b/test/unit/errors/command-error.test.ts @@ -137,6 +137,51 @@ describe("CommandError", () => { expect(result.statusCode).toBe(404); expect(result.context).toEqual({ appId: "abc" }); }); + + it("should extract href as helpUrl from Ably ErrorInfo-like errors with code and statusCode", () => { + const ablyError = Object.assign(new Error("Unauthorized"), { + code: 40100, + statusCode: 401, + href: "https://help.ably.io/error/40100", + }); + const result = CommandError.from(ablyError); + expect(result.code).toBe(40100); + expect(result.statusCode).toBe(401); + expect(result.context.helpUrl).toBe("https://help.ably.io/error/40100"); + }); + + it("should extract href as helpUrl from errors with code only", () => { + const err = Object.assign(new Error("Connection failed"), { + code: 80003, + href: "https://help.ably.io/error/80003", + }); + const result = CommandError.from(err); + expect(result.code).toBe(80003); + expect(result.context.helpUrl).toBe("https://help.ably.io/error/80003"); + }); + + it("should merge href-derived helpUrl with provided context", () => { + const ablyError = Object.assign(new Error("Not Found"), { + code: 40400, + statusCode: 404, + href: "https://help.ably.io/error/40400", + }); + const result = CommandError.from(ablyError, { appId: "abc" }); + expect(result.code).toBe(40400); + expect(result.context).toEqual({ + appId: "abc", + helpUrl: "https://help.ably.io/error/40400", + }); + }); + + it("should not add helpUrl when href is absent", () => { + const ablyError = Object.assign(new Error("Unauthorized"), { + code: 40100, + statusCode: 401, + }); + const result = CommandError.from(ablyError); + expect(result.context.helpUrl).toBeUndefined(); + }); }); describe("toJsonData()", () => { diff --git a/test/unit/services/control-api.test.ts b/test/unit/services/control-api.test.ts index b0c51405..a50c8635 100644 --- a/test/unit/services/control-api.test.ts +++ b/test/unit/services/control-api.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import nock from "nock"; import { nockControl, controlApiCleanup, } from "../../helpers/control-api-test-helpers.js"; import { ControlApi } from "../../../src/services/control-api.js"; +import { CommandError } from "../../../src/errors/command-error.js"; describe("ControlApi", function () { const accessToken = "test-access-token"; @@ -397,4 +398,85 @@ describe("ControlApi", function () { ); }); }); + + describe("structured error output", function () { + it("should throw CommandError instead of plain Error on API failure", async function () { + nock(`https://${controlHost}`) + .get("/v1/me") + .reply(400, { message: "Bad Request" }); + + await expect(api.getMe()).rejects.toBeInstanceOf(CommandError); + }); + + it("should preserve Ably errorCode and helpUrl from API response", async function () { + nock(`https://${controlHost}`).get("/v1/me").reply(400, { + message: "Unable to modify existing channel namespace id with POST", + code: 40300, + statusCode: 400, + href: "https://help.ably.io/error/40300", + details: null, + }); + + const error = await api.getMe().catch((error_) => error_); + const cmdError = error as CommandError; + expect(cmdError.statusCode).toBe(400); + expect(cmdError.context.errorCode).toBe(40300); + expect(cmdError.context.helpUrl).toBe("https://help.ably.io/error/40300"); + }); + + it("should include errorCode and helpUrl in toJsonData() output", async function () { + nock(`https://${controlHost}`).get("/v1/me").reply(400, { + message: "Namespace not found", + code: 40400, + href: "https://help.ably.io/error/40400", + }); + + const error = await api.getMe().catch((error_) => error_); + const cmdError = error as CommandError; + const jsonData = cmdError.toJsonData(); + expect(jsonData.statusCode).toBe(400); + expect(jsonData.errorCode).toBe(40400); + expect(jsonData.helpUrl).toBe("https://help.ably.io/error/40400"); + expect(jsonData.error).toContain("Namespace not found"); + }); + + it("should not include errorCode or helpUrl when API response lacks them", async function () { + nock(`https://${controlHost}`) + .get("/v1/me") + .reply(500, { message: "Internal Server Error" }); + + const error = await api.getMe().catch((error_) => error_); + const cmdError = error as CommandError; + expect(cmdError.statusCode).toBe(500); + expect(cmdError.context.errorCode).toBeUndefined(); + expect(cmdError.context.helpUrl).toBeUndefined(); + }); + + it("should not write to stderr on API failure", async function () { + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + nock(`https://${controlHost}`).get("/v1/me").reply(400, { + message: "Bad Request", + code: 40300, + href: "https://help.ably.io/error/40300", + }); + + await api.getMe().catch(() => {}); + + expect(stderrSpy).not.toHaveBeenCalled(); + stderrSpy.mockRestore(); + }); + + it("should handle non-JSON error responses gracefully", async function () { + nock(`https://${controlHost}`).get("/v1/me").reply(502, "Bad Gateway"); + + const error = await api.getMe().catch((error_) => error_); + const cmdError = error as CommandError; + expect(cmdError).toBeInstanceOf(CommandError); + expect(cmdError.statusCode).toBe(502); + expect(cmdError.message).toContain("Bad Gateway"); + expect(cmdError.context.errorCode).toBeUndefined(); + expect(cmdError.context.helpUrl).toBeUndefined(); + }); + }); });