From 4eaca1f436ac9230ad8fc0a6600baa78e8284ac5 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 16 Mar 2026 18:27:15 +0000 Subject: [PATCH] feat(cli): add spinner utility and success/info helpers to DashboardCommand --- package-lock.json | 200 +++++++++++++++++++++++ package.json | 1 + src/cli/dashboard/_shared/base.ts | 26 +++ src/cli/dashboard/_shared/spinner.ts | 48 ++++++ tests/unit/cli/dashboard/base.test.ts | 86 ++++++++++ tests/unit/cli/dashboard/spinner.test.ts | 131 +++++++++++++++ 6 files changed, 492 insertions(+) create mode 100644 src/cli/dashboard/_shared/spinner.ts create mode 100644 tests/unit/cli/dashboard/spinner.test.ts diff --git a/package-lock.json b/package-lock.json index 488da300..781e0f8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "llmist": "^16.0.4", "marklassian": "^1.1.0", "open": "^11.0.0", + "ora": "^9.3.0", "pg": "^8.18.0", "trello.js": "^1.2.8", "zangief": "^1.0.5", @@ -4917,6 +4918,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -6641,6 +6657,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -7050,6 +7078,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-network-error": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", @@ -7098,6 +7138,18 @@ "node": ">=8" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -7698,6 +7750,22 @@ "dev": true, "license": "MIT" }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/long": { "version": "5.3.2", "license": "Apache-2.0" @@ -7865,6 +7933,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimist": { "version": "1.2.8", "dev": true, @@ -8064,6 +8144,21 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/open": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", @@ -8105,6 +8200,71 @@ } } }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/p-limit": { "version": "4.0.0", "dev": true, @@ -8633,6 +8793,22 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -8928,6 +9104,18 @@ "dev": true, "license": "MIT" }, + "node_modules/stdin-discarder": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/streamx": { "version": "2.23.0", "license": "MIT", @@ -9868,6 +10056,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zangief": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zangief/-/zangief-1.0.5.tgz", diff --git a/package.json b/package.json index 037a332f..8e448954 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "llmist": "^16.0.4", "marklassian": "^1.1.0", "open": "^11.0.0", + "ora": "^9.3.0", "pg": "^8.18.0", "trello.js": "^1.2.8", "zangief": "^1.0.5", diff --git a/src/cli/dashboard/_shared/base.ts b/src/cli/dashboard/_shared/base.ts index 28afb1fb..de089e21 100644 --- a/src/cli/dashboard/_shared/base.ts +++ b/src/cli/dashboard/_shared/base.ts @@ -1,8 +1,10 @@ import { Command, Flags } from '@oclif/core'; import { TRPCClientError } from '@trpc/client'; +import chalk from 'chalk'; import { type DashboardClient, createDashboardClient } from './client.js'; import { type CliConfig, loadConfig } from './config.js'; import { printDetail, printTable } from './format.js'; +import { withSpinner } from './spinner.js'; export function extractBaseFlags(argv: string[]): { server?: string; org?: string } | undefined { let server: string | undefined; @@ -83,6 +85,30 @@ export abstract class DashboardCommand extends Command { printDetail(obj, fields); } + /** + * Print a success message with a green ✓ prefix. + */ + protected success(message: string): void { + console.log(chalk.green(`✓ ${message}`)); + } + + /** + * Print an informational message with a blue ℹ prefix. + */ + protected info(message: string): void { + console.log(chalk.blue(`ℹ ${message}`)); + } + + /** + * Wrap an async function with an animated spinner. + * Automatically suppressed when --json flag is active, NO_COLOR=1, or CI=1. + */ + protected withSpinner(message: string, fn: () => Promise): Promise { + // Suppress spinner when --json flag is present + const isJson = this.argv.includes('--json'); + return withSpinner(message, fn, { silent: isJson }); + } + protected handleError(err: unknown): never { if (err instanceof TRPCClientError) { const code = (err.data as { code?: string } | undefined)?.code; diff --git a/src/cli/dashboard/_shared/spinner.ts b/src/cli/dashboard/_shared/spinner.ts new file mode 100644 index 00000000..c552ef30 --- /dev/null +++ b/src/cli/dashboard/_shared/spinner.ts @@ -0,0 +1,48 @@ +import ora from 'ora'; + +/** + * Returns true if spinners should be suppressed (silent mode). + * Spinners are suppressed when: + * - --json flag would be passed (NO_COLOR env var is set) + * - CI environment detected + * - NO_COLOR env var set (convention for disabling colors/animations) + * - Explicitly requested via `silent` option + */ +export function isSilentMode(options?: { silent?: boolean }): boolean { + if (options?.silent) return true; + if (process.env.NO_COLOR) return true; + if (process.env.CI) return true; + return false; +} + +/** + * Wraps an async function with an animated spinner. + * Clears the spinner on success or failure. + * Spinner is automatically suppressed in CI, NO_COLOR, or when `silent` is true. + * + * @param message - The spinner text to display while `fn` is running + * @param fn - The async function to execute + * @param options - Optional configuration + * @returns The result of `fn` + */ +export async function withSpinner( + message: string, + fn: () => Promise, + options?: { silent?: boolean }, +): Promise { + const silent = isSilentMode(options); + + if (silent) { + return fn(); + } + + const spinner = ora(message).start(); + try { + const result = await fn(); + spinner.stop(); + return result; + } catch (err) { + spinner.stop(); + throw err; + } +} diff --git a/tests/unit/cli/dashboard/base.test.ts b/tests/unit/cli/dashboard/base.test.ts index 61a2c3ce..754e3474 100644 --- a/tests/unit/cli/dashboard/base.test.ts +++ b/tests/unit/cli/dashboard/base.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockLoadConfig = vi.fn(); const mockCreateDashboardClient = vi.fn(); +const mockWithSpinner = vi + .fn() + .mockImplementation((_msg: string, fn: () => Promise) => fn()); vi.mock('../../../../src/cli/dashboard/_shared/config.js', () => ({ loadConfig: (...args: unknown[]) => mockLoadConfig(...args), @@ -11,6 +14,11 @@ vi.mock('../../../../src/cli/dashboard/_shared/client.js', () => ({ createDashboardClient: (...args: unknown[]) => mockCreateDashboardClient(...args), })); +vi.mock('../../../../src/cli/dashboard/_shared/spinner.js', () => ({ + withSpinner: (...args: unknown[]) => mockWithSpinner(...args), + isSilentMode: vi.fn().mockReturnValue(false), +})); + vi.mock('chalk', () => ({ default: { bold: (s: string) => s, @@ -46,6 +54,27 @@ class TestErrorCommand extends DashboardCommand { } } +class TestOutputCommand extends DashboardCommand { + static override id = 'test-output'; + static override description = 'Test output command'; + + lastResult: unknown; + + async run(): Promise {} + + callSuccess(msg: string): void { + this.success(msg); + } + + callInfo(msg: string): void { + this.info(msg); + } + + async callWithSpinner(message: string, fn: () => Promise): Promise { + return this.withSpinner(message, fn); + } +} + describe('extractBaseFlags', () => { it('returns undefined when no overrides present', () => { expect(extractBaseFlags([])).toBeUndefined(); @@ -198,4 +227,61 @@ describe('DashboardCommand', () => { await expect(cmd.run()).rejects.toThrow('something else'); }); }); + + describe('success helper', () => { + it('prints a green ✓ prefixed message', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const cmd = new TestOutputCommand([], {} as never); + cmd.callSuccess('Operation completed'); + + expect(consoleSpy).toHaveBeenCalledWith('✓ Operation completed'); + consoleSpy.mockRestore(); + }); + }); + + describe('info helper', () => { + it('prints a blue ℹ prefixed message', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const cmd = new TestOutputCommand([], {} as never); + cmd.callInfo('Some information'); + + expect(consoleSpy).toHaveBeenCalledWith('ℹ Some information'); + consoleSpy.mockRestore(); + }); + }); + + describe('withSpinner helper', () => { + it('calls withSpinner from spinner module with the message and fn', async () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + mockWithSpinner.mockImplementation((_msg: string, fn: () => Promise) => fn()); + + const cmd = new TestOutputCommand([], {} as never); + const result = await cmd.callWithSpinner('Loading...', async () => 'done'); + + expect(result).toBe('done'); + expect(mockWithSpinner).toHaveBeenCalledWith( + 'Loading...', + expect.any(Function), + expect.objectContaining({ silent: false }), + ); + }); + + it('passes silent=true when --json flag is present', async () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + mockWithSpinner.mockImplementation((_msg: string, fn: () => Promise) => fn()); + + const cmd = new TestOutputCommand(['--json'], {} as never); + await cmd.callWithSpinner('Loading...', async () => null); + + expect(mockWithSpinner).toHaveBeenCalledWith( + 'Loading...', + expect.any(Function), + expect.objectContaining({ silent: true }), + ); + }); + }); }); diff --git a/tests/unit/cli/dashboard/spinner.test.ts b/tests/unit/cli/dashboard/spinner.test.ts new file mode 100644 index 00000000..140dfab5 --- /dev/null +++ b/tests/unit/cli/dashboard/spinner.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockOraInstance = { + start: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), +}; + +const mockOra = vi.fn().mockReturnValue(mockOraInstance); + +vi.mock('ora', () => ({ + default: (...args: unknown[]) => mockOra(...args), +})); + +import { isSilentMode, withSpinner } from '../../../../src/cli/dashboard/_shared/spinner.js'; + +describe('isSilentMode', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns false when no env vars set and no option', () => { + vi.stubEnv('NO_COLOR', ''); + vi.stubEnv('CI', ''); + expect(isSilentMode()).toBe(false); + }); + + it('returns true when silent option is true', () => { + vi.stubEnv('NO_COLOR', ''); + vi.stubEnv('CI', ''); + expect(isSilentMode({ silent: true })).toBe(true); + }); + + it('returns false when silent option is false', () => { + vi.stubEnv('NO_COLOR', ''); + vi.stubEnv('CI', ''); + expect(isSilentMode({ silent: false })).toBe(false); + }); + + it('returns true when NO_COLOR is set', () => { + vi.stubEnv('NO_COLOR', '1'); + expect(isSilentMode()).toBe(true); + }); + + it('returns true when CI is set', () => { + vi.stubEnv('CI', '1'); + expect(isSilentMode()).toBe(true); + }); + + it('returns true when CI=true', () => { + vi.stubEnv('CI', 'true'); + expect(isSilentMode()).toBe(true); + }); +}); + +describe('withSpinner', () => { + beforeEach(() => { + vi.unstubAllEnvs(); + mockOraInstance.start.mockClear(); + mockOraInstance.stop.mockClear(); + mockOra.mockClear(); + }); + + it('returns the result of fn on success', async () => { + const result = await withSpinner('Loading...', async () => 42, { silent: true }); + expect(result).toBe(42); + }); + + it('propagates errors from fn', async () => { + await expect( + withSpinner( + 'Loading...', + async () => { + throw new Error('oops'); + }, + { silent: true }, + ), + ).rejects.toThrow('oops'); + }); + + it('shows spinner when not silent', async () => { + vi.stubEnv('NO_COLOR', ''); + vi.stubEnv('CI', ''); + await withSpinner('Loading...', async () => 'done'); + + expect(mockOra).toHaveBeenCalledWith('Loading...'); + expect(mockOraInstance.start).toHaveBeenCalled(); + expect(mockOraInstance.stop).toHaveBeenCalled(); + }); + + it('stops spinner even when fn throws', async () => { + vi.stubEnv('NO_COLOR', ''); + vi.stubEnv('CI', ''); + await expect( + withSpinner('Loading...', async () => { + throw new Error('fail'); + }), + ).rejects.toThrow('fail'); + + expect(mockOraInstance.stop).toHaveBeenCalled(); + }); + + it('does not create spinner in silent mode (silent option)', async () => { + await withSpinner('Loading...', async () => 'done', { silent: true }); + + expect(mockOra).not.toHaveBeenCalled(); + }); + + it('does not create spinner when NO_COLOR is set', async () => { + vi.stubEnv('NO_COLOR', '1'); + + await withSpinner('Loading...', async () => 'done'); + + expect(mockOra).not.toHaveBeenCalled(); + }); + + it('does not create spinner when CI is set', async () => { + vi.stubEnv('CI', '1'); + + await withSpinner('Loading...', async () => 'done'); + + expect(mockOra).not.toHaveBeenCalled(); + }); + + it('passes the message to ora', async () => { + vi.stubEnv('NO_COLOR', ''); + vi.stubEnv('CI', ''); + await withSpinner('Fetching data...', async () => null); + + expect(mockOra).toHaveBeenCalledWith('Fetching data...'); + }); +});