From c10dbc31f735492697a9d5ffe7770b7aed67d917 Mon Sep 17 00:00:00 2001 From: Stephanie Date: Mon, 13 Jul 2026 17:48:45 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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; }