Skip to content

Commit b8fe57d

Browse files
authored
fix: parsing/destructuring command inputs (#130)
* bug fixes * fix
1 parent c6aa04d commit b8fe57d

14 files changed

Lines changed: 379 additions & 17 deletions

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ When creating or modifying code that constructs URLs, sends credentials, or make
8585
- **Assert on `RequestInit` configuration in tests**, not just on response output. Verify `redirect`, `method`, and `headers` are set as intended.
8686
- **Add at least one adversarial input test** for any function that accepts user-provided strings and embeds them in URLs or paths.
8787

88+
## Generic Abstractions Must Handle Real-World Variation
89+
90+
### Lessons
91+
92+
1. **Enumerate all variants a system can produce, not just the ones you see first.** `unwrapField()` only handled `optional` and `default` because those were the only wrappers visible in the initial hand-written schemas. When codegen produced schemas using `z.lazy()`, `z.record()`, `z.any()`, and `z.union()`, they all silently fell through to a catch-all that mapped them to `"string"`. Before writing a generic handler, inspect the full set of types/formats the upstream system can produce and add explicit branches or a loud failure for unrecognized cases.
93+
94+
2. **Fail loudly on unrecognized input instead of falling through to a default.** The `unwrapField` catch-all `return { typeName: def.type, isOptional: false }` silently returned garbage. A `throw new Error('unhandled Zod type: ' + def.type)` would have surfaced the problem immediately at registration time instead of producing subtle runtime validation failures across 1400+ fields.
95+
96+
3. **Test with real generated schemas, not just hand-crafted toy schemas.** If our `extractSchemaArgs` tests had included even one actual schema from the codegen output (which uses `z.lazy` extensively), the bug would have been caught before merging.
97+
98+
4. **Generic request builders need escape hatches for endpoint-specific semantics.** The ES bulk API needs NDJSON; the index API needs body promotion. A "one size fits all" `collectBody()` silently produced wrong output for both. When designing generic abstractions, ask: "what endpoint-specific behavior could this need?" and add explicit extension points (`bodyFormat`, `BODY_ROOT_FIELDS`) rather than special-casing later.
99+
100+
5. **User-facing error messages should diagnose common mistakes.** Raw error propagation (e.g., `SSL routines:tls_get_more_records:packet length too long`) is unhelpful. When you catch an error class that has known causes (TLS mismatch, auth failure, DNS resolution), pattern-match on the message and append a human-readable hint with a suggested fix.
101+
102+
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.
103+
88104
## Spec-Kit Workflow
89105

90106
The project uses [spec-kit](https://github.com/github/spec-kit) for AI-assisted feature development.

src/cloud/handler.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ import { getCloudClient } from '../lib/cloud-client.ts'
99
import { buildCloudRequestParams } from './request-builder.ts'
1010
import type { JsonValue, ParsedResult } from '../factory.ts'
1111

12+
const DEFAULT_POLL_INTERVAL_MS = 10_000
13+
const DEFAULT_POLL_TIMEOUT_MS = 300_000
14+
1215
/**
1316
* Dependencies for `createCloudHandler`.
1417
*/
1518
export interface CloudHandlerDeps {
1619
getCloudClient: () => CloudClient
1720
buildCloudRequestParams: typeof buildCloudRequestParams
21+
pollIntervalMs?: number
22+
pollTimeoutMs?: number
1823
}
1924

2025
const defaultDeps: CloudHandlerDeps = { getCloudClient, buildCloudRequestParams }
@@ -47,13 +52,55 @@ export function createCloudHandler(
4752

4853
try {
4954
const body = await client.request(params)
55+
56+
if (parsed.options.wait === true && isCreateProjectCommand(def.name)) {
57+
const id = (body as Record<string, unknown>)?.id as string | undefined
58+
if (id != null) {
59+
const statusPath = `${def.path}/${id}/status`
60+
await pollProjectStatus(client, statusPath, deps)
61+
process.stderr.write(`Project ${id} is ready.\n`)
62+
}
63+
}
64+
5065
return body as JsonValue
5166
} catch (err) {
5267
return cloudApiError(err)
5368
}
5469
}
5570
}
5671

72+
const CREATE_PROJECT_RE = /^create-(?:elasticsearch|observability|security)-project$/
73+
74+
export function isCreateProjectCommand (name: string): boolean {
75+
return CREATE_PROJECT_RE.test(name)
76+
}
77+
78+
async function pollProjectStatus (
79+
client: CloudClient,
80+
statusPath: string,
81+
deps: CloudHandlerDeps
82+
): Promise<void> {
83+
const interval = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
84+
const timeout = deps.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS
85+
const start = Date.now()
86+
87+
while (Date.now() - start < timeout) {
88+
await sleep(interval)
89+
try {
90+
const status = await client.request({ method: 'GET', path: statusPath }) as Record<string, unknown>
91+
if (status.phase === 'initialized') return
92+
process.stderr.write(`Waiting for project... phase: ${status.phase ?? 'unknown'}\n`)
93+
} catch {
94+
process.stderr.write('Waiting for project... (status check failed, retrying)\n')
95+
}
96+
}
97+
throw new Error('Timed out waiting for project to reach "initialized" phase')
98+
}
99+
100+
function sleep (ms: number): Promise<void> {
101+
return new Promise((resolve) => setTimeout(resolve, ms))
102+
}
103+
57104
function missingConfigError(err: unknown): JsonValue {
58105
const message = err instanceof Error ? err.message : String(err)
59106
return { error: { code: 'missing_config', message } }

src/cloud/register.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
*/
55

66
import { z } from 'zod'
7+
import { Command } from 'commander'
78
import { defineCommand, defineGroup } from '../factory.ts'
89
import type { OpaqueCommandHandle } from '../factory.ts'
910
import type { CloudApiDefinition, CloudPathParam, CloudQueryParam } from './types.ts'
1011
import { validateCloudApiDefinition } from './types.ts'
1112
import { allCloudApis } from './apis.ts'
1213
import { allServerlessApis } from './serverless-apis.ts'
13-
import { createCloudHandler } from './handler.ts'
14+
import { createCloudHandler, isCreateProjectCommand } from './handler.ts'
1415

1516
/**
1617
* Builds the unified flat Zod schema for a Cloud API command.
@@ -99,12 +100,16 @@ export function registerCloudCommands(
99100

100101
const leafHandles = defs.map((def) => {
101102
const schema = buildCommandSchema(def)
102-
return defineCommand({
103+
const cmd = defineCommand({
103104
name: def.name,
104105
description: def.description,
105106
input: schema,
106107
handler: createCloudHandler(def),
107108
})
109+
if (isCreateProjectCommand(def.name)) {
110+
(cmd as Command).option('--wait', 'Wait for the project to reach "initialized" phase before returning')
111+
}
112+
return cmd
108113
})
109114

110115
namespaceHandles.push(

src/es/apis/bulk.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const bulkApis: EsApiDefinition[] = [
1818
description: 'Bulk index or delete documents.',
1919
method: 'POST',
2020
path: '/{index}/_bulk',
21-
input: BulkRequest
21+
input: BulkRequest,
22+
bodyFormat: 'ndjson'
2223
}
2324
]

src/es/apis/msearch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const msearchApis: EsApiDefinition[] = [
1818
description: 'Run multiple searches.',
1919
method: 'GET',
2020
path: '/{index}/_msearch',
21-
input: MsearchRequest
21+
input: MsearchRequest,
22+
bodyFormat: 'ndjson'
2223
}
2324
]

src/es/apis/msearch_template.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const msearchTemplateApis: EsApiDefinition[] = [
1818
description: 'Run multiple templated searches.',
1919
method: 'GET',
2020
path: '/{index}/_msearch/template',
21-
input: MsearchTemplateRequest
21+
input: MsearchTemplateRequest,
22+
bodyFormat: 'ndjson'
2223
}
2324
]

src/es/errors.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,27 @@ export function missingConfigError (err: unknown): JsonValue {
1212
return { error: { code: 'missing_config', message } }
1313
}
1414

15+
const TLS_HINTS = [
16+
/SSL routines/i,
17+
/wrong version number/i,
18+
/ssl3_get_record/i,
19+
/tls_get_more_records/i,
20+
/packet length too long/i,
21+
/EPROTO/i,
22+
/ERR_SSL/i,
23+
]
24+
25+
function isTlsError (message: string): boolean {
26+
return TLS_HINTS.some((re) => re.test(message))
27+
}
28+
29+
function appendTlsHint (message: string): string {
30+
if (isTlsError(message)) {
31+
return message + '\n\nHint: this looks like a TLS/SSL error. If your Elasticsearch is running on plain HTTP, change the url in your config from https:// to http://.'
32+
}
33+
return message
34+
}
35+
1536
/** Builds a structured error payload from a thrown transport error. */
1637
export function transportError (err: unknown): JsonValue {
1738
if (err instanceof errors.ResponseError) {
@@ -25,7 +46,7 @@ export function transportError (err: unknown): JsonValue {
2546
}
2647

2748
if (err instanceof errors.ConnectionError) {
28-
return { error: { code: 'connection_error', message: connectionMessage(err) } }
49+
return { error: { code: 'connection_error', message: appendTlsHint(connectionMessage(err)) } }
2950
}
3051

3152
if (err instanceof errors.TimeoutError) {
@@ -34,12 +55,11 @@ export function transportError (err: unknown): JsonValue {
3455
}
3556

3657
const message = err instanceof Error ? err.message : String(err)
37-
return { error: { code: 'transport_error', message } }
58+
return { error: { code: 'transport_error', message: appendTlsHint(message) } }
3859
}
3960

4061
function connectionMessage (err: errors.ConnectionError): string {
4162
const reason = err.message || 'connection failed'
42-
// err.meta is DiagnosticResult; .meta.connection is the nested transport metadata
4363
const url = err.meta?.meta?.connection?.url?.toString()
4464
return url ? `${reason} (${url})` : reason
4565
}

src/es/request-builder.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ export function buildRequestParams (
3838

3939
const params: TransportRequestParams = { method: def.method, path }
4040
if (Object.keys(querystring).length > 0) params.querystring = querystring
41-
if (body !== undefined) params.body = body as NonNullable<TransportRequestParams['body']>
41+
42+
if (body !== undefined) {
43+
if (def.bodyFormat === 'ndjson') {
44+
params.bulkBody = toNdjson(body)
45+
} else {
46+
params.body = body as NonNullable<TransportRequestParams['body']>
47+
}
48+
}
4249
return params
4350
}
4451

@@ -92,9 +99,35 @@ function buildQuerystring (
9299
return qs
93100
}
94101

102+
/**
103+
* Serializes a body object into NDJSON format for bulk/msearch APIs.
104+
*
105+
* Finds the first array-valued field in the body and emits each element as
106+
* a separate JSON line. If no array field is found, the body itself is
107+
* serialized as a single JSON line. The result always ends with a trailing
108+
* newline as required by Elasticsearch.
109+
*/
110+
function toNdjson (body: Record<string, unknown>): string {
111+
for (const value of Object.values(body)) {
112+
if (Array.isArray(value)) {
113+
return value.map((item) => JSON.stringify(item)).join('\n') + '\n'
114+
}
115+
}
116+
return JSON.stringify(body) + '\n'
117+
}
118+
119+
/**
120+
* Fields whose value should replace the entire body rather than being nested
121+
* under a key. The ES index/create APIs expect the document to BE the body.
122+
*/
123+
const BODY_ROOT_FIELDS = new Set(['document'])
124+
95125
/**
96126
* Collects request body fields from entries with `foundIn === "body"` or no `foundIn`.
97127
* Returns `undefined` when no body fields are present in the input.
128+
*
129+
* Special case: when the only body field with a value is in `BODY_ROOT_FIELDS`
130+
* (e.g. `document`), its value is promoted to be the entire body (#95).
98131
*/
99132
function collectBody (
100133
schemaArgs: SchemaArgDefinition[],
@@ -108,5 +141,12 @@ function collectBody (
108141
if (value !== undefined) body[arg.schemaKey] = value
109142
}
110143

111-
return Object.keys(body).length > 0 ? body : undefined
144+
if (Object.keys(body).length === 0) return undefined
145+
146+
const keys = Object.keys(body)
147+
if (keys.length === 1 && BODY_ROOT_FIELDS.has(keys[0]!)) {
148+
return body[keys[0]!] as Record<string, unknown>
149+
}
150+
151+
return body
112152
}

src/es/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export interface EsApiDefinition {
7979
input?: z.ZodObject<z.ZodRawShape> | (() => z.ZodObject<z.ZodRawShape>)
8080
/** how to handle the response body; defaults to `"json"` */
8181
responseType?: 'json' | 'text'
82+
/** how to serialize the request body; defaults to `"json"` */
83+
bodyFormat?: 'json' | 'ndjson'
8284
}
8385

8486
/** valid command/namespace name: lowercase alphanumeric with hyphens (from `defineCommand` rules) */

src/lib/schema-args.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,16 @@ interface ZodFieldDef {
6969
type: string
7070
innerType?: { def: ZodFieldDef }
7171
defaultValue?: unknown
72+
getter?: () => z.ZodType
73+
options?: z.ZodType[]
7274
}
7375

7476
/**
75-
* Unwraps `optional` and `default` wrapper types from a Zod schema field,
76-
* returning the underlying type name, optional status, and default value.
77+
* Unwraps wrapper types from a Zod schema field, resolving lazy thunks,
78+
* records, unions, and any/unknown to their CLI-appropriate type names.
7779
*/
7880
function unwrapField (field: z.ZodType): { typeName: string, isOptional: boolean, defaultValue?: unknown } {
7981
const def = field.def as ZodFieldDef
80-
// date/bigint/symbol/undefined/void/never cannot be represented in JSON Schema
81-
// and would cause z.toJSONSchema() to throw when help is rendered -- fail fast here
8282
if (def.type === 'date') {
8383
throw new Error('Date cannot be represented in JSON Schema: use z.string() with an ISO-8601 description instead of z.date()')
8484
}
@@ -93,6 +93,18 @@ function unwrapField (field: z.ZodType): { typeName: string, isOptional: boolean
9393
return { ...inner, defaultValue: def.defaultValue, isOptional: false }
9494
}
9595

96+
if (def.type === 'lazy' && typeof def.getter === 'function') {
97+
return unwrapField(def.getter())
98+
}
99+
100+
if (def.type === 'record' || def.type === 'any' || def.type === 'unknown') {
101+
return { typeName: 'object', isOptional: false }
102+
}
103+
104+
if (def.type === 'union' && Array.isArray(def.options) && def.options.length > 0) {
105+
return unwrapField(def.options[0] as z.ZodType)
106+
}
107+
96108
return { typeName: def.type, isOptional: false }
97109
}
98110

0 commit comments

Comments
 (0)