diff --git a/.claude/skills/ably-codebase-review/SKILL.md b/.claude/skills/ably-codebase-review/SKILL.md index 422cdb2f..fc3603e8 100644 --- a/.claude/skills/ably-codebase-review/SKILL.md +++ b/.claude/skills/ably-codebase-review/SKILL.md @@ -122,14 +122,14 @@ Launch these agents **in parallel**. Each agent gets a focused mandate and uses - Subscribe/stream commands must have `durationFlag` - Subscribe commands with replay must have `rewindFlag` - History/stats commands must have `timeRangeFlags` - - Commands creating realtime connections must have `clientIdFlag` + - Commands creating realtime connections or performing mutations (publish, update, delete, append) must have `clientIdFlag` - Control API commands must use `ControlBaseCommand.globalFlags` **Method (LSP — for ambiguous cases):** 3. Use `LSP goToDefinition` on flag spread references to confirm they resolve to `src/flags.ts` (not a local redefinition) **Reasoning guidance:** -- A command that creates a realtime client but doesn't have `clientIdFlag` is a deviation +- A command that creates a realtime client or performs a mutation (publish, update, delete, append) but doesn't have `clientIdFlag` is a deviation - A non-subscribe command having `durationFlag` is suspicious but might be valid (e.g., presence enter) - Control API commands should NOT have `productApiFlags` diff --git a/.claude/skills/ably-review/SKILL.md b/.claude/skills/ably-review/SKILL.md index 145f710b..3826fb17 100644 --- a/.claude/skills/ably-review/SKILL.md +++ b/.claude/skills/ably-review/SKILL.md @@ -102,7 +102,7 @@ For each changed command file, run the relevant checks. Spawn agents for paralle **Flag architecture check (grep, with LSP for ambiguous cases):** 1. **Grep** for flag spreads (`productApiFlags`, `clientIdFlag`, `durationFlag`, `rewindFlag`, `timeRangeFlags`, `ControlBaseCommand.globalFlags`) 2. Verify correct flag sets per the skill rules -3. Check subscribe commands have `durationFlag`, `rewindFlag`, `clientIdFlag` as appropriate +3. Check subscribe commands have `durationFlag`, `rewindFlag`, `clientIdFlag` as appropriate; mutation commands (publish, update, delete, append) should also have `clientIdFlag` 4. For ambiguous cases, use **LSP** `goToDefinition` to confirm flag imports resolve to `src/flags.ts` **JSON output check (grep/read):** diff --git a/AGENTS.md b/AGENTS.md index fb3e57a9..7d3feedf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ Flags are NOT global. Each command explicitly declares only the flags it needs v - **`coreGlobalFlags`** — `--verbose`, `--json`, `--pretty-json`, `--web-cli-help` (hidden) (on every command via `AblyBaseCommand.globalFlags`) - **`productApiFlags`** — core + hidden product API flags (`port`, `tlsPort`, `tls`). Use for commands that talk to the Ably product API. - **`controlApiFlags`** — core + hidden control API flags (`control-host`, `dashboard-host`). Use for commands that talk to the Control API. -- **`clientIdFlag`** — `--client-id`. Add to any command that creates a realtime connection (publish, subscribe, presence enter/subscribe, spaces enter/get/subscribe, locks acquire/get/subscribe, cursors set/get/subscribe, locations set/get/subscribe, etc.). The rule: if the command calls `space.enter()`, creates a realtime client, or joins a channel, include `clientIdFlag`. Do NOT add globally. +- **`clientIdFlag`** — `--client-id`. Add to any command where the user might want to control which client identity performs the operation. This includes: commands that create a realtime connection (subscribe, presence enter/subscribe, spaces, etc.), publish, and REST mutations where permissions may depend on the client (update, delete, append). Do NOT add globally. - **`durationFlag`** — `--duration` / `-D`. Use for long-running subscribe/stream commands that auto-exit after N seconds. - **`rewindFlag`** — `--rewind`. Use for subscribe commands that support message replay (default: 0). - **`timeRangeFlags`** — `--start`, `--end`. Use for history and stats commands. Parse with `parseTimestamp()` from `src/utils/time.ts`. Accepts ISO 8601, Unix ms, or relative (e.g., `"1h"`, `"30m"`, `"2d"`). diff --git a/README.md b/README.md index 6c3061ea..8e2ec25a 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,9 @@ $ ably-interactive * [`ably bench publisher CHANNEL`](#ably-bench-publisher-channel) * [`ably bench subscriber CHANNEL`](#ably-bench-subscriber-channel) * [`ably channels`](#ably-channels) +* [`ably channels append CHANNEL SERIAL MESSAGE`](#ably-channels-append-channel-serial-message) * [`ably channels batch-publish [MESSAGE]`](#ably-channels-batch-publish-message) +* [`ably channels delete CHANNEL SERIAL`](#ably-channels-delete-channel-serial) * [`ably channels history CHANNEL`](#ably-channels-history-channel) * [`ably channels inspect CHANNEL`](#ably-channels-inspect-channel) * [`ably channels list`](#ably-channels-list) @@ -118,6 +120,7 @@ $ ably-interactive * [`ably channels presence subscribe CHANNEL`](#ably-channels-presence-subscribe-channel) * [`ably channels publish CHANNEL MESSAGE`](#ably-channels-publish-channel-message) * [`ably channels subscribe CHANNELS`](#ably-channels-subscribe-channels) +* [`ably channels update CHANNEL SERIAL MESSAGE`](#ably-channels-update-channel-serial-message) * [`ably config`](#ably-config) * [`ably config path`](#ably-config-path) * [`ably config show`](#ably-config-show) @@ -1370,7 +1373,9 @@ EXAMPLES $ ably channels list COMMANDS + ably channels append Append data to a message on an Ably channel ably channels batch-publish Publish messages to multiple Ably channels with a single request + ably channels delete Delete a message on an Ably channel ably channels history Retrieve message history for a channel ably channels inspect Open the Ably dashboard to inspect a specific channel ably channels list List active channels using the channel enumeration API @@ -1378,10 +1383,54 @@ COMMANDS ably channels presence Manage presence on Ably channels ably channels publish Publish a message to an Ably channel ably channels subscribe Subscribe to messages published on one or more Ably channels + ably channels update Update a message on an Ably channel ``` _See code: [src/commands/channels/index.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/index.ts)_ +## `ably channels append CHANNEL SERIAL MESSAGE` + +Append data to a message on an Ably channel + +``` +USAGE + $ ably channels append CHANNEL SERIAL MESSAGE [-v] [--json | --pretty-json] [--client-id ] [--description + ] [-e ] [-n ] + +ARGUMENTS + CHANNEL The channel name + SERIAL The serial of the message to append to + MESSAGE The message to append (JSON format or plain text) + +FLAGS + -e, --encoding= The encoding for the message + -n, --name= The event name + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --description= Description of the append operation + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Append data to a message on an Ably channel + +EXAMPLES + $ ably channels append my-channel "01234567890:0" '{"data":"appended content"}' + + $ ably channels append my-channel "01234567890:0" "Appended plain text" + + $ ably channels append my-channel "01234567890:0" '{"data":"appended"}' --name event-name + + $ ably channels append my-channel "01234567890:0" '{"data":"appended"}' --description "Added context" + + $ ably channels append my-channel "01234567890:0" '{"data":"appended"}' --json + + $ ably channels append my-channel "01234567890:0" '{"data":"appended"}' --pretty-json +``` + +_See code: [src/commands/channels/append.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/append.ts)_ + ## `ably channels batch-publish [MESSAGE]` Publish messages to multiple Ably channels with a single request @@ -1427,6 +1476,41 @@ EXAMPLES _See code: [src/commands/channels/batch-publish.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/batch-publish.ts)_ +## `ably channels delete CHANNEL SERIAL` + +Delete a message on an Ably channel + +``` +USAGE + $ ably channels delete CHANNEL SERIAL [-v] [--json | --pretty-json] [--client-id ] [--description ] + +ARGUMENTS + CHANNEL The channel name + SERIAL The serial of the message to delete + +FLAGS + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --description= Description of the delete operation + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Delete a message on an Ably channel + +EXAMPLES + $ ably channels delete my-channel "01234567890:0" + + $ ably channels delete my-channel "01234567890:0" --description "Removed by admin" + + $ ably channels delete my-channel "01234567890:0" --json + + $ ably channels delete my-channel "01234567890:0" --pretty-json +``` + +_See code: [src/commands/channels/delete.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/delete.ts)_ + ## `ably channels history CHANNEL` Retrieve message history for a channel @@ -1824,6 +1908,49 @@ EXAMPLES _See code: [src/commands/channels/subscribe.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/subscribe.ts)_ +## `ably channels update CHANNEL SERIAL MESSAGE` + +Update a message on an Ably channel + +``` +USAGE + $ ably channels update CHANNEL SERIAL MESSAGE [-v] [--json | --pretty-json] [--client-id ] [--description + ] [-e ] [-n ] + +ARGUMENTS + CHANNEL The channel name + SERIAL The serial of the message to update + MESSAGE The updated message (JSON format or plain text) + +FLAGS + -e, --encoding= The encoding for the message + -n, --name= The event name + -v, --verbose Output verbose logs + --client-id= Overrides any default client ID when using API authentication. Use "none" to explicitly set + no client ID. Not applicable when using token authentication. + --description= Description of the update operation + --json Output in JSON format + --pretty-json Output in colorized JSON format + +DESCRIPTION + Update a message on an Ably channel + +EXAMPLES + $ ably channels update my-channel "01234567890:0" '{"data":"updated content"}' + + $ ably channels update my-channel "01234567890:0" "Updated plain text" + + $ ably channels update my-channel "01234567890:0" '{"data":"updated"}' --name event-name + + $ ably channels update my-channel "01234567890:0" '{"data":"updated"}' --description "Corrected typo" + + $ ably channels update my-channel "01234567890:0" '{"data":"updated"}' --json + + $ ably channels update my-channel "01234567890:0" '{"data":"updated"}' --pretty-json +``` + +_See code: [src/commands/channels/update.ts](https://github.com/ably/ably-cli/blob/v0.17.0/src/commands/channels/update.ts)_ + ## `ably config` Manage Ably CLI configuration diff --git a/package.json b/package.json index c1cf180b..0b9cff87 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", - "ably": "^2.14.0", + "ably": "^2.19.0", "chalk": "5", "cli-table3": "^0.6.5", "color-json": "^3.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc94f3f..369d4018 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,10 +15,10 @@ importers: dependencies: '@ably/chat': specifier: ^1.0.0 - version: 1.0.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.0.0(ably@2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@ably/spaces': specifier: ^0.4.0 - version: 0.4.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.4.0(ably@2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@inquirer/prompts': specifier: ^5.1.3 version: 5.5.0 @@ -41,8 +41,8 @@ importers: specifier: ^5.5.0 version: 5.5.0 ably: - specifier: ^2.14.0 - version: 2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^2.19.0 + version: 2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) chalk: specifier: '5' version: 5.4.1 @@ -553,8 +553,8 @@ packages: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} '@babel/compat-data@7.26.8': @@ -637,8 +637,8 @@ packages: resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} engines: {node: '>=6.9.0'} - '@babel/runtime@7.28.3': - resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} '@babel/template@7.27.0': @@ -730,6 +730,12 @@ packages: resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==} engines: {node: '>=16'} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.2': resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==} engines: {node: '>=18'} @@ -742,11 +748,11 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.9': - resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] + cpu: [arm64] + os: [android] '@esbuild/android-arm64@0.25.2': resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==} @@ -760,10 +766,10 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.9': - resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [arm] os: [android] '@esbuild/android-arm@0.25.2': @@ -778,10 +784,10 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.9': - resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} - cpu: [arm] + cpu: [x64] os: [android] '@esbuild/android-x64@0.25.2': @@ -796,11 +802,11 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.9': - resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} - cpu: [x64] - os: [android] + cpu: [arm64] + os: [darwin] '@esbuild/darwin-arm64@0.25.2': resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==} @@ -814,10 +820,10 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.9': - resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [darwin] '@esbuild/darwin-x64@0.25.2': @@ -832,11 +838,11 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.9': - resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} - cpu: [x64] - os: [darwin] + cpu: [arm64] + os: [freebsd] '@esbuild/freebsd-arm64@0.25.2': resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==} @@ -850,10 +856,10 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.9': - resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [freebsd] '@esbuild/freebsd-x64@0.25.2': @@ -868,11 +874,11 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.9': - resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] + cpu: [arm64] + os: [linux] '@esbuild/linux-arm64@0.25.2': resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==} @@ -886,10 +892,10 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.9': - resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [arm] os: [linux] '@esbuild/linux-arm@0.25.2': @@ -904,10 +910,10 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.9': - resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} - cpu: [arm] + cpu: [ia32] os: [linux] '@esbuild/linux-ia32@0.25.2': @@ -922,10 +928,10 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.9': - resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} - cpu: [ia32] + cpu: [loong64] os: [linux] '@esbuild/linux-loong64@0.25.2': @@ -940,10 +946,10 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.9': - resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} - cpu: [loong64] + cpu: [mips64el] os: [linux] '@esbuild/linux-mips64el@0.25.2': @@ -958,10 +964,10 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.9': - resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} - cpu: [mips64el] + cpu: [ppc64] os: [linux] '@esbuild/linux-ppc64@0.25.2': @@ -976,10 +982,10 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.9': - resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} - cpu: [ppc64] + cpu: [riscv64] os: [linux] '@esbuild/linux-riscv64@0.25.2': @@ -994,10 +1000,10 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.9': - resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} - cpu: [riscv64] + cpu: [s390x] os: [linux] '@esbuild/linux-s390x@0.25.2': @@ -1012,10 +1018,10 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.9': - resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} - cpu: [s390x] + cpu: [x64] os: [linux] '@esbuild/linux-x64@0.25.2': @@ -1030,11 +1036,11 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.9': - resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} - cpu: [x64] - os: [linux] + cpu: [arm64] + os: [netbsd] '@esbuild/netbsd-arm64@0.25.2': resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==} @@ -1048,10 +1054,10 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.25.9': - resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [netbsd] '@esbuild/netbsd-x64@0.25.2': @@ -1066,11 +1072,11 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.9': - resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] + cpu: [arm64] + os: [openbsd] '@esbuild/openbsd-arm64@0.25.2': resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==} @@ -1084,10 +1090,10 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.25.9': - resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [x64] os: [openbsd] '@esbuild/openbsd-x64@0.25.2': @@ -1102,18 +1108,18 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.9': - resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.9': - resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.2': resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==} engines: {node: '>=18'} @@ -1126,11 +1132,11 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.9': - resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} - cpu: [x64] - os: [sunos] + cpu: [arm64] + os: [win32] '@esbuild/win32-arm64@0.25.2': resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==} @@ -1144,10 +1150,10 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.9': - resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} - cpu: [arm64] + cpu: [ia32] os: [win32] '@esbuild/win32-ia32@0.25.2': @@ -1162,10 +1168,10 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.9': - resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} - cpu: [ia32] + cpu: [x64] os: [win32] '@esbuild/win32-x64@0.25.2': @@ -1180,12 +1186,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.9': - resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@eslint-community/eslint-utils@4.6.1': resolution: {integrity: sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2654,8 +2654,8 @@ packages: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} - ably@2.14.0: - resolution: {integrity: sha512-GWNza+URnh/W5IuoJX7nXJpQCs2Dxby6t5A20vL3PBqGIJceA94/1xje4HOZbqFtMEPkRVsYHBIEuQRWL+CuvQ==} + ably@2.19.0: + resolution: {integrity: sha512-uEgWf8p6UySLDFTO25S0fsd2ccwjB0hsuOV0EztVoxRhdsqSgnzCOmYSxUprVW5THlCLFA25tP9tIcj5G/XTVA==} engines: {node: '>=16'} peerDependencies: react: '>=16.8.0' @@ -3520,6 +3520,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.25.2: resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==} engines: {node: '>=18'} @@ -3530,11 +3535,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.25.9: - resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -4032,8 +4032,8 @@ packages: get-tsconfig@4.10.0: resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} git-hooks-list@3.2.0: resolution: {integrity: sha512-ZHG9a1gEhUMX1TvGrLdyWb9kDopCBbTnI8z4JgRMYxsijWipgjSEYoPWqBuIB0DnRnvqlQSEeVmzpeuPm7NdFQ==} @@ -6348,9 +6348,9 @@ packages: snapshots: - '@ably/chat@1.0.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@ably/chat@1.0.0(ably@2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - ably: 2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ably: 2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) async-mutex: 0.5.0 dequal: 2.0.3 lodash.clonedeep: 4.5.0 @@ -6364,9 +6364,9 @@ snapshots: dependencies: bops: 1.0.1 - '@ably/spaces@0.4.0(ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@ably/spaces@0.4.0(ably@2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - ably: 2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + ably: 2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nanoid: 3.3.11 optionalDependencies: react: 18.3.1 @@ -6900,7 +6900,7 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/code-frame@7.27.1': + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 @@ -6997,7 +6997,7 @@ snapshots: '@babel/runtime@7.27.1': {} - '@babel/runtime@7.28.3': {} + '@babel/runtime@7.28.6': {} '@babel/template@7.27.0': dependencies: @@ -7090,13 +7090,16 @@ snapshots: esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.25.2': optional: true '@esbuild/aix-ppc64@0.25.4': optional: true - '@esbuild/aix-ppc64@0.25.9': + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.25.2': @@ -7105,7 +7108,7 @@ snapshots: '@esbuild/android-arm64@0.25.4': optional: true - '@esbuild/android-arm64@0.25.9': + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.25.2': @@ -7114,7 +7117,7 @@ snapshots: '@esbuild/android-arm@0.25.4': optional: true - '@esbuild/android-arm@0.25.9': + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.25.2': @@ -7123,7 +7126,7 @@ snapshots: '@esbuild/android-x64@0.25.4': optional: true - '@esbuild/android-x64@0.25.9': + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.25.2': @@ -7132,7 +7135,7 @@ snapshots: '@esbuild/darwin-arm64@0.25.4': optional: true - '@esbuild/darwin-arm64@0.25.9': + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.25.2': @@ -7141,7 +7144,7 @@ snapshots: '@esbuild/darwin-x64@0.25.4': optional: true - '@esbuild/darwin-x64@0.25.9': + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.25.2': @@ -7150,7 +7153,7 @@ snapshots: '@esbuild/freebsd-arm64@0.25.4': optional: true - '@esbuild/freebsd-arm64@0.25.9': + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.25.2': @@ -7159,7 +7162,7 @@ snapshots: '@esbuild/freebsd-x64@0.25.4': optional: true - '@esbuild/freebsd-x64@0.25.9': + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.25.2': @@ -7168,7 +7171,7 @@ snapshots: '@esbuild/linux-arm64@0.25.4': optional: true - '@esbuild/linux-arm64@0.25.9': + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.25.2': @@ -7177,7 +7180,7 @@ snapshots: '@esbuild/linux-arm@0.25.4': optional: true - '@esbuild/linux-arm@0.25.9': + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.25.2': @@ -7186,7 +7189,7 @@ snapshots: '@esbuild/linux-ia32@0.25.4': optional: true - '@esbuild/linux-ia32@0.25.9': + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.25.2': @@ -7195,7 +7198,7 @@ snapshots: '@esbuild/linux-loong64@0.25.4': optional: true - '@esbuild/linux-loong64@0.25.9': + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.25.2': @@ -7204,7 +7207,7 @@ snapshots: '@esbuild/linux-mips64el@0.25.4': optional: true - '@esbuild/linux-mips64el@0.25.9': + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.25.2': @@ -7213,7 +7216,7 @@ snapshots: '@esbuild/linux-ppc64@0.25.4': optional: true - '@esbuild/linux-ppc64@0.25.9': + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.25.2': @@ -7222,7 +7225,7 @@ snapshots: '@esbuild/linux-riscv64@0.25.4': optional: true - '@esbuild/linux-riscv64@0.25.9': + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.25.2': @@ -7231,7 +7234,7 @@ snapshots: '@esbuild/linux-s390x@0.25.4': optional: true - '@esbuild/linux-s390x@0.25.9': + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.25.2': @@ -7240,7 +7243,7 @@ snapshots: '@esbuild/linux-x64@0.25.4': optional: true - '@esbuild/linux-x64@0.25.9': + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.2': @@ -7249,7 +7252,7 @@ snapshots: '@esbuild/netbsd-arm64@0.25.4': optional: true - '@esbuild/netbsd-arm64@0.25.9': + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.25.2': @@ -7258,7 +7261,7 @@ snapshots: '@esbuild/netbsd-x64@0.25.4': optional: true - '@esbuild/netbsd-x64@0.25.9': + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.2': @@ -7267,7 +7270,7 @@ snapshots: '@esbuild/openbsd-arm64@0.25.4': optional: true - '@esbuild/openbsd-arm64@0.25.9': + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.25.2': @@ -7276,10 +7279,10 @@ snapshots: '@esbuild/openbsd-x64@0.25.4': optional: true - '@esbuild/openbsd-x64@0.25.9': + '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.25.9': + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.25.2': @@ -7288,7 +7291,7 @@ snapshots: '@esbuild/sunos-x64@0.25.4': optional: true - '@esbuild/sunos-x64@0.25.9': + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.25.2': @@ -7297,7 +7300,7 @@ snapshots: '@esbuild/win32-arm64@0.25.4': optional: true - '@esbuild/win32-arm64@0.25.9': + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.25.2': @@ -7306,7 +7309,7 @@ snapshots: '@esbuild/win32-ia32@0.25.4': optional: true - '@esbuild/win32-ia32@0.25.9': + '@esbuild/win32-x64@0.25.12': optional: true '@esbuild/win32-x64@0.25.2': @@ -7315,9 +7318,6 @@ snapshots: '@esbuild/win32-x64@0.25.4': optional: true - '@esbuild/win32-x64@0.25.9': - optional: true - '@eslint-community/eslint-utils@4.6.1(eslint@9.34.0(jiti@2.4.2))': dependencies: eslint: 9.34.0(jiti@2.4.2) @@ -8376,8 +8376,8 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.3 + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -9047,7 +9047,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.14(@edge-runtime/vm@3.2.0)(@types/node@22.14.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4) + vitest: 4.0.14(@edge-runtime/vm@3.2.0)(@types/node@20.17.30)(@vitest/ui@4.0.14)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.29.2)(tsx@4.19.4) '@vitest/utils@4.0.14': dependencies: @@ -9080,7 +9080,7 @@ snapshots: abbrev@3.0.1: {} - ably@2.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + ably@2.19.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@ably/msgpack-js': 0.4.0 dequal: 2.0.3 @@ -9970,6 +9970,36 @@ snapshots: esbuild-windows-64: 0.14.47 esbuild-windows-arm64: 0.14.47 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + optional: true + esbuild@0.25.2: optionalDependencies: '@esbuild/aix-ppc64': 0.25.2 @@ -10026,36 +10056,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.4 '@esbuild/win32-x64': 0.25.4 - esbuild@0.25.9: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.9 - '@esbuild/android-arm': 0.25.9 - '@esbuild/android-arm64': 0.25.9 - '@esbuild/android-x64': 0.25.9 - '@esbuild/darwin-arm64': 0.25.9 - '@esbuild/darwin-x64': 0.25.9 - '@esbuild/freebsd-arm64': 0.25.9 - '@esbuild/freebsd-x64': 0.25.9 - '@esbuild/linux-arm': 0.25.9 - '@esbuild/linux-arm64': 0.25.9 - '@esbuild/linux-ia32': 0.25.9 - '@esbuild/linux-loong64': 0.25.9 - '@esbuild/linux-mips64el': 0.25.9 - '@esbuild/linux-ppc64': 0.25.9 - '@esbuild/linux-riscv64': 0.25.9 - '@esbuild/linux-s390x': 0.25.9 - '@esbuild/linux-x64': 0.25.9 - '@esbuild/netbsd-arm64': 0.25.9 - '@esbuild/netbsd-x64': 0.25.9 - '@esbuild/openbsd-arm64': 0.25.9 - '@esbuild/openbsd-x64': 0.25.9 - '@esbuild/openharmony-arm64': 0.25.9 - '@esbuild/sunos-x64': 0.25.9 - '@esbuild/win32-arm64': 0.25.9 - '@esbuild/win32-ia32': 0.25.9 - '@esbuild/win32-x64': 0.25.9 - optional: true - escalade@3.2.0: {} escape-string-regexp@1.0.5: {} @@ -10703,7 +10703,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-tsconfig@4.10.1: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 optional: true @@ -12699,8 +12699,8 @@ snapshots: tsx@4.19.4: dependencies: - esbuild: 0.25.9 - get-tsconfig: 4.10.1 + esbuild: 0.25.12 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 optional: true diff --git a/src/commands/channels/append.ts b/src/commands/channels/append.ts new file mode 100644 index 00000000..4fd19175 --- /dev/null +++ b/src/commands/channels/append.ts @@ -0,0 +1,120 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../base-command.js"; +import { clientIdFlag, productApiFlags } from "../../flags.js"; +import { BaseFlags } from "../../types/cli.js"; +import { prepareMessageFromInput } from "../../utils/message.js"; +import { + formatProgress, + formatResource, + formatSuccess, + formatWarning, +} from "../../utils/output.js"; + +export default class ChannelsAppend extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to append to", + required: true, + }), + message: Args.string({ + description: "The message to append (JSON format or plain text)", + required: true, + }), + }; + + static override description = "Append data to a message on an Ably channel"; + + static override examples = [ + '$ ably channels append my-channel "01234567890:0" \'{"data":"appended content"}\'', + '$ ably channels append my-channel "01234567890:0" "Appended plain text"', + '$ ably channels append my-channel "01234567890:0" \'{"data":"appended"}\' --name event-name', + '$ ably channels append my-channel "01234567890:0" \'{"data":"appended"}\' --description "Added context"', + '$ ably channels append my-channel "01234567890:0" \'{"data":"appended"}\' --json', + '$ ably channels append my-channel "01234567890:0" \'{"data":"appended"}\' --pretty-json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + description: Flags.string({ + description: "Description of the append operation", + }), + encoding: Flags.string({ + char: "e", + description: "The encoding for the message", + }), + name: Flags.string({ + char: "n", + description: "The event name", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsAppend); + const channelName = args.channel; + const serial = args.serial; + + try { + const rest = await this.createAblyRestClient(flags as BaseFlags); + if (!rest) return; + + const channel = rest.channels.get(channelName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Appending to message ${formatResource(serial)} on channel ${formatResource(channelName)}`, + ), + ); + } + + const message = prepareMessageFromInput(args.message, flags, { serial }); + const operation: Ably.MessageOperation | undefined = flags.description + ? { description: flags.description } + : undefined; + + const result = await channel.appendMessage(message, operation); + + const versionSerial = result?.versionSerial; + + this.logCliEvent( + flags, + "channelAppend", + "messageAppended", + `Appended to message ${serial} on channel ${channelName}`, + { channel: channelName, serial, versionSerial }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { channel: channelName, serial, versionSerial }, + flags, + ); + } else { + this.log( + formatSuccess( + `Appended to message ${formatResource(serial)} on channel ${formatResource(channelName)}.`, + ), + ); + if (versionSerial) { + this.log(` Version serial: ${formatResource(versionSerial)}`); + } else if (versionSerial === null) { + this.log( + formatWarning("Message was superseded by a subsequent operation."), + ); + } + } + } catch (error) { + this.fail(error, flags as BaseFlags, "channelAppend", { + channel: channelName, + serial, + }); + } + } +} diff --git a/src/commands/channels/delete.ts b/src/commands/channels/delete.ts new file mode 100644 index 00000000..4d20ff42 --- /dev/null +++ b/src/commands/channels/delete.ts @@ -0,0 +1,108 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../base-command.js"; +import { clientIdFlag, productApiFlags } from "../../flags.js"; +import { BaseFlags } from "../../types/cli.js"; +import { + formatProgress, + formatResource, + formatSuccess, + formatWarning, +} from "../../utils/output.js"; + +export default class ChannelsDelete extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to delete", + required: true, + }), + }; + + static override description = "Delete a message on an Ably channel"; + + static override examples = [ + '$ ably channels delete my-channel "01234567890:0"', + '$ ably channels delete my-channel "01234567890:0" --description "Removed by admin"', + '$ ably channels delete my-channel "01234567890:0" --json', + '$ ably channels delete my-channel "01234567890:0" --pretty-json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + description: Flags.string({ + description: "Description of the delete operation", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsDelete); + const channelName = args.channel; + const serial = args.serial; + + try { + const rest = await this.createAblyRestClient(flags as BaseFlags); + if (!rest) return; + + const channel = rest.channels.get(channelName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Deleting message ${formatResource(serial)} on channel ${formatResource(channelName)}`, + ), + ); + } + + const message: Partial = { serial }; + const operation: Ably.MessageOperation | undefined = flags.description + ? { description: flags.description } + : undefined; + + const result = await channel.deleteMessage( + message as Ably.Message, + operation, + ); + + const versionSerial = result?.versionSerial; + + this.logCliEvent( + flags, + "channelDelete", + "messageDeleted", + `Deleted message ${serial} on channel ${channelName}`, + { channel: channelName, serial, versionSerial }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { channel: channelName, serial, versionSerial }, + flags, + ); + } else { + this.log( + formatSuccess( + `Message ${formatResource(serial)} deleted on channel ${formatResource(channelName)}.`, + ), + ); + if (versionSerial) { + this.log(` Version serial: ${formatResource(versionSerial)}`); + } else if (versionSerial === null) { + this.log( + formatWarning("Message was superseded by a subsequent operation."), + ); + } + } + } catch (error) { + this.fail(error, flags as BaseFlags, "channelDelete", { + channel: channelName, + serial, + }); + } + } +} diff --git a/src/commands/channels/history.ts b/src/commands/channels/history.ts index 4909a1fb..e6f57668 100644 --- a/src/commands/channels/history.ts +++ b/src/commands/channels/history.ts @@ -112,6 +112,20 @@ export default class ChannelsHistory extends AblyBaseCommand { `${formatLabel("Event")} ${formatEventType(message.name || "(none)")}`, ); + if (message.action !== undefined) { + this.log( + `${formatLabel("Action")} ${formatEventType(String(message.action))}`, + ); + } + + if (message.serial) { + this.log(`${formatLabel("Serial")} ${message.serial}`); + } + + if (message.version) { + this.log(`${formatLabel("Version")} ${message.version}`); + } + if (message.clientId) { this.log( `${formatLabel("Client ID")} ${formatClientId(message.clientId)}`, diff --git a/src/commands/channels/publish.ts b/src/commands/channels/publish.ts index 0abf438f..2e78c2c6 100644 --- a/src/commands/channels/publish.ts +++ b/src/commands/channels/publish.ts @@ -6,7 +6,7 @@ import { AblyBaseCommand } from "../../base-command.js"; import { clientIdFlag, productApiFlags } from "../../flags.js"; import { BaseFlags } from "../../types/cli.js"; import { errorMessage } from "../../utils/errors.js"; -import { interpolateMessage } from "../../utils/message.js"; +import { prepareMessageFromInput } from "../../utils/message.js"; import { formatProgress, formatResource, @@ -142,77 +142,26 @@ export default class ChannelsPublish extends AblyBaseCommand { ), ); } else if (errors === 0) { + const serial = + results[0]?.serial == null ? undefined : String(results[0].serial); this.log( formatSuccess( `Message published to channel: ${formatResource(args.channel as string)}.`, ), ); + if (serial) { + this.log(` Serial: ${formatResource(serial)}`); + } } else { // Error message already logged by publishMessages loop or prepareMessage } } } - private prepareMessage( - rawMessage: string, - flags: Record, - index: number, - ): Ably.Message { - // Apply interpolation to the message - const interpolatedMessage = interpolateMessage(rawMessage, index); - - // Parse the message - let messageData; - try { - messageData = JSON.parse(interpolatedMessage); - } catch { - // If parsing fails, use the raw message as data - messageData = { data: interpolatedMessage }; - } - - // Prepare the message - const message: Partial = {}; - - // If name is provided in flags, use it. Otherwise, check if it's in the message data - if (flags.name) { - message.name = flags.name as string; - } else if (messageData.name) { - message.name = messageData.name; - // Remove the name from the data to avoid duplication - delete messageData.name; - } - - // Add extras if provided in the message data (before processing data) - if ( - messageData.extras && - typeof messageData.extras === "object" && - Object.keys(messageData.extras).length > 0 - ) { - message.extras = messageData.extras; - // Remove extras from messageData to avoid duplication in data - delete messageData.extras; - } - - // If data is explicitly provided in the message, use it - if ("data" in messageData) { - message.data = messageData.data; - } else if (Object.keys(messageData).length > 0) { - // Otherwise use the entire messageData object (not empty) as the data - message.data = messageData; - } - - // Add encoding if provided - if (flags.encoding) { - message.encoding = flags.encoding as string; - } - - return message as Ably.Message; - } - private async publishMessages( args: Record, flags: Record, - publisher: (msg: Ably.Message) => Promise, + publisher: (msg: Ably.Message) => Promise, ): Promise { // Validate count and delay const count = Math.max(1, flags.count as number); @@ -254,23 +203,32 @@ export default class ChannelsPublish extends AblyBaseCommand { await new Promise((resolve) => setTimeout(resolve, delay)); } const messageIndex = i + 1; - const message = this.prepareMessage( - args.message as string, - flags, - messageIndex, - ); + const message = prepareMessageFromInput(args.message as string, flags, { + interpolationIndex: messageIndex, + }); try { - await publisher(message); + const publishResult = await publisher(message); publishedCount++; - const result = { index: messageIndex, message, success: true }; + const serial = publishResult?.serials?.[0] ?? undefined; + const result = { + index: messageIndex, + message, + success: true, + ...(serial === undefined ? {} : { serial }), + }; results.push(result); this.logCliEvent( flags, "publish", "messagePublished", `Message ${messageIndex} published to channel ${args.channel}`, - { index: messageIndex, message, channel: args.channel }, + { + index: messageIndex, + message, + channel: args.channel, + ...(serial === undefined ? {} : { serial }), + }, ); if ( !this.shouldSuppressOutput(flags) && @@ -282,6 +240,9 @@ export default class ChannelsPublish extends AblyBaseCommand { `Message ${messageIndex} published to channel: ${formatResource(args.channel as string)}.`, ), ); + if (serial) { + this.log(` Serial: ${formatResource(serial)}`); + } } } catch (error) { errorCount++; @@ -362,7 +323,7 @@ export default class ChannelsPublish extends AblyBaseCommand { }); await this.publishMessages(args, flags, async (msg) => { - await channel.publish(msg); + return channel.publish(msg); }); } catch (error) { this.fail(error, flags as BaseFlags, "channelPublish"); @@ -390,7 +351,7 @@ export default class ChannelsPublish extends AblyBaseCommand { ); await this.publishMessages(args, flags, async (msg) => { - await channel.publish(msg); + return channel.publish(msg); }); } catch (error) { this.fail(error, flags as BaseFlags, "channelPublish"); diff --git a/src/commands/channels/subscribe.ts b/src/commands/channels/subscribe.ts index 5f8309fe..b0b05c5a 100644 --- a/src/commands/channels/subscribe.ts +++ b/src/commands/channels/subscribe.ts @@ -203,6 +203,10 @@ export default class ChannelsSubscribe extends AblyBaseCommand { event: message.name || "(none)", id: message.id, timestamp, + action: + message.action === undefined ? undefined : String(message.action), + serial: message.serial, + version: message.version, ...(flags["sequence-numbers"] ? { sequence: this.sequenceCounter } : {}), @@ -228,6 +232,21 @@ export default class ChannelsSubscribe extends AblyBaseCommand { `${formatTimestamp(timestamp)}${sequencePrefix} ${formatResource(`Channel: ${channel.name}`)} | Event: ${formatEventType(name)}`, ); + // Action, serial, version + if (message.action !== undefined) { + this.log( + `${formatLabel("Action")} ${formatEventType(String(message.action))}`, + ); + } + + if (message.serial) { + this.log(`${formatLabel("Serial")} ${message.serial}`); + } + + if (message.version) { + this.log(`${formatLabel("Version")} ${message.version}`); + } + // Message data with consistent formatting this.log(formatLabel("Data")); this.log(formatMessageData(message.data)); diff --git a/src/commands/channels/update.ts b/src/commands/channels/update.ts new file mode 100644 index 00000000..9079c9f1 --- /dev/null +++ b/src/commands/channels/update.ts @@ -0,0 +1,120 @@ +import { Args, Flags } from "@oclif/core"; +import * as Ably from "ably"; + +import { AblyBaseCommand } from "../../base-command.js"; +import { clientIdFlag, productApiFlags } from "../../flags.js"; +import { BaseFlags } from "../../types/cli.js"; +import { prepareMessageFromInput } from "../../utils/message.js"; +import { + formatProgress, + formatResource, + formatSuccess, + formatWarning, +} from "../../utils/output.js"; + +export default class ChannelsUpdate extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The channel name", + required: true, + }), + serial: Args.string({ + description: "The serial of the message to update", + required: true, + }), + message: Args.string({ + description: "The updated message (JSON format or plain text)", + required: true, + }), + }; + + static override description = "Update a message on an Ably channel"; + + static override examples = [ + '$ ably channels update my-channel "01234567890:0" \'{"data":"updated content"}\'', + '$ ably channels update my-channel "01234567890:0" "Updated plain text"', + '$ ably channels update my-channel "01234567890:0" \'{"data":"updated"}\' --name event-name', + '$ ably channels update my-channel "01234567890:0" \'{"data":"updated"}\' --description "Corrected typo"', + '$ ably channels update my-channel "01234567890:0" \'{"data":"updated"}\' --json', + '$ ably channels update my-channel "01234567890:0" \'{"data":"updated"}\' --pretty-json', + ]; + + static override flags = { + ...productApiFlags, + ...clientIdFlag, + description: Flags.string({ + description: "Description of the update operation", + }), + encoding: Flags.string({ + char: "e", + description: "The encoding for the message", + }), + name: Flags.string({ + char: "n", + description: "The event name", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsUpdate); + const channelName = args.channel; + const serial = args.serial; + + try { + const rest = await this.createAblyRestClient(flags as BaseFlags); + if (!rest) return; + + const channel = rest.channels.get(channelName); + + if (!this.shouldOutputJson(flags)) { + this.log( + formatProgress( + `Updating message ${formatResource(serial)} on channel ${formatResource(channelName)}`, + ), + ); + } + + const message = prepareMessageFromInput(args.message, flags, { serial }); + const operation: Ably.MessageOperation | undefined = flags.description + ? { description: flags.description } + : undefined; + + const result = await channel.updateMessage(message, operation); + + const versionSerial = result?.versionSerial; + + this.logCliEvent( + flags, + "channelUpdate", + "messageUpdated", + `Updated message ${serial} on channel ${channelName}`, + { channel: channelName, serial, versionSerial }, + ); + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { channel: channelName, serial, versionSerial }, + flags, + ); + } else { + this.log( + formatSuccess( + `Message ${formatResource(serial)} updated on channel ${formatResource(channelName)}.`, + ), + ); + if (versionSerial) { + this.log(` Version serial: ${formatResource(versionSerial)}`); + } else if (versionSerial === null) { + this.log( + formatWarning("Message was superseded by a subsequent operation."), + ); + } + } + } catch (error) { + this.fail(error, flags as BaseFlags, "channelUpdate", { + channel: channelName, + serial, + }); + } + } +} diff --git a/src/utils/message.ts b/src/utils/message.ts index ac17c69d..f1d82519 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -1,5 +1,70 @@ +import * as Ably from "ably"; + export function interpolateMessage(template: string, count: number): string { let result = template.replaceAll("{{.Count}}", count.toString()); result = result.replaceAll("{{.Timestamp}}", Date.now().toString()); return result; } + +export function prepareMessageFromInput( + rawMessage: string, + flags: Record, + options?: { serial?: string; interpolationIndex?: number }, +): Ably.Message { + // Apply interpolation if index provided + const processedMessage = + options?.interpolationIndex === undefined + ? rawMessage + : interpolateMessage(rawMessage, options.interpolationIndex); + + let messageData; + try { + const parsed = JSON.parse(processedMessage); + // Only treat plain objects as structured message data; wrap primitives and arrays in { data: ... } + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + messageData = parsed; + } else { + messageData = { data: parsed }; + } + } catch { + messageData = { data: processedMessage }; + } + + const message: Partial = {}; + + if (options?.serial !== undefined) { + message.serial = options.serial; + } + + if (flags.name) { + message.name = flags.name as string; + } else if (messageData.name) { + message.name = messageData.name; + delete messageData.name; + } + + if ( + messageData.extras && + typeof messageData.extras === "object" && + Object.keys(messageData.extras).length > 0 + ) { + message.extras = messageData.extras; + delete messageData.extras; + } + + if ("data" in messageData) { + message.data = messageData.data; + } else if (Object.keys(messageData).length > 0) { + message.data = messageData; + } + + if (flags.encoding) { + message.encoding = flags.encoding as string; + } + + return message as Ably.Message; +} diff --git a/test/helpers/mock-ably-realtime.ts b/test/helpers/mock-ably-realtime.ts index 0c4e3c34..1eee489c 100644 --- a/test/helpers/mock-ably-realtime.ts +++ b/test/helpers/mock-ably-realtime.ts @@ -209,7 +209,7 @@ function createMockChannel(name: string): MockRealtimeChannel { emitter.off(eventOrCallback, callback); } }), - publish: vi.fn().mockImplementation(async () => {}), + publish: vi.fn().mockResolvedValue({ serials: ["mock-serial-001"] }), history: vi.fn().mockResolvedValue({ items: [] }), attach: vi.fn().mockImplementation(async function ( this: MockRealtimeChannel, diff --git a/test/helpers/mock-ably-rest.ts b/test/helpers/mock-ably-rest.ts index 04403248..109b81f8 100644 --- a/test/helpers/mock-ably-rest.ts +++ b/test/helpers/mock-ably-rest.ts @@ -28,6 +28,9 @@ export interface MockRestChannel { publish: Mock; history: Mock; status: Mock; + updateMessage: Mock; + deleteMessage: Mock; + appendMessage: Mock; presence: MockRestPresence; } @@ -116,8 +119,17 @@ function createMockRestPresence(): MockRestPresence { function createMockRestChannel(name: string): MockRestChannel { return { name, - publish: vi.fn().mockImplementation(async () => {}), + publish: vi.fn().mockResolvedValue({ serials: ["mock-serial-001"] }), history: vi.fn().mockResolvedValue({ items: [] }), + updateMessage: vi + .fn() + .mockResolvedValue({ versionSerial: "mock-version-serial-update" }), + deleteMessage: vi + .fn() + .mockResolvedValue({ versionSerial: "mock-version-serial-delete" }), + appendMessage: vi + .fn() + .mockResolvedValue({ versionSerial: "mock-version-serial-append" }), status: vi.fn().mockResolvedValue({ channelId: name, status: { diff --git a/test/unit/commands/channels/append.test.ts b/test/unit/commands/channels/append.test.ts new file mode 100644 index 00000000..4982bf61 --- /dev/null +++ b/test/unit/commands/channels/append.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("channels:append command", () => { + beforeEach(() => { + getMockAblyRest(); + }); + + standardHelpTests("channels:append", import.meta.url); + standardArgValidationTests("channels:append", import.meta.url, { + requiredArgs: ["test-channel", "serial-001"], + }); + standardFlagTests("channels:append", import.meta.url, [ + "--json", + "--name", + "--encoding", + "--description", + ]); + + describe("functionality", () => { + it("should append to a message with JSON data", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:append", + "test-channel", + "serial-001", + '{"data":"appended"}', + ], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-channel"); + expect(channel.appendMessage).toHaveBeenCalledOnce(); + expect(channel.appendMessage.mock.calls[0][0]).toEqual({ + serial: "serial-001", + data: "appended", + }); + expect(stdout).toContain("Appended"); + }); + + it("should append with plain text", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:append", "test-channel", "serial-001", "PlainText"], + import.meta.url, + ); + + expect(channel.appendMessage.mock.calls[0][0]).toEqual({ + serial: "serial-001", + data: "PlainText", + }); + }); + + it("should apply --name flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:append", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--name", + "my-event", + ], + import.meta.url, + ); + + expect(channel.appendMessage.mock.calls[0][0]).toHaveProperty( + "name", + "my-event", + ); + }); + + it("should apply --encoding flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:append", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--encoding", + "utf8", + ], + import.meta.url, + ); + + expect(channel.appendMessage.mock.calls[0][0]).toHaveProperty( + "encoding", + "utf8", + ); + }); + + it("should pass description as operation metadata", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:append", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--description", + "Added-context", + ], + import.meta.url, + ); + + expect(channel.appendMessage.mock.calls[0][1]).toEqual({ + description: "Added-context", + }); + }); + + it("should preserve extras from message data", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:append", + "test-channel", + "serial-001", + '{"data":"hello","extras":{"push":{"notification":{"title":"Test","body":"Push"}}}}', + ], + import.meta.url, + ); + + const sentMessage = channel.appendMessage.mock.calls[0][0]; + expect(sentMessage).toHaveProperty("data", "hello"); + expect(sentMessage).toHaveProperty("extras"); + expect(sentMessage.extras).toEqual({ + push: { notification: { title: "Test", body: "Push" } }, + }); + }); + + it("should not pass operation when no description provided", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:append", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(channel.appendMessage.mock.calls[0][1]).toBeUndefined(); + }); + + it("should output JSON when --json flag is used", async () => { + const { stdout } = await runCommand( + [ + "channels:append", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "channels:append"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("channel", "test-channel"); + expect(result).toHaveProperty("serial", "serial-001"); + expect(result).toHaveProperty( + "versionSerial", + "mock-version-serial-append", + ); + }); + + it("should handle null versionSerial (operation superseded)", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.appendMessage.mockResolvedValue({ versionSerial: null }); + + const { stdout } = await runCommand( + ["channels:append", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(stdout).toContain("superseded"); + }); + + it("should display version serial in human-readable output", async () => { + const { stdout } = await runCommand( + ["channels:append", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(stdout).toContain("mock-version-serial-append"); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.appendMessage.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["channels:append", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("API error"); + }); + }); +}); diff --git a/test/unit/commands/channels/delete.test.ts b/test/unit/commands/channels/delete.test.ts new file mode 100644 index 00000000..ddb1f62d --- /dev/null +++ b/test/unit/commands/channels/delete.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("channels:delete command", () => { + beforeEach(() => { + getMockAblyRest(); + }); + + standardHelpTests("channels:delete", import.meta.url); + standardArgValidationTests("channels:delete", import.meta.url, { + requiredArgs: ["test-channel"], + }); + standardFlagTests("channels:delete", import.meta.url, [ + "--json", + "--description", + ]); + + describe("functionality", () => { + it("should delete a message successfully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + ["channels:delete", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-channel"); + expect(channel.deleteMessage).toHaveBeenCalledOnce(); + expect(channel.deleteMessage.mock.calls[0][0]).toEqual({ + serial: "serial-001", + }); + expect(stdout).toContain("deleted"); + }); + + it("should pass description as operation metadata", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:delete", + "test-channel", + "serial-001", + "--description", + "Removed-by-admin", + ], + import.meta.url, + ); + + expect(channel.deleteMessage).toHaveBeenCalledOnce(); + expect(channel.deleteMessage.mock.calls[0][1]).toEqual({ + description: "Removed-by-admin", + }); + }); + + it("should not pass operation when no description provided", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:delete", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(channel.deleteMessage.mock.calls[0][1]).toBeUndefined(); + }); + + it("should output JSON when --json flag is used", async () => { + const { stdout } = await runCommand( + ["channels:delete", "test-channel", "serial-001", "--json"], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "channels:delete"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("channel", "test-channel"); + expect(result).toHaveProperty("serial", "serial-001"); + expect(result).toHaveProperty( + "versionSerial", + "mock-version-serial-delete", + ); + }); + + it("should handle null versionSerial (operation superseded)", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.deleteMessage.mockResolvedValue({ versionSerial: null }); + + const { stdout } = await runCommand( + ["channels:delete", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(stdout).toContain("superseded"); + }); + + it("should display version serial in human-readable output", async () => { + const { stdout } = await runCommand( + ["channels:delete", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(stdout).toContain("mock-version-serial-delete"); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.deleteMessage.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["channels:delete", "test-channel", "serial-001"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("API error"); + }); + }); +}); diff --git a/test/unit/commands/channels/history.test.ts b/test/unit/commands/channels/history.test.ts index e8627eb4..646cc036 100644 --- a/test/unit/commands/channels/history.test.ts +++ b/test/unit/commands/channels/history.test.ts @@ -74,6 +74,32 @@ describe("channels:history command", () => { expect(stdout).toContain("client-1"); }); + it("should display message versioning metadata", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.history.mockResolvedValue({ + items: [ + { + name: "test-event", + data: "hello", + timestamp: Date.now(), + action: "message.update", + serial: "serial-001", + version: "version-serial-001", + }, + ], + }); + + const { stdout } = await runCommand( + ["channels:history", "test-channel"], + import.meta.url, + ); + + expect(stdout).toContain("message.update"); + expect(stdout).toContain("serial-001"); + expect(stdout).toContain("version-serial-001"); + }); + it("should handle empty history", async () => { const mock = getMockAblyRest(); const channel = mock.channels._getChannel("test-channel"); diff --git a/test/unit/commands/channels/publish.test.ts b/test/unit/commands/channels/publish.test.ts index a01a6720..e8b3db6c 100644 --- a/test/unit/commands/channels/publish.test.ts +++ b/test/unit/commands/channels/publish.test.ts @@ -209,6 +209,26 @@ describe("ChannelsPublish", function () { expect(realtimeChannel.publish).not.toHaveBeenCalled(); }); + it("should handle null serial from publish result (conflation)", async function () { + const restMock = getMockAblyRest(); + const channel = restMock.channels._getChannel("test-channel"); + channel.publish.mockResolvedValue({ serials: [null] }); + + const { stdout } = await runCommand( + [ + "channels:publish", + "test-channel", + '{"data":"hello"}', + "--transport", + "rest", + ], + import.meta.url, + ); + + // Should NOT display "null" as a serial + expect(stdout).not.toContain("Serial:"); + }); + it("should use rest transport for single message by default", async function () { const realtimeMock = getMockAblyRealtime(); const restMock = getMockAblyRest(); @@ -224,6 +244,29 @@ describe("ChannelsPublish", function () { expect(realtimeChannel.publish).not.toHaveBeenCalled(); }); + it("should include serial in per-message output for multi-message publish", async function () { + const restMock = getMockAblyRest(); + restMock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + [ + "channels:publish", + "test-channel", + '{"data":"count test"}', + "--transport", + "rest", + "--count", + "2", + "--delay", + "0", + ], + import.meta.url, + ); + + // Each message should show its serial + expect(stdout).toContain("mock-serial-001"); + }); + describe("message delay and ordering", function () { it("should publish messages with delay", async function () { const realtimeMock = getMockAblyRealtime(); diff --git a/test/unit/commands/channels/update.test.ts b/test/unit/commands/channels/update.test.ts new file mode 100644 index 00000000..86bfabd0 --- /dev/null +++ b/test/unit/commands/channels/update.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js"; +import { + standardHelpTests, + standardArgValidationTests, + standardFlagTests, +} from "../../../helpers/standard-tests.js"; + +describe("channels:update command", () => { + beforeEach(() => { + getMockAblyRest(); + }); + + standardHelpTests("channels:update", import.meta.url); + standardArgValidationTests("channels:update", import.meta.url, { + requiredArgs: ["test-channel", "serial-001"], + }); + standardFlagTests("channels:update", import.meta.url, [ + "--json", + "--name", + "--encoding", + "--description", + ]); + + describe("functionality", () => { + it("should update a message with JSON data", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + const { stdout } = await runCommand( + ["channels:update", "test-channel", "serial-001", '{"data":"updated"}'], + import.meta.url, + ); + + expect(mock.channels.get).toHaveBeenCalledWith("test-channel"); + expect(channel.updateMessage).toHaveBeenCalledOnce(); + expect(channel.updateMessage.mock.calls[0][0]).toEqual({ + serial: "serial-001", + data: "updated", + }); + expect(stdout).toContain("updated"); + }); + + it("should update a message with plain text", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:update", "test-channel", "serial-001", "PlainText"], + import.meta.url, + ); + + expect(channel.updateMessage.mock.calls[0][0]).toEqual({ + serial: "serial-001", + data: "PlainText", + }); + }); + + it("should apply --name flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:update", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--name", + "my-event", + ], + import.meta.url, + ); + + expect(channel.updateMessage.mock.calls[0][0]).toHaveProperty( + "name", + "my-event", + ); + }); + + it("should apply --encoding flag", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:update", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--encoding", + "utf8", + ], + import.meta.url, + ); + + expect(channel.updateMessage.mock.calls[0][0]).toHaveProperty( + "encoding", + "utf8", + ); + }); + + it("should pass description as operation metadata", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + [ + "channels:update", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--description", + "Fixed-typo", + ], + import.meta.url, + ); + + expect(channel.updateMessage.mock.calls[0][1]).toEqual({ + description: "Fixed-typo", + }); + }); + + it("should not pass operation when no description provided", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + + await runCommand( + ["channels:update", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(channel.updateMessage.mock.calls[0][1]).toBeUndefined(); + }); + + it("should output JSON when --json flag is used", async () => { + const { stdout } = await runCommand( + [ + "channels:update", + "test-channel", + "serial-001", + '{"data":"hello"}', + "--json", + ], + import.meta.url, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "channels:update"); + expect(result).toHaveProperty("success", true); + expect(result).toHaveProperty("channel", "test-channel"); + expect(result).toHaveProperty("serial", "serial-001"); + expect(result).toHaveProperty( + "versionSerial", + "mock-version-serial-update", + ); + }); + + it("should handle null versionSerial (operation superseded)", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.updateMessage.mockResolvedValue({ versionSerial: null }); + + const { stdout } = await runCommand( + ["channels:update", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(stdout).toContain("superseded"); + }); + + it("should display version serial in human-readable output", async () => { + const { stdout } = await runCommand( + ["channels:update", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(stdout).toContain("mock-version-serial-update"); + }); + }); + + describe("error handling", () => { + it("should handle API errors gracefully", async () => { + const mock = getMockAblyRest(); + const channel = mock.channels._getChannel("test-channel"); + channel.updateMessage.mockRejectedValue(new Error("API error")); + + const { error } = await runCommand( + ["channels:update", "test-channel", "serial-001", '{"data":"hello"}'], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("API error"); + }); + }); +}); diff --git a/test/unit/utils/message.test.ts b/test/unit/utils/message.test.ts new file mode 100644 index 00000000..658e7876 --- /dev/null +++ b/test/unit/utils/message.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { prepareMessageFromInput } from "../../../src/utils/message.js"; + +describe("prepareMessageFromInput", () => { + it("should handle plain text input", () => { + const msg = prepareMessageFromInput("hello", {}); + expect(msg.data).toBe("hello"); + }); + + it("should handle JSON object with data field", () => { + const msg = prepareMessageFromInput('{"data":"value"}', {}); + expect(msg.data).toBe("value"); + }); + + it("should handle JSON number (primitive)", () => { + const msg = prepareMessageFromInput("5", {}); + expect(msg.data).toBe(5); + }); + + it("should handle JSON array", () => { + const msg = prepareMessageFromInput("[1,2,3]", {}); + expect(msg.data).toEqual([1, 2, 3]); + }); + + it("should handle JSON boolean", () => { + const msg = prepareMessageFromInput("true", {}); + expect(msg.data).toBe(true); + }); + + it("should handle JSON null", () => { + const msg = prepareMessageFromInput("null", {}); + expect(msg.data).toBeNull(); + }); + + it("should extract name from JSON data", () => { + const msg = prepareMessageFromInput('{"name":"evt","data":"hello"}', {}); + expect(msg.name).toBe("evt"); + expect(msg.data).toBe("hello"); + }); + + it("should prefer --name flag over JSON name", () => { + const msg = prepareMessageFromInput('{"name":"evt","data":"hello"}', { + name: "override", + }); + expect(msg.name).toBe("override"); + }); + + it("should extract extras from JSON data", () => { + const msg = prepareMessageFromInput( + '{"data":"hello","extras":{"push":{"notification":{"title":"T"}}}}', + {}, + ); + expect(msg.data).toBe("hello"); + expect(msg.extras).toEqual({ push: { notification: { title: "T" } } }); + }); + + it("should ignore empty extras", () => { + const msg = prepareMessageFromInput('{"data":"hello","extras":{}}', {}); + expect(msg.data).toBe("hello"); + expect(msg.extras).toBeUndefined(); + }); + + it("should set serial when provided", () => { + const msg = prepareMessageFromInput( + '{"data":"hello"}', + {}, + { serial: "s1" }, + ); + expect(msg.serial).toBe("s1"); + }); + + it("should set encoding from flag", () => { + const msg = prepareMessageFromInput("hello", { encoding: "utf8" }); + expect(msg.encoding).toBe("utf8"); + }); + + it("should produce empty data for empty object input", () => { + const msg = prepareMessageFromInput("{}", {}); + expect(msg.data).toBeUndefined(); + }); + + it("should apply interpolation when interpolationIndex is provided", () => { + const msg = prepareMessageFromInput( + "Message {{.Count}}", + {}, + { + interpolationIndex: 3, + }, + ); + expect(msg.data).toBe("Message 3"); + }); + + it("should not set serial when not provided", () => { + const msg = prepareMessageFromInput('{"data":"hello"}', {}); + expect(msg.serial).toBeUndefined(); + }); +});