diff --git a/AGENTS.md b/AGENTS.md index ca3e1a7..670e90f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,21 +13,31 @@ ## Key Conventions - +- CLI command groups are co-located under `src/commands/intent-based-actions/`; + register new groups in that directory's `index.ts`. +- Keep human/JSON rendering in `format.ts`, Backstage action invocation in + `client.ts`, and command-level error presentation in `intent-errors.ts`. +- Add or update the co-located `*.test.ts` file when changing command behavior. ## Architecture - +- Intent-based commands invoke the bundled `@backstage/cli` through + `backstage-cli actions execute`; they do not call Backstage HTTP APIs + directly. +- `backstage-passthrough.ts` owns the lower-level `auth`, `actions`, and + `sources` commands, while the other files wrap action execution with + purpose-specific flags and output formatting. +- `docs list`, `docs get`, and `docs coverage` use the RHDH-only + `techdocs-mcp-extras` actions. `docs search` uses the standard + `search:query` action. ## Pattern References - +- New command group: `src/commands/intent-based-actions/catalog.ts` +- Shared list/search command behavior: `src/commands/intent-based-actions/helpers.ts` +- Human/JSON output formatting: `src/commands/intent-based-actions/format.ts` +- Structured CLI errors: `src/commands/intent-based-actions/intent-errors.ts` +- Repeatable `key=value` and JSON input parsing: `src/commands/intent-based-actions/kv.ts` ## PR Conventions diff --git a/README.md b/README.md index 1527ac6..53a03a8 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,70 @@ or when executing from the project root you can also use: npx @red-hat-developer-hub/cli ``` +## Commands + +The CLI provides two categories of commands: + +### Plugin Development Commands + +- `plugin export`: Export a Backstage plugin as a dynamic plugin +- `plugin package`: Package dynamic plugins for distribution +- `plugin check-versions`: Verify plugin compatibility with RHDH versions + +### Intent-Based RHDH Interaction Commands + +High-level commands for interacting with RHDH instances: + +- `auth`: Log in to, select, inspect, and manage authenticated RHDH instances +- `actions`: List and execute actions, and manage action-discovery sources +- `catalog`: List, get, validate, register, and unregister catalog entities +- `api`: List API entities and retrieve their OpenAPI/AsyncAPI/GraphQL specifications +- `search`: Search catalog, TechDocs, and template content +- `docs`: Search TechDocs and, on RHDH instances with optional plugins, list entities, retrieve pages, and view coverage +- `template`: List, execute, and dry-run software templates + +**Quick Examples:** + +```bash +# Authenticate with your RHDH instance +rhdh-cli auth login --backend-url https://rhdh.example.com + +# List production components +rhdh-cli catalog list --kind Component --filter spec.lifecycle=production + +# Search documentation +rhdh-cli search "deployment guide" --types '["techdocs"]' + +# Get API specification +rhdh-cli api get-spec --name my-api + +# Execute a template +rhdh-cli template execute \ + --template-ref template:default/nodejs-service \ + --value name=my-app \ + --value owner=team-platform +``` + +All commands support `--help` for detailed usage and `--output json` for machine-readable output. + +**📚 For complete documentation, setup guides, and examples, see:** + +- **[Intent-Based CLI Documentation](docs/Intent-Based-CLI.md)** - Complete guide for RHDH interaction commands + +### Optional TechDocs Features + +**TechDocs content retrieval** (`docs list`, `docs get`, `docs coverage`, `docs build`): + +- Requires **TechDocs MCP extras plugin** (`techdocs-mcp-extras`) +- See the [CLI documentation](docs/Intent-Based-CLI.md#rhdh-instance-configuration) for setup instructions + +**TechDocs search** (`docs search`): + +- Requires **TechDocs search backend module** (`search-backend-module-techdocs`) +- Standard Backstage plugin for indexing TechDocs content + +All other commands work without these optional plugins. + ### Bumping Backstage Dependencies To update the `@backstage/*` dependencies to a new Backstage release: diff --git a/docs/Intent-Based-CLI.md b/docs/Intent-Based-CLI.md new file mode 100644 index 0000000..5ca1a1d --- /dev/null +++ b/docs/Intent-Based-CLI.md @@ -0,0 +1,1082 @@ +# RHDH CLI - Intent-Based Commands Documentation + +Complete guide for using `rhdh-cli` to interact with Red Hat Developer Hub instances. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [RHDH Instance Configuration](#rhdh-instance-configuration) +- [Authentication](#authentication) +- [Register Action Sources](#register-action-sources) +- [Command to Action Mapping](#command-to-action-mapping) +- [Commands Reference](#commands-reference) + - [Catalog Commands](#catalog-commands) + - [API Commands](#api-commands) + - [Search Commands](#search-commands) + - [TechDocs Commands](#techdocs-commands) + - [Template Commands](#template-commands) + - [Auth Commands](#auth-commands) + - [Actions Commands](#actions-commands) +- [Common Workflows](#common-workflows) +- [Output Modes](#output-modes) +- [Agent Integration](#agent-integration) +- [Troubleshooting](#troubleshooting) + +## Overview + +`rhdh-cli` provides intent-based commands for querying and managing RHDH catalog entities, API specifications, TechDocs content, and software templates. All commands support both human-readable output (default) and structured JSON output (`--output json`) for automation and AI agents. + +**Key Features:** + +- **No local project context required** - Works standalone after authentication +- **Self-documenting** - `--help` provides complete command documentation +- **Agent-friendly** - JSON output mode with structured error messages +- **Multi-instance support** - Manage multiple RHDH environments + +## Installation + +```bash +# Via npx (recommended for one-off use) +npx @red-hat-developer-hub/cli + +# Via global install +npm install -g @red-hat-developer-hub/cli +rhdh-cli --help +``` + +## RHDH Instance Configuration + +Before using the CLI, your RHDH instance requires specific configuration. + +### 1. Enable the Auth Plugin + +The `rhdh-cli auth login` flow requires the `@backstage/plugin-auth` frontend plugin to serve the OAuth2 consent page. RHDH does not include this plugin by default. + +Install it as a dynamic plugin from `rhdh-plugin-export-overlays`: + +```yaml +# dynamic-plugins.yaml +plugins: + - package: 'oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-auth:bs_1.49.4__0.1.6' + disabled: false + pluginConfig: + dynamicPlugins: + frontend: + backstage.plugin-auth: + dynamicRoutes: + - path: /oauth2/* + importName: Router +``` + +### 2. Enable OAuth2 Server Endpoints + +Add to `app-config.local.yaml`: + +```yaml +auth: + experimentalClientIdMetadataDocuments: + enabled: true + experimentalRefreshToken: + enabled: true +``` + +### 3. Enable TechDocs MCP Extras Plugin (Optional) + +To use TechDocs actions (`docs list`, `docs get`, `docs coverage`), install the TechDocs MCP extras plugin: + +```yaml +# dynamic-plugins.yaml +plugins: + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-techdocs-mcp-extras:bs_1.49.4__0.2.3 + disabled: false +``` + +**Note:** Only `docs list`, `docs get`, `docs coverage`, and `docs build` require this plugin. + +### 4. Enable TechDocs Search Backend Module (Optional) + +To use TechDocs search functionality (`search --types '["techdocs"]'` and `docs search`), install the TechDocs search backend module: + +```yaml +# dynamic-plugins.yaml +plugins: + - package: 'oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-search-backend-module-techdocs:bs_1.52.0__0.4.15' + disabled: false +``` + +**Note:** This is a standard Backstage plugin for indexing TechDocs content in the search backend. + +## Authentication + +Authenticate with your RHDH instance: + +```bash +rhdh-cli auth login --backend-url https://rhdh.example.com +``` + +This opens a browser for OAuth2 consent. After approval, credentials are stored locally. + +**Verify authentication:** + +```bash +rhdh-cli auth show +``` + +### Managing Multiple Instances + +```bash +# List all authenticated instances +rhdh-cli auth list + +# Select active instance (interactive) +rhdh-cli auth select + +# Login with instance name +rhdh-cli auth login --backend-url https://rhdh-prod.example.com --instance production + +# Use specific instance for a command +rhdh-cli catalog list --kind Component --instance production +``` + +## Register Action Sources + +The CLI maintains its own client-side source list. Register sources for the plugins available on your instance: + +```bash +rhdh-cli actions sources add catalog +rhdh-cli actions sources add scaffolder +rhdh-cli actions sources add search +rhdh-cli actions sources add auth +rhdh-cli actions sources add notifications +rhdh-cli actions sources add techdocs-mcp-extras # If plugin is installed +``` + +**Verify registration:** + +```bash +rhdh-cli actions sources list +``` + +**Important Notes:** + +- Source registration is per-instance. Switching instances with `auth select` requires re-adding sources. +- Only add sources for plugins that have the actions backend endpoint. +- Adding a source for a plugin without it causes `actions list` to fail entirely. + +## Command to Action Mapping + +The following table shows how intent-based CLI commands map to underlying Backstage actions: + +| Command | Action ID | Notes | +| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `catalog list` | `catalog:query-catalog-entities` | Supports `--kind`, `--type`, `--filter` (repeatable), `--limit`, `--fields` | +| `catalog get ` | `catalog:query-catalog-entities` + `catalog:get-catalog-entity` | Queries catalog for ambiguity check; `--kind`, `--namespace` to filter/disambiguate | +| `catalog validate` | `catalog:validate-entity` | Accepts `--entity` or `--entity-file` | +| `catalog register` | `catalog:register-entity` | Requires `--location-url` | +| `catalog unregister` | `catalog:unregister-entity` | Requires `--location-id` or `--location-url` | +| `api list` | `catalog:query-catalog-entities` | Hardcoded `kind=API`, supports `--type`, `--filter` (repeatable) | +| `api get-spec ` | `catalog:query-catalog-entities` + `catalog:get-catalog-entity` | Queries catalog with `kind=api` default for ambiguity check; extracts `spec.definition` | +| `search ` | `search:query` | Supports `--types`, `--page-limit`, `--page-cursor` | +| `docs search ` | `search:query` | Hardcoded `types=["techdocs"]`; requires `search-backend-module-techdocs` plugin | +| `docs list` | `techdocs-mcp-extras:fetch-techdocs` | RHDH only, requires plugin; supports `--kind`, `--owner`, `--lifecycle`, `--tags` | +| `docs get ` | `catalog:query-catalog-entities` + `techdocs-mcp-extras:retrieve-techdocs-content` | RHDH only; queries catalog for ambiguity check; `--kind`, `--namespace` to filter/disambiguate | +| `docs build ` | `catalog:query-catalog-entities` + TechDocs sync endpoint | Queries catalog for ambiguity check; `--kind`, `--namespace` to filter/disambiguate | +| `docs coverage` | `techdocs-mcp-extras:analyze-techdocs-coverage` | RHDH only, requires plugin | +| `template list` | `catalog:query-catalog-entities` | Hardcoded `kind=Template`, supports `--filter` (repeatable) | +| `template execute ` | `catalog:query-catalog-entities` + `scaffolder:execute-template` | Queries catalog with `kind=template` default for ambiguity check; `--namespace` to filter/disambiguate | +| `template dry-run` | `scaffolder:dry-run-template` | Requires `--template-file`; `--value` (repeatable) is optional; reads YAML from disk | +| `auth *` | Pass-through to `backstage-cli auth *` | Output rebranded as `rhdh-cli` | +| `actions *` | Pass-through to `backstage-cli actions *` | Output rebranded as `rhdh-cli` | + +**Note:** Commands marked "RHDH only" require the `techdocs-mcp-extras` plugin to be installed on your RHDH instance. See [RHDH Instance Configuration](#rhdh-instance-configuration) for setup instructions. + +## Commands Reference + +All commands support: + +- `--help` for detailed usage information +- `--output json` for machine-readable structured output +- `--instance ` to target a specific authenticated RHDH instance + +### Catalog Commands + +Query and manage the RHDH software catalog. + +#### `catalog list` + +List catalog entities with filtering and field selection. + +```bash +# List all components +rhdh-cli catalog list --kind Component + +# Filter by type and lifecycle +rhdh-cli catalog list --kind Component --type service --filter spec.lifecycle=production + +# Multiple filters +rhdh-cli catalog list \ + --kind Component \ + --type service \ + --filter spec.lifecycle=production \ + --filter spec.owner=team-platform + +# Limit results +rhdh-cli catalog list --kind Component --limit 50 + +# Select specific fields +rhdh-cli catalog list --kind Component --fields metadata.name,spec.owner,spec.lifecycle + +# JSON output for automation +rhdh-cli catalog list --kind Component --output json +``` + +**Options:** + +- `--kind ` - Entity kind (Component, API, System, User, Group, etc.) +- `--type ` - Entity type (service, website, library, etc.) +- `--filter ` - Query predicate (repeatable), e.g., `--filter spec.lifecycle=production` +- `--limit ` - Maximum results to return +- `--fields ` - Comma-separated fields to include +- `--output ` - Output format: `human` (default) or `json` +- `--instance ` - RHDH instance name + +#### `catalog get` + +Get a specific catalog entity. + +```bash +# Short name (queries catalog, works if unambiguous) +rhdh-cli catalog get my-service + +# Full entity reference +rhdh-cli catalog get component:default/my-service + +# Namespace/name format with --kind to filter +rhdh-cli catalog get default/my-service --kind component + +# With disambiguation flags +rhdh-cli catalog get my-service --kind component --namespace production + +# JSON output +rhdh-cli catalog get component:default/my-service --output json +``` + +**Positional Argument:** + +- `` - **Required.** Entity reference in format `[kind:][namespace/]name` + - `my-service` - short name + - `default/my-service` - namespace/name + - `component:default/my-service` - full reference + +**Options:** + +- `--kind ` - Entity kind (to filter/disambiguate) +- `--namespace ` - Entity namespace (to filter/disambiguate) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Behavior:** + +When a short name or partial reference is provided, the CLI queries the catalog to find all matching entities: + +- If exactly 1 match: uses that entity +- If 0 matches: errors "Entity not found" +- If > 1 matches: errors listing all matching entities + +Use `--kind` and/or `--namespace` flags to narrow the search and avoid ambiguity. + +**Error Example:** + +```bash +$ rhdh-cli catalog get my-service +Error: Ambiguous entity reference. Multiple entities named "my-service" found: + component:default/my-service + component:production/my-service + api:default/my-service + +Use full reference to disambiguate. + +$ rhdh-cli catalog get my-service --kind component +Error: Ambiguous entity reference. Multiple entities named "my-service" found: + component:default/my-service + component:production/my-service + +Use full reference to disambiguate. +``` + +#### `catalog validate` + +Validate entity YAML against the catalog schema. + +```bash +# Validate from file +rhdh-cli catalog validate --entity-file ./catalog-info.yaml + +# Validate inline YAML +rhdh-cli catalog validate --entity "apiVersion: backstage.io/v1alpha1..." + +# With location +rhdh-cli catalog validate --entity-file ./catalog-info.yaml --location https://github.com/org/repo +``` + +**Options:** + +- `--entity ` - Entity YAML content +- `--entity-file ` - Path to entity YAML file +- `--location ` - Location to validate +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `catalog register` + +Register a catalog entity from a location URL. + +```bash +# Register from GitHub +rhdh-cli catalog register \ + --location-url https://github.com/myorg/myrepo/blob/main/catalog-info.yaml +``` + +**Options:** + +- `--location-url ` - Location URL to register (required) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `catalog unregister` + +Unregister a catalog entity by location. + +```bash +# Unregister by location ID +rhdh-cli catalog unregister --location-id + +# Unregister by location URL +rhdh-cli catalog unregister --location-url https://github.com/org/repo/blob/main/catalog-info.yaml +``` + +**Options:** + +- `--location-id ` - Location ID to unregister +- `--location-url ` - Location URL to unregister +- `--output ` - Output format +- `--instance ` - RHDH instance name + +### API Commands + +Query API entities and retrieve specifications. + +#### `api list` + +List API entities in the catalog. + +```bash +# List all APIs +rhdh-cli api list + +# Filter by type +rhdh-cli api list --type openapi +rhdh-cli api list --type graphql + +# Filter by owner +rhdh-cli api list --filter spec.owner=team-a + +# Combine filters +rhdh-cli api list --type openapi --filter spec.lifecycle=production + +# JSON output +rhdh-cli api list --output json +``` + +**Options:** + +- `--type ` - API type (`openapi`, `asyncapi`, `graphql`, `grpc`) +- `--filter ` - Query predicate (repeatable) +- `--limit ` - Maximum results to return +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `api get-spec` + +Get the full API specification (OpenAPI, AsyncAPI, GraphQL, gRPC). + +```bash +# Short name (queries catalog with kind=api filter) +rhdh-cli api get-spec my-api + +# Full entity reference +rhdh-cli api get-spec api:default/my-api + +# With custom namespace to disambiguate +rhdh-cli api get-spec my-api --namespace production + +# Save to file +rhdh-cli api get-spec my-api > openapi.yaml + +# JSON output +rhdh-cli api get-spec my-api --output json +``` + +**Positional Argument:** + +- `` - **Required.** API entity reference in `[kind:][namespace/]name` format + +**Options:** + +- `--namespace ` - Entity namespace (to filter/disambiguate) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Behavior:** + +When a short name is provided, queries the catalog filtering by `kind=api`. If multiple API entities with the same name exist in different namespaces, an ambiguity error is shown. + +**Error Example:** + +```bash +$ rhdh-cli api get-spec my-api +Error: Ambiguous entity reference. Multiple entities named "my-api" found: + api:default/my-api + api:production/my-api + +Use full reference to disambiguate. +``` + +**Output:** + +- Human mode: Raw specification (YAML or schema) +- JSON mode: `{"name": "...", "type": "openapi", "definition": "..."}` + +### Search Commands + +Search across catalog, TechDocs, and templates. + +#### `search ` + +Search all content types. + +```bash +# Search everything +rhdh-cli search "deployment guide" + +# Search specific types +rhdh-cli search "authentication" --types '["techdocs"]' +rhdh-cli search "component" --types '["software-catalog"]' + +# Pagination +rhdh-cli search "query" --page-limit 20 --page-cursor + +# JSON output +rhdh-cli search "deployment" --output json +``` + +**Options:** + +- `` - Search term (required) +- `--types ` - Content types to search (JSON array) +- `--page-limit ` - Results per page (default: 10) +- `--page-cursor ` - Pagination cursor +- `--output ` - Output format +- `--instance ` - RHDH instance name + +### TechDocs Commands + +Search and retrieve TechDocs content. + +#### `docs search ` + +Search TechDocs content (requires TechDocs search backend module). + +```bash +# Search TechDocs +rhdh-cli docs search "getting started" + +# With pagination +rhdh-cli docs search "API reference" --page-limit 20 + +# JSON output +rhdh-cli docs search "deployment" --output json +``` + +**Options:** + +- `` - Search term (required) +- `--page-limit ` - Results per page (default: 10) +- `--page-cursor ` - Pagination cursor +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Note:** Requires the TechDocs search backend module plugin. See [RHDH Instance Configuration](#rhdh-instance-configuration) for setup. + +#### `docs list` + +List entities with TechDocs (RHDH only, requires `techdocs-mcp-extras` plugin). + +```bash +# List all entities with docs +rhdh-cli docs list + +# Filter by entity kind +rhdh-cli docs list --kind Component + +# Filter by owner and lifecycle +rhdh-cli docs list --owner team-platform --lifecycle production + +# JSON output +rhdh-cli docs list --output json +``` + +**Options:** + +- `--kind ` - Filter by entity kind (Component, API, etc.) +- `--owner ` - Filter by owner +- `--lifecycle ` - Filter by lifecycle (production, experimental, etc.) +- `--tags ` - Filter by tags (comma-separated) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. + +#### `docs get` + +Get TechDocs page content for an entity (RHDH only, requires `techdocs-mcp-extras` plugin). + +```bash +# Short name (queries catalog, works if unambiguous) +rhdh-cli docs get my-service + +# Full entity reference +rhdh-cli docs get component:default/my-service + +# Short name with --kind to filter/disambiguate +rhdh-cli docs get my-service --kind component + +# Get specific page +rhdh-cli docs get component:default/my-service --page-path architecture/overview + +# With custom namespace +rhdh-cli docs get api:production/my-api + +# Save to file +rhdh-cli docs get component:default/my-service > README.md + +# JSON output +rhdh-cli docs get component:default/my-service --output json +``` + +**Positional Argument:** + +- `` - **Required.** Entity reference in `[kind:][namespace/]name` format + +**Options:** + +- `--kind ` - Entity kind (to filter/disambiguate), e.g., component, api, system +- `--namespace ` - Entity namespace (to filter/disambiguate) +- `--page-path ` - Specific doc page path (default: index) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Behavior:** + +When a short name is provided, the CLI queries the catalog to find all matching entities: + +- If exactly 1 match: uses that entity +- If 0 matches: errors "Entity not found" +- If > 1 matches: errors listing all matching entities + +**Error Example:** + +```bash +$ rhdh-cli docs get my-service +Error: Ambiguous entity reference. Multiple entities named "my-service" found: + component:default/my-service + system:default/my-service + +Use full reference to disambiguate. +``` + +**Output:** + +- Human mode: Plain text content (HTML stripped) +- JSON mode: `{"entityRef": "...", "content": "...", "pageTitle": "...", "metadata": {...}}` + +**Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. + +#### `docs build` + +Trigger TechDocs build for an entity. + +```bash +# Short name (queries catalog, works if unambiguous) +rhdh-cli docs build my-service + +# Full entity reference +rhdh-cli docs build component:default/my-service + +# Short name with --kind to filter/disambiguate +rhdh-cli docs build my-service --kind component + +# With custom namespace +rhdh-cli docs build api:production/my-api + +# JSON output +rhdh-cli docs build component:default/my-service --output json +``` + +**Positional Argument:** + +- `` - **Required.** Entity reference in `[kind:][namespace/]name` format + +**Options:** + +- `--kind ` - Entity kind (to filter/disambiguate), e.g., component, api, system +- `--namespace ` - Entity namespace (to filter/disambiguate) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Behavior:** + +Like `docs get`, queries the catalog for ambiguity detection. See `docs get` for details. + +**Output:** + +``` +✓ Triggering TechDocs build for component:default/my-service +Build endpoint: /api/techdocs/sync/default/component/my-service + +Note: Build may take a few moments. Use rhdh-cli docs get component:default/my-service to retrieve content once built. +``` + +**Use Case:** +When you try to get documentation that hasn't been built yet, you'll see: + +```bash +$ rhdh-cli docs get system:default/rhdh-local +TechDocs content not found for system:default/rhdh-local +The documentation may not have been built yet. + +Trigger build with: rhdh-cli docs build system:default/rhdh-local +Or visit the TechDocs page in RHDH to trigger a build. +``` + +#### `docs coverage` + +Show TechDocs coverage report (RHDH only, requires `techdocs-mcp-extras` plugin). + +```bash +# Get coverage report +rhdh-cli docs coverage + +# JSON output +rhdh-cli docs coverage --output json +``` + +**Options:** + +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Output:** + +``` +TechDocs Coverage Report + +Total entities: 150 +Documented entities: 120 +Coverage: 80% +``` + +**Note:** Requires `techdocs-mcp-extras` plugin on RHDH instance. + +### Template Commands + +List and execute software templates. + +#### `template list` + +List available software templates. + +```bash +# List all templates +rhdh-cli template list + +# Filter by tags +rhdh-cli template list --filter metadata.tags=nodejs + +# Filter by owner +rhdh-cli template list --filter spec.owner=team-platform + +# Limit results +rhdh-cli template list --limit 20 + +# JSON output +rhdh-cli template list --output json +``` + +**Options:** + +- `--filter ` - Query predicate (repeatable) +- `--limit ` - Maximum results to return +- `--output ` - Output format +- `--instance ` - RHDH instance name + +#### `template execute` + +Execute a software template. + +```bash +# Short name (queries catalog with kind=template filter) +rhdh-cli template execute nodejs-service + +# Full template reference +rhdh-cli template execute template:default/nodejs-service + +# Execute with key-value pairs +rhdh-cli template execute nodejs-service \ + --value name=my-app \ + --value owner=team-a + +# With custom namespace to disambiguate +rhdh-cli template execute react-app \ + --namespace production \ + --value name=my-app \ + --value owner=team-frontend + +# With secrets (use with caution - visible in process list) +rhdh-cli template execute my-template \ + --value name=my-app \ + --secret token=abc123 + +# JSON output +rhdh-cli template execute my-template \ + --value name=my-app \ + --output json +``` + +**Positional Argument:** + +- `` - **Required.** Template reference in `[kind:][namespace/]name` format + +**Options:** + +- `--namespace ` - Template namespace (to filter/disambiguate) +- `--value ` - Template input value (repeatable, optional) +- `--secret ` - Template secret (repeatable, optional) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +**Behavior:** + +When a short name is provided, queries the catalog filtering by `kind=template`. If multiple templates with the same name exist in different namespaces, an ambiguity error is shown. + +**Error Example:** + +```bash +$ rhdh-cli template execute my-template +Error: Ambiguous entity reference. Multiple entities named "my-template" found: + template:default/my-template + template:production/my-template + +Use full reference to disambiguate. +``` + +**Note:** Values and secrets are optional — some templates accept no parameters. + +**Security Warning:** `--secret` flags are visible in process lists on shared systems. Use with caution. + +#### `template dry-run` + +Validate a software template without making changes. + +```bash +# Dry-run from file +rhdh-cli template dry-run \ + --template-file ./template.yaml \ + --value name=test-app \ + --value owner=test-team + +# JSON output +rhdh-cli template dry-run \ + --template-file ./template.yaml \ + --value name=test-app \ + --output json +``` + +**Options:** + +- `--template-file ` - Path to template YAML file (required) +- `--value ` - Template input value (repeatable) +- `--output ` - Output format +- `--instance ` - RHDH instance name + +### Auth Commands + +Manage authenticated RHDH instances. + +```bash +# Login to RHDH instance +rhdh-cli auth login --backend-url https://rhdh.example.com + +# Login with instance name +rhdh-cli auth login --backend-url https://rhdh.example.com --instance production + +# List authenticated instances +rhdh-cli auth list + +# Select active instance +rhdh-cli auth select + +# Show current instance details +rhdh-cli auth show + +# Print access token +rhdh-cli auth print-token + +# Logout +rhdh-cli auth logout +``` + +### Actions Commands + +List and execute RHDH actions directly. + +```bash +# List available actions on the RHDH instance +rhdh-cli actions list + +# Execute an action directly +rhdh-cli actions execute catalog:query-catalog-entities --query '{"kind":"Component"}' + +# Manage action sources +rhdh-cli actions sources list +rhdh-cli actions sources add +rhdh-cli actions sources remove +``` + +**Note:** Intent-based commands (`catalog`, `api`, `docs`, `template`) are recommended over direct action execution for better usability and error messages. + +## Common Workflows + +### Workflow 1: Find All Production Services + +```bash +rhdh-cli catalog list \ + --kind Component \ + --type service \ + --filter spec.lifecycle=production \ + --fields metadata.name,spec.owner,metadata.description \ + --output json +``` + +### Workflow 2: Get API Specification for Integration + +```bash +# 1. Find the API +rhdh-cli api list --type openapi --output json + +# 2. Get the OpenAPI spec +rhdh-cli api get-spec --name my-api --output json +``` + +### Workflow 3: Search Documentation and Retrieve Content + +```bash +# 1. Search for relevant docs +rhdh-cli docs search "deployment" --output json + +# 2. Get specific doc page (RHDH only) +rhdh-cli docs get --entity-ref component:default/my-service --page-path deployment +``` + +### Workflow 4: Validate and Register New Entity + +```bash +# 1. Validate locally +rhdh-cli catalog validate --entity-file ./catalog-info.yaml --output json + +# 2. Register if valid +rhdh-cli catalog register \ + --location-url https://github.com/org/repo/blob/main/catalog-info.yaml +``` + +### Workflow 5: Create New Service from Template + +```bash +# 1. Browse available templates +rhdh-cli template list + +# 2. Execute template +rhdh-cli template execute \ + --template-ref template:default/nodejs-microservice \ + --value name=payment-service \ + --value description="Payment processing service" \ + --value owner=team-payments \ + --value port=8080 +``` + +## Output Modes + +All commands support two output modes: + +### Human-Readable Mode (Default) + +Formatted for CLI use with colors, tables, and readable text. + +```bash +rhdh-cli catalog list --kind Component +``` + +### JSON Mode + +Structured output for automation, scripting, and AI agents. + +```bash +rhdh-cli catalog list --kind Component --output json +``` + +**JSON Error Format:** + +```json +{ + "error": "Error message", + "reason": "Detailed explanation", + "suggestion": "rhdh-cli catalog list --kind Component" +} +``` + +**Exit Codes:** + +- `0` - Success +- Non-zero - Error occurred (check stderr and JSON error object) + +## Agent Integration + +### Best Practices for AI Agents + +1. **Always use JSON output:** `--output json` for all commands +2. **Parse errors from JSON:** Check for `error` field in response +3. **Use specific filters:** Leverage `--kind`, `--type`, `--filter` to reduce result size +4. **Respect pagination:** Use `--limit` and cursor-based pagination for large result sets +5. **Handle field selection:** Use `--fields` to retrieve only needed data +6. **Instance-specific queries:** Use `--instance` when working with multiple RHDH environments +7. **Error recovery:** Parse `suggestion` field from error responses for corrective actions + +### Discovery via --help + +All commands support `--help` for complete documentation: + +```bash +rhdh-cli --help +rhdh-cli catalog --help +rhdh-cli catalog list --help +rhdh-cli api get-spec --help +``` + +The `--help` output is the complete protocol contract — agents can operate from help text alone without external documentation. + +### Example Agent Workflow + +```bash +# 1. Authenticate +rhdh-cli auth login --backend-url https://rhdh.example.com + +# 2. Register action sources +rhdh-cli actions sources add catalog +rhdh-cli actions sources add scaffolder + +# 3. Query entities +rhdh-cli catalog list \ + --kind Component \ + --filter spec.lifecycle=production \ + --fields metadata.name,spec.owner \ + --output json | jq '.entities[].metadata.name' + +# 4. Get API spec +rhdh-cli api get-spec --name my-api --output json | jq '.definition' + +# 5. Execute template +rhdh-cli template execute \ + --template-ref template:default/service \ + --value name=new-service \ + --value owner=team-a \ + --output json +``` + +## Troubleshooting + +### Authentication Errors + +```bash +# Check current auth status +rhdh-cli auth show + +# Re-authenticate +rhdh-cli auth login --backend-url https://rhdh.example.com + +# If using wrong RHDH instance, select the right one +rhdh-cli auth select +``` + +### Action Not Found + +If a command reports an action is not available: + +- Ensure your RHDH instance version is 1.10 or newer +- Verify required plugins are installed on the RHDH instance (e.g., `techdocs-mcp-extras` for `docs list/get/coverage`) +- Check that action sources are registered: `rhdh-cli actions sources list` +- Use `rhdh-cli actions list` to see all available actions on the connected RHDH instance + +### TechDocs Commands Failing + +If `docs list`, `docs get`, or `docs coverage` fail: + +- These commands require the `techdocs-mcp-extras` plugin on your RHDH instance +- Verify the plugin is installed and enabled on the RHDH instance +- Check server-side configuration in `app-config.local.yaml` +- Verify client-side source registration: `rhdh-cli actions sources list` should show `techdocs-mcp-extras` +- Use `docs search` as an alternative, which works with all RHDH instances + +### Output Parsing Issues + +If JSON output is malformed: + +- Check for errors on stderr +- Verify exit code (0 = success) +- Ensure you included `--output json` flag + +### Large Result Sets + +For catalogs with many entities: + +```bash +# Use limits +rhdh-cli catalog list --kind Component --limit 100 + +# Use specific filters +rhdh-cli catalog list \ + --kind Component \ + --type service \ + --filter spec.lifecycle=production + +# Select only needed fields +rhdh-cli catalog list \ + --kind Component \ + --fields metadata.name,spec.owner +``` + +### Multiple Instance Confusion + +```bash +# Check which instance is active +rhdh-cli auth show + +# List all instances +rhdh-cli auth list + +# Switch instance +rhdh-cli auth select + +# Or use --instance flag for one-off commands +rhdh-cli catalog list --kind Component --instance production +``` diff --git a/src/commands/intent-based-actions/api.ts b/src/commands/intent-based-actions/api.ts new file mode 100644 index 0000000..5155eb1 --- /dev/null +++ b/src/commands/intent-based-actions/api.ts @@ -0,0 +1,115 @@ +import { Command } from 'commander'; +import { execAction } from './client'; +import { + runEntityListAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} from './helpers'; +import { parseOutputFlag, writeOutput } from './format'; +import { handleCommandError } from './intent-errors'; +import { collect, resolveJsonInput } from './kv'; + +export function registerApiCommands(program: Command) { + const api = program + .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( + '--filter ', + 'Query predicate, e.g. --filter spec.owner=team-a (repeatable)', + collect, + [] as string[], + ) + .option('--limit ', 'Maximum results to return', parseInt) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const query: Record = { kind: 'API' }; + if (opts.type) query['spec.type'] = opts.type; + + let predicate: string | undefined; + try { + predicate = resolveJsonInput(opts.filter); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli api list --type openapi --filter spec.owner=team-a', + }); + } + // --filter flags merge on top of the --type shortcut. + const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; + + const flags: ActionFlags = { + query: JSON.stringify(merged), + 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('--namespace ', 'Entity namespace (to filter/disambiguate)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (ref: string, opts) => { + const mode = parseOutputFlag(opts.output); + + try { + // APIs default to kind=api if not specified + const { name, namespace } = await resolveEntityWithAmbiguityCheck(ref, { + defaultKind: 'api', + namespaceFlag: opts.namespace, + instance: opts.instance, + }); + + const raw = await execAction('catalog:get-catalog-entity', { + name, + kind: 'API', + 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 "${name}" has no spec.definition`), + mode, + { suggestion: 'rhdh-cli api list' }, + ); + } + + if (mode === 'json') { + writeOutput({ name, type: spec?.type, definition }, mode); + } else { + const defStr = + typeof definition === 'string' + ? definition + : JSON.stringify(definition, null, 2); + process.stdout.write(`${defStr}\n`); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli api list', + }); + } + }); +} diff --git a/src/commands/intent-based-actions/backstage-passthrough.ts b/src/commands/intent-based-actions/backstage-passthrough.ts index a1d34a5..b4d4bbd 100644 --- a/src/commands/intent-based-actions/backstage-passthrough.ts +++ b/src/commands/intent-based-actions/backstage-passthrough.ts @@ -25,12 +25,44 @@ export function registerAuthCommands(program: Command) { .command('auth') .description('Manage authentication to Backstage/RHDH instances'); - registerPassthroughCommand( - auth, - 'login', - 'Log in to a Backstage/RHDH instance', - ['auth', 'login'], - ); + // Special handling for 'login' to support --backend-url + auth + .command('login') + .description('Log in to a Backstage/RHDH instance') + .option('--backend-url ', 'Backend base URL') + .option('--instance ', 'Name for this instance') + .option('--no-browser', 'Do not open browser automatically') + .allowUnknownOption() + .action(function loginAction(this: Command, opts: Record) { + const args: string[] = ['auth', 'login']; + + // Forward --backend-url if provided + if (opts.backendUrl) { + args.push('--backend-url', String(opts.backendUrl)); + } + + // Forward other known options + if (opts.instance) { + args.push('--instance', String(opts.instance)); + } + if (opts.browser === false) { + args.push('--no-browser'); + } + + // Forward any unknown options + const knownOpts = ['backendUrl', 'instance', 'browser']; + for (const [key, value] of Object.entries(opts)) { + if (!knownOpts.includes(key) && value !== undefined) { + args.push(`--${key}`); + if (value !== true) { + args.push(String(value)); + } + } + } + + execPassthrough(args); + }); + registerPassthroughCommand( auth, 'logout', diff --git a/src/commands/intent-based-actions/catalog.test.ts b/src/commands/intent-based-actions/catalog.test.ts new file mode 100644 index 0000000..329ebc2 --- /dev/null +++ b/src/commands/intent-based-actions/catalog.test.ts @@ -0,0 +1,47 @@ +import { Command } from 'commander'; +import { registerCatalogCommands } from './catalog'; +import { runEntityListAction } from './helpers'; + +jest.mock('./helpers'); + +const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< + typeof runEntityListAction +>; + +describe('catalog list', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requests only the default table fields in human output', async () => { + const program = new Command(); + registerCatalogCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'catalog', + 'list', + '--kind', + 'template', + ]); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + instance: undefined, + limit: undefined, + query: JSON.stringify({ kind: 'template' }), + fields: JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]), + }, + 'human', + 'rhdh-cli catalog list --kind Component', + undefined, + ); + }); +}); diff --git a/src/commands/intent-based-actions/catalog.ts b/src/commands/intent-based-actions/catalog.ts new file mode 100644 index 0000000..60b4c4a --- /dev/null +++ b/src/commands/intent-based-actions/catalog.ts @@ -0,0 +1,224 @@ +import { readFileSync } from 'node:fs'; +import { Command } from 'commander'; +import { + runEntityListAction, + runRawAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} 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('--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); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli catalog list --kind Component --filter spec.lifecycle=production', + }); + } + // --filter flags merge on top of the --kind/--type shortcuts. + const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; + + const fields = parseList(opts.fields); + const actionFields = + fields ?? + (mode === 'human' + ? ['metadata.name', 'kind', 'metadata.namespace', 'spec.type'] + : undefined); + + const flags: ActionFlags = { + instance: opts.instance, + limit: opts.limit, + fields: actionFields ? JSON.stringify(actionFields) : 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') + .option('--kind ', 'Entity kind (to disambiguate short names)') + .option( + '--namespace ', + 'Entity namespace (to disambiguate short names)', + ) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (ref: string, opts) => { + const mode = parseOutputFlag(opts.output); + + try { + const { name, kind, namespace } = await resolveEntityWithAmbiguityCheck( + ref, + { + kindFlag: opts.kind, + namespaceFlag: opts.namespace, + instance: opts.instance, + }, + ); + + await runRawAction( + 'catalog:get-catalog-entity', + { + name, + kind, + namespace, + instance: opts.instance, + }, + mode, + 'rhdh-cli catalog get my-service', + ); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli catalog get component:default/my-service', + }); + } + }); + + 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 631b745..21b2596 100644 --- a/src/commands/intent-based-actions/client.test.ts +++ b/src/commands/intent-based-actions/client.test.ts @@ -1,10 +1,246 @@ import { EventEmitter } from 'node:events'; -import { spawn } from 'node:child_process'; -import { execPassthrough } from './client'; +import { execFileSync, spawn } from 'node:child_process'; +import { CliAuth } from '@backstage/cli-node'; +import { + execAction, + execActionJson, + execPassthrough, + triggerTechDocsBuild, +} from './client'; jest.mock('node:child_process'); +jest.mock('@backstage/cli-node'); +const mockExecFileSync = execFileSync as jest.MockedFunction< + typeof execFileSync +>; const mockSpawn = spawn as jest.MockedFunction; +const mockCliAuthCreate = CliAuth.create as jest.MockedFunction< + typeof CliAuth.create +>; + +function mockExecFileSyncReturning(output: string) { + mockExecFileSync.mockReturnValue(output as never); +} + +function mockExecFileSyncThrowing(stderr: string) { + mockExecFileSync.mockImplementation(() => { + const error = new Error('Command failed') as Error & { stderr: Buffer }; + error.stderr = Buffer.from(stderr); + throw error; + }); +} + +describe('execAction', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the Backstage CLI stdout', async () => { + mockExecFileSyncReturning('{"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 () => { + mockExecFileSyncReturning('{}'); + + await execAction('catalog:query-catalog-entities', { + instance: 'default', + limit: 5, + }); + + const [command, args] = mockExecFileSync.mock.calls[0]; + expect(command).toBe(process.execPath); + expect(args).toEqual( + expect.arrayContaining([ + 'actions', + 'execute', + 'catalog:query-catalog-entities', + '--instance', + 'default', + '--limit', + '5', + ]), + ); + }); + + it('passes flag values containing special characters as literal arguments', async () => { + mockExecFileSyncReturning('{}'); + + await execAction('catalog:query-catalog-entities', { + query: '{"kind":"Component"}', + }); + + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).toEqual( + expect.arrayContaining(['--query', '{"kind":"Component"}']), + ); + }); + + it('passes action ids and flag names as literal arguments', async () => { + mockExecFileSyncReturning('{}'); + + await execAction('actions:foo;echo pwned', { + 'bad;echo pwned': "it's a test", + }); + + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).toEqual( + expect.arrayContaining([ + 'actions:foo;echo pwned', + '--bad;echo pwned', + "it's a test", + ]), + ); + }); + + it('adds boolean-true flags with no value', async () => { + mockExecFileSyncReturning('{}'); + + await execAction('actions:list', { verbose: true }); + + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).toEqual(expect.arrayContaining(['--verbose'])); + expect(args).not.toEqual(expect.arrayContaining(['--verbose', 'true'])); + }); + + it('omits flags that are false or undefined', async () => { + mockExecFileSyncReturning('{}'); + + await execAction('actions:list', { verbose: false, instance: undefined }); + + const [, args] = mockExecFileSync.mock.calls[0]; + expect(args).not.toEqual(expect.arrayContaining(['--verbose'])); + expect(args).not.toEqual(expect.arrayContaining(['--instance'])); + }); + + it('throws with the "Error:" line from stderr when the command fails', () => { + mockExecFileSyncThrowing('some noise\nError: Entity not found\nmore noise'); + + expect(() => + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).toThrow('Entity not found'); + }); + + it('falls back to the last stderr line when no "Error:" line is present', () => { + mockExecFileSyncThrowing('first line\nlast line'); + + expect(() => + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).toThrow('last line'); + }); + + it('rebrands "backstage-cli" as "rhdh-cli" in the thrown error message', () => { + mockExecFileSyncThrowing('Error: run backstage-cli auth login first'); + + expect(() => + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).toThrow('run rhdh-cli auth login first'); + }); + + it('throws a generic message when the command fails without stderr content', () => { + mockExecFileSync.mockImplementation(() => { + throw new Error('Command failed'); + }); + + expect(() => + execAction('catalog:get-catalog-entity', { name: 'missing' }), + ).toThrow('rhdh-cli command failed'); + }); +}); + +describe('execActionJson', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('parses valid JSON output', async () => { + mockExecFileSyncReturning('{"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 () => { + mockExecFileSyncReturning('not json'); + + const result = await execActionJson('catalog:get-catalog-entity', { + name: 'x', + }); + + expect(result).toBe('not json'); + }); +}); + +describe('triggerTechDocsBuild', () => { + const fetchMock = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = fetchMock; + mockCliAuthCreate.mockResolvedValue({ + getAccessToken: jest.fn().mockResolvedValue('test-token'), + getBaseUrl: jest.fn().mockReturnValue('https://rhdh.example.com'), + } as unknown as CliAuth); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('waits for a successful authenticated TechDocs sync response', async () => { + fetchMock.mockResolvedValue({ + ok: true, + text: jest.fn().mockResolvedValue('build logs'), + }); + + const result = await triggerTechDocsBuild( + { + namespace: 'default', + kind: 'component', + name: 'my service', + }, + 'local', + ); + + expect(mockCliAuthCreate).toHaveBeenCalledWith({ instanceName: 'local' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://rhdh.example.com/api/techdocs/sync/default/component/my%20service', + { + headers: { Authorization: 'Bearer test-token' }, + }, + ); + expect(result).toBe('build logs'); + }); + + it('throws when the TechDocs sync endpoint returns a non-success status', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: jest.fn().mockResolvedValue('build failed'), + }); + + await expect( + triggerTechDocsBuild({ + namespace: 'default', + kind: 'component', + name: 'my-service', + }), + ).rejects.toThrow( + 'TechDocs build failed with 500 Internal Server Error: build failed', + ); + }); +}); describe('execPassthrough', () => { let exitSpy: jest.SpyInstance; diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index 665f1c7..45bafbb 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -1,6 +1,7 @@ -import { spawn } from 'node:child_process'; +import { spawn, execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; +import { CliAuth } from '@backstage/cli-node'; const resolvedCliBinaries = new Map(); @@ -128,3 +129,88 @@ export function execPassthrough(args: string[]): void { process.exit(code ?? 1); }); } + +export function execAction( + actionId: string, + flags: Record, +): string { + const bin = resolveCliModuleBinary('actions'); + const args = ['actions', 'execute', actionId]; + + for (const [key, value] of Object.entries(flags)) { + if (value === undefined || value === false) continue; + args.push(`--${key}`); + if (value !== true) { + args.push(String(value)); + } + } + + try { + return execFileSync(process.execPath, [bin, ...args], { + encoding: 'utf-8', + timeout: 60_000, + maxBuffer: 50 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + let errorMsg = 'backstage-cli command failed'; + const stderrValue = + typeof error === 'object' && error !== null && 'stderr' in error + ? (error as { stderr?: string | Buffer }).stderr + : undefined; + let stderr = ''; + if (Buffer.isBuffer(stderrValue)) { + stderr = stderrValue.toString('utf-8').trim(); + } else if (typeof stderrValue === 'string') { + stderr = stderrValue.trim(); + } + if (stderr) { + const lines = stderr.split('\n').filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + errorMsg = errorLine + ? errorLine.replace(/^\s*Error:\s*/i, '').trim() + : lines[lines.length - 1].trim(); + } + throw new Error(rebrand(errorMsg)); + } +} + +export function execActionJson( + actionId: string, + flags: Record, +): unknown { + const raw = execAction(actionId, flags); + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +export async function triggerTechDocsBuild( + entity: { namespace: string; kind: string; name: string }, + instance?: string, +): Promise { + const auth = await CliAuth.create({ instanceName: instance }); + const accessToken = await auth.getAccessToken(); + const path = [entity.namespace, entity.kind, entity.name] + .map(encodeURIComponent) + .join('/'); + const url = new URL( + `/api/techdocs/sync/${path}`, + auth.getBaseUrl(), + ).toString(); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const body = await response.text(); + + if (!response.ok) { + const status = `${response.status} ${response.statusText}`.trim(); + throw new Error( + `TechDocs build failed with ${status}${body ? `: ${body}` : ''}`, + ); + } + + return body; +} diff --git a/src/commands/intent-based-actions/docs.test.ts b/src/commands/intent-based-actions/docs.test.ts new file mode 100644 index 0000000..8f6c27d --- /dev/null +++ b/src/commands/intent-based-actions/docs.test.ts @@ -0,0 +1,272 @@ +import { Command } from 'commander'; +import { execAction, execActionJson, triggerTechDocsBuild } from './client'; +import { registerDocsCommands } from './docs'; +import { resolveEntityWithAmbiguityCheck, runSearchAction } from './helpers'; +import { handleCommandError } from './intent-errors'; + +jest.mock('./client'); +jest.mock('./helpers'); +jest.mock('./intent-errors'); + +const mockExecActionJson = execActionJson as jest.MockedFunction< + typeof execActionJson +>; +const mockExecAction = execAction as jest.MockedFunction; +const mockTriggerTechDocsBuild = triggerTechDocsBuild as jest.MockedFunction< + typeof triggerTechDocsBuild +>; +const mockHandleCommandError = handleCommandError as jest.MockedFunction< + typeof handleCommandError +>; +const mockRunSearchAction = runSearchAction as jest.MockedFunction< + typeof runSearchAction +>; +const mockResolveEntityWithAmbiguityCheck = + resolveEntityWithAmbiguityCheck as jest.MockedFunction< + typeof resolveEntityWithAmbiguityCheck + >; + +function captureStdout() { + return jest.spyOn(process.stdout, 'write').mockImplementation(() => true); +} + +describe('docs get', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reports an unresolved entity as a catalog error', async () => { + const error = new Error('Entity not found'); + mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'get', 'missing']); + + expect(stderrSpy).not.toHaveBeenCalled(); + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human'); + + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('verifies that a full entity reference exists before retrieving docs', async () => { + const error = new Error('Entity not found: system:default/missing'); + mockResolveEntityWithAmbiguityCheck.mockRejectedValue(error); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'get', + 'system:default/missing', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'system:default/missing', + expect.objectContaining({ verifyExists: true }), + ); + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human'); + expect(mockExecActionJson).not.toHaveBeenCalled(); + }); + + it('reports missing generated docs as an error', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'System:default/rhdh-local', + kind: 'System', + namespace: 'default', + name: 'rhdh-local', + }); + mockExecActionJson.mockReturnValue({ + error: 'TechDocs content not found', + }); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'get', + 'system:default/rhdh-local', + ]); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'TechDocs content not found for System:default/rhdh-local', + ), + ); + expect(exitSpy).toHaveBeenCalledWith(1); + + stderrSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it('reports missing generated docs as a structured JSON error', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'System:default/rhdh-local', + kind: 'System', + namespace: 'default', + name: 'rhdh-local', + }); + mockExecAction.mockReturnValue( + JSON.stringify({ error: 'TechDocs content not found' }), + ); + const stdoutSpy = captureStdout(); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'get', + 'system:default/rhdh-local', + '--output', + 'json', + ]); + + expect(mockHandleCommandError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'TechDocs content not found for System:default/rhdh-local', + }), + 'json', + { suggestion: 'rhdh-cli docs build System:default/rhdh-local' }, + ); + stdoutSpy.mockRestore(); + }); +}); + +describe('docs search', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('suggests enabling the TechDocs search backend when search fails', async () => { + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'search', 'rhdh']); + + expect(mockRunSearchAction).toHaveBeenCalledWith( + 'rhdh', + expect.objectContaining({ types: '["techdocs"]' }), + 'human', + 'Enable search-backend-module-techdocs on the RHDH instance.', + ); + }); +}); + +describe('docs list', () => { + it('rejects the unsupported --limit option', async () => { + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeErr: () => undefined }); + registerDocsCommands(program); + + await expect( + program.parseAsync(['node', 'test', 'docs', 'list', '--limit', '5']), + ).rejects.toMatchObject({ code: 'commander.unknownOption' }); + }); +}); + +describe('docs coverage', () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + writeSpy = captureStdout(); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('uses N/A when coverage fields are missing from the response', async () => { + mockExecActionJson.mockReturnValue({ totalEntities: 10 }); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'coverage']); + + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain('Total entities: 10'); + expect(output).toContain('Documented entities: N/A'); + expect(output).toContain('Coverage: N/A'); + }); + + it('explains that RHDH is required when the coverage action is unavailable', async () => { + const error = new Error('Unknown action'); + mockExecActionJson.mockImplementation(() => { + throw error; + }); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync(['node', 'test', 'docs', 'coverage']); + + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human', { + suggestion: 'Use an RHDH instance with techdocs-mcp-extras enabled.', + }); + }); +}); + +describe('docs build', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls the authenticated TechDocs sync endpoint before reporting success', async () => { + mockResolveEntityWithAmbiguityCheck.mockResolvedValue({ + entityRef: 'Component:default/my-service', + kind: 'Component', + namespace: 'default', + name: 'my-service', + }); + mockTriggerTechDocsBuild.mockResolvedValue('build output'); + const writeSpy = captureStdout(); + const program = new Command(); + registerDocsCommands(program); + + await program.parseAsync([ + 'node', + 'test', + 'docs', + 'build', + 'component:default/my-service', + '--instance', + 'local', + ]); + + expect(mockResolveEntityWithAmbiguityCheck).toHaveBeenCalledWith( + 'component:default/my-service', + expect.objectContaining({ verifyExists: true }), + ); + expect(mockTriggerTechDocsBuild).toHaveBeenCalledWith( + { + kind: 'component', + namespace: 'default', + name: 'my-service', + }, + 'local', + ); + expect(writeSpy).toHaveBeenCalledWith( + expect.stringContaining('TechDocs build completed'), + ); + writeSpy.mockRestore(); + }); +}); diff --git a/src/commands/intent-based-actions/docs.ts b/src/commands/intent-based-actions/docs.ts new file mode 100644 index 0000000..4741703 --- /dev/null +++ b/src/commands/intent-based-actions/docs.ts @@ -0,0 +1,340 @@ +import chalk from 'chalk'; +import { Command } from 'commander'; +import { execAction, execActionJson, triggerTechDocsBuild } from './client'; +import { + runSearchAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} from './helpers'; +import { + parseOutputFlag, + writeOutput, + formatEntityTable, + extractEntities, + type OutputMode, +} from './format'; +import { handleCommandError } from './intent-errors'; + +const RHDH_ONLY_SUGGESTION = + 'Use an RHDH instance with techdocs-mcp-extras enabled.'; +const TECHDOCS_SEARCH_SUGGESTION = + 'Enable search-backend-module-techdocs on the RHDH instance.'; + +function reportMissingTechDocs(entityRef: string, mode: OutputMode): never { + if (mode === 'json') { + return handleCommandError( + new Error(`TechDocs content not found for ${entityRef}`), + mode, + { suggestion: `rhdh-cli docs build ${entityRef}` }, + ); + } + + process.stderr.write( + `${chalk.yellow('TechDocs content not found for')} ${entityRef}\n`, + ); + process.stderr.write( + `${chalk.dim('The documentation may not have been built yet.')}\n`, + ); + process.stderr.write( + `\n${chalk.dim('Trigger build with:')} ${chalk.cyan(`rhdh-cli docs build ${entityRef}`)}\n`, + ); + process.stderr.write( + `${chalk.dim('Or visit the TechDocs page in RHDH to trigger a build.')}\n`, + ); + return process.exit(1); +} + +export function registerDocsCommands(program: Command) { + const docs = program + .command('docs') + .description('Search and retrieve TechDocs content'); + + docs + .command('search ') + .description( + 'Search TechDocs content (requires search-backend-module-techdocs)', + ) + .option('--page-limit ', 'Results per page (default: 10)', parseInt) + .option('--page-cursor ', 'Pagination cursor') + .option('--output ', 'Output format: human (default), json') + .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, + TECHDOCS_SEARCH_SUGGESTION, + ); + }); + + docs + .command('list') + .description( + 'List entities with TechDocs (RHDH only, via techdocs-mcp-extras)', + ) + .option('--kind ', 'Filter by entity kind (Component, API, etc.)') + .option('--owner ', 'Filter by owner') + .option( + '--lifecycle ', + 'Filter by lifecycle (production, experimental, etc.)', + ) + .option('--tags ', 'Filter by tags (comma-separated)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + try { + const flags: ActionFlags = { + entityType: opts.kind, + 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_ONLY_SUGGESTION, + }); + } + }); + + docs + .command('get ') + .description( + 'Get TechDocs page content for an entity (RHDH only, via techdocs-mcp-extras)', + ) + .option('--kind ', 'Entity kind (to disambiguate short names)') + .option( + '--namespace ', + 'Entity namespace (to disambiguate short names)', + ) + .option('--page-path ', 'Specific doc page path (default: index)') + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (ref: string, opts) => { + const mode = parseOutputFlag(opts.output); + let entityRef: string; + + try { + ({ entityRef } = await resolveEntityWithAmbiguityCheck(ref, { + kindFlag: opts.kind, + namespaceFlag: opts.namespace, + instance: opts.instance, + verifyExists: true, + })); + } catch (error) { + handleCommandError(error, mode); + return; + } + + try { + const flags: ActionFlags = { + entityRef, + pagePath: opts.pagePath, + instance: opts.instance, + }; + + if (mode === 'json') { + const raw = await execAction( + 'techdocs-mcp-extras:retrieve-techdocs-content', + flags, + ); + let result: unknown; + try { + result = JSON.parse(raw); + } catch { + process.stdout.write(raw); + result = undefined; + } + if (result !== undefined) { + const errorMsg = (result as Record | undefined) + ?.error; + if (typeof errorMsg === 'string') { + if ( + errorMsg.includes('not found') || + errorMsg.includes('not have been built') + ) { + reportMissingTechDocs(entityRef, mode); + } + handleCommandError(new Error(errorMsg), mode); + } + process.stdout.write(raw); + } + } else { + const result = await execActionJson( + 'techdocs-mcp-extras:retrieve-techdocs-content', + 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) { + // Check if it's a "not built yet" error + if ( + errorMsg.includes('not found') || + errorMsg.includes('not have been built') + ) { + reportMissingTechDocs(entityRef, mode); + } + handleCommandError(new Error(errorMsg), mode); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + // Check if error message indicates docs not built + const errMsg = error instanceof Error ? error.message : String(error); + if ( + errMsg.includes('not found') || + errMsg.includes('not have been built') + ) { + reportMissingTechDocs(entityRef, mode); + } + handleCommandError(error, mode, { + suggestion: RHDH_ONLY_SUGGESTION, + }); + } + }); + + 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: ActionFlags = { + 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; + const coverageLabel = coverage === undefined ? 'N/A' : `${coverage}%`; + + if (total !== undefined) { + const lines = [ + `${chalk.bold('TechDocs Coverage Report')}`, + '', + `Total entities: ${total}`, + `Documented entities: ${documented ?? 'N/A'}`, + `Coverage: ${coverageLabel}`, + ]; + process.stdout.write(`${lines.join('\n')}\n`); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: RHDH_ONLY_SUGGESTION, + }); + } + }); + + docs + .command('build ') + .description('Trigger TechDocs build for an entity') + .option('--kind ', 'Entity kind (to disambiguate short names)') + .option( + '--namespace ', + 'Entity namespace (to disambiguate short names)', + ) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (ref: string, opts) => { + const mode = parseOutputFlag(opts.output); + + try { + const { entityRef, kind, namespace, name } = + await resolveEntityWithAmbiguityCheck(ref, { + kindFlag: opts.kind, + namespaceFlag: opts.namespace, + instance: opts.instance, + verifyExists: true, + }); + + const kindLower = kind.toLowerCase(); + + await triggerTechDocsBuild( + { namespace, kind: kindLower, name }, + opts.instance, + ); + + if (mode === 'json') { + process.stdout.write( + `${JSON.stringify({ + entityRef, + namespace, + kind: kindLower, + name, + message: 'TechDocs build completed successfully', + })}\n`, + ); + } else { + process.stdout.write( + `${chalk.green('✓')} TechDocs build completed for ${chalk.cyan(entityRef)}\n`, + ); + } + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli docs build component:default/my-service', + }); + } + }); +} 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..c0aabeb --- /dev/null +++ b/src/commands/intent-based-actions/format.test.ts @@ -0,0 +1,235 @@ +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('falls back to top-level text when document is absent', () => { + const output = formatSearchResults([ + { title: 'Flat result', text: 'Flat result text' }, + ]); + expect(output).toContain('Flat result text'); + }); + + it('omits the location line when no location is present', () => { + const output = formatSearchResults([{ title: 'No location' }]); + expect(output).toContain('No location'); + }); + + 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..65e5841 --- /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 ?? result.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..5f81b6c --- /dev/null +++ b/src/commands/intent-based-actions/helpers.test.ts @@ -0,0 +1,506 @@ +import { execAction, execActionJson } from './client'; +import { handleCommandError } from './intent-errors'; +import { + runEntityListAction, + runRawAction, + runSearchAction, + resolveEntityWithAmbiguityCheck, +} 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.mockReturnValue('{"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.mockReturnValue({ + 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.mockImplementation(() => { + throw 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.mockImplementation(() => { + throw 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.mockReturnValue('{"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.mockReturnValue('{"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.mockImplementation(() => { + throw 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.mockReturnValue('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.mockReturnValue('{}'); + + 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.mockReturnValue('{"results":[]}'); + + await runSearchAction('term', {}, 'json'); + + expect(writeSpy).toHaveBeenCalledWith('{"results":[]}'); + }); + + it('extracts result.results and renders snippets in human mode', async () => { + mockExecActionJson.mockReturnValue({ + 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.mockReturnValue([ + { document: { title: 'Bare result' } }, + ]); + + await runSearchAction('term', {}, 'human'); + + const output = writeSpy.mock.calls[0][0] as string; + expect(output).toContain('Bare result'); + }); + + it('falls back to JSON output for a non-array search result', async () => { + const result = { message: 'unexpected response shape' }; + mockExecActionJson.mockReturnValue(result); + + await runSearchAction('term', {}, 'human'); + + expect(writeSpy).toHaveBeenCalledWith( + `${JSON.stringify(result, null, 2)}\n`, + ); + expect(mockHandleCommandError).not.toHaveBeenCalled(); + }); + + it('routes errors to handleCommandError with the given suggestion', async () => { + const error = new Error('boom'); + mockExecActionJson.mockImplementation(() => { + throw error; + }); + + await runSearchAction('term', {}, 'human', 'rhdh-cli search "term"'); + + expect(mockHandleCommandError).toHaveBeenCalledWith(error, 'human', { + suggestion: 'rhdh-cli search "term"', + }); + }); +}); + +describe('resolveEntityWithAmbiguityCheck', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns directly when full reference is provided (kind and namespace)', async () => { + const result = await resolveEntityWithAmbiguityCheck( + 'component:default/my-service', + ); + + expect(result).toEqual({ + kind: 'component', + namespace: 'default', + name: 'my-service', + entityRef: 'component:default/my-service', + }); + + // Should not query the catalog + expect(mockExecActionJson).not.toHaveBeenCalled(); + }); + + it('rejects a full reference that is absent when existence verification is requested', async () => { + mockExecActionJson.mockReturnValue({ items: [] }); + + await expect( + resolveEntityWithAmbiguityCheck('system:default/missing', { + verifyExists: true, + }), + ).rejects.toThrow('Entity not found: system:default/missing'); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ + 'metadata.name': 'missing', + kind: 'system', + 'metadata.namespace': 'default', + }), + instance: undefined, + }, + ); + }); + + it('returns directly when kind flag and namespace flag are provided', async () => { + const result = await resolveEntityWithAmbiguityCheck('my-service', { + kindFlag: 'component', + namespaceFlag: 'production', + }); + + expect(result).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + entityRef: 'component:production/my-service', + }); + + // Should not query the catalog + expect(mockExecActionJson).not.toHaveBeenCalled(); + }); + + it('queries catalog when short name is provided and resolves single match', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ], + }); + + const result = await resolveEntityWithAmbiguityCheck('my-service'); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ 'metadata.name': 'my-service' }), + instance: undefined, + }, + ); + + expect(result).toEqual({ + kind: 'Component', + namespace: 'default', + name: 'my-service', + entityRef: 'Component:default/my-service', + }); + }); + + it('queries catalog with kind filter when defaultKind is provided', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Template', + metadata: { name: 'my-template', namespace: 'default' }, + }, + ], + }); + + const result = await resolveEntityWithAmbiguityCheck('my-template', { + defaultKind: 'template', + }); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ + 'metadata.name': 'my-template', + kind: 'template', + }), + instance: undefined, + }, + ); + + expect(result).toEqual({ + kind: 'Template', + namespace: 'default', + name: 'my-template', + entityRef: 'Template:default/my-template', + }); + }); + + it('returns directly when both kind and namespace flags are provided (full reference)', async () => { + const result = await resolveEntityWithAmbiguityCheck('my-service', { + kindFlag: 'component', + namespaceFlag: 'production', + }); + + // Should NOT query catalog when we have full reference + expect(mockExecActionJson).not.toHaveBeenCalled(); + + expect(result).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + entityRef: 'component:production/my-service', + }); + }); + + it('throws error when no entities found', async () => { + mockExecActionJson.mockReturnValue({ items: [] }); + + await expect( + resolveEntityWithAmbiguityCheck('nonexistent-service'), + ).rejects.toThrow('Entity not found: nonexistent-service'); + }); + + it('throws error when no entities found with kind filter', async () => { + mockExecActionJson.mockReturnValue({ items: [] }); + + await expect( + resolveEntityWithAmbiguityCheck('nonexistent', { + kindFlag: 'component', + }), + ).rejects.toThrow('Entity not found: component:*/nonexistent'); + }); + + it('throws ambiguity error when multiple entities found', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'production' }, + }, + { + kind: 'API', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ], + }); + + await expect(resolveEntityWithAmbiguityCheck('my-service')).rejects.toThrow( + /Ambiguous entity reference.*Multiple entities named "my-service" found/, + ); + }); + + it('includes all matching entities in ambiguity error message', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'production' }, + }, + ], + }); + + await expect(resolveEntityWithAmbiguityCheck('my-service')).rejects.toThrow( + /Component:default\/my-service[\s\S]*Component:production\/my-service[\s\S]*Use full reference to disambiguate/, + ); + }); + + it('returns directly when namespace/name format with kind flag (full reference)', async () => { + const result = await resolveEntityWithAmbiguityCheck( + 'production/my-service', + { + kindFlag: 'component', + }, + ); + + // Should NOT query catalog when we have full reference (kind from flag + namespace from ref) + expect(mockExecActionJson).not.toHaveBeenCalled(); + + expect(result).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + entityRef: 'component:production/my-service', + }); + }); + + it('passes instance option through to catalog query', async () => { + mockExecActionJson.mockReturnValue({ + items: [ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ], + }); + + await resolveEntityWithAmbiguityCheck('my-service', { + instance: 'my-instance', + }); + + expect(mockExecActionJson).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ 'metadata.name': 'my-service' }), + instance: 'my-instance', + }, + ); + }); + + it('handles entities array directly (backward compatibility)', async () => { + mockExecActionJson.mockReturnValue([ + { + kind: 'Component', + metadata: { name: 'my-service', namespace: 'default' }, + }, + ]); + + const result = await resolveEntityWithAmbiguityCheck('my-service'); + + expect(result).toEqual({ + kind: 'Component', + namespace: 'default', + name: 'my-service', + entityRef: 'Component:default/my-service', + }); + }); +}); diff --git a/src/commands/intent-based-actions/helpers.ts b/src/commands/intent-based-actions/helpers.ts new file mode 100644 index 0000000..0714891 --- /dev/null +++ b/src/commands/intent-based-actions/helpers.ts @@ -0,0 +1,207 @@ +import { execAction, execActionJson } from './client'; +import { + extractEntities, + formatEntityTable, + formatSearchResults, + OutputMode, + writeOutput, +} from './format'; +import { handleCommandError } from './intent-errors'; +import { parseEntityRef } from './kv'; + +export 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); + let results: unknown; + if (Array.isArray(result)) { + results = result; + } else if (result && typeof result === 'object' && 'results' in result) { + results = result.results; + } + + if (Array.isArray(results)) { + writeOutput(results, mode, data => + formatSearchResults(data as Array>), + ); + } else { + writeOutput(result, mode); + } + } + } catch (error) { + handleCommandError(error, mode, suggestion ? { suggestion } : undefined); + } +} + +/** + * Resolves an entity reference with ambiguity detection. + * + * If the reference is a full reference (kind:namespace/name), returns it directly. + * If the reference is a short name or partial reference, queries the catalog to find all matching entities. + * - If exactly 1 match: returns that entity's full reference + * - If 0 matches: throws "not found" error + * - If > 1 matches: throws ambiguity error with list of all matches + * + * @param ref - Entity reference string ([kind:][namespace/]name) + * @param options - Optional kind/namespace overrides and instance + * @returns Resolved entity reference with kind, namespace, and name + */ +export async function resolveEntityWithAmbiguityCheck( + ref: string, + options: { + kindFlag?: string; + namespaceFlag?: string; + defaultKind?: string; + instance?: string; + verifyExists?: boolean; + } = {}, +): Promise<{ + kind: string; + namespace: string; + name: string; + entityRef: string; +}> { + const parsed = parseEntityRef(ref); + + // Determine kind and namespace from flags, parsed values, or defaults + const kind = options.kindFlag || parsed.kind || options.defaultKind; + const namespace = options.namespaceFlag || parsed.namespace; + const name = parsed.name; + + // If we have full reference (kind and namespace specified), return directly + if (kind && namespace && !options.verifyExists) { + return { + kind, + namespace, + name, + entityRef: `${kind}:${namespace}/${name}`, + }; + } + + // Query catalog for all entities with this name + const query: Record = { 'metadata.name': name }; + + // If kind is specified but namespace is not, filter by kind + if (kind) { + query.kind = kind; + } + + // If namespace is specified but kind is not, filter by namespace + if (namespace) { + query['metadata.namespace'] = namespace; + } + + const flags: ActionFlags = { + query: JSON.stringify(query), + instance: options.instance, + }; + + const result = await execActionJson('catalog:query-catalog-entities', flags); + const entities = extractEntities(result); + + // Handle results + if (entities.length === 0) { + let refStr = name; + if (kind) { + refStr = namespace ? `${kind}:${namespace}/${name}` : `${kind}:*/${name}`; + } else if (namespace) { + refStr = `*:${namespace}/${name}`; + } + throw new Error(`Entity not found: ${refStr}`); + } + + if (entities.length === 1) { + const entity = entities[0] as Record; + const metadata = entity.metadata as Record; + const entityKind = String(entity.kind || 'unknown'); + const entityNamespace = String(metadata.namespace || 'default'); + const entityName = String(metadata.name || name); + + return { + kind: entityKind, + namespace: entityNamespace, + name: entityName, + entityRef: `${entityKind}:${entityNamespace}/${entityName}`, + }; + } + + // Multiple matches - build error with all matching references + const matches = entities.map((e: unknown) => { + const entity = e as Record; + const metadata = entity.metadata as Record; + const entityKind = String(entity.kind || 'unknown'); + const entityNamespace = String(metadata.namespace || 'default'); + const entityName = String(metadata.name || name); + return `${entityKind}:${entityNamespace}/${entityName}`; + }); + + throw new Error( + `Ambiguous entity reference. Multiple entities named "${name}" found:\n ${matches.join('\n ')}\n\nUse full reference to disambiguate.`, + ); +} diff --git a/src/commands/intent-based-actions/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..30e0279 --- /dev/null +++ b/src/commands/intent-based-actions/intent-errors.test.ts @@ -0,0 +1,192 @@ +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('does not classify an incidental 404 in an entity name as not found', () => { + handleCommandError( + new Error('Entity service-404 failed validation'), + 'json', + ); + expect(writtenError().reason).toBe('Entity service-404 failed validation'); + }); + + it('maps an ECONNREFUSED error to a connectivity reason', () => { + handleCommandError(new Error('connect ECONNREFUSED 127.0.0.1'), 'json'); + expect(writtenError().reason).toMatch(/Could not connect/); + }); + + 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 RHDH 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..b056652 --- /dev/null +++ b/src/commands/intent-based-actions/intent-errors.ts @@ -0,0 +1,119 @@ +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 (hasStatusCode(fullMessage, 401) || fullMessage.includes('Unauthorized')) { + return 'Authentication failed or token expired. Re-authenticate with: rhdh-cli auth login'; + } + if (hasStatusCode(fullMessage, 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 RHDH instance. Check that the instance is running and reachable.'; + } + if (fullMessage.includes('No authenticated instances')) { + return 'No RHDH instance configured. Run: rhdh-cli auth login --backend-url '; + } + + const stderrMessage = extractStderrMessage(error); + if (stderrMessage) return stderrMessage; + + return extractPrimaryMessage(error); +} + +function hasStatusCode(message: string, statusCode: number): boolean { + return new RegExp(`(?:^|[\\s:=])${statusCode}(?:$|[\\s,.;])`).test(message); +} + +function collectMessages(error: unknown): string { + const parts: string[] = []; + let current: unknown = error; + 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 stderrMessage = extractStderrMessage(error); + if (stderrMessage) return stderrMessage; + + return error.message; +} + +function extractStderrMessage(error: unknown): string | undefined { + const stderr = getStderr(error); + if (!stderr || !stderr.trim()) return undefined; + + const lines = stderr + .trim() + .split('\n') + .filter(l => l.trim()); + const errorLine = lines.find(l => /^Error:/i.test(l.trim())); + return errorLine + ? errorLine.replace(/^\s*Error:\s*/i, '').trim() + : lines[0].trim(); +} diff --git a/src/commands/intent-based-actions/kv.test.ts b/src/commands/intent-based-actions/kv.test.ts new file mode 100644 index 0000000..3df2a4c --- /dev/null +++ b/src/commands/intent-based-actions/kv.test.ts @@ -0,0 +1,192 @@ +import { + collect, + parseKeyValuePairs, + parseList, + resolveJsonInput, + parseEntityRef, +} 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('preserves whitespace-only values as strings', () => { + expect(parseKeyValuePairs(['description= '])).toEqual({ + description: ' ', + }); + }); + + it('keeps values with embedded "=" intact', () => { + expect(parseKeyValuePairs(['query=kind=Component'])).toEqual({ + query: 'kind=Component', + }); + }); + + 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'])).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/, + ); + }); +}); + +describe('parseEntityRef', () => { + it('parses a short name', () => { + expect(parseEntityRef('my-service')).toEqual({ + name: 'my-service', + }); + }); + + it('parses namespace/name format', () => { + expect(parseEntityRef('default/my-service')).toEqual({ + namespace: 'default', + name: 'my-service', + }); + }); + + it('parses full kind:namespace/name format', () => { + expect(parseEntityRef('component:default/my-service')).toEqual({ + kind: 'component', + namespace: 'default', + name: 'my-service', + }); + }); + + it('parses kind:name format without namespace', () => { + expect(parseEntityRef('component:my-service')).toEqual({ + kind: 'component', + name: 'my-service', + }); + }); + + it('handles production namespace', () => { + expect(parseEntityRef('component:production/my-service')).toEqual({ + kind: 'component', + namespace: 'production', + name: 'my-service', + }); + }); + + it('handles names with hyphens and underscores', () => { + expect(parseEntityRef('api:default/my-api_v2')).toEqual({ + kind: 'api', + namespace: 'default', + name: 'my-api_v2', + }); + }); + + it('throws for empty string', () => { + expect(() => parseEntityRef('')).toThrow(/cannot be empty/); + }); + + it('throws for whitespace-only string', () => { + expect(() => parseEntityRef(' ')).toThrow(/cannot be empty/); + }); +}); diff --git a/src/commands/intent-based-actions/kv.ts b/src/commands/intent-based-actions/kv.ts new file mode 100644 index 0000000..04d0be7 --- /dev/null +++ b/src/commands/intent-based-actions/kv.ts @@ -0,0 +1,193 @@ +/** + * 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.trim() !== '' && !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, +): 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; +} + +/** + * Parses an entity reference in the format [kind:][namespace/]name + * and returns the parsed components. + * + * Examples: + * - "my-service" -> {name: "my-service"} + * - "default/my-service" -> {namespace: "default", name: "my-service"} + * - "component:default/my-service" -> {kind: "component", namespace: "default", name: "my-service"} + */ +export function parseEntityRef(ref: string): { + kind?: string; + namespace?: string; + name: string; +} { + if (!ref || ref.trim() === '') { + throw new Error('Entity reference cannot be empty'); + } + + // Check for full format: kind:namespace/name + const colonIndex = ref.indexOf(':'); + if (colonIndex > 0) { + const kind = ref.slice(0, colonIndex); + const remainder = ref.slice(colonIndex + 1); + const slashIndex = remainder.indexOf('/'); + + if (slashIndex > 0) { + // kind:namespace/name + return { + kind, + namespace: remainder.slice(0, slashIndex), + name: remainder.slice(slashIndex + 1), + }; + } + + // kind:name (no namespace) + return { + kind, + name: remainder, + }; + } + + // Check for namespace/name format + const slashIndex = ref.indexOf('/'); + if (slashIndex > 0) { + return { + namespace: ref.slice(0, slashIndex), + name: ref.slice(slashIndex + 1), + }; + } + + // Just a name + return { + name: ref, + }; +} + +/** + * Resolves an entity reference from a positional argument, + * with optional kind and namespace overrides or defaults. + * + * @param ref - Required positional entity reference + * @param defaultKind - Default kind if not specified in ref (e.g., 'template', 'api') + * @param kindFlag - Optional --kind flag to override or disambiguate + * @param namespaceFlag - Optional --namespace flag to override or disambiguate + * @param requireKind - If true, throws error if kind is not specified + */ +export function resolveEntityRef( + ref: string, + options: { + defaultKind?: string; + kindFlag?: string; + namespaceFlag?: string; + requireKind?: boolean; + } = {}, +): { + kind?: string; + namespace: string; + name: string; + entityRef: string; +} { + const parsed = parseEntityRef(ref); + + // Determine kind: flag > parsed > default + const kind = options.kindFlag || parsed.kind || options.defaultKind; + + // Determine namespace: flag > parsed > 'default' + const namespace = options.namespaceFlag || parsed.namespace || 'default'; + const name = parsed.name; + + // Validate kind requirement + if (options.requireKind && !kind) { + throw new Error( + `Entity kind is required. Provide full reference (e.g., component:default/${name}) or use --kind flag.`, + ); + } + + // Build the canonical entity reference + const entityRef = kind + ? `${kind}:${namespace}/${name}` + : `${namespace}/${name}`; + + return { + kind, + namespace, + name, + entityRef, + }; +} diff --git a/src/commands/intent-based-actions/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.test.ts b/src/commands/intent-based-actions/template.test.ts new file mode 100644 index 0000000..1c969f3 --- /dev/null +++ b/src/commands/intent-based-actions/template.test.ts @@ -0,0 +1,38 @@ +import { Command } from 'commander'; +import { runEntityListAction } from './helpers'; +import { registerTemplateCommands } from './template'; + +jest.mock('./helpers'); + +const mockRunEntityListAction = runEntityListAction as jest.MockedFunction< + typeof runEntityListAction +>; + +describe('template list', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('requests only the fields rendered in human output', async () => { + const program = new Command(); + registerTemplateCommands(program); + + await program.parseAsync(['node', 'test', 'template', 'list']); + + expect(mockRunEntityListAction).toHaveBeenCalledWith( + 'catalog:query-catalog-entities', + { + query: JSON.stringify({ kind: 'Template' }), + instance: undefined, + limit: undefined, + fields: JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]), + }, + 'human', + ); + }); +}); diff --git a/src/commands/intent-based-actions/template.ts b/src/commands/intent-based-actions/template.ts new file mode 100644 index 0000000..4ed46bd --- /dev/null +++ b/src/commands/intent-based-actions/template.ts @@ -0,0 +1,192 @@ +import { readFileSync } from 'node:fs'; +import { Command } from 'commander'; +import { + runEntityListAction, + runRawAction, + resolveEntityWithAmbiguityCheck, + type ActionFlags, +} 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( + '--filter ', + 'Query predicate, e.g. --filter metadata.tags=nodejs (repeatable)', + collect, + [] as string[], + ) + .option('--limit ', 'Maximum results to return', parseInt) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async opts => { + const mode = parseOutputFlag(opts.output); + + const query: Record = { kind: 'Template' }; + + let predicate: string | undefined; + try { + predicate = resolveJsonInput(opts.filter); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli template list --filter metadata.tags=nodejs', + }); + } + // --filter flags merge on top of the kind=Template query. + const merged = predicate ? { ...query, ...JSON.parse(predicate) } : query; + + const flags: ActionFlags = { + query: JSON.stringify(merged), + instance: opts.instance, + limit: opts.limit, + fields: + mode === 'human' + ? JSON.stringify([ + 'metadata.name', + 'kind', + 'metadata.namespace', + 'spec.type', + ]) + : undefined, + }; + + await runEntityListAction('catalog:query-catalog-entities', flags, mode); + }); + + template + .command('execute ') + .description('Execute a software template') + .option('--namespace ', 'Template namespace (to filter/disambiguate)') + .option( + '--value ', + 'Template input value, e.g. --value name=my-app (repeatable)', + collect, + [] as string[], + ) + .option( + '--secret ', + 'Template secret, e.g. --secret token=abc (repeatable)', + collect, + [] as string[], + ) + .option('--output ', 'Output format: human (default), json') + .option('--instance ', 'Backstage instance name') + .action(async (ref: string, opts) => { + const mode = parseOutputFlag(opts.output); + + try { + // Templates default to kind=template if not specified + const { namespace, name } = await resolveEntityWithAmbiguityCheck(ref, { + defaultKind: 'template', + namespaceFlag: opts.namespace, + instance: opts.instance, + }); + + // Build the canonical template reference + const templateRef = `template:${namespace}/${name}`; + + // Values are optional - some templates accept no parameters + let values: string | undefined; + try { + values = resolveJsonInput(opts.value); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template execute my-template --value key=value --value otherKey=otherValue', + }); + } + + let secrets: string | undefined; + try { + secrets = resolveJsonInput(opts.secret); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template execute my-template --secret token=abc', + }); + } + + await runRawAction( + 'scaffolder:execute-template', + { + templateRef, + values, + secrets, + instance: opts.instance, + }, + mode, + 'rhdh-cli template list', + ); + } catch (error) { + handleCommandError(error, mode, { + suggestion: 'rhdh-cli template execute my-template', + }); + } + }); + + 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('--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', + }); + } + + // Values are optional - some templates accept no parameters + let values: string | undefined; + try { + values = resolveJsonInput(opts.value); + } catch (error) { + handleCommandError(error, mode, { + suggestion: + 'rhdh-cli template 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', + ); + }); +}