diff --git a/src/base-command.ts b/src/base-command.ts index 8f18aa56..74ec2c65 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -118,6 +118,12 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand { hidden: process.env.ABLY_SHOW_DEV_FLAGS !== "true", env: "ABLY_CONTROL_HOST", }), + "dashboard-host": Flags.string({ + description: + "Override the host for the Ably dashboard, which defaults to https://ably.com", + hidden: process.env.ABLY_SHOW_DEV_FLAGS !== "true", + env: "ABLY_DASHBOARD_HOST", + }), env: Flags.string({ description: "Override the environment for all product API calls", env: "ABLY_CLI_ENV", diff --git a/src/commands/channels/inspect.ts b/src/commands/channels/inspect.ts new file mode 100644 index 00000000..c83935b9 --- /dev/null +++ b/src/commands/channels/inspect.ts @@ -0,0 +1,54 @@ +import { Args, Flags } from "@oclif/core"; +import chalk from "chalk"; + +import { AblyBaseCommand } from "../../base-command.js"; +import openUrl from "../../utils/open-url.js"; + +export default class ChannelsInspect extends AblyBaseCommand { + static override args = { + channel: Args.string({ + description: "The name of the channel to inspect in the Ably dashboard", + required: true, + }), + }; + + static override description = + "Open the Ably dashboard to inspect a specific channel"; + + static override examples = ["<%= config.bin %> <%= command.id %> my-channel"]; + + static override flags = { + ...AblyBaseCommand.globalFlags, + app: Flags.string({ + description: "App ID to use (uses current app if not specified)", + env: "ABLY_APP_ID", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ChannelsInspect); + + const currentAccount = this.configManager.getCurrentAccount(); + const accountId = currentAccount?.accountId; + if (!accountId) { + this.error( + `No account configured. Please log in first with ${chalk.cyan('"ably accounts login"')}.`, + ); + } + + const appId = flags.app ?? this.configManager.getCurrentAppId(); + if (!appId) { + this.error( + `No app selected. Please select an app first with ${chalk.cyan('"ably apps switch"')} or specify one with ${chalk.cyan("--app")}.`, + ); + } + + let dashboardHost = flags["dashboard-host"] ?? "https://ably.com"; + if (dashboardHost && !/^https?:\/\//i.test(dashboardHost)) { + dashboardHost = `https://${dashboardHost}`; + } + const url = `${dashboardHost}/accounts/${accountId}/apps/${appId}/channels/${encodeURIComponent(args.channel)}`; + + await openUrl(url, this); + } +} diff --git a/src/types/cli.ts b/src/types/cli.ts index cde9e952..7b075aae 100644 --- a/src/types/cli.ts +++ b/src/types/cli.ts @@ -9,6 +9,7 @@ export interface BaseFlags { "api-key"?: string; "client-id"?: string; "control-host"?: string; + "dashboard-host"?: string; env?: string; endpoint?: string; host?: string; diff --git a/src/utils/open-url.ts b/src/utils/open-url.ts index c31cb3b9..93108510 100644 --- a/src/utils/open-url.ts +++ b/src/utils/open-url.ts @@ -1,15 +1,21 @@ import open from "open"; import isTestMode from "./test-mode.js"; +import isWebCliMode from "./web-mode.js"; import chalk from "chalk"; interface Logger { log: (msg: string) => void; } -// openUrl opens a browser window if we're running normally, but just prints that it will if we're testing -// we don't want to open browsers in unit tests, and we can't use mocking to catch the calls because of how +// openUrl opens a browser window if we're running normally, but just prints that it will if we're testing. +// In web CLI mode it prints "Visit " instead of trying to open a browser. +// We don't want to open browsers in unit tests, and we can't use mocking to catch the calls because of how // oclif loads the commands. const openUrl = async (url: string, logger: Logger): Promise => { + if (isWebCliMode()) { + logger.log(`${chalk.cyan("Visit")} ${url}`); + return; + } logger.log( `${chalk.cyan("Opening")} ${url} ${chalk.cyan("in your browser")}...`, ); diff --git a/test/unit/commands/channels/inspect.test.ts b/test/unit/commands/channels/inspect.test.ts new file mode 100644 index 00000000..947d5524 --- /dev/null +++ b/test/unit/commands/channels/inspect.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { runCommand } from "@oclif/test"; +import { getMockConfigManager } from "../../../helpers/mock-config-manager.js"; + +describe("channels:inspect command", () => { + const originalEnv = process.env.ABLY_WEB_CLI_MODE; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.ABLY_WEB_CLI_MODE; + } else { + process.env.ABLY_WEB_CLI_MODE = originalEnv; + } + + vi.clearAllMocks(); + }); + + describe("normal CLI mode", () => { + beforeEach(() => { + delete process.env.ABLY_WEB_CLI_MODE; + }); + + it("should open browser with correct dashboard URL", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + const appId = mockConfig.getCurrentAppId()!; + + const { stdout } = await runCommand( + ["channels:inspect", "my-channel"], + import.meta.url, + ); + + expect(stdout).toContain("Opening"); + expect(stdout).toContain("in your browser"); + expect(stdout).toContain( + `https://ably.com/accounts/${accountId}/apps/${appId}/channels/my-channel`, + ); + }); + + it("should URL-encode special characters in channel name", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + const appId = mockConfig.getCurrentAppId()!; + + const { stdout } = await runCommand( + ["channels:inspect", "my-channel/foo#bar"], + import.meta.url, + ); + + expect(stdout).toContain( + `https://ably.com/accounts/${accountId}/apps/${appId}/channels/my-channel%2Ffoo%23bar`, + ); + }); + + it("should error when no account is configured", async () => { + const mockConfig = getMockConfigManager(); + mockConfig.clearAccounts(); + + const { error } = await runCommand( + ["channels:inspect", "my-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("No account configured"); + expect(error?.message).toContain("ably accounts login"); + }); + + it("should error when no app is selected", async () => { + const mockConfig = getMockConfigManager(); + mockConfig.setCurrentAppIdForAccount(undefined); + + const { error } = await runCommand( + ["channels:inspect", "my-channel"], + import.meta.url, + ); + + expect(error).toBeDefined(); + expect(error?.message).toContain("No app selected"); + expect(error?.message).toContain("ably apps switch"); + expect(error?.message).toContain("--app"); + }); + + it("should use --app flag over current app", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + + const { stdout } = await runCommand( + ["channels:inspect", "my-channel", "--app", "custom-app-id"], + import.meta.url, + ); + + expect(stdout).toContain( + `https://ably.com/accounts/${accountId}/apps/custom-app-id/channels/my-channel`, + ); + }); + + it("should use --app flag when no current app is set", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + mockConfig.setCurrentAppIdForAccount(undefined); + + const { stdout } = await runCommand( + ["channels:inspect", "my-channel", "--app", "override-app"], + import.meta.url, + ); + + expect(stdout).toContain( + `https://ably.com/accounts/${accountId}/apps/override-app/channels/my-channel`, + ); + }); + + it("should use --dashboard-host flag to override base URL", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + const appId = mockConfig.getCurrentAppId()!; + + const { stdout } = await runCommand( + [ + "channels:inspect", + "my-channel", + "--dashboard-host", + "https://staging.ably.com", + ], + import.meta.url, + ); + + expect(stdout).toContain( + `https://staging.ably.com/accounts/${accountId}/apps/${appId}/channels/my-channel`, + ); + expect(stdout).not.toContain("https://ably.com/accounts"); + }); + + it("should prepend https:// when --dashboard-host has no scheme", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + const appId = mockConfig.getCurrentAppId()!; + + const { stdout } = await runCommand( + [ + "channels:inspect", + "my-channel", + "--dashboard-host", + "staging.ably.com", + ], + import.meta.url, + ); + + expect(stdout).toContain( + `https://staging.ably.com/accounts/${accountId}/apps/${appId}/channels/my-channel`, + ); + }); + }); + + describe("web CLI mode", () => { + beforeEach(() => { + process.env.ABLY_WEB_CLI_MODE = "true"; + }); + + it("should display URL without opening browser", async () => { + const mockConfig = getMockConfigManager(); + const accountId = mockConfig.getCurrentAccount()!.accountId!; + const appId = mockConfig.getCurrentAppId()!; + + const { stdout } = await runCommand( + ["channels:inspect", "my-channel"], + import.meta.url, + ); + + expect(stdout).toContain("Visit"); + expect(stdout).toContain( + `https://ably.com/accounts/${accountId}/apps/${appId}/channels/my-channel`, + ); + expect(stdout).not.toContain("Opening"); + expect(stdout).not.toContain("in your browser"); + }); + }); + + describe("help", () => { + it("should display help with --help flag", async () => { + const { stdout } = await runCommand( + ["channels:inspect", "--help"], + import.meta.url, + ); + + expect(stdout).toContain("Open the Ably dashboard to inspect"); + expect(stdout).toContain("USAGE"); + expect(stdout).toContain("ARGUMENTS"); + }); + }); +});