From 7b2de5bb4510c66b5e06ff865ac695a90980cb37 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Mon, 24 Aug 2026 11:38:24 -0400 Subject: [PATCH] feat: wrap backstage-cli for auth and actions commands Expose auth, actions, and action-source commands on rhdh-cli by passing them through to the bundled backstage-cli binary. Signed-off-by: Stephanie Co-authored-by: Cursor --- src/commands/index.ts | 2 + .../backstage-passthrough.ts | 99 +++++++++++++++++ .../intent-based-actions/client.test.ts | 101 ++++++++++++++++++ src/commands/intent-based-actions/client.ts | 87 +++++++++++++++ src/commands/intent-based-actions/index.ts | 11 ++ 5 files changed, 300 insertions(+) create mode 100644 src/commands/intent-based-actions/backstage-passthrough.ts create mode 100644 src/commands/intent-based-actions/client.test.ts create mode 100644 src/commands/intent-based-actions/client.ts create mode 100644 src/commands/intent-based-actions/index.ts diff --git a/src/commands/index.ts b/src/commands/index.ts index 978160d..86aeb74 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -19,6 +19,7 @@ import { assertError } from '@backstage/errors'; import { Command } from 'commander'; import { exitWithError } from '../lib/errors'; +import { registerIntentCommands } from './intent-based-actions'; export function registerPluginCommand(program: Command) { const command = program @@ -143,6 +144,7 @@ export function registerPluginCommand(program: Command) { } export function registerCommands(program: Command) { registerPluginCommand(program); + registerIntentCommands(program); } // Wraps an action function so that it always exits and handles errors diff --git a/src/commands/intent-based-actions/backstage-passthrough.ts b/src/commands/intent-based-actions/backstage-passthrough.ts new file mode 100644 index 0000000..a1d34a5 --- /dev/null +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -0,0 +1,99 @@ +import { Command } from 'commander'; +import { execPassthrough } from './client'; + +// Registers a subcommand that simply forwards all its arguments (including +// `-h`/`--help`) to the underlying `backstage-cli` invocation, e.g. +// `rhdh-cli auth login ` becomes `backstage-cli auth login `. +function registerPassthroughCommand( + parent: Command, + name: string, + description: string, + passthroughArgs: string[], +) { + parent + .command(name) + .description(description) + .allowUnknownOption() + .helpOption(false) + .action(function passthroughAction(this: Command) { + execPassthrough([...passthroughArgs, ...this.args]); + }); +} + +export function registerAuthCommands(program: Command) { + const auth = program + .command('auth') + .description('Manage authentication to Backstage/RHDH instances'); + + registerPassthroughCommand( + auth, + 'login', + 'Log in to a Backstage/RHDH instance', + ['auth', 'login'], + ); + registerPassthroughCommand( + auth, + 'logout', + 'Log out and clear stored credentials', + ['auth', 'logout'], + ); + registerPassthroughCommand( + auth, + 'show', + 'Show details of an authenticated instance', + ['auth', 'show'], + ); + registerPassthroughCommand(auth, 'list', 'List authenticated instances', [ + 'auth', + 'list', + ]); + registerPassthroughCommand(auth, 'select', 'Select the default instance', [ + 'auth', + 'select', + ]); + registerPassthroughCommand( + auth, + 'print-token', + 'Print an access token to stdout', + ['auth', 'print-token'], + ); +} + +export function registerActionsCommands(program: Command) { + const actions = program + .command('actions') + .description('List and execute Backstage actions'); + + registerPassthroughCommand( + actions, + 'list', + 'List available actions from configured plugin sources', + ['actions', 'list'], + ); + registerPassthroughCommand(actions, 'execute', 'Execute an action', [ + 'actions', + 'execute', + ]); + + const sources = actions + .command('sources') + .description('Manage plugin sources for action discovery'); + + registerPassthroughCommand( + sources, + 'add', + 'Add plugin source(s) for action discovery', + ['actions', 'sources', 'add'], + ); + registerPassthroughCommand( + sources, + 'list', + 'List configured plugin sources', + ['actions', 'sources', 'list'], + ); + registerPassthroughCommand(sources, 'remove', 'Remove plugin source(s)', [ + 'actions', + 'sources', + 'remove', + ]); +} diff --git a/src/commands/intent-based-actions/client.test.ts b/src/commands/intent-based-actions/client.test.ts new file mode 100644 index 0000000..735a434 --- /dev/null +++ b/src/commands/intent-based-actions/client.test.ts @@ -0,0 +1,101 @@ +import { EventEmitter } from 'node:events'; +import { spawn } from 'node:child_process'; +import { execPassthrough } from './client'; + +jest.mock('node:child_process'); + +const mockSpawn = spawn as jest.MockedFunction; + +describe('execPassthrough', () => { + let exitSpy: jest.SpyInstance; + let stdoutSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + function createFakeChild() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + return child; + } + + beforeEach(() => { + jest.clearAllMocks(); + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('spawns the resolved binary with the given passthrough args', () => { + const child = createFakeChild(); + mockSpawn.mockReturnValue(child as unknown as ReturnType); + + execPassthrough(['auth', 'login', '--backend-url', 'https://example.com']); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const [command, args] = mockSpawn.mock.calls[0]; + expect(command).toBe(process.execPath); + expect(args).toEqual( + expect.arrayContaining([ + 'auth', + 'login', + '--backend-url', + 'https://example.com', + ]), + ); + }); + + it('rebrands "backstage-cli" as "rhdh-cli" in streamed stdout and exits with the child code', () => { + const child = createFakeChild(); + mockSpawn.mockReturnValue(child as unknown as ReturnType); + + execPassthrough(['auth', 'login']); + child.stdout.emit( + 'data', + Buffer.from('Run backstage-cli auth login to continue\n'), + ); + child.emit('close', 0); + + const written = stdoutSpy.mock.calls.map(call => call[0]).join(''); + expect(written).toContain('Run rhdh-cli auth login to continue'); + expect(written).not.toContain('backstage-cli'); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits with code 1 when the child process closes with no exit code', () => { + const child = createFakeChild(); + mockSpawn.mockReturnValue(child as unknown as ReturnType); + + execPassthrough(['auth', 'login']); + child.emit('close', null); + + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('reports a launch failure and exits 1 when spawn errors', () => { + const child = createFakeChild(); + mockSpawn.mockReturnValue(child as unknown as ReturnType); + + execPassthrough(['auth', 'login']); + child.emit('error', new Error('ENOENT')); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to launch backstage-cli: ENOENT'), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts new file mode 100644 index 0000000..d2093fd --- /dev/null +++ b/src/commands/intent-based-actions/client.ts @@ -0,0 +1,87 @@ +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + +let resolvedCliBinary: string | undefined; + +// Resolves the `backstage-cli` binary from the `@backstage/cli` dependency +// via Node's module resolution, so we always run a known, trusted version. +function resolveBackstageCliBinary(): string { + if (resolvedCliBinary) return resolvedCliBinary; + + let pkgJsonPath: string; + try { + pkgJsonPath = require.resolve('@backstage/cli/package.json'); + } catch { + throw new Error( + 'Unable to locate the "@backstage/cli" dependency. Try reinstalling ' + + 'dependencies (e.g. `yarn install`).', + ); + } + + const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { + bin?: string | Record; + }; + const relBin = + typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['backstage-cli']; + if (!relBin) { + throw new Error( + 'Unable to locate the "backstage-cli" binary: the installed ' + + '@backstage/cli package does not declare it.', + ); + } + + resolvedCliBinary = join(dirname(pkgJsonPath), relBin); + return resolvedCliBinary; +} + +// Keeps output consistently branded as `rhdh-cli`. +function rebrand(text: string): string { + return text.replace(/backstage-cli/g, 'rhdh-cli'); +} + +// Rebrands output as it streams in, without buffering more than a couple +// characters at a time, so interactive commands still feel responsive. +function createRebrandingWriter(target: NodeJS.WritableStream) { + const tailLength = 'backstage-cli'.length - 1; + let pending = ''; + return { + write(chunk: Buffer | string) { + pending += chunk.toString(); + if (pending.length <= tailLength) return; + const flushEnd = pending.length - tailLength; + target.write(rebrand(pending.slice(0, flushEnd))); + pending = pending.slice(flushEnd); + }, + end() { + if (pending) target.write(rebrand(pending)); + pending = ''; + }, + }; +} + +export function execPassthrough(args: string[]): void { + const bin = resolveBackstageCliBinary(); + const child = spawn(process.execPath, [bin, ...args], { + stdio: ['inherit', 'pipe', 'pipe'], + timeout: 120_000, + }); + + const stdout = createRebrandingWriter(process.stdout); + const stderr = createRebrandingWriter(process.stderr); + child.stdout.on('data', (chunk: Buffer) => stdout.write(chunk)); + child.stderr.on('data', (chunk: Buffer) => stderr.write(chunk)); + + child.on('error', (error: NodeJS.ErrnoException) => { + stdout.end(); + stderr.end(); + process.stderr.write(`Failed to launch backstage-cli: ${error.message}\n`); + process.exit(1); + }); + + child.on('close', code => { + stdout.end(); + stderr.end(); + process.exit(code ?? 1); + }); +} diff --git a/src/commands/intent-based-actions/index.ts b/src/commands/intent-based-actions/index.ts new file mode 100644 index 0000000..df5e7e6 --- /dev/null +++ b/src/commands/intent-based-actions/index.ts @@ -0,0 +1,11 @@ +import { Command } from 'commander'; +import { + registerAuthCommands, + registerActionsCommands, +} from './backstage-passthrough'; + +// Registers Backstage CLI pass-through commands (auth, actions, sources). +export function registerIntentCommands(program: Command) { + registerAuthCommands(program); + registerActionsCommands(program); +}