Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions src/commands/intent-based-actions/backstage-passthrough.ts
Original file line number Diff line number Diff line change
@@ -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 <args>` becomes `backstage-cli auth login <args>`.
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',
]);
}
101 changes: 101 additions & 0 deletions src/commands/intent-based-actions/client.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof spawn>;

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<typeof spawn>);

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<typeof spawn>);

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<typeof spawn>);

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<typeof spawn>);

execPassthrough(['auth', 'login']);
child.emit('error', new Error('ENOENT'));

expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to launch backstage-cli: ENOENT'),
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
87 changes: 87 additions & 0 deletions src/commands/intent-based-actions/client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};
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');

Check warning on line 40 in src/commands/intent-based-actions/client.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-cli&issues=AaA0blaZ5pieHL9F3fJO&open=AaA0blaZ5pieHL9F3fJO&pullRequest=167
}

// 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);
});
}
11 changes: 11 additions & 0 deletions src/commands/intent-based-actions/index.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading