diff --git a/docs/Auto-completion.md b/docs/Auto-completion.md index a2fda542d..279224104 100644 --- a/docs/Auto-completion.md +++ b/docs/Auto-completion.md @@ -107,4 +107,12 @@ If you don't see any suggestions, verify that: - Auto-completion works with all Ably CLI commands and flags - The completion system is context-aware and will only suggest valid options -- Custom aliases are not included in auto-completion \ No newline at end of file +- Custom aliases are not included in auto-completion +- Hidden development flags (e.g., `--control-host`, `--dashboard-host`) are excluded from auto-completion unless `ABLY_SHOW_DEV_FLAGS=true` is set + +--- + +## Related + +- [Interactive REPL](Interactive-REPL.md) — Interactive mode provides its own tab completion for commands and flags +- [Troubleshooting](Troubleshooting.md) — General troubleshooting for CLI issues \ No newline at end of file diff --git a/docs/Debugging.md b/docs/Debugging.md index d3d1b25a9..06b879a94 100644 --- a/docs/Debugging.md +++ b/docs/Debugging.md @@ -55,4 +55,21 @@ Refer to [Testing.md](Testing.md) for how to run specific tests. ```bash DEBUG=oclif* bin/run.js [command] ``` +* **Terminal Diagnostics:** Enable terminal state logging for TTY/stdin/stdout issues: + ```bash + TERMINAL_DIAGNOSTICS=1 ably-interactive + ``` * **Check Configuration:** Use `ably config show` to view stored credentials or `ably config path` to find the config file location. +* **Override Configuration:** Use environment variables to override config for testing: + ```bash + ABLY_API_KEY=your_key ably channels list + ``` + +--- + +## Related + +- [Development Stage](Environment-Variables/Development-Usage.md) Env Variables — Development, testing, debugging, and internal env variables. For user-facing variables, run `ably env`. +- [Testing Guide](Testing.md) — Test layers, running tests, and debugging E2E failures +- [E2E Testing CLI Runner](E2E-Testing-CLI-Runner.md) — E2E test runner debugging flags (`E2E_DEBUG`, `ABLY_CLI_TEST_SHOW_OUTPUT`) +- [Troubleshooting](Troubleshooting.md) — Solutions for common build, test, and runtime issues diff --git a/docs/E2E-Testing-CLI-Runner.md b/docs/E2E-Testing-CLI-Runner.md index 8adbf70c7..7fab37b3e 100644 --- a/docs/E2E-Testing-CLI-Runner.md +++ b/docs/E2E-Testing-CLI-Runner.md @@ -164,7 +164,7 @@ const events = await waitForJsonEvents( When tests fail, the system automatically outputs: -``` +```text === E2E TEST FAILURE DEBUG === Test: should handle presence events Error: Timeout waiting for pattern "Action: enter" @@ -237,4 +237,12 @@ await waitForOutput(subscriber, 'target event'); - **Better CI reliability with proper process management** - **Clear separation of concerns with helper functions** -The system automatically tracks CLI runners per test and provides comprehensive debugging output when tests fail, making it much easier to diagnose issues in CI environments. \ No newline at end of file +The system automatically tracks CLI runners per test and provides comprehensive debugging output when tests fail, making it much easier to diagnose issues in CI environments. + +--- + +## Related + +- [Testing Guide](Testing.md) — Test layers, auth in tests, duration defaults, and running tests +- [Debugging Guide](Debugging.md) — General debugging tips including `DEBUG` env var and Node inspector +- [Troubleshooting](Troubleshooting.md) — Solutions for common build and test errors (WebSocket mocking, HTTP mocking, memory leaks) \ No newline at end of file diff --git a/docs/Environment-Variables/Development-Usage.md b/docs/Environment-Variables/Development-Usage.md new file mode 100644 index 000000000..6567e26be --- /dev/null +++ b/docs/Environment-Variables/Development-Usage.md @@ -0,0 +1,28 @@ +# Environment Variables — Development Stage Usage + +These variables are for CLI contributors, development, testing, and internal modes. They are **not intended for end-user configuration** unless explicitly noted. + +> For user-facing environment variables (authentication, configuration, behavioral control, host overrides), run `ably env` in your terminal. + +--- + +## Quick Reference + +| Variable | Category | Purpose | Default | +| --- | --- | --- | --- | +| `ABLY_SHOW_DEV_FLAGS` | Development | Reveal hidden dev flags | Not set | +| `ABLY_CONTROL_HOST` | Host Override | Override Control API host | `control.ably.net` | +| `ABLY_DASHBOARD_HOST` | Host Override | Override Ably dashboard URL | `https://ably.com` | +| `DEBUG` | Debugging | oclif framework debug output | Not set | +| `TERMINAL_DIAGNOSTICS` | Debugging | Terminal state diagnostics | Not set | +| `ABLY_CLI_TEST_MODE` | Testing | Enable test mode | Not set | +| `SKIP_CONFIRMATION` | Testing | Auto-confirm prompts (test alias for `ABLY_CLI_NON_INTERACTIVE`) | Not set | +| `GENERATING_DOC` | Tooling | Doc generation mode | Not set | +| `CI` | Environment | CI detection | Not set | +| `ABLY_INTERACTIVE_MODE` | Internal | Interactive shell mode flag | Not set | +| `ABLY_WRAPPER_MODE` | Internal | Wrapper script detection | Not set | +| `ABLY_SUPPRESS_WELCOME` | Internal | Suppress welcome logo | Not set | +| `ABLY_WEB_CLI_MODE` | Internal | Web browser CLI mode | Not set | +| `ABLY_ANONYMOUS_USER_MODE` | Internal | Anonymous web CLI mode | Not set | +| `ABLY_CURRENT_COMMAND` | Internal | Current command tracking | Set automatically | +| `NODE_ENV` | Internal | Node environment override | Not set | diff --git a/docs/Environment-Variables/General-Usage.md b/docs/Environment-Variables/General-Usage.md new file mode 100644 index 000000000..4d33aefbc --- /dev/null +++ b/docs/Environment-Variables/General-Usage.md @@ -0,0 +1,23 @@ +# Environment Variables — General Usage + +These environment variables are most commonly used during development as well as by end users in CI/CD pipelines, scripts, and production use. + +> **Note:** The CLI does not automatically load `.env` files. Set environment variables in your shell, CI/CD configuration, or inline with your commands. + +--- + +## Quick Reference + +| Variable | Category | Purpose | Default | +| --- | --- | --- | --- | +| `ABLY_API_KEY` | Authentication | API key for data plane commands | None | +| `ABLY_TOKEN` | Authentication | Token/JWT for data plane commands | None | +| `ABLY_ACCESS_TOKEN` | Authentication | Access token for Control API commands | None | +| `ABLY_APP_ID` | App Selection | Default app for `--app` flag | None | +| `ABLY_CLI_CONFIG_DIR` | Configuration | Custom config directory | `~/.ably` | +| `ABLY_HISTORY_FILE` | Configuration | Custom history file location | `~/.ably/history` | +| `ABLY_CLI_DEFAULT_DURATION` | Behavior | Auto-exit long-running commands (seconds) | None (forever) | +| `ABLY_CLI_NON_INTERACTIVE` | Behavior | Auto-confirm "Did you mean?" prompts | Not set | +| `ABLY_ENDPOINT` | Host Override | Override Realtime/REST API endpoint | SDK default | + +> For development, testing, debugging, and internal variables, see [Development Stage Usage](Development-Usage.md). diff --git a/docs/Exit-Codes.md b/docs/Exit-Codes.md index 1d5f7b12a..7f892f5a9 100644 --- a/docs/Exit-Codes.md +++ b/docs/Exit-Codes.md @@ -54,4 +54,11 @@ The `bin/ably-interactive` wrapper script uses these exit codes to determine whe Exit codes are handled in: - `src/commands/interactive.ts`: Sets exit code 42 for user exit - `src/utils/sigint-exit.ts`: Handles SIGINT behavior and exit code 130 -- `bin/ably-interactive`: Wrapper script that interprets exit codes \ No newline at end of file +- `bin/ably-interactive`: Wrapper script that interprets exit codes + +--- + +## Related + +- [Interactive-REPL.md](Interactive-REPL.md) — Architecture and wrapper script design +- [Troubleshooting.md](Troubleshooting.md#interactive-mode-issues) — Common interactive mode issues \ No newline at end of file diff --git a/docs/Interactive-REPL.md b/docs/Interactive-REPL.md index 395565aa8..712c547fa 100644 --- a/docs/Interactive-REPL.md +++ b/docs/Interactive-REPL.md @@ -29,7 +29,7 @@ There are some relevant Node.js projects we can draw inspiration from: [oclif](https://oclif.io/) does not appear to have any plugins to support an interactive/embedded CLI mode. However, a [REPL plugin](https://github.com/sisou/oclif-plugin-repl) exists, although that's unlikely to share much with the goals of interactive CLI. -If there are any existing libraries that we can depend on to enable this functionality, taht that should be our preference to keep the CLI complexity low. However, any dependencies used should be well maintained and popular. If the additional dependencies to support this functionality add any material bloat, we should consider how this functionality can be added as an optional plugin so that the standard locally installed CLI has minimal dependencies. +If there are any existing libraries that we can depend on to enable this functionality, that should be our preference to keep the CLI complexity low. However, any dependencies used should be well maintained and popular. If the additional dependencies to support this functionality add any material bloat, we should consider how this functionality can be added as an optional plugin so that the standard locally installed CLI has minimal dependencies. ## Execution Plan @@ -43,7 +43,7 @@ The chosen approach runs commands inline (no spawning/forking) with a bash wrapp - **Inline execution**: Commands run in the same process, eliminating spawn overhead - **Natural Ctrl+C**: Interrupting commands exits the process, wrapper restarts seamlessly -- **Persistent history**: Command history saved to `~/.ably/history` across restarts +- **Persistent history**: Command history saved to `~/.ably/history` across restarts (configurable via `ABLY_HISTORY_FILE`) - **Special exit handling**: Typing 'exit' uses exit code 42 to signal wrapper to terminate (see [Exit Codes documentation](Exit-Codes.md) for details) **Expected Performance**: @@ -409,3 +409,13 @@ private getAvailableCommands(): string[] { 5. **Reliability**: Leverages OS-level process management This plan delivers a responsive interactive shell with natural Ctrl+C handling and seamless user experience through the bash wrapper approach. + +--- + +## Related + +- [Exit Codes](Exit-Codes.md) — Exit codes used in interactive mode and wrapper script behavior +- [Troubleshooting](Troubleshooting.md#interactive-mode-issues) — Common interactive mode issues (unexpected exits, Ctrl+C, history) +- [Auto-completion](Auto-completion.md) — Shell tab completion setup for commands and flags +- [Testing Guide](Testing.md) — Subprocess and TTY test layers for interactive mode +- [Project Structure](Project-Structure.md) — Repository layout including `src/commands/interactive.ts` and `bin/ably-interactive` diff --git a/docs/Project-Structure.md b/docs/Project-Structure.md index 45d0dcb4d..dcc6f3cc1 100644 --- a/docs/Project-Structure.md +++ b/docs/Project-Structure.md @@ -167,3 +167,13 @@ This document outlines the directory structure of the Ably CLI project. ├── LICENSE └── README.md ``` + +--- + +## Related + +- [Development Stage](Environment-Variables/Development-Usage.md) Env Variables — Development, testing, debugging, and internal env variables. For user-facing variables, run `ably env`. +- [Testing Guide](Testing.md) — Test layers and directory layout (`test/unit/`, `test/e2e/`, `test/tty/`, `test/integration/`) +- [Debugging Guide](Debugging.md) — Debugging tips for CLI development +- [Interactive REPL](Interactive-REPL.md) — Architecture of `src/commands/interactive.ts` and `bin/ably-interactive` +- [Exit Codes](Exit-Codes.md) — Exit codes handled in `src/commands/interactive.ts` and `src/utils/sigint-exit.ts` diff --git a/docs/Testing.md b/docs/Testing.md index d5148a233..3a65eeabf 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -68,7 +68,7 @@ Unit tests for commands with `--json` support should test all three output modes ## Choosing the right layer -``` +```text What are you testing? │ ├─ Flag parsing, help output, error messages, output format? @@ -99,7 +99,7 @@ Explicit rules: |---------|-------------| | `pnpm test:unit` | All unit tests | | `pnpm test:integration` | Subprocess/interactive tests | -| `pnpm test:e2e` | E2E tests (needs `ABLY_API_KEY` etc.) | +| `pnpm test:e2e` | E2E tests (needs `E2E_ABLY_API_KEY` etc.) | | `pnpm test:tty` | TTY tests (local only, needs real terminal) | | `pnpm test` | Unit + integration + E2E | | `pnpm test test/unit/commands/foo.test.ts` | Specific test file | @@ -114,6 +114,8 @@ Set `E2E_DEBUG=true` and/or `ABLY_CLI_TEST_SHOW_OUTPUT=true` for verbose output: E2E_DEBUG=true ABLY_CLI_TEST_SHOW_OUTPUT=true pnpm test:e2e ``` +See [E2E-Testing-CLI-Runner.md](E2E-Testing-CLI-Runner.md) for the full E2E debugging guide. + --- ## Test structure @@ -134,6 +136,8 @@ Exempt: `interactive.test.ts`, `interactive-sigint.test.ts`, `bench/*.test.ts`. ### Auth in tests +Authentication in tests uses different mechanisms depending on the layer. Run `ably env` for the full reference on `ABLY_API_KEY`, `ABLY_TOKEN`, `ABLY_ACCESS_TOKEN`, and other auth env vars. + **Unit tests** — `MockConfigManager` provides auth automatically. No env vars or flags needed: ```typescript @@ -159,7 +163,7 @@ runCommand(["channels", "publish", "my-channel", "hello"], { ### Duration in tests -Unit and integration tests set `ABLY_CLI_DEFAULT_DURATION: "0.25"` in `vitest.config.ts`, so subscribe/long-running commands auto-exit after 250ms. Do NOT pass `--duration` to `runCommand()` — it overrides the fast default. +Unit and integration tests set `ABLY_CLI_DEFAULT_DURATION: "0.25"` in `vitest.config.ts`, so subscribe/long-running commands auto-exit after 250ms. Do NOT pass `--duration` to `runCommand()` — it overrides the fast default. Run `ably env ABLY_CLI_DEFAULT_DURATION` for full details on this variable and the 28 commands it affects. Exceptions: `test:wait` command tests (required flag), `interactive-sigint.test.ts` (needs longer for SIGINT), and help output checks. @@ -247,3 +251,11 @@ Each accepts an optional `Partial` to override fields: | `killTty()` | Kill the PTY process (async) | | `PROMPT_PATTERN` | `"ably>"` | | `DEFAULT_WAIT_TIMEOUT` | 8000ms | + +--- + +## Related + +- [Debugging Guide](Debugging.md) — Debugging tips for CLI development, including `DEBUG` and Node inspector +- [E2E Testing CLI Runner](E2E-Testing-CLI-Runner.md) — E2E test runner system, debugging flags, and process management +- [Troubleshooting](Troubleshooting.md) — Solutions for common build and test errors diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 5f86dacdb..308e4dd49 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -11,7 +11,7 @@ This document provides solutions for common issues encountered when developing o **Problem**: Tests failing with errors about modules not being found or incorrect paths. **Example Error**: -``` +```text Error: Cannot find module '../commands/publish' ``` @@ -37,7 +37,7 @@ import MyCommand from '../../src/commands/my-command.js' **Problem**: Tests fail with memory errors or hang indefinitely. **Example Error**: -``` +```text FATAL ERROR: JavaScript heap out of memory ``` @@ -68,7 +68,7 @@ afterEach(async () => { **Problem**: Tests involving WebSocket connections fail or hang. **Example Error**: -``` +```text Timeout of 2000ms exceeded ``` @@ -106,7 +106,7 @@ afterEach(() => { **Problem**: Tests involving HTTP requests fail with network errors. **Example Error**: -``` +```text Error: connect ECONNREFUSED ``` @@ -166,6 +166,10 @@ afterEach(() => { ```bash ABLY_API_KEY=your_key ably channels list ``` +- Override the config directory entirely: + ```bash + ABLY_CLI_CONFIG_DIR=/path/to/custom/config ably accounts login + ``` --- @@ -190,7 +194,7 @@ afterEach(() => { **Problem**: TypeScript compilation errors. **Example Error**: -``` +```text Property 'x' does not exist on type 'Y' ``` @@ -220,10 +224,10 @@ Property 'x' does not exist on type 'Y' **Problem**: The interactive mode exits with unexpected error codes. **Solution**: -- Check the exit code to understand what happened (see [Exit Codes documentation](Exit-Codes.md)) +- Check the exit code to understand what happened (see [Exit Codes documentation](Exit-Codes.md) and [Development Stage Env Variables](Environment-Variables/Development-Usage.md) for interactive mode env vars) - Common exit codes: - - Exit code 0: Normal exit (usually from 'exit' command) - - Exit code 42: User typed 'exit' (special code for wrapper) + - Exit code 0: Wrapper (`ably-interactive`) terminated normally + - Exit code 42: User typed 'exit' in interactive mode (signals wrapper to terminate) - Exit code 130: SIGINT/Ctrl+C (double Ctrl+C or force quit) - Exit code 143: SIGTERM received @@ -243,8 +247,8 @@ Property 'x' does not exist on type 'Y' **Solution**: - Check that `~/.ably/history` file exists and is writable -- Verify the `ABLY_HISTORY_FILE` environment variable if using custom location -- Ensure the history file isn't exceeding size limits (default: 1000 commands) +- Verify the `ABLY_HISTORY_FILE` environment variable if using custom location (run `ably env ABLY_HISTORY_FILE` for details) +- Ensure the history file isn't exceeding size limits (default: 1000 commands, auto-trimmed at 2000 lines) --- @@ -253,3 +257,14 @@ Property 'x' does not exist on type 'Y' If you find errors in documentation or rules, please update them using the proper workflow and submit a pull request. See `AGENTS.md` for more details on the development workflow. + +--- + +## Related + +- [Development Stage](Environment-Variables/Development-Usage.md) Env Variables — Development, testing, debugging, and internal env variables. For user-facing variables, run `ably env`. +- [Debugging Guide](Debugging.md) — Debugging tips including `DEBUG`, `TERMINAL_DIAGNOSTICS`, and Node inspector +- [Testing Guide](Testing.md) — Test layers, running tests, and debugging failures +- [Exit Codes](Exit-Codes.md) — Exit codes used by the CLI, particularly in interactive mode +- [Interactive REPL](Interactive-REPL.md) — Interactive mode architecture and wrapper script design +- [Auto-completion](Auto-completion.md) — Shell tab completion setup and troubleshooting diff --git a/src/base-command.ts b/src/base-command.ts index 6f9189f8d..2186fc3e7 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -63,6 +63,11 @@ export const WEB_CLI_RESTRICTED_COMMANDS = [ // config only applicable to local env "config*", + // env documents local-CLI environment variables (auth, config dirs, history + // files) that don't apply in web CLI mode where auth and config are managed + // by the surrounding web UI + "env", + // File-reading commands can expose server filesystem contents in web CLI mode "push:config:set-apns", "push:config:set-fcm", diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 000000000..b100083ba --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,104 @@ +import { Args } from "@oclif/core"; +import pkg from "fast-levenshtein"; + +import { AblyBaseCommand } from "../base-command.js"; +import { ENV_VARS_DATA } from "../data/env-vars.js"; +import { coreGlobalFlags } from "../flags.js"; +import { BaseFlags } from "../types/cli.js"; +import { + getEnvVarSummaries, + renderMinimalReference, + renderSingleVar, +} from "../utils/env-vars-render.js"; + +const { get: levenshteinDistance } = pkg; + +function buildEnvVarArgDescription(): string { + const summaries = getEnvVarSummaries(); + const maxNameLength = Math.max(...summaries.map((s) => s.name.length)); + const lines = ["Environment variable name. Supported variables:", ""]; + for (const { name, summary } of summaries) { + lines.push(`${name.padEnd(maxNameLength)} ${summary}`); + } + return lines.join("\n"); +} + +const ENV_VAR_HELP_EXAMPLES: string[] = [ + ...ENV_VARS_DATA.variables.map((v) => `$ ably env ${v.name}`), + "$ ably env ABLY_API_KEY --json", + "$ ably env ABLY_API_KEY --pretty-json", +]; + +export default class EnvCommand extends AblyBaseCommand { + static override description = + "Environment variables for authentication and configuration of default settings\n\nExplicitly set environment variables in your shell, CI/CD, or inline. They are not auto-loaded."; + + static override examples = ENV_VAR_HELP_EXAMPLES; + + static override args = { + envVarName: Args.string({ + description: buildEnvVarArgDescription(), + required: false, + }), + }; + + static override flags = { + ...coreGlobalFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(EnvCommand); + const requested = args.envVarName?.toUpperCase(); + + if (requested) { + const match = ENV_VARS_DATA.variables.find((v) => v.name === requested); + if (!match) { + const suggestion = this.suggestVarName(requested); + const hint = suggestion + ? `Did you mean ${suggestion}? Run \`ably env\` to see all supported variables.` + : "Run `ably env` to see all supported variables."; + this.fail( + `Unknown environment variable: ${args.envVarName}. ${hint}`, + flags as BaseFlags, + "env", + ); + } + + if (this.shouldOutputJson(flags)) { + this.logJsonResult({ envVar: match }, flags as BaseFlags); + return; + } + + this.log(renderSingleVar(match.name)); + return; + } + + if (this.shouldOutputJson(flags)) { + this.logJsonResult( + { + envVars: ENV_VARS_DATA.variables, + crossCutting: ENV_VARS_DATA.crossCutting, + relatedLinks: ENV_VARS_DATA.relatedLinks, + }, + flags as BaseFlags, + ); + return; + } + + this.log(renderMinimalReference()); + } + + private suggestVarName(input: string): string | undefined { + const threshold = Math.min(Math.max(1, Math.floor(input.length / 2)), 3); + let best: { name: string; distance: number } | undefined; + for (const v of ENV_VARS_DATA.variables) { + const distance = levenshteinDistance(input, v.name, { + useCollator: true, + }); + if (!best || distance < best.distance) { + best = { name: v.name, distance }; + } + } + return best && best.distance <= threshold ? best.name : undefined; + } +} diff --git a/src/data/env-vars.ts b/src/data/env-vars.ts new file mode 100644 index 000000000..dfbfce1bd --- /dev/null +++ b/src/data/env-vars.ts @@ -0,0 +1,556 @@ +// Per-variable CLI reference rendered by `ably env` and `ably env `. +// Intentionally trimmed to "what it is and what it overrides" — the long-form +// prose with side-effect notes, doc cross-links, and obtaining flows lives in +// docs/Environment-Variables/General-Usage.md and is the source of truth. +// +// Editing convention: +// - Plain strings only. No chalk, no template helpers. +// - Inline `code-spans` use backticks (rendered cyan). +// - Inline **bold-spans** use double asterisks. +// - One inline URL max per variable, only the canonical "where to get one" +// link. All other doc references go in `relatedLinks` (JSON-only). +// - Variable order matters: rendering follows array order. + +export type EnvVarCategory = + | "Authentication" + | "App Selection" + | "Configuration" + | "Behavioral Control" + | "Host Override"; + +export type Block = + | { kind: "paragraph"; text: string } + | { kind: "bullets"; items: readonly string[] } + | { kind: "numbered"; items: readonly string[] } + | { kind: "code"; lines: readonly string[] } + | { kind: "note"; text: string } + | { kind: "important"; text: string } + | { + kind: "table"; + headers: readonly string[]; + rows: readonly (readonly string[])[]; + }; + +export class DetailSection { + constructor( + public readonly heading: string, + public readonly blocks: readonly Block[], + ) {} +} + +export class Example { + constructor(public readonly lines: readonly string[]) {} +} + +export class EnvVarEntry { + constructor( + public readonly name: string, + public readonly category: EnvVarCategory, + public readonly summary: string, + public readonly format: string, + public readonly default_: string, + public readonly precedence: string | null, + public readonly appliesTo: readonly string[], + public readonly intro: string, + public readonly example: Example, + public readonly details: readonly DetailSection[], + ) {} +} + +export class CrossCuttingSection { + constructor( + public readonly heading: string, + public readonly blocks: readonly Block[], + ) {} +} + +export class RelatedLink { + constructor( + public readonly text: string, + public readonly url: string, + public readonly blurb: string, + ) {} +} + +export interface Prerequisite { + label: string; + commands: readonly string[]; + authVars: readonly string[]; +} + +export class EnvVarsData { + constructor( + public readonly meta: { + lede: string; + note: string; + prerequisites: readonly Prerequisite[]; + }, + public readonly variables: readonly EnvVarEntry[], + public readonly crossCutting: { + authResolutionOrder: CrossCuttingSection; + oneShotUsage: CrossCuttingSection; + cicdUsage: CrossCuttingSection; + commandsByAuthType: CrossCuttingSection; + }, + public readonly relatedLinks: readonly RelatedLink[], + ) {} +} + +const ABLY_API_KEY = new EnvVarEntry( + "ABLY_API_KEY", + "Authentication", + "API key for data plane commands", + "APP_ID.KEY_ID:KEY_SECRET", + "None", + "`ABLY_TOKEN` > **`ABLY_API_KEY`** > config file > interactive prompt", + [ + "channels", + "rooms", + "spaces", + "connections", + "bench", + "logs", + "auth issue-ably-token", + "auth issue-jwt-token", + "auth revoke-token", + ], + "Authenticate data plane commands with an Ably API key. Manage keys in the Ably dashboard: https://ably.com/accounts/any/apps/any/app_keys", + new Example([ + `export ABLY_API_KEY="your-app-id.key-id:key-secret"`, + `ably channels publish my-channel "Hello"`, + ]), + [ + new DetailSection("Login bypass", [ + { + kind: "paragraph", + text: "Bypasses the `ably login` workflow and skips interactive app/key selection. Useful in scripts and CI/CD pipelines.", + }, + ]), + new DetailSection("Client ID", [ + { + kind: "paragraph", + text: "Auto-generates a default client ID in the format `ably-cli-{uuid}`. Override with `--client-id `, or pass `--client-id none` to send no client ID.", + }, + ]), + ], +); + +const ABLY_TOKEN = new EnvVarEntry( + "ABLY_TOKEN", + "Authentication", + "Token/JWT for data plane commands", + "Ably token string or JWT string", + "None", + "**`ABLY_TOKEN`** > `ABLY_API_KEY` > config file > interactive prompt", + ["channels", "rooms", "spaces", "connections", "bench", "logs"], + "Authenticate data plane commands with an Ably token or JWT (**highest priority** of all auth methods). Issue with `ably auth issue-ably-token` or `ably auth issue-jwt-token`.", + new Example([ + `export ABLY_TOKEN="$(ably auth issue-ably-token --token-only)"`, + `ably channels subscribe my-channel`, + ]), + [ + new DetailSection("Login bypass", [ + { + kind: "paragraph", + text: "Bypasses the `ably login` workflow and skips interactive app/key selection. Useful in scripts and CI/CD pipelines.", + }, + ]), + new DetailSection("Client ID", [ + { + kind: "paragraph", + text: "`--client-id` is ignored when `ABLY_TOKEN` is set — the client ID is embedded in the token. A warning is logged if `--client-id` is passed.", + }, + ]), + new DetailSection("Token expiry", [ + { + kind: "paragraph", + text: "The CLI does not refresh tokens. If the token expires during a long-running command (e.g. `channels subscribe`), the connection fails. Prefer `ABLY_API_KEY` for long-running commands.", + }, + ]), + new DetailSection("", [ + { + kind: "important", + text: "`ABLY_TOKEN` overrides any API key, so `auth issue-ably-token` (which requires a key) fails when set. Run `unset ABLY_TOKEN` before issuing tokens.", + }, + ]), + ], +); + +const ABLY_ACCESS_TOKEN = new EnvVarEntry( + "ABLY_ACCESS_TOKEN", + "Authentication", + "Access token for Control API commands", + "OAuth 2.0 bearer token string", + "None", + "**`ABLY_ACCESS_TOKEN`** > config file access token", + ["accounts", "apps", "auth keys", "integrations", "queues", "push", "stats"], + "Authenticate Control API commands with an access token. Create one in the Ably dashboard: https://ably.com/users/access_tokens", + new Example([ + `export ABLY_ACCESS_TOKEN="your-access-token"`, + `ably apps list --json`, + ]), + [ + new DetailSection("Login bypass", [ + { + kind: "paragraph", + text: "Bypasses the `ably login` workflow and skips account config lookup. Useful in scripts and CI/CD pipelines.", + }, + ]), + ], +); + +const ABLY_APP_ID = new EnvVarEntry( + "ABLY_APP_ID", + "App Selection", + "Default app for `--app` flag", + "App ID (e.g., `abc123`) or app name (e.g., `My App`)", + "None", + "`--app` CLI flag > **`ABLY_APP_ID`** > current app config > interactive prompt", + ["any command accepting --app"], + "Provide a default value for the `--app` flag across commands.", + new Example([`export ABLY_APP_ID="your-app-id"`, `ably auth keys list`]), + [], +); + +const ABLY_CLI_CONFIG_DIR = new EnvVarEntry( + "ABLY_CLI_CONFIG_DIR", + "Configuration", + "Custom config directory", + "Directory path", + "~/.ably", + null, + ["all commands"], + "Override the directory where the CLI stores its configuration file.", + new Example([ + `export ABLY_CLI_CONFIG_DIR="/path/to/custom/config"`, + `ably accounts login`, + ]), + [], +); + +const ABLY_HISTORY_FILE = new EnvVarEntry( + "ABLY_HISTORY_FILE", + "Configuration", + "Custom history file location", + "File path", + "~/.ably/history", + null, + ["ably-interactive"], + "Override the location of the interactive mode command history file.", + new Example([ + `export ABLY_HISTORY_FILE="/path/to/custom/history"`, + `ably-interactive`, + ]), + [ + new DetailSection("", [ + { + kind: "note", + text: "Auto-set by the `ably-interactive` shell wrapper; only set manually for a custom location.", + }, + ]), + ], +); + +const ABLY_CLI_DEFAULT_DURATION = new EnvVarEntry( + "ABLY_CLI_DEFAULT_DURATION", + "Behavioral Control", + "Auto-exit long-running commands (seconds)", + 'Number (seconds). Value <= 0 is treated as "run forever".', + "None (forever)", + "`--duration` flag > **`ABLY_CLI_DEFAULT_DURATION`** > run forever", + ["subscribe", "stream", "enter", "set", "acquire"], + "Auto-exit long-running commands after N seconds.", + new Example([ + `ABLY_CLI_DEFAULT_DURATION=30 ably channels subscribe my-channel`, + ]), + [], +); + +const ABLY_CLI_NON_INTERACTIVE = new EnvVarEntry( + "ABLY_CLI_NON_INTERACTIVE", + "Behavioral Control", + 'Auto-confirm "Did you mean?" prompts', + '`"true"`', + "Not set", + null, + ["all commands"], + "Skip confirmation prompts for non-interactive/automated use.", + new Example([ + `export ABLY_CLI_NON_INTERACTIVE=true`, + `ably chanels publish my-channel "Hello"`, + ]), + [ + new DetailSection("Scope", [ + { + kind: "paragraph", + text: "Only auto-confirms `Did you mean...?` suggestions for mistyped commands and topic-command disambiguation.", + }, + { + kind: "important", + text: "Does **not** skip prompts for destructive operations — those still require the `--force` flag. Output formatting, spinners, and other interactive features are also unaffected.", + }, + ]), + ], +); + +const ABLY_ENDPOINT = new EnvVarEntry( + "ABLY_ENDPOINT", + "Host Override", + "Override Realtime/REST API endpoint", + "Hostname or URL (passed as-is, no normalization)", + "SDK default", + "**`ABLY_ENDPOINT`** > account config endpoint > SDK default", + ["channels", "rooms", "spaces", "connections", "bench", "logs"], + "Override the Ably Realtime/REST API endpoint for all data plane commands.", + new Example([ + `export ABLY_ENDPOINT="custom-endpoint.example.com"`, + `ably channels publish my-channel "Hello"`, + ]), + [], +); + +const AUTH_RESOLUTION_ORDER = new CrossCuttingSection( + "Authentication Resolution Order", + [ + { + kind: "paragraph", + text: "Data plane commands (channels, rooms, spaces, etc.):", + }, + { + kind: "numbered", + items: [ + "`ABLY_TOKEN` environment variable (token auth)", + "`ABLY_API_KEY` environment variable (API key auth)", + "API key from logged-in account configuration (`~/.ably/config`)", + "Interactive prompt to select app and key (requires `ABLY_ACCESS_TOKEN` or logged-in account)", + ], + }, + { + kind: "paragraph", + text: "Control API commands (accounts, apps, auth keys, etc.):", + }, + { + kind: "numbered", + items: [ + "`ABLY_ACCESS_TOKEN` environment variable", + "Access token from logged-in account configuration (`~/.ably/config`)", + ], + }, + { kind: "paragraph", text: "Login bypass summary:" }, + { + kind: "table", + headers: ["Variable", "What it bypasses", "Error on invalid credential"], + rows: [ + [ + "`ABLY_API_KEY`", + "Skips interactive app/key selection. App ID extracted from key.", + '"Invalid API key. Ensure you have a valid key configured." (40100)', + ], + [ + "`ABLY_TOKEN`", + "Skips interactive app/key selection. Token passed directly to SDK.", + '"Invalid token. Please provide a valid Ably Token or JWT." (40100)', + ], + [ + "`ABLY_ACCESS_TOKEN`", + "Skips account config lookup. Token used as bearer token for Control API.", + "HTTP 401 from Control API", + ], + ], + }, + { kind: "paragraph", text: "When any auth env var is set:" }, + { + kind: "bullets", + items: [ + "The account/app info banner is suppressed", + "No `~/.ably/` config files are required", + ], + }, + ], +); + +const ONE_SHOT_USAGE = new CrossCuttingSection( + "Running Commands Without Login (One-Shot Usage)", + [ + { + kind: "paragraph", + text: 'Environment variables enable "one-shot" command execution without any prior login, config files, or interactive prompts:', + }, + { + kind: "code", + lines: [ + "# Data plane: publish with no setup", + `ABLY_API_KEY="appId.keyId:keySecret" ably channels publish my-channel "Hello"`, + "", + "# Token auth: issue and use in one line", + `ABLY_TOKEN="$(ABLY_API_KEY='appId.keyId:keySecret' ably auth issue-ably-token --token-only)" ably channels subscribe my-channel`, + "", + "# Control API: manage apps with no login", + `ABLY_ACCESS_TOKEN="your-access-token" ably apps list --json`, + "", + "# Fully contextless: combine auth + app + non-interactive", + `export ABLY_ACCESS_TOKEN="your-access-token"`, + `export ABLY_APP_ID="your-app-id"`, + `export ABLY_CLI_NON_INTERACTIVE=true`, + `ably auth keys list --json`, + ], + }, + ], +); + +const CICD_USAGE = new CrossCuttingSection("CI/CD Usage", [ + { + kind: "paragraph", + text: "Environment variables are the recommended authentication method for CI/CD pipelines:", + }, + { + kind: "code", + lines: [ + "# GitHub Actions: store secrets in repository settings", + `ABLY_API_KEY="\${{ secrets.ABLY_API_KEY }}" ably channels publish deploy-notifications "Deployment v1.2.3 complete"`, + `ABLY_ACCESS_TOKEN="\${{ secrets.ABLY_ACCESS_TOKEN }}" ably apps list --json`, + "", + "# Combine with duration for scripted subscribe", + `ABLY_API_KEY="\${{ secrets.ABLY_API_KEY }}" ABLY_CLI_DEFAULT_DURATION=10 ably channels subscribe my-channel --json`, + "", + "# Comprehensive workflow-level setup", + `export ABLY_ACCESS_TOKEN="\${{ secrets.ABLY_ACCESS_TOKEN }}"`, + `export ABLY_APP_ID="my-production-app"`, + `export ABLY_CLI_NON_INTERACTIVE=true`, + `ably auth keys list --json`, + ], + }, +]); + +const COMMANDS_BY_AUTH_TYPE = new CrossCuttingSection("Commands by Auth Type", [ + { kind: "paragraph", text: "Data Plane (`ABLY_API_KEY` / `ABLY_TOKEN`):" }, + { + kind: "table", + headers: ["Command group", "Subcommands"], + rows: [ + [ + "`channels`", + "publish, subscribe, list, history, occupancy, inspect, presence, annotations", + ], + ["`rooms`", "messages, presence, typing, reactions, occupancy"], + ["`spaces`", "locks, cursors, members, locations, occupancy"], + ["`connections`", "test"], + ["`bench`", "publish, subscribe"], + ["`logs`", "channel-lifecycle, connection-lifecycle, push"], + ["`auth`", "issue-ably-token, issue-jwt-token, revoke-token"], + ], + }, + { kind: "paragraph", text: "Control API (`ABLY_ACCESS_TOKEN`):" }, + { + kind: "table", + headers: ["Command group", "Subcommands"], + rows: [ + ["`accounts`", "login, switch, list, current, logout"], + ["`apps`", "list, create, delete, update, current, switch"], + ["`auth keys`", "list, create, update, revoke, switch, current, get"], + ["`integrations`", "list, create, delete, update"], + ["`queues`", "list, create, delete"], + ["`push`", "device registrations, channel subscriptions, config"], + ["`stats`", "app, account"], + ], + }, + { kind: "paragraph", text: "Hybrid:" }, + { + kind: "paragraph", + text: "Any data plane command when no API key is configured triggers the interactive app/key selection flow, which uses `ABLY_ACCESS_TOKEN` (or a logged-in account) to call the Control API.", + }, +]); + +export const ENV_VARS_DATA: EnvVarsData = new EnvVarsData( + { + lede: "The Ably CLI supports environment variables for authentication and configuration. These bypass the `ably login` workflow and are useful in scripts, CI/CD pipelines, and automated workflows.", + note: "The CLI does not automatically load `.env` files. Set them in your shell or CI.", + prerequisites: [ + { + label: "Data plane commands", + commands: [ + "channels", + "rooms", + "spaces", + "connections", + "bench", + "logs", + "auth", + ], + authVars: ["ABLY_API_KEY", "ABLY_TOKEN"], + }, + { + label: "Control API commands", + commands: [ + "accounts", + "apps", + "auth keys", + "integrations", + "queues", + "push", + "stats", + ], + authVars: ["ABLY_ACCESS_TOKEN"], + }, + ], + }, + [ + ABLY_API_KEY, + ABLY_TOKEN, + ABLY_ACCESS_TOKEN, + ABLY_ENDPOINT, + ABLY_APP_ID, + ABLY_CLI_CONFIG_DIR, + ABLY_HISTORY_FILE, + ABLY_CLI_DEFAULT_DURATION, + ABLY_CLI_NON_INTERACTIVE, + ], + { + authResolutionOrder: AUTH_RESOLUTION_ORDER, + oneShotUsage: ONE_SHOT_USAGE, + cicdUsage: CICD_USAGE, + commandsByAuthType: COMMANDS_BY_AUTH_TYPE, + }, + [ + new RelatedLink( + "Authentication overview", + "https://ably.com/docs/auth", + "API key format, basic vs token auth, and security guidance", + ), + new RelatedLink( + "API keys", + "https://ably.com/docs/platform/account/app/api", + "Create and manage API keys in the dashboard", + ), + new RelatedLink( + "Token authentication", + "https://ably.com/docs/auth/token", + "Token auth flows, TTL limits, and token refresh", + ), + new RelatedLink( + "JWTs", + "https://ably.com/docs/auth/token/jwt", + "JWT creation, claims, channel-scoped claims, and per-connection rate limits", + ), + new RelatedLink( + "Capabilities", + "https://ably.com/docs/auth/capabilities", + "Capability operations and wildcard syntax for keys and tokens", + ), + new RelatedLink( + "Access tokens", + "https://ably.com/docs/platform/account/access-tokens", + "Create, manage, rotate, and revoke access tokens for the Control API and CLI", + ), + new RelatedLink( + "Control API", + "https://ably.com/docs/platform/account/control-api", + "Control API authentication and usage reference", + ), + new RelatedLink( + "Ably CLI", + "https://ably.com/docs/platform/tools/cli", + "Official CLI documentation including authentication setup", + ), + ], +); diff --git a/src/utils/env-vars-render.ts b/src/utils/env-vars-render.ts new file mode 100644 index 000000000..529d72d15 --- /dev/null +++ b/src/utils/env-vars-render.ts @@ -0,0 +1,183 @@ +import chalk from "chalk"; +import Table from "cli-table3"; + +import { + type Block, + type DetailSection, + type EnvVarEntry, + ENV_VARS_DATA, +} from "../data/env-vars.js"; + +const c = { + code: (s: string) => chalk.cyan(s), + bold: (s: string) => chalk.bold(s), + dim: (s: string) => chalk.dim(s), + heading: (s: string) => chalk.bold.underline(s), + category: (s: string) => chalk.bold(s), + varHeading: (s: string) => chalk.bold.cyan(s), + callout: { + note: (s: string) => `${chalk.yellow("⚠")} ${s}`, + important: (s: string) => `${chalk.red("!")} ${s}`, + }, + bullet: (s: string) => ` • ${s}`, + numbered: (n: number, s: string) => ` ${n}. ${s}`, +}; + +const BORDERLESS_TABLE_CHARS = { + top: "", + "top-mid": "", + "top-left": "", + "top-right": "", + bottom: "", + "bottom-mid": "", + "bottom-left": "", + "bottom-right": "", + left: "", + "left-mid": "", + mid: "", + "mid-mid": "", + right: "", + "right-mid": "", + middle: " ", +}; + +// Inline-markup post-processor for plain strings authored in env-vars.ts: +// `code-spans` → cyan +// **bold-spans** → bold +function applyInlineMarkup(text: string): string { + return text + .replaceAll(/\*\*([^*]+)\*\*/g, (_m, inner: string) => c.bold(inner)) + .replaceAll(/`([^`]+)`/g, (_m, inner: string) => c.code(inner)); +} + +function stripInlineMarkup(text: string): string { + return text + .replaceAll(/\*\*([^*]+)\*\*/g, "$1") + .replaceAll(/`([^`]+)`/g, "$1"); +} + +function renderParagraph(text: string): string { + return applyInlineMarkup(text); +} + +function renderBlock(b: Block): string { + switch (b.kind) { + case "paragraph": { + return renderParagraph(b.text); + } + case "bullets": { + return b.items + .map((item) => c.bullet(applyInlineMarkup(item))) + .join("\n"); + } + case "numbered": { + return b.items + .map((item, i) => c.numbered(i + 1, applyInlineMarkup(item))) + .join("\n"); + } + case "code": { + const body = b.lines.map((line) => ` ${line}`).join("\n"); + return body.replaceAll( + /^(\s*)\$ /gm, + (_m, indent: string) => `${indent}${chalk.green("$ ")}`, + ); + } + case "note": { + return c.callout.note(applyInlineMarkup(b.text)); + } + case "important": { + return c.callout.important(applyInlineMarkup(b.text)); + } + case "table": { + const t = new Table({ + head: b.headers.map((h) => c.bold(h)), + chars: BORDERLESS_TABLE_CHARS, + style: { "padding-left": 0, "padding-right": 2, head: [], border: [] }, + }); + for (const row of b.rows) + t.push(row.map((cell) => applyInlineMarkup(cell))); + return t.toString(); + } + } +} + +function renderPropertyTable(rows: Array<[string, string]>): string { + if (rows.length === 0) return ""; + const t = new Table({ + chars: BORDERLESS_TABLE_CHARS, + style: { "padding-left": 0, "padding-right": 2, head: [], border: [] }, + }); + for (const [k, v] of rows) t.push([c.bold(k), applyInlineMarkup(v)]); + return t.toString(); +} + +function buildPropertyTable(v: EnvVarEntry): Array<[string, string]> { + const rows: Array<[string, string]> = [["Format", v.format]]; + if (v.appliesTo.length > 0) { + rows.push([ + "Applicable commands", + v.appliesTo.map((s) => `\`${s}\``).join(", "), + ]); + } + if (v.default_ && v.default_ !== "None" && v.default_ !== "Not set") { + rows.push(["Default", v.default_]); + } + if (v.precedence) { + rows.push(["Precedence", v.precedence]); + } + return rows; +} + +function renderDetailSection(s: DetailSection): string { + const parts: string[] = []; + if (s.heading) parts.push(c.bold(`${s.heading}:`)); + for (const block of s.blocks) parts.push(renderBlock(block)); + return parts.join("\n\n"); +} + +function renderVarSection(v: EnvVarEntry): string { + const parts: string[] = [c.varHeading(v.name), applyInlineMarkup(v.intro)]; + const tbl = renderPropertyTable(buildPropertyTable(v)); + if (tbl) parts.push(tbl); + for (const section of v.details) parts.push(renderDetailSection(section)); + parts.push( + c.bold("Example:"), + v.example.lines.map((line) => ` ${chalk.green("$ ")}${line}`).join("\n"), + ); + return parts.join("\n\n"); +} + +export function getEnvVarSummaries(): Array<{ name: string; summary: string }> { + return ENV_VARS_DATA.variables.map((v) => ({ + name: v.name, + summary: stripInlineMarkup(v.summary), + })); +} + +export function renderMinimalReference(): string { + const entries = getEnvVarSummaries(); + const maxNameLength = Math.max(...entries.map((e) => e.name.length)); + const prefix = "ably env "; + const padTarget = prefix.length + maxNameLength + 2; + + const lines: string[] = [ + "Ably Environment variables for authentication and configuration of default settings", + "", + ]; + for (const e of entries) { + const left = `${prefix}${e.name}`; + const padded = left.padEnd(padTarget); + const colored = c.code(padded); + lines.push(` ${colored} - ${e.summary}`); + } + lines.push("", `Run \`${c.code("ably env --help")}\` for more information.`); + return lines.join("\n") + "\n"; +} + +export function renderSingleVar(name: string): string { + const section = ENV_VARS_DATA.variables.find((v) => v.name === name); + if (!section) { + throw new Error(`No section defined for env var: ${name}`); + } + return renderVarSection(section) + "\n"; +} diff --git a/test/integration/env.test.ts b/test/integration/env.test.ts new file mode 100644 index 000000000..33d51ff97 --- /dev/null +++ b/test/integration/env.test.ts @@ -0,0 +1,93 @@ +import { exec } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { beforeAll, describe, expect, it } from "vitest"; + +const execAsync = promisify(exec); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe("env command (integration)", () => { + const timeout = 15000; + let binPath: string; + + beforeAll(() => { + binPath = path.join(__dirname, "../../bin/development.js"); + }); + + it( + "prints the minimal reference with every var name and the help-page footer", + async () => { + const { stdout } = await execAsync(`NO_COLOR=1 node ${binPath} env`, { + env: { ...process.env, NO_COLOR: "1" }, + }); + const expected = [ + "ABLY_API_KEY", + "ABLY_TOKEN", + "ABLY_ACCESS_TOKEN", + "ABLY_ENDPOINT", + "ABLY_APP_ID", + "ABLY_CLI_CONFIG_DIR", + "ABLY_HISTORY_FILE", + "ABLY_CLI_DEFAULT_DURATION", + "ABLY_CLI_NON_INTERACTIVE", + ]; + for (const name of expected) { + expect(stdout).toContain(name); + expect(stdout).toContain(`ably env ${name}`); + } + expect(stdout).toContain("Ably Environment variables"); + expect(stdout).toContain("ably env --help"); + }, + timeout, + ); + + it( + "per-var view contains var-specific content", + async () => { + const { stdout } = await execAsync( + `NO_COLOR=1 node ${binPath} env ABLY_TOKEN`, + { env: { ...process.env, NO_COLOR: "1" } }, + ); + expect(stdout).toContain("ABLY_TOKEN"); + expect(stdout).toContain("highest priority"); + expect(stdout).not.toContain("custom-endpoint.example.com"); + }, + timeout, + ); + + it( + "--json emits the envVars envelope", + async () => { + const { stdout } = await execAsync(`node ${binPath} env --json`); + const line = stdout.trim().split("\n").find(Boolean); + const result = JSON.parse(line!); + expect(result.type).toBe("result"); + expect(result.command).toBe("env"); + expect(result.success).toBe(true); + expect(result.envVars).toHaveLength(9); + }, + timeout, + ); + + it( + "exits non-zero with a suggestion for an unknown var", + async () => { + const result = await execAsync(`node ${binPath} env ABLY_API_KEYY`) + .then((r) => ({ ok: true as const, ...r })) + .catch((error: { code?: number; stderr?: string }) => ({ + ok: false as const, + code: error.code, + stderr: error.stderr, + })); + expect(result.ok).toBe(false); + expect((result as { code?: number }).code).not.toBe(0); + expect((result as { stderr?: string }).stderr).toContain( + "Did you mean ABLY_API_KEY", + ); + }, + timeout, + ); +}); diff --git a/test/unit/commands/env.test.ts b/test/unit/commands/env.test.ts new file mode 100644 index 000000000..06fe6b379 --- /dev/null +++ b/test/unit/commands/env.test.ts @@ -0,0 +1,152 @@ +import { runCommand } from "@oclif/test"; +import { describe, expect, it } from "vitest"; + +import { + standardArgValidationTests, + standardHelpTests, +} from "../../helpers/standard-tests.js"; +import { parseJsonOutput } from "../../helpers/ndjson.js"; + +const CANONICAL_NAMES = [ + "ABLY_API_KEY", + "ABLY_TOKEN", + "ABLY_ACCESS_TOKEN", + "ABLY_ENDPOINT", + "ABLY_APP_ID", + "ABLY_CLI_CONFIG_DIR", + "ABLY_HISTORY_FILE", + "ABLY_CLI_DEFAULT_DURATION", + "ABLY_CLI_NON_INTERACTIVE", +]; + +describe("env command", () => { + standardHelpTests("env", import.meta.url); + standardArgValidationTests("env", import.meta.url); + + describe("functionality", () => { + it("contains every var name in the minimal default view", async () => { + const { stdout } = await runCommand(["env"], import.meta.url); + for (const name of CANONICAL_NAMES) expect(stdout).toContain(name); + }); + + it("shows a summary table with ably env prefix for all variables", async () => { + const { stdout } = await runCommand(["env"], import.meta.url); + expect(stdout).toContain("Ably Environment variables"); + for (const name of CANONICAL_NAMES) { + expect(stdout).toContain(`ably env ${name}`); + } + expect(stdout).not.toContain("Quick Reference"); + }); + + it("does not render cross-cutting sections in the minimal view", async () => { + const { stdout } = await runCommand(["env"], import.meta.url); + expect(stdout).not.toContain("Authentication Resolution Order"); + expect(stdout).not.toContain("Running Commands Without Login"); + expect(stdout).not.toContain("CI/CD Usage"); + expect(stdout).not.toContain("Commands by Auth Type"); + }); + + it("does not contain any dev-only references", async () => { + const { stdout } = await runCommand(["env"], import.meta.url); + expect(stdout).not.toContain("Development-Usage.md"); + expect(stdout).not.toContain("Debugging.md"); + expect(stdout).not.toContain("Testing.md"); + expect(stdout).not.toContain("Troubleshooting.md"); + expect(stdout).not.toContain("Interactive-REPL.md"); + expect(stdout).not.toContain("Testing note:"); + }); + + it("contains the help-page footer", async () => { + const { stdout } = await runCommand(["env"], import.meta.url); + expect(stdout).toContain("ably env --help"); + expect(stdout).toContain("for more information."); + }); + + it("renders only the requested var when name is passed", async () => { + const { stdout } = await runCommand( + ["env", "ABLY_TOKEN"], + import.meta.url, + ); + expect(stdout).toContain("ABLY_TOKEN"); + expect(stdout).toContain("highest priority"); + expect(stdout).not.toContain("custom-endpoint.example.com"); + expect(stdout).not.toContain("TIP: Run"); + }); + }); + + describe("flags", () => { + it("--json (no args) emits envVars + crossCutting + relatedLinks envelope", async () => { + const { stdout } = await runCommand(["env", "--json"], import.meta.url); + const result = parseJsonOutput(stdout); + expect(result).toHaveProperty("type", "result"); + expect(result).toHaveProperty("command", "env"); + expect(result).toHaveProperty("success", true); + expect(result.envVars).toHaveLength(9); + expect(result.envVars[0]).toMatchObject({ + name: "ABLY_API_KEY", + category: "Authentication", + format: "APP_ID.KEY_ID:KEY_SECRET", + }); + expect(result.envVars[3].name).toBe("ABLY_ENDPOINT"); + expect(result.envVars[8].name).toBe("ABLY_CLI_NON_INTERACTIVE"); + expect(result.crossCutting).toBeDefined(); + expect(result.crossCutting.authResolutionOrder.heading).toBe( + "Authentication Resolution Order", + ); + expect(result.relatedLinks).toHaveLength(8); + }); + + it("--json with a var name emits envVar singular envelope", async () => { + const { stdout } = await runCommand( + ["env", "ABLY_API_KEY", "--json"], + import.meta.url, + ); + const result = parseJsonOutput(stdout); + expect(result).toHaveProperty("type", "result"); + expect(result.envVar.name).toBe("ABLY_API_KEY"); + expect(result.envVar.category).toBe("Authentication"); + expect(result.envVar.format).toBe("APP_ID.KEY_ID:KEY_SECRET"); + expect(result.envVar.intro).toBeDefined(); + expect(result.envVar.example).toBeDefined(); + expect(result.envVar.example.lines.length).toBeGreaterThan(0); + expect(Array.isArray(result.envVar.details)).toBe(true); + }); + + it("--json envelope for ABLY_TOKEN includes the issue-token footgun callout", async () => { + const { stdout } = await runCommand( + ["env", "ABLY_TOKEN", "--json"], + import.meta.url, + ); + const result = parseJsonOutput(stdout); + expect(JSON.stringify(result.envVar.details)).toContain( + "unset ABLY_TOKEN", + ); + }); + + it("var name matches case-insensitively", async () => { + const { stdout } = await runCommand( + ["env", "ably_api_key", "--json"], + import.meta.url, + ); + const result = parseJsonOutput(stdout); + expect(result.envVar.name).toBe("ABLY_API_KEY"); + }); + }); + + describe("error handling", () => { + it("suggests a closest match for a typo", async () => { + const { error } = await runCommand( + ["env", "ABLY_API_KEYY"], + import.meta.url, + ); + expect(error?.message).toContain("Unknown environment variable"); + expect(error?.message).toContain("Did you mean ABLY_API_KEY"); + }); + + it("does not suggest an unsupported var like DEBUG", async () => { + const { error } = await runCommand(["env", "DEBUG"], import.meta.url); + expect(error?.message).toContain("Unknown environment variable"); + expect(error?.message).not.toContain("Did you mean"); + }); + }); +}); diff --git a/test/unit/data/env-vars.test.ts b/test/unit/data/env-vars.test.ts new file mode 100644 index 000000000..2c9d230b3 --- /dev/null +++ b/test/unit/data/env-vars.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { + CrossCuttingSection, + DetailSection, + ENV_VARS_DATA, + EnvVarEntry, + EnvVarsData, + Example, + RelatedLink, +} from "../../../src/data/env-vars.js"; + +const CANONICAL_NAMES = [ + "ABLY_API_KEY", + "ABLY_TOKEN", + "ABLY_ACCESS_TOKEN", + "ABLY_ENDPOINT", + "ABLY_APP_ID", + "ABLY_CLI_CONFIG_DIR", + "ABLY_HISTORY_FILE", + "ABLY_CLI_DEFAULT_DURATION", + "ABLY_CLI_NON_INTERACTIVE", +]; + +describe("ENV_VARS_DATA", () => { + it("is an EnvVarsData instance", () => { + expect(ENV_VARS_DATA).toBeInstanceOf(EnvVarsData); + }); + + it("lists exactly 9 variables in canonical order", () => { + expect(ENV_VARS_DATA.variables).toHaveLength(9); + expect(ENV_VARS_DATA.variables.map((v) => v.name)).toEqual(CANONICAL_NAMES); + }); + + it("every variable is a properly typed EnvVarEntry instance", () => { + for (const v of ENV_VARS_DATA.variables) { + expect(v).toBeInstanceOf(EnvVarEntry); + expect(v.example).toBeInstanceOf(Example); + for (const d of v.details) expect(d).toBeInstanceOf(DetailSection); + } + }); + + it("variable names are unique and match the ABLY_* shape", () => { + const names = ENV_VARS_DATA.variables.map((v) => v.name); + expect(new Set(names).size).toBe(names.length); + for (const name of names) expect(name).toMatch(/^ABLY_[A-Z_]+$/); + }); + + it("every variable has all required fields populated", () => { + for (const v of ENV_VARS_DATA.variables) { + expect(v.category.length).toBeGreaterThan(0); + expect(v.format.length).toBeGreaterThan(0); + expect(v.default_.length).toBeGreaterThan(0); + expect(v.intro.length).toBeGreaterThan(0); + } + }); + + it("every variable has at least one example shell line (minimal-view contract)", () => { + for (const v of ENV_VARS_DATA.variables) { + expect(v.example.lines.length).toBeGreaterThanOrEqual(1); + for (const line of v.example.lines) { + expect(line.length).toBeGreaterThan(0); + } + } + }); + + it("details arrays match the documented shape per variable", () => { + const byName = Object.fromEntries( + ENV_VARS_DATA.variables.map((v) => [v.name, v]), + ); + expect(byName.ABLY_API_KEY.details).toHaveLength(2); + expect(byName.ABLY_TOKEN.details).toHaveLength(4); + expect(byName.ABLY_ACCESS_TOKEN.details).toHaveLength(1); + expect(byName.ABLY_APP_ID.details).toHaveLength(0); + expect(byName.ABLY_CLI_CONFIG_DIR.details).toHaveLength(0); + expect(byName.ABLY_HISTORY_FILE.details).toHaveLength(1); + expect(byName.ABLY_CLI_DEFAULT_DURATION.details).toHaveLength(0); + expect(byName.ABLY_CLI_NON_INTERACTIVE.details).toHaveLength(1); + expect(byName.ABLY_ENDPOINT.details).toHaveLength(0); + }); + + it("only the documented primary URLs appear inline in variable entries", () => { + const allowedUrls = new Set([ + "https://ably.com/accounts/any/apps/any/app_keys", + "https://ably.com/users/access_tokens", + ]); + const serializedVars = JSON.stringify(ENV_VARS_DATA.variables); + const found = serializedVars.match(/https:\/\/[^\s")]+/g) ?? []; + for (const url of found) { + expect(allowedUrls.has(url)).toBe(true); + } + }); + + it("cross-cutting sections are CrossCuttingSection instances with the expected headings", () => { + const cc = ENV_VARS_DATA.crossCutting; + expect(cc.authResolutionOrder).toBeInstanceOf(CrossCuttingSection); + expect(cc.oneShotUsage).toBeInstanceOf(CrossCuttingSection); + expect(cc.cicdUsage).toBeInstanceOf(CrossCuttingSection); + expect(cc.commandsByAuthType).toBeInstanceOf(CrossCuttingSection); + expect(cc.authResolutionOrder.heading).toBe( + "Authentication Resolution Order", + ); + expect(cc.oneShotUsage.heading).toBe( + "Running Commands Without Login (One-Shot Usage)", + ); + expect(cc.cicdUsage.heading).toBe("CI/CD Usage"); + expect(cc.commandsByAuthType.heading).toBe("Commands by Auth Type"); + }); + + it("relatedLinks contains 8 external links (internal doc cross-links stripped)", () => { + expect(ENV_VARS_DATA.relatedLinks).toHaveLength(8); + for (const link of ENV_VARS_DATA.relatedLinks) { + expect(link).toBeInstanceOf(RelatedLink); + expect(link.url).toMatch(/^https:\/\/ably\.com\//); + expect(link.text.length).toBeGreaterThan(0); + expect(link.blurb.length).toBeGreaterThan(0); + } + }); + + it("does not contain any dev-only doc references", () => { + const serialized = JSON.stringify(ENV_VARS_DATA); + for (const banned of [ + "Development-Usage.md", + "Debugging.md", + "Testing.md", + "Troubleshooting.md", + "Interactive-REPL.md", + "Testing note:", + ]) { + expect(serialized).not.toContain(banned); + } + }); +}); diff --git a/test/unit/utils/env-vars-render.test.ts b/test/unit/utils/env-vars-render.test.ts new file mode 100644 index 000000000..0b5baeb02 --- /dev/null +++ b/test/unit/utils/env-vars-render.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { ENV_VARS_DATA } from "../../../src/data/env-vars.js"; +import { + renderMinimalReference, + renderSingleVar, +} from "../../../src/utils/env-vars-render.js"; + +describe("env-vars-render", () => { + describe("renderMinimalReference", () => { + it("contains every variable name", () => { + const out = renderMinimalReference(); + for (const v of ENV_VARS_DATA.variables) expect(out).toContain(v.name); + }); + + it("has a descriptive header about environment variables", () => { + expect(renderMinimalReference()).toContain("Ably Environment variables"); + }); + + it("does not render a Quick Reference section", () => { + expect(renderMinimalReference()).not.toContain("Quick Reference"); + }); + + it("renders each variable with the ably env prefix", () => { + const out = renderMinimalReference(); + for (const v of ENV_VARS_DATA.variables) { + expect(out).toContain(`ably env ${v.name}`); + } + }); + + it("ends with a reference to ably env --help", () => { + expect(renderMinimalReference()).toContain("ably env --help"); + }); + + it("does not render the cross-cutting headings (minimal view drops them)", () => { + const out = renderMinimalReference(); + expect(out).not.toContain("Authentication Resolution Order"); + expect(out).not.toContain("Running Commands Without Login"); + expect(out).not.toContain("CI/CD Usage"); + expect(out).not.toContain("Commands by Auth Type"); + expect(out).not.toContain("Related"); + }); + + it("does not contain dev-only references", () => { + const out = renderMinimalReference(); + for (const banned of [ + "Development-Usage.md", + "Debugging.md", + "Testing.md", + "Troubleshooting.md", + "Interactive-REPL.md", + "Testing note:", + ]) { + expect(out).not.toContain(banned); + } + }); + + it("fits within a reasonable line budget (under 100 lines)", () => { + const lineCount = renderMinimalReference().split("\n").length; + expect(lineCount).toBeLessThan(100); + }); + }); + + describe("renderSingleVar", () => { + it("returns a non-empty string for every variable that contains the name", () => { + for (const v of ENV_VARS_DATA.variables) { + const out = renderSingleVar(v.name); + expect(out.length).toBeGreaterThan(0); + expect(out).toContain(v.name); + } + }); + + it("ABLY_TOKEN section mentions 'highest priority'", () => { + expect(renderSingleVar("ABLY_TOKEN")).toContain("highest priority"); + }); + + it("ABLY_API_KEY section contains its format and not other vars' content", () => { + const out = renderSingleVar("ABLY_API_KEY"); + expect(out).toContain("APP_ID.KEY_ID:KEY_SECRET"); + expect(out).not.toContain("custom-endpoint.example.com"); + }); + + it("renders an Example: block with at least one shell prompt for every variable", () => { + for (const v of ENV_VARS_DATA.variables) { + const out = renderSingleVar(v.name); + expect(out).toContain("Example:"); + expect(out).toMatch(/\$ /); + } + }); + + it("every variable section is at most 30 lines long", () => { + for (const v of ENV_VARS_DATA.variables) { + const lineCount = renderSingleVar(v.name).split("\n").length; + expect(lineCount).toBeLessThanOrEqual(30); + } + }); + + it("contains at most one https:// URL per variable section", () => { + for (const v of ENV_VARS_DATA.variables) { + const out = renderSingleVar(v.name); + const matches = out.match(/https:\/\//g) ?? []; + expect(matches.length).toBeLessThanOrEqual(1); + } + }); + + it("ABLY_API_KEY section surfaces the dashboard URL", () => { + expect(renderSingleVar("ABLY_API_KEY")).toContain( + "https://ably.com/accounts/any/apps/any/app_keys", + ); + }); + + it("ABLY_ACCESS_TOKEN section surfaces the access-tokens URL", () => { + expect(renderSingleVar("ABLY_ACCESS_TOKEN")).toContain( + "https://ably.com/users/access_tokens", + ); + }); + + it("ABLY_TOKEN section contains the unset-before-issuing footgun callout", () => { + expect(renderSingleVar("ABLY_TOKEN")).toContain("unset ABLY_TOKEN"); + }); + + it("ABLY_HISTORY_FILE section explains the wrapper auto-set behavior", () => { + expect(renderSingleVar("ABLY_HISTORY_FILE")).toContain( + "ably-interactive", + ); + }); + + it("does not surface deleted detail-section content (Behavior, Obtaining, etc.)", () => { + for (const name of [ + "ABLY_API_KEY", + "ABLY_TOKEN", + "ABLY_ACCESS_TOKEN", + "ABLY_APP_ID", + "ABLY_CLI_CONFIG_DIR", + "ABLY_CLI_DEFAULT_DURATION", + "ABLY_CLI_NON_INTERACTIVE", + "ABLY_ENDPOINT", + ]) { + const out = renderSingleVar(name); + expect(out).not.toContain("Behavior:"); + expect(out).not.toContain("Obtaining "); + expect(out).not.toContain("Token display"); + expect(out).not.toContain("App name caching"); + expect(out).not.toContain("Crossover usage"); + expect(out).not.toContain("Specifically affects"); + expect(out).not.toContain("28 long-running"); + expect(out).not.toContain("Examples:"); + } + }); + + it("throws for an unknown name", () => { + expect(() => renderSingleVar("ABLY_NOPE")).toThrow(/No section defined/); + }); + }); +});