Skip to content
Draft
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ elasticsearch:
api_key: $(keychain:elastic-cli/api-key)
```

The same expressions also work in **CLI string flag values**, so you can load a
file, env var, or command output directly into any flag. Quote the value to
prevent your shell from interpreting `$(...)`:

```bash
elastic stack kb workflows post-workflows-workflow --yaml '$(file:./workflow.yaml)'
elastic stack es indices create --index '$(env:TARGET_INDEX)'
```

## Global options

| Option | Description |
Expand Down
47 changes: 44 additions & 3 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { SchemaArgDefinition } from './lib/schema-args.ts'
import { simplifyZodIssues, formatIssuesText } from './lib/zod-error.ts'
import { renderText, formatHandlerError } from './output.ts'
import { pickFields, parseFieldList, applyTemplate } from './lib/output-transform.ts'
import { containsExpression, resolveString } from './config/resolvers.ts'

/** pre-built schema for coercing string → number, reused per option invocation */
const numberSchema = z.coerce.number()
Expand Down Expand Up @@ -445,6 +446,30 @@ function parseJsonContent (raw: string, source: string, cmd: OpaqueCommandHandle
}
}

/**
* Resolves any `$(resolver:params)` expressions in a CLI string flag value, using
* the same resolver registry the config loader uses (`file`, `env`, `cmd`, etc.).
*
* Returns the input synchronously when no expression is present, so the common
* (no-expression) fast path does not introduce a microtask hop. Only when at
* least one expression is detected does this return a Promise.
*
* Only top-level string flag values are processed; the resolved result is NOT
* deep-walked, so any `$(...)` literals inside the resolved content (e.g. inside
* a YAML file) are left intact for the user / downstream service.
*
* Errors are routed through Commander's `cmd.error()` so the failing flag name
* appears in the user-facing message and the handler is never invoked.
*/
function resolveFlagExpression (
value: string, flagDisplay: string, cmd: OpaqueCommandHandle,
): string | Promise<string> {
if (!containsExpression(value)) return value
return resolveString(value, flagDisplay).catch((e: unknown) => {
return cmd.error(`option ${flagDisplay}: ${(e as Error).message}`) as never
})
}

function isErrorResult (value: JsonValue): boolean {
return (
typeof value === 'object' &&
Expand Down Expand Up @@ -599,8 +624,17 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
if (opt.type === 'boolean') {
options[opt.long] = rawVal === true
} else if (rawVal !== undefined) {
// number coercion already done by parseArg; string values passed through as-is
options[opt.long] = rawVal as string | number
// number coercion already done by parseArg; string values may contain
// $(file:...)/$(env:...) expressions, resolved here before reaching the handler.
// Fast path: when no expression is present, resolveFlagExpression returns
// the original string synchronously and we avoid a microtask hop, keeping
// legacy synchronous test invocations (cmd.parse) working unchanged.
if ((opt.type === undefined || opt.type === 'string') && typeof rawVal === 'string') {
const resolved = resolveFlagExpression(rawVal, `--${opt.long}`, cmd)
options[opt.long] = typeof resolved === 'string' ? resolved : await resolved
} else {
options[opt.long] = rawVal as string | number
}
}
}

Expand Down Expand Up @@ -656,7 +690,14 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
}
} else {
// string, number (already coerced by parseArg), enum
cliInput[arg.schemaKey] = raw
// resolve $(...) expressions in string-shaped fields before validation;
// see resolveFlagExpression docs for fast-path semantics.
if ((arg.type === 'string' || arg.type === 'enum') && typeof raw === 'string') {
const resolved = resolveFlagExpression(raw, `--${arg.cliFlag}`, cmd)
cliInput[arg.schemaKey] = typeof resolved === 'string' ? resolved : await resolved
} else {
cliInput[arg.schemaKey] = raw
}
}
}
if (Object.keys(cliInput).length > 0) {
Expand Down
158 changes: 158 additions & 0 deletions test/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3405,6 +3405,164 @@ describe('JSON schema in help output -- transport meta stripping', () => {
})
})

describe('expression resolution on string flag values', () => {
let tmpDir: string
let origIsTTY: boolean | undefined

before(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'elastic-cli-expr-'))
})
after(() => {
rmSync(tmpDir, { recursive: true })
})
beforeEach(() => {
origIsTTY = process.stdin.isTTY
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true, writable: true })
})
afterEach(() => {
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true, writable: true })
})

it('resolves $(file:...) in a user-defined string option', async () => {
const filePath = join(tmpDir, 'value.txt')
writeFileSync(filePath, 'hello-from-file')
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'name', description: 'Name', type: 'string' }],
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--name', `$(file:${filePath})`])
assert.equal(received.length, 1)
assert.equal(received[0].options['name'], 'hello-from-file')
})

it('resolves $(env:...) in a user-defined string option', async () => {
process.env['ELASTIC_CLI_TEST_EXPR_VAR'] = 'env-value'
try {
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'name', description: 'Name', type: 'string' }],
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--name', '$(env:ELASTIC_CLI_TEST_EXPR_VAR)'])
assert.equal(received.length, 1)
assert.equal(received[0].options['name'], 'env-value')
} finally {
delete process.env['ELASTIC_CLI_TEST_EXPR_VAR']
}
})

it('resolves $(file:...) in a schema-derived string field before validation', async () => {
const filePath = join(tmpDir, 'workflow.yaml')
writeFileSync(filePath, 'name: my-workflow\nsteps: []\n')
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'create-workflow',
description: 'Create a workflow',
input: z.object({ yaml: z.string().min(5) }),
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--yaml', `$(file:${filePath})`])
assert.equal(received.length, 1)
assert.equal((received[0].input as { yaml: string }).yaml, 'name: my-workflow\nsteps: []')
})

it('partial substitution preserves surrounding text', async () => {
process.env['ELASTIC_CLI_TEST_EXPR_USER'] = 'alice'
try {
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'name', description: 'Name', type: 'string' }],
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--name', 'prefix-$(env:ELASTIC_CLI_TEST_EXPR_USER)-suffix'])
assert.equal(received.length, 1)
assert.equal(received[0].options['name'], 'prefix-alice-suffix')
} finally {
delete process.env['ELASTIC_CLI_TEST_EXPR_USER']
}
})

it('values without expressions are passed through unchanged', async () => {
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'name', description: 'Name', type: 'string' }],
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--name', 'plain-value'])
assert.equal(received.length, 1)
assert.equal(received[0].options['name'], 'plain-value')
})

it('errors with the flag name when $(file:...) target does not exist', async () => {
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'name', description: 'Name', type: 'string' }],
handler: () => ({}),
})
const err = await captureErrAsync(cmd, ['--name', '$(file:/nonexistent/path/9f3c1a)'])
assert.match(err, /--name/)
assert.match(err, /9f3c1a/)
})

it('errors when an unknown resolver is referenced', async () => {
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'name', description: 'Name', type: 'string' }],
handler: () => ({}),
})
const err = await captureErrAsync(cmd, ['--name', '$(no_such_resolver:foo)'])
assert.match(err, /Unknown resolver/i)
})

it('does not attempt resolution on number flags', async () => {
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'count', description: 'Count', type: 'number' }],
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--count', '42'])
assert.equal(received.length, 1)
assert.equal(received[0].options['count'], 42)
})

it('does not attempt resolution on boolean flags', async () => {
const received: ParsedResult[] = []
const cmd = defineCommand({
name: 'build',
description: 'Build',
options: [{ long: 'verbose', description: 'Verbose', type: 'boolean' }],
handler: (parsed) => { received.push(parsed); return {} },
})
await invokeAsync(cmd, ['--verbose'])
assert.equal(received.length, 1)
assert.equal(received[0].options['verbose'], true)
})

it('resolves expressions even on --dry-run so missing files surface', async () => {
const cmd = defineCommand({
name: 'build',
description: 'Build',
input: z.object({ name: z.string().optional() }),
handler: () => ({}),
})
const err = await captureErrAsync(cmd, ['--dry-run', '--name', '$(file:/nonexistent/path/abc)'])
assert.match(err, /--name/)
})
})

/** invokes a command handle via parseAsync; surfaces CommanderError via exitOverride */
async function invokeAsync(handle: OpaqueCommandHandle, argv: string[]): Promise<void> {
handle.exitOverride()
Expand Down