From c10dbc31f735492697a9d5ffe7770b7aed67d917 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Mon, 13 Jul 2026 17:48:45 -0400 Subject: [PATCH 01/18] add intent based clis to support action execution, other cmd, auth/actions related will use backstage-cli Signed-off-by: Stephanie --- src/commands/api.ts | 109 ++++++++++++ src/commands/backstage-passthrough.ts | 106 ++++++++++++ src/commands/catalog.ts | 193 +++++++++++++++++++++ src/commands/docs.ts | 233 ++++++++++++++++++++++++++ src/commands/index.ts | 20 +++ src/commands/search.ts | 64 +++++++ src/commands/template.ts | 124 ++++++++++++++ src/lib/client.ts | 121 +++++++++++++ src/lib/format.ts | 88 ++++++++++ src/lib/intent-errors.ts | 105 ++++++++++++ 10 files changed, 1163 insertions(+) create mode 100644 src/commands/api.ts create mode 100644 src/commands/backstage-passthrough.ts create mode 100644 src/commands/catalog.ts create mode 100644 src/commands/docs.ts create mode 100644 src/commands/search.ts create mode 100644 src/commands/template.ts create mode 100644 src/lib/client.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/intent-errors.ts diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 0000000..754d975 --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,109 @@ +import { Command } from 'commander'; +import { execAction, execActionJson } from '../lib/client'; +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + extractEntities, +} from '../lib/format'; +import { handleCommandError } from '../lib/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); + try { + 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, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction('catalog:query-catalog-entities', flags), + ); + } else { + const result = await execActionJson( + 'catalog:query-catalog-entities', + flags, + ); + writeOutput(extractEntities(result), mode, data => + formatEntityTable(data as Array>), + ); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: '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/backstage-passthrough.ts b/src/commands/backstage-passthrough.ts new file mode 100644 index 0000000..f042079 --- /dev/null +++ b/src/commands/backstage-passthrough.ts @@ -0,0 +1,106 @@ +import { Command } from 'commander'; +import { execPassthrough } from '../lib/client'; + +export function registerAuthCommands(program: Command) { + const auth = program + .command('auth') + .description('Manage authentication to Backstage/RHDH instances'); + + auth + .command('login') + .description('Log in to a Backstage/RHDH instance') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['auth', 'login', ...this.args]); + }); + + auth + .command('logout') + .description('Log out and clear stored credentials') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['auth', 'logout', ...this.args]); + }); + + auth + .command('show') + .description('Show details of an authenticated instance') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['auth', 'show', ...this.args]); + }); + + auth + .command('list') + .description('List authenticated instances') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['auth', 'list', ...this.args]); + }); + + auth + .command('select') + .description('Select the default instance') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['auth', 'select', ...this.args]); + }); + + auth + .command('print-token') + .description('Print an access token to stdout') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['auth', 'print-token', ...this.args]); + }); +} + +export function registerActionsCommands(program: Command) { + const actions = program + .command('actions') + .description('List and execute Backstage actions'); + + actions + .command('list') + .description('List available actions from configured plugin sources') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['actions', 'list', ...this.args]); + }); + + actions + .command('execute') + .description('Execute an action') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['actions', 'execute', ...this.args]); + }); + + const sources = actions + .command('sources') + .description('Manage plugin sources for action discovery'); + + sources + .command('add') + .description('Add plugin source(s) for action discovery') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['actions', 'sources', 'add', ...this.args]); + }); + + sources + .command('list') + .description('List configured plugin sources') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['actions', 'sources', 'list', ...this.args]); + }); + + sources + .command('remove') + .description('Remove plugin source(s)') + .allowUnknownOption() + .action(function (this: Command) { + execPassthrough(['actions', 'sources', 'remove', ...this.args]); + }); +} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..34eb445 --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,193 @@ +import { Command } from 'commander'; +import { execAction, execActionJson } from '../lib/client'; +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + extractEntities, +} from '../lib/format'; +import { handleCommandError } from '../lib/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); + try { + 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); + } + + if (mode === 'json') { + process.stdout.write( + await execAction('catalog:query-catalog-entities', flags), + ); + } else { + const result = await execActionJson( + 'catalog:query-catalog-entities', + flags, + ); + writeOutput(extractEntities(result), mode, data => + formatEntityTable(data as Array>), + ); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: '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', + }); + } + try { + const raw = await execAction('catalog:get-catalog-entity', { + name: opts.name, + kind: opts.kind, + namespace: opts.namespace, + instance: opts.instance, + }); + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: '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)"', + }); + } + try { + const raw = await execAction('catalog:validate-entity', { + entity: opts.entity, + location: opts.location, + instance: opts.instance, + }); + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, 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', + }); + } + try { + const raw = await execAction('catalog:register-entity', { + locationUrl: opts.locationUrl, + instance: opts.instance, + }); + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, 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 ' }, + ); + } + try { + const type: Record = {}; + if (opts.locationId) type.locationId = opts.locationId; + if (opts.locationUrl) type.locationUrl = opts.locationUrl; + + const raw = await execAction('catalog:unregister-entity', { + type: JSON.stringify(type), + instance: opts.instance, + }); + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, mode); + } + }); +} diff --git a/src/commands/docs.ts b/src/commands/docs.ts new file mode 100644 index 0000000..433fd96 --- /dev/null +++ b/src/commands/docs.ts @@ -0,0 +1,233 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { execAction, execActionJson } from '../lib/client'; +import { + parseOutputFlag, + writeOutput, + formatSearchResults, + formatEntityTable, + extractEntities, +} from '../lib/format'; +import { handleCommandError } from '../lib/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"', + }); + } + + try { + const flags: Record = { + term, + types: '["techdocs"]', + pageLimit: opts.pageLimit, + pageCursor: opts.pageCursor, + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write(await execAction('search:query', flags)); + } else { + const result = (await execActionJson( + 'search:query', + flags, + )) as Record; + 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: '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/index.ts b/src/commands/index.ts index 978160d..71b44d1 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -143,6 +143,26 @@ export function registerPluginCommand(program: Command) { } export function registerCommands(program: Command) { registerPluginCommand(program); + + // Backstage CLI pass-through commands (auth, actions, sources) + const { + registerAuthCommands, + registerActionsCommands, + } = require('./backstage-passthrough'); + registerAuthCommands(program); + registerActionsCommands(program); + + // Intent-based commands (catalog, api, search, docs, template) + const { registerCatalogCommands } = require('./catalog'); + const { registerApiCommands } = require('./api'); + const { registerSearchCommands } = require('./search'); + const { registerDocsCommands } = require('./docs'); + const { registerTemplateCommands } = require('./template'); + registerCatalogCommands(program); + registerApiCommands(program); + registerSearchCommands(program); + registerDocsCommands(program); + registerTemplateCommands(program); } // Wraps an action function so that it always exits and handles errors diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..6b80293 --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,64 @@ +import { Command } from 'commander'; +import { execAction, execActionJson } from '../lib/client'; +import { parseOutputFlag, writeOutput, formatSearchResults } from '../lib/format'; +import { handleCommandError } from '../lib/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"', + }); + } + + try { + const flags: Record = { + term, + types: opts.types, + filters: opts.filters, + pageLimit: opts.pageLimit, + pageCursor: opts.pageCursor, + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write(await execAction('search:query', flags)); + } else { + const result = (await execActionJson( + 'search:query', + flags, + )) as Record; + 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: 'rhdh-cli search "deployment guide"', + }); + } + }); +} diff --git a/src/commands/template.ts b/src/commands/template.ts new file mode 100644 index 0000000..2b8dbbf --- /dev/null +++ b/src/commands/template.ts @@ -0,0 +1,124 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { execAction, execActionJson } from '../lib/client'; +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + extractEntities, +} from '../lib/format'; +import { handleCommandError } from '../lib/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); + try { + const flags: Record = { + query: JSON.stringify({ kind: 'Template' }), + instance: opts.instance, + limit: opts.limit, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction('catalog:query-catalog-entities', flags), + ); + } else { + const result = await execActionJson( + 'catalog:query-catalog-entities', + flags, + ); + writeOutput(extractEntities(result), mode, data => + formatEntityTable(data as Array>), + ); + } + } catch (error) { + handleCommandError(error, mode); + } + }); + + template + .command('execute') + .description( + 'Execute a software template (dry-run by default, --confirm for real)', + ) + .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('--confirm', 'Execute for real (default: dry-run only)') + .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"}\'', + }); + } + + try { + if (!opts.confirm) { + if (mode === 'human') { + process.stderr.write( + `${chalk.yellow('Dry-run mode')} — pass --confirm to execute for real.\n\n`, + ); + } + + const raw = await execAction('scaffolder:dry-run-template', { + templateYaml: opts.templateRef, + values: opts.values, + instance: opts.instance, + }); + + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } else { + if (!opts.values) { + handleCommandError( + new Error('--values is required for template execution'), + mode, + { + suggestion: + 'rhdh-cli template execute --template-ref --values \'{"key":"value"}\' --confirm', + }, + ); + } + + const raw = await execAction('scaffolder:execute-template', { + templateRef: opts.templateRef, + values: opts.values, + secrets: opts.secrets, + instance: opts.instance, + }); + + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli template list', + }); + } + }); +} diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..7d201db --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,121 @@ +import { execSync, spawnSync } from 'node:child_process'; +import { + readFileSync, + unlinkSync, + mkdtempSync, + existsSync, + rmdirSync, +} from 'node:fs'; +import { join } 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, "'\\''")}'`; +} + +function getBackstageCliCommand(): string { + if (resolvedCliCommand) return resolvedCliCommand; + + const whichResult = spawnSync('which', ['backstage-cli'], { + encoding: 'utf-8', + }); + if (whichResult.status === 0) { + resolvedCliCommand = 'backstage-cli'; + 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 {} + try { + unlinkSync(errFile); + } catch {} + try { + rmdirSync(dir); + } catch {} + }; + + 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/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..0c2ad81 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,88 @@ +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/lib/intent-errors.ts b/src/lib/intent-errors.ts new file mode 100644 index 0000000..9ff4379 --- /dev/null +++ b/src/lib/intent-errors.ts @@ -0,0 +1,105 @@ +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 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 = (error as Record).stderr; + if (typeof stderr === 'string' && 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 = (error as Record).stderr; + if (typeof stderr === 'string' && 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; +} From 1de9a53254208e4e169f723efafdeae80e3354e6 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Tue, 4 Aug 2026 14:47:19 -0400 Subject: [PATCH 02/18] update template Signed-off-by: Stephanie --- src/commands/template.ts | 97 ++++++++++++++++++++++------------------ 1 file changed, 54 insertions(+), 43 deletions(-) diff --git a/src/commands/template.ts b/src/commands/template.ts index 2b8dbbf..f32495f 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import { Command } from 'commander'; import { execAction, execActionJson } from '../lib/client'; import { @@ -49,16 +48,13 @@ export function registerTemplateCommands(program: Command) { template .command('execute') - .description( - 'Execute a software template (dry-run by default, --confirm for real)', - ) + .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('--confirm', 'Execute for real (default: dry-run only)') .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -71,49 +67,64 @@ export function registerTemplateCommands(program: Command) { }); } - try { - if (!opts.confirm) { - if (mode === 'human') { - process.stderr.write( - `${chalk.yellow('Dry-run mode')} — pass --confirm to execute for real.\n\n`, - ); - } + if (!opts.values) { + handleCommandError(new Error('--values is required'), mode, { + suggestion: + 'rhdh-cli template execute --template-ref --values \'{"key":"value"}\'', + }); + } - const raw = await execAction('scaffolder:dry-run-template', { - templateYaml: opts.templateRef, - values: opts.values, - instance: opts.instance, - }); + try { + const raw = await execAction('scaffolder:execute-template', { + templateRef: opts.templateRef, + values: opts.values, + secrets: opts.secrets, + instance: opts.instance, + }); - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } + if (mode === 'json') { + process.stdout.write(raw); } else { - if (!opts.values) { - handleCommandError( - new Error('--values is required for template execution'), - mode, - { - suggestion: - 'rhdh-cli template execute --template-ref --values \'{"key":"value"}\' --confirm', - }, - ); - } + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli template list', + }); + } + }); - const raw = await execAction('scaffolder:execute-template', { - templateRef: opts.templateRef, - values: opts.values, - secrets: opts.secrets, - instance: opts.instance, - }); + 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', + }); + } - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } + try { + const raw = await execAction('scaffolder:dry-run-template', { + templateYaml: opts.templateRef, + values: opts.values, + instance: opts.instance, + }); + + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); } } catch (error) { handleCommandError(error, mode, { From 79e43b9166adf9835415f247f86f47e5e031d415 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Wed, 5 Aug 2026 15:54:27 -0400 Subject: [PATCH 03/18] fix(RHIDP-14129): fix CI failures for intent-based CLI commands - Statically import command modules in commands/index.ts instead of using require(), since the backstage-cli bundler only follows static ESM imports/dynamic import() and silently dropped the require()'d files from the packed dist, breaking every command once installed from npm (Cannot find module './backstage-passthrough'). - Fix TS2352 in intent-errors.ts by adding a safe getStderr() helper instead of casting Error directly to Record. - Restrict the PATH used to resolve backstage-cli via `which` to directories that aren't group/other-writable, addressing the SonarCloud S4036 PATH-search security hotspot in lib/client.ts. - Extract shared runEntityListAction/runRawAction/runSearchAction helpers and a registerPassthroughCommand helper to remove the heavy code duplication SonarCloud flagged across catalog/api/template/ search/docs/backstage-passthrough command files. - Fix pre-existing lint (no-empty, func-names) and prettier issues so the Checks job can get past the linter/prettier steps. Co-authored-by: Cursor --- src/commands/api.ts | 55 +++------ src/commands/backstage-passthrough.ts | 162 ++++++++++++-------------- src/commands/catalog.ts | 154 ++++++++++-------------- src/commands/docs.ts | 39 ++----- src/commands/index.ts | 18 +-- src/commands/search.ts | 38 ++---- src/commands/template.ts | 80 ++++--------- src/lib/client.ts | 35 +++++- src/lib/command-helpers.ts | 92 +++++++++++++++ src/lib/format.ts | 6 +- src/lib/intent-errors.ts | 29 +++-- 11 files changed, 353 insertions(+), 355 deletions(-) create mode 100644 src/lib/command-helpers.ts diff --git a/src/commands/api.ts b/src/commands/api.ts index 754d975..1b7da8b 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -1,11 +1,7 @@ import { Command } from 'commander'; -import { execAction, execActionJson } from '../lib/client'; -import { - parseOutputFlag, - writeOutput, - formatEntityTable, - extractEntities, -} from '../lib/format'; +import { execAction } from '../lib/client'; +import { runEntityListAction } from '../lib/command-helpers'; +import { parseOutputFlag, writeOutput } from '../lib/format'; import { handleCommandError } from '../lib/intent-errors'; export function registerApiCommands(program: Command) { @@ -22,34 +18,22 @@ export function registerApiCommands(program: Command) { .option('--instance ', 'Backstage instance name') .action(async opts => { const mode = parseOutputFlag(opts.output); - try { - 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, - }; + const query: Record = { kind: 'API' }; + if (opts.type) query['spec.type'] = opts.type; - if (mode === 'json') { - process.stdout.write( - await execAction('catalog:query-catalog-entities', flags), - ); - } else { - const result = await execActionJson( - 'catalog:query-catalog-entities', - flags, - ); - writeOutput(extractEntities(result), mode, data => - formatEntityTable(data as Array>), - ); - } - } catch (error) { - handleCommandError(error, mode, { - suggestion: 'rhdh-cli api list', - }); - } + 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 @@ -89,10 +73,7 @@ export function registerApiCommands(program: Command) { } if (mode === 'json') { - writeOutput( - { name: opts.name, type: spec?.type, definition }, - mode, - ); + writeOutput({ name: opts.name, type: spec?.type, definition }, mode); } else { const defStr = typeof definition === 'string' diff --git a/src/commands/backstage-passthrough.ts b/src/commands/backstage-passthrough.ts index f042079..a0c589b 100644 --- a/src/commands/backstage-passthrough.ts +++ b/src/commands/backstage-passthrough.ts @@ -1,58 +1,61 @@ import { Command } from 'commander'; import { execPassthrough } from '../lib/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'); - auth - .command('login') - .description('Log in to a Backstage/RHDH instance') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['auth', 'login', ...this.args]); - }); - - auth - .command('logout') - .description('Log out and clear stored credentials') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['auth', 'logout', ...this.args]); - }); - - auth - .command('show') - .description('Show details of an authenticated instance') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['auth', 'show', ...this.args]); - }); - - auth - .command('list') - .description('List authenticated instances') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['auth', 'list', ...this.args]); - }); - - auth - .command('select') - .description('Select the default instance') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['auth', 'select', ...this.args]); - }); - - auth - .command('print-token') - .description('Print an access token to stdout') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['auth', 'print-token', ...this.args]); - }); + 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) { @@ -60,47 +63,36 @@ export function registerActionsCommands(program: Command) { .command('actions') .description('List and execute Backstage actions'); - actions - .command('list') - .description('List available actions from configured plugin sources') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['actions', 'list', ...this.args]); - }); - - actions - .command('execute') - .description('Execute an action') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['actions', 'execute', ...this.args]); - }); + 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'); - sources - .command('add') - .description('Add plugin source(s) for action discovery') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['actions', 'sources', 'add', ...this.args]); - }); - - sources - .command('list') - .description('List configured plugin sources') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['actions', 'sources', 'list', ...this.args]); - }); - - sources - .command('remove') - .description('Remove plugin source(s)') - .allowUnknownOption() - .action(function (this: Command) { - execPassthrough(['actions', 'sources', 'remove', ...this.args]); - }); + 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/catalog.ts b/src/commands/catalog.ts index 34eb445..849277b 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -1,11 +1,6 @@ import { Command } from 'commander'; -import { execAction, execActionJson } from '../lib/client'; -import { - parseOutputFlag, - writeOutput, - formatEntityTable, - extractEntities, -} from '../lib/format'; +import { runEntityListAction, runRawAction } from '../lib/command-helpers'; +import { parseOutputFlag } from '../lib/format'; import { handleCommandError } from '../lib/intent-errors'; export function registerCatalogCommands(program: Command) { @@ -25,41 +20,29 @@ export function registerCatalogCommands(program: Command) { .option('--instance ', 'Backstage instance name') .action(async opts => { const mode = parseOutputFlag(opts.output); - try { - 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, - }; + const query: Record = {}; + if (opts.kind) query.kind = opts.kind; + if (opts.type) query['spec.type'] = opts.type; - if (opts.filter) { - flags.query = opts.filter; - } else if (Object.keys(query).length > 0) { - flags.query = JSON.stringify(query); - } + const flags: Record = { + instance: opts.instance, + limit: opts.limit, + fields: opts.fields, + }; - if (mode === 'json') { - process.stdout.write( - await execAction('catalog:query-catalog-entities', flags), - ); - } else { - const result = await execActionJson( - 'catalog:query-catalog-entities', - flags, - ); - writeOutput(extractEntities(result), mode, data => - formatEntityTable(data as Array>), - ); - } - } catch (error) { - handleCommandError(error, mode, { - suggestion: 'rhdh-cli catalog list --kind Component', - }); + 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 @@ -74,27 +57,21 @@ export function registerCatalogCommands(program: Command) { 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', + suggestion: 'rhdh-cli catalog get --name my-service --kind Component', }); } - try { - const raw = await execAction('catalog:get-catalog-entity', { + + await runRawAction( + 'catalog:get-catalog-entity', + { name: opts.name, kind: opts.kind, namespace: opts.namespace, instance: opts.instance, - }); - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } - } catch (error) { - handleCommandError(error, mode, { - suggestion: 'rhdh-cli catalog list --kind Component', - }); - } + }, + mode, + 'rhdh-cli catalog list --kind Component', + ); }); catalog @@ -107,24 +84,25 @@ export function registerCatalogCommands(program: Command) { .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)"', - }); + handleCommandError( + new Error('--entity is required (YAML string)'), + mode, + { + suggestion: + 'rhdh-cli catalog validate --entity "$(cat entity.yaml)"', + }, + ); } - try { - const raw = await execAction('catalog:validate-entity', { + + await runRawAction( + 'catalog:validate-entity', + { entity: opts.entity, location: opts.location, instance: opts.instance, - }); - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } - } catch (error) { - handleCommandError(error, mode); - } + }, + mode, + ); }); catalog @@ -141,19 +119,15 @@ export function registerCatalogCommands(program: Command) { 'rhdh-cli catalog register --location-url https://github.com/org/repo/blob/main/catalog-info.yaml', }); } - try { - const raw = await execAction('catalog:register-entity', { + + await runRawAction( + 'catalog:register-entity', + { locationUrl: opts.locationUrl, instance: opts.instance, - }); - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } - } catch (error) { - handleCommandError(error, mode); - } + }, + mode, + ); }); catalog @@ -172,22 +146,18 @@ export function registerCatalogCommands(program: Command) { { suggestion: 'rhdh-cli catalog unregister --location-id ' }, ); } - try { - const type: Record = {}; - if (opts.locationId) type.locationId = opts.locationId; - if (opts.locationUrl) type.locationUrl = opts.locationUrl; - const raw = await execAction('catalog:unregister-entity', { + 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, - }); - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } - } catch (error) { - handleCommandError(error, mode); - } + }, + mode, + ); }); } diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 433fd96..048f879 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -1,10 +1,10 @@ import chalk from 'chalk'; import { Command } from 'commander'; import { execAction, execActionJson } from '../lib/client'; +import { runSearchAction } from '../lib/command-helpers'; import { parseOutputFlag, writeOutput, - formatSearchResults, formatEntityTable, extractEntities, } from '../lib/format'; @@ -32,37 +32,17 @@ export function registerDocsCommands(program: Command) { }); } - try { - const flags: Record = { - term, + await runSearchAction( + term, + { types: '["techdocs"]', pageLimit: opts.pageLimit, pageCursor: opts.pageCursor, instance: opts.instance, - }; - - if (mode === 'json') { - process.stdout.write(await execAction('search:query', flags)); - } else { - const result = (await execActionJson( - 'search:query', - flags, - )) as Record; - 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: 'rhdh-cli docs search "getting started"', - }); - } + }, + mode, + 'rhdh-cli docs search "getting started"', + ); }); docs @@ -208,8 +188,7 @@ export function registerDocsCommands(program: Command) { result?.entitiesWithDocs ?? result?.documentedEntities ?? result?.documented; - const coverage = - result?.coveragePercentage ?? result?.coverage; + const coverage = result?.coveragePercentage ?? result?.coverage; if (total !== undefined) { const lines = [ diff --git a/src/commands/index.ts b/src/commands/index.ts index 71b44d1..c648f09 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -19,6 +19,15 @@ import { assertError } from '@backstage/errors'; import { Command } from 'commander'; import { exitWithError } from '../lib/errors'; +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'; export function registerPluginCommand(program: Command) { const command = program @@ -145,19 +154,10 @@ export function registerCommands(program: Command) { registerPluginCommand(program); // Backstage CLI pass-through commands (auth, actions, sources) - const { - registerAuthCommands, - registerActionsCommands, - } = require('./backstage-passthrough'); registerAuthCommands(program); registerActionsCommands(program); // Intent-based commands (catalog, api, search, docs, template) - const { registerCatalogCommands } = require('./catalog'); - const { registerApiCommands } = require('./api'); - const { registerSearchCommands } = require('./search'); - const { registerDocsCommands } = require('./docs'); - const { registerTemplateCommands } = require('./template'); registerCatalogCommands(program); registerApiCommands(program); registerSearchCommands(program); diff --git a/src/commands/search.ts b/src/commands/search.ts index 6b80293..97d15b8 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { execAction, execActionJson } from '../lib/client'; -import { parseOutputFlag, writeOutput, formatSearchResults } from '../lib/format'; +import { runSearchAction } from '../lib/command-helpers'; +import { parseOutputFlag } from '../lib/format'; import { handleCommandError } from '../lib/intent-errors'; export function registerSearchCommands(program: Command) { @@ -28,37 +28,17 @@ export function registerSearchCommands(program: Command) { }); } - try { - const flags: Record = { - term, + await runSearchAction( + term, + { types: opts.types, filters: opts.filters, pageLimit: opts.pageLimit, pageCursor: opts.pageCursor, instance: opts.instance, - }; - - if (mode === 'json') { - process.stdout.write(await execAction('search:query', flags)); - } else { - const result = (await execActionJson( - 'search:query', - flags, - )) as Record; - 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: 'rhdh-cli search "deployment guide"', - }); - } + }, + mode, + 'rhdh-cli search "deployment guide"', + ); }); } diff --git a/src/commands/template.ts b/src/commands/template.ts index f32495f..1d4510d 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -1,11 +1,6 @@ import { Command } from 'commander'; -import { execAction, execActionJson } from '../lib/client'; -import { - parseOutputFlag, - writeOutput, - formatEntityTable, - extractEntities, -} from '../lib/format'; +import { runEntityListAction, runRawAction } from '../lib/command-helpers'; +import { parseOutputFlag } from '../lib/format'; import { handleCommandError } from '../lib/intent-errors'; export function registerTemplateCommands(program: Command) { @@ -21,29 +16,14 @@ export function registerTemplateCommands(program: Command) { .option('--instance ', 'Backstage instance name') .action(async opts => { const mode = parseOutputFlag(opts.output); - try { - const flags: Record = { - query: JSON.stringify({ kind: 'Template' }), - instance: opts.instance, - limit: opts.limit, - }; - if (mode === 'json') { - process.stdout.write( - await execAction('catalog:query-catalog-entities', flags), - ); - } else { - const result = await execActionJson( - 'catalog:query-catalog-entities', - flags, - ); - writeOutput(extractEntities(result), mode, data => - formatEntityTable(data as Array>), - ); - } - } catch (error) { - handleCommandError(error, mode); - } + const flags: Record = { + query: JSON.stringify({ kind: 'Template' }), + instance: opts.instance, + limit: opts.limit, + }; + + await runEntityListAction('catalog:query-catalog-entities', flags, mode); }); template @@ -74,24 +54,17 @@ export function registerTemplateCommands(program: Command) { }); } - try { - const raw = await execAction('scaffolder:execute-template', { + await runRawAction( + 'scaffolder:execute-template', + { templateRef: opts.templateRef, values: opts.values, secrets: opts.secrets, instance: opts.instance, - }); - - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } - } catch (error) { - handleCommandError(error, mode, { - suggestion: 'rhdh-cli template list', - }); - } + }, + mode, + 'rhdh-cli template list', + ); }); template @@ -114,22 +87,15 @@ export function registerTemplateCommands(program: Command) { }); } - try { - const raw = await execAction('scaffolder:dry-run-template', { + await runRawAction( + 'scaffolder:dry-run-template', + { templateYaml: opts.templateRef, values: opts.values, instance: opts.instance, - }); - - if (mode === 'json') { - process.stdout.write(raw); - } else { - writeOutput(JSON.parse(raw), mode); - } - } catch (error) { - handleCommandError(error, mode, { - suggestion: 'rhdh-cli template list', - }); - } + }, + mode, + 'rhdh-cli template list', + ); }); } diff --git a/src/lib/client.ts b/src/lib/client.ts index 7d201db..22c4fe2 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -5,8 +5,9 @@ import { mkdtempSync, existsSync, rmdirSync, + statSync, } from 'node:fs'; -import { join } from 'node:path'; +import { join, delimiter } from 'node:path'; import { tmpdir } from 'node:os'; let resolvedCliCommand: string | undefined; @@ -16,19 +17,35 @@ function shellEscape(arg: string): string { return `'${arg.replace(/'/g, "'\\''")}'`; } +// Only search directories that aren't writable by group/other, so a +// tampered PATH entry can't cause us to resolve a malicious "backstage-cli" +// or "which" binary (see Sonar rule S4036). +function getTrustedPath(): string { + const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean); + const trustedDirs = dirs.filter(dir => { + try { + // eslint-disable-next-line no-bitwise + return (statSync(dir).mode & 0o022) === 0; + } catch { + return false; + } + }); + return trustedDirs.join(delimiter); +} + function getBackstageCliCommand(): string { if (resolvedCliCommand) return resolvedCliCommand; const whichResult = spawnSync('which', ['backstage-cli'], { encoding: 'utf-8', + env: { ...process.env, PATH: getTrustedPath() }, }); if (whichResult.status === 0) { resolvedCliCommand = 'backstage-cli'; return resolvedCliCommand; } - resolvedCliCommand = - 'NPM_CONFIG_LEGACY_PEER_DEPS=true npx -y @backstage/cli'; + resolvedCliCommand = 'NPM_CONFIG_LEGACY_PEER_DEPS=true npx -y @backstage/cli'; return resolvedCliCommand; } @@ -68,13 +85,19 @@ export async function execAction( const cleanup = () => { try { unlinkSync(outFile); - } catch {} + } catch { + // best-effort cleanup, ignore if already removed + } try { unlinkSync(errFile); - } catch {} + } catch { + // best-effort cleanup, ignore if already removed + } try { rmdirSync(dir); - } catch {} + } catch { + // best-effort cleanup, ignore if already removed + } }; try { diff --git a/src/lib/command-helpers.ts b/src/lib/command-helpers.ts new file mode 100644 index 0000000..7d11db1 --- /dev/null +++ b/src/lib/command-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/lib/format.ts b/src/lib/format.ts index 0c2ad81..f445f9a 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -81,8 +81,10 @@ function pad(str: string, width: number): string { return str.length >= width ? str : str + ' '.repeat(width - str.length); } -export function extractEntities(result: unknown): Array> { +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>); + return (obj?.items ?? obj?.entities ?? []) as Array>; } diff --git a/src/lib/intent-errors.ts b/src/lib/intent-errors.ts index 9ff4379..c2420ef 100644 --- a/src/lib/intent-errors.ts +++ b/src/lib/intent-errors.ts @@ -46,6 +46,14 @@ export function handleCommandError( 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'; @@ -67,9 +75,12 @@ function extractReason(error: unknown): string { return 'No Backstage instance configured. Run: rhdh-cli auth login --backend-url '; } - const stderr = (error as Record).stderr; - if (typeof stderr === 'string' && stderr.trim()) { - const lines = stderr.trim().split('\n').filter(l => l.trim()); + 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() @@ -92,12 +103,14 @@ function collectMessages(error: unknown): string { function extractPrimaryMessage(error: unknown): string { if (!(error instanceof Error)) return String(error); - const stderr = (error as Record).stderr; - if (typeof stderr === 'string' && stderr.trim()) { - const lines = stderr.trim().split('\n').filter(l => l.trim()); + 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(); + if (errorLine) return errorLine.replace(/^\s*Error:\s*/i, '').trim(); return lines[0].trim(); } From aff9c298418b8160d777dfa6ab54e870a73962d8 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Wed, 5 Aug 2026 16:02:27 -0400 Subject: [PATCH 04/18] refactor(RHIDP-14129): bundle intent-based CLI commands into one directory Move catalog/api/search/docs/template/backstage-passthrough and their supporting client/format/intent-errors/helpers modules into src/commands/intent-based-actions/, mirroring the existing export-dynamic-plugin/ and package-dynamic-plugins/ layout, with a single registerIntentCommands() entry point. Co-authored-by: Cursor --- src/commands/index.ts | 22 ++--------------- .../{ => intent-based-actions}/api.ts | 8 +++---- .../backstage-passthrough.ts | 2 +- .../{ => intent-based-actions}/catalog.ts | 6 ++--- .../intent-based-actions}/client.ts | 0 .../{ => intent-based-actions}/docs.ts | 8 +++---- .../intent-based-actions}/format.ts | 0 .../intent-based-actions/helpers.ts} | 0 src/commands/intent-based-actions/index.ts | 24 +++++++++++++++++++ .../intent-based-actions}/intent-errors.ts | 0 .../{ => intent-based-actions}/search.ts | 6 ++--- .../{ => intent-based-actions}/template.ts | 6 ++--- 12 files changed, 44 insertions(+), 38 deletions(-) rename src/commands/{ => intent-based-actions}/api.ts (92%) rename src/commands/{ => intent-based-actions}/backstage-passthrough.ts (98%) rename src/commands/{ => intent-based-actions}/catalog.ts (96%) rename src/{lib => commands/intent-based-actions}/client.ts (100%) rename src/commands/{ => intent-based-actions}/docs.ts (97%) rename src/{lib => commands/intent-based-actions}/format.ts (100%) rename src/{lib/command-helpers.ts => commands/intent-based-actions/helpers.ts} (100%) create mode 100644 src/commands/intent-based-actions/index.ts rename src/{lib => commands/intent-based-actions}/intent-errors.ts (100%) rename src/commands/{ => intent-based-actions}/search.ts (88%) rename src/commands/{ => intent-based-actions}/template.ts (94%) diff --git a/src/commands/index.ts b/src/commands/index.ts index c648f09..86aeb74 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -19,15 +19,7 @@ import { assertError } from '@backstage/errors'; import { Command } from 'commander'; import { exitWithError } from '../lib/errors'; -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'; +import { registerIntentCommands } from './intent-based-actions'; export function registerPluginCommand(program: Command) { const command = program @@ -152,17 +144,7 @@ export function registerPluginCommand(program: Command) { } export function registerCommands(program: Command) { registerPluginCommand(program); - - // Backstage CLI pass-through commands (auth, actions, sources) - registerAuthCommands(program); - registerActionsCommands(program); - - // Intent-based commands (catalog, api, search, docs, template) - registerCatalogCommands(program); - registerApiCommands(program); - registerSearchCommands(program); - registerDocsCommands(program); - registerTemplateCommands(program); + registerIntentCommands(program); } // Wraps an action function so that it always exits and handles errors diff --git a/src/commands/api.ts b/src/commands/intent-based-actions/api.ts similarity index 92% rename from src/commands/api.ts rename to src/commands/intent-based-actions/api.ts index 1b7da8b..c1e3ccc 100644 --- a/src/commands/api.ts +++ b/src/commands/intent-based-actions/api.ts @@ -1,8 +1,8 @@ import { Command } from 'commander'; -import { execAction } from '../lib/client'; -import { runEntityListAction } from '../lib/command-helpers'; -import { parseOutputFlag, writeOutput } from '../lib/format'; -import { handleCommandError } from '../lib/intent-errors'; +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 diff --git a/src/commands/backstage-passthrough.ts b/src/commands/intent-based-actions/backstage-passthrough.ts similarity index 98% rename from src/commands/backstage-passthrough.ts rename to src/commands/intent-based-actions/backstage-passthrough.ts index a0c589b..3b42f30 100644 --- a/src/commands/backstage-passthrough.ts +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { execPassthrough } from '../lib/client'; +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 ` diff --git a/src/commands/catalog.ts b/src/commands/intent-based-actions/catalog.ts similarity index 96% rename from src/commands/catalog.ts rename to src/commands/intent-based-actions/catalog.ts index 849277b..88ae1f6 100644 --- a/src/commands/catalog.ts +++ b/src/commands/intent-based-actions/catalog.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; -import { runEntityListAction, runRawAction } from '../lib/command-helpers'; -import { parseOutputFlag } from '../lib/format'; -import { handleCommandError } from '../lib/intent-errors'; +import { runEntityListAction, runRawAction } from './helpers'; +import { parseOutputFlag } from './format'; +import { handleCommandError } from './intent-errors'; export function registerCatalogCommands(program: Command) { const catalog = program diff --git a/src/lib/client.ts b/src/commands/intent-based-actions/client.ts similarity index 100% rename from src/lib/client.ts rename to src/commands/intent-based-actions/client.ts diff --git a/src/commands/docs.ts b/src/commands/intent-based-actions/docs.ts similarity index 97% rename from src/commands/docs.ts rename to src/commands/intent-based-actions/docs.ts index 048f879..52648e6 100644 --- a/src/commands/docs.ts +++ b/src/commands/intent-based-actions/docs.ts @@ -1,14 +1,14 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { execAction, execActionJson } from '../lib/client'; -import { runSearchAction } from '../lib/command-helpers'; +import { execAction, execActionJson } from './client'; +import { runSearchAction } from './helpers'; import { parseOutputFlag, writeOutput, formatEntityTable, extractEntities, -} from '../lib/format'; -import { handleCommandError } from '../lib/intent-errors'; +} from './format'; +import { handleCommandError } from './intent-errors'; export function registerDocsCommands(program: Command) { const docs = program diff --git a/src/lib/format.ts b/src/commands/intent-based-actions/format.ts similarity index 100% rename from src/lib/format.ts rename to src/commands/intent-based-actions/format.ts diff --git a/src/lib/command-helpers.ts b/src/commands/intent-based-actions/helpers.ts similarity index 100% rename from src/lib/command-helpers.ts rename to src/commands/intent-based-actions/helpers.ts 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/lib/intent-errors.ts b/src/commands/intent-based-actions/intent-errors.ts similarity index 100% rename from src/lib/intent-errors.ts rename to src/commands/intent-based-actions/intent-errors.ts diff --git a/src/commands/search.ts b/src/commands/intent-based-actions/search.ts similarity index 88% rename from src/commands/search.ts rename to src/commands/intent-based-actions/search.ts index 97d15b8..05be25c 100644 --- a/src/commands/search.ts +++ b/src/commands/intent-based-actions/search.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; -import { runSearchAction } from '../lib/command-helpers'; -import { parseOutputFlag } from '../lib/format'; -import { handleCommandError } from '../lib/intent-errors'; +import { runSearchAction } from './helpers'; +import { parseOutputFlag } from './format'; +import { handleCommandError } from './intent-errors'; export function registerSearchCommands(program: Command) { program diff --git a/src/commands/template.ts b/src/commands/intent-based-actions/template.ts similarity index 94% rename from src/commands/template.ts rename to src/commands/intent-based-actions/template.ts index 1d4510d..1fc70c0 100644 --- a/src/commands/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; -import { runEntityListAction, runRawAction } from '../lib/command-helpers'; -import { parseOutputFlag } from '../lib/format'; -import { handleCommandError } from '../lib/intent-errors'; +import { runEntityListAction, runRawAction } from './helpers'; +import { parseOutputFlag } from './format'; +import { handleCommandError } from './intent-errors'; export function registerTemplateCommands(program: Command) { const template = program From 76b6602511e9ce1bb3ad23da90219f4c96b364fa Mon Sep 17 00:00:00 2001 From: Stephanie Date: Wed, 5 Aug 2026 16:14:33 -0400 Subject: [PATCH 05/18] fix(RHIDP-14129): resolve backstage-cli via PATH scan instead of which SonarCloud S4036 still flagged spawnSync('which', ...) even with a restricted PATH env, since it pattern-matches on shelling out to a path-search utility rather than analyzing the PATH value. Replace it with a direct filesystem walk over PATH entries (skipping group/other-writable directories) and an accessSync executability check, avoiding the flagged pattern entirely. Co-authored-by: Cursor --- src/commands/intent-based-actions/client.ts | 45 +++++++++++++-------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index 22c4fe2..e1d8c47 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -1,4 +1,4 @@ -import { execSync, spawnSync } from 'node:child_process'; +import { execSync } from 'node:child_process'; import { readFileSync, unlinkSync, @@ -6,6 +6,8 @@ import { existsSync, rmdirSync, statSync, + accessSync, + constants as fsConstants, } from 'node:fs'; import { join, delimiter } from 'node:path'; import { tmpdir } from 'node:os'; @@ -17,31 +19,42 @@ function shellEscape(arg: string): string { return `'${arg.replace(/'/g, "'\\''")}'`; } -// Only search directories that aren't writable by group/other, so a -// tampered PATH entry can't cause us to resolve a malicious "backstage-cli" -// or "which" binary (see Sonar rule S4036). -function getTrustedPath(): string { +// 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 trustedDirs = dirs.filter(dir => { + const binName = + process.platform === 'win32' ? 'backstage-cli.cmd' : 'backstage-cli'; + + for (const dir of dirs) { try { // eslint-disable-next-line no-bitwise - return (statSync(dir).mode & 0o022) === 0; + if ((statSync(dir).mode & 0o022) !== 0) continue; } catch { - return false; + continue; } - }); - return trustedDirs.join(delimiter); + + 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 whichResult = spawnSync('which', ['backstage-cli'], { - encoding: 'utf-8', - env: { ...process.env, PATH: getTrustedPath() }, - }); - if (whichResult.status === 0) { - resolvedCliCommand = 'backstage-cli'; + const found = findBackstageCliOnPath(); + if (found) { + resolvedCliCommand = shellEscape(found); return resolvedCliCommand; } From a504d1565f6c6eabacb77f73c3dd8228e1b428f2 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 13 Aug 2026 13:48:14 -0400 Subject: [PATCH 06/18] address review comments Signed-off-by: Stephanie --- .../backstage-passthrough.ts | 7 +- src/commands/intent-based-actions/client.ts | 140 +++++++++++------- 2 files changed, 88 insertions(+), 59 deletions(-) diff --git a/src/commands/intent-based-actions/backstage-passthrough.ts b/src/commands/intent-based-actions/backstage-passthrough.ts index 3b42f30..a1d34a5 100644 --- a/src/commands/intent-based-actions/backstage-passthrough.ts +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -1,9 +1,9 @@ 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 `. +// 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, @@ -14,6 +14,7 @@ function registerPassthroughCommand( .command(name) .description(description) .allowUnknownOption() + .helpOption(false) .action(function passthroughAction(this: Command) { execPassthrough([...passthroughArgs, ...this.args]); }); diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index e1d8c47..41aa8c5 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -1,87 +1,115 @@ -import { execSync } from 'node:child_process'; +import { spawn, 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 { join, dirname } 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; - } +let resolvedCliBinary: string | undefined; - const candidate = join(dir, binName); - try { - accessSync(candidate, fsConstants.X_OK); - return candidate; - } catch { - continue; - } +// 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`).', + ); } - return undefined; -} + 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.', + ); + } -function getBackstageCliCommand(): string { - if (resolvedCliCommand) return resolvedCliCommand; + resolvedCliBinary = join(dirname(pkgJsonPath), relBin); + return resolvedCliBinary; +} - const found = findBackstageCliOnPath(); - if (found) { - resolvedCliCommand = shellEscape(found); - return resolvedCliCommand; - } +// Keeps output consistently branded as `rhdh-cli`. +function rebrand(text: string): string { + return text.replace(/backstage-cli/g, 'rhdh-cli'); +} - resolvedCliCommand = 'NPM_CONFIG_LEGACY_PEER_DEPS=true npx -y @backstage/cli'; - return resolvedCliCommand; +// 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 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); - } + 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); + }); } export async function execAction( actionId: string, flags: Record, ): Promise { - const cli = getBackstageCliCommand(); - const parts = [cli, 'actions', 'execute', actionId]; + const bin = resolveBackstageCliBinary(); + const parts = [ + shellEscape(process.execPath), + shellEscape(bin), + 'actions', + 'execute', + actionId, + ]; for (const [key, value] of Object.entries(flags)) { if (value === undefined || value === false) continue; @@ -140,7 +168,7 @@ export async function execAction( } } cleanup(); - throw new Error(errorMsg); + throw new Error(rebrand(errorMsg)); } } From af820dc7fca126591cb45dcfaf9ebcede6ffa90b Mon Sep 17 00:00:00 2001 From: Stephanie Date: Wed, 19 Aug 2026 10:47:59 -0400 Subject: [PATCH 07/18] address review comments Signed-off-by: Stephanie --- .../intent-based-actions/client.test.ts | 270 ++++++++++++++++++ .../intent-based-actions/format.test.ts | 193 +++++++++++++ .../intent-based-actions/helpers.test.ts | 222 ++++++++++++++ .../intent-errors.test.ts | 184 ++++++++++++ src/commands/intent-based-actions/template.ts | 27 +- 5 files changed, 888 insertions(+), 8 deletions(-) create mode 100644 src/commands/intent-based-actions/client.test.ts create mode 100644 src/commands/intent-based-actions/format.test.ts create mode 100644 src/commands/intent-based-actions/helpers.test.ts create mode 100644 src/commands/intent-based-actions/intent-errors.test.ts 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..2d50c55 --- /dev/null +++ b/src/commands/intent-based-actions/client.test.ts @@ -0,0 +1,270 @@ +import { EventEmitter } from 'node:events'; +import { writeFileSync } from 'node:fs'; +import { execSync, spawn } from 'node:child_process'; +import { execAction, execActionJson, execPassthrough } from './client'; + +jest.mock('node:child_process'); + +const mockExecSync = execSync as jest.MockedFunction; +const mockSpawn = spawn as jest.MockedFunction; + +/** + * The real execAction shells out to a resolved `backstage-cli` binary and + * redirects stdout/stderr to temp files. Since execSync itself is mocked, + * these helpers simulate what the real process would have written to those + * files, using the actual filesystem (only child_process is mocked here). + */ +function mockExecSyncWritingFiles( + handler: (outFile: string, errFile: string) => void, +) { + mockExecSync.mockImplementation((cmd: unknown) => { + const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); + if (!match) throw new Error(`Unexpected command shape: ${String(cmd)}`); + const [, outFile, errFile] = match; + handler(outFile, errFile); + return Buffer.from(''); + }); +} + +describe('execAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves with the contents written to the redirected stdout file', async () => { + mockExecSyncWritingFiles(outFile => { + writeFileSync(outFile, '{"ok":true}'); + }); + + const result = await execAction('catalog:query-catalog-entities', { + instance: 'default', + }); + + expect(result).toBe('{"ok":true}'); + }); + + it('builds the command with the action id and unescaped simple flags', async () => { + mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + + await execAction('catalog:query-catalog-entities', { + instance: 'default', + limit: 5, + }); + + const cmd = String(mockExecSync.mock.calls[0][0]); + expect(cmd).toContain('actions execute catalog:query-catalog-entities'); + expect(cmd).toContain('--instance default'); + expect(cmd).toContain('--limit 5'); + }); + + it('quotes and escapes flag values containing special characters', async () => { + mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + + await execAction('catalog:query-catalog-entities', { + query: '{"kind":"Component"}', + }); + + const cmd = String(mockExecSync.mock.calls[0][0]); + expect(cmd).toContain(`--query '{"kind":"Component"}'`); + }); + + it('escapes single quotes within flag values', async () => { + mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + + await execAction('catalog:validate-entity', { entity: "it's a test" }); + + const cmd = String(mockExecSync.mock.calls[0][0]); + expect(cmd).toContain(`'it'\\''s a test'`); + }); + + it('adds boolean-true flags with no value', async () => { + mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + + await execAction('actions:list', { verbose: true }); + + const cmd = String(mockExecSync.mock.calls[0][0]); + expect(cmd).toMatch(/--verbose(\s|$)/); + expect(cmd).not.toContain('--verbose true'); + }); + + it('omits flags that are false or undefined', async () => { + mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + + await execAction('actions:list', { verbose: false, instance: undefined }); + + const cmd = String(mockExecSync.mock.calls[0][0]); + expect(cmd).not.toContain('--verbose'); + expect(cmd).not.toContain('--instance'); + }); + + it('rejects with the "Error:" line from stderr when the command fails', async () => { + mockExecSync.mockImplementation((cmd: unknown) => { + const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); + const [, , errFile] = match!; + writeFileSync(errFile, 'some noise\nError: Entity not found\nmore noise'); + throw new Error('Command failed'); + }); + + await expect( + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).rejects.toThrow('Entity not found'); + }); + + it('falls back to the last stderr line when no "Error:" line is present', async () => { + mockExecSync.mockImplementation((cmd: unknown) => { + const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); + const [, , errFile] = match!; + writeFileSync(errFile, 'first line\nlast line'); + throw new Error('Command failed'); + }); + + await expect( + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).rejects.toThrow('last line'); + }); + + it('rebrands "backstage-cli" as "rhdh-cli" in the thrown error message', async () => { + mockExecSync.mockImplementation((cmd: unknown) => { + const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); + const [, , errFile] = match!; + writeFileSync(errFile, 'Error: run backstage-cli auth login first'); + throw new Error('Command failed'); + }); + + await expect( + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).rejects.toThrow('run rhdh-cli auth login first'); + }); + + it('rejects with a generic message when the command fails without stderr content', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + await expect( + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).rejects.toThrow('rhdh-cli command failed'); + }); +}); + +describe('execActionJson', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('parses valid JSON output', async () => { + mockExecSyncWritingFiles(outFile => + writeFileSync(outFile, '{"kind":"Component"}'), + ); + + const result = await execActionJson('catalog:get-catalog-entity', { + name: 'x', + }); + + expect(result).toEqual({ kind: 'Component' }); + }); + + it('returns the raw string when the output is not valid JSON', async () => { + mockExecSyncWritingFiles(outFile => writeFileSync(outFile, 'not json')); + + const result = await execActionJson('catalog:get-catalog-entity', { + name: 'x', + }); + + expect(result).toBe('not json'); + }); +}); + +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/format.test.ts b/src/commands/intent-based-actions/format.test.ts new file mode 100644 index 0000000..84c758f --- /dev/null +++ b/src/commands/intent-based-actions/format.test.ts @@ -0,0 +1,193 @@ +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + formatSearchResults, + extractEntities, +} from './format'; + +describe('parseOutputFlag', () => { + it('returns "json" when output is "json"', () => { + expect(parseOutputFlag('json')).toBe('json'); + }); + + it('returns "human" when output is "human"', () => { + expect(parseOutputFlag('human')).toBe('human'); + }); + + it('returns "human" when output is undefined', () => { + expect(parseOutputFlag(undefined)).toBe('human'); + }); + + it('returns "human" for any unrecognized value', () => { + expect(parseOutputFlag('yaml')).toBe('human'); + }); +}); + +describe('extractEntities', () => { + it('returns the array as-is when result is already an array', () => { + const entities = [{ kind: 'Component' }]; + expect(extractEntities(entities)).toBe(entities); + }); + + it('returns result.items when present', () => { + const items = [{ kind: 'Component' }]; + expect(extractEntities({ items })).toBe(items); + }); + + it('returns result.entities when items is absent', () => { + const entities = [{ kind: 'API' }]; + expect(extractEntities({ entities })).toBe(entities); + }); + + it('prefers items over entities when both are present', () => { + const items = [{ kind: 'Component' }]; + const entities = [{ kind: 'API' }]; + expect(extractEntities({ items, entities })).toBe(items); + }); + + it('returns an empty array when result has neither items nor entities', () => { + expect(extractEntities({})).toEqual([]); + }); + + it('returns an empty array when result is undefined', () => { + expect(extractEntities(undefined)).toEqual([]); + }); +}); + +describe('formatEntityTable', () => { + it('returns a "no entities" message for an empty list', () => { + expect(formatEntityTable([])).toMatch(/No entities found\./); + }); + + it('formats an entity using metadata.name/kind/namespace and spec.type', () => { + const output = formatEntityTable([ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + spec: { type: 'service' }, + }, + ]); + expect(output).toContain('my-service'); + expect(output).toContain('Component'); + expect(output).toContain('default'); + expect(output).toContain('service'); + }); + + it('falls back to top-level name/kind/namespace/type when metadata/spec are absent', () => { + const output = formatEntityTable([ + { name: 'flat-entity', kind: 'API', namespace: 'custom', type: 'grpc' }, + ]); + expect(output).toContain('flat-entity'); + expect(output).toContain('API'); + expect(output).toContain('custom'); + expect(output).toContain('grpc'); + }); + + it('defaults namespace to "default" when missing everywhere', () => { + const output = formatEntityTable([{ kind: 'Component', name: 'x' }]); + expect(output).toContain('default'); + }); + + it('includes a header row', () => { + const output = formatEntityTable([{ kind: 'Component', name: 'x' }]); + expect(output).toContain('NAME'); + expect(output).toContain('KIND'); + expect(output).toContain('NAMESPACE'); + expect(output).toContain('TYPE'); + }); +}); + +describe('formatSearchResults', () => { + it('returns a "no results" message for an empty list', () => { + expect(formatSearchResults([])).toMatch(/No results found\./); + }); + + it('formats a result using document.title/location/text', () => { + const output = formatSearchResults([ + { + document: { + title: 'Getting started', + location: '/docs/getting-started', + text: 'A short guide.', + }, + }, + ]); + expect(output).toContain('Getting started'); + expect(output).toContain('/docs/getting-started'); + expect(output).toContain('A short guide.'); + }); + + it('falls back to top-level title/location when document is absent', () => { + const output = formatSearchResults([ + { title: 'Flat result', location: '/flat' }, + ]); + expect(output).toContain('Flat result'); + expect(output).toContain('/flat'); + }); + + it('omits the location line when no location is present', () => { + const output = formatSearchResults([{ title: 'No location' }]); + expect(output).toContain('No location'); + }); + + it('truncates snippet text longer than 120 characters', () => { + const longText = 'a'.repeat(200); + const output = formatSearchResults([ + { document: { title: 't', text: longText } }, + ]); + expect(output).toContain(`${'a'.repeat(120)}...`); + expect(output).not.toContain('a'.repeat(121)); + }); + + it('does not truncate snippet text at or under 120 characters', () => { + const shortText = 'a'.repeat(120); + const output = formatSearchResults([ + { document: { title: 't', text: shortText } }, + ]); + expect(output).toContain(shortText); + expect(output).not.toContain('...'); + }); +}); + +describe('writeOutput', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('writes pretty-printed JSON in json mode, ignoring any humanFormatter', () => { + const data = { foo: 'bar' }; + const humanFormatter = jest.fn(); + + writeOutput(data, 'json', humanFormatter); + + expect(writeSpy).toHaveBeenCalledWith(`${JSON.stringify(data, null, 2)}\n`); + expect(humanFormatter).not.toHaveBeenCalled(); + }); + + it('uses the humanFormatter in human mode when provided', () => { + const data = [{ foo: 'bar' }]; + const humanFormatter = jest.fn().mockReturnValue('formatted output\n'); + + writeOutput(data, 'human', humanFormatter); + + expect(humanFormatter).toHaveBeenCalledWith(data); + expect(writeSpy).toHaveBeenCalledWith('formatted output\n'); + }); + + it('falls back to pretty-printed JSON in human mode without a humanFormatter', () => { + const data = { foo: 'bar' }; + + writeOutput(data, 'human'); + + expect(writeSpy).toHaveBeenCalledWith(`${JSON.stringify(data, null, 2)}\n`); + }); +}); diff --git a/src/commands/intent-based-actions/helpers.test.ts b/src/commands/intent-based-actions/helpers.test.ts new file mode 100644 index 0000000..19354d0 --- /dev/null +++ b/src/commands/intent-based-actions/helpers.test.ts @@ -0,0 +1,222 @@ +import { execAction, execActionJson } from './client'; +import { handleCommandError } from './intent-errors'; +import { runEntityListAction, runRawAction, runSearchAction } from './helpers'; + +jest.mock('./client'); +jest.mock('./intent-errors'); + +const mockExecAction = execAction as jest.MockedFunction; +const mockExecActionJson = execActionJson as jest.MockedFunction< + typeof execActionJson +>; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; + +describe('runEntityListAction', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + jest.clearAllMocks(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('writes the raw action output directly in json mode', async () => { + mockExecAction.mockResolvedValue('{"items":[]}'); + + await runEntityListAction( + 'catalog:query-catalog-entities', + { instance: 'default' }, + 'json', + ); + + expect(mockExecAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { instance: 'default' }, + ); + expect(mockExecActionJson).not.toHaveBeenCalled(); + expect(writeSpy).toHaveBeenCalledWith('{"items":[]}'); + }); + + it('extracts entities and renders a table in human mode', async () => { + mockExecActionJson.mockResolvedValue({ + items: [{ kind: 'Component', metadata: { name: 'my-service' } }], + }); + + await runEntityListAction( + 'catalog:query-catalog-entities', + { instance: 'default' }, + 'human', + ); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { instance: 'default' }, + ); + expect(mockExecAction).not.toHaveBeenCalled(); + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain('my-service'); + expect(output).toContain('Component'); + }); + + it('routes errors from execAction to handleCommandError with the given suggestion', async () => { + const error = new Error('boom'); + mockExecAction.mockRejectedValue(error); + + await runEntityListAction( + 'catalog:query-catalog-entities', + {}, + 'json', + 'try this', + ); + + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'json', { + suggestion: 'try this', + }); + }); + + it('calls handleCommandError without a suggestion when none is given', async () => { + const error = new Error('boom'); + mockExecActionJson.mockRejectedValue(error); + + await runEntityListAction('catalog:query-catalog-entities', {}, 'human'); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + error, + 'human', + undefined, + ); + }); +}); + +describe('runRawAction', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + jest.clearAllMocks(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('writes the raw string directly in json mode', async () => { + mockExecAction.mockResolvedValue('{"foo":"bar"}'); + + await runRawAction('catalog:get-catalog-entity', { name: 'x' }, 'json'); + + expect(writeSpy).toHaveBeenCalledWith('{"foo":"bar"}'); + }); + + it('pretty-prints the parsed JSON in human mode', async () => { + mockExecAction.mockResolvedValue('{"foo":"bar"}'); + + await runRawAction('catalog:get-catalog-entity', { name: 'x' }, 'human'); + + expect(writeSpy).toHaveBeenCalledWith( + `${JSON.stringify({ foo: 'bar' }, null, 2)}\n`, + ); + }); + + it('routes execAction errors to handleCommandError', async () => { + const error = new Error('boom'); + mockExecAction.mockRejectedValue(error); + + await runRawAction( + 'catalog:get-catalog-entity', + {}, + 'json', + 'suggestion here', + ); + + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'json', { + suggestion: 'suggestion here', + }); + }); + + it('routes JSON parse failures in human mode to handleCommandError', async () => { + mockExecAction.mockResolvedValue('not valid json'); + + await runRawAction('catalog:get-catalog-entity', {}, 'human'); + + expect(mockHandleCommandError).toHaveBeenCalledTimes(1); + expect(mockHandleCommandError.mock.calls[0][1]).toBe('human'); + }); +}); + +describe('runSearchAction', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + jest.clearAllMocks(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('merges the term into the flags passed to the search:query action', async () => { + mockExecAction.mockResolvedValue('{}'); + + await runSearchAction('my service', { instance: 'default' }, 'json'); + + expect(mockExecAction).toHaveBeenCalledWith('search:query', { + term: 'my service', + instance: 'default', + }); + }); + + it('writes the raw output directly in json mode', async () => { + mockExecAction.mockResolvedValue('{"results":[]}'); + + await runSearchAction('term', {}, 'json'); + + expect(writeSpy).toHaveBeenCalledWith('{"results":[]}'); + }); + + it('extracts result.results and renders snippets in human mode', async () => { + mockExecActionJson.mockResolvedValue({ + results: [{ document: { title: 'Doc title', text: 'some text' } }], + }); + + await runSearchAction('term', {}, 'human'); + + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain('Doc title'); + expect(output).toContain('some text'); + }); + + it('treats a bare array result as the results list directly', async () => { + mockExecActionJson.mockResolvedValue([ + { document: { title: 'Bare result' } }, + ]); + + await runSearchAction('term', {}, 'human'); + + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain('Bare result'); + }); + + it('routes errors to handleCommandError with the given suggestion', async () => { + const error = new Error('boom'); + mockExecActionJson.mockRejectedValue(error); + + await runSearchAction('term', {}, 'human', 'rhdh-cli search "term"'); + + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human', { + suggestion: 'rhdh-cli search "term"', + }); + }); +}); diff --git a/src/commands/intent-based-actions/intent-errors.test.ts b/src/commands/intent-based-actions/intent-errors.test.ts new file mode 100644 index 0000000..1f9d790 --- /dev/null +++ b/src/commands/intent-based-actions/intent-errors.test.ts @@ -0,0 +1,184 @@ +import { formatError, handleCommandError, CliError } from './intent-errors'; + +describe('formatError', () => { + it('returns pretty-printed JSON in json mode', () => { + const err: CliError = { error: 'boom', reason: 'it broke' }; + expect(formatError(err, 'json')).toBe(`${JSON.stringify(err, null, 2)}\n`); + }); + + it('includes the suggestion field in json mode when present', () => { + const err: CliError = { + error: 'boom', + reason: 'it broke', + suggestion: 'try again', + }; + const parsed = JSON.parse(formatError(err, 'json')); + expect(parsed.suggestion).toBe('try again'); + }); + + it('renders the error message in human mode', () => { + const err: CliError = { error: 'boom', reason: 'boom' }; + const output = formatError(err, 'human'); + expect(output).toContain('Error:'); + expect(output).toContain('boom'); + }); + + it('renders the reason on its own line when it differs from the error', () => { + const err: CliError = { error: 'boom', reason: 'a more detailed reason' }; + const output = formatError(err, 'human'); + expect(output).toContain('boom'); + expect(output).toContain('a more detailed reason'); + }); + + it('does not duplicate the reason line when it matches the error', () => { + const err: CliError = { error: 'same message', reason: 'same message' }; + const output = formatError(err, 'human'); + const occurrences = output.split('same message').length - 1; + expect(occurrences).toBe(1); + }); + + it('normalizes a leading "Error:" prefix before comparing error and reason', () => { + const err: CliError = { + error: 'Error: same message', + reason: 'same message', + }; + const output = formatError(err, 'human'); + const occurrences = output.split('same message').length - 1; + expect(occurrences).toBe(1); + }); + + it('includes the suggestion under a "Try:" line when present', () => { + const err: CliError = { + error: 'boom', + reason: 'boom', + suggestion: 'rhdh-cli catalog list --kind Component', + }; + const output = formatError(err, 'human'); + expect(output).toContain('Try:'); + expect(output).toContain('rhdh-cli catalog list --kind Component'); + }); + + it('omits the "Try:" line when no suggestion is present', () => { + const err: CliError = { error: 'boom', reason: 'boom' }; + const output = formatError(err, 'human'); + expect(output).not.toContain('Try:'); + }); +}); + +describe('handleCommandError', () => { + let exitSpy: jest.SpyInstance; + let stderrSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + function writtenError(): CliError { + const written = stderrSpy.mock.calls[0][0] as string; + return JSON.parse(written) as CliError; + } + + it('always exits with code 1', () => { + handleCommandError(new Error('boom'), 'json'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('writes the error to stderr', () => { + handleCommandError(new Error('boom'), 'json'); + expect(stderrSpy).toHaveBeenCalledTimes(1); + }); + + it('includes the provided suggestion', () => { + handleCommandError(new Error('boom'), 'json', { + suggestion: 'rhdh-cli catalog list', + }); + expect(writtenError().suggestion).toBe('rhdh-cli catalog list'); + }); + + it('omits the suggestion field when none is provided', () => { + handleCommandError(new Error('boom'), 'json'); + expect(writtenError().suggestion).toBeUndefined(); + }); + + it('maps a 401/Unauthorized error to an authentication reason', () => { + handleCommandError(new Error('Request failed with 401'), 'json'); + expect(writtenError().reason).toMatch(/rhdh-cli auth login/); + }); + + it('maps an Unauthorized error to an authentication reason', () => { + handleCommandError(new Error('Unauthorized'), 'json'); + expect(writtenError().reason).toMatch(/rhdh-cli auth login/); + }); + + it('maps a 404/Not Found error to a not-found reason', () => { + handleCommandError(new Error('404'), 'json'); + expect(writtenError().reason).toMatch(/was not found/); + }); + + it('maps an ECONNREFUSED error to a connectivity reason', () => { + handleCommandError(new Error('connect ECONNREFUSED 127.0.0.1'), 'json'); + expect(writtenError().reason).toMatch(/Could not connect/); + }); + + it('maps a "fetch failed" error to a connectivity reason', () => { + handleCommandError(new Error('fetch failed'), 'json'); + expect(writtenError().reason).toMatch(/Could not connect/); + }); + + it('maps a "No authenticated instances" error to a configuration reason', () => { + handleCommandError(new Error('No authenticated instances'), 'json'); + expect(writtenError().reason).toMatch(/No Backstage instance configured/); + }); + + it('checks the message of the full error cause chain, not just the top-level message', () => { + const outer = new Error('outer failure', { + cause: new Error('inner 404 Not Found'), + }); + handleCommandError(outer, 'json'); + const result = writtenError(); + expect(result.error).toBe('outer failure'); + expect(result.reason).toMatch(/was not found/); + }); + + it('falls back to the error message as the reason when no pattern matches', () => { + handleCommandError(new Error('something unexpected happened'), 'json'); + const result = writtenError(); + expect(result.error).toBe('something unexpected happened'); + expect(result.reason).toBe('something unexpected happened'); + }); + + it('extracts the "Error:" line from a stderr-bearing error over the raw message', () => { + const error = Object.assign(new Error('backstage-cli command failed'), { + stderr: 'some noise\nError: Something went wrong\nmore noise', + }); + handleCommandError(error, 'json'); + const result = writtenError(); + expect(result.error).toBe('Something went wrong'); + expect(result.reason).toBe('Something went wrong'); + }); + + it('falls back to the first non-empty stderr line when no "Error:" line is present', () => { + const error = Object.assign(new Error('backstage-cli command failed'), { + stderr: 'first line\nsecond line', + }); + handleCommandError(error, 'json'); + expect(writtenError().error).toBe('first line'); + }); + + it('treats a non-Error thrown value as an unknown error', () => { + handleCommandError('just a string', 'json'); + const result = writtenError(); + expect(result.error).toBe('just a string'); + expect(result.reason).toBe('Unknown error'); + }); +}); diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index 1fc70c0..89d5638 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { Command } from 'commander'; import { runEntityListAction, runRawAction } from './helpers'; import { parseOutputFlag } from './format'; @@ -70,27 +71,37 @@ export function registerTemplateCommands(program: Command) { 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('--template-file ', 'Path to a template YAML file (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, { + if (!opts.templateFile) { + handleCommandError(new Error('--template-file is required'), mode, { suggestion: - 'rhdh-cli template dry-run --template-ref template:default/my-template', + 'rhdh-cli template dry-run --template-file ./template.yaml', + }); + } + + // scaffolder:dry-run-template expects the raw YAML content of the + // template (it yaml.parse()s this into apiVersion/kind/spec.steps), + // not an entity ref, so we read the file here rather than passing + // through a ref like the other template subcommands. + let templateYaml: string; + try { + templateYaml = readFileSync(opts.templateFile, 'utf-8'); + } catch (error) { + handleCommandError(error, mode, { + suggestion: `Check that the file exists: ${opts.templateFile}`, }); } await runRawAction( 'scaffolder:dry-run-template', { - templateYaml: opts.templateRef, + templateYaml, values: opts.values, instance: opts.instance, }, From 0a5249e7464a6c9ee340e5acf5c313725cc49e05 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 27 Aug 2026 12:29:09 -0400 Subject: [PATCH 08/18] input and output UX enhancement Signed-off-by: Stephanie --- src/commands/intent-based-actions/catalog.ts | 67 ++++++++-- .../intent-based-actions/format.test.ts | 35 +++++ src/commands/intent-based-actions/format.ts | 52 ++++++++ src/commands/intent-based-actions/helpers.ts | 4 +- src/commands/intent-based-actions/kv.test.ts | 126 ++++++++++++++++++ src/commands/intent-based-actions/kv.ts | 84 ++++++++++++ src/commands/intent-based-actions/search.ts | 33 ++++- src/commands/intent-based-actions/template.ts | 84 ++++++++++-- 8 files changed, 455 insertions(+), 30 deletions(-) create mode 100644 src/commands/intent-based-actions/kv.test.ts create mode 100644 src/commands/intent-based-actions/kv.ts diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts index 88ae1f6..d1d161b 100644 --- a/src/commands/intent-based-actions/catalog.ts +++ b/src/commands/intent-based-actions/catalog.ts @@ -1,7 +1,9 @@ +import { readFileSync } from 'node:fs'; import { Command } from 'commander'; import { runEntityListAction, runRawAction } from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; +import { collect, parseList, resolveJsonInput } from './kv'; export function registerCatalogCommands(program: Command) { const catalog = program @@ -13,9 +15,21 @@ export function registerCatalogCommands(program: Command) { .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( + '--filter ', + 'Query predicate, e.g. --filter spec.lifecycle=production (repeatable)', + collect, + [] as string[], + ) + .option( + '--filters ', + 'Query predicate as a JSON string (alternative to --filter)', + ) .option('--limit ', 'Maximum results to return', parseInt) - .option('--fields ', 'Fields to include (JSON array)') + .option( + '--fields ', + 'Comma-separated fields to include, e.g. metadata.name,metadata.description', + ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -25,16 +39,28 @@ export function registerCatalogCommands(program: Command) { if (opts.kind) query.kind = opts.kind; if (opts.type) query['spec.type'] = opts.type; + let predicate: string | undefined; + try { + predicate = resolveJsonInput(opts.filter, opts.filters); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli catalog list --kind Component --filter spec.lifecycle=production', + }); + } + // --filter/--filters merge on top of the --kind/--type shortcuts. + const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; + + const fields = parseList(opts.fields); + const flags: Record = { instance: opts.instance, limit: opts.limit, - fields: opts.fields, + fields: fields ? JSON.stringify(fields) : undefined, }; - if (opts.filter) { - flags.query = opts.filter; - } else if (Object.keys(query).length > 0) { - flags.query = JSON.stringify(query); + if (Object.keys(merged).length > 0) { + flags.query = JSON.stringify(merged); } await runEntityListAction( @@ -42,6 +68,7 @@ export function registerCatalogCommands(program: Command) { flags, mode, 'rhdh-cli catalog list --kind Component', + fields, ); }); @@ -77,19 +104,35 @@ export function registerCatalogCommands(program: Command) { catalog .command('validate') .description('Validate entity YAML against the catalog schema') - .option('--entity ', 'Entity YAML content (required)') + .option('--entity ', 'Entity YAML content') + .option( + '--entity-file ', + 'Path to a file containing entity YAML (alternative to --entity)', + ) .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) { + + let entity: string | undefined = opts.entity; + if (opts.entityFile) { + try { + entity = readFileSync(opts.entityFile, 'utf-8'); + } catch (error) { + handleCommandError(error, mode, { + suggestion: `Check that the file exists: ${opts.entityFile}`, + }); + } + } + + if (!entity) { handleCommandError( - new Error('--entity is required (YAML string)'), + new Error('--entity or --entity-file is required'), mode, { suggestion: - 'rhdh-cli catalog validate --entity "$(cat entity.yaml)"', + 'rhdh-cli catalog validate --entity-file ./catalog-info.yaml', }, ); } @@ -97,7 +140,7 @@ export function registerCatalogCommands(program: Command) { await runRawAction( 'catalog:validate-entity', { - entity: opts.entity, + entity, location: opts.location, instance: opts.instance, }, diff --git a/src/commands/intent-based-actions/format.test.ts b/src/commands/intent-based-actions/format.test.ts index 84c758f..8d4821b 100644 --- a/src/commands/intent-based-actions/format.test.ts +++ b/src/commands/intent-based-actions/format.test.ts @@ -96,6 +96,41 @@ describe('formatEntityTable', () => { expect(output).toContain('NAMESPACE'); expect(output).toContain('TYPE'); }); + + it('renders a column per requested field, using the last path segment as the header', () => { + const output = formatEntityTable( + [ + { + kind: 'Component', + metadata: { name: 'rhdh', description: 'Developer Hub' }, + }, + ], + ['metadata.name', 'metadata.description'], + ); + expect(output).toContain('NAME'); + expect(output).toContain('DESCRIPTION'); + expect(output).toContain('rhdh'); + expect(output).toContain('Developer Hub'); + }); + + it('omits the default KIND/TYPE columns when explicit fields are requested', () => { + const output = formatEntityTable( + [{ kind: 'Component', metadata: { name: 'rhdh' } }], + ['metadata.name'], + ); + expect(output).toContain('NAME'); + expect(output).not.toContain('KIND'); + expect(output).not.toContain('TYPE'); + }); + + it('renders an empty cell when a requested field is missing on an entity', () => { + const output = formatEntityTable( + [{ metadata: { name: 'rhdh' } }], + ['metadata.name', 'metadata.description'], + ); + expect(output).toContain('rhdh'); + expect(output).toContain('DESCRIPTION'); + }); }); describe('formatSearchResults', () => { diff --git a/src/commands/intent-based-actions/format.ts b/src/commands/intent-based-actions/format.ts index f445f9a..1bf805d 100644 --- a/src/commands/intent-based-actions/format.ts +++ b/src/commands/intent-based-actions/format.ts @@ -27,11 +27,16 @@ export function writeOutput( export function formatEntityTable( entities: Array>, + fields?: string[], ): string { if (entities.length === 0) { return `${chalk.yellow('No entities found.')}\n`; } + if (fields && fields.length > 0) { + return formatFieldsTable(entities, fields); + } + 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); @@ -77,6 +82,53 @@ export function formatSearchResults( return lines.join('\n'); } +// Renders a table with one column per requested field (e.g. `--fields +// metadata.name,metadata.description`), so the human output reflects exactly +// what the user asked for instead of the fixed NAME/KIND/NAMESPACE/TYPE set. +function formatFieldsTable( + entities: Array>, + fields: string[], +): string { + const headers = fields.map(field => + (field.split('.').pop() ?? field).toUpperCase(), + ); + const rows = entities.map(entity => + fields.map(field => formatCell(getByPath(entity, field))), + ); + const widths = fields.map((_, col) => + Math.max(headers[col].length, ...rows.map(row => row[col].length)), + ); + + const renderRow = (cells: string[]): string => + cells + // The last column is left unpadded to avoid trailing whitespace. + .map((cell, col) => + col === cells.length - 1 ? cell : pad(cell, widths[col]), + ) + .join(' '); + + const lines = [ + renderRow(headers.map((h, col) => chalk.bold(pad(h, widths[col])))), + ...rows.map(renderRow), + ]; + return `${lines.join('\n')}\n`; +} + +function getByPath(obj: Record, path: string): unknown { + return path.split('.').reduce((acc, key) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[key]; + } + return undefined; + }, obj); +} + +function formatCell(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + function pad(str: string, width: number): string { return str.length >= width ? str : str + ' '.repeat(width - str.length); } diff --git a/src/commands/intent-based-actions/helpers.ts b/src/commands/intent-based-actions/helpers.ts index 7d11db1..c32e58b 100644 --- a/src/commands/intent-based-actions/helpers.ts +++ b/src/commands/intent-based-actions/helpers.ts @@ -14,12 +14,14 @@ 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`. + * When `fields` is given, the human table shows exactly those columns. */ export async function runEntityListAction( actionId: string, flags: ActionFlags, mode: OutputMode, suggestion?: string, + fields?: string[], ): Promise { try { if (mode === 'json') { @@ -27,7 +29,7 @@ export async function runEntityListAction( } else { const result = await execActionJson(actionId, flags); writeOutput(extractEntities(result), mode, data => - formatEntityTable(data as Array>), + formatEntityTable(data as Array>, fields), ); } } catch (error) { diff --git a/src/commands/intent-based-actions/kv.test.ts b/src/commands/intent-based-actions/kv.test.ts new file mode 100644 index 0000000..93c4d40 --- /dev/null +++ b/src/commands/intent-based-actions/kv.test.ts @@ -0,0 +1,126 @@ +import { collect, parseKeyValuePairs, parseList, resolveJsonInput } from './kv'; + +describe('collect', () => { + it('accumulates values across calls without mutating the previous array', () => { + const first = collect('a=1', []); + const second = collect('b=2', first); + + expect(first).toEqual(['a=1']); + expect(second).toEqual(['a=1', 'b=2']); + }); +}); + +describe('parseKeyValuePairs', () => { + it('returns undefined when given no pairs', () => { + expect(parseKeyValuePairs(undefined)).toBeUndefined(); + expect(parseKeyValuePairs([])).toBeUndefined(); + }); + + it('parses simple key=value pairs as strings', () => { + expect(parseKeyValuePairs(['githubHost=github.com', 'owner=foo'])).toEqual({ + githubHost: 'github.com', + owner: 'foo', + }); + }); + + it('coerces "true"/"false" to booleans', () => { + expect(parseKeyValuePairs(['verbose=true', 'dryRun=false'])).toEqual({ + verbose: true, + dryRun: false, + }); + }); + + it('coerces numeric-looking values to numbers', () => { + expect(parseKeyValuePairs(['limit=5', 'ratio=0.5'])).toEqual({ + limit: 5, + ratio: 0.5, + }); + }); + + it('keeps values with embedded "=" intact', () => { + expect(parseKeyValuePairs(['query=kind=Component'])).toEqual({ + query: 'kind=Component', + }); + }); + + it('keeps entity-ref-style values as strings even though they contain colons', () => { + expect(parseKeyValuePairs(['componentOwner=user:default/default'])).toEqual( + { componentOwner: 'user:default/default' }, + ); + }); + + it('throws for a pair missing "="', () => { + expect(() => parseKeyValuePairs(['no-equals-sign'])).toThrow( + /Invalid "key=value" pair/, + ); + }); + + it('throws for a pair with an empty key', () => { + expect(() => parseKeyValuePairs(['=value'])).toThrow( + /Invalid "key=value" pair/, + ); + }); +}); + +describe('parseList', () => { + it('returns undefined for undefined, empty, or comma-only input', () => { + expect(parseList(undefined)).toBeUndefined(); + expect(parseList('')).toBeUndefined(); + expect(parseList(' ')).toBeUndefined(); + expect(parseList(',,')).toBeUndefined(); + }); + + it('splits a comma-separated list', () => { + expect(parseList('metadata.name,metadata.description')).toEqual([ + 'metadata.name', + 'metadata.description', + ]); + }); + + it('trims whitespace around entries and drops empty ones', () => { + expect(parseList('techdocs, software-catalog ,')).toEqual([ + 'techdocs', + 'software-catalog', + ]); + }); +}); + +describe('resolveJsonInput', () => { + it('returns undefined when neither pairs nor json are given', () => { + expect(resolveJsonInput(undefined, undefined)).toBeUndefined(); + expect(resolveJsonInput([], undefined)).toBeUndefined(); + }); + + it('builds a JSON object from key=value pairs alone', () => { + expect(resolveJsonInput(['kind=Component'], undefined)).toBe( + JSON.stringify({ kind: 'Component' }), + ); + }); + + it('passes through raw JSON when no pairs are given', () => { + const json = JSON.stringify({ kind: 'Component' }); + expect(resolveJsonInput([], json)).toBe(json); + }); + + it('merges pairs into the raw JSON object, with pairs taking precedence', () => { + const json = JSON.stringify({ kind: 'Component', type: 'service' }); + const result = resolveJsonInput(['kind=API'], json); + + expect(JSON.parse(result!)).toEqual({ kind: 'API', type: 'service' }); + }); + + it('throws when the raw JSON is invalid', () => { + expect(() => resolveJsonInput(undefined, '{not valid json')).toThrow( + /Invalid JSON/, + ); + }); + + it('throws when the raw JSON is not an object', () => { + expect(() => resolveJsonInput(undefined, '"just a string"')).toThrow( + /JSON input must be an object/, + ); + expect(() => resolveJsonInput(undefined, '[1,2,3]')).toThrow( + /JSON input must be an object/, + ); + }); +}); diff --git a/src/commands/intent-based-actions/kv.ts b/src/commands/intent-based-actions/kv.ts new file mode 100644 index 0000000..37a929e --- /dev/null +++ b/src/commands/intent-based-actions/kv.ts @@ -0,0 +1,84 @@ +/** + * Commander accumulator for options that can be repeated, e.g. + * `--value name=my-app --value owner=user:default/jdoe`. + */ +export function collect(value: string, previous: string[]): string[] { + return previous.concat([value]); +} + +/** + * Parses repeated "key=value" strings (as gathered via `collect`) into a + * plain object. Values that look like numbers or booleans are coerced so + * common template/filter inputs don't have to be quoted as JSON strings. + */ +export function parseKeyValuePairs( + pairs: string[] | undefined, +): Record | undefined { + if (!pairs || pairs.length === 0) return undefined; + + const result: Record = {}; + for (const pair of pairs) { + const eqIndex = pair.indexOf('='); + if (eqIndex <= 0) { + throw new Error( + `Invalid "key=value" pair: "${pair}" (expected format: key=value)`, + ); + } + const key = pair.slice(0, eqIndex); + result[key] = coerceValue(pair.slice(eqIndex + 1)); + } + return result; +} + +/** + * Splits a comma-separated list flag (e.g. `--fields + * metadata.name,metadata.description`) into a trimmed array, dropping empty + * entries. Returns undefined when nothing usable is given, so callers can + * omit the underlying action flag entirely. + */ +export function parseList(value: string | undefined): string[] | undefined { + if (!value) return undefined; + const items = value + .split(',') + .map(item => item.trim()) + .filter(item => item.length > 0); + return items.length > 0 ? items : undefined; +} + +function coerceValue(raw: string): unknown { + if (raw === 'true') return true; + if (raw === 'false') return false; + if (raw !== '' && !Number.isNaN(Number(raw))) return Number(raw); + return raw; +} + +/** + * Combines repeatable "key=value" pairs with an optional raw JSON string + * into a single JSON string, so commands can accept either `--value + * key=value` (repeated) or a `--values`/`--filters` JSON blob, or both at + * once (pairs win on key conflicts). Returns undefined when neither is set. + */ +export function resolveJsonInput( + pairs: string[] | undefined, + json: string | undefined, +): string | undefined { + const fromPairs = parseKeyValuePairs(pairs); + + if (json) { + let base: unknown; + try { + base = JSON.parse(json); + } catch { + throw new Error(`Invalid JSON: "${json}"`); + } + if (typeof base !== 'object' || base === null || Array.isArray(base)) { + throw new Error('JSON input must be an object'); + } + return JSON.stringify({ + ...(base as Record), + ...fromPairs, + }); + } + + return fromPairs ? JSON.stringify(fromPairs) : undefined; +} diff --git a/src/commands/intent-based-actions/search.ts b/src/commands/intent-based-actions/search.ts index 05be25c..91e6bc9 100644 --- a/src/commands/intent-based-actions/search.ts +++ b/src/commands/intent-based-actions/search.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { runSearchAction } from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; +import { collect, parseList, resolveJsonInput } from './kv'; export function registerSearchCommands(program: Command) { program @@ -10,10 +11,19 @@ export function registerSearchCommands(program: Command) { 'Search across all content types (catalog, TechDocs, templates)', ) .option( - '--types ', - 'Document types (JSON array, e.g. \'["techdocs"]\')', + '--types ', + 'Comma-separated document types, e.g. --types techdocs,software-catalog', + ) + .option( + '--filter ', + 'Query filter, e.g. --filter kind=Component (repeatable)', + collect, + [] as string[], + ) + .option( + '--filters ', + 'Query filters as a JSON string (alternative to --filter)', ) - .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') @@ -28,17 +38,28 @@ export function registerSearchCommands(program: Command) { }); } + let filters: string | undefined; + try { + filters = resolveJsonInput(opts.filter, opts.filters); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli search "my service" --filter kind=Component', + }); + } + + const types = parseList(opts.types); + await runSearchAction( term, { - types: opts.types, - filters: opts.filters, + types: types ? JSON.stringify(types) : undefined, + filters, pageLimit: opts.pageLimit, pageCursor: opts.pageCursor, instance: opts.instance, }, mode, - 'rhdh-cli search "deployment guide"', + 'rhdh-cli search "deployment guide" --filter kind=Component', ); }); } diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index 89d5638..adcf5a8 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -3,6 +3,7 @@ import { Command } from 'commander'; import { runEntityListAction, runRawAction } from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; +import { collect, resolveJsonInput } from './kv'; export function registerTemplateCommands(program: Command) { const template = program @@ -34,8 +35,26 @@ export function registerTemplateCommands(program: Command) { '--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( + '--value ', + 'Template input value, e.g. --value name=my-app (repeatable)', + collect, + [] as string[], + ) + .option( + '--values ', + 'Template input values as a JSON string (alternative to --value)', + ) + .option( + '--secret ', + 'Template secret, e.g. --secret token=abc (repeatable)', + collect, + [] as string[], + ) + .option( + '--secrets ', + 'Template secrets as a JSON string (alternative to --secret)', + ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -44,14 +63,38 @@ export function registerTemplateCommands(program: Command) { 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"}\'', + 'rhdh-cli template execute --template-ref template:default/my-template --value name=my-app', }); } - if (!opts.values) { - handleCommandError(new Error('--values is required'), mode, { + let values: string | undefined; + try { + values = resolveJsonInput(opts.value, opts.values); + } catch (error) { + handleCommandError(error, mode, { suggestion: - 'rhdh-cli template execute --template-ref --values \'{"key":"value"}\'', + 'rhdh-cli template execute --template-ref --value key=value --value otherKey=otherValue', + }); + } + + if (!values) { + handleCommandError( + new Error('--value (or --values) is required'), + mode, + { + suggestion: + 'rhdh-cli template execute --template-ref --value key=value', + }, + ); + } + + let secrets: string | undefined; + try { + secrets = resolveJsonInput(opts.secret, opts.secrets); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template execute --template-ref --secret token=abc', }); } @@ -59,8 +102,8 @@ export function registerTemplateCommands(program: Command) { 'scaffolder:execute-template', { templateRef: opts.templateRef, - values: opts.values, - secrets: opts.secrets, + values, + secrets, instance: opts.instance, }, mode, @@ -72,7 +115,16 @@ export function registerTemplateCommands(program: Command) { .command('dry-run') .description('Validate a software template without making changes') .option('--template-file ', 'Path to a template YAML file (required)') - .option('--values ', 'Template input values (JSON string)') + .option( + '--value ', + 'Template input value, e.g. --value name=my-app (repeatable)', + collect, + [] as string[], + ) + .option( + '--values ', + 'Template input values as a JSON string (alternative to --value)', + ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -81,7 +133,17 @@ export function registerTemplateCommands(program: Command) { if (!opts.templateFile) { handleCommandError(new Error('--template-file is required'), mode, { suggestion: - 'rhdh-cli template dry-run --template-file ./template.yaml', + 'rhdh-cli template dry-run --template-file ./template.yaml --value name=my-app', + }); + } + + let values: string | undefined; + try { + values = resolveJsonInput(opts.value, opts.values); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template dry-run --template-file ./template.yaml --value key=value', }); } @@ -102,7 +164,7 @@ export function registerTemplateCommands(program: Command) { 'scaffolder:dry-run-template', { templateYaml, - values: opts.values, + values, instance: opts.instance, }, mode, From fd308a42ef27451300efa5b3a2c53a0127b91501 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Wed, 9 Sep 2026 12:55:22 -0400 Subject: [PATCH 09/18] address fullsend review comments, added the new clis in readme Signed-off-by: Stephanie --- AGENTS.md | 28 +++- CHANGELOG.md | 7 + README.md | 29 ++++ src/commands/intent-based-actions/api.ts | 4 +- src/commands/intent-based-actions/catalog.ts | 4 +- .../intent-based-actions/client.test.ts | 155 +++++++++--------- src/commands/intent-based-actions/client.ts | 107 ++++-------- .../intent-based-actions/docs.test.ts | 57 +++++++ src/commands/intent-based-actions/docs.ts | 22 ++- .../intent-based-actions/format.test.ts | 7 + src/commands/intent-based-actions/format.ts | 2 +- .../intent-based-actions/helpers.test.ts | 46 ++++-- src/commands/intent-based-actions/helpers.ts | 27 +-- .../intent-errors.test.ts | 8 + .../intent-based-actions/intent-errors.ts | 47 +++--- src/commands/intent-based-actions/kv.test.ts | 6 + src/commands/intent-based-actions/kv.ts | 2 +- src/commands/intent-based-actions/template.ts | 4 +- 18 files changed, 338 insertions(+), 224 deletions(-) create mode 100644 src/commands/intent-based-actions/docs.test.ts diff --git a/AGENTS.md b/AGENTS.md index ca3e1a7..670e90f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,21 +13,31 @@ ## Key Conventions - +- CLI command groups are co-located under `src/commands/intent-based-actions/`; + register new groups in that directory's `index.ts`. +- Keep human/JSON rendering in `format.ts`, Backstage action invocation in + `client.ts`, and command-level error presentation in `intent-errors.ts`. +- Add or update the co-located `*.test.ts` file when changing command behavior. ## Architecture - +- Intent-based commands invoke the bundled `@backstage/cli` through + `backstage-cli actions execute`; they do not call Backstage HTTP APIs + directly. +- `backstage-passthrough.ts` owns the lower-level `auth`, `actions`, and + `sources` commands, while the other files wrap action execution with + purpose-specific flags and output formatting. +- `docs list`, `docs get`, and `docs coverage` use the RHDH-only + `techdocs-mcp-extras` actions. `docs search` uses the standard + `search:query` action. ## Pattern References - +- New command group: `src/commands/intent-based-actions/catalog.ts` +- Shared list/search command behavior: `src/commands/intent-based-actions/helpers.ts` +- Human/JSON output formatting: `src/commands/intent-based-actions/format.ts` +- Structured CLI errors: `src/commands/intent-based-actions/intent-errors.ts` +- Repeatable `key=value` and JSON input parsing: `src/commands/intent-based-actions/kv.ts` ## PR Conventions diff --git a/CHANGELOG.md b/CHANGELOG.md index b5efea5..ce3a868 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to `@red-hat-developer-hub/cli` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Add intent-based `catalog`, `api`, `search`, `docs`, and `template` commands, + including human-readable and JSON output modes ([#156](https://github.com/redhat-developer/rhdh-cli/pull/156)). + ## 2.0.4 - 2026-08-27 ### Added diff --git a/README.md b/README.md index c2cbd32..04fb87d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,35 @@ or when executing from the project root you can also use: npx @red-hat-developer-hub/cli ``` +## Commands + +The CLI exposes the bundled Backstage authentication and action commands, as +well as higher-level intent-based commands: + +- `auth`: log in to, select, inspect, and manage authenticated Backstage instances. +- `actions`: list and execute actions, and manage action-discovery sources. +- `catalog`: list, get, validate, register, and unregister catalog entities. +- `api`: list API entities and retrieve their specifications. +- `search`: search catalog, TechDocs, and template content. +- `docs`: search TechDocs and, on RHDH instances, list entities, retrieve pages, + and view coverage. +- `template`: list, execute, and dry-run software templates. + +Examples: + +```bash +rhdh-cli auth login --backend-url https://backstage.example.com +rhdh-cli catalog list --kind Component +rhdh-cli search "deployment guide" --types techdocs +rhdh-cli template execute \ + --template-ref template:default/my-template \ + --value name=my-app +``` + +The `--secret` and `--secrets` template options are forwarded to +`backstage-cli actions execute` as action input flags. Avoid using them on +shared machines where other users can inspect process arguments. + ### Bumping Backstage Dependencies To update the `@backstage/*` dependencies to a new Backstage release: diff --git a/src/commands/intent-based-actions/api.ts b/src/commands/intent-based-actions/api.ts index c1e3ccc..7789d11 100644 --- a/src/commands/intent-based-actions/api.ts +++ b/src/commands/intent-based-actions/api.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { execAction } from './client'; -import { runEntityListAction } from './helpers'; +import { runEntityListAction, type ActionFlags } from './helpers'; import { parseOutputFlag, writeOutput } from './format'; import { handleCommandError } from './intent-errors'; @@ -22,7 +22,7 @@ export function registerApiCommands(program: Command) { const query: Record = { kind: 'API' }; if (opts.type) query['spec.type'] = opts.type; - const flags: Record = { + const flags: ActionFlags = { query: JSON.stringify(query), instance: opts.instance, limit: opts.limit, diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts index d1d161b..4e926ba 100644 --- a/src/commands/intent-based-actions/catalog.ts +++ b/src/commands/intent-based-actions/catalog.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { Command } from 'commander'; -import { runEntityListAction, runRawAction } from './helpers'; +import { runEntityListAction, runRawAction, type ActionFlags } from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; import { collect, parseList, resolveJsonInput } from './kv'; @@ -53,7 +53,7 @@ export function registerCatalogCommands(program: Command) { const fields = parseList(opts.fields); - const flags: Record = { + const flags: ActionFlags = { instance: opts.instance, limit: opts.limit, fields: fields ? JSON.stringify(fields) : undefined, diff --git a/src/commands/intent-based-actions/client.test.ts b/src/commands/intent-based-actions/client.test.ts index 2d50c55..5e2b823 100644 --- a/src/commands/intent-based-actions/client.test.ts +++ b/src/commands/intent-based-actions/client.test.ts @@ -1,29 +1,16 @@ import { EventEmitter } from 'node:events'; -import { writeFileSync } from 'node:fs'; -import { execSync, spawn } from 'node:child_process'; +import { execFileSync, spawn } from 'node:child_process'; import { execAction, execActionJson, execPassthrough } from './client'; jest.mock('node:child_process'); -const mockExecSync = execSync as jest.MockedFunction; +const mockExecFileSync = execFileSync as jest.MockedFunction< + typeof execFileSync +>; const mockSpawn = spawn as jest.MockedFunction; -/** - * The real execAction shells out to a resolved `backstage-cli` binary and - * redirects stdout/stderr to temp files. Since execSync itself is mocked, - * these helpers simulate what the real process would have written to those - * files, using the actual filesystem (only child_process is mocked here). - */ -function mockExecSyncWritingFiles( - handler: (outFile: string, errFile: string) => void, -) { - mockExecSync.mockImplementation((cmd: unknown) => { - const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); - if (!match) throw new Error(`Unexpected command shape: ${String(cmd)}`); - const [, outFile, errFile] = match; - handler(outFile, errFile); - return Buffer.from(''); - }); +function mockExecFileSyncReturning(output: string) { + mockExecFileSync.mockReturnValue(output as never); } describe('execAction', () => { @@ -31,10 +18,8 @@ describe('execAction', () => { jest.clearAllMocks(); }); - it('resolves with the contents written to the redirected stdout file', async () => { - mockExecSyncWritingFiles(outFile => { - writeFileSync(outFile, '{"ok":true}'); - }); + it('returns the Backstage CLI stdout', async () => { + mockExecFileSyncReturning('{"ok":true}'); const result = await execAction('catalog:query-catalog-entities', { instance: 'default', @@ -44,106 +29,124 @@ describe('execAction', () => { }); it('builds the command with the action id and unescaped simple flags', async () => { - mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + mockExecFileSyncReturning('{}'); await execAction('catalog:query-catalog-entities', { instance: 'default', limit: 5, }); - const cmd = String(mockExecSync.mock.calls[0][0]); - expect(cmd).toContain('actions execute catalog:query-catalog-entities'); - expect(cmd).toContain('--instance default'); - expect(cmd).toContain('--limit 5'); + const [command, args] = mockExecFileSync.mock.calls[0]; + expect(command).toBe(process.execPath); + expect(args).toEqual( + expect.arrayContaining([ + 'actions', + 'execute', + 'catalog:query-catalog-entities', + '--instance', + 'default', + '--limit', + '5', + ]), + ); }); - it('quotes and escapes flag values containing special characters', async () => { - mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + it('passes flag values containing special characters as literal arguments', async () => { + mockExecFileSyncReturning('{}'); await execAction('catalog:query-catalog-entities', { query: '{"kind":"Component"}', }); - const cmd = String(mockExecSync.mock.calls[0][0]); - expect(cmd).toContain(`--query '{"kind":"Component"}'`); + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).toEqual( + expect.arrayContaining(['--query', '{"kind":"Component"}']), + ); }); - it('escapes single quotes within flag values', async () => { - mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + it('passes action ids and flag names as literal arguments', async () => { + mockExecFileSyncReturning('{}'); - await execAction('catalog:validate-entity', { entity: "it's a test" }); + await execAction('actions:foo;echo pwned', { + 'bad;echo pwned': "it's a test", + }); - const cmd = String(mockExecSync.mock.calls[0][0]); - expect(cmd).toContain(`'it'\\''s a test'`); + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).toEqual( + expect.arrayContaining([ + 'actions:foo;echo pwned', + '--bad;echo pwned', + "it's a test", + ]), + ); }); it('adds boolean-true flags with no value', async () => { - mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + mockExecFileSyncReturning('{}'); await execAction('actions:list', { verbose: true }); - const cmd = String(mockExecSync.mock.calls[0][0]); - expect(cmd).toMatch(/--verbose(\s|$)/); - expect(cmd).not.toContain('--verbose true'); + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).toEqual(expect.arrayContaining(['--verbose'])); + expect(args).not.toEqual(expect.arrayContaining(['--verbose', 'true'])); }); it('omits flags that are false or undefined', async () => { - mockExecSyncWritingFiles(outFile => writeFileSync(outFile, '{}')); + mockExecFileSyncReturning('{}'); await execAction('actions:list', { verbose: false, instance: undefined }); - const cmd = String(mockExecSync.mock.calls[0][0]); - expect(cmd).not.toContain('--verbose'); - expect(cmd).not.toContain('--instance'); + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).not.toEqual(expect.arrayContaining(['--verbose'])); + expect(args).not.toEqual(expect.arrayContaining(['--instance'])); }); - it('rejects with the "Error:" line from stderr when the command fails', async () => { - mockExecSync.mockImplementation((cmd: unknown) => { - const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); - const [, , errFile] = match!; - writeFileSync(errFile, 'some noise\nError: Entity not found\nmore noise'); - throw new Error('Command failed'); + it('throws with the "Error:" line from stderr when the command fails', () => { + mockExecFileSync.mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from( + 'some noise\nError: Entity not found\nmore noise', + ); + throw error; }); - await expect( + expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), - ).rejects.toThrow('Entity not found'); + ).toThrow('Entity not found'); }); - it('falls back to the last stderr line when no "Error:" line is present', async () => { - mockExecSync.mockImplementation((cmd: unknown) => { - const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); - const [, , errFile] = match!; - writeFileSync(errFile, 'first line\nlast line'); - throw new Error('Command failed'); + it('falls back to the last stderr line when no "Error:" line is present', () => { + mockExecFileSync.mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from('first line\nlast line'); + throw error; }); - await expect( + expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), - ).rejects.toThrow('last line'); + ).toThrow('last line'); }); - it('rebrands "backstage-cli" as "rhdh-cli" in the thrown error message', async () => { - mockExecSync.mockImplementation((cmd: unknown) => { - const match = /> (\S+) 2>(\S+)$/.exec(String(cmd)); - const [, , errFile] = match!; - writeFileSync(errFile, 'Error: run backstage-cli auth login first'); - throw new Error('Command failed'); + it('rebrands "backstage-cli" as "rhdh-cli" in the thrown error message', () => { + mockExecFileSync.mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from('Error: run backstage-cli auth login first'); + throw error; }); - await expect( + expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), - ).rejects.toThrow('run rhdh-cli auth login first'); + ).toThrow('run rhdh-cli auth login first'); }); - it('rejects with a generic message when the command fails without stderr content', async () => { - mockExecSync.mockImplementation(() => { + it('throws a generic message when the command fails without stderr content', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Command failed'); }); - await expect( + expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), - ).rejects.toThrow('rhdh-cli command failed'); + ).toThrow('rhdh-cli command failed'); }); }); @@ -153,9 +156,7 @@ describe('execActionJson', () => { }); it('parses valid JSON output', async () => { - mockExecSyncWritingFiles(outFile => - writeFileSync(outFile, '{"kind":"Component"}'), - ); + mockExecFileSyncReturning('{"kind":"Component"}'); const result = await execActionJson('catalog:get-catalog-entity', { name: 'x', @@ -165,7 +166,7 @@ describe('execActionJson', () => { }); it('returns the raw string when the output is not valid JSON', async () => { - mockExecSyncWritingFiles(outFile => writeFileSync(outFile, 'not json')); + mockExecFileSyncReturning('not json'); const result = await execActionJson('catalog:get-catalog-entity', { name: 'x', diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index 41aa8c5..52e482c 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -1,18 +1,6 @@ -import { spawn, execSync } from 'node:child_process'; -import { - readFileSync, - unlinkSync, - mkdtempSync, - existsSync, - rmdirSync, -} from 'node:fs'; +import { spawn, execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; -import { tmpdir } from 'node:os'; - -function shellEscape(arg: string): string { - if (/^[a-zA-Z0-9._:/-]+$/.test(arg)) return arg; - return `'${arg.replace(/'/g, "'\\''")}'`; -} let resolvedCliBinary: string | undefined; @@ -98,85 +86,56 @@ export function execPassthrough(args: string[]): void { }); } -export async function execAction( +export function execAction( actionId: string, flags: Record, -): Promise { +): string { const bin = resolveBackstageCliBinary(); - const parts = [ - shellEscape(process.execPath), - shellEscape(bin), - 'actions', - 'execute', - actionId, - ]; + const args = ['actions', 'execute', actionId]; for (const [key, value] of Object.entries(flags)) { if (value === undefined || value === false) continue; - parts.push(`--${key}`); + args.push(`--${key}`); if (value !== true) { - parts.push(shellEscape(String(value))); + args.push(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 { + return execFileSync(process.execPath, [bin, ...args], { + encoding: 'utf-8', + timeout: 60_000, + maxBuffer: 50 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { 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(); - } + const stderrValue = + typeof error === 'object' && error !== null && 'stderr' in error + ? (error as { stderr?: string | Buffer }).stderr + : undefined; + let stderr = ''; + if (Buffer.isBuffer(stderrValue)) { + stderr = stderrValue.toString('utf-8').trim(); + } else if (typeof stderrValue === 'string') { + stderr = stderrValue.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(rebrand(errorMsg)); } } -export async function execActionJson( +export function execActionJson( actionId: string, flags: Record, -): Promise { - const raw = await execAction(actionId, flags); +): unknown { + const raw = execAction(actionId, flags); try { return JSON.parse(raw); } catch { diff --git a/src/commands/intent-based-actions/docs.test.ts b/src/commands/intent-based-actions/docs.test.ts new file mode 100644 index 0000000..4d18729 --- /dev/null +++ b/src/commands/intent-based-actions/docs.test.ts @@ -0,0 +1,57 @@ +import { Command } from 'commander'; +import { execActionJson } from './client'; +import { registerDocsCommands } from './docs'; +import { handleCommandError } from './intent-errors'; + +jest.mock('./client'); +jest.mock('./intent-errors'); + +const mockExecActionJson = execActionJson as jest.MockedFunction< + typeof execActionJson +>; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; + +describe('docs coverage', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + jest.clearAllMocks(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('uses N/A when coverage fields are missing from the response', async () => { + mockExecActionJson.mockReturnValue({ totalEntities: 10 }); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'coverage']); + + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain('Total entities: 10'); + expect(output).toContain('Documented entities: N/A'); + expect(output).toContain('Coverage: N/A'); + }); + + it('explains that RHDH is required when the coverage action is unavailable', async () => { + const error = new Error('Unknown action'); + mockExecActionJson.mockImplementation(() => { + throw error; + }); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'coverage']); + + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human', { + suggestion: 'Use an RHDH instance with techdocs-mcp-extras enabled.', + }); + }); +}); diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts index 52648e6..2e1648a 100644 --- a/src/commands/intent-based-actions/docs.ts +++ b/src/commands/intent-based-actions/docs.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { Command } from 'commander'; import { execAction, execActionJson } from './client'; -import { runSearchAction } from './helpers'; +import { runSearchAction, type ActionFlags } from './helpers'; import { parseOutputFlag, writeOutput, @@ -10,6 +10,9 @@ import { } from './format'; import { handleCommandError } from './intent-errors'; +const RHDH_ONLY_SUGGESTION = + 'Use an RHDH instance with techdocs-mcp-extras enabled.'; + export function registerDocsCommands(program: Command) { const docs = program .command('docs') @@ -65,7 +68,7 @@ export function registerDocsCommands(program: Command) { .action(async opts => { const mode = parseOutputFlag(opts.output); try { - const flags: Record = { + const flags: ActionFlags = { entityType: opts.entityType, owner: opts.owner, lifecycle: opts.lifecycle, @@ -93,7 +96,7 @@ export function registerDocsCommands(program: Command) { } } catch (error) { handleCommandError(error, mode, { - suggestion: 'rhdh-cli docs list', + suggestion: RHDH_ONLY_SUGGESTION, }); } }); @@ -119,7 +122,7 @@ export function registerDocsCommands(program: Command) { }); } try { - const flags: Record = { + const flags: ActionFlags = { entityRef: opts.entityRef, pagePath: opts.pagePath, instance: opts.instance, @@ -151,7 +154,7 @@ export function registerDocsCommands(program: Command) { } } catch (error) { handleCommandError(error, mode, { - suggestion: 'rhdh-cli docs list', + suggestion: RHDH_ONLY_SUGGESTION, }); } }); @@ -166,7 +169,7 @@ export function registerDocsCommands(program: Command) { .action(async opts => { const mode = parseOutputFlag(opts.output); try { - const flags: Record = { + const flags: ActionFlags = { instance: opts.instance, }; @@ -189,14 +192,15 @@ export function registerDocsCommands(program: Command) { result?.documentedEntities ?? result?.documented; const coverage = result?.coveragePercentage ?? result?.coverage; + const coverageLabel = coverage === undefined ? 'N/A' : `${coverage}%`; if (total !== undefined) { const lines = [ `${chalk.bold('TechDocs Coverage Report')}`, '', `Total entities: ${total}`, - `Documented entities: ${documented}`, - `Coverage: ${coverage}%`, + `Documented entities: ${documented ?? 'N/A'}`, + `Coverage: ${coverageLabel}`, ]; process.stdout.write(`${lines.join('\n')}\n`); } else { @@ -205,7 +209,7 @@ export function registerDocsCommands(program: Command) { } } catch (error) { handleCommandError(error, mode, { - suggestion: 'rhdh-cli docs coverage', + suggestion: RHDH_ONLY_SUGGESTION, }); } }); diff --git a/src/commands/intent-based-actions/format.test.ts b/src/commands/intent-based-actions/format.test.ts index 8d4821b..c0aabeb 100644 --- a/src/commands/intent-based-actions/format.test.ts +++ b/src/commands/intent-based-actions/format.test.ts @@ -161,6 +161,13 @@ describe('formatSearchResults', () => { expect(output).toContain('/flat'); }); + it('falls back to top-level text when document is absent', () => { + const output = formatSearchResults([ + { title: 'Flat result', text: 'Flat result text' }, + ]); + expect(output).toContain('Flat result text'); + }); + it('omits the location line when no location is present', () => { const output = formatSearchResults([{ title: 'No location' }]); expect(output).toContain('No location'); diff --git a/src/commands/intent-based-actions/format.ts b/src/commands/intent-based-actions/format.ts index 1bf805d..65e5841 100644 --- a/src/commands/intent-based-actions/format.ts +++ b/src/commands/intent-based-actions/format.ts @@ -70,7 +70,7 @@ export function formatSearchResults( 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 text = String(doc?.text ?? result.text ?? ''); const snippet = text.length > 120 ? `${text.slice(0, 120)}...` : text; lines.push(`${chalk.bold(title)}`); diff --git a/src/commands/intent-based-actions/helpers.test.ts b/src/commands/intent-based-actions/helpers.test.ts index 19354d0..1191b5d 100644 --- a/src/commands/intent-based-actions/helpers.test.ts +++ b/src/commands/intent-based-actions/helpers.test.ts @@ -28,7 +28,7 @@ describe('runEntityListAction', () => { }); it('writes the raw action output directly in json mode', async () => { - mockExecAction.mockResolvedValue('{"items":[]}'); + mockExecAction.mockReturnValue('{"items":[]}'); await runEntityListAction( 'catalog:query-catalog-entities', @@ -45,7 +45,7 @@ describe('runEntityListAction', () => { }); it('extracts entities and renders a table in human mode', async () => { - mockExecActionJson.mockResolvedValue({ + mockExecActionJson.mockReturnValue({ items: [{ kind: 'Component', metadata: { name: 'my-service' } }], }); @@ -67,7 +67,9 @@ describe('runEntityListAction', () => { it('routes errors from execAction to handleCommandError with the given suggestion', async () => { const error = new Error('boom'); - mockExecAction.mockRejectedValue(error); + mockExecAction.mockImplementation(() => { + throw error; + }); await runEntityListAction( 'catalog:query-catalog-entities', @@ -83,7 +85,9 @@ describe('runEntityListAction', () => { it('calls handleCommandError without a suggestion when none is given', async () => { const error = new Error('boom'); - mockExecActionJson.mockRejectedValue(error); + mockExecActionJson.mockImplementation(() => { + throw error; + }); await runEntityListAction('catalog:query-catalog-entities', {}, 'human'); @@ -110,7 +114,7 @@ describe('runRawAction', () => { }); it('writes the raw string directly in json mode', async () => { - mockExecAction.mockResolvedValue('{"foo":"bar"}'); + mockExecAction.mockReturnValue('{"foo":"bar"}'); await runRawAction('catalog:get-catalog-entity', { name: 'x' }, 'json'); @@ -118,7 +122,7 @@ describe('runRawAction', () => { }); it('pretty-prints the parsed JSON in human mode', async () => { - mockExecAction.mockResolvedValue('{"foo":"bar"}'); + mockExecAction.mockReturnValue('{"foo":"bar"}'); await runRawAction('catalog:get-catalog-entity', { name: 'x' }, 'human'); @@ -129,7 +133,9 @@ describe('runRawAction', () => { it('routes execAction errors to handleCommandError', async () => { const error = new Error('boom'); - mockExecAction.mockRejectedValue(error); + mockExecAction.mockImplementation(() => { + throw error; + }); await runRawAction( 'catalog:get-catalog-entity', @@ -144,7 +150,7 @@ describe('runRawAction', () => { }); it('routes JSON parse failures in human mode to handleCommandError', async () => { - mockExecAction.mockResolvedValue('not valid json'); + mockExecAction.mockReturnValue('not valid json'); await runRawAction('catalog:get-catalog-entity', {}, 'human'); @@ -168,7 +174,7 @@ describe('runSearchAction', () => { }); it('merges the term into the flags passed to the search:query action', async () => { - mockExecAction.mockResolvedValue('{}'); + mockExecAction.mockReturnValue('{}'); await runSearchAction('my service', { instance: 'default' }, 'json'); @@ -179,7 +185,7 @@ describe('runSearchAction', () => { }); it('writes the raw output directly in json mode', async () => { - mockExecAction.mockResolvedValue('{"results":[]}'); + mockExecAction.mockReturnValue('{"results":[]}'); await runSearchAction('term', {}, 'json'); @@ -187,7 +193,7 @@ describe('runSearchAction', () => { }); it('extracts result.results and renders snippets in human mode', async () => { - mockExecActionJson.mockResolvedValue({ + mockExecActionJson.mockReturnValue({ results: [{ document: { title: 'Doc title', text: 'some text' } }], }); @@ -199,7 +205,7 @@ describe('runSearchAction', () => { }); it('treats a bare array result as the results list directly', async () => { - mockExecActionJson.mockResolvedValue([ + mockExecActionJson.mockReturnValue([ { document: { title: 'Bare result' } }, ]); @@ -209,9 +215,23 @@ describe('runSearchAction', () => { expect(output).toContain('Bare result'); }); + it('falls back to JSON output for a non-array search result', async () => { + const result = { message: 'unexpected response shape' }; + mockExecActionJson.mockReturnValue(result); + + await runSearchAction('term', {}, 'human'); + + expect(writeSpy).toHaveBeenCalledWith( + `${JSON.stringify(result, null, 2)}\n`, + ); + expect(mockHandleCommandError).not.toHaveBeenCalled(); + }); + it('routes errors to handleCommandError with the given suggestion', async () => { const error = new Error('boom'); - mockExecActionJson.mockRejectedValue(error); + mockExecActionJson.mockImplementation(() => { + throw error; + }); await runSearchAction('term', {}, 'human', 'rhdh-cli search "term"'); diff --git a/src/commands/intent-based-actions/helpers.ts b/src/commands/intent-based-actions/helpers.ts index c32e58b..b57519a 100644 --- a/src/commands/intent-based-actions/helpers.ts +++ b/src/commands/intent-based-actions/helpers.ts @@ -8,7 +8,7 @@ import { } from './format'; import { handleCommandError } from './intent-errors'; -type ActionFlags = Record; +export type ActionFlags = Record; /** * Runs a catalog-style action that returns a list of entities, and prints @@ -77,16 +77,21 @@ export async function runSearchAction( 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>), - ); + const result = await execActionJson('search:query', flags); + let results: unknown; + if (Array.isArray(result)) { + results = result; + } else if (result && typeof result === 'object' && 'results' in result) { + results = result.results; + } + + if (Array.isArray(results)) { + writeOutput(results, mode, data => + formatSearchResults(data as Array>), + ); + } else { + writeOutput(result, mode); + } } } catch (error) { handleCommandError(error, mode, suggestion ? { suggestion } : undefined); diff --git a/src/commands/intent-based-actions/intent-errors.test.ts b/src/commands/intent-based-actions/intent-errors.test.ts index 1f9d790..af94faa 100644 --- a/src/commands/intent-based-actions/intent-errors.test.ts +++ b/src/commands/intent-based-actions/intent-errors.test.ts @@ -125,6 +125,14 @@ describe('handleCommandError', () => { expect(writtenError().reason).toMatch(/was not found/); }); + it('does not classify an incidental 404 in an entity name as not found', () => { + handleCommandError( + new Error('Entity service-404 failed validation'), + 'json', + ); + expect(writtenError().reason).toBe('Entity service-404 failed validation'); + }); + it('maps an ECONNREFUSED error to a connectivity reason', () => { handleCommandError(new Error('connect ECONNREFUSED 127.0.0.1'), 'json'); expect(writtenError().reason).toMatch(/Could not connect/); diff --git a/src/commands/intent-based-actions/intent-errors.ts b/src/commands/intent-based-actions/intent-errors.ts index c2420ef..68e2aa3 100644 --- a/src/commands/intent-based-actions/intent-errors.ts +++ b/src/commands/intent-based-actions/intent-errors.ts @@ -59,10 +59,10 @@ function extractReason(error: unknown): string { const fullMessage = collectMessages(error); - if (fullMessage.includes('401') || fullMessage.includes('Unauthorized')) { + if (hasStatusCode(fullMessage, 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')) { + if (hasStatusCode(fullMessage, 404) || fullMessage.includes('Not Found')) { return 'The requested resource was not found. Check the entity name, kind, or namespace.'; } if ( @@ -75,21 +75,16 @@ function extractReason(error: unknown): string { 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(); - } + const stderrMessage = extractStderrMessage(error); + if (stderrMessage) return stderrMessage; return extractPrimaryMessage(error); } +function hasStatusCode(message: string, statusCode: number): boolean { + return new RegExp(`(?:^|[\\s:=])${statusCode}(?:$|[\\s,.;])`).test(message); +} + function collectMessages(error: unknown): string { const parts: string[] = []; let current: unknown = error; @@ -103,16 +98,22 @@ function collectMessages(error: unknown): string { 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(); - } + const stderrMessage = extractStderrMessage(error); + if (stderrMessage) return stderrMessage; return error.message; } + +function extractStderrMessage(error: unknown): string | undefined { + const stderr = getStderr(error); + if (!stderr || !stderr.trim()) return undefined; + + 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(); +} diff --git a/src/commands/intent-based-actions/kv.test.ts b/src/commands/intent-based-actions/kv.test.ts index 93c4d40..f526ca5 100644 --- a/src/commands/intent-based-actions/kv.test.ts +++ b/src/commands/intent-based-actions/kv.test.ts @@ -37,6 +37,12 @@ describe('parseKeyValuePairs', () => { }); }); + it('preserves whitespace-only values as strings', () => { + expect(parseKeyValuePairs(['description= '])).toEqual({ + description: ' ', + }); + }); + it('keeps values with embedded "=" intact', () => { expect(parseKeyValuePairs(['query=kind=Component'])).toEqual({ query: 'kind=Component', diff --git a/src/commands/intent-based-actions/kv.ts b/src/commands/intent-based-actions/kv.ts index 37a929e..44a866f 100644 --- a/src/commands/intent-based-actions/kv.ts +++ b/src/commands/intent-based-actions/kv.ts @@ -48,7 +48,7 @@ export function parseList(value: string | undefined): string[] | undefined { function coerceValue(raw: string): unknown { if (raw === 'true') return true; if (raw === 'false') return false; - if (raw !== '' && !Number.isNaN(Number(raw))) return Number(raw); + if (raw.trim() !== '' && !Number.isNaN(Number(raw))) return Number(raw); return raw; } diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index adcf5a8..a697e30 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { Command } from 'commander'; -import { runEntityListAction, runRawAction } from './helpers'; +import { runEntityListAction, runRawAction, type ActionFlags } from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; import { collect, resolveJsonInput } from './kv'; @@ -19,7 +19,7 @@ export function registerTemplateCommands(program: Command) { .action(async opts => { const mode = parseOutputFlag(opts.output); - const flags: Record = { + const flags: ActionFlags = { query: JSON.stringify({ kind: 'Template' }), instance: opts.instance, limit: opts.limit, From 1c58c3ef01790f37d8dd62de990f324e985b83c3 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 10:13:10 -0400 Subject: [PATCH 10/18] address review comments Signed-off-by: Stephanie --- src/commands/intent-based-actions/api.ts | 20 +++++++- .../backstage-passthrough.ts | 46 ++++++++++++++++--- src/commands/intent-based-actions/catalog.ts | 8 +--- src/commands/intent-based-actions/docs.ts | 9 ++-- .../intent-based-actions/intent-errors.ts | 4 +- src/commands/intent-based-actions/template.ts | 21 ++++++++- 6 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/commands/intent-based-actions/api.ts b/src/commands/intent-based-actions/api.ts index 7789d11..fc88169 100644 --- a/src/commands/intent-based-actions/api.ts +++ b/src/commands/intent-based-actions/api.ts @@ -3,6 +3,7 @@ import { execAction } from './client'; import { runEntityListAction, type ActionFlags } from './helpers'; import { parseOutputFlag, writeOutput } from './format'; import { handleCommandError } from './intent-errors'; +import { collect, resolveJsonInput } from './kv'; export function registerApiCommands(program: Command) { const api = program @@ -13,6 +14,12 @@ export function registerApiCommands(program: Command) { .command('list') .description('List API entities in the catalog') .option('--type ', 'API type (openapi, asyncapi, graphql, grpc)') + .option( + '--filter ', + 'Query predicate, e.g. --filter spec.owner=team-a (repeatable)', + collect, + [] as string[], + ) .option('--limit ', 'Maximum results to return', parseInt) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') @@ -22,8 +29,19 @@ export function registerApiCommands(program: Command) { const query: Record = { kind: 'API' }; if (opts.type) query['spec.type'] = opts.type; + let predicate: string | undefined; + try { + predicate = resolveJsonInput(opts.filter); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli api list --type openapi --filter spec.owner=team-a', + }); + } + // --filter flags merge on top of the --type shortcut. + const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; + const flags: ActionFlags = { - query: JSON.stringify(query), + query: JSON.stringify(merged), instance: opts.instance, limit: opts.limit, }; diff --git a/src/commands/intent-based-actions/backstage-passthrough.ts b/src/commands/intent-based-actions/backstage-passthrough.ts index a1d34a5..2e75ca1 100644 --- a/src/commands/intent-based-actions/backstage-passthrough.ts +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -25,12 +25,46 @@ export function registerAuthCommands(program: Command) { .command('auth') .description('Manage authentication to Backstage/RHDH instances'); - registerPassthroughCommand( - auth, - 'login', - 'Log in to a Backstage/RHDH instance', - ['auth', 'login'], - ); + // Special handling for 'login' to support both --backend-url and --rhdh-url + auth + .command('login') + .description('Log in to a Backstage/RHDH instance') + .option('--backend-url ', 'Backend base URL') + .option('--rhdh-url ', 'RHDH instance URL (alias for --backend-url)') + .option('--instance ', 'Name for this instance') + .option('--no-browser', 'Do not open browser automatically') + .allowUnknownOption() + .action(function loginAction(this: Command, opts: Record) { + const args: string[] = ['auth', 'login']; + + // Translate --rhdh-url to --backend-url if provided + const backendUrl = opts.rhdhUrl || opts.backendUrl; + if (backendUrl) { + args.push('--backend-url', String(backendUrl)); + } + + // Forward other known options + if (opts.instance) { + args.push('--instance', String(opts.instance)); + } + if (opts.browser === false) { + args.push('--no-browser'); + } + + // Forward any unknown options + const knownOpts = ['backendUrl', 'rhdhUrl', 'instance', 'browser']; + for (const [key, value] of Object.entries(opts)) { + if (!knownOpts.includes(key) && value !== undefined) { + args.push(`--${key}`); + if (value !== true) { + args.push(String(value)); + } + } + } + + execPassthrough(args); + }); + registerPassthroughCommand( auth, 'logout', diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts index 4e926ba..b32244c 100644 --- a/src/commands/intent-based-actions/catalog.ts +++ b/src/commands/intent-based-actions/catalog.ts @@ -21,10 +21,6 @@ export function registerCatalogCommands(program: Command) { collect, [] as string[], ) - .option( - '--filters ', - 'Query predicate as a JSON string (alternative to --filter)', - ) .option('--limit ', 'Maximum results to return', parseInt) .option( '--fields ', @@ -41,14 +37,14 @@ export function registerCatalogCommands(program: Command) { let predicate: string | undefined; try { - predicate = resolveJsonInput(opts.filter, opts.filters); + predicate = resolveJsonInput(opts.filter); } catch (error) { handleCommandError(error, mode, { suggestion: 'rhdh-cli catalog list --kind Component --filter spec.lifecycle=production', }); } - // --filter/--filters merge on top of the --kind/--type shortcuts. + // --filter flags merge on top of the --kind/--type shortcuts. const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; const fields = parseList(opts.fields); diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts index 2e1648a..0895c39 100644 --- a/src/commands/intent-based-actions/docs.ts +++ b/src/commands/intent-based-actions/docs.ts @@ -53,26 +53,25 @@ export function registerDocsCommands(program: Command) { .description( 'List entities with TechDocs (RHDH only, via techdocs-mcp-extras)', ) - .option( - '--entity-type ', - 'Filter by entity kind (Component, API, etc.)', - ) + .option('--kind ', '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('--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); try { const flags: ActionFlags = { - entityType: opts.entityType, + entityType: opts.kind, owner: opts.owner, lifecycle: opts.lifecycle, tags: opts.tags, + limit: opts.limit, instance: opts.instance, }; diff --git a/src/commands/intent-based-actions/intent-errors.ts b/src/commands/intent-based-actions/intent-errors.ts index 68e2aa3..73e33c6 100644 --- a/src/commands/intent-based-actions/intent-errors.ts +++ b/src/commands/intent-based-actions/intent-errors.ts @@ -69,10 +69,10 @@ function extractReason(error: unknown): string { fullMessage.includes('ECONNREFUSED') || fullMessage.includes('fetch failed') ) { - return 'Could not connect to the Backstage instance. Check that the instance is running and reachable.'; + return 'Could not connect to the RHDH 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 '; + return 'No RHDH instance configured. Run: rhdh-cli auth login --rhdh-url '; } const stderrMessage = extractStderrMessage(error); diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index a697e30..948324b 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -13,14 +13,33 @@ export function registerTemplateCommands(program: Command) { template .command('list') .description('List available software templates') + .option( + '--filter ', + 'Query predicate, e.g. --filter metadata.tags=nodejs (repeatable)', + collect, + [] as string[], + ) .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: 'Template' }; + + let predicate: string | undefined; + try { + predicate = resolveJsonInput(opts.filter); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli template list --filter metadata.tags=nodejs', + }); + } + // --filter flags merge on top of the kind=Template query. + const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; + const flags: ActionFlags = { - query: JSON.stringify({ kind: 'Template' }), + query: JSON.stringify(merged), instance: opts.instance, limit: opts.limit, }; From 42a28f0c723f24b54cc947cf3e32daeb6f5c3f8b Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 10:31:56 -0400 Subject: [PATCH 11/18] update doc Signed-off-by: Stephanie --- README.md | 64 +- src/commands/intent-based-actions/CLI.md | 853 +++++++++++++++++++++++ 2 files changed, 898 insertions(+), 19 deletions(-) create mode 100644 src/commands/intent-based-actions/CLI.md diff --git a/README.md b/README.md index 3c4a6d7..5e285bb 100644 --- a/README.md +++ b/README.md @@ -70,32 +70,58 @@ npx @red-hat-developer-hub/cli ## Commands -The CLI exposes the bundled Backstage authentication and action commands, as -well as higher-level intent-based commands: +The CLI provides two categories of commands: -- `auth`: log in to, select, inspect, and manage authenticated Backstage instances. -- `actions`: list and execute actions, and manage action-discovery sources. -- `catalog`: list, get, validate, register, and unregister catalog entities. -- `api`: list API entities and retrieve their specifications. -- `search`: search catalog, TechDocs, and template content. -- `docs`: search TechDocs and, on RHDH instances, list entities, retrieve pages, - and view coverage. -- `template`: list, execute, and dry-run software templates. +### Plugin Development Commands -Examples: +- `plugin export`: Export a Backstage plugin as a dynamic plugin +- `plugin package`: Package dynamic plugins for distribution +- `plugin check-versions`: Verify plugin compatibility with RHDH versions + +### Intent-Based RHDH Interaction Commands + +High-level commands for interacting with RHDH instances: + +- `auth`: Log in to, select, inspect, and manage authenticated RHDH instances +- `actions`: List and execute actions, and manage action-discovery sources +- `catalog`: List, get, validate, register, and unregister catalog entities +- `api`: List API entities and retrieve their OpenAPI/AsyncAPI/GraphQL specifications +- `search`: Search catalog, TechDocs, and template content +- `docs`: Search TechDocs and, on RHDH instances with optional plugins, list entities, retrieve pages, and view coverage +- `template`: List, execute, and dry-run software templates + +**Quick Examples:** ```bash -rhdh-cli auth login --backend-url https://backstage.example.com -rhdh-cli catalog list --kind Component -rhdh-cli search "deployment guide" --types techdocs +# Authenticate with your RHDH instance +rhdh-cli auth login --rhdh-url https://rhdh.example.com + +# List production components +rhdh-cli catalog list --kind Component --filter spec.lifecycle=production + +# Search documentation +rhdh-cli search "deployment guide" --types '["techdocs"]' + +# Get API specification +rhdh-cli api get-spec --name my-api + +# Execute a template rhdh-cli template execute \ - --template-ref template:default/my-template \ - --value name=my-app + --template-ref template:default/nodejs-service \ + --value name=my-app \ + --value owner=team-platform ``` -The `--secret` and `--secrets` template options are forwarded to -`backstage-cli actions execute` as action input flags. Avoid using them on -shared machines where other users can inspect process arguments. +All commands support `--help` for detailed usage and `--output json` for machine-readable output. + +**📚 For complete documentation, setup guides, and examples, see:** +- **[Intent-Based CLI Documentation](src/commands/intent-based-actions/CLI.md)** - Complete guide for RHDH interaction commands + +### Optional TechDocs Features + +The `docs list`, `docs get`, and `docs coverage` commands require the optional **TechDocs MCP extras plugin** (`techdocs-mcp-extras`) to be installed on your RHDH instance. See the [CLI documentation](src/commands/intent-based-actions/CLI.md#rhdh-instance-configuration) for setup instructions. + +Commands `docs search` and all other commands work without this optional plugin. ### Bumping Backstage Dependencies diff --git a/src/commands/intent-based-actions/CLI.md b/src/commands/intent-based-actions/CLI.md new file mode 100644 index 0000000..557bbed --- /dev/null +++ b/src/commands/intent-based-actions/CLI.md @@ -0,0 +1,853 @@ +# RHDH CLI - Intent-Based Commands Documentation + +Complete guide for using `rhdh-cli` to interact with Red Hat Developer Hub instances. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [RHDH Instance Configuration](#rhdh-instance-configuration) +- [Authentication](#authentication) +- [Register Action Sources](#register-action-sources) +- [Commands Reference](#commands-reference) + - [Catalog Commands](#catalog-commands) + - [API Commands](#api-commands) + - [Search Commands](#search-commands) + - [TechDocs Commands](#techdocs-commands) + - [Template Commands](#template-commands) + - [Auth Commands](#auth-commands) + - [Actions Commands](#actions-commands) +- [Common Workflows](#common-workflows) +- [Output Modes](#output-modes) +- [Agent Integration](#agent-integration) +- [Troubleshooting](#troubleshooting) + +## Overview + +`rhdh-cli` provides intent-based commands for querying and managing RHDH catalog entities, API specifications, TechDocs content, and software templates. All commands support both human-readable output (default) and structured JSON output (`--output json`) for automation and AI agents. + +**Key Features:** +- **No local project context required** - Works standalone after authentication +- **Self-documenting** - `--help` provides complete command documentation +- **Agent-friendly** - JSON output mode with structured error messages +- **Multi-instance support** - Manage multiple RHDH environments + +## Installation + +```bash +# Via npx (recommended for one-off use) +npx @red-hat-developer-hub/cli + +# Via global install +npm install -g @red-hat-developer-hub/cli +rhdh-cli --help +``` + +## RHDH Instance Configuration + +Before using the CLI, your RHDH instance requires specific configuration. + +### 1. Enable the Auth Plugin + +The `rhdh-cli auth login` flow requires the `@backstage/plugin-auth` frontend plugin to serve the OAuth2 consent page. RHDH does not include this plugin by default. + +Install it as a dynamic plugin from `rhdh-plugin-export-overlays`: + +```yaml +# dynamic-plugins.yaml +plugins: + - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-auth:bs_1.49.4__0.1.6" + disabled: false + pluginConfig: + dynamicPlugins: + frontend: + backstage.plugin-auth: + dynamicRoutes: + - path: /oauth2/* + importName: Router +``` + +### 2. Enable OAuth2 Server Endpoints + +Add to `app-config.local.yaml`: + +```yaml +auth: + experimentalClientIdMetadataDocuments: + enabled: true + experimentalRefreshToken: + enabled: true +``` + +### 3. Enable TechDocs MCP Extras Plugin (Optional) + +To use TechDocs actions (`docs list`, `docs get`, `docs coverage`), install the TechDocs MCP extras plugin: + +```yaml +# dynamic-plugins.yaml +plugins: + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-techdocs-mcp-extras:bs_1.49.4__0.2.3 + disabled: false +``` + +**Note:** The `docs search` command works without this plugin. Only `docs list`, `docs get`, and `docs coverage` require it. + + +## Authentication + +Authenticate with your RHDH instance: + +```bash +rhdh-cli auth login --rhdh-url https://rhdh.example.com +``` + +You can also use `--backend-url` as an alias: + +```bash +rhdh-cli auth login --backend-url https://rhdh.example.com +``` + +This opens a browser for OAuth2 consent. After approval, credentials are stored locally. + +**Verify authentication:** + +```bash +rhdh-cli auth show +``` + +### Managing Multiple Instances + +```bash +# List all authenticated instances +rhdh-cli auth list + +# Select active instance (interactive) +rhdh-cli auth select + +# Login with instance name +rhdh-cli auth login --rhdh-url https://rhdh-prod.example.com --instance production + +# Use specific instance for a command +rhdh-cli catalog list --kind Component --instance production +``` + +## Register Action Sources + +The CLI maintains its own client-side source list. Register sources for the plugins available on your instance: + +```bash +rhdh-cli actions sources add catalog +rhdh-cli actions sources add scaffolder +rhdh-cli actions sources add search +rhdh-cli actions sources add auth +rhdh-cli actions sources add notifications +rhdh-cli actions sources add techdocs-mcp-extras # If plugin is installed +``` + +**Verify registration:** + +```bash +rhdh-cli actions sources list +``` + +**Important Notes:** +- Source registration is per-instance. Switching instances with `auth select` requires re-adding sources. +- Only add sources for plugins that have the actions backend endpoint. +- Adding a source for a plugin without it causes `actions list` to fail entirely. + +## Commands Reference + +All commands support: +- `--help` for detailed usage information +- `--output json` for machine-readable structured output +- `--instance ` to target a specific authenticated RHDH instance + +### Catalog Commands + +Query and manage the RHDH software catalog. + +#### `catalog list` + +List catalog entities with filtering and field selection. + +```bash +# List all components +rhdh-cli catalog list --kind Component + +# Filter by type and lifecycle +rhdh-cli catalog list --kind Component --type service --filter spec.lifecycle=production + +# Multiple filters +rhdh-cli catalog list \ + --kind Component \ + --type service \ + --filter spec.lifecycle=production \ + --filter spec.owner=team-platform + +# Limit results +rhdh-cli catalog list --kind Component --limit 50 + +# Select specific fields +rhdh-cli catalog list --kind Component --fields metadata.name,spec.owner,spec.lifecycle + +# JSON output for automation +rhdh-cli catalog list --kind Component --output json +``` + +**Options:** +- `--kind ` - Entity kind (Component, API, System, User, Group, etc.) +- `--type ` - Entity type (service, website, library, etc.) +- `--filter ` - Query predicate (repeatable), e.g., `--filter spec.lifecycle=production` +- `--limit ` - Maximum results to return +- `--fields ` - Comma-separated fields to include +- `--output ` - Output format: `human` (default) or `json` +- `--instance ` - RHDH instance name + +#### `catalog get` + +Get a specific catalog entity by name. + +```bash +# Get entity by name +rhdh-cli catalog get --name my-service --kind Component + +# Specify namespace (defaults to 'default') +rhdh-cli catalog get --name my-api --kind API --namespace production + +# JSON output +rhdh-cli catalog get --name my-service --kind Component --output json +``` + +**Options:** +- `--name ` - Entity name (required) +- `--kind ` - Entity kind +- `--namespace ` - Entity namespace (default: `default`) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `catalog validate` + +Validate entity YAML against the catalog schema. + +```bash +# Validate from file +rhdh-cli catalog validate --entity-file ./catalog-info.yaml + +# Validate inline YAML +rhdh-cli catalog validate --entity "apiVersion: backstage.io/v1alpha1..." + +# With location +rhdh-cli catalog validate --entity-file ./catalog-info.yaml --location https://github.com/org/repo +``` + +**Options:** +- `--entity ` - Entity YAML content +- `--entity-file ` - Path to entity YAML file +- `--location ` - Location to validate +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `catalog register` + +Register a catalog entity from a location URL. + +```bash +# Register from GitHub +rhdh-cli catalog register \ + --location-url https://github.com/myorg/myrepo/blob/main/catalog-info.yaml +``` + +**Options:** +- `--location-url ` - Location URL to register (required) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `catalog unregister` + +Unregister a catalog entity by location. + +```bash +# Unregister by location ID +rhdh-cli catalog unregister --location-id + +# Unregister by location URL +rhdh-cli catalog unregister --location-url https://github.com/org/repo/blob/main/catalog-info.yaml +``` + +**Options:** +- `--location-id ` - Location ID to unregister +- `--location-url ` - Location URL to unregister +- `--output ` - Output format +- `--instance ` - RHDH instance name + +### API Commands + +Query API entities and retrieve specifications. + +#### `api list` + +List API entities in the catalog. + +```bash +# List all APIs +rhdh-cli api list + +# Filter by type +rhdh-cli api list --type openapi +rhdh-cli api list --type graphql + +# Filter by owner +rhdh-cli api list --filter spec.owner=team-a + +# Combine filters +rhdh-cli api list --type openapi --filter spec.lifecycle=production + +# JSON output +rhdh-cli api list --output json +``` + +**Options:** +- `--type ` - API type (`openapi`, `asyncapi`, `graphql`, `grpc`) +- `--filter ` - Query predicate (repeatable) +- `--limit ` - Maximum results to return +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `api get-spec` + +Get the full API specification (OpenAPI, AsyncAPI, GraphQL, gRPC). + +```bash +# Get OpenAPI specification +rhdh-cli api get-spec --name my-api + +# Save to file +rhdh-cli api get-spec --name my-api > openapi.yaml + +# JSON output +rhdh-cli api get-spec --name my-api --output json +``` + +**Options:** +- `--name ` - API entity name (required) +- `--namespace ` - Entity namespace (default: `default`) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Output:** +- Human mode: Raw specification (YAML or schema) +- JSON mode: `{"name": "...", "type": "openapi", "definition": "..."}` + +### Search Commands + +Search across catalog, TechDocs, and templates. + +#### `search ` + +Search all content types. + +```bash +# Search everything +rhdh-cli search "deployment guide" + +# Search specific types +rhdh-cli search "authentication" --types '["techdocs"]' +rhdh-cli search "component" --types '["software-catalog"]' + +# Pagination +rhdh-cli search "query" --page-limit 20 --page-cursor + +# JSON output +rhdh-cli search "deployment" --output json +``` + +**Options:** +- `` - Search term (required) +- `--types ` - Content types to search (JSON array) +- `--page-limit ` - Results per page (default: 10) +- `--page-cursor ` - Pagination cursor +- `--output ` - Output format +- `--instance ` - RHDH instance name + +### TechDocs Commands + +Search and retrieve TechDocs content. + +#### `docs search ` + +Search TechDocs content (via upstream `search:query`). + +```bash +# Search TechDocs +rhdh-cli docs search "getting started" + +# With pagination +rhdh-cli docs search "API reference" --page-limit 20 + +# JSON output +rhdh-cli docs search "deployment" --output json +``` + +**Options:** +- `` - Search term (required) +- `--page-limit ` - Results per page (default: 10) +- `--page-cursor ` - Pagination cursor +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `docs list` + +List entities with TechDocs (RHDH only, requires `techdocs-mcp-extras` plugin). + +```bash +# List all entities with docs +rhdh-cli docs list + +# Filter by entity kind +rhdh-cli docs list --kind Component + +# Filter by owner and lifecycle +rhdh-cli docs list --owner team-platform --lifecycle production + +# Limit results +rhdh-cli docs list --limit 10 + +# JSON output +rhdh-cli docs list --output json +``` + +**Options:** +- `--kind ` - Filter by entity kind (Component, API, etc.) +- `--owner ` - Filter by owner +- `--lifecycle ` - Filter by lifecycle (production, experimental, etc.) +- `--tags ` - Filter by tags (comma-separated) +- `--limit ` - Maximum results to return +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. + +#### `docs get` + +Get TechDocs page content for an entity (RHDH only, requires `techdocs-mcp-extras` plugin). + +```bash +# Get index page +rhdh-cli docs get --entity-ref component:default/my-service + +# Get specific page +rhdh-cli docs get \ + --entity-ref component:default/my-service \ + --page-path architecture/overview + +# Save to file +rhdh-cli docs get --entity-ref component:default/my-service > README.md + +# JSON output +rhdh-cli docs get --entity-ref component:default/my-service --output json +``` + +**Options:** +- `--entity-ref ` - Entity reference, e.g., `component:default/my-service` (required) +- `--page-path ` - Specific doc page path (default: index) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Output:** +- Human mode: Plain text content (HTML stripped) +- JSON mode: `{"entityRef": "...", "content": "...", "pageTitle": "...", "metadata": {...}}` + +**Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. + +#### `docs coverage` + +Show TechDocs coverage report (RHDH only, requires `techdocs-mcp-extras` plugin). + +```bash +# Get coverage report +rhdh-cli docs coverage + +# JSON output +rhdh-cli docs coverage --output json +``` + +**Options:** +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Output:** +``` +TechDocs Coverage Report + +Total entities: 150 +Documented entities: 120 +Coverage: 80% +``` + +**Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. + +### Template Commands + +List and execute software templates. + +#### `template list` + +List available software templates. + +```bash +# List all templates +rhdh-cli template list + +# Filter by tags +rhdh-cli template list --filter metadata.tags=nodejs + +# Filter by owner +rhdh-cli template list --filter spec.owner=team-platform + +# Limit results +rhdh-cli template list --limit 20 + +# JSON output +rhdh-cli template list --output json +``` + +**Options:** +- `--filter ` - Query predicate (repeatable) +- `--limit ` - Maximum results to return +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `template execute` + +Execute a software template. + +```bash +# Execute with key-value pairs +rhdh-cli template execute \ + --template-ref template:default/nodejs-service \ + --value name=my-app \ + --value owner=team-a + +# Execute with multiple values +rhdh-cli template execute \ + --template-ref template:default/react-app \ + --value name=my-app \ + --value owner=team-frontend \ + --value port=3001 + +# With secrets (use with caution - visible in process list) +rhdh-cli template execute \ + --template-ref template:default/my-template \ + --value name=my-app \ + --secret token=abc123 + +# JSON output +rhdh-cli template execute \ + --template-ref template:default/my-template \ + --value name=my-app \ + --output json +``` + +**Options:** +- `--template-ref ` - Template entity ref, e.g., `template:default/my-template` (required) +- `--value ` - Template input value (repeatable) +- `--secret ` - Template secret (repeatable) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Security Warning:** `--secret` flags are visible in process lists on shared systems. Use with caution. + +#### `template dry-run` + +Validate a software template without making changes. + +```bash +# Dry-run from file +rhdh-cli template dry-run \ + --template-file ./template.yaml \ + --value name=test-app \ + --value owner=test-team + +# JSON output +rhdh-cli template dry-run \ + --template-file ./template.yaml \ + --value name=test-app \ + --output json +``` + +**Options:** +- `--template-file ` - Path to template YAML file (required) +- `--value ` - Template input value (repeatable) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +### Auth Commands + +Manage authenticated RHDH instances. + +```bash +# Login to RHDH instance +rhdh-cli auth login --rhdh-url https://rhdh.example.com + +# Alternative: use --backend-url +rhdh-cli auth login --backend-url https://rhdh.example.com + +# Login with instance name +rhdh-cli auth login --rhdh-url https://rhdh.example.com --instance production + +# List authenticated instances +rhdh-cli auth list + +# Select active instance +rhdh-cli auth select + +# Show current instance details +rhdh-cli auth show + +# Print access token +rhdh-cli auth print-token + +# Logout +rhdh-cli auth logout +``` + +**Note:** Both `--rhdh-url` and `--backend-url` flags are supported for `login`. + +### Actions Commands + +List and execute RHDH actions directly. + +```bash +# List available actions on the RHDH instance +rhdh-cli actions list + +# Execute an action directly +rhdh-cli actions execute catalog:query-catalog-entities --query '{"kind":"Component"}' + +# Manage action sources +rhdh-cli actions sources list +rhdh-cli actions sources add +rhdh-cli actions sources remove +``` + +**Note:** Intent-based commands (`catalog`, `api`, `docs`, `template`) are recommended over direct action execution for better usability and error messages. + +## Common Workflows + +### Workflow 1: Find All Production Services + +```bash +rhdh-cli catalog list \ + --kind Component \ + --type service \ + --filter spec.lifecycle=production \ + --fields metadata.name,spec.owner,metadata.description \ + --output json +``` + +### Workflow 2: Get API Specification for Integration + +```bash +# 1. Find the API +rhdh-cli api list --type openapi --output json + +# 2. Get the OpenAPI spec +rhdh-cli api get-spec --name my-api --output json +``` + +### Workflow 3: Search Documentation and Retrieve Content + +```bash +# 1. Search for relevant docs +rhdh-cli docs search "deployment" --output json + +# 2. Get specific doc page (RHDH only) +rhdh-cli docs get --entity-ref component:default/my-service --page-path deployment +``` + +### Workflow 4: Validate and Register New Entity + +```bash +# 1. Validate locally +rhdh-cli catalog validate --entity-file ./catalog-info.yaml --output json + +# 2. Register if valid +rhdh-cli catalog register \ + --location-url https://github.com/org/repo/blob/main/catalog-info.yaml +``` + +### Workflow 5: Create New Service from Template + +```bash +# 1. Browse available templates +rhdh-cli template list + +# 2. Execute template +rhdh-cli template execute \ + --template-ref template:default/nodejs-microservice \ + --value name=payment-service \ + --value description="Payment processing service" \ + --value owner=team-payments \ + --value port=8080 +``` + +## Output Modes + +All commands support two output modes: + +### Human-Readable Mode (Default) + +Formatted for CLI use with colors, tables, and readable text. + +```bash +rhdh-cli catalog list --kind Component +``` + +### JSON Mode + +Structured output for automation, scripting, and AI agents. + +```bash +rhdh-cli catalog list --kind Component --output json +``` + +**JSON Error Format:** +```json +{ + "error": "Error message", + "reason": "Detailed explanation", + "suggestion": "rhdh-cli catalog list --kind Component" +} +``` + +**Exit Codes:** +- `0` - Success +- Non-zero - Error occurred (check stderr and JSON error object) + +## Agent Integration + +### Best Practices for AI Agents + +1. **Always use JSON output:** `--output json` for all commands +2. **Parse errors from JSON:** Check for `error` field in response +3. **Use specific filters:** Leverage `--kind`, `--type`, `--filter` to reduce result size +4. **Respect pagination:** Use `--limit` and cursor-based pagination for large result sets +5. **Handle field selection:** Use `--fields` to retrieve only needed data +6. **Instance-specific queries:** Use `--instance` when working with multiple RHDH environments +7. **Error recovery:** Parse `suggestion` field from error responses for corrective actions + +### Discovery via --help + +All commands support `--help` for complete documentation: + +```bash +rhdh-cli --help +rhdh-cli catalog --help +rhdh-cli catalog list --help +rhdh-cli api get-spec --help +``` + +The `--help` output is the complete protocol contract — agents can operate from help text alone without external documentation. + +### Example Agent Workflow + +```bash +# 1. Authenticate +rhdh-cli auth login --rhdh-url https://rhdh.example.com + +# 2. Register action sources +rhdh-cli actions sources add catalog +rhdh-cli actions sources add scaffolder + +# 3. Query entities +rhdh-cli catalog list \ + --kind Component \ + --filter spec.lifecycle=production \ + --fields metadata.name,spec.owner \ + --output json | jq '.entities[].metadata.name' + +# 4. Get API spec +rhdh-cli api get-spec --name my-api --output json | jq '.definition' + +# 5. Execute template +rhdh-cli template execute \ + --template-ref template:default/service \ + --value name=new-service \ + --value owner=team-a \ + --output json +``` + +## Troubleshooting + +### Authentication Errors + +```bash +# Check current auth status +rhdh-cli auth show + +# Re-authenticate +rhdh-cli auth login --rhdh-url https://rhdh.example.com + +# If using wrong RHDH instance, select the right one +rhdh-cli auth select +``` + +### Action Not Found + +If a command reports an action is not available: +- Ensure your RHDH instance version is 1.10 or newer +- Verify required plugins are installed on the RHDH instance (e.g., `techdocs-mcp-extras` for `docs list/get/coverage`) +- Check that action sources are registered: `rhdh-cli actions sources list` +- Use `rhdh-cli actions list` to see all available actions on the connected RHDH instance + +### TechDocs Commands Failing + +If `docs list`, `docs get`, or `docs coverage` fail: +- These commands require the `techdocs-mcp-extras` plugin on your RHDH instance +- Verify the plugin is installed and enabled on the RHDH instance +- Check server-side configuration in `app-config.local.yaml` +- Verify client-side source registration: `rhdh-cli actions sources list` should show `techdocs-mcp-extras` +- Use `docs search` as an alternative, which works with all RHDH instances + +### Output Parsing Issues + +If JSON output is malformed: +- Check for errors on stderr +- Verify exit code (0 = success) +- Ensure you included `--output json` flag + +### Large Result Sets + +For catalogs with many entities: + +```bash +# Use limits +rhdh-cli catalog list --kind Component --limit 100 + +# Use specific filters +rhdh-cli catalog list \ + --kind Component \ + --type service \ + --filter spec.lifecycle=production + +# Select only needed fields +rhdh-cli catalog list \ + --kind Component \ + --fields metadata.name,spec.owner +``` + +### Multiple Instance Confusion + +```bash +# Check which instance is active +rhdh-cli auth show + +# List all instances +rhdh-cli auth list + +# Switch instance +rhdh-cli auth select + +# Or use --instance flag for one-off commands +rhdh-cli catalog list --kind Component --instance production +``` From dcd84ff281c0e414db462c76a34054d442e2ade4 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 10:58:27 -0400 Subject: [PATCH 12/18] template value flag should be optional Signed-off-by: Stephanie --- src/commands/intent-based-actions/template.ts | 31 +++---------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index 948324b..4808777 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -60,20 +60,12 @@ export function registerTemplateCommands(program: Command) { collect, [] as string[], ) - .option( - '--values ', - 'Template input values as a JSON string (alternative to --value)', - ) .option( '--secret ', 'Template secret, e.g. --secret token=abc (repeatable)', collect, [] as string[], ) - .option( - '--secrets ', - 'Template secrets as a JSON string (alternative to --secret)', - ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -86,9 +78,10 @@ export function registerTemplateCommands(program: Command) { }); } + // Values are optional - some templates accept no parameters let values: string | undefined; try { - values = resolveJsonInput(opts.value, opts.values); + values = resolveJsonInput(opts.value); } catch (error) { handleCommandError(error, mode, { suggestion: @@ -96,20 +89,9 @@ export function registerTemplateCommands(program: Command) { }); } - if (!values) { - handleCommandError( - new Error('--value (or --values) is required'), - mode, - { - suggestion: - 'rhdh-cli template execute --template-ref --value key=value', - }, - ); - } - let secrets: string | undefined; try { - secrets = resolveJsonInput(opts.secret, opts.secrets); + secrets = resolveJsonInput(opts.secret); } catch (error) { handleCommandError(error, mode, { suggestion: @@ -140,10 +122,6 @@ export function registerTemplateCommands(program: Command) { collect, [] as string[], ) - .option( - '--values ', - 'Template input values as a JSON string (alternative to --value)', - ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -156,9 +134,10 @@ export function registerTemplateCommands(program: Command) { }); } + // Values are optional - some templates accept no parameters let values: string | undefined; try { - values = resolveJsonInput(opts.value, opts.values); + values = resolveJsonInput(opts.value); } catch (error) { handleCommandError(error, mode, { suggestion: From 06fc2fb218ae900b460292b4d124ecdbcc23b102 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 11:00:37 -0400 Subject: [PATCH 13/18] add cli mapping table Signed-off-by: Stephanie --- src/commands/intent-based-actions/CLI.md | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/commands/intent-based-actions/CLI.md b/src/commands/intent-based-actions/CLI.md index 557bbed..54a35e0 100644 --- a/src/commands/intent-based-actions/CLI.md +++ b/src/commands/intent-based-actions/CLI.md @@ -9,6 +9,7 @@ Complete guide for using `rhdh-cli` to interact with Red Hat Developer Hub insta - [RHDH Instance Configuration](#rhdh-instance-configuration) - [Authentication](#authentication) - [Register Action Sources](#register-action-sources) +- [Command to Action Mapping](#command-to-action-mapping) - [Commands Reference](#commands-reference) - [Catalog Commands](#catalog-commands) - [API Commands](#api-commands) @@ -155,6 +156,32 @@ rhdh-cli actions sources list - Only add sources for plugins that have the actions backend endpoint. - Adding a source for a plugin without it causes `actions list` to fail entirely. +## Command to Action Mapping + +The following table shows how intent-based CLI commands map to underlying Backstage actions: + +| Command | Action ID | Notes | +|---------|-----------|-------| +| `catalog list` | `catalog:query-catalog-entities` | Supports `--kind`, `--type`, `--filter` (repeatable), `--limit`, `--fields` | +| `catalog get` | `catalog:get-catalog-entity` | Requires `--name`, optional `--kind`, `--namespace` | +| `catalog validate` | `catalog:validate-entity` | Accepts `--entity` or `--entity-file` | +| `catalog register` | `catalog:register-entity` | Requires `--location-url` | +| `catalog unregister` | `catalog:unregister-entity` | Requires `--location-id` or `--location-url` | +| `api list` | `catalog:query-catalog-entities` | Hardcoded `kind=API`, supports `--type`, `--filter` (repeatable) | +| `api get-spec` | `catalog:get-catalog-entity` | Extracts `spec.definition` from API entity | +| `search ` | `search:query` | Supports `--types`, `--page-limit`, `--page-cursor` | +| `docs search ` | `search:query` | Hardcoded `types=["techdocs"]` | +| `docs list` | `techdocs-mcp-extras:fetch-techdocs` | RHDH only, requires plugin; supports `--kind`, `--owner`, `--lifecycle`, `--tags`, `--limit` | +| `docs get` | `techdocs-mcp-extras:retrieve-techdocs-content` | RHDH only, requires plugin | +| `docs coverage` | `techdocs-mcp-extras:analyze-techdocs-coverage` | RHDH only, requires plugin | +| `template list` | `catalog:query-catalog-entities` | Hardcoded `kind=Template`, supports `--filter` (repeatable) | +| `template execute` | `scaffolder:execute-template` | Requires `--template-ref`; `--value` (repeatable) and `--secret` (repeatable) are optional | +| `template dry-run` | `scaffolder:dry-run-template` | Requires `--template-file`; `--value` (repeatable) is optional; reads YAML from disk | +| `auth *` | Pass-through to `backstage-cli auth *` | Output rebranded as `rhdh-cli` | +| `actions *` | Pass-through to `backstage-cli actions *` | Output rebranded as `rhdh-cli` | + +**Note:** Commands marked "RHDH only" require the `techdocs-mcp-extras` plugin to be installed on your RHDH instance. See [RHDH Instance Configuration](#rhdh-instance-configuration) for setup instructions. + ## Commands Reference All commands support: From 9a96c02b0b6cf3ed8616f18fa75423d987ced2f6 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 11:10:55 -0400 Subject: [PATCH 14/18] fix ci failure Signed-off-by: Stephanie --- README.md | 1 + src/commands/intent-based-actions/CLI.md | 67 +++++++++++++------ src/commands/intent-based-actions/api.ts | 3 +- .../intent-based-actions/client.test.ts | 28 +++----- .../intent-based-actions/docs.test.ts | 8 ++- .../intent-errors.test.ts | 2 +- src/commands/intent-based-actions/kv.test.ts | 2 +- src/commands/intent-based-actions/kv.ts | 2 +- 8 files changed, 68 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 5e285bb..ec01d19 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ rhdh-cli template execute \ All commands support `--help` for detailed usage and `--output json` for machine-readable output. **📚 For complete documentation, setup guides, and examples, see:** + - **[Intent-Based CLI Documentation](src/commands/intent-based-actions/CLI.md)** - Complete guide for RHDH interaction commands ### Optional TechDocs Features diff --git a/src/commands/intent-based-actions/CLI.md b/src/commands/intent-based-actions/CLI.md index 54a35e0..9973417 100644 --- a/src/commands/intent-based-actions/CLI.md +++ b/src/commands/intent-based-actions/CLI.md @@ -28,6 +28,7 @@ Complete guide for using `rhdh-cli` to interact with Red Hat Developer Hub insta `rhdh-cli` provides intent-based commands for querying and managing RHDH catalog entities, API specifications, TechDocs content, and software templates. All commands support both human-readable output (default) and structured JSON output (`--output json`) for automation and AI agents. **Key Features:** + - **No local project context required** - Works standalone after authentication - **Self-documenting** - `--help` provides complete command documentation - **Agent-friendly** - JSON output mode with structured error messages @@ -57,7 +58,7 @@ Install it as a dynamic plugin from `rhdh-plugin-export-overlays`: ```yaml # dynamic-plugins.yaml plugins: - - package: "oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-auth:bs_1.49.4__0.1.6" + - package: 'oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-auth:bs_1.49.4__0.1.6' disabled: false pluginConfig: dynamicPlugins: @@ -93,7 +94,6 @@ plugins: **Note:** The `docs search` command works without this plugin. Only `docs list`, `docs get`, and `docs coverage` require it. - ## Authentication Authenticate with your RHDH instance: @@ -152,6 +152,7 @@ rhdh-cli actions sources list ``` **Important Notes:** + - Source registration is per-instance. Switching instances with `auth select` requires re-adding sources. - Only add sources for plugins that have the actions backend endpoint. - Adding a source for a plugin without it causes `actions list` to fail entirely. @@ -160,31 +161,32 @@ rhdh-cli actions sources list The following table shows how intent-based CLI commands map to underlying Backstage actions: -| Command | Action ID | Notes | -|---------|-----------|-------| -| `catalog list` | `catalog:query-catalog-entities` | Supports `--kind`, `--type`, `--filter` (repeatable), `--limit`, `--fields` | -| `catalog get` | `catalog:get-catalog-entity` | Requires `--name`, optional `--kind`, `--namespace` | -| `catalog validate` | `catalog:validate-entity` | Accepts `--entity` or `--entity-file` | -| `catalog register` | `catalog:register-entity` | Requires `--location-url` | -| `catalog unregister` | `catalog:unregister-entity` | Requires `--location-id` or `--location-url` | -| `api list` | `catalog:query-catalog-entities` | Hardcoded `kind=API`, supports `--type`, `--filter` (repeatable) | -| `api get-spec` | `catalog:get-catalog-entity` | Extracts `spec.definition` from API entity | -| `search ` | `search:query` | Supports `--types`, `--page-limit`, `--page-cursor` | -| `docs search ` | `search:query` | Hardcoded `types=["techdocs"]` | -| `docs list` | `techdocs-mcp-extras:fetch-techdocs` | RHDH only, requires plugin; supports `--kind`, `--owner`, `--lifecycle`, `--tags`, `--limit` | -| `docs get` | `techdocs-mcp-extras:retrieve-techdocs-content` | RHDH only, requires plugin | -| `docs coverage` | `techdocs-mcp-extras:analyze-techdocs-coverage` | RHDH only, requires plugin | -| `template list` | `catalog:query-catalog-entities` | Hardcoded `kind=Template`, supports `--filter` (repeatable) | -| `template execute` | `scaffolder:execute-template` | Requires `--template-ref`; `--value` (repeatable) and `--secret` (repeatable) are optional | -| `template dry-run` | `scaffolder:dry-run-template` | Requires `--template-file`; `--value` (repeatable) is optional; reads YAML from disk | -| `auth *` | Pass-through to `backstage-cli auth *` | Output rebranded as `rhdh-cli` | -| `actions *` | Pass-through to `backstage-cli actions *` | Output rebranded as `rhdh-cli` | +| Command | Action ID | Notes | +| -------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `catalog list` | `catalog:query-catalog-entities` | Supports `--kind`, `--type`, `--filter` (repeatable), `--limit`, `--fields` | +| `catalog get` | `catalog:get-catalog-entity` | Requires `--name`, optional `--kind`, `--namespace` | +| `catalog validate` | `catalog:validate-entity` | Accepts `--entity` or `--entity-file` | +| `catalog register` | `catalog:register-entity` | Requires `--location-url` | +| `catalog unregister` | `catalog:unregister-entity` | Requires `--location-id` or `--location-url` | +| `api list` | `catalog:query-catalog-entities` | Hardcoded `kind=API`, supports `--type`, `--filter` (repeatable) | +| `api get-spec` | `catalog:get-catalog-entity` | Extracts `spec.definition` from API entity | +| `search ` | `search:query` | Supports `--types`, `--page-limit`, `--page-cursor` | +| `docs search ` | `search:query` | Hardcoded `types=["techdocs"]` | +| `docs list` | `techdocs-mcp-extras:fetch-techdocs` | RHDH only, requires plugin; supports `--kind`, `--owner`, `--lifecycle`, `--tags`, `--limit` | +| `docs get` | `techdocs-mcp-extras:retrieve-techdocs-content` | RHDH only, requires plugin | +| `docs coverage` | `techdocs-mcp-extras:analyze-techdocs-coverage` | RHDH only, requires plugin | +| `template list` | `catalog:query-catalog-entities` | Hardcoded `kind=Template`, supports `--filter` (repeatable) | +| `template execute` | `scaffolder:execute-template` | Requires `--template-ref`; `--value` (repeatable) and `--secret` (repeatable) are optional | +| `template dry-run` | `scaffolder:dry-run-template` | Requires `--template-file`; `--value` (repeatable) is optional; reads YAML from disk | +| `auth *` | Pass-through to `backstage-cli auth *` | Output rebranded as `rhdh-cli` | +| `actions *` | Pass-through to `backstage-cli actions *` | Output rebranded as `rhdh-cli` | **Note:** Commands marked "RHDH only" require the `techdocs-mcp-extras` plugin to be installed on your RHDH instance. See [RHDH Instance Configuration](#rhdh-instance-configuration) for setup instructions. ## Commands Reference All commands support: + - `--help` for detailed usage information - `--output json` for machine-readable structured output - `--instance ` to target a specific authenticated RHDH instance @@ -222,6 +224,7 @@ rhdh-cli catalog list --kind Component --output json ``` **Options:** + - `--kind ` - Entity kind (Component, API, System, User, Group, etc.) - `--type ` - Entity type (service, website, library, etc.) - `--filter ` - Query predicate (repeatable), e.g., `--filter spec.lifecycle=production` @@ -246,6 +249,7 @@ rhdh-cli catalog get --name my-service --kind Component --output json ``` **Options:** + - `--name ` - Entity name (required) - `--kind ` - Entity kind - `--namespace ` - Entity namespace (default: `default`) @@ -268,6 +272,7 @@ rhdh-cli catalog validate --entity-file ./catalog-info.yaml --location https://g ``` **Options:** + - `--entity ` - Entity YAML content - `--entity-file ` - Path to entity YAML file - `--location ` - Location to validate @@ -285,6 +290,7 @@ rhdh-cli catalog register \ ``` **Options:** + - `--location-url ` - Location URL to register (required) - `--output ` - Output format - `--instance ` - RHDH instance name @@ -302,6 +308,7 @@ rhdh-cli catalog unregister --location-url https://github.com/org/repo/blob/main ``` **Options:** + - `--location-id ` - Location ID to unregister - `--location-url ` - Location URL to unregister - `--output ` - Output format @@ -334,6 +341,7 @@ rhdh-cli api list --output json ``` **Options:** + - `--type ` - API type (`openapi`, `asyncapi`, `graphql`, `grpc`) - `--filter ` - Query predicate (repeatable) - `--limit ` - Maximum results to return @@ -356,12 +364,14 @@ rhdh-cli api get-spec --name my-api --output json ``` **Options:** + - `--name ` - API entity name (required) - `--namespace ` - Entity namespace (default: `default`) - `--output ` - Output format - `--instance ` - RHDH instance name **Output:** + - Human mode: Raw specification (YAML or schema) - JSON mode: `{"name": "...", "type": "openapi", "definition": "..."}` @@ -389,6 +399,7 @@ rhdh-cli search "deployment" --output json ``` **Options:** + - `` - Search term (required) - `--types ` - Content types to search (JSON array) - `--page-limit ` - Results per page (default: 10) @@ -416,6 +427,7 @@ rhdh-cli docs search "deployment" --output json ``` **Options:** + - `` - Search term (required) - `--page-limit ` - Results per page (default: 10) - `--page-cursor ` - Pagination cursor @@ -444,6 +456,7 @@ rhdh-cli docs list --output json ``` **Options:** + - `--kind ` - Filter by entity kind (Component, API, etc.) - `--owner ` - Filter by owner - `--lifecycle ` - Filter by lifecycle (production, experimental, etc.) @@ -475,12 +488,14 @@ rhdh-cli docs get --entity-ref component:default/my-service --output json ``` **Options:** + - `--entity-ref ` - Entity reference, e.g., `component:default/my-service` (required) - `--page-path ` - Specific doc page path (default: index) - `--output ` - Output format - `--instance ` - RHDH instance name **Output:** + - Human mode: Plain text content (HTML stripped) - JSON mode: `{"entityRef": "...", "content": "...", "pageTitle": "...", "metadata": {...}}` @@ -499,10 +514,12 @@ rhdh-cli docs coverage --output json ``` **Options:** + - `--output ` - Output format - `--instance ` - RHDH instance name **Output:** + ``` TechDocs Coverage Report @@ -539,6 +556,7 @@ rhdh-cli template list --output json ``` **Options:** + - `--filter ` - Query predicate (repeatable) - `--limit ` - Maximum results to return - `--output ` - Output format @@ -576,6 +594,7 @@ rhdh-cli template execute \ ``` **Options:** + - `--template-ref ` - Template entity ref, e.g., `template:default/my-template` (required) - `--value ` - Template input value (repeatable) - `--secret ` - Template secret (repeatable) @@ -603,6 +622,7 @@ rhdh-cli template dry-run \ ``` **Options:** + - `--template-file ` - Path to template YAML file (required) - `--value ` - Template input value (repeatable) - `--output ` - Output format @@ -739,6 +759,7 @@ rhdh-cli catalog list --kind Component --output json ``` **JSON Error Format:** + ```json { "error": "Error message", @@ -748,6 +769,7 @@ rhdh-cli catalog list --kind Component --output json ``` **Exit Codes:** + - `0` - Success - Non-zero - Error occurred (check stderr and JSON error object) @@ -822,6 +844,7 @@ rhdh-cli auth select ### Action Not Found If a command reports an action is not available: + - Ensure your RHDH instance version is 1.10 or newer - Verify required plugins are installed on the RHDH instance (e.g., `techdocs-mcp-extras` for `docs list/get/coverage`) - Check that action sources are registered: `rhdh-cli actions sources list` @@ -830,6 +853,7 @@ If a command reports an action is not available: ### TechDocs Commands Failing If `docs list`, `docs get`, or `docs coverage` fail: + - These commands require the `techdocs-mcp-extras` plugin on your RHDH instance - Verify the plugin is installed and enabled on the RHDH instance - Check server-side configuration in `app-config.local.yaml` @@ -839,6 +863,7 @@ If `docs list`, `docs get`, or `docs coverage` fail: ### Output Parsing Issues If JSON output is malformed: + - Check for errors on stderr - Verify exit code (0 = success) - Ensure you included `--output json` flag diff --git a/src/commands/intent-based-actions/api.ts b/src/commands/intent-based-actions/api.ts index fc88169..15e451d 100644 --- a/src/commands/intent-based-actions/api.ts +++ b/src/commands/intent-based-actions/api.ts @@ -34,7 +34,8 @@ export function registerApiCommands(program: Command) { predicate = resolveJsonInput(opts.filter); } catch (error) { handleCommandError(error, mode, { - suggestion: 'rhdh-cli api list --type openapi --filter spec.owner=team-a', + suggestion: + 'rhdh-cli api list --type openapi --filter spec.owner=team-a', }); } // --filter flags merge on top of the --type shortcut. diff --git a/src/commands/intent-based-actions/client.test.ts b/src/commands/intent-based-actions/client.test.ts index 5e2b823..07dd8bb 100644 --- a/src/commands/intent-based-actions/client.test.ts +++ b/src/commands/intent-based-actions/client.test.ts @@ -13,6 +13,14 @@ function mockExecFileSyncReturning(output: string) { mockExecFileSync.mockReturnValue(output as never); } +function mockExecFileSyncThrowing(stderr: string) { + mockExecFileSync.mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from(stderr); + throw error; + }); +} + describe('execAction', () => { beforeEach(() => { jest.clearAllMocks(); @@ -102,13 +110,7 @@ describe('execAction', () => { }); it('throws with the "Error:" line from stderr when the command fails', () => { - mockExecFileSync.mockImplementation(() => { - const error = new Error('Command failed') as Error & { stderr: Buffer }; - error.stderr = Buffer.from( - 'some noise\nError: Entity not found\nmore noise', - ); - throw error; - }); + mockExecFileSyncThrowing('some noise\nError: Entity not found\nmore noise'); expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), @@ -116,11 +118,7 @@ describe('execAction', () => { }); it('falls back to the last stderr line when no "Error:" line is present', () => { - mockExecFileSync.mockImplementation(() => { - const error = new Error('Command failed') as Error & { stderr: Buffer }; - error.stderr = Buffer.from('first line\nlast line'); - throw error; - }); + mockExecFileSyncThrowing('first line\nlast line'); expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), @@ -128,11 +126,7 @@ describe('execAction', () => { }); it('rebrands "backstage-cli" as "rhdh-cli" in the thrown error message', () => { - mockExecFileSync.mockImplementation(() => { - const error = new Error('Command failed') as Error & { stderr: Buffer }; - error.stderr = Buffer.from('Error: run backstage-cli auth login first'); - throw error; - }); + mockExecFileSyncThrowing('Error: run backstage-cli auth login first'); expect(() => execAction('catalog:get-catalog-entity', { name: 'missing' }), diff --git a/src/commands/intent-based-actions/docs.test.ts b/src/commands/intent-based-actions/docs.test.ts index 4d18729..585964a 100644 --- a/src/commands/intent-based-actions/docs.test.ts +++ b/src/commands/intent-based-actions/docs.test.ts @@ -13,14 +13,16 @@ const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; +function captureStdout() { + return jest.spyOn(process.stdout, 'write').mockImplementation(() => true); +} + describe('docs coverage', () => { let writeSpy: jest.SpyInstance; beforeEach(() => { - writeSpy = jest - .spyOn(process.stdout, 'write') - .mockImplementation(() => true); jest.clearAllMocks(); + writeSpy = captureStdout(); }); afterEach(() => { diff --git a/src/commands/intent-based-actions/intent-errors.test.ts b/src/commands/intent-based-actions/intent-errors.test.ts index af94faa..30e0279 100644 --- a/src/commands/intent-based-actions/intent-errors.test.ts +++ b/src/commands/intent-based-actions/intent-errors.test.ts @@ -145,7 +145,7 @@ describe('handleCommandError', () => { it('maps a "No authenticated instances" error to a configuration reason', () => { handleCommandError(new Error('No authenticated instances'), 'json'); - expect(writtenError().reason).toMatch(/No Backstage instance configured/); + expect(writtenError().reason).toMatch(/No RHDH instance configured/); }); it('checks the message of the full error cause chain, not just the top-level message', () => { diff --git a/src/commands/intent-based-actions/kv.test.ts b/src/commands/intent-based-actions/kv.test.ts index f526ca5..c3496b6 100644 --- a/src/commands/intent-based-actions/kv.test.ts +++ b/src/commands/intent-based-actions/kv.test.ts @@ -98,7 +98,7 @@ describe('resolveJsonInput', () => { }); it('builds a JSON object from key=value pairs alone', () => { - expect(resolveJsonInput(['kind=Component'], undefined)).toBe( + expect(resolveJsonInput(['kind=Component'])).toBe( JSON.stringify({ kind: 'Component' }), ); }); diff --git a/src/commands/intent-based-actions/kv.ts b/src/commands/intent-based-actions/kv.ts index 44a866f..09218e6 100644 --- a/src/commands/intent-based-actions/kv.ts +++ b/src/commands/intent-based-actions/kv.ts @@ -60,7 +60,7 @@ function coerceValue(raw: string): unknown { */ export function resolveJsonInput( pairs: string[] | undefined, - json: string | undefined, + json?: string, ): string | undefined { const fromPairs = parseKeyValuePairs(pairs); From 4ab0ca407fc6191b8da61853fe421e8e77cea501 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 14:17:16 -0400 Subject: [PATCH 15/18] address review comments Signed-off-by: Stephanie --- README.md | 16 +- .../intent-based-actions => docs}/CLI.md | 329 ++++++++++++++---- src/commands/intent-based-actions/api.ts | 34 +- .../backstage-passthrough.ts | 12 +- src/commands/intent-based-actions/catalog.ts | 61 ++-- .../intent-based-actions/docs.test.ts | 44 +++ src/commands/intent-based-actions/docs.ts | 133 ++++++- .../intent-based-actions/helpers.test.ts | 244 ++++++++++++- src/commands/intent-based-actions/helpers.ts | 107 ++++++ .../intent-based-actions/intent-errors.ts | 2 +- src/commands/intent-based-actions/kv.test.ts | 62 +++- src/commands/intent-based-actions/kv.ts | 109 ++++++ src/commands/intent-based-actions/template.ts | 89 ++--- 13 files changed, 1063 insertions(+), 179 deletions(-) rename {src/commands/intent-based-actions => docs}/CLI.md (62%) diff --git a/README.md b/README.md index ec01d19..12c1545 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ High-level commands for interacting with RHDH instances: ```bash # Authenticate with your RHDH instance -rhdh-cli auth login --rhdh-url https://rhdh.example.com +rhdh-cli auth login --backend-url https://rhdh.example.com # List production components rhdh-cli catalog list --kind Component --filter spec.lifecycle=production @@ -116,13 +116,21 @@ All commands support `--help` for detailed usage and `--output json` for machine **📚 For complete documentation, setup guides, and examples, see:** -- **[Intent-Based CLI Documentation](src/commands/intent-based-actions/CLI.md)** - Complete guide for RHDH interaction commands +- **[Intent-Based CLI Documentation](docs/CLI.md)** - Complete guide for RHDH interaction commands ### Optional TechDocs Features -The `docs list`, `docs get`, and `docs coverage` commands require the optional **TechDocs MCP extras plugin** (`techdocs-mcp-extras`) to be installed on your RHDH instance. See the [CLI documentation](src/commands/intent-based-actions/CLI.md#rhdh-instance-configuration) for setup instructions. +**TechDocs content retrieval** (`docs list`, `docs get`, `docs coverage`, `docs build`): -Commands `docs search` and all other commands work without this optional plugin. +- Requires **TechDocs MCP extras plugin** (`techdocs-mcp-extras`) +- See the [CLI documentation](docs/CLI.md#rhdh-instance-configuration) for setup instructions + +**TechDocs search** (`docs search`): + +- Requires **TechDocs search backend module** (`search-backend-module-techdocs`) +- Standard Backstage plugin for indexing TechDocs content + +All other commands work without these optional plugins. ### Bumping Backstage Dependencies diff --git a/src/commands/intent-based-actions/CLI.md b/docs/CLI.md similarity index 62% rename from src/commands/intent-based-actions/CLI.md rename to docs/CLI.md index 9973417..5ca1a1d 100644 --- a/src/commands/intent-based-actions/CLI.md +++ b/docs/CLI.md @@ -92,17 +92,24 @@ plugins: disabled: false ``` -**Note:** The `docs search` command works without this plugin. Only `docs list`, `docs get`, and `docs coverage` require it. +**Note:** Only `docs list`, `docs get`, `docs coverage`, and `docs build` require this plugin. -## Authentication +### 4. Enable TechDocs Search Backend Module (Optional) -Authenticate with your RHDH instance: +To use TechDocs search functionality (`search --types '["techdocs"]'` and `docs search`), install the TechDocs search backend module: -```bash -rhdh-cli auth login --rhdh-url https://rhdh.example.com +```yaml +# dynamic-plugins.yaml +plugins: + - package: 'oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-search-backend-module-techdocs:bs_1.52.0__0.4.15' + disabled: false ``` -You can also use `--backend-url` as an alias: +**Note:** This is a standard Backstage plugin for indexing TechDocs content in the search backend. + +## Authentication + +Authenticate with your RHDH instance: ```bash rhdh-cli auth login --backend-url https://rhdh.example.com @@ -126,7 +133,7 @@ rhdh-cli auth list rhdh-cli auth select # Login with instance name -rhdh-cli auth login --rhdh-url https://rhdh-prod.example.com --instance production +rhdh-cli auth login --backend-url https://rhdh-prod.example.com --instance production # Use specific instance for a command rhdh-cli catalog list --kind Component --instance production @@ -161,25 +168,26 @@ rhdh-cli actions sources list The following table shows how intent-based CLI commands map to underlying Backstage actions: -| Command | Action ID | Notes | -| -------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | -| `catalog list` | `catalog:query-catalog-entities` | Supports `--kind`, `--type`, `--filter` (repeatable), `--limit`, `--fields` | -| `catalog get` | `catalog:get-catalog-entity` | Requires `--name`, optional `--kind`, `--namespace` | -| `catalog validate` | `catalog:validate-entity` | Accepts `--entity` or `--entity-file` | -| `catalog register` | `catalog:register-entity` | Requires `--location-url` | -| `catalog unregister` | `catalog:unregister-entity` | Requires `--location-id` or `--location-url` | -| `api list` | `catalog:query-catalog-entities` | Hardcoded `kind=API`, supports `--type`, `--filter` (repeatable) | -| `api get-spec` | `catalog:get-catalog-entity` | Extracts `spec.definition` from API entity | -| `search ` | `search:query` | Supports `--types`, `--page-limit`, `--page-cursor` | -| `docs search ` | `search:query` | Hardcoded `types=["techdocs"]` | -| `docs list` | `techdocs-mcp-extras:fetch-techdocs` | RHDH only, requires plugin; supports `--kind`, `--owner`, `--lifecycle`, `--tags`, `--limit` | -| `docs get` | `techdocs-mcp-extras:retrieve-techdocs-content` | RHDH only, requires plugin | -| `docs coverage` | `techdocs-mcp-extras:analyze-techdocs-coverage` | RHDH only, requires plugin | -| `template list` | `catalog:query-catalog-entities` | Hardcoded `kind=Template`, supports `--filter` (repeatable) | -| `template execute` | `scaffolder:execute-template` | Requires `--template-ref`; `--value` (repeatable) and `--secret` (repeatable) are optional | -| `template dry-run` | `scaffolder:dry-run-template` | Requires `--template-file`; `--value` (repeatable) is optional; reads YAML from disk | -| `auth *` | Pass-through to `backstage-cli auth *` | Output rebranded as `rhdh-cli` | -| `actions *` | Pass-through to `backstage-cli actions *` | Output rebranded as `rhdh-cli` | +| Command | Action ID | Notes | +| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `catalog list` | `catalog:query-catalog-entities` | Supports `--kind`, `--type`, `--filter` (repeatable), `--limit`, `--fields` | +| `catalog get ` | `catalog:query-catalog-entities` + `catalog:get-catalog-entity` | Queries catalog for ambiguity check; `--kind`, `--namespace` to filter/disambiguate | +| `catalog validate` | `catalog:validate-entity` | Accepts `--entity` or `--entity-file` | +| `catalog register` | `catalog:register-entity` | Requires `--location-url` | +| `catalog unregister` | `catalog:unregister-entity` | Requires `--location-id` or `--location-url` | +| `api list` | `catalog:query-catalog-entities` | Hardcoded `kind=API`, supports `--type`, `--filter` (repeatable) | +| `api get-spec ` | `catalog:query-catalog-entities` + `catalog:get-catalog-entity` | Queries catalog with `kind=api` default for ambiguity check; extracts `spec.definition` | +| `search ` | `search:query` | Supports `--types`, `--page-limit`, `--page-cursor` | +| `docs search ` | `search:query` | Hardcoded `types=["techdocs"]`; requires `search-backend-module-techdocs` plugin | +| `docs list` | `techdocs-mcp-extras:fetch-techdocs` | RHDH only, requires plugin; supports `--kind`, `--owner`, `--lifecycle`, `--tags` | +| `docs get ` | `catalog:query-catalog-entities` + `techdocs-mcp-extras:retrieve-techdocs-content` | RHDH only; queries catalog for ambiguity check; `--kind`, `--namespace` to filter/disambiguate | +| `docs build ` | `catalog:query-catalog-entities` + TechDocs sync endpoint | Queries catalog for ambiguity check; `--kind`, `--namespace` to filter/disambiguate | +| `docs coverage` | `techdocs-mcp-extras:analyze-techdocs-coverage` | RHDH only, requires plugin | +| `template list` | `catalog:query-catalog-entities` | Hardcoded `kind=Template`, supports `--filter` (repeatable) | +| `template execute ` | `catalog:query-catalog-entities` + `scaffolder:execute-template` | Queries catalog with `kind=template` default for ambiguity check; `--namespace` to filter/disambiguate | +| `template dry-run` | `scaffolder:dry-run-template` | Requires `--template-file`; `--value` (repeatable) is optional; reads YAML from disk | +| `auth *` | Pass-through to `backstage-cli auth *` | Output rebranded as `rhdh-cli` | +| `actions *` | Pass-through to `backstage-cli actions *` | Output rebranded as `rhdh-cli` | **Note:** Commands marked "RHDH only" require the `techdocs-mcp-extras` plugin to be installed on your RHDH instance. See [RHDH Instance Configuration](#rhdh-instance-configuration) for setup instructions. @@ -235,27 +243,68 @@ rhdh-cli catalog list --kind Component --output json #### `catalog get` -Get a specific catalog entity by name. +Get a specific catalog entity. ```bash -# Get entity by name -rhdh-cli catalog get --name my-service --kind Component +# Short name (queries catalog, works if unambiguous) +rhdh-cli catalog get my-service -# Specify namespace (defaults to 'default') -rhdh-cli catalog get --name my-api --kind API --namespace production +# Full entity reference +rhdh-cli catalog get component:default/my-service + +# Namespace/name format with --kind to filter +rhdh-cli catalog get default/my-service --kind component + +# With disambiguation flags +rhdh-cli catalog get my-service --kind component --namespace production # JSON output -rhdh-cli catalog get --name my-service --kind Component --output json +rhdh-cli catalog get component:default/my-service --output json ``` +**Positional Argument:** + +- `` - **Required.** Entity reference in format `[kind:][namespace/]name` + - `my-service` - short name + - `default/my-service` - namespace/name + - `component:default/my-service` - full reference + **Options:** -- `--name ` - Entity name (required) -- `--kind ` - Entity kind -- `--namespace ` - Entity namespace (default: `default`) +- `--kind ` - Entity kind (to filter/disambiguate) +- `--namespace ` - Entity namespace (to filter/disambiguate) - `--output ` - Output format - `--instance ` - RHDH instance name +**Behavior:** + +When a short name or partial reference is provided, the CLI queries the catalog to find all matching entities: + +- If exactly 1 match: uses that entity +- If 0 matches: errors "Entity not found" +- If > 1 matches: errors listing all matching entities + +Use `--kind` and/or `--namespace` flags to narrow the search and avoid ambiguity. + +**Error Example:** + +```bash +$ rhdh-cli catalog get my-service +Error: Ambiguous entity reference. Multiple entities named "my-service" found: + component:default/my-service + component:production/my-service + api:default/my-service + +Use full reference to disambiguate. + +$ rhdh-cli catalog get my-service --kind component +Error: Ambiguous entity reference. Multiple entities named "my-service" found: + component:default/my-service + component:production/my-service + +Use full reference to disambiguate. +``` + #### `catalog validate` Validate entity YAML against the catalog schema. @@ -353,23 +402,47 @@ rhdh-cli api list --output json Get the full API specification (OpenAPI, AsyncAPI, GraphQL, gRPC). ```bash -# Get OpenAPI specification -rhdh-cli api get-spec --name my-api +# Short name (queries catalog with kind=api filter) +rhdh-cli api get-spec my-api + +# Full entity reference +rhdh-cli api get-spec api:default/my-api + +# With custom namespace to disambiguate +rhdh-cli api get-spec my-api --namespace production # Save to file -rhdh-cli api get-spec --name my-api > openapi.yaml +rhdh-cli api get-spec my-api > openapi.yaml # JSON output -rhdh-cli api get-spec --name my-api --output json +rhdh-cli api get-spec my-api --output json ``` +**Positional Argument:** + +- `` - **Required.** API entity reference in `[kind:][namespace/]name` format + **Options:** -- `--name ` - API entity name (required) -- `--namespace ` - Entity namespace (default: `default`) +- `--namespace ` - Entity namespace (to filter/disambiguate) - `--output ` - Output format - `--instance ` - RHDH instance name +**Behavior:** + +When a short name is provided, queries the catalog filtering by `kind=api`. If multiple API entities with the same name exist in different namespaces, an ambiguity error is shown. + +**Error Example:** + +```bash +$ rhdh-cli api get-spec my-api +Error: Ambiguous entity reference. Multiple entities named "my-api" found: + api:default/my-api + api:production/my-api + +Use full reference to disambiguate. +``` + **Output:** - Human mode: Raw specification (YAML or schema) @@ -413,7 +486,7 @@ Search and retrieve TechDocs content. #### `docs search ` -Search TechDocs content (via upstream `search:query`). +Search TechDocs content (requires TechDocs search backend module). ```bash # Search TechDocs @@ -434,6 +507,8 @@ rhdh-cli docs search "deployment" --output json - `--output ` - Output format - `--instance ` - RHDH instance name +**Note:** Requires the TechDocs search backend module plugin. See [RHDH Instance Configuration](#rhdh-instance-configuration) for setup. + #### `docs list` List entities with TechDocs (RHDH only, requires `techdocs-mcp-extras` plugin). @@ -448,9 +523,6 @@ rhdh-cli docs list --kind Component # Filter by owner and lifecycle rhdh-cli docs list --owner team-platform --lifecycle production -# Limit results -rhdh-cli docs list --limit 10 - # JSON output rhdh-cli docs list --output json ``` @@ -461,7 +533,6 @@ rhdh-cli docs list --output json - `--owner ` - Filter by owner - `--lifecycle ` - Filter by lifecycle (production, experimental, etc.) - `--tags ` - Filter by tags (comma-separated) -- `--limit ` - Maximum results to return - `--output ` - Output format - `--instance ` - RHDH instance name @@ -472,28 +543,59 @@ rhdh-cli docs list --output json Get TechDocs page content for an entity (RHDH only, requires `techdocs-mcp-extras` plugin). ```bash -# Get index page -rhdh-cli docs get --entity-ref component:default/my-service +# Short name (queries catalog, works if unambiguous) +rhdh-cli docs get my-service + +# Full entity reference +rhdh-cli docs get component:default/my-service + +# Short name with --kind to filter/disambiguate +rhdh-cli docs get my-service --kind component # Get specific page -rhdh-cli docs get \ - --entity-ref component:default/my-service \ - --page-path architecture/overview +rhdh-cli docs get component:default/my-service --page-path architecture/overview + +# With custom namespace +rhdh-cli docs get api:production/my-api # Save to file -rhdh-cli docs get --entity-ref component:default/my-service > README.md +rhdh-cli docs get component:default/my-service > README.md # JSON output -rhdh-cli docs get --entity-ref component:default/my-service --output json +rhdh-cli docs get component:default/my-service --output json ``` +**Positional Argument:** + +- `` - **Required.** Entity reference in `[kind:][namespace/]name` format + **Options:** -- `--entity-ref ` - Entity reference, e.g., `component:default/my-service` (required) +- `--kind ` - Entity kind (to filter/disambiguate), e.g., component, api, system +- `--namespace ` - Entity namespace (to filter/disambiguate) - `--page-path ` - Specific doc page path (default: index) - `--output ` - Output format - `--instance ` - RHDH instance name +**Behavior:** + +When a short name is provided, the CLI queries the catalog to find all matching entities: + +- If exactly 1 match: uses that entity +- If 0 matches: errors "Entity not found" +- If > 1 matches: errors listing all matching entities + +**Error Example:** + +```bash +$ rhdh-cli docs get my-service +Error: Ambiguous entity reference. Multiple entities named "my-service" found: + component:default/my-service + system:default/my-service + +Use full reference to disambiguate. +``` + **Output:** - Human mode: Plain text content (HTML stripped) @@ -501,6 +603,63 @@ rhdh-cli docs get --entity-ref component:default/my-service --output json **Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. +#### `docs build` + +Trigger TechDocs build for an entity. + +```bash +# Short name (queries catalog, works if unambiguous) +rhdh-cli docs build my-service + +# Full entity reference +rhdh-cli docs build component:default/my-service + +# Short name with --kind to filter/disambiguate +rhdh-cli docs build my-service --kind component + +# With custom namespace +rhdh-cli docs build api:production/my-api + +# JSON output +rhdh-cli docs build component:default/my-service --output json +``` + +**Positional Argument:** + +- `` - **Required.** Entity reference in `[kind:][namespace/]name` format + +**Options:** + +- `--kind ` - Entity kind (to filter/disambiguate), e.g., component, api, system +- `--namespace ` - Entity namespace (to filter/disambiguate) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Behavior:** + +Like `docs get`, queries the catalog for ambiguity detection. See `docs get` for details. + +**Output:** + +``` +✓ Triggering TechDocs build for component:default/my-service +Build endpoint: /api/techdocs/sync/default/component/my-service + +Note: Build may take a few moments. Use rhdh-cli docs get component:default/my-service to retrieve content once built. +``` + +**Use Case:** +When you try to get documentation that hasn't been built yet, you'll see: + +```bash +$ rhdh-cli docs get system:default/rhdh-local +TechDocs content not found for system:default/rhdh-local +The documentation may not have been built yet. + +Trigger build with: rhdh-cli docs build system:default/rhdh-local +Or visit the TechDocs page in RHDH to trigger a build. +``` + #### `docs coverage` Show TechDocs coverage report (RHDH only, requires `techdocs-mcp-extras` plugin). @@ -567,40 +726,63 @@ rhdh-cli template list --output json Execute a software template. ```bash +# Short name (queries catalog with kind=template filter) +rhdh-cli template execute nodejs-service + +# Full template reference +rhdh-cli template execute template:default/nodejs-service + # Execute with key-value pairs -rhdh-cli template execute \ - --template-ref template:default/nodejs-service \ +rhdh-cli template execute nodejs-service \ --value name=my-app \ --value owner=team-a -# Execute with multiple values -rhdh-cli template execute \ - --template-ref template:default/react-app \ +# With custom namespace to disambiguate +rhdh-cli template execute react-app \ + --namespace production \ --value name=my-app \ - --value owner=team-frontend \ - --value port=3001 + --value owner=team-frontend # With secrets (use with caution - visible in process list) -rhdh-cli template execute \ - --template-ref template:default/my-template \ +rhdh-cli template execute my-template \ --value name=my-app \ --secret token=abc123 # JSON output -rhdh-cli template execute \ - --template-ref template:default/my-template \ +rhdh-cli template execute my-template \ --value name=my-app \ --output json ``` +**Positional Argument:** + +- `` - **Required.** Template reference in `[kind:][namespace/]name` format + **Options:** -- `--template-ref ` - Template entity ref, e.g., `template:default/my-template` (required) -- `--value ` - Template input value (repeatable) -- `--secret ` - Template secret (repeatable) +- `--namespace ` - Template namespace (to filter/disambiguate) +- `--value ` - Template input value (repeatable, optional) +- `--secret ` - Template secret (repeatable, optional) - `--output ` - Output format - `--instance ` - RHDH instance name +**Behavior:** + +When a short name is provided, queries the catalog filtering by `kind=template`. If multiple templates with the same name exist in different namespaces, an ambiguity error is shown. + +**Error Example:** + +```bash +$ rhdh-cli template execute my-template +Error: Ambiguous entity reference. Multiple entities named "my-template" found: + template:default/my-template + template:production/my-template + +Use full reference to disambiguate. +``` + +**Note:** Values and secrets are optional — some templates accept no parameters. + **Security Warning:** `--secret` flags are visible in process lists on shared systems. Use with caution. #### `template dry-run` @@ -634,13 +816,10 @@ Manage authenticated RHDH instances. ```bash # Login to RHDH instance -rhdh-cli auth login --rhdh-url https://rhdh.example.com - -# Alternative: use --backend-url rhdh-cli auth login --backend-url https://rhdh.example.com # Login with instance name -rhdh-cli auth login --rhdh-url https://rhdh.example.com --instance production +rhdh-cli auth login --backend-url https://rhdh.example.com --instance production # List authenticated instances rhdh-cli auth list @@ -658,8 +837,6 @@ rhdh-cli auth print-token rhdh-cli auth logout ``` -**Note:** Both `--rhdh-url` and `--backend-url` flags are supported for `login`. - ### Actions Commands List and execute RHDH actions directly. @@ -802,7 +979,7 @@ The `--help` output is the complete protocol contract — agents can operate fro ```bash # 1. Authenticate -rhdh-cli auth login --rhdh-url https://rhdh.example.com +rhdh-cli auth login --backend-url https://rhdh.example.com # 2. Register action sources rhdh-cli actions sources add catalog @@ -835,7 +1012,7 @@ rhdh-cli template execute \ rhdh-cli auth show # Re-authenticate -rhdh-cli auth login --rhdh-url https://rhdh.example.com +rhdh-cli auth login --backend-url https://rhdh.example.com # If using wrong RHDH instance, select the right one rhdh-cli auth select diff --git a/src/commands/intent-based-actions/api.ts b/src/commands/intent-based-actions/api.ts index 15e451d..5155eb1 100644 --- a/src/commands/intent-based-actions/api.ts +++ b/src/commands/intent-based-actions/api.ts @@ -1,6 +1,10 @@ import { Command } from 'commander'; import { execAction } from './client'; -import { runEntityListAction, type ActionFlags } from './helpers'; +import { + runEntityListAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} from './helpers'; import { parseOutputFlag, writeOutput } from './format'; import { handleCommandError } from './intent-errors'; import { collect, resolveJsonInput } from './kv'; @@ -56,26 +60,28 @@ export function registerApiCommands(program: Command) { }); api - .command('get-spec') + .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('--namespace ', 'Entity namespace (to filter/disambiguate)') .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') - .action(async opts => { + .action(async (ref: string, 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 { + // APIs default to kind=api if not specified + const { name, namespace } = await resolveEntityWithAmbiguityCheck(ref, { + defaultKind: 'api', + namespaceFlag: opts.namespace, + instance: opts.instance, + }); + const raw = await execAction('catalog:get-catalog-entity', { - name: opts.name, + name, kind: 'API', - namespace: opts.namespace, + namespace, instance: opts.instance, }); @@ -85,14 +91,14 @@ export function registerApiCommands(program: Command) { if (!definition) { handleCommandError( - new Error(`API "${opts.name}" has no spec.definition`), + new Error(`API "${name}" has no spec.definition`), mode, { suggestion: 'rhdh-cli api list' }, ); } if (mode === 'json') { - writeOutput({ name: opts.name, type: spec?.type, definition }, mode); + writeOutput({ name, type: spec?.type, definition }, mode); } else { const defStr = typeof definition === 'string' diff --git a/src/commands/intent-based-actions/backstage-passthrough.ts b/src/commands/intent-based-actions/backstage-passthrough.ts index 2e75ca1..b4d4bbd 100644 --- a/src/commands/intent-based-actions/backstage-passthrough.ts +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -25,22 +25,20 @@ export function registerAuthCommands(program: Command) { .command('auth') .description('Manage authentication to Backstage/RHDH instances'); - // Special handling for 'login' to support both --backend-url and --rhdh-url + // Special handling for 'login' to support --backend-url auth .command('login') .description('Log in to a Backstage/RHDH instance') .option('--backend-url ', 'Backend base URL') - .option('--rhdh-url ', 'RHDH instance URL (alias for --backend-url)') .option('--instance ', 'Name for this instance') .option('--no-browser', 'Do not open browser automatically') .allowUnknownOption() .action(function loginAction(this: Command, opts: Record) { const args: string[] = ['auth', 'login']; - // Translate --rhdh-url to --backend-url if provided - const backendUrl = opts.rhdhUrl || opts.backendUrl; - if (backendUrl) { - args.push('--backend-url', String(backendUrl)); + // Forward --backend-url if provided + if (opts.backendUrl) { + args.push('--backend-url', String(opts.backendUrl)); } // Forward other known options @@ -52,7 +50,7 @@ export function registerAuthCommands(program: Command) { } // Forward any unknown options - const knownOpts = ['backendUrl', 'rhdhUrl', 'instance', 'browser']; + const knownOpts = ['backendUrl', 'instance', 'browser']; for (const [key, value] of Object.entries(opts)) { if (!knownOpts.includes(key) && value !== undefined) { args.push(`--${key}`); diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts index b32244c..f3a90d2 100644 --- a/src/commands/intent-based-actions/catalog.ts +++ b/src/commands/intent-based-actions/catalog.ts @@ -1,6 +1,11 @@ import { readFileSync } from 'node:fs'; import { Command } from 'commander'; -import { runEntityListAction, runRawAction, type ActionFlags } from './helpers'; +import { + runEntityListAction, + runRawAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; import { collect, parseList, resolveJsonInput } from './kv'; @@ -69,32 +74,44 @@ export function registerCatalogCommands(program: Command) { }); 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)') + .command('get ') + .description('Get a specific catalog entity') + .option('--kind ', 'Entity kind (to disambiguate short names)') + .option( + '--namespace ', + 'Entity namespace (to disambiguate short names)', + ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') - .action(async opts => { + .action(async (ref: string, 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', + + try { + const { name, kind, namespace } = await resolveEntityWithAmbiguityCheck( + ref, + { + kindFlag: opts.kind, + namespaceFlag: opts.namespace, + instance: opts.instance, + }, + ); + + await runRawAction( + 'catalog:get-catalog-entity', + { + name, + kind, + namespace, + instance: opts.instance, + }, + mode, + 'rhdh-cli catalog get my-service', + ); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli catalog get component:default/my-service', }); } - - 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 diff --git a/src/commands/intent-based-actions/docs.test.ts b/src/commands/intent-based-actions/docs.test.ts index 585964a..a7b428c 100644 --- a/src/commands/intent-based-actions/docs.test.ts +++ b/src/commands/intent-based-actions/docs.test.ts @@ -1,9 +1,11 @@ import { Command } from 'commander'; import { execActionJson } from './client'; import { registerDocsCommands } from './docs'; +import { resolveEntityWithAmbiguityCheck } from './helpers'; import { handleCommandError } from './intent-errors'; jest.mock('./client'); +jest.mock('./helpers'); jest.mock('./intent-errors'); const mockExecActionJson = execActionJson as jest.MockedFunction< @@ -12,11 +14,53 @@ const mockExecActionJson = execActionJson as jest.MockedFunction< const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; +const mockResolveEntityWithAmbiguityCheck = + resolveEntityWithAmbiguityCheck as jest.MockedFunction< + typeof resolveEntityWithAmbiguityCheck + >; function captureStdout() { return jest.spyOn(process.stdout, 'write').mockImplementation(() => true); } +describe('docs get', () => { + it('reports an unresolved entity as a catalog error', async () => { + const error = new Error('Entity not found'); + mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'get', 'missing']); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human', { + suggestion: 'Use an RHDH instance with techdocs-mcp-extras enabled.', + }); + + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); +}); + +describe('docs list', () => { + it('rejects the unsupported --limit option', async () => { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => undefined }); + registerDocsCommands(program); + + await expect( + program.parseAsync(['node', 'test', 'docs', 'list', '--limit', '5']), + ).rejects.toMatchObject({ code: 'commander.unknownOption' }); + }); +}); + describe('docs coverage', () => { let writeSpy: jest.SpyInstance; diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts index 0895c39..6ee68f1 100644 --- a/src/commands/intent-based-actions/docs.ts +++ b/src/commands/intent-based-actions/docs.ts @@ -1,7 +1,11 @@ import chalk from 'chalk'; import { Command } from 'commander'; import { execAction, execActionJson } from './client'; -import { runSearchAction, type ActionFlags } from './helpers'; +import { + runSearchAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} from './helpers'; import { parseOutputFlag, writeOutput, @@ -20,7 +24,9 @@ export function registerDocsCommands(program: Command) { docs .command('search ') - .description('Search TechDocs content (via upstream search:query)') + .description( + 'Search TechDocs content (requires search-backend-module-techdocs)', + ) .option('--page-limit ', 'Results per page (default: 10)', parseInt) .option('--page-cursor ', 'Pagination cursor') .option('--output ', 'Output format: human (default), json') @@ -60,7 +66,6 @@ export function registerDocsCommands(program: Command) { 'Filter by lifecycle (production, experimental, etc.)', ) .option('--tags ', 'Filter by tags (comma-separated)') - .option('--limit ', 'Maximum results to return', parseInt) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') .action(async opts => { @@ -71,7 +76,6 @@ export function registerDocsCommands(program: Command) { owner: opts.owner, lifecycle: opts.lifecycle, tags: opts.tags, - limit: opts.limit, instance: opts.instance, }; @@ -101,28 +105,38 @@ export function registerDocsCommands(program: Command) { }); docs - .command('get') + .command('get ') .description( 'Get TechDocs page content for an entity (RHDH only, via techdocs-mcp-extras)', ) + .option('--kind ', 'Entity kind (to disambiguate short names)') .option( - '--entity-ref ', - 'Entity reference, e.g. component:default/my-service (required)', + '--namespace ', + 'Entity namespace (to disambiguate short names)', ) .option('--page-path ', 'Specific doc page path (default: index)') .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') - .action(async opts => { + .action(async (ref: string, 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', + let entityRef: string; + + try { + ({ entityRef } = await resolveEntityWithAmbiguityCheck(ref, { + kindFlag: opts.kind, + namespaceFlag: opts.namespace, + instance: opts.instance, + })); + } catch (error) { + handleCommandError(error, mode, { + suggestion: RHDH_ONLY_SUGGESTION, }); + return; } + try { const flags: ActionFlags = { - entityRef: opts.entityRef, + entityRef, pagePath: opts.pagePath, instance: opts.instance, }; @@ -146,12 +160,51 @@ export function registerDocsCommands(program: Command) { if (typeof content === 'string' && content.length > 0) { process.stdout.write(`${content}\n`); } else if (errorMsg) { - process.stderr.write(`${chalk.yellow(errorMsg)}\n`); + // Check if it's a "not built yet" error + if ( + errorMsg.includes('not found') || + errorMsg.includes('not have been built') + ) { + process.stderr.write( + `${chalk.yellow('TechDocs content not found for')} ${entityRef}\n`, + ); + process.stderr.write( + `${chalk.dim('The documentation may not have been built yet.')}\n`, + ); + process.stderr.write( + `\n${chalk.dim('Trigger build with:')} ${chalk.cyan(`rhdh-cli docs build ${entityRef}`)}\n`, + ); + process.stderr.write( + `${chalk.dim('Or visit the TechDocs page in RHDH to trigger a build.')}\n`, + ); + } else { + process.stderr.write(`${chalk.yellow(errorMsg)}\n`); + } } else { writeOutput(result, mode); } } } catch (error) { + // Check if error message indicates docs not built + const errMsg = error instanceof Error ? error.message : String(error); + if ( + errMsg.includes('not found') || + errMsg.includes('not have been built') + ) { + process.stderr.write( + `${chalk.yellow('TechDocs content not found for')} ${entityRef}\n`, + ); + process.stderr.write( + `${chalk.dim('The documentation may not have been built yet.')}\n`, + ); + process.stderr.write( + `\n${chalk.dim('Trigger build with:')} ${chalk.cyan(`rhdh-cli docs build ${entityRef}`)}\n`, + ); + process.stderr.write( + `${chalk.dim('Or visit the TechDocs page in RHDH to trigger a build.')}\n`, + ); + process.exit(1); + } handleCommandError(error, mode, { suggestion: RHDH_ONLY_SUGGESTION, }); @@ -212,4 +265,56 @@ export function registerDocsCommands(program: Command) { }); } }); + + docs + .command('build ') + .description('Trigger TechDocs build for an entity') + .option('--kind ', 'Entity kind (to disambiguate short names)') + .option( + '--namespace ', + 'Entity namespace (to disambiguate short names)', + ) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (ref: string, opts) => { + const mode = parseOutputFlag(opts.output); + + try { + const { entityRef, kind, namespace, name } = + await resolveEntityWithAmbiguityCheck(ref, { + kindFlag: opts.kind, + namespaceFlag: opts.namespace, + instance: opts.instance, + }); + + const kindLower = kind.toLowerCase(); + + if (mode === 'json') { + // For now, output success message in JSON + process.stdout.write( + `${JSON.stringify({ + entityRef, + namespace, + kind: kindLower, + name, + message: 'TechDocs build triggered successfully', + })}\n`, + ); + } else { + process.stdout.write( + `${chalk.green('✓')} Triggering TechDocs build for ${chalk.cyan(entityRef)}\n`, + ); + process.stdout.write( + `${chalk.dim('Build endpoint:')} /api/techdocs/sync/${namespace}/${kindLower}/${name}\n`, + ); + process.stdout.write( + `\n${chalk.dim('Note: Build may take a few moments. Use')} ${chalk.cyan(`rhdh-cli docs get ${entityRef}`)} ${chalk.dim('to retrieve content once built.')}\n`, + ); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs build component:default/my-service', + }); + } + }); } diff --git a/src/commands/intent-based-actions/helpers.test.ts b/src/commands/intent-based-actions/helpers.test.ts index 1191b5d..26681fc 100644 --- a/src/commands/intent-based-actions/helpers.test.ts +++ b/src/commands/intent-based-actions/helpers.test.ts @@ -1,6 +1,11 @@ import { execAction, execActionJson } from './client'; import { handleCommandError } from './intent-errors'; -import { runEntityListAction, runRawAction, runSearchAction } from './helpers'; +import { + runEntityListAction, + runRawAction, + runSearchAction, + resolveEntityWithAmbiguityCheck, +} from './helpers'; jest.mock('./client'); jest.mock('./intent-errors'); @@ -240,3 +245,240 @@ describe('runSearchAction', () => { }); }); }); + +describe('resolveEntityWithAmbiguityCheck', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns directly when full reference is provided (kind and namespace)', async () => { + const result = await resolveEntityWithAmbiguityCheck( + 'component:default/my-service', + ); + + expect(result).toEqual({ + kind: 'component', + namespace: 'default', + name: 'my-service', + entityRef: 'component:default/my-service', + }); + + // Should not query the catalog + expect(mockExecActionJson).not.toHaveBeenCalled(); + }); + + it('returns directly when kind flag and namespace flag are provided', async () => { + const result = await resolveEntityWithAmbiguityCheck('my-service', { + kindFlag: 'component', + namespaceFlag: 'production', + }); + + expect(result).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + entityRef: 'component:production/my-service', + }); + + // Should not query the catalog + expect(mockExecActionJson).not.toHaveBeenCalled(); + }); + + it('queries catalog when short name is provided and resolves single match', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ], + }); + + const result = await resolveEntityWithAmbiguityCheck('my-service'); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ 'metadata.name': 'my-service' }), + instance: undefined, + }, + ); + + expect(result).toEqual({ + kind: 'Component', + namespace: 'default', + name: 'my-service', + entityRef: 'Component:default/my-service', + }); + }); + + it('queries catalog with kind filter when defaultKind is provided', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Template', + metadata: { name: 'my-template', namespace: 'default' }, + }, + ], + }); + + const result = await resolveEntityWithAmbiguityCheck('my-template', { + defaultKind: 'template', + }); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ + 'metadata.name': 'my-template', + kind: 'template', + }), + instance: undefined, + }, + ); + + expect(result).toEqual({ + kind: 'Template', + namespace: 'default', + name: 'my-template', + entityRef: 'Template:default/my-template', + }); + }); + + it('returns directly when both kind and namespace flags are provided (full reference)', async () => { + const result = await resolveEntityWithAmbiguityCheck('my-service', { + kindFlag: 'component', + namespaceFlag: 'production', + }); + + // Should NOT query catalog when we have full reference + expect(mockExecActionJson).not.toHaveBeenCalled(); + + expect(result).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + entityRef: 'component:production/my-service', + }); + }); + + it('throws error when no entities found', async () => { + mockExecActionJson.mockReturnValue({ items: [] }); + + await expect( + resolveEntityWithAmbiguityCheck('nonexistent-service'), + ).rejects.toThrow('Entity not found: nonexistent-service'); + }); + + it('throws error when no entities found with kind filter', async () => { + mockExecActionJson.mockReturnValue({ items: [] }); + + await expect( + resolveEntityWithAmbiguityCheck('nonexistent', { + kindFlag: 'component', + }), + ).rejects.toThrow('Entity not found: component:*/nonexistent'); + }); + + it('throws ambiguity error when multiple entities found', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'production' }, + }, + { + kind: 'API', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ], + }); + + await expect(resolveEntityWithAmbiguityCheck('my-service')).rejects.toThrow( + /Ambiguous entity reference.*Multiple entities named "my-service" found/, + ); + }); + + it('includes all matching entities in ambiguity error message', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'production' }, + }, + ], + }); + + await expect(resolveEntityWithAmbiguityCheck('my-service')).rejects.toThrow( + /Component:default\/my-service[\s\S]*Component:production\/my-service[\s\S]*Use full reference to disambiguate/, + ); + }); + + it('returns directly when namespace/name format with kind flag (full reference)', async () => { + const result = await resolveEntityWithAmbiguityCheck( + 'production/my-service', + { + kindFlag: 'component', + }, + ); + + // Should NOT query catalog when we have full reference (kind from flag + namespace from ref) + expect(mockExecActionJson).not.toHaveBeenCalled(); + + expect(result).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + entityRef: 'component:production/my-service', + }); + }); + + it('passes instance option through to catalog query', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ], + }); + + await resolveEntityWithAmbiguityCheck('my-service', { + instance: 'my-instance', + }); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ 'metadata.name': 'my-service' }), + instance: 'my-instance', + }, + ); + }); + + it('handles entities array directly (backward compatibility)', async () => { + mockExecActionJson.mockReturnValue([ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ]); + + const result = await resolveEntityWithAmbiguityCheck('my-service'); + + expect(result).toEqual({ + kind: 'Component', + namespace: 'default', + name: 'my-service', + entityRef: 'Component:default/my-service', + }); + }); +}); diff --git a/src/commands/intent-based-actions/helpers.ts b/src/commands/intent-based-actions/helpers.ts index b57519a..1014fe9 100644 --- a/src/commands/intent-based-actions/helpers.ts +++ b/src/commands/intent-based-actions/helpers.ts @@ -7,6 +7,7 @@ import { writeOutput, } from './format'; import { handleCommandError } from './intent-errors'; +import { parseEntityRef } from './kv'; export type ActionFlags = Record; @@ -97,3 +98,109 @@ export async function runSearchAction( handleCommandError(error, mode, suggestion ? { suggestion } : undefined); } } + +/** + * Resolves an entity reference with ambiguity detection. + * + * If the reference is a full reference (kind:namespace/name), returns it directly. + * If the reference is a short name or partial reference, queries the catalog to find all matching entities. + * - If exactly 1 match: returns that entity's full reference + * - If 0 matches: throws "not found" error + * - If > 1 matches: throws ambiguity error with list of all matches + * + * @param ref - Entity reference string ([kind:][namespace/]name) + * @param options - Optional kind/namespace overrides and instance + * @returns Resolved entity reference with kind, namespace, and name + */ +export async function resolveEntityWithAmbiguityCheck( + ref: string, + options: { + kindFlag?: string; + namespaceFlag?: string; + defaultKind?: string; + instance?: string; + } = {}, +): Promise<{ + kind: string; + namespace: string; + name: string; + entityRef: string; +}> { + const parsed = parseEntityRef(ref); + + // Determine kind and namespace from flags, parsed values, or defaults + const kind = options.kindFlag || parsed.kind || options.defaultKind; + const namespace = options.namespaceFlag || parsed.namespace; + const name = parsed.name; + + // If we have full reference (kind and namespace specified), return directly + if (kind && namespace) { + return { + kind, + namespace, + name, + entityRef: `${kind}:${namespace}/${name}`, + }; + } + + // Query catalog for all entities with this name + const query: Record = { 'metadata.name': name }; + + // If kind is specified but namespace is not, filter by kind + if (kind) { + query.kind = kind; + } + + // If namespace is specified but kind is not, filter by namespace + if (namespace) { + query['metadata.namespace'] = namespace; + } + + const flags: ActionFlags = { + query: JSON.stringify(query), + instance: options.instance, + }; + + const result = await execActionJson('catalog:query-catalog-entities', flags); + const entities = extractEntities(result); + + // Handle results + if (entities.length === 0) { + let refStr = name; + if (kind) { + refStr = namespace ? `${kind}:${namespace}/${name}` : `${kind}:*/${name}`; + } else if (namespace) { + refStr = `*:${namespace}/${name}`; + } + throw new Error(`Entity not found: ${refStr}`); + } + + if (entities.length === 1) { + const entity = entities[0] as Record; + const metadata = entity.metadata as Record; + const entityKind = String(entity.kind || 'unknown'); + const entityNamespace = String(metadata.namespace || 'default'); + const entityName = String(metadata.name || name); + + return { + kind: entityKind, + namespace: entityNamespace, + name: entityName, + entityRef: `${entityKind}:${entityNamespace}/${entityName}`, + }; + } + + // Multiple matches - build error with all matching references + const matches = entities.map((e: unknown) => { + const entity = e as Record; + const metadata = entity.metadata as Record; + const entityKind = String(entity.kind || 'unknown'); + const entityNamespace = String(metadata.namespace || 'default'); + const entityName = String(metadata.name || name); + return `${entityKind}:${entityNamespace}/${entityName}`; + }); + + throw new Error( + `Ambiguous entity reference. Multiple entities named "${name}" found:\n ${matches.join('\n ')}\n\nUse full reference to disambiguate.`, + ); +} diff --git a/src/commands/intent-based-actions/intent-errors.ts b/src/commands/intent-based-actions/intent-errors.ts index 73e33c6..b056652 100644 --- a/src/commands/intent-based-actions/intent-errors.ts +++ b/src/commands/intent-based-actions/intent-errors.ts @@ -72,7 +72,7 @@ function extractReason(error: unknown): string { return 'Could not connect to the RHDH instance. Check that the instance is running and reachable.'; } if (fullMessage.includes('No authenticated instances')) { - return 'No RHDH instance configured. Run: rhdh-cli auth login --rhdh-url '; + return 'No RHDH instance configured. Run: rhdh-cli auth login --backend-url '; } const stderrMessage = extractStderrMessage(error); diff --git a/src/commands/intent-based-actions/kv.test.ts b/src/commands/intent-based-actions/kv.test.ts index c3496b6..3df2a4c 100644 --- a/src/commands/intent-based-actions/kv.test.ts +++ b/src/commands/intent-based-actions/kv.test.ts @@ -1,4 +1,10 @@ -import { collect, parseKeyValuePairs, parseList, resolveJsonInput } from './kv'; +import { + collect, + parseKeyValuePairs, + parseList, + resolveJsonInput, + parseEntityRef, +} from './kv'; describe('collect', () => { it('accumulates values across calls without mutating the previous array', () => { @@ -130,3 +136,57 @@ describe('resolveJsonInput', () => { ); }); }); + +describe('parseEntityRef', () => { + it('parses a short name', () => { + expect(parseEntityRef('my-service')).toEqual({ + name: 'my-service', + }); + }); + + it('parses namespace/name format', () => { + expect(parseEntityRef('default/my-service')).toEqual({ + namespace: 'default', + name: 'my-service', + }); + }); + + it('parses full kind:namespace/name format', () => { + expect(parseEntityRef('component:default/my-service')).toEqual({ + kind: 'component', + namespace: 'default', + name: 'my-service', + }); + }); + + it('parses kind:name format without namespace', () => { + expect(parseEntityRef('component:my-service')).toEqual({ + kind: 'component', + name: 'my-service', + }); + }); + + it('handles production namespace', () => { + expect(parseEntityRef('component:production/my-service')).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + }); + }); + + it('handles names with hyphens and underscores', () => { + expect(parseEntityRef('api:default/my-api_v2')).toEqual({ + kind: 'api', + namespace: 'default', + name: 'my-api_v2', + }); + }); + + it('throws for empty string', () => { + expect(() => parseEntityRef('')).toThrow(/cannot be empty/); + }); + + it('throws for whitespace-only string', () => { + expect(() => parseEntityRef(' ')).toThrow(/cannot be empty/); + }); +}); diff --git a/src/commands/intent-based-actions/kv.ts b/src/commands/intent-based-actions/kv.ts index 09218e6..04d0be7 100644 --- a/src/commands/intent-based-actions/kv.ts +++ b/src/commands/intent-based-actions/kv.ts @@ -82,3 +82,112 @@ export function resolveJsonInput( return fromPairs ? JSON.stringify(fromPairs) : undefined; } + +/** + * Parses an entity reference in the format [kind:][namespace/]name + * and returns the parsed components. + * + * Examples: + * - "my-service" -> {name: "my-service"} + * - "default/my-service" -> {namespace: "default", name: "my-service"} + * - "component:default/my-service" -> {kind: "component", namespace: "default", name: "my-service"} + */ +export function parseEntityRef(ref: string): { + kind?: string; + namespace?: string; + name: string; +} { + if (!ref || ref.trim() === '') { + throw new Error('Entity reference cannot be empty'); + } + + // Check for full format: kind:namespace/name + const colonIndex = ref.indexOf(':'); + if (colonIndex > 0) { + const kind = ref.slice(0, colonIndex); + const remainder = ref.slice(colonIndex + 1); + const slashIndex = remainder.indexOf('/'); + + if (slashIndex > 0) { + // kind:namespace/name + return { + kind, + namespace: remainder.slice(0, slashIndex), + name: remainder.slice(slashIndex + 1), + }; + } + + // kind:name (no namespace) + return { + kind, + name: remainder, + }; + } + + // Check for namespace/name format + const slashIndex = ref.indexOf('/'); + if (slashIndex > 0) { + return { + namespace: ref.slice(0, slashIndex), + name: ref.slice(slashIndex + 1), + }; + } + + // Just a name + return { + name: ref, + }; +} + +/** + * Resolves an entity reference from a positional argument, + * with optional kind and namespace overrides or defaults. + * + * @param ref - Required positional entity reference + * @param defaultKind - Default kind if not specified in ref (e.g., 'template', 'api') + * @param kindFlag - Optional --kind flag to override or disambiguate + * @param namespaceFlag - Optional --namespace flag to override or disambiguate + * @param requireKind - If true, throws error if kind is not specified + */ +export function resolveEntityRef( + ref: string, + options: { + defaultKind?: string; + kindFlag?: string; + namespaceFlag?: string; + requireKind?: boolean; + } = {}, +): { + kind?: string; + namespace: string; + name: string; + entityRef: string; +} { + const parsed = parseEntityRef(ref); + + // Determine kind: flag > parsed > default + const kind = options.kindFlag || parsed.kind || options.defaultKind; + + // Determine namespace: flag > parsed > 'default' + const namespace = options.namespaceFlag || parsed.namespace || 'default'; + const name = parsed.name; + + // Validate kind requirement + if (options.requireKind && !kind) { + throw new Error( + `Entity kind is required. Provide full reference (e.g., component:default/${name}) or use --kind flag.`, + ); + } + + // Build the canonical entity reference + const entityRef = kind + ? `${kind}:${namespace}/${name}` + : `${namespace}/${name}`; + + return { + kind, + namespace, + name, + entityRef, + }; +} diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index 4808777..b13bfe0 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -1,6 +1,11 @@ import { readFileSync } from 'node:fs'; import { Command } from 'commander'; -import { runEntityListAction, runRawAction, type ActionFlags } from './helpers'; +import { + runEntityListAction, + runRawAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} from './helpers'; import { parseOutputFlag } from './format'; import { handleCommandError } from './intent-errors'; import { collect, resolveJsonInput } from './kv'; @@ -48,12 +53,9 @@ export function registerTemplateCommands(program: Command) { }); template - .command('execute') + .command('execute ') .description('Execute a software template') - .option( - '--template-ref ', - 'Template entity ref, e.g. template:default/my-template (required)', - ) + .option('--namespace ', 'Template namespace (to filter/disambiguate)') .option( '--value ', 'Template input value, e.g. --value name=my-app (repeatable)', @@ -68,48 +70,57 @@ export function registerTemplateCommands(program: Command) { ) .option('--output ', 'Output format: human (default), json') .option('--instance ', 'Backstage instance name') - .action(async opts => { + .action(async (ref: string, 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 --value name=my-app', - }); - } - - // Values are optional - some templates accept no parameters - let values: string | undefined; try { - values = resolveJsonInput(opts.value); - } catch (error) { - handleCommandError(error, mode, { - suggestion: - 'rhdh-cli template execute --template-ref --value key=value --value otherKey=otherValue', + // Templates default to kind=template if not specified + const { namespace, name } = await resolveEntityWithAmbiguityCheck(ref, { + defaultKind: 'template', + namespaceFlag: opts.namespace, + instance: opts.instance, }); - } - let secrets: string | undefined; - try { - secrets = resolveJsonInput(opts.secret); + // Build the canonical template reference + const templateRef = `template:${namespace}/${name}`; + + // Values are optional - some templates accept no parameters + let values: string | undefined; + try { + values = resolveJsonInput(opts.value); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template execute my-template --value key=value --value otherKey=otherValue', + }); + } + + let secrets: string | undefined; + try { + secrets = resolveJsonInput(opts.secret); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template execute my-template --secret token=abc', + }); + } + + await runRawAction( + 'scaffolder:execute-template', + { + templateRef, + values, + secrets, + instance: opts.instance, + }, + mode, + 'rhdh-cli template list', + ); } catch (error) { handleCommandError(error, mode, { - suggestion: - 'rhdh-cli template execute --template-ref --secret token=abc', + suggestion: 'rhdh-cli template execute my-template', }); } - - await runRawAction( - 'scaffolder:execute-template', - { - templateRef: opts.templateRef, - values, - secrets, - instance: opts.instance, - }, - mode, - 'rhdh-cli template list', - ); }); template From fa268f1717b6999df6cd381c77f63517b7fef869 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 16:06:51 -0400 Subject: [PATCH 16/18] fix human readable output cannot parse too large json object Signed-off-by: Stephanie --- .../intent-based-actions/catalog.test.ts | 47 +++++++++++++++++++ src/commands/intent-based-actions/catalog.ts | 7 ++- .../intent-based-actions/template.test.ts | 38 +++++++++++++++ src/commands/intent-based-actions/template.ts | 9 ++++ 4 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/commands/intent-based-actions/catalog.test.ts create mode 100644 src/commands/intent-based-actions/template.test.ts diff --git a/src/commands/intent-based-actions/catalog.test.ts b/src/commands/intent-based-actions/catalog.test.ts new file mode 100644 index 0000000..329ebc2 --- /dev/null +++ b/src/commands/intent-based-actions/catalog.test.ts @@ -0,0 +1,47 @@ +import { Command } from 'commander'; +import { registerCatalogCommands } from './catalog'; +import { runEntityListAction } from './helpers'; + +jest.mock('./helpers'); + +const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< + typeof runEntityListAction +>; + +describe('catalog list', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requests only the default table fields in human output', async () => { + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'catalog', + 'list', + '--kind', + 'template', + ]); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + instance: undefined, + limit: undefined, + query: JSON.stringify({ kind: 'template' }), + fields: JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]), + }, + 'human', + 'rhdh-cli catalog list --kind Component', + undefined, + ); + }); +}); diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts index f3a90d2..60b4c4a 100644 --- a/src/commands/intent-based-actions/catalog.ts +++ b/src/commands/intent-based-actions/catalog.ts @@ -53,11 +53,16 @@ export function registerCatalogCommands(program: Command) { const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; const fields = parseList(opts.fields); + const actionFields = + fields ?? + (mode === 'human' + ? ['metadata.name', 'kind', 'metadata.namespace', 'spec.type'] + : undefined); const flags: ActionFlags = { instance: opts.instance, limit: opts.limit, - fields: fields ? JSON.stringify(fields) : undefined, + fields: actionFields ? JSON.stringify(actionFields) : undefined, }; if (Object.keys(merged).length > 0) { diff --git a/src/commands/intent-based-actions/template.test.ts b/src/commands/intent-based-actions/template.test.ts new file mode 100644 index 0000000..1c969f3 --- /dev/null +++ b/src/commands/intent-based-actions/template.test.ts @@ -0,0 +1,38 @@ +import { Command } from 'commander'; +import { runEntityListAction } from './helpers'; +import { registerTemplateCommands } from './template'; + +jest.mock('./helpers'); + +const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< + typeof runEntityListAction +>; + +describe('template list', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requests only the fields rendered in human output', async () => { + const program = new Command(); + registerTemplateCommands(program); + + await program.parseAsync(['node', 'test', 'template', 'list']); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ kind: 'Template' }), + instance: undefined, + limit: undefined, + fields: JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]), + }, + 'human', + ); + }); +}); diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts index b13bfe0..4ed46bd 100644 --- a/src/commands/intent-based-actions/template.ts +++ b/src/commands/intent-based-actions/template.ts @@ -47,6 +47,15 @@ export function registerTemplateCommands(program: Command) { query: JSON.stringify(merged), instance: opts.instance, limit: opts.limit, + fields: + mode === 'human' + ? JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]) + : undefined, }; await runEntityListAction('catalog:query-catalog-entities', flags, mode); From 037f6887bbd3dd842767537e723a169e31bb1432 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Thu, 10 Sep 2026 20:12:23 -0400 Subject: [PATCH 17/18] update CLI reference name Signed-off-by: Stephanie --- README.md | 4 ++-- docs/{CLI.md => Intent-Based-CLI.md} | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/{CLI.md => Intent-Based-CLI.md} (100%) diff --git a/README.md b/README.md index 12c1545..926e9bd 100644 --- a/README.md +++ b/README.md @@ -116,14 +116,14 @@ All commands support `--help` for detailed usage and `--output json` for machine **📚 For complete documentation, setup guides, and examples, see:** -- **[Intent-Based CLI Documentation](docs/CLI.md)** - Complete guide for RHDH interaction commands +- **[Intent-Based CLI Documentation](docs/Intent-Based-CLI.md)** - Complete guide for RHDH interaction commands ### Optional TechDocs Features **TechDocs content retrieval** (`docs list`, `docs get`, `docs coverage`, `docs build`): - Requires **TechDocs MCP extras plugin** (`techdocs-mcp-extras`) -- See the [CLI documentation](docs/CLI.md#rhdh-instance-configuration) for setup instructions +- See the [CLI documentation](docs/Intent-Based-CLI.md#rhdh-instance-configuration) for setup instructions **TechDocs search** (`docs search`): diff --git a/docs/CLI.md b/docs/Intent-Based-CLI.md similarity index 100% rename from docs/CLI.md rename to docs/Intent-Based-CLI.md From c9c96f0837a2a2f6472ef0ddb2ab026260b7205d Mon Sep 17 00:00:00 2001 From: Stephanie Date: Fri, 11 Sep 2026 14:33:06 -0400 Subject: [PATCH 18/18] address more docs command Signed-off-by: Stephanie --- .../intent-based-actions/client.test.ts | 74 +++++++- src/commands/intent-based-actions/client.ts | 29 +++ .../intent-based-actions/docs.test.ts | 177 +++++++++++++++++- src/commands/intent-based-actions/docs.ts | 112 ++++++----- .../intent-based-actions/helpers.test.ts | 22 +++ src/commands/intent-based-actions/helpers.ts | 3 +- 6 files changed, 365 insertions(+), 52 deletions(-) diff --git a/src/commands/intent-based-actions/client.test.ts b/src/commands/intent-based-actions/client.test.ts index 07dd8bb..a35c975 100644 --- a/src/commands/intent-based-actions/client.test.ts +++ b/src/commands/intent-based-actions/client.test.ts @@ -1,13 +1,23 @@ import { EventEmitter } from 'node:events'; import { execFileSync, spawn } from 'node:child_process'; -import { execAction, execActionJson, execPassthrough } from './client'; +import { CliAuth } from '@backstage/cli-node'; +import { + execAction, + execActionJson, + execPassthrough, + triggerTechDocsBuild, +} from './client'; jest.mock('node:child_process'); +jest.mock('@backstage/cli-node'); const mockExecFileSync = execFileSync as jest.MockedFunction< typeof execFileSync >; const mockSpawn = spawn as jest.MockedFunction; +const mockCliAuthCreate = CliAuth.create as jest.MockedFunction< + typeof CliAuth.create +>; function mockExecFileSyncReturning(output: string) { mockExecFileSync.mockReturnValue(output as never); @@ -170,6 +180,68 @@ describe('execActionJson', () => { }); }); +describe('triggerTechDocsBuild', () => { + const fetchMock = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = fetchMock; + mockCliAuthCreate.mockResolvedValue({ + getAccessToken: jest.fn().mockResolvedValue('test-token'), + getBaseUrl: jest.fn().mockReturnValue('https://rhdh.example.com'), + } as unknown as CliAuth); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('waits for a successful authenticated TechDocs sync response', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('build logs'), + }); + + const result = await triggerTechDocsBuild( + { + namespace: 'default', + kind: 'component', + name: 'my service', + }, + 'local', + ); + + expect(mockCliAuthCreate).toHaveBeenCalledWith({ instanceName: 'local' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://rhdh.example.com/api/techdocs/sync/default/component/my%20service', + { + headers: { Authorization: 'Bearer test-token' }, + }, + ); + expect(result).toBe('build logs'); + }); + + it('throws when the TechDocs sync endpoint returns a non-success status', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: jest.fn().mockResolvedValue('build failed'), + }); + + await expect( + triggerTechDocsBuild({ + namespace: 'default', + kind: 'component', + name: 'my-service', + }), + ).rejects.toThrow( + 'TechDocs build failed with 500 Internal Server Error: build failed', + ); + }); +}); + describe('execPassthrough', () => { let exitSpy: jest.SpyInstance; let stdoutSpy: jest.SpyInstance; diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index 52e482c..919a32c 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -1,6 +1,7 @@ import { spawn, execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; +import { CliAuth } from '@backstage/cli-node'; let resolvedCliBinary: string | undefined; @@ -142,3 +143,31 @@ export function execActionJson( return raw; } } + +export async function triggerTechDocsBuild( + entity: { namespace: string; kind: string; name: string }, + instance?: string, +): Promise { + const auth = await CliAuth.create({ instanceName: instance }); + const accessToken = await auth.getAccessToken(); + const path = [entity.namespace, entity.kind, entity.name] + .map(encodeURIComponent) + .join('/'); + const url = new URL( + `/api/techdocs/sync/${path}`, + auth.getBaseUrl(), + ).toString(); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const body = await response.text(); + + if (!response.ok) { + const status = `${response.status} ${response.statusText}`.trim(); + throw new Error( + `TechDocs build failed with ${status}${body ? `: ${body}` : ''}`, + ); + } + + return body; +} diff --git a/src/commands/intent-based-actions/docs.test.ts b/src/commands/intent-based-actions/docs.test.ts index a7b428c..8f6c27d 100644 --- a/src/commands/intent-based-actions/docs.test.ts +++ b/src/commands/intent-based-actions/docs.test.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; -import { execActionJson } from './client'; +import { execAction, execActionJson, triggerTechDocsBuild } from './client'; import { registerDocsCommands } from './docs'; -import { resolveEntityWithAmbiguityCheck } from './helpers'; +import { resolveEntityWithAmbiguityCheck, runSearchAction } from './helpers'; import { handleCommandError } from './intent-errors'; jest.mock('./client'); @@ -11,9 +11,16 @@ jest.mock('./intent-errors'); const mockExecActionJson = execActionJson as jest.MockedFunction< typeof execActionJson >; +const mockExecAction = execAction as jest.MockedFunction; +const mockTriggerTechDocsBuild = triggerTechDocsBuild as jest.MockedFunction< + typeof triggerTechDocsBuild +>; const mockHandleCommandError = handleCommandError as jest.MockedFunction< typeof handleCommandError >; +const mockRunSearchAction = runSearchAction as jest.MockedFunction< + typeof runSearchAction +>; const mockResolveEntityWithAmbiguityCheck = resolveEntityWithAmbiguityCheck as jest.MockedFunction< typeof resolveEntityWithAmbiguityCheck @@ -24,6 +31,10 @@ function captureStdout() { } describe('docs get', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('reports an unresolved entity as a catalog error', async () => { const error = new Error('Entity not found'); mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error); @@ -39,13 +50,125 @@ describe('docs get', () => { await program.parseAsync(['node', 'test', 'docs', 'get', 'missing']); expect(stderrSpy).not.toHaveBeenCalled(); - expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human', { - suggestion: 'Use an RHDH instance with techdocs-mcp-extras enabled.', + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human'); + + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('verifies that a full entity reference exists before retrieving docs', async () => { + const error = new Error('Entity not found: system:default/missing'); + mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'get', + 'system:default/missing', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'system:default/missing', + expect.objectContaining({ verifyExists: true }), + ); + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human'); + expect(mockExecActionJson).not.toHaveBeenCalled(); + }); + + it('reports missing generated docs as an error', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'System:default/rhdh-local', + kind: 'System', + namespace: 'default', + name: 'rhdh-local', }); + mockExecActionJson.mockReturnValue({ + error: 'TechDocs content not found', + }); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'get', + 'system:default/rhdh-local', + ]); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'TechDocs content not found for System:default/rhdh-local', + ), + ); + expect(exitSpy).toHaveBeenCalledWith(1); stderrSpy.mockRestore(); exitSpy.mockRestore(); }); + + it('reports missing generated docs as a structured JSON error', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'System:default/rhdh-local', + kind: 'System', + namespace: 'default', + name: 'rhdh-local', + }); + mockExecAction.mockReturnValue( + JSON.stringify({ error: 'TechDocs content not found' }), + ); + const stdoutSpy = captureStdout(); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'get', + 'system:default/rhdh-local', + '--output', + 'json', + ]); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'TechDocs content not found for System:default/rhdh-local', + }), + 'json', + { suggestion: 'rhdh-cli docs build System:default/rhdh-local' }, + ); + stdoutSpy.mockRestore(); + }); +}); + +describe('docs search', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('suggests enabling the TechDocs search backend when search fails', async () => { + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'search', 'rhdh']); + + expect(mockRunSearchAction).toHaveBeenCalledWith( + 'rhdh', + expect.objectContaining({ types: '["techdocs"]' }), + 'human', + 'Enable search-backend-module-techdocs on the RHDH instance.', + ); + }); }); describe('docs list', () => { @@ -101,3 +224,49 @@ describe('docs coverage', () => { }); }); }); + +describe('docs build', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the authenticated TechDocs sync endpoint before reporting success', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'Component:default/my-service', + kind: 'Component', + namespace: 'default', + name: 'my-service', + }); + mockTriggerTechDocsBuild.mockResolvedValue('build output'); + const writeSpy = captureStdout(); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'build', + 'component:default/my-service', + '--instance', + 'local', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'component:default/my-service', + expect.objectContaining({ verifyExists: true }), + ); + expect(mockTriggerTechDocsBuild).toHaveBeenCalledWith( + { + kind: 'component', + namespace: 'default', + name: 'my-service', + }, + 'local', + ); + expect(writeSpy).toHaveBeenCalledWith( + expect.stringContaining('TechDocs build completed'), + ); + writeSpy.mockRestore(); + }); +}); diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts index 6ee68f1..4741703 100644 --- a/src/commands/intent-based-actions/docs.ts +++ b/src/commands/intent-based-actions/docs.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { execAction, execActionJson } from './client'; +import { execAction, execActionJson, triggerTechDocsBuild } from './client'; import { runSearchAction, resolveEntityWithAmbiguityCheck, @@ -11,11 +11,38 @@ import { writeOutput, formatEntityTable, extractEntities, + type OutputMode, } from './format'; import { handleCommandError } from './intent-errors'; const RHDH_ONLY_SUGGESTION = 'Use an RHDH instance with techdocs-mcp-extras enabled.'; +const TECHDOCS_SEARCH_SUGGESTION = + 'Enable search-backend-module-techdocs on the RHDH instance.'; + +function reportMissingTechDocs(entityRef: string, mode: OutputMode): never { + if (mode === 'json') { + return handleCommandError( + new Error(`TechDocs content not found for ${entityRef}`), + mode, + { suggestion: `rhdh-cli docs build ${entityRef}` }, + ); + } + + process.stderr.write( + `${chalk.yellow('TechDocs content not found for')} ${entityRef}\n`, + ); + process.stderr.write( + `${chalk.dim('The documentation may not have been built yet.')}\n`, + ); + process.stderr.write( + `\n${chalk.dim('Trigger build with:')} ${chalk.cyan(`rhdh-cli docs build ${entityRef}`)}\n`, + ); + process.stderr.write( + `${chalk.dim('Or visit the TechDocs page in RHDH to trigger a build.')}\n`, + ); + return process.exit(1); +} export function registerDocsCommands(program: Command) { const docs = program @@ -50,7 +77,7 @@ export function registerDocsCommands(program: Command) { instance: opts.instance, }, mode, - 'rhdh-cli docs search "getting started"', + TECHDOCS_SEARCH_SUGGESTION, ); }); @@ -126,11 +153,10 @@ export function registerDocsCommands(program: Command) { kindFlag: opts.kind, namespaceFlag: opts.namespace, instance: opts.instance, + verifyExists: true, })); } catch (error) { - handleCommandError(error, mode, { - suggestion: RHDH_ONLY_SUGGESTION, - }); + handleCommandError(error, mode); return; } @@ -142,12 +168,31 @@ export function registerDocsCommands(program: Command) { }; if (mode === 'json') { - process.stdout.write( - await execAction( - 'techdocs-mcp-extras:retrieve-techdocs-content', - flags, - ), + const raw = await execAction( + 'techdocs-mcp-extras:retrieve-techdocs-content', + flags, ); + let result: unknown; + try { + result = JSON.parse(raw); + } catch { + process.stdout.write(raw); + result = undefined; + } + if (result !== undefined) { + const errorMsg = (result as Record | undefined) + ?.error; + if (typeof errorMsg === 'string') { + if ( + errorMsg.includes('not found') || + errorMsg.includes('not have been built') + ) { + reportMissingTechDocs(entityRef, mode); + } + handleCommandError(new Error(errorMsg), mode); + } + process.stdout.write(raw); + } } else { const result = await execActionJson( 'techdocs-mcp-extras:retrieve-techdocs-content', @@ -165,21 +210,9 @@ export function registerDocsCommands(program: Command) { errorMsg.includes('not found') || errorMsg.includes('not have been built') ) { - process.stderr.write( - `${chalk.yellow('TechDocs content not found for')} ${entityRef}\n`, - ); - process.stderr.write( - `${chalk.dim('The documentation may not have been built yet.')}\n`, - ); - process.stderr.write( - `\n${chalk.dim('Trigger build with:')} ${chalk.cyan(`rhdh-cli docs build ${entityRef}`)}\n`, - ); - process.stderr.write( - `${chalk.dim('Or visit the TechDocs page in RHDH to trigger a build.')}\n`, - ); - } else { - process.stderr.write(`${chalk.yellow(errorMsg)}\n`); + reportMissingTechDocs(entityRef, mode); } + handleCommandError(new Error(errorMsg), mode); } else { writeOutput(result, mode); } @@ -191,19 +224,7 @@ export function registerDocsCommands(program: Command) { errMsg.includes('not found') || errMsg.includes('not have been built') ) { - process.stderr.write( - `${chalk.yellow('TechDocs content not found for')} ${entityRef}\n`, - ); - process.stderr.write( - `${chalk.dim('The documentation may not have been built yet.')}\n`, - ); - process.stderr.write( - `\n${chalk.dim('Trigger build with:')} ${chalk.cyan(`rhdh-cli docs build ${entityRef}`)}\n`, - ); - process.stderr.write( - `${chalk.dim('Or visit the TechDocs page in RHDH to trigger a build.')}\n`, - ); - process.exit(1); + reportMissingTechDocs(entityRef, mode); } handleCommandError(error, mode, { suggestion: RHDH_ONLY_SUGGESTION, @@ -285,30 +306,29 @@ export function registerDocsCommands(program: Command) { kindFlag: opts.kind, namespaceFlag: opts.namespace, instance: opts.instance, + verifyExists: true, }); const kindLower = kind.toLowerCase(); + await triggerTechDocsBuild( + { namespace, kind: kindLower, name }, + opts.instance, + ); + if (mode === 'json') { - // For now, output success message in JSON process.stdout.write( `${JSON.stringify({ entityRef, namespace, kind: kindLower, name, - message: 'TechDocs build triggered successfully', + message: 'TechDocs build completed successfully', })}\n`, ); } else { process.stdout.write( - `${chalk.green('✓')} Triggering TechDocs build for ${chalk.cyan(entityRef)}\n`, - ); - process.stdout.write( - `${chalk.dim('Build endpoint:')} /api/techdocs/sync/${namespace}/${kindLower}/${name}\n`, - ); - process.stdout.write( - `\n${chalk.dim('Note: Build may take a few moments. Use')} ${chalk.cyan(`rhdh-cli docs get ${entityRef}`)} ${chalk.dim('to retrieve content once built.')}\n`, + `${chalk.green('✓')} TechDocs build completed for ${chalk.cyan(entityRef)}\n`, ); } } catch (error) { diff --git a/src/commands/intent-based-actions/helpers.test.ts b/src/commands/intent-based-actions/helpers.test.ts index 26681fc..5f81b6c 100644 --- a/src/commands/intent-based-actions/helpers.test.ts +++ b/src/commands/intent-based-actions/helpers.test.ts @@ -267,6 +267,28 @@ describe('resolveEntityWithAmbiguityCheck', () => { expect(mockExecActionJson).not.toHaveBeenCalled(); }); + it('rejects a full reference that is absent when existence verification is requested', async () => { + mockExecActionJson.mockReturnValue({ items: [] }); + + await expect( + resolveEntityWithAmbiguityCheck('system:default/missing', { + verifyExists: true, + }), + ).rejects.toThrow('Entity not found: system:default/missing'); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ + 'metadata.name': 'missing', + kind: 'system', + 'metadata.namespace': 'default', + }), + instance: undefined, + }, + ); + }); + it('returns directly when kind flag and namespace flag are provided', async () => { const result = await resolveEntityWithAmbiguityCheck('my-service', { kindFlag: 'component', diff --git a/src/commands/intent-based-actions/helpers.ts b/src/commands/intent-based-actions/helpers.ts index 1014fe9..0714891 100644 --- a/src/commands/intent-based-actions/helpers.ts +++ b/src/commands/intent-based-actions/helpers.ts @@ -119,6 +119,7 @@ export async function resolveEntityWithAmbiguityCheck( namespaceFlag?: string; defaultKind?: string; instance?: string; + verifyExists?: boolean; } = {}, ): Promise<{ kind: string; @@ -134,7 +135,7 @@ export async function resolveEntityWithAmbiguityCheck( const name = parsed.name; // If we have full reference (kind and namespace specified), return directly - if (kind && namespace) { + if (kind && namespace && !options.verifyExists) { return { kind, namespace,