From e1510e5514f5a15428e661413e020deb43c2a783 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Mon, 13 Apr 2026 14:04:34 -0400 Subject: [PATCH 1/2] fix readme --- AGENTS.md | 8 ++++ README.md | 23 ++++++++---- src/cloud/register.ts | 31 ++++++++++++++++ src/cloud/request-builder.ts | 25 ++++++++++--- src/es/handler.ts | 9 ++++- test/cloud/register.test.ts | 44 ++++++++++++++++++++++ test/cloud/request-builder.test.ts | 53 +++++++++++++++++++++++++++ test/es/handler.test.ts | 59 ++++++++++++++++++++++++++++++ 8 files changed, 238 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e055482..13dd5fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,14 @@ When creating or modifying code that constructs URLs, sends credentials, or make 6. **When consuming codegen output, treat it as untrusted input.** The codegen produces valid schemas, but the CLI's generic layers made assumptions about which Zod types would appear. Validate those assumptions with tests that exercise actual generated output. +7. **Inspect the type definition before setting properties on shared types.** When building an object typed as an external interface (e.g., `TransportRequestParams` from `@elastic/transport`), read the type definition first. Don't assume it has a property just because it seems logical -- `TransportRequestParams` has `bulkBody` for NDJSON, not `body` + `headers`. JavaScript silently allows setting non-existent properties, so only `tsc --noEmit` or CI will catch this. Always run a type-check (`npx tsc --noEmit`) locally before pushing, not just tests. + +8. **Guard clauses that silently discard data are dangerous.** `collectBody()` had `if (!(def.body instanceof z.ZodObject)) return undefined` -- a guard clause that silently threw away all stdin/`--input-file` input for every Cloud POST command without an explicit body schema. That was the *common* case, not the edge case. When writing a guard clause that returns early with no data, ask: "what happens to the caller's input?" If the answer is "it gets silently dropped," that's almost certainly a bug. Prefer forwarding unknown input (with clear passthrough semantics) over silently discarding it. + +9. **Trace the full data flow across layers for every combination of modes.** The `--json` flag broke cat APIs because the handler returned raw text and the factory blindly called `JSON.stringify()` on it. Neither layer was wrong in isolation -- the handler correctly returned text, the factory correctly serialized JSON. But together, the combination of `responseType: 'text'` + `--json` was never traced end-to-end. When a feature involves two cooperating layers (handler + output formatter, request builder + transport), enumerate all mode combinations and verify each one produces correct output. + +10. **Codegen output needs a UX review.** Machine-generated command names (e.g., `list-deployments` from `listDeployments` operationId) are precise but verbose. Users will instinctively try shorter forms (`list`, `get`). When registering auto-generated commands, add short aliases where unambiguous, and test that users can discover commands with intuitive names. + ## Spec-Kit Workflow The project uses [spec-kit](https://github.com/github/spec-kit) for AI-assisted feature development. diff --git a/README.md b/README.md index 03c518ae..ca2707d6 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ current_context: local contexts: local: elasticsearch: - url: https://localhost:9200 + url: http://localhost:9200 auth: api_key: your-api-key-here staging: @@ -151,15 +151,22 @@ Requires a `cloud` service block in the active context. #### `cloud deployments` ```bash -elastic cloud deployments list -elastic cloud deployments get --deployment-id -elastic cloud deployments shutdown --deployment-id +elastic cloud deployments list # alias for list-deployments +elastic cloud deployments get --id # alias for get-deployment +elastic cloud deployments shutdown --id # alias for shutdown-deployment +elastic cloud deployments create <<< '{"name":"my-deployment",...}' ``` -#### `cloud projects` +#### `cloud elasticsearch-projects` ```bash -elastic cloud projects list -elastic cloud projects get --project-id -elastic cloud projects delete --project-id +elastic cloud elasticsearch-projects list # alias for list-elasticsearch-projects +elastic cloud elasticsearch-projects create <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud elasticsearch-projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud elasticsearch-projects get --id # alias for get-elasticsearch-project +elastic cloud elasticsearch-projects delete --id ``` + +Similar namespaces exist for `observability-projects` and `security-projects`. + +Run `elastic cloud --help` for all available namespace groups. diff --git a/src/cloud/register.ts b/src/cloud/register.ts index 6e63e2fb..8da6726b 100644 --- a/src/cloud/register.ts +++ b/src/cloud/register.ts @@ -60,6 +60,22 @@ function queryParamToZod(q: CloudQueryParam): z.ZodType { return q.required === true ? base : base.optional() } +/** + * Strips the namespace (or its singular form) suffix from a command name + * to produce a short alias. E.g. "list-deployments" in namespace "deployments" + * becomes "list"; "get-deployment" also becomes "get". + */ +function stripNamespaceSuffix (name: string, namespace: string): string { + const suffixes = [namespace] + if (namespace.endsWith('s')) suffixes.push(namespace.slice(0, -1)) + for (const suffix of suffixes) { + if (name.endsWith(`-${suffix}`)) { + return name.slice(0, -(suffix.length + 1)) + } + } + return name +} + /** * Registers all Cloud control plane API commands under a top-level `cloud` group. * @@ -67,6 +83,7 @@ function queryParamToZod(q: CloudQueryParam): z.ZodType { * 1. A unified flat Zod schema is built from `pathParams` + `queryParams` + optional `body`. * 2. `defineCommand` is called with that schema as `input`. * 3. Commands are grouped by namespace (e.g. `deployments`, `projects`). + * 4. Short aliases are added when unambiguous (e.g. `list` for `list-deployments`). * * @param definitions - flat array of API definitions; defaults to the full built-in registry * @returns an `OpaqueCommandHandle` for the top-level `cloud` group @@ -98,6 +115,16 @@ export function registerCloudCommands( seen.add(def.name) } + const shortNames = new Map() + for (const def of defs) { + const short = stripNamespaceSuffix(def.name, namespace) + if (short !== def.name) shortNames.set(def.name, short) + } + const shortCounts = new Map() + for (const short of shortNames.values()) { + shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1) + } + const leafHandles = defs.map((def) => { const schema = buildCommandSchema(def) const cmd = defineCommand({ @@ -109,6 +136,10 @@ export function registerCloudCommands( if (isCreateProjectCommand(def.name)) { (cmd as Command).option('--wait', 'Wait for the project to reach "initialized" phase before returning') } + const short = shortNames.get(def.name) + if (short != null && shortCounts.get(short) === 1) { + (cmd as Command).alias(short) + } return cmd }) diff --git a/src/cloud/request-builder.ts b/src/cloud/request-builder.ts index 71dc7f7b..69034851 100644 --- a/src/cloud/request-builder.ts +++ b/src/cloud/request-builder.ts @@ -79,18 +79,33 @@ function buildQuerystring( return qs } +const BODY_METHODS: ReadonlySet = new Set(['POST', 'PUT', 'PATCH']) + function collectBody( def: CloudApiDefinition, input: Record, ): Record | undefined { - if (!(def.body instanceof z.ZodObject)) return undefined + if (def.body instanceof z.ZodObject) { + const body: Record = {} + for (const fieldName of Object.keys(def.body.shape as Record)) { + if (input[fieldName] !== undefined) { + body[fieldName] = input[fieldName] + } + } + return Object.keys(body).length > 0 ? body : undefined + } + if (!BODY_METHODS.has(def.method)) return undefined + + const reserved = new Set([ + ...(def.pathParams ?? []).map((p) => p.name), + ...(def.queryParams ?? []).map((q) => q.cliFlag ?? q.name), + ]) const body: Record = {} - for (const fieldName of Object.keys(def.body.shape as Record)) { - if (input[fieldName] !== undefined) { - body[fieldName] = input[fieldName] + for (const [key, value] of Object.entries(input)) { + if (!reserved.has(key) && value !== undefined) { + body[key] = value } } - return Object.keys(body).length > 0 ? body : undefined } diff --git a/src/es/handler.ts b/src/es/handler.ts index 8f728f5a..8aa50a8d 100644 --- a/src/es/handler.ts +++ b/src/es/handler.ts @@ -62,8 +62,15 @@ export function createEsHandler ( try { const responseType = def.responseType ?? 'json' + const jsonRequested = parsed.options.json === true - if (responseType === 'text') { + if (responseType === 'text' && jsonRequested) { + const qs = (params.querystring ?? {}) as Record + qs.format = 'json' + params.querystring = qs + const body = await transport.request(params) + return body + } else if (responseType === 'text') { const body = await transport.request(params) return body } else { diff --git a/test/cloud/register.test.ts b/test/cloud/register.test.ts index 216fc8d7..2d4567fa 100644 --- a/test/cloud/register.test.ts +++ b/test/cloud/register.test.ts @@ -94,4 +94,48 @@ describe('registerCloudCommands', () => { assert.ok(leafNames.includes('create-elasticsearch-project')) }) }) + + describe('short aliases (#87)', () => { + it('adds short alias when command name ends with namespace suffix', () => { + const defs: CloudApiDefinition[] = [ + { name: 'list-deployments', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, + { name: 'get-deployment', namespace: 'deployments', description: 'Get', method: 'GET', path: '/api/v1/deployments/{id}', pathParams: [{ name: 'id', description: 'ID', required: true }] }, + ] + const group = registerCloudCommands(defs) + const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! + const listCmd = deploymentsGroup.commands.find((c) => c.name() === 'list-deployments')! + const getCmd = deploymentsGroup.commands.find((c) => c.name() === 'get-deployment')! + assert.ok(listCmd.aliases().includes('list'), 'list-deployments should have alias "list"') + assert.ok(getCmd.aliases().includes('get'), 'get-deployment should have alias "get"') + }) + + it('does not add alias when short name collides within namespace', () => { + const defs: CloudApiDefinition[] = [ + { name: 'get-items', namespace: 'items', description: 'A', method: 'GET', path: '/a' }, + { name: 'get-item', namespace: 'items', description: 'B', method: 'GET', path: '/b' }, + ] + const group = registerCloudCommands(defs) + const itemsGroup = group.commands.find((c) => c.name() === 'items')! + for (const cmd of itemsGroup.commands) { + assert.deepEqual(cmd.aliases(), [], `${cmd.name()} should have no alias since both resolve to "get"`) + } + }) + + it('does not add alias when command name does not match namespace suffix', () => { + const defs: CloudApiDefinition[] = [ + { name: 'shutdown', namespace: 'deployments', description: 'Shutdown', method: 'POST', path: '/api/v1/shutdown' }, + ] + const group = registerCloudCommands(defs) + const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! + const cmd = deploymentsGroup.commands.find((c) => c.name() === 'shutdown')! + assert.deepEqual(cmd.aliases(), []) + }) + + it('deployments namespace has working "list" alias for "list-deployments"', () => { + const group = registerCloudCommands() + const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! + const listCmd = deploymentsGroup.commands.find((c) => c.name() === 'list-deployments')! + assert.ok(listCmd.aliases().includes('list')) + }) + }) }) diff --git a/test/cloud/request-builder.test.ts b/test/cloud/request-builder.test.ts index 9d3d04f8..167a70c9 100644 --- a/test/cloud/request-builder.test.ts +++ b/test/cloud/request-builder.test.ts @@ -164,4 +164,57 @@ describe('buildCloudRequestParams', () => { assert.ok(!result.path.includes('#'), 'hash must be encoded') assert.equal(result.path, '/api/v1/deployments/..%2F..%2F..%2Fsecret%3F%23') }) + + it('forwards stdin body for POST commands without explicit body schema (#86)', () => { + const def: CloudApiDefinition = { + name: 'create-elasticsearch-project', + namespace: 'elasticsearch-projects', + description: 'Create', + method: 'POST', + path: '/api/v1/serverless/projects/elasticsearch', + } + const result = buildCloudRequestParams(def, parsed({ name: 'demo', region_id: 'aws-us-east-1' })) + assert.deepEqual(result.body, { name: 'demo', region_id: 'aws-us-east-1' }) + }) + + it('excludes path and query params from passthrough body (#86)', () => { + const def: CloudApiDefinition = { + name: 'patch-project', + namespace: 'elasticsearch-projects', + description: 'Patch', + method: 'PATCH', + path: '/api/v1/serverless/projects/elasticsearch/{id}', + pathParams: [{ name: 'id', description: 'ID', required: true }], + queryParams: [{ name: 'dry_run', type: 'boolean', description: 'Dry run' }], + } + const result = buildCloudRequestParams(def, parsed({ id: 'abc', dry_run: true, name: 'new-name' })) + assert.deepEqual(result.body, { name: 'new-name' }, 'path and query params should not be in body') + assert.equal(result.path, '/api/v1/serverless/projects/elasticsearch/abc') + assert.deepEqual(result.querystring, { dry_run: 'true' }) + }) + + it('does not create body for GET commands without explicit schema', () => { + const def: CloudApiDefinition = { + name: 'list', + namespace: 'deployments', + description: 'List', + method: 'GET', + path: '/api/v1/deployments', + } + const result = buildCloudRequestParams(def, parsed({ extra: 'value' })) + assert.equal(result.body, undefined) + }) + + it('does not create body for DELETE commands without explicit schema', () => { + const def: CloudApiDefinition = { + name: 'delete', + namespace: 'deployments', + description: 'Delete', + method: 'DELETE', + path: '/api/v1/deployments/{id}', + pathParams: [{ name: 'id', description: 'ID', required: true }], + } + const result = buildCloudRequestParams(def, parsed({ id: 'abc', extra: 'value' })) + assert.equal(result.body, undefined) + }) }) diff --git a/test/es/handler.test.ts b/test/es/handler.test.ts index a2be2e06..77d9df5d 100644 --- a/test/es/handler.test.ts +++ b/test/es/handler.test.ts @@ -267,4 +267,63 @@ describe('createEsHandler', () => { assert.equal(err['code'], 'transport_error') assert.equal(err['message'], 'something unexpected') }) + + it('injects format=json and parses as JSON when --json is active with responseType: text (#88)', async () => { + const capturedParams: TransportRequestParams[] = [] + const jsonBody = [{ alias: '.kibana', index: '.kibana_1' }] + const deps = makeDeps({ + getTransport: () => ({ + request: async (params: TransportRequestParams) => { + capturedParams.push(params) + return jsonBody + }, + } as unknown as Transport), + buildRequestParams: () => ({ method: 'GET', path: '/_cat/aliases' }), + }) + + const handler = createEsHandler(makeDef({ responseType: 'text' }), [], deps) + const result = await handler(parsedInput({}, { json: true })) + + assert.deepEqual(result, jsonBody) + assert.equal((capturedParams[0]?.querystring as Record)?.format, 'json') + }) + + it('does not inject format=json when --json is not active with responseType: text', async () => { + const capturedParams: TransportRequestParams[] = [] + const deps = makeDeps({ + getTransport: () => ({ + request: async (params: TransportRequestParams) => { + capturedParams.push(params) + return 'green\n' + }, + } as unknown as Transport), + buildRequestParams: () => ({ method: 'GET', path: '/_cat/health' }), + }) + + const handler = createEsHandler(makeDef({ responseType: 'text' }), [], deps) + const result = await handler(parsedInput()) + + assert.equal(result, 'green\n') + assert.equal(capturedParams[0]?.querystring, undefined) + }) + + it('does not inject format=json for responseType: json even with --json', async () => { + const capturedParams: TransportRequestParams[] = [] + const jsonBody = { status: 'green' } + const deps = makeDeps({ + getTransport: () => ({ + request: async (params: TransportRequestParams) => { + capturedParams.push(params) + return jsonBody + }, + } as unknown as Transport), + buildRequestParams: () => ({ method: 'GET', path: '/_cluster/health' }), + }) + + const handler = createEsHandler(makeDef({ responseType: 'json' }), [], deps) + const result = await handler(parsedInput({}, { json: true })) + + assert.deepEqual(result, jsonBody) + assert.equal(capturedParams[0]?.querystring, undefined) + }) }) From dbb7c4237513be673eed5791b9b64f7d11224472 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Mon, 13 Apr 2026 15:25:35 -0400 Subject: [PATCH 2/2] add more subcommands --- README.md | 53 ++++++++--- src/cli.ts | 9 +- src/cloud/register.ts | 182 ++++++++++++++++++++++++++--------- test/cloud/register.test.ts | 183 +++++++++++++++++++++++++++--------- 4 files changed, 324 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index ca2707d6..94802c31 100644 --- a/README.md +++ b/README.md @@ -143,30 +143,55 @@ elastic es update --index my-index --id abc123 Run `elastic es --help` for all available options on any command. -### `cloud` - Elastic Cloud control plane +### `cloud` - Elastic Cloud (hosted) -Manage Elastic Cloud deployments and Elasticsearch serverless projects. +Manage Elastic Cloud hosted deployments. Requires a `cloud` service block in the active context. #### `cloud deployments` ```bash -elastic cloud deployments list # alias for list-deployments -elastic cloud deployments get --id # alias for get-deployment -elastic cloud deployments shutdown --id # alias for shutdown-deployment -elastic cloud deployments create <<< '{"name":"my-deployment",...}' +elastic cloud deployments list-deployments +elastic cloud deployments get-deployment --id +elastic cloud deployments shutdown-deployment --id +elastic cloud deployments create-deployment <<< '{"name":"my-deployment",...}' ``` -#### `cloud elasticsearch-projects` +Run `elastic cloud --help` for all available namespace groups (accounts, +billing-costs-analysis, deployment-templates, extensions, organizations, etc.). + +### `serverless` - Elastic Serverless + +Manage Elastic Serverless projects and resources. +Requires a `cloud` service block in the active context. + +#### `serverless es projects` - Elasticsearch projects + +```bash +elastic serverless es projects list +elastic serverless es projects create <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic serverless es projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic serverless es projects get --id +elastic serverless es projects delete --id +elastic serverless es projects get-status --id +elastic serverless es projects get-roles --id +elastic serverless es projects reset-credentials --id +``` + +#### `serverless observability projects` / `serverless security projects` + +Same commands as `es projects` but for Observability and Security project types: ```bash -elastic cloud elasticsearch-projects list # alias for list-elasticsearch-projects -elastic cloud elasticsearch-projects create <<< '{"name":"demo","region_id":"aws-us-east-1"}' -elastic cloud elasticsearch-projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' -elastic cloud elasticsearch-projects get --id # alias for get-elasticsearch-project -elastic cloud elasticsearch-projects delete --id +elastic serverless observability projects list +elastic serverless security projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' ``` -Similar namespaces exist for `observability-projects` and `security-projects`. +#### Other serverless resources + +```bash +elastic serverless regions list-regions +elastic serverless traffic-filters list-traffic-filters +``` -Run `elastic cloud --help` for all available namespace groups. +Run `elastic serverless --help` for all available groups. diff --git a/src/cli.ts b/src/cli.ts index e7a1a4a9..511b75b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -69,7 +69,14 @@ if (firstArg === 'cloud') { const { registerCloudCommands } = await import('./cloud/register.ts') program.addCommand(registerCloudCommands()) } else { - program.addCommand(defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud deployments and serverless projects' })) + program.addCommand(defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud deployments' })) +} + +if (firstArg === 'serverless') { + const { registerServerlessCommands } = await import('./cloud/register.ts') + program.addCommand(registerServerlessCommands()) +} else { + program.addCommand(defineGroup({ name: 'serverless', description: 'Manage Elastic Serverless projects and resources' })) } // Load config early so --help can hide blocked commands. Skip for commands diff --git a/src/cloud/register.ts b/src/cloud/register.ts index 8da6726b..af94380e 100644 --- a/src/cloud/register.ts +++ b/src/cloud/register.ts @@ -61,74 +61,145 @@ function queryParamToZod(q: CloudQueryParam): z.ZodType { } /** - * Strips the namespace (or its singular form) suffix from a command name - * to produce a short alias. E.g. "list-deployments" in namespace "deployments" - * becomes "list"; "get-deployment" also becomes "get". + * Maps project-type namespaces from codegen to short CLI group names. + * E.g. `elasticsearch-projects` → `es`, used to build + * `elastic serverless es projects `. */ -function stripNamespaceSuffix (name: string, namespace: string): string { - const suffixes = [namespace] - if (namespace.endsWith('s')) suffixes.push(namespace.slice(0, -1)) - for (const suffix of suffixes) { - if (name.endsWith(`-${suffix}`)) { - return name.slice(0, -(suffix.length + 1)) +const PROJECT_NAMESPACES: Record = { + 'elasticsearch-projects': 'es', + 'observability-projects': 'observability', + 'security-projects': 'security', +} + +/** + * Strips the project-type identifier from a codegen command name to produce + * a short action name for the restructured tree. + * + * E.g. `list-elasticsearch-projects` → `list`, + * `reset-elasticsearch-project-credentials` → `reset-credentials`, + * `get-elasticsearch-project-status` → `get-status`. + */ +export function simplifyProjectCommandName (name: string, namespace: string): string { + const singular = namespace.endsWith('s') ? namespace.slice(0, -1) : namespace + let simplified = name.replace(`-${namespace}`, '') + if (simplified === name) { + simplified = name.replace(`-${singular}`, '') + } + return simplified || name +} + +function groupByNamespace (definitions: CloudApiDefinition[]): Map { + const byNamespace = new Map() + for (const def of definitions) { + let group = byNamespace.get(def.namespace) + if (group == null) { + group = [] + byNamespace.set(def.namespace, group) + } + group.push(def) + } + return byNamespace +} + +function checkDuplicates (defs: CloudApiDefinition[], namespace: string): void { + const seen = new Set() + for (const def of defs) { + if (seen.has(def.name)) { + throw new Error(`duplicate command name "${def.name}" in namespace "${namespace}"`) } + seen.add(def.name) } - return name } /** - * Registers all Cloud control plane API commands under a top-level `cloud` group. + * Registers Cloud Hosted (non-serverless) API commands under a top-level `cloud` group. * * For each definition: * 1. A unified flat Zod schema is built from `pathParams` + `queryParams` + optional `body`. * 2. `defineCommand` is called with that schema as `input`. - * 3. Commands are grouped by namespace (e.g. `deployments`, `projects`). - * 4. Short aliases are added when unambiguous (e.g. `list` for `list-deployments`). + * 3. Commands are grouped by namespace (e.g. `deployments`, `accounts`). * - * @param definitions - flat array of API definitions; defaults to the full built-in registry + * @param definitions - flat array of API definitions; defaults to the hosted cloud registry * @returns an `OpaqueCommandHandle` for the top-level `cloud` group */ export function registerCloudCommands( - definitions: CloudApiDefinition[] = [...allCloudApis, ...allServerlessApis], + definitions: CloudApiDefinition[] = allCloudApis, ): OpaqueCommandHandle { for (const def of definitions) { validateCloudApiDefinition(def) } - const byNamespace = new Map() + const byNamespace = groupByNamespace(definitions) + const namespaceHandles: OpaqueCommandHandle[] = [] + + for (const [namespace, defs] of byNamespace) { + checkDuplicates(defs, namespace) + + const leafHandles = defs.map((def) => { + const schema = buildCommandSchema(def) + return defineCommand({ + name: def.name, + description: def.description, + input: schema, + handler: createCloudHandler(def), + }) + }) + + namespaceHandles.push( + defineGroup({ name: namespace, description: `Cloud ${namespace} commands` }, ...leafHandles) + ) + } + + return defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud deployments' }, ...namespaceHandles) +} + +/** + * Registers Serverless API commands under a top-level `serverless` group. + * + * Project namespaces (`elasticsearch-projects`, `observability-projects`, + * `security-projects`) are restructured into a cleaner hierarchy: + * + * elastic serverless es projects list + * elastic serverless observability projects create + * elastic serverless security projects get --id + * + * Other serverless namespaces (regions, traffic-filters, etc.) are kept as + * direct children of the `serverless` group with their original command names. + * + * @param definitions - flat array of API definitions; defaults to the serverless registry + * @returns an `OpaqueCommandHandle` for the top-level `serverless` group + */ +export function registerServerlessCommands( + definitions: CloudApiDefinition[] = allServerlessApis, +): OpaqueCommandHandle { for (const def of definitions) { - let group = byNamespace.get(def.namespace) + validateCloudApiDefinition(def) + } + + const projectDefs = new Map() + const otherDefs = new Map() + + for (const def of definitions) { + const target = PROJECT_NAMESPACES[def.namespace] != null ? projectDefs : otherDefs + let group = target.get(def.namespace) if (group == null) { group = [] - byNamespace.set(def.namespace, group) + target.set(def.namespace, group) } group.push(def) } - const namespaceHandles: OpaqueCommandHandle[] = [] - for (const [namespace, defs] of byNamespace) { - const seen = new Set() - for (const def of defs) { - if (seen.has(def.name)) { - throw new Error(`duplicate command name "${def.name}" in namespace "${namespace}"`) - } - seen.add(def.name) - } + const topLevelHandles: OpaqueCommandHandle[] = [] - const shortNames = new Map() - for (const def of defs) { - const short = stripNamespaceSuffix(def.name, namespace) - if (short !== def.name) shortNames.set(def.name, short) - } - const shortCounts = new Map() - for (const short of shortNames.values()) { - shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1) - } + for (const [namespace, defs] of projectDefs) { + const typeShort = PROJECT_NAMESPACES[namespace]! + const typeLabel = namespace.replace(/-projects$/, '') const leafHandles = defs.map((def) => { + const shortName = simplifyProjectCommandName(def.name, namespace) const schema = buildCommandSchema(def) const cmd = defineCommand({ - name: def.name, + name: shortName, description: def.description, input: schema, handler: createCloudHandler(def), @@ -136,17 +207,40 @@ export function registerCloudCommands( if (isCreateProjectCommand(def.name)) { (cmd as Command).option('--wait', 'Wait for the project to reach "initialized" phase before returning') } - const short = shortNames.get(def.name) - if (short != null && shortCounts.get(short) === 1) { - (cmd as Command).alias(short) - } return cmd }) - namespaceHandles.push( - defineGroup({ name: namespace, description: `Cloud ${namespace} commands` }, ...leafHandles) + const projectsGroup = defineGroup( + { name: 'projects', description: `Manage ${typeLabel} projects` }, + ...leafHandles, + ) + const typeGroup = defineGroup( + { name: typeShort, description: `Elastic Serverless ${typeLabel} commands` }, + projectsGroup, + ) + topLevelHandles.push(typeGroup) + } + + for (const [namespace, defs] of otherDefs) { + checkDuplicates(defs, namespace) + + const leafHandles = defs.map((def) => { + const schema = buildCommandSchema(def) + return defineCommand({ + name: def.name, + description: def.description, + input: schema, + handler: createCloudHandler(def), + }) + }) + + topLevelHandles.push( + defineGroup({ name: namespace, description: `Serverless ${namespace} commands` }, ...leafHandles) ) } - return defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud deployments and serverless projects' }, ...namespaceHandles) + return defineGroup( + { name: 'serverless', description: 'Manage Elastic Serverless projects and resources' }, + ...topLevelHandles, + ) } diff --git a/test/cloud/register.test.ts b/test/cloud/register.test.ts index 2d4567fa..7310de49 100644 --- a/test/cloud/register.test.ts +++ b/test/cloud/register.test.ts @@ -5,7 +5,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { registerCloudCommands } from '../../src/cloud/register.ts' +import { registerCloudCommands, registerServerlessCommands, simplifyProjectCommandName } from '../../src/cloud/register.ts' import type { CloudApiDefinition } from '../../src/cloud/types.ts' describe('registerCloudCommands', () => { @@ -18,12 +18,12 @@ describe('registerCloudCommands', () => { it('creates namespace subgroups from definitions', () => { const defs: CloudApiDefinition[] = [ { name: 'list', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, - { name: 'list', namespace: 'projects', description: 'List', method: 'GET', path: '/api/v1/projects' }, + { name: 'list', namespace: 'accounts', description: 'List', method: 'GET', path: '/api/v1/accounts' }, ] const group = registerCloudCommands(defs) const subcommands = group.commands.map((c) => c.name()) assert.ok(subcommands.includes('deployments')) - assert.ok(subcommands.includes('projects')) + assert.ok(subcommands.includes('accounts')) }) it('registers leaf commands under their namespace', () => { @@ -55,16 +55,21 @@ describe('registerCloudCommands', () => { }) }) - describe('default API definitions', () => { - it('includes cloud and serverless namespaces by default', () => { + describe('default API definitions (hosted cloud only)', () => { + it('includes hosted cloud namespaces', () => { const group = registerCloudCommands() const subcommands = group.commands.map((c) => c.name()) assert.ok(subcommands.includes('deployments'), 'should have deployments') assert.ok(subcommands.includes('accounts'), 'should have accounts') assert.ok(subcommands.includes('extensions'), 'should have extensions') - assert.ok(subcommands.includes('elasticsearch-projects'), 'should have elasticsearch-projects') - assert.ok(subcommands.includes('regions'), 'should have regions') - assert.ok(subcommands.includes('traffic-filters'), 'should have traffic-filters') + }) + + it('does not include serverless namespaces', () => { + const group = registerCloudCommands() + const subcommands = group.commands.map((c) => c.name()) + assert.ok(!subcommands.includes('elasticsearch-projects'), 'should not have elasticsearch-projects') + assert.ok(!subcommands.includes('regions'), 'should not have regions') + assert.ok(!subcommands.includes('traffic-filters'), 'should not have traffic-filters') }) it('deployments namespace has list, get, and shutdown commands', () => { @@ -84,58 +89,148 @@ describe('registerCloudCommands', () => { assert.ok(leafNames.includes('update-current-account')) }) - it('elasticsearch-projects namespace has CRUD commands', () => { + it('no commands have aliases', () => { const group = registerCloudCommands() - const esProjects = group.commands.find((c) => c.name() === 'elasticsearch-projects')! - const leafNames = esProjects.commands.map((c) => c.name()) - assert.ok(leafNames.includes('list-elasticsearch-projects')) - assert.ok(leafNames.includes('get-elasticsearch-project')) - assert.ok(leafNames.includes('delete-elasticsearch-project')) - assert.ok(leafNames.includes('create-elasticsearch-project')) + for (const ns of group.commands) { + for (const cmd of ns.commands) { + assert.deepEqual(cmd.aliases(), [], `${cmd.name()} should have no alias`) + } + } }) }) +}) + +describe('registerServerlessCommands', () => { + describe('command tree structure', () => { + it('returns a top-level "serverless" group', () => { + const group = registerServerlessCommands([]) + assert.equal(group.name(), 'serverless') + }) - describe('short aliases (#87)', () => { - it('adds short alias when command name ends with namespace suffix', () => { + it('restructures elasticsearch-projects under serverless > es > projects', () => { const defs: CloudApiDefinition[] = [ - { name: 'list-deployments', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, - { name: 'get-deployment', namespace: 'deployments', description: 'Get', method: 'GET', path: '/api/v1/deployments/{id}', pathParams: [{ name: 'id', description: 'ID', required: true }] }, + { name: 'list-elasticsearch-projects', namespace: 'elasticsearch-projects', description: 'List', method: 'GET', path: '/api/v1/serverless/projects/elasticsearch' }, + { name: 'create-elasticsearch-project', namespace: 'elasticsearch-projects', description: 'Create', method: 'POST', path: '/api/v1/serverless/projects/elasticsearch' }, ] - const group = registerCloudCommands(defs) - const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! - const listCmd = deploymentsGroup.commands.find((c) => c.name() === 'list-deployments')! - const getCmd = deploymentsGroup.commands.find((c) => c.name() === 'get-deployment')! - assert.ok(listCmd.aliases().includes('list'), 'list-deployments should have alias "list"') - assert.ok(getCmd.aliases().includes('get'), 'get-deployment should have alias "get"') + const group = registerServerlessCommands(defs) + const esGroup = group.commands.find((c) => c.name() === 'es')! + assert.ok(esGroup, 'should have "es" subgroup') + const projectsGroup = esGroup.commands.find((c) => c.name() === 'projects')! + assert.ok(projectsGroup, 'should have "projects" under "es"') + const leafNames = projectsGroup.commands.map((c) => c.name()) + assert.deepEqual(leafNames, ['list', 'create']) }) - it('does not add alias when short name collides within namespace', () => { + it('restructures observability-projects under serverless > observability > projects', () => { const defs: CloudApiDefinition[] = [ - { name: 'get-items', namespace: 'items', description: 'A', method: 'GET', path: '/a' }, - { name: 'get-item', namespace: 'items', description: 'B', method: 'GET', path: '/b' }, + { name: 'list-observability-projects', namespace: 'observability-projects', description: 'List', method: 'GET', path: '/api/v1/serverless/projects/observability' }, + { name: 'get-observability-project', namespace: 'observability-projects', description: 'Get', method: 'GET', path: '/api/v1/serverless/projects/observability/{id}', pathParams: [{ name: 'id', description: 'ID', required: true }] }, ] - const group = registerCloudCommands(defs) - const itemsGroup = group.commands.find((c) => c.name() === 'items')! - for (const cmd of itemsGroup.commands) { - assert.deepEqual(cmd.aliases(), [], `${cmd.name()} should have no alias since both resolve to "get"`) - } + const group = registerServerlessCommands(defs) + const obsGroup = group.commands.find((c) => c.name() === 'observability')! + const projectsGroup = obsGroup.commands.find((c) => c.name() === 'projects')! + const leafNames = projectsGroup.commands.map((c) => c.name()) + assert.deepEqual(leafNames, ['list', 'get']) }) - it('does not add alias when command name does not match namespace suffix', () => { + it('restructures security-projects under serverless > security > projects', () => { const defs: CloudApiDefinition[] = [ - { name: 'shutdown', namespace: 'deployments', description: 'Shutdown', method: 'POST', path: '/api/v1/shutdown' }, + { name: 'delete-security-project', namespace: 'security-projects', description: 'Delete', method: 'DELETE', path: '/api/v1/serverless/projects/security/{id}', pathParams: [{ name: 'id', description: 'ID', required: true }] }, ] - const group = registerCloudCommands(defs) - const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! - const cmd = deploymentsGroup.commands.find((c) => c.name() === 'shutdown')! - assert.deepEqual(cmd.aliases(), []) + const group = registerServerlessCommands(defs) + const secGroup = group.commands.find((c) => c.name() === 'security')! + const projectsGroup = secGroup.commands.find((c) => c.name() === 'projects')! + const leafNames = projectsGroup.commands.map((c) => c.name()) + assert.deepEqual(leafNames, ['delete']) }) - it('deployments namespace has working "list" alias for "list-deployments"', () => { - const group = registerCloudCommands() - const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! - const listCmd = deploymentsGroup.commands.find((c) => c.name() === 'list-deployments')! - assert.ok(listCmd.aliases().includes('list')) + it('keeps non-project namespaces as direct children of serverless', () => { + const defs: CloudApiDefinition[] = [ + { name: 'list-regions', namespace: 'regions', description: 'List', method: 'GET', path: '/api/v1/serverless/regions' }, + { name: 'get-region', namespace: 'regions', description: 'Get', method: 'GET', path: '/api/v1/serverless/regions/{id}', pathParams: [{ name: 'id', description: 'ID', required: true }] }, + ] + const group = registerServerlessCommands(defs) + const regionsGroup = group.commands.find((c) => c.name() === 'regions')! + assert.ok(regionsGroup, 'should have "regions" subgroup') + const leafNames = regionsGroup.commands.map((c) => c.name()) + assert.deepEqual(leafNames, ['list-regions', 'get-region']) + }) + + it('adds --wait flag to create project commands', () => { + const defs: CloudApiDefinition[] = [ + { name: 'create-elasticsearch-project', namespace: 'elasticsearch-projects', description: 'Create', method: 'POST', path: '/api/v1/serverless/projects/elasticsearch' }, + { name: 'list-elasticsearch-projects', namespace: 'elasticsearch-projects', description: 'List', method: 'GET', path: '/api/v1/serverless/projects/elasticsearch' }, + ] + const group = registerServerlessCommands(defs) + const projectsGroup = group.commands.find((c) => c.name() === 'es')!.commands.find((c) => c.name() === 'projects')! + const createCmd = projectsGroup.commands.find((c) => c.name() === 'create')! + const listCmd = projectsGroup.commands.find((c) => c.name() === 'list')! + const createOpts = createCmd.options.map((o) => o.long) + const listOpts = listCmd.options.map((o) => o.long) + assert.ok(createOpts.includes('--wait'), 'create should have --wait') + assert.ok(!listOpts.includes('--wait'), 'list should not have --wait') + }) + }) + + describe('default API definitions', () => { + it('includes project types as restructured groups', () => { + const group = registerServerlessCommands() + const subcommands = group.commands.map((c) => c.name()) + assert.ok(subcommands.includes('es'), 'should have "es" group') + assert.ok(subcommands.includes('observability'), 'should have "observability" group') + assert.ok(subcommands.includes('security'), 'should have "security" group') + }) + + it('includes non-project serverless namespaces', () => { + const group = registerServerlessCommands() + const subcommands = group.commands.map((c) => c.name()) + assert.ok(subcommands.includes('regions'), 'should have regions') + assert.ok(subcommands.includes('traffic-filters'), 'should have traffic-filters') + }) + + it('es projects has CRUD commands with short names', () => { + const group = registerServerlessCommands() + const esGroup = group.commands.find((c) => c.name() === 'es')! + const projectsGroup = esGroup.commands.find((c) => c.name() === 'projects')! + const leafNames = projectsGroup.commands.map((c) => c.name()) + assert.ok(leafNames.includes('list'), 'should have list') + assert.ok(leafNames.includes('create'), 'should have create') + assert.ok(leafNames.includes('get'), 'should have get') + assert.ok(leafNames.includes('delete'), 'should have delete') + assert.ok(leafNames.includes('patch'), 'should have patch') + assert.ok(leafNames.includes('resume'), 'should have resume') + assert.ok(leafNames.includes('get-status'), 'should have get-status') + assert.ok(leafNames.includes('get-roles'), 'should have get-roles') + assert.ok(leafNames.includes('reset-credentials'), 'should have reset-credentials') }) }) }) + +describe('simplifyProjectCommandName', () => { + it('strips plural namespace suffix', () => { + assert.equal(simplifyProjectCommandName('list-elasticsearch-projects', 'elasticsearch-projects'), 'list') + }) + + it('strips singular namespace suffix', () => { + assert.equal(simplifyProjectCommandName('create-elasticsearch-project', 'elasticsearch-projects'), 'create') + }) + + it('strips mid-name project type leaving hyphenated remainder', () => { + assert.equal(simplifyProjectCommandName('reset-elasticsearch-project-credentials', 'elasticsearch-projects'), 'reset-credentials') + }) + + it('strips project type from compound action names', () => { + assert.equal(simplifyProjectCommandName('get-elasticsearch-project-status', 'elasticsearch-projects'), 'get-status') + assert.equal(simplifyProjectCommandName('get-elasticsearch-project-roles', 'elasticsearch-projects'), 'get-roles') + }) + + it('works for observability namespace', () => { + assert.equal(simplifyProjectCommandName('list-observability-projects', 'observability-projects'), 'list') + assert.equal(simplifyProjectCommandName('resume-observability-project', 'observability-projects'), 'resume') + }) + + it('works for security namespace', () => { + assert.equal(simplifyProjectCommandName('delete-security-project', 'security-projects'), 'delete') + assert.equal(simplifyProjectCommandName('get-security-project-status', 'security-projects'), 'get-status') + }) +})