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/api.ts b/src/commands/intent-based-actions/api.ts new file mode 100644 index 0000000..c1e3ccc --- /dev/null +++ b/src/commands/intent-based-actions/api.ts @@ -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 ', 'API type (openapi, asyncapi, graphql, grpc)') + .option('--limit ', 'Maximum results to return', parseInt) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const query: Record = { kind: 'API' }; + if (opts.type) query['spec.type'] = opts.type; + + const flags: Record = { + 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 ', 'API entity name (required)') + .option('--namespace ', 'Entity namespace (default: default)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', '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; + const spec = entity?.spec as Record | 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', + }); + } + }); +} 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..3b42f30 --- /dev/null +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -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 ` +// becomes `backstage-cli auth login `. +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', + ]); +} diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts new file mode 100644 index 0000000..88ae1f6 --- /dev/null +++ b/src/commands/intent-based-actions/catalog.ts @@ -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 ', 'Entity kind (Component, API, System, etc.)') + .option('--type ', 'Entity type (service, website, library, etc.)') + .option('--filter ', 'Full query predicate (JSON)') + .option('--limit ', 'Maximum results to return', parseInt) + .option('--fields ', 'Fields to include (JSON array)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const query: Record = {}; + if (opts.kind) query.kind = opts.kind; + if (opts.type) query['spec.type'] = opts.type; + + const flags: Record = { + 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 ', 'Entity name (required)') + .option('--kind ', 'Entity kind') + .option('--namespace ', 'Entity namespace (default: default)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', '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 ', 'Entity YAML content (required)') + .option('--location ', 'Location to validate') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', '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 ', 'Location URL to register (required)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', '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 ', 'Location ID to unregister') + .option('--location-url ', 'Location URL to unregister') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', '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 ' }, + ); + } + + const type: Record = {}; + 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, + ); + }); +} diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts new file mode 100644 index 0000000..e1d8c47 --- /dev/null +++ b/src/commands/intent-based-actions/client.ts @@ -0,0 +1,157 @@ +import { execSync } from 'node:child_process'; +import { + readFileSync, + unlinkSync, + mkdtempSync, + existsSync, + rmdirSync, + statSync, + accessSync, + constants as fsConstants, +} from 'node:fs'; +import { join, delimiter } from 'node:path'; +import { tmpdir } from 'node:os'; + +let resolvedCliCommand: string | undefined; + +function shellEscape(arg: string): string { + if (/^[a-zA-Z0-9._:/-]+$/.test(arg)) return arg; + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +// Resolves "backstage-cli" by walking PATH ourselves (rather than shelling +// out to `which`), only trusting directories that aren't writable by +// group/other, so a tampered PATH entry can't cause us to resolve a +// malicious binary (see Sonar rule S4036: OS commands should not be +// searched for in PATH). +function findBackstageCliOnPath(): string | undefined { + const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean); + const binName = + process.platform === 'win32' ? 'backstage-cli.cmd' : 'backstage-cli'; + + for (const dir of dirs) { + try { + // eslint-disable-next-line no-bitwise + if ((statSync(dir).mode & 0o022) !== 0) continue; + } catch { + continue; + } + + const candidate = join(dir, binName); + try { + accessSync(candidate, fsConstants.X_OK); + return candidate; + } catch { + continue; + } + } + + return undefined; +} + +function getBackstageCliCommand(): string { + if (resolvedCliCommand) return resolvedCliCommand; + + const found = findBackstageCliOnPath(); + if (found) { + resolvedCliCommand = shellEscape(found); + return resolvedCliCommand; + } + + resolvedCliCommand = 'NPM_CONFIG_LEGACY_PEER_DEPS=true npx -y @backstage/cli'; + return resolvedCliCommand; +} + +export function execPassthrough(args: string[]): void { + const cli = getBackstageCliCommand(); + const cmd = `${cli} ${args.map(shellEscape).join(' ')}`; + try { + execSync(cmd, { + encoding: 'utf-8', + stdio: 'inherit', + timeout: 120_000, + }); + } catch (error: any) { + process.exit(error.status ?? 1); + } +} + +export async function execAction( + actionId: string, + flags: Record, +): Promise { + const cli = getBackstageCliCommand(); + const parts = [cli, 'actions', 'execute', actionId]; + + for (const [key, value] of Object.entries(flags)) { + if (value === undefined || value === false) continue; + parts.push(`--${key}`); + if (value !== true) { + parts.push(shellEscape(String(value))); + } + } + + const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-')); + const outFile = join(dir, 'out.json'); + const errFile = join(dir, 'err.txt'); + + const cleanup = () => { + try { + unlinkSync(outFile); + } catch { + // best-effort cleanup, ignore if already removed + } + try { + unlinkSync(errFile); + } catch { + // best-effort cleanup, ignore if already removed + } + try { + rmdirSync(dir); + } catch { + // best-effort cleanup, ignore if already removed + } + }; + + try { + execSync( + `${parts.join(' ')} > ${shellEscape(outFile)} 2>${shellEscape(errFile)}`, + { + encoding: 'utf-8', + timeout: 60_000, + maxBuffer: 50 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + + const result = readFileSync(outFile, 'utf-8'); + cleanup(); + return result; + } catch { + let errorMsg = 'backstage-cli command failed'; + if (existsSync(errFile)) { + const stderr = readFileSync(errFile, 'utf-8').trim(); + if (stderr) { + const lines = stderr.split('\n').filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + errorMsg = errorLine + ? errorLine.replace(/^\s*Error:\s*/i, '').trim() + : lines[lines.length - 1].trim(); + } + } + cleanup(); + throw new Error(errorMsg); + } +} + +export async function execActionJson( + actionId: string, + flags: Record, +): Promise { + const raw = await execAction(actionId, flags); + try { + return JSON.parse(raw); + } catch { + return raw; + } +} diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts new file mode 100644 index 0000000..52648e6 --- /dev/null +++ b/src/commands/intent-based-actions/docs.ts @@ -0,0 +1,212 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { execAction, execActionJson } from './client'; +import { runSearchAction } from './helpers'; +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + extractEntities, +} from './format'; +import { handleCommandError } from './intent-errors'; + +export function registerDocsCommands(program: Command) { + const docs = program + .command('docs') + .description('Search and retrieve TechDocs content'); + + docs + .command('search ') + .description('Search TechDocs content (via upstream search:query)') + .option('--page-limit ', 'Results per page (default: 10)', parseInt) + .option('--page-cursor ', 'Pagination cursor') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (termParts: string[], opts) => { + const mode = parseOutputFlag(opts.output); + const term = termParts.join(' '); + + if (!term) { + handleCommandError(new Error('Search term is required'), mode, { + suggestion: 'rhdh-cli docs search "deployment guide"', + }); + } + + await runSearchAction( + term, + { + types: '["techdocs"]', + pageLimit: opts.pageLimit, + pageCursor: opts.pageCursor, + instance: opts.instance, + }, + mode, + 'rhdh-cli docs search "getting started"', + ); + }); + + docs + .command('list') + .description( + 'List entities with TechDocs (RHDH only, via techdocs-mcp-extras)', + ) + .option( + '--entity-type ', + 'Filter by entity kind (Component, API, etc.)', + ) + .option('--owner ', 'Filter by owner') + .option( + '--lifecycle ', + 'Filter by lifecycle (production, experimental, etc.)', + ) + .option('--tags ', 'Filter by tags (comma-separated)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + try { + const flags: Record = { + entityType: opts.entityType, + owner: opts.owner, + lifecycle: opts.lifecycle, + tags: opts.tags, + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction('techdocs-mcp-extras:fetch-techdocs', flags), + ); + } else { + const result = await execActionJson( + 'techdocs-mcp-extras:fetch-techdocs', + flags, + ); + const entities = extractEntities(result); + if (entities.length > 0) { + writeOutput(entities, mode, data => + formatEntityTable(data as Array>), + ); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs list', + }); + } + }); + + docs + .command('get') + .description( + 'Get TechDocs page content for an entity (RHDH only, via techdocs-mcp-extras)', + ) + .option( + '--entity-ref ', + 'Entity reference, e.g. component:default/my-service (required)', + ) + .option('--page-path ', 'Specific doc page path (default: index)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + if (!opts.entityRef) { + handleCommandError(new Error('--entity-ref is required'), mode, { + suggestion: + 'rhdh-cli docs get --entity-ref component:default/my-service', + }); + } + try { + const flags: Record = { + entityRef: opts.entityRef, + pagePath: opts.pagePath, + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction( + 'techdocs-mcp-extras:retrieve-techdocs-content', + flags, + ), + ); + } else { + const result = await execActionJson( + 'techdocs-mcp-extras:retrieve-techdocs-content', + flags, + ); + const obj = result as Record | undefined; + const content = obj?.content ?? obj?.text; + const errorMsg = obj?.error as string | undefined; + + if (typeof content === 'string' && content.length > 0) { + process.stdout.write(`${content}\n`); + } else if (errorMsg) { + process.stderr.write(`${chalk.yellow(errorMsg)}\n`); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs list', + }); + } + }); + + docs + .command('coverage') + .description( + 'Show TechDocs coverage report (RHDH only, via techdocs-mcp-extras)', + ) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + try { + const flags: Record = { + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction( + 'techdocs-mcp-extras:analyze-techdocs-coverage', + flags, + ), + ); + } else { + const result = (await execActionJson( + 'techdocs-mcp-extras:analyze-techdocs-coverage', + flags, + )) as Record; + + const total = result?.totalEntities ?? result?.total; + const documented = + result?.entitiesWithDocs ?? + result?.documentedEntities ?? + result?.documented; + const coverage = result?.coveragePercentage ?? result?.coverage; + + if (total !== undefined) { + const lines = [ + `${chalk.bold('TechDocs Coverage Report')}`, + '', + `Total entities: ${total}`, + `Documented entities: ${documented}`, + `Coverage: ${coverage}%`, + ]; + process.stdout.write(`${lines.join('\n')}\n`); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs coverage', + }); + } + }); +} diff --git a/src/commands/intent-based-actions/format.ts b/src/commands/intent-based-actions/format.ts new file mode 100644 index 0000000..f445f9a --- /dev/null +++ b/src/commands/intent-based-actions/format.ts @@ -0,0 +1,90 @@ +import chalk from 'chalk'; + +export type OutputMode = 'human' | 'json'; + +export function parseOutputFlag(output: string | undefined): OutputMode { + if (output === 'json') return 'json'; + return 'human'; +} + +export function writeOutput( + data: unknown, + mode: OutputMode, + humanFormatter?: (data: unknown) => string, +): void { + if (mode === 'json') { + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); + return; + } + + if (humanFormatter) { + process.stdout.write(humanFormatter(data)); + return; + } + + process.stdout.write(`${JSON.stringify(data, null, 2)}\n`); +} + +export function formatEntityTable( + entities: Array>, +): string { + if (entities.length === 0) { + return `${chalk.yellow('No entities found.')}\n`; + } + + const lines: string[] = []; + const header = `${chalk.bold(pad('NAME', 40))} ${chalk.bold(pad('KIND', 16))} ${chalk.bold(pad('NAMESPACE', 16))} ${chalk.bold('TYPE')}`; + lines.push(header); + + for (const entity of entities) { + const metadata = entity.metadata as Record | undefined; + const spec = entity.spec as Record | undefined; + const name = String(metadata?.name ?? entity.name ?? ''); + const kind = String(entity.kind ?? ''); + const namespace = String( + metadata?.namespace ?? entity.namespace ?? 'default', + ); + const type = String(spec?.type ?? entity.type ?? ''); + lines.push( + `${pad(name, 40)} ${pad(kind, 16)} ${pad(namespace, 16)} ${type}`, + ); + } + + return `${lines.join('\n')}\n`; +} + +export function formatSearchResults( + results: Array>, +): string { + if (results.length === 0) { + return `${chalk.yellow('No results found.')}\n`; + } + + const lines: string[] = []; + for (const result of results) { + const doc = result.document as Record | undefined; + const title = String(doc?.title ?? result.title ?? ''); + const location = String(doc?.location ?? result.location ?? ''); + const text = String(doc?.text ?? ''); + const snippet = text.length > 120 ? `${text.slice(0, 120)}...` : text; + + lines.push(`${chalk.bold(title)}`); + if (location) lines.push(` ${chalk.dim(location)}`); + if (snippet) lines.push(` ${snippet}`); + lines.push(''); + } + + return lines.join('\n'); +} + +function pad(str: string, width: number): string { + return str.length >= width ? str : str + ' '.repeat(width - str.length); +} + +export function extractEntities( + result: unknown, +): Array> { + if (Array.isArray(result)) return result; + const obj = result as Record | undefined; + return (obj?.items ?? obj?.entities ?? []) as Array>; +} diff --git a/src/commands/intent-based-actions/helpers.ts b/src/commands/intent-based-actions/helpers.ts new file mode 100644 index 0000000..7d11db1 --- /dev/null +++ b/src/commands/intent-based-actions/helpers.ts @@ -0,0 +1,92 @@ +import { execAction, execActionJson } from './client'; +import { + extractEntities, + formatEntityTable, + formatSearchResults, + OutputMode, + writeOutput, +} from './format'; +import { handleCommandError } from './intent-errors'; + +type ActionFlags = Record; + +/** + * Runs a catalog-style action that returns a list of entities, and prints + * them either as JSON (raw action output) or as a human-readable table. + * Shared by `catalog list`, `api list`, `template list`, and `docs list`. + */ +export async function runEntityListAction( + actionId: string, + flags: ActionFlags, + mode: OutputMode, + suggestion?: string, +): Promise { + try { + if (mode === 'json') { + process.stdout.write(await execAction(actionId, flags)); + } else { + const result = await execActionJson(actionId, flags); + writeOutput(extractEntities(result), mode, data => + formatEntityTable(data as Array>), + ); + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} + +/** + * Runs an action whose raw output is a JSON string, and prints it either + * as-is (JSON mode) or pretty-printed (human mode). Shared by several + * `catalog` and `template` subcommands. + */ +export async function runRawAction( + actionId: string, + flags: ActionFlags, + mode: OutputMode, + suggestion?: string, +): Promise { + try { + const raw = await execAction(actionId, flags); + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} + +/** + * Runs a `search:query` action and prints the results either as JSON or as + * human-readable search result snippets. Shared by `search` and `docs + * search`, which only differ in the extra flags they pass along. + */ +export async function runSearchAction( + term: string, + extraFlags: ActionFlags, + mode: OutputMode, + suggestion?: string, +): Promise { + try { + const flags: ActionFlags = { term, ...extraFlags }; + + if (mode === 'json') { + process.stdout.write(await execAction('search:query', flags)); + } else { + const result = (await execActionJson('search:query', flags)) as Record< + string, + unknown + >; + const results = (result?.results ?? result) as Array< + Record + >; + writeOutput(Array.isArray(results) ? results : result, mode, data => + formatSearchResults(data as Array>), + ); + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} diff --git a/src/commands/intent-based-actions/index.ts b/src/commands/intent-based-actions/index.ts new file mode 100644 index 0000000..46bc5f5 --- /dev/null +++ b/src/commands/intent-based-actions/index.ts @@ -0,0 +1,24 @@ +import { Command } from 'commander'; +import { + registerAuthCommands, + registerActionsCommands, +} from './backstage-passthrough'; +import { registerCatalogCommands } from './catalog'; +import { registerApiCommands } from './api'; +import { registerSearchCommands } from './search'; +import { registerDocsCommands } from './docs'; +import { registerTemplateCommands } from './template'; + +// Registers the intent-based CLI surface: Backstage CLI pass-through +// commands (auth, actions, sources) plus the higher-level intent commands +// (catalog, api, search, docs, template) that wrap `actions execute` calls. +export function registerIntentCommands(program: Command) { + registerAuthCommands(program); + registerActionsCommands(program); + + registerCatalogCommands(program); + registerApiCommands(program); + registerSearchCommands(program); + registerDocsCommands(program); + registerTemplateCommands(program); +} diff --git a/src/commands/intent-based-actions/intent-errors.ts b/src/commands/intent-based-actions/intent-errors.ts new file mode 100644 index 0000000..c2420ef --- /dev/null +++ b/src/commands/intent-based-actions/intent-errors.ts @@ -0,0 +1,118 @@ +import chalk from 'chalk'; +import type { OutputMode } from './format'; + +export interface CliError { + error: string; + reason: string; + suggestion?: string; +} + +export function formatError(err: CliError, mode: OutputMode): string { + if (mode === 'json') { + return `${JSON.stringify(err, null, 2)}\n`; + } + + const lines = [`${chalk.red('Error:')} ${err.error}`]; + + const normalizedError = err.error.replace(/^Error:\s*/i, '').trim(); + const normalizedReason = err.reason.replace(/^Error:\s*/i, '').trim(); + if (normalizedReason && normalizedReason !== normalizedError) { + lines.push('', normalizedReason); + } + + if (err.suggestion) { + lines.push('', `${chalk.dim('Try:')}`, ` ${err.suggestion}`); + } + + return `${lines.join('\n')}\n`; +} + +export function handleCommandError( + error: unknown, + mode: OutputMode, + context?: { suggestion?: string }, +): never { + const message = extractPrimaryMessage(error); + + const cliError: CliError = { + error: message, + reason: extractReason(error), + }; + if (context?.suggestion) { + cliError.suggestion = context.suggestion; + } + + process.stderr.write(formatError(cliError, mode)); + process.exit(1); +} + +function getStderr(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('stderr' in error)) { + return undefined; + } + const { stderr } = error as { stderr: unknown }; + return typeof stderr === 'string' ? stderr : undefined; +} + +function extractReason(error: unknown): string { + if (!(error instanceof Error)) return 'Unknown error'; + + const fullMessage = collectMessages(error); + + if (fullMessage.includes('401') || fullMessage.includes('Unauthorized')) { + return 'Authentication failed or token expired. Re-authenticate with: rhdh-cli auth login'; + } + if (fullMessage.includes('404') || fullMessage.includes('Not Found')) { + return 'The requested resource was not found. Check the entity name, kind, or namespace.'; + } + if ( + fullMessage.includes('ECONNREFUSED') || + fullMessage.includes('fetch failed') + ) { + return 'Could not connect to the Backstage instance. Check that the instance is running and reachable.'; + } + if (fullMessage.includes('No authenticated instances')) { + return 'No Backstage instance configured. Run: rhdh-cli auth login --backend-url '; + } + + const stderr = getStderr(error); + if (stderr && stderr.trim()) { + const lines = stderr + .trim() + .split('\n') + .filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + return errorLine + ? errorLine.replace(/^\s*Error:\s*/i, '').trim() + : lines[0].trim(); + } + + return extractPrimaryMessage(error); +} + +function collectMessages(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + while (current instanceof Error) { + parts.push(current.message); + current = current.cause; + } + return parts.join(' '); +} + +function extractPrimaryMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + + const stderr = getStderr(error); + if (stderr && stderr.trim()) { + const lines = stderr + .trim() + .split('\n') + .filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + if (errorLine) return errorLine.replace(/^\s*Error:\s*/i, '').trim(); + return lines[0].trim(); + } + + return error.message; +} diff --git a/src/commands/intent-based-actions/search.ts b/src/commands/intent-based-actions/search.ts new file mode 100644 index 0000000..05be25c --- /dev/null +++ b/src/commands/intent-based-actions/search.ts @@ -0,0 +1,44 @@ +import { Command } from 'commander'; +import { runSearchAction } from './helpers'; +import { parseOutputFlag } from './format'; +import { handleCommandError } from './intent-errors'; + +export function registerSearchCommands(program: Command) { + program + .command('search ') + .description( + 'Search across all content types (catalog, TechDocs, templates)', + ) + .option( + '--types ', + 'Document types (JSON array, e.g. \'["techdocs"]\')', + ) + .option('--filters ', 'Query filters (JSON)') + .option('--page-limit ', 'Results per page (default: 10)', parseInt) + .option('--page-cursor ', 'Pagination cursor') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (termParts: string[], opts) => { + const mode = parseOutputFlag(opts.output); + const term = termParts.join(' '); + + if (!term) { + handleCommandError(new Error('Search term is required'), mode, { + suggestion: 'rhdh-cli search "my service"', + }); + } + + await runSearchAction( + term, + { + types: opts.types, + filters: opts.filters, + pageLimit: opts.pageLimit, + pageCursor: opts.pageCursor, + instance: opts.instance, + }, + mode, + 'rhdh-cli search "deployment guide"', + ); + }); +} diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts new file mode 100644 index 0000000..1fc70c0 --- /dev/null +++ b/src/commands/intent-based-actions/template.ts @@ -0,0 +1,101 @@ +import { Command } from 'commander'; +import { runEntityListAction, runRawAction } from './helpers'; +import { parseOutputFlag } from './format'; +import { handleCommandError } from './intent-errors'; + +export function registerTemplateCommands(program: Command) { + const template = program + .command('template') + .description('List and execute software templates'); + + template + .command('list') + .description('List available software templates') + .option('--limit ', 'Maximum results to return', parseInt) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const flags: Record = { + query: JSON.stringify({ kind: 'Template' }), + instance: opts.instance, + limit: opts.limit, + }; + + await runEntityListAction('catalog:query-catalog-entities', flags, mode); + }); + + template + .command('execute') + .description('Execute a software template') + .option( + '--template-ref ', + 'Template entity ref, e.g. template:default/my-template (required)', + ) + .option('--values ', 'Template input values (JSON string, required)') + .option('--secrets ', 'Template secrets (JSON string)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + if (!opts.templateRef) { + handleCommandError(new Error('--template-ref is required'), mode, { + suggestion: + 'rhdh-cli template execute --template-ref template:default/my-template --values \'{"name":"my-app"}\'', + }); + } + + if (!opts.values) { + handleCommandError(new Error('--values is required'), mode, { + suggestion: + 'rhdh-cli template execute --template-ref --values \'{"key":"value"}\'', + }); + } + + await runRawAction( + 'scaffolder:execute-template', + { + templateRef: opts.templateRef, + values: opts.values, + secrets: opts.secrets, + instance: opts.instance, + }, + mode, + 'rhdh-cli template list', + ); + }); + + template + .command('dry-run') + .description('Validate a software template without making changes') + .option( + '--template-ref ', + 'Template entity ref, e.g. template:default/my-template (required)', + ) + .option('--values ', 'Template input values (JSON string)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + if (!opts.templateRef) { + handleCommandError(new Error('--template-ref is required'), mode, { + suggestion: + 'rhdh-cli template dry-run --template-ref template:default/my-template', + }); + } + + await runRawAction( + 'scaffolder:dry-run-template', + { + templateYaml: opts.templateRef, + values: opts.values, + instance: opts.instance, + }, + mode, + 'rhdh-cli template list', + ); + }); +}