diff --git a/README.md b/README.md index fb6390fe..f086ec18 100644 --- a/README.md +++ b/README.md @@ -224,55 +224,69 @@ elastic es update --index my-index --id abc123 Run `elastic es --help` for all available options on any command. -### `cloud` - Elastic Cloud (hosted) +### `cloud` - Elastic Cloud -Manage Elastic Cloud hosted deployments. +Manage Elastic Cloud: Hosted deployments and Serverless projects. Requires a `cloud` service block in the active context. -#### `cloud deployments` +The tree has three kinds of children: + +- Cross-cutting namespaces as direct children of `cloud` (APIs that apply to + both Hosted and Serverless). +- `cloud hosted …` for Hosted-Deployment APIs. +- `cloud serverless …` for Serverless-Project APIs. + +#### Cross-cutting (account, auth, orgs, roles) ```bash -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",...}' +elastic cloud accounts get-current-account +elastic cloud authentication get-api-keys +elastic cloud organizations list-organizations +elastic cloud organizations get-organization --organization-id +elastic cloud user-role-assignments add-role-assignments --user-id <<< '{...}' ``` -Run `elastic cloud --help` for all available namespace groups (accounts, -billing-costs-analysis, deployment-templates, extensions, organizations, etc.). +#### `cloud hosted` - Hosted Deployments -### `serverless` - Elastic Serverless +```bash +elastic cloud hosted deployments list-deployments +elastic cloud hosted deployments get-deployment --id +elastic cloud hosted deployments shutdown-deployment --id +elastic cloud hosted deployments create-deployment <<< '{"name":"my-deployment",...}' +elastic cloud hosted deployment-templates list-deployment-templates +elastic cloud hosted extensions list-extensions +elastic cloud hosted stack get-version-stacks +``` -Manage Elastic Serverless projects and resources. -Requires a `cloud` service block in the active context. +Run `elastic cloud hosted --help` for all available namespace groups +(billing-costs-analysis, deployment-templates, deployments, deployments-traffic-filter, +extensions, stack, trusted-environments). -#### `serverless es projects` - Elasticsearch projects +#### `cloud serverless` - Serverless 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 +elastic cloud serverless es projects list +elastic cloud serverless es projects create <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud serverless es projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud serverless es projects get --id +elastic cloud serverless es projects delete --id +elastic cloud serverless es projects get-status --id +elastic cloud serverless es projects get-roles --id +elastic cloud serverless es projects reset-credentials --id ``` -#### `serverless observability projects` / `serverless security projects` - -Same commands as `es projects` but for Observability and Security project types: +Same commands are available under `observability` and `security`: ```bash -elastic serverless observability projects list -elastic serverless security projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud serverless observability projects list +elastic cloud serverless security projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' ``` -#### Other serverless resources +Other serverless resources: ```bash -elastic serverless regions list-regions -elastic serverless traffic-filters list-traffic-filters +elastic cloud serverless regions list-regions +elastic cloud serverless traffic-filters list-traffic-filters ``` -Run `elastic serverless --help` for all available groups. +Run `elastic cloud serverless --help` for all available groups. diff --git a/src/cli.ts b/src/cli.ts index a8145c65..fee43422 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -71,14 +71,7 @@ if (firstArg === 'cloud') { const { registerCloudCommands } = await import('./cloud/register.ts') program.addCommand(registerCloudCommands()) } else { - 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' })) + program.addCommand(defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud (hosted deployments and serverless projects)' })) } if (firstArg === 'docs') { diff --git a/src/cloud/register.ts b/src/cloud/register.ts index af94380e..34d6ed2f 100644 --- a/src/cloud/register.ts +++ b/src/cloud/register.ts @@ -63,7 +63,7 @@ function queryParamToZod(q: CloudQueryParam): z.ZodType { /** * Maps project-type namespaces from codegen to short CLI group names. * E.g. `elasticsearch-projects` → `es`, used to build - * `elastic serverless es projects `. + * `elastic cloud serverless es projects `. */ const PROJECT_NAMESPACES: Record = { 'elasticsearch-projects': 'es', @@ -71,6 +71,32 @@ const PROJECT_NAMESPACES: Record = { 'security-projects': 'security', } +/** + * Cross-cutting namespaces promoted to direct children of `cloud` because their APIs + * apply to both Hosted deployments and Serverless projects. + */ +const PROMOTED_NAMESPACES = new Set([ + 'accounts', + 'authentication', + 'organizations', + 'user-role-assignments', +]) + +/** + * Namespaces that belong under `cloud serverless`. Enumerated rather than derived + * from `allServerlessApis` so callers passing synthetic definitions to + * `registerCloudCommands` still partition deterministically. + */ +const SERVERLESS_NAMESPACES = new Set([ + 'elasticsearch-projects', + 'observability-projects', + 'security-projects', + 'regions', + 'traffic-filters', + 'linked-projects', + 'linked-candidate-projects', +]) + /** * Strips the project-type identifier from a codegen command name to produce * a short action name for the restructured tree. @@ -111,75 +137,77 @@ function checkDuplicates (defs: CloudApiDefinition[], namespace: string): void { } } -/** - * 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`, `accounts`). - * - * @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, -): OpaqueCommandHandle { - for (const def of definitions) { - validateCloudApiDefinition(def) - } - - const byNamespace = groupByNamespace(definitions) - const namespaceHandles: OpaqueCommandHandle[] = [] +function buildFlatLeaf (def: CloudApiDefinition): OpaqueCommandHandle { + const schema = buildCommandSchema(def) + return defineCommand({ + name: def.name, + description: def.description, + input: schema, + handler: createCloudHandler(def), + }) +} - for (const [namespace, defs] of byNamespace) { +function buildFlatNamespaceGroups ( + defsByNamespace: Map, + descriptionPrefix: string, +): OpaqueCommandHandle[] { + const handles: OpaqueCommandHandle[] = [] + for (const [namespace, defs] of defsByNamespace) { 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) + const leaves = defs.map(buildFlatLeaf) + handles.push( + defineGroup({ name: namespace, description: `${descriptionPrefix} ${namespace} commands` }, ...leaves), ) } - - return defineGroup({ name: 'cloud', description: 'Manage Elastic Cloud deployments' }, ...namespaceHandles) + return handles } -/** - * 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, +function buildServerlessProjectGroup ( + namespace: string, + defs: CloudApiDefinition[], ): OpaqueCommandHandle { - for (const def of definitions) { - validateCloudApiDefinition(def) - } + const typeShort = PROJECT_NAMESPACES[namespace]! + const typeLabel = namespace.replace(/-projects$/, '') + + const leaves = defs.map((def) => { + const shortName = simplifyProjectCommandName(def.name, namespace) + const schema = buildCommandSchema(def) + const cmd = defineCommand({ + name: shortName, + description: def.description, + input: schema, + handler: createCloudHandler(def), + }) + if (isCreateProjectCommand(def.name)) { + (cmd as Command).option('--wait', 'Wait for the project to reach "initialized" phase before returning') + } + return cmd + }) + + const projectsGroup = defineGroup( + { name: 'projects', description: `Manage ${typeLabel} projects` }, + ...leaves, + ) + return defineGroup( + { name: typeShort, description: `Elastic Serverless ${typeLabel} commands` }, + projectsGroup, + ) +} +function buildHostedGroup (defs: CloudApiDefinition[]): OpaqueCommandHandle { + const byNamespace = groupByNamespace(defs) + const namespaceHandles = buildFlatNamespaceGroups(byNamespace, 'Cloud hosted') + return defineGroup( + { name: 'hosted', description: 'Manage Elastic Cloud Hosted deployments' }, + ...namespaceHandles, + ) +} + +function buildServerlessGroup (defs: CloudApiDefinition[]): OpaqueCommandHandle { const projectDefs = new Map() const otherDefs = new Map() - for (const def of definitions) { + for (const def of defs) { const target = PROJECT_NAMESPACES[def.namespace] != null ? projectDefs : otherDefs let group = target.get(def.namespace) if (group == null) { @@ -189,58 +217,82 @@ export function registerServerlessCommands( group.push(def) } - const topLevelHandles: OpaqueCommandHandle[] = [] - - 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: shortName, - description: def.description, - input: schema, - handler: createCloudHandler(def), - }) - if (isCreateProjectCommand(def.name)) { - (cmd as Command).option('--wait', 'Wait for the project to reach "initialized" phase before returning') - } - return cmd - }) - - const projectsGroup = defineGroup( - { name: 'projects', description: `Manage ${typeLabel} projects` }, - ...leafHandles, - ) - const typeGroup = defineGroup( - { name: typeShort, description: `Elastic Serverless ${typeLabel} commands` }, - projectsGroup, - ) - topLevelHandles.push(typeGroup) + const children: OpaqueCommandHandle[] = [] + for (const [namespace, nsDefs] of projectDefs) { + children.push(buildServerlessProjectGroup(namespace, nsDefs)) } + children.push(...buildFlatNamespaceGroups(otherDefs, 'Serverless')) - for (const [namespace, defs] of otherDefs) { - checkDuplicates(defs, namespace) + return defineGroup( + { name: 'serverless', description: 'Manage Elastic Serverless projects and resources' }, + ...children, + ) +} - const leafHandles = defs.map((def) => { - const schema = buildCommandSchema(def) - return defineCommand({ - name: def.name, - description: def.description, - input: schema, - handler: createCloudHandler(def), - }) - }) +interface PartitionedDefinitions { + promoted: Map + hosted: CloudApiDefinition[] + serverless: CloudApiDefinition[] +} - topLevelHandles.push( - defineGroup({ name: namespace, description: `Serverless ${namespace} commands` }, ...leafHandles) - ) +function partitionDefinitions (definitions: CloudApiDefinition[]): PartitionedDefinitions { + const promoted = new Map() + const hosted: CloudApiDefinition[] = [] + const serverless: CloudApiDefinition[] = [] + + for (const def of definitions) { + if (PROMOTED_NAMESPACES.has(def.namespace)) { + let group = promoted.get(def.namespace) + if (group == null) { + group = [] + promoted.set(def.namespace, group) + } + group.push(def) + } else if (SERVERLESS_NAMESPACES.has(def.namespace)) { + serverless.push(def) + } else { + hosted.push(def) + } } + return { promoted, hosted, serverless } +} + +/** + * Registers the unified Cloud command tree under a top-level `cloud` group. + * + * The tree has three kinds of children: + * - **Promoted cross-cutting namespaces** (`account`, `authentication`, `organizations`, + * `user-role-assignments`) as direct children of `cloud`, since their APIs apply to + * both Hosted and Serverless. + * - **`cloud hosted `** for Hosted-specific APIs (deployments, + * deployment-templates, extensions, stack versions, etc.). + * - **`cloud serverless <...>`** for Serverless APIs. Project namespaces are + * restructured into `serverless projects ` (e.g. + * `serverless es projects list`); other namespaces (regions, traffic-filters, …) + * remain as flat groups with their codegen command names. + * + * @param definitions - flat array of API definitions; defaults to the full built-in + * registry (hosted + serverless APIs combined). + * @returns an `OpaqueCommandHandle` for the top-level `cloud` group. + */ +export function registerCloudCommands( + definitions: CloudApiDefinition[] = [...allCloudApis, ...allServerlessApis], +): OpaqueCommandHandle { + for (const def of definitions) { + validateCloudApiDefinition(def) + } + + const { promoted, hosted, serverless } = partitionDefinitions(definitions) + + const promotedGroups = buildFlatNamespaceGroups(promoted, 'Cloud') + const hostedGroup = buildHostedGroup(hosted) + const serverlessGroup = buildServerlessGroup(serverless) + return defineGroup( - { name: 'serverless', description: 'Manage Elastic Serverless projects and resources' }, - ...topLevelHandles, + { name: 'cloud', description: 'Manage Elastic Cloud (hosted deployments and serverless projects)' }, + ...promotedGroups, + hostedGroup, + serverlessGroup, ) } diff --git a/test/cloud/register.test.ts b/test/cloud/register.test.ts index 7310de49..6fece5ac 100644 --- a/test/cloud/register.test.ts +++ b/test/cloud/register.test.ts @@ -5,203 +5,212 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' -import { registerCloudCommands, registerServerlessCommands, simplifyProjectCommandName } from '../../src/cloud/register.ts' +import { registerCloudCommands, simplifyProjectCommandName } from '../../src/cloud/register.ts' import type { CloudApiDefinition } from '../../src/cloud/types.ts' describe('registerCloudCommands', () => { - describe('command tree structure', () => { + describe('top-level tree', () => { it('returns a top-level "cloud" group', () => { const group = registerCloudCommands([]) assert.equal(group.name(), 'cloud') }) - it('creates namespace subgroups from definitions', () => { - const defs: CloudApiDefinition[] = [ - { name: 'list', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, - { name: 'list', namespace: 'accounts', description: 'List', method: 'GET', path: '/api/v1/accounts' }, - ] - const group = registerCloudCommands(defs) + it('has promoted, hosted, and serverless subgroups with default definitions', () => { + const group = registerCloudCommands() const subcommands = group.commands.map((c) => c.name()) - assert.ok(subcommands.includes('deployments')) - assert.ok(subcommands.includes('accounts')) + assert.ok(subcommands.includes('accounts'), 'should have promoted "accounts"') + assert.ok(subcommands.includes('authentication'), 'should have promoted "authentication"') + assert.ok(subcommands.includes('organizations'), 'should have promoted "organizations"') + assert.ok(subcommands.includes('user-role-assignments'), 'should have promoted "user-role-assignments"') + assert.ok(subcommands.includes('hosted'), 'should have "hosted" subgroup') + assert.ok(subcommands.includes('serverless'), 'should have "serverless" subgroup') }) - it('registers leaf commands under their namespace', () => { - const defs: CloudApiDefinition[] = [ - { name: 'list', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, - { name: 'get', namespace: 'deployments', description: 'Get', method: 'GET', path: '/api/v1/deployments/{deployment_id}', pathParams: [{ name: 'deployment_id', description: 'ID', required: true }] }, - ] - const group = registerCloudCommands(defs) - const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! - const leafNames = deploymentsGroup.commands.map((c) => c.name()) - assert.deepEqual(leafNames, ['list', 'get']) + it('does not expose hosted or serverless namespaces at the top level', () => { + const group = registerCloudCommands() + const subcommands = group.commands.map((c) => c.name()) + assert.ok(!subcommands.includes('deployments'), 'deployments must live under hosted') + assert.ok(!subcommands.includes('extensions'), 'extensions must live under hosted') + assert.ok(!subcommands.includes('stack'), 'stack must live under hosted') + assert.ok(!subcommands.includes('regions'), 'regions must live under serverless') + assert.ok(!subcommands.includes('traffic-filters'), 'traffic-filters must live under serverless') + assert.ok(!subcommands.includes('elasticsearch-projects'), 'elasticsearch-projects must live under serverless es') }) }) - describe('validation', () => { - it('throws on invalid definition', () => { - const defs: CloudApiDefinition[] = [ - { name: '', namespace: 'deployments', description: 'Bad', method: 'GET', path: '/test' }, - ] - assert.throws(() => registerCloudCommands(defs), /invalid name/) + describe('promoted namespaces (cloud level)', () => { + it('accounts has its codegen command names preserved', () => { + const group = registerCloudCommands() + const accountsGroup = group.commands.find((c) => c.name() === 'accounts')! + const leafNames = accountsGroup.commands.map((c) => c.name()) + assert.ok(leafNames.includes('get-current-account')) + assert.ok(leafNames.includes('update-current-account')) + }) + + it('organizations has its codegen command names preserved', () => { + const group = registerCloudCommands() + const orgsGroup = group.commands.find((c) => c.name() === 'organizations')! + const leafNames = orgsGroup.commands.map((c) => c.name()) + assert.ok(leafNames.includes('list-organizations')) + assert.ok(leafNames.includes('get-organization')) }) - it('throws on duplicate command names within a namespace', () => { + it('promoted synthetic defs are lifted out of hosted', () => { const defs: CloudApiDefinition[] = [ - { name: 'list', namespace: 'deployments', description: 'List 1', method: 'GET', path: '/a' }, - { name: 'list', namespace: 'deployments', description: 'List 2', method: 'GET', path: '/b' }, + { name: 'get-current-account', namespace: 'accounts', description: 'Get', method: 'GET', path: '/api/v1/account' }, + { name: 'list-deployments', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, ] - assert.throws(() => registerCloudCommands(defs), /duplicate/) + const group = registerCloudCommands(defs) + const top = group.commands.map((c) => c.name()) + assert.ok(top.includes('accounts')) + assert.ok(top.includes('hosted')) + const hostedChildren = group.commands.find((c) => c.name() === 'hosted')!.commands.map((c) => c.name()) + assert.ok(!hostedChildren.includes('accounts'), 'accounts must not appear under hosted') + assert.ok(hostedChildren.includes('deployments')) }) }) - describe('default API definitions (hosted cloud only)', () => { - it('includes hosted cloud namespaces', () => { + describe('hosted subgroup', () => { + it('contains hosted-specific 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') - }) - - it('does not include serverless namespaces', () => { + const hosted = group.commands.find((c) => c.name() === 'hosted')! + const namespaces = hosted.commands.map((c) => c.name()) + assert.ok(namespaces.includes('deployments')) + assert.ok(namespaces.includes('deployment-templates')) + assert.ok(namespaces.includes('deployments-traffic-filter')) + assert.ok(namespaces.includes('extensions')) + assert.ok(namespaces.includes('stack')) + assert.ok(namespaces.includes('trusted-environments')) + assert.ok(namespaces.includes('billing-costs-analysis')) + }) + + it('does not contain promoted 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') + const hosted = group.commands.find((c) => c.name() === 'hosted')! + const namespaces = hosted.commands.map((c) => c.name()) + assert.ok(!namespaces.includes('accounts')) + assert.ok(!namespaces.includes('authentication')) + assert.ok(!namespaces.includes('organizations')) + assert.ok(!namespaces.includes('user-role-assignments')) }) it('deployments namespace has list, get, and shutdown commands', () => { const group = registerCloudCommands() - const deploymentsGroup = group.commands.find((c) => c.name() === 'deployments')! - const leafNames = deploymentsGroup.commands.map((c) => c.name()) + const deployments = group.commands.find((c) => c.name() === 'hosted')! + .commands.find((c) => c.name() === 'deployments')! + const leafNames = deployments.commands.map((c) => c.name()) assert.ok(leafNames.includes('list-deployments')) assert.ok(leafNames.includes('get-deployment')) assert.ok(leafNames.includes('shutdown-deployment')) }) + }) - it('accounts namespace has get and update commands', () => { + describe('serverless subgroup', () => { + it('contains project-type groups and flat serverless namespaces', () => { const group = registerCloudCommands() - const accountsGroup = group.commands.find((c) => c.name() === 'accounts')! - const leafNames = accountsGroup.commands.map((c) => c.name()) - assert.ok(leafNames.includes('get-current-account')) - assert.ok(leafNames.includes('update-current-account')) + const serverless = group.commands.find((c) => c.name() === 'serverless')! + const namespaces = serverless.commands.map((c) => c.name()) + assert.ok(namespaces.includes('es'), 'should have "es" project-type group') + assert.ok(namespaces.includes('observability')) + assert.ok(namespaces.includes('security')) + assert.ok(namespaces.includes('regions')) + assert.ok(namespaces.includes('traffic-filters')) }) - it('no commands have aliases', () => { + it('es projects has CRUD commands with short names', () => { const group = registerCloudCommands() - 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') - }) - - it('restructures elasticsearch-projects under serverless > es > projects', () => { - const defs: CloudApiDefinition[] = [ - { 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 = 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']) + const projects = group.commands.find((c) => c.name() === 'serverless')! + .commands.find((c) => c.name() === 'es')! + .commands.find((c) => c.name() === 'projects')! + const leafNames = projects.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')) + assert.ok(leafNames.includes('get-roles')) + assert.ok(leafNames.includes('reset-credentials')) }) - it('restructures observability-projects under serverless > observability > projects', () => { + it('restructures synthetic project defs under serverless > > projects', () => { const defs: CloudApiDefinition[] = [ { 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 = 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('restructures security-projects under serverless > security > projects', () => { - const defs: CloudApiDefinition[] = [ - { 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 = 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']) + const group = registerCloudCommands(defs) + const projects = group.commands.find((c) => c.name() === 'serverless')! + .commands.find((c) => c.name() === 'observability')! + .commands.find((c) => c.name() === 'projects')! + assert.deepEqual(projects.commands.map((c) => c.name()), ['list', 'get']) }) - it('keeps non-project namespaces as direct children of serverless', () => { + it('keeps non-project serverless namespaces flat with codegen names', () => { 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']) + const group = registerCloudCommands(defs) + const regions = group.commands.find((c) => c.name() === 'serverless')! + .commands.find((c) => c.name() === 'regions')! + assert.deepEqual(regions.commands.map((c) => c.name()), ['list-regions', 'get-region']) }) - it('adds --wait flag to create project commands', () => { + it('adds --wait flag to create project commands only', () => { 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') + const group = registerCloudCommands(defs) + const projects = group.commands.find((c) => c.name() === 'serverless')! + .commands.find((c) => c.name() === 'es')! + .commands.find((c) => c.name() === 'projects')! + const createCmd = projects.commands.find((c) => c.name() === 'create')! + const listCmd = projects.commands.find((c) => c.name() === 'list')! + assert.ok(createCmd.options.map((o) => o.long).includes('--wait')) + assert.ok(!listCmd.options.map((o) => o.long).includes('--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') + describe('validation', () => { + it('throws on invalid definition', () => { + const defs: CloudApiDefinition[] = [ + { name: '', namespace: 'deployments', description: 'Bad', method: 'GET', path: '/test' }, + ] + assert.throws(() => registerCloudCommands(defs), /invalid name/) }) - 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('throws on duplicate command names within a hosted namespace', () => { + const defs: CloudApiDefinition[] = [ + { name: 'list', namespace: 'deployments', description: 'List 1', method: 'GET', path: '/a' }, + { name: 'list', namespace: 'deployments', description: 'List 2', method: 'GET', path: '/b' }, + ] + assert.throws(() => registerCloudCommands(defs), /duplicate/) }) - 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') + it('throws on duplicate command names within a promoted namespace', () => { + const defs: CloudApiDefinition[] = [ + { name: 'get-current-account', namespace: 'accounts', description: '1', method: 'GET', path: '/a' }, + { name: 'get-current-account', namespace: 'accounts', description: '2', method: 'GET', path: '/b' }, + ] + assert.throws(() => registerCloudCommands(defs), /duplicate/) + }) + }) + + describe('default command aliases', () => { + it('no commands have aliases', () => { + const group = registerCloudCommands() + interface CommandNode { + name(): string + aliases(): string[] + commands: CommandNode[] + } + const visit = (cmd: CommandNode): void => { + assert.deepEqual(cmd.aliases(), [], `${cmd.name()} should have no alias`) + for (const child of cmd.commands) visit(child) + } + for (const child of group.commands as unknown as CommandNode[]) visit(child) }) }) }) diff --git a/test/functional/cloud/smoke.sh b/test/functional/cloud/smoke.sh index e7594711..0117ae42 100755 --- a/test/functional/cloud/smoke.sh +++ b/test/functional/cloud/smoke.sh @@ -121,11 +121,11 @@ fi echo "" -# ── Cloud Hosted ───────────────────────────────────────────────────── +# ── Cross-cutting ──────────────────────────────────────────────────── -echo "Cloud Hosted API:" +echo "Cross-cutting API:" -# accounts get-current-account +# accounts get-current-account (promoted to cloud level) output=$(retry_with_backoff $CLI cloud accounts get-current-account --json 2>&1) || true if [ -n "$output" ]; then assert_exit_zero "accounts get-current-account" $CLI cloud accounts get-current-account --json @@ -134,12 +134,18 @@ else fail "accounts get-current-account" "empty response" fi -# deployments list-deployments -output=$(retry_with_backoff $CLI cloud deployments list-deployments --json 2>&1) || true +echo "" + +# ── Cloud Hosted ───────────────────────────────────────────────────── + +echo "Cloud Hosted API:" + +# hosted deployments list-deployments +output=$(retry_with_backoff $CLI cloud hosted deployments list-deployments --json 2>&1) || true if [ -n "$output" ]; then - assert_exit_zero "deployments list-deployments" $CLI cloud deployments list-deployments --json + assert_exit_zero "hosted deployments list-deployments" $CLI cloud hosted deployments list-deployments --json else - fail "deployments list-deployments" "empty response" + fail "hosted deployments list-deployments" "empty response" fi echo "" @@ -149,17 +155,17 @@ echo "" echo "Serverless API:" # serverless es projects list -output=$(retry_with_backoff $CLI serverless es projects list --json 2>&1) || true +output=$(retry_with_backoff $CLI cloud serverless es projects list --json 2>&1) || true if [ -n "$output" ]; then - assert_exit_zero "serverless es projects list" $CLI serverless es projects list --json + assert_exit_zero "serverless es projects list" $CLI cloud serverless es projects list --json else fail "serverless es projects list" "empty response" fi # serverless regions list-regions -output=$(retry_with_backoff $CLI serverless regions list-regions --json 2>&1) || true +output=$(retry_with_backoff $CLI cloud serverless regions list-regions --json 2>&1) || true if [ -n "$output" ]; then - assert_exit_zero "serverless regions list-regions" $CLI serverless regions list-regions --json + assert_exit_zero "serverless regions list-regions" $CLI cloud serverless regions list-regions --json else fail "serverless regions list-regions" "empty response" fi