diff --git a/src/commands/intent-based-actions/api.ts b/src/commands/intent-based-actions/api.ts new file mode 100644 index 0000000..c1e3ccc --- /dev/null +++ b/src/commands/intent-based-actions/api.ts @@ -0,0 +1,90 @@ +import { Command } from 'commander'; +import { execAction } from './client'; +import { runEntityListAction } from './helpers'; +import { parseOutputFlag, writeOutput } from './format'; +import { handleCommandError } from './intent-errors'; + +export function registerApiCommands(program: Command) { + const api = program + .command('api') + .description('Query API entities and retrieve specifications'); + + api + .command('list') + .description('List API entities in the catalog') + .option('--type ', 'API type (openapi, asyncapi, graphql, grpc)') + .option('--limit ', 'Maximum results to return', parseInt) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const query: Record = { kind: 'API' }; + if (opts.type) query['spec.type'] = opts.type; + + const flags: Record = { + query: JSON.stringify(query), + instance: opts.instance, + limit: opts.limit, + }; + + await runEntityListAction( + 'catalog:query-catalog-entities', + flags, + mode, + 'rhdh-cli api list', + ); + }); + + api + .command('get-spec') + .description( + 'Get the full API specification (OpenAPI, AsyncAPI, GraphQL, gRPC)', + ) + .option('--name ', 'API entity name (required)') + .option('--namespace ', 'Entity namespace (default: default)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + if (!opts.name) { + handleCommandError(new Error('--name is required'), mode, { + suggestion: 'rhdh-cli api get-spec --name my-api', + }); + } + try { + const raw = await execAction('catalog:get-catalog-entity', { + name: opts.name, + kind: 'API', + namespace: opts.namespace, + instance: opts.instance, + }); + + const entity = JSON.parse(raw) as Record; + const spec = entity?.spec as Record | undefined; + const definition = spec?.definition; + + if (!definition) { + handleCommandError( + new Error(`API "${opts.name}" has no spec.definition`), + mode, + { suggestion: 'rhdh-cli api list' }, + ); + } + + if (mode === 'json') { + writeOutput({ name: opts.name, type: spec?.type, definition }, mode); + } else { + const defStr = + typeof definition === 'string' + ? definition + : JSON.stringify(definition, null, 2); + process.stdout.write(`${defStr}\n`); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli api list', + }); + } + }); +} diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts new file mode 100644 index 0000000..d1d161b --- /dev/null +++ b/src/commands/intent-based-actions/catalog.ts @@ -0,0 +1,206 @@ +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 + .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 ', + '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 ', + '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 => { + const mode = parseOutputFlag(opts.output); + + const query: Record = {}; + 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: fields ? JSON.stringify(fields) : undefined, + }; + + if (Object.keys(merged).length > 0) { + flags.query = JSON.stringify(merged); + } + + await runEntityListAction( + 'catalog:query-catalog-entities', + flags, + mode, + 'rhdh-cli catalog list --kind Component', + fields, + ); + }); + + catalog + .command('get') + .description('Get a specific catalog entity by name') + .option('--name ', 'Entity name (required)') + .option('--kind ', 'Entity kind') + .option('--namespace ', 'Entity namespace (default: default)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + if (!opts.name) { + handleCommandError(new Error('--name is required'), mode, { + suggestion: 'rhdh-cli catalog get --name my-service --kind Component', + }); + } + + await runRawAction( + 'catalog:get-catalog-entity', + { + name: opts.name, + kind: opts.kind, + namespace: opts.namespace, + instance: opts.instance, + }, + mode, + 'rhdh-cli catalog list --kind Component', + ); + }); + + catalog + .command('validate') + .description('Validate entity YAML against the catalog schema') + .option('--entity ', 'Entity YAML content') + .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); + + 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 or --entity-file is required'), + mode, + { + suggestion: + 'rhdh-cli catalog validate --entity-file ./catalog-info.yaml', + }, + ); + } + + await runRawAction( + 'catalog:validate-entity', + { + entity, + location: opts.location, + instance: opts.instance, + }, + mode, + ); + }); + + catalog + .command('register') + .description('Register a catalog entity from a location URL') + .option('--location-url ', 'Location URL to register (required)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + if (!opts.locationUrl) { + handleCommandError(new Error('--location-url is required'), mode, { + suggestion: + 'rhdh-cli catalog register --location-url https://github.com/org/repo/blob/main/catalog-info.yaml', + }); + } + + await runRawAction( + 'catalog:register-entity', + { + locationUrl: opts.locationUrl, + instance: opts.instance, + }, + mode, + ); + }); + + catalog + .command('unregister') + .description('Unregister a catalog entity by location') + .option('--location-id ', 'Location ID to unregister') + .option('--location-url ', 'Location URL to unregister') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + if (!opts.locationId && !opts.locationUrl) { + handleCommandError( + new Error('--location-id or --location-url is required'), + mode, + { suggestion: 'rhdh-cli catalog unregister --location-id ' }, + ); + } + + const type: Record = {}; + if (opts.locationId) type.locationId = opts.locationId; + if (opts.locationUrl) type.locationUrl = opts.locationUrl; + + await runRawAction( + 'catalog:unregister-entity', + { + type: JSON.stringify(type), + instance: opts.instance, + }, + mode, + ); + }); +} diff --git a/src/commands/intent-based-actions/client.test.ts b/src/commands/intent-based-actions/client.test.ts index 735a434..2d50c55 100644 --- a/src/commands/intent-based-actions/client.test.ts +++ b/src/commands/intent-based-actions/client.test.ts @@ -1,11 +1,180 @@ import { EventEmitter } from 'node:events'; -import { spawn } from 'node:child_process'; -import { execPassthrough } from './client'; +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; diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index d2093fd..41aa8c5 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -1,6 +1,18 @@ -import { spawn } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { spawn, execSync } from 'node:child_process'; +import { + readFileSync, + unlinkSync, + mkdtempSync, + existsSync, + rmdirSync, +} 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; @@ -85,3 +97,89 @@ export function execPassthrough(args: string[]): void { process.exit(code ?? 1); }); } + +export async function execAction( + actionId: string, + flags: Record, +): Promise { + 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; + parts.push(`--${key}`); + if (value !== true) { + parts.push(shellEscape(String(value))); + } + } + + const dir = mkdtempSync(join(tmpdir(), 'rhdh-cli-')); + const outFile = join(dir, 'out.json'); + const errFile = join(dir, 'err.txt'); + + const cleanup = () => { + try { + unlinkSync(outFile); + } catch { + // best-effort cleanup, ignore if already removed + } + try { + unlinkSync(errFile); + } catch { + // best-effort cleanup, ignore if already removed + } + try { + rmdirSync(dir); + } catch { + // best-effort cleanup, ignore if already removed + } + }; + + try { + execSync( + `${parts.join(' ')} > ${shellEscape(outFile)} 2>${shellEscape(errFile)}`, + { + encoding: 'utf-8', + timeout: 60_000, + maxBuffer: 50 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); + + const result = readFileSync(outFile, 'utf-8'); + cleanup(); + return result; + } catch { + let errorMsg = 'backstage-cli command failed'; + if (existsSync(errFile)) { + const stderr = readFileSync(errFile, 'utf-8').trim(); + if (stderr) { + const lines = stderr.split('\n').filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + errorMsg = errorLine + ? errorLine.replace(/^\s*Error:\s*/i, '').trim() + : lines[lines.length - 1].trim(); + } + } + cleanup(); + throw new Error(rebrand(errorMsg)); + } +} + +export async function execActionJson( + actionId: string, + flags: Record, +): Promise { + const raw = await execAction(actionId, flags); + try { + return JSON.parse(raw); + } catch { + return raw; + } +} diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts new file mode 100644 index 0000000..52648e6 --- /dev/null +++ b/src/commands/intent-based-actions/docs.ts @@ -0,0 +1,212 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { execAction, execActionJson } from './client'; +import { runSearchAction } from './helpers'; +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + extractEntities, +} from './format'; +import { handleCommandError } from './intent-errors'; + +export function registerDocsCommands(program: Command) { + const docs = program + .command('docs') + .description('Search and retrieve TechDocs content'); + + docs + .command('search ') + .description('Search TechDocs content (via upstream search:query)') + .option('--page-limit ', 'Results per page (default: 10)', parseInt) + .option('--page-cursor ', 'Pagination cursor') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (termParts: string[], opts) => { + const mode = parseOutputFlag(opts.output); + const term = termParts.join(' '); + + if (!term) { + handleCommandError(new Error('Search term is required'), mode, { + suggestion: 'rhdh-cli docs search "deployment guide"', + }); + } + + await runSearchAction( + term, + { + types: '["techdocs"]', + pageLimit: opts.pageLimit, + pageCursor: opts.pageCursor, + instance: opts.instance, + }, + mode, + 'rhdh-cli docs search "getting started"', + ); + }); + + docs + .command('list') + .description( + 'List entities with TechDocs (RHDH only, via techdocs-mcp-extras)', + ) + .option( + '--entity-type ', + 'Filter by entity kind (Component, API, etc.)', + ) + .option('--owner ', 'Filter by owner') + .option( + '--lifecycle ', + 'Filter by lifecycle (production, experimental, etc.)', + ) + .option('--tags ', 'Filter by tags (comma-separated)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + try { + const flags: Record = { + entityType: opts.entityType, + owner: opts.owner, + lifecycle: opts.lifecycle, + tags: opts.tags, + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction('techdocs-mcp-extras:fetch-techdocs', flags), + ); + } else { + const result = await execActionJson( + 'techdocs-mcp-extras:fetch-techdocs', + flags, + ); + const entities = extractEntities(result); + if (entities.length > 0) { + writeOutput(entities, mode, data => + formatEntityTable(data as Array>), + ); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs list', + }); + } + }); + + docs + .command('get') + .description( + 'Get TechDocs page content for an entity (RHDH only, via techdocs-mcp-extras)', + ) + .option( + '--entity-ref ', + 'Entity reference, e.g. component:default/my-service (required)', + ) + .option('--page-path ', 'Specific doc page path (default: index)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + if (!opts.entityRef) { + handleCommandError(new Error('--entity-ref is required'), mode, { + suggestion: + 'rhdh-cli docs get --entity-ref component:default/my-service', + }); + } + try { + const flags: Record = { + entityRef: opts.entityRef, + pagePath: opts.pagePath, + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction( + 'techdocs-mcp-extras:retrieve-techdocs-content', + flags, + ), + ); + } else { + const result = await execActionJson( + 'techdocs-mcp-extras:retrieve-techdocs-content', + flags, + ); + const obj = result as Record | undefined; + const content = obj?.content ?? obj?.text; + const errorMsg = obj?.error as string | undefined; + + if (typeof content === 'string' && content.length > 0) { + process.stdout.write(`${content}\n`); + } else if (errorMsg) { + process.stderr.write(`${chalk.yellow(errorMsg)}\n`); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs list', + }); + } + }); + + docs + .command('coverage') + .description( + 'Show TechDocs coverage report (RHDH only, via techdocs-mcp-extras)', + ) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + try { + const flags: Record = { + instance: opts.instance, + }; + + if (mode === 'json') { + process.stdout.write( + await execAction( + 'techdocs-mcp-extras:analyze-techdocs-coverage', + flags, + ), + ); + } else { + const result = (await execActionJson( + 'techdocs-mcp-extras:analyze-techdocs-coverage', + flags, + )) as Record; + + const total = result?.totalEntities ?? result?.total; + const documented = + result?.entitiesWithDocs ?? + result?.documentedEntities ?? + result?.documented; + const coverage = result?.coveragePercentage ?? result?.coverage; + + if (total !== undefined) { + const lines = [ + `${chalk.bold('TechDocs Coverage Report')}`, + '', + `Total entities: ${total}`, + `Documented entities: ${documented}`, + `Coverage: ${coverage}%`, + ]; + process.stdout.write(`${lines.join('\n')}\n`); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs coverage', + }); + } + }); +} diff --git a/src/commands/intent-based-actions/format.test.ts b/src/commands/intent-based-actions/format.test.ts new file mode 100644 index 0000000..8d4821b --- /dev/null +++ b/src/commands/intent-based-actions/format.test.ts @@ -0,0 +1,228 @@ +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'); + }); + + 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', () => { + 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/format.ts b/src/commands/intent-based-actions/format.ts new file mode 100644 index 0000000..1bf805d --- /dev/null +++ b/src/commands/intent-based-actions/format.ts @@ -0,0 +1,142 @@ +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>, + 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); + + 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'); +} + +// 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); +} + +export function extractEntities( + result: unknown, +): Array> { + if (Array.isArray(result)) return result; + const obj = result as Record | undefined; + return (obj?.items ?? obj?.entities ?? []) as Array>; +} diff --git a/src/commands/intent-based-actions/helpers.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/helpers.ts b/src/commands/intent-based-actions/helpers.ts new file mode 100644 index 0000000..c32e58b --- /dev/null +++ b/src/commands/intent-based-actions/helpers.ts @@ -0,0 +1,94 @@ +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`. + * 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') { + process.stdout.write(await execAction(actionId, flags)); + } else { + const result = await execActionJson(actionId, flags); + writeOutput(extractEntities(result), mode, data => + formatEntityTable(data as Array>, fields), + ); + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} + +/** + * Runs an action whose raw output is a JSON string, and prints it either + * as-is (JSON mode) or pretty-printed (human mode). Shared by several + * `catalog` and `template` subcommands. + */ +export async function runRawAction( + actionId: string, + flags: ActionFlags, + mode: OutputMode, + suggestion?: string, +): Promise { + try { + const raw = await execAction(actionId, flags); + if (mode === 'json') { + process.stdout.write(raw); + } else { + writeOutput(JSON.parse(raw), mode); + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} + +/** + * Runs a `search:query` action and prints the results either as JSON or as + * human-readable search result snippets. Shared by `search` and `docs + * search`, which only differ in the extra flags they pass along. + */ +export async function runSearchAction( + term: string, + extraFlags: ActionFlags, + mode: OutputMode, + suggestion?: string, +): Promise { + try { + const flags: ActionFlags = { term, ...extraFlags }; + + if (mode === 'json') { + process.stdout.write(await execAction('search:query', flags)); + } else { + const result = (await execActionJson('search:query', flags)) as Record< + string, + unknown + >; + const results = (result?.results ?? result) as Array< + Record + >; + writeOutput(Array.isArray(results) ? results : result, mode, data => + formatSearchResults(data as Array>), + ); + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} diff --git a/src/commands/intent-based-actions/index.ts b/src/commands/intent-based-actions/index.ts index df5e7e6..46bc5f5 100644 --- a/src/commands/intent-based-actions/index.ts +++ b/src/commands/intent-based-actions/index.ts @@ -3,9 +3,22 @@ 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 Backstage CLI pass-through commands (auth, actions, sources). +// Registers the intent-based CLI surface: Backstage CLI pass-through +// commands (auth, actions, sources) plus the higher-level intent commands +// (catalog, api, search, docs, template) that wrap `actions execute` calls. export function registerIntentCommands(program: Command) { registerAuthCommands(program); registerActionsCommands(program); + + registerCatalogCommands(program); + registerApiCommands(program); + registerSearchCommands(program); + registerDocsCommands(program); + registerTemplateCommands(program); } diff --git a/src/commands/intent-based-actions/intent-errors.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/intent-errors.ts b/src/commands/intent-based-actions/intent-errors.ts new file mode 100644 index 0000000..c2420ef --- /dev/null +++ b/src/commands/intent-based-actions/intent-errors.ts @@ -0,0 +1,118 @@ +import chalk from 'chalk'; +import type { OutputMode } from './format'; + +export interface CliError { + error: string; + reason: string; + suggestion?: string; +} + +export function formatError(err: CliError, mode: OutputMode): string { + if (mode === 'json') { + return `${JSON.stringify(err, null, 2)}\n`; + } + + const lines = [`${chalk.red('Error:')} ${err.error}`]; + + const normalizedError = err.error.replace(/^Error:\s*/i, '').trim(); + const normalizedReason = err.reason.replace(/^Error:\s*/i, '').trim(); + if (normalizedReason && normalizedReason !== normalizedError) { + lines.push('', normalizedReason); + } + + if (err.suggestion) { + lines.push('', `${chalk.dim('Try:')}`, ` ${err.suggestion}`); + } + + return `${lines.join('\n')}\n`; +} + +export function handleCommandError( + error: unknown, + mode: OutputMode, + context?: { suggestion?: string }, +): never { + const message = extractPrimaryMessage(error); + + const cliError: CliError = { + error: message, + reason: extractReason(error), + }; + if (context?.suggestion) { + cliError.suggestion = context.suggestion; + } + + process.stderr.write(formatError(cliError, mode)); + process.exit(1); +} + +function getStderr(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('stderr' in error)) { + return undefined; + } + const { stderr } = error as { stderr: unknown }; + return typeof stderr === 'string' ? stderr : undefined; +} + +function extractReason(error: unknown): string { + if (!(error instanceof Error)) return 'Unknown error'; + + const fullMessage = collectMessages(error); + + if (fullMessage.includes('401') || fullMessage.includes('Unauthorized')) { + return 'Authentication failed or token expired. Re-authenticate with: rhdh-cli auth login'; + } + if (fullMessage.includes('404') || fullMessage.includes('Not Found')) { + return 'The requested resource was not found. Check the entity name, kind, or namespace.'; + } + if ( + fullMessage.includes('ECONNREFUSED') || + fullMessage.includes('fetch failed') + ) { + return 'Could not connect to the Backstage instance. Check that the instance is running and reachable.'; + } + if (fullMessage.includes('No authenticated instances')) { + return 'No Backstage instance configured. Run: rhdh-cli auth login --backend-url '; + } + + const stderr = getStderr(error); + if (stderr && stderr.trim()) { + const lines = stderr + .trim() + .split('\n') + .filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + return errorLine + ? errorLine.replace(/^\s*Error:\s*/i, '').trim() + : lines[0].trim(); + } + + return extractPrimaryMessage(error); +} + +function collectMessages(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + while (current instanceof Error) { + parts.push(current.message); + current = current.cause; + } + return parts.join(' '); +} + +function extractPrimaryMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + + const stderr = getStderr(error); + if (stderr && stderr.trim()) { + const lines = stderr + .trim() + .split('\n') + .filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + if (errorLine) return errorLine.replace(/^\s*Error:\s*/i, '').trim(); + return lines[0].trim(); + } + + return error.message; +} diff --git a/src/commands/intent-based-actions/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 new file mode 100644 index 0000000..91e6bc9 --- /dev/null +++ b/src/commands/intent-based-actions/search.ts @@ -0,0 +1,65 @@ +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 + .command('search ') + .description( + 'Search across all content types (catalog, TechDocs, templates)', + ) + .option( + '--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('--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"', + }); + } + + 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: types ? JSON.stringify(types) : undefined, + filters, + pageLimit: opts.pageLimit, + pageCursor: opts.pageCursor, + instance: opts.instance, + }, + mode, + '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 new file mode 100644 index 0000000..adcf5a8 --- /dev/null +++ b/src/commands/intent-based-actions/template.ts @@ -0,0 +1,174 @@ +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, resolveJsonInput } from './kv'; + +export function registerTemplateCommands(program: Command) { + const template = program + .command('template') + .description('List and execute software templates'); + + template + .command('list') + .description('List available software templates') + .option('--limit ', 'Maximum results to return', parseInt) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const flags: Record = { + query: JSON.stringify({ kind: 'Template' }), + instance: opts.instance, + limit: opts.limit, + }; + + await runEntityListAction('catalog:query-catalog-entities', flags, mode); + }); + + template + .command('execute') + .description('Execute a software template') + .option( + '--template-ref ', + 'Template entity ref, e.g. template:default/my-template (required)', + ) + .option( + '--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 => { + 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', + }); + } + + let values: string | undefined; + try { + values = resolveJsonInput(opts.value, opts.values); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + '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', + }); + } + + await runRawAction( + 'scaffolder:execute-template', + { + templateRef: opts.templateRef, + values, + secrets, + instance: opts.instance, + }, + mode, + 'rhdh-cli template list', + ); + }); + + template + .command('dry-run') + .description('Validate a software template without making changes') + .option('--template-file ', 'Path to a template YAML file (required)') + .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 => { + const mode = parseOutputFlag(opts.output); + + if (!opts.templateFile) { + handleCommandError(new Error('--template-file is required'), mode, { + suggestion: + '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', + }); + } + + // 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, + values, + instance: opts.instance, + }, + mode, + 'rhdh-cli template list', + ); + }); +}