Skip to content
Open
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
90 changes: 90 additions & 0 deletions src/commands/intent-based-actions/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Command } from 'commander';
import { execAction } from './client';
import { runEntityListAction } from './helpers';
import { parseOutputFlag, writeOutput } from './format';
import { handleCommandError } from './intent-errors';

export function registerApiCommands(program: Command) {
const api = program
.command('api')
.description('Query API entities and retrieve specifications');

api
.command('list')
.description('List API entities in the catalog')
.option('--type <type>', 'API type (openapi, asyncapi, graphql, grpc)')
.option('--limit <n>', 'Maximum results to return', parseInt)

Check warning on line 16 in src/commands/intent-based-actions/api.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-cli&issues=AZ_TTmfhsWKn8BrhSjL4&open=AZ_TTmfhsWKn8BrhSjL4&pullRequest=156
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);

const query: Record<string, unknown> = { kind: 'API' };
if (opts.type) query['spec.type'] = opts.type;

const flags: Record<string, string | number | undefined> = {
query: JSON.stringify(query),
instance: opts.instance,
limit: opts.limit,
};

await runEntityListAction(
'catalog:query-catalog-entities',
flags,
mode,
'rhdh-cli api list',
);
});

api
.command('get-spec')
.description(
'Get the full API specification (OpenAPI, AsyncAPI, GraphQL, gRPC)',
)
.option('--name <name>', 'API entity name (required)')
.option('--namespace <ns>', 'Entity namespace (default: default)')
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);
if (!opts.name) {
handleCommandError(new Error('--name is required'), mode, {
suggestion: 'rhdh-cli api get-spec --name my-api',
});
}
try {
const raw = await execAction('catalog:get-catalog-entity', {
name: opts.name,
kind: 'API',
namespace: opts.namespace,
instance: opts.instance,
});

const entity = JSON.parse(raw) as Record<string, unknown>;
const spec = entity?.spec as Record<string, unknown> | undefined;
const definition = spec?.definition;

if (!definition) {
handleCommandError(
new Error(`API "${opts.name}" has no spec.definition`),
mode,
{ suggestion: 'rhdh-cli api list' },
);
}

if (mode === 'json') {
writeOutput({ name: opts.name, type: spec?.type, definition }, mode);
} else {
const defStr =
typeof definition === 'string'
? definition
: JSON.stringify(definition, null, 2);
process.stdout.write(`${defStr}\n`);
}
} catch (error) {
handleCommandError(error, mode, {
suggestion: 'rhdh-cli api list',
});
}
});
}
98 changes: 98 additions & 0 deletions src/commands/intent-based-actions/backstage-passthrough.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Command } from 'commander';
import { execPassthrough } from './client';

// Registers a subcommand that simply forwards all its arguments 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()
.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',
]);
}
163 changes: 163 additions & 0 deletions src/commands/intent-based-actions/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Command } from 'commander';
import { runEntityListAction, runRawAction } from './helpers';
import { parseOutputFlag } from './format';
import { handleCommandError } from './intent-errors';

export function registerCatalogCommands(program: Command) {
const catalog = program
.command('catalog')
.description('Query and manage the Backstage software catalog');

catalog
.command('list')
.description('List catalog entities')
.option('--kind <kind>', 'Entity kind (Component, API, System, etc.)')
.option('--type <type>', 'Entity type (service, website, library, etc.)')
.option('--filter <json>', 'Full query predicate (JSON)')
.option('--limit <n>', 'Maximum results to return', parseInt)

Check warning on line 17 in src/commands/intent-based-actions/catalog.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-cli&issues=AZ_TTmfYsWKn8BrhSjL3&open=AZ_TTmfYsWKn8BrhSjL3&pullRequest=156
.option('--fields <json>', 'Fields to include (JSON array)')
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);

const query: Record<string, unknown> = {};
if (opts.kind) query.kind = opts.kind;
if (opts.type) query['spec.type'] = opts.type;

const flags: Record<string, string | number | undefined> = {
instance: opts.instance,
limit: opts.limit,
fields: opts.fields,
};

if (opts.filter) {
flags.query = opts.filter;
} else if (Object.keys(query).length > 0) {
flags.query = JSON.stringify(query);
}

await runEntityListAction(
'catalog:query-catalog-entities',
flags,
mode,
'rhdh-cli catalog list --kind Component',
);
});

catalog
.command('get')
.description('Get a specific catalog entity by name')
.option('--name <name>', 'Entity name (required)')
.option('--kind <kind>', 'Entity kind')
.option('--namespace <ns>', 'Entity namespace (default: default)')
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);
if (!opts.name) {
handleCommandError(new Error('--name is required'), mode, {
suggestion: 'rhdh-cli catalog get --name my-service --kind Component',
});
}

await runRawAction(
'catalog:get-catalog-entity',
{
name: opts.name,
kind: opts.kind,
namespace: opts.namespace,
instance: opts.instance,
},
mode,
'rhdh-cli catalog list --kind Component',
);
});

catalog
.command('validate')
.description('Validate entity YAML against the catalog schema')
.option('--entity <yaml>', 'Entity YAML content (required)')
.option('--location <url>', 'Location to validate')
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);
if (!opts.entity) {
handleCommandError(
new Error('--entity is required (YAML string)'),
mode,
{
suggestion:
'rhdh-cli catalog validate --entity "$(cat entity.yaml)"',
},
);
}

await runRawAction(
'catalog:validate-entity',
{
entity: opts.entity,
location: opts.location,
instance: opts.instance,
},
mode,
);
});

catalog
.command('register')
.description('Register a catalog entity from a location URL')
.option('--location-url <url>', 'Location URL to register (required)')
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);
if (!opts.locationUrl) {
handleCommandError(new Error('--location-url is required'), mode, {
suggestion:
'rhdh-cli catalog register --location-url https://github.com/org/repo/blob/main/catalog-info.yaml',
});
}

await runRawAction(
'catalog:register-entity',
{
locationUrl: opts.locationUrl,
instance: opts.instance,
},
mode,
);
});

catalog
.command('unregister')
.description('Unregister a catalog entity by location')
.option('--location-id <id>', 'Location ID to unregister')
.option('--location-url <url>', 'Location URL to unregister')
.option('--output <format>', 'Output format: human (default), json')
.option('--instance <name>', 'Backstage instance name')
.action(async opts => {
const mode = parseOutputFlag(opts.output);
if (!opts.locationId && !opts.locationUrl) {
handleCommandError(
new Error('--location-id or --location-url is required'),
mode,
{ suggestion: 'rhdh-cli catalog unregister --location-id <id>' },
);
}

const type: Record<string, string> = {};
if (opts.locationId) type.locationId = opts.locationId;
if (opts.locationUrl) type.locationUrl = opts.locationUrl;

await runRawAction(
'catalog:unregister-entity',
{
type: JSON.stringify(type),
instance: opts.instance,
},
mode,
);
});
}
Loading
Loading