diff --git a/README.md b/README.md index 0f1d9674..8712b440 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/factory.ts b/src/factory.ts index 841711ec..51c49448 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -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() @@ -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 { + 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' && @@ -599,8 +624,17 @@ export function defineCommand (config: CommandConfig): 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 + } } } @@ -656,7 +690,14 @@ export function defineCommand (config: CommandConfig): 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) { diff --git a/test/factory.test.ts b/test/factory.test.ts index c396b289..550f8629 100644 --- a/test/factory.test.ts +++ b/test/factory.test.ts @@ -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 { handle.exitOverride()