Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 42 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ current_context: local
contexts:
local:
elasticsearch:
url: https://localhost:9200
url: http://localhost:9200
auth:
api_key: your-api-key-here
staging:
Expand Down Expand Up @@ -189,23 +189,55 @@ elastic es update --index my-index --id abc123

Run `elastic es <command> --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
elastic cloud deployments get --deployment-id <id>
elastic cloud deployments shutdown --deployment-id <id>
elastic cloud deployments list-deployments
elastic cloud deployments get-deployment --id <id>
elastic cloud deployments shutdown-deployment --id <id>
elastic cloud deployments create-deployment <<< '{"name":"my-deployment",...}'
```

#### `cloud 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 <id>
elastic serverless es projects delete --id <id>
elastic serverless es projects get-status --id <id>
elastic serverless es projects get-roles --id <id>
elastic serverless es projects reset-credentials --id <id>
```

#### `serverless observability projects` / `serverless security projects`

Same commands as `es projects` but for Observability and Security project types:

```bash
elastic cloud projects list
elastic cloud projects get --project-id <id>
elastic cloud projects delete --project-id <id>
elastic serverless observability projects list
elastic serverless security projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}'
```

#### Other serverless resources

```bash
elastic serverless regions list-regions
elastic serverless traffic-filters list-traffic-filters
```

Run `elastic serverless --help` for all available groups.
9 changes: 8 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Comment thread
JoshMock marked this conversation as resolved.
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
Expand Down
165 changes: 145 additions & 20 deletions src/cloud/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,47 +61,145 @@ function queryParamToZod(q: CloudQueryParam): z.ZodType {
}

/**
* Registers all Cloud control plane API commands under a top-level `cloud` group.
* Maps project-type namespaces from codegen to short CLI group names.
* E.g. `elasticsearch-projects` → `es`, used to build
* `elastic serverless es projects <action>`.
*/
const PROJECT_NAMESPACES: Record<string, string> = {
'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<string, CloudApiDefinition[]> {
const byNamespace = new Map<string, CloudApiDefinition[]>()
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<string>()
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)
}
}
Comment thread
JoshMock marked this conversation as resolved.

/**
* 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`).
* 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<string, CloudApiDefinition[]>()
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 <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<string, CloudApiDefinition[]>()
const otherDefs = new Map<string, CloudApiDefinition[]>()

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<string>()
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[] = []

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),
Expand All @@ -112,10 +210,37 @@ export function registerCloudCommands(
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,
)
}
25 changes: 20 additions & 5 deletions src/cloud/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,33 @@ function buildQuerystring(
return qs
}

const BODY_METHODS: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH'])

function collectBody(
def: CloudApiDefinition,
input: Record<string, unknown>,
): Record<string, unknown> | undefined {
if (!(def.body instanceof z.ZodObject)) return undefined
if (def.body instanceof z.ZodObject) {
const body: Record<string, unknown> = {}
for (const fieldName of Object.keys(def.body.shape as Record<string, unknown>)) {
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<string, unknown> = {}
for (const fieldName of Object.keys(def.body.shape as Record<string, unknown>)) {
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
}
9 changes: 8 additions & 1 deletion src/es/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
qs.format = 'json'
params.querystring = qs
const body = await transport.request<JsonValue>(params)
return body
} else if (responseType === 'text') {
const body = await transport.request<string>(params)
return body
} else {
Expand Down
Loading