diff --git a/README.md b/README.md index 43315746..ee6bb4d3 100644 --- a/README.md +++ b/README.md @@ -24,18 +24,18 @@ elastic --help ## Configuration The CLI looks for a config file in your home directory. The following file names -are recognised (checked in this order): +are checked (in this order): 1. `.elasticrc` 2. `.elasticrc.json` 3. `.elasticrc.yaml` 4. `.elasticrc.yml` -You can also point to a config file explicitly with `--config-file ` or -the `ELASTIC_CLI_CONFIG_FILE` environment variable. Precedence: -`--config-file` > `ELASTIC_CLI_CONFIG_FILE` > home directory discovery. +Place your config at `~/.elasticrc.yml` (recommended). -JavaScript and TypeScript config files are not supported for security reasons. +To use a config file in a different location, pass `--config-file ` or set +the `ELASTIC_CLI_CONFIG_FILE` environment variable. The flag takes precedence +over the environment variable. ```yaml current_context: local @@ -119,7 +119,7 @@ elasticsearch: | Option | Description | |---|---| -| `--config-file ` | Path to a config file, bypassing automatic discovery | +| `--config-file ` | Path to a config file (default: `~/.elasticrc.yml`) | | `--use-context ` | Override the active context from the config file | | `--json` | Output results as JSON | diff --git a/package-lock.json b/package-lock.json index fd13b135..dafffcca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@elastic/transport": "^9.3.5", "cli-table3": "^0.6.5", "commander": "^14.0.3", - "cosmiconfig": "^9.0.1", "yaml": "^2.8.3", "zod": "^4.3.6" }, @@ -29,29 +28,6 @@ "typescript-eslint": "8.58.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1079,12 +1055,6 @@ "node": ">=4" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, "node_modules/array-find-index": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", @@ -1125,15 +1095,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1207,32 +1168,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1300,24 +1235,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -1782,22 +1699,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1827,12 +1728,6 @@ "dev": true, "license": "ISC" }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT" - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -1888,24 +1783,6 @@ "dev": true, "license": "ISC" }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -1917,6 +1794,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -1989,12 +1867,6 @@ "ms": "^2.1.1" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2189,36 +2061,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2256,12 +2098,6 @@ "dev": true, "license": "MIT" }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -2363,15 +2199,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2643,7 +2470,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index a378b2f9..2723d900 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@elastic/transport": "^9.3.5", "cli-table3": "^0.6.5", "commander": "^14.0.3", - "cosmiconfig": "^9.0.1", "yaml": "^2.8.3", "zod": "^4.3.6" }, diff --git a/src/cli.ts b/src/cli.ts index 866c9249..aced63a3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ const program = new Command() program .name('elastic') .description('Interface with Elasticsearch, Elastic Serverless and Elastic Cloud APIs from the command line.') - .option('--config-file ', 'path to a config file, bypassing cosmiconfig discovery') + .option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') .option('--use-context ', 'override the active context from the config file') .option('--json', 'output as JSON') diff --git a/src/config/loader.ts b/src/config/loader.ts index ed6650fa..5b9f7378 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -7,69 +7,81 @@ * Configuration file discovery, loading, validation, and context resolution. * * Pipeline: - * 1. Create cosmiconfig explorer with ID 'elastic' for discovery + * 1. Discover config file in home directory (or via --config-file / ELASTIC_CLI_CONFIG_FILE) * 2. Resolve expressions in config values (e.g. $(env:VAR), $(cmd:...), $(keychain:...)) * 3. Validate resolved config with Zod schemas * 4. Resolve the active context (default or --use-context override) * 5. Return typed ResolvedConfig to command handlers * * Supports: - * - cosmiconfig discovery (searches standard locations) - * - --config-file override (bypass discovery) + * - Home-directory discovery (~/.elasticrc.yml and variants) + * - --config-file or ELASTIC_CLI_CONFIG_FILE env var (bypass discovery) * - --use-context override (select non-default context) * - Clear error messages with field paths and context names * - Structured error payloads (code + message) */ +import { access, constants, readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { extname, join } from 'node:path' import { z } from 'zod' -import { cosmiconfig } from 'cosmiconfig' +import { parse as parseYaml } from 'yaml' import { ConfigFileSchema } from './schema.ts' import { resolveExpressions } from './resolvers.ts' import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts' +/** Extensions that are rejected to prevent arbitrary code execution. */ +const EXECUTABLE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs']) + +/** File names checked during home-directory discovery, in priority order. */ +const CONFIG_FILE_NAMES = ['.elasticrc', '.elasticrc.json', '.elasticrc.yaml', '.elasticrc.yml'] + +/** Environment variable that overrides config file discovery with an explicit path. */ +const ENV_CONFIG_FILE = 'ELASTIC_CLI_CONFIG_FILE' + /** - * Loader that rejects executable config formats for security. + * Searches a single directory for the first readable config file. + * + * Checks each file name in {@link CONFIG_FILE_NAMES} order. Returns the + * absolute path of the first readable match, or `null` if none is found. * - * Cosmiconfig's default loaders will `import()` JavaScript and TypeScript - * files, which enables arbitrary code execution from untrusted directories. - * This loader throws a descriptive error instead. + * @param dir - Directory to search. Defaults to the user's home directory. */ -function rejectExecutableLoader (): never { - throw new Error( - 'JavaScript and TypeScript config files are not supported for security reasons. Use .elasticrc.yml instead.' - ) +export async function discoverConfigFile (dir?: string): Promise { + const searchDir = dir ?? homedir() + for (const name of CONFIG_FILE_NAMES) { + const candidate = join(searchDir, name) + try { + await access(candidate, constants.R_OK) + return candidate + } catch { continue } + } + return null } /** - * Creates a cosmiconfig explorer configured for the Elastic CLI. + * Reads and parses a config file from disk. * - * Uses application ID `elastic`, which causes cosmiconfig to search for: - * - `.elasticrc`, `.elasticrc.yml`, `.elasticrc.yaml`, `.elasticrc.json` - * - `elastic` property in `package.json` + * Supports YAML (`.yml`, `.yaml`) and JSON (`.json`) files, plus + * extensionless files (parsed as YAML, which is a superset of JSON). * - * Executable config formats (`.js`, `.ts`, `.mjs`, `.cjs`) are explicitly - * blocked to prevent arbitrary code execution from untrusted directories. + * Executable formats (`.js`, `.ts`, `.mjs`, `.cjs`) are rejected to + * prevent arbitrary code execution. * - * The explorer searches from the given directory upward toward the home - * directory (`searchStrategy: 'global'`). + * @param filePath - Absolute path to the config file. + * @returns The parsed config object. */ -export function createExplorer () { - return cosmiconfig('elastic', { - searchStrategy: 'global', - searchPlaces: [ - 'package.json', - '.elasticrc', - '.elasticrc.json', - '.elasticrc.yaml', - '.elasticrc.yml', - ], - loaders: { - '.js': rejectExecutableLoader, - '.ts': rejectExecutableLoader, - '.mjs': rejectExecutableLoader, - '.cjs': rejectExecutableLoader, - }, - }) +export async function loadConfigFile (filePath: string): Promise { + const ext = extname(filePath) + if (EXECUTABLE_EXTENSIONS.has(ext)) { + throw new Error( + 'JavaScript and TypeScript config files are not supported for security reasons. Use .elasticrc.yml instead.' + ) + } + + const content = await readFile(filePath, 'utf-8') + if (ext === '.json') return JSON.parse(content) as unknown + return parseYaml(content) as unknown } /** @@ -100,9 +112,7 @@ export function resolveContext (config: ConfigFile, contextName: string): Resolv /** Options accepted by {@link loadConfig}. */ export interface LoadConfigOptions { - /** Directory to start cosmiconfig discovery from. Defaults to `process.cwd()`. */ - searchFrom?: string - /** Explicit path to a config file. Bypasses cosmiconfig discovery when set. */ + /** Explicit path to a config file. Bypasses discovery when set. */ configPath?: string /** Context name override (`--use-context` flag). Overrides `current_context` in the file. */ contextName?: string @@ -121,7 +131,7 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr * Full config loading pipeline: discover/load → validate → resolve context. * * Steps: - * 1. Load raw config via cosmiconfig (discovery or explicit path) + * 1. Discover or resolve config file path, then read and parse it * 2. Resolve expressions in string values (env, cmd, keychain) * 3. Validate with `ConfigFileSchema` (Zod) * 4. Resolve the active context (from `contextName` override or `current_context`) @@ -133,24 +143,30 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr * @returns A `LoadConfigResult` discriminated union. */ export async function loadConfig (options: LoadConfigOptions = {}): Promise { - const { searchFrom, configPath, contextName } = options - const explorer = createExplorer() + const { configPath, contextName } = options // Step 1: load raw config + // Precedence: --config-file flag > ELASTIC_CLI_CONFIG_FILE env var > home-directory discovery let raw: unknown try { - const result = configPath != null - ? await explorer.load(configPath) - : await explorer.search(searchFrom) + const envConfigFile = process.env[ENV_CONFIG_FILE] + let resolvedPath: string | null + if (configPath != null) { + resolvedPath = configPath + } else if (envConfigFile != null && envConfigFile.length > 0) { + resolvedPath = envConfigFile + } else { + resolvedPath = await discoverConfigFile() + } - if (result == null) { + if (resolvedPath == null) { return { ok: false, - error: { message: 'No configuration file found. Create a .elasticrc.yml in your home directory or project root.' } + error: { message: 'No configuration file found. Create a .elasticrc.yml in your home directory, or use --config-file / ELASTIC_CLI_CONFIG_FILE to specify a path.' } } } - raw = result.config + raw = await loadConfigFile(resolvedPath) } catch (err) { const message = err instanceof Error ? err.message : String(err) return { ok: false, error: { message } } diff --git a/test/cli.test.ts b/test/cli.test.ts index fae71efe..ff1aa528 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -21,7 +21,7 @@ import { Command } from 'commander' function makeProgram(): InstanceType { const prog = new Command('elastic') prog.exitOverride() - prog.option('--config-file ', 'path to a config file, bypassing cosmiconfig discovery') + prog.option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') prog.option('--use-context ', 'override the active context from the config file') prog.option('--json', 'output as JSON') return prog diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index 42a97cc2..c6cc71f8 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -5,10 +5,10 @@ import { describe, it, before, after } from 'node:test' import assert from 'node:assert/strict' -import { mkdtemp, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { createExplorer, resolveContext, loadConfig } from '../../src/config/loader.ts' +import { loadConfigFile, discoverConfigFile, resolveContext, loadConfig } from '../../src/config/loader.ts' import type { ConfigFile, ResolvedConfig } from '../../src/config/types.ts' // --------------------------------------------------------------------------- @@ -52,51 +52,80 @@ const VALID_CONFIG_OBJECT: ConfigFile = { // --------------------------------------------------------------------------- -describe('createExplorer', () => { - it('exports a createExplorer function', () => { - assert.equal(typeof createExplorer, 'function') +describe('loadConfigFile', () => { + let tmpDir: string + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-test-')) }) + after(async () => rm(tmpDir, { recursive: true })) - describe('search()', () => { - let tmpDir: string - before(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-test-')) - await writeFile(join(tmpDir, '.elasticrc.yml'), VALID_CONFIG_YAML) - }) - after(async () => rm(tmpDir, { recursive: true })) - - it('discovers a .elasticrc.yml file by searching from a directory', async () => { - const explorer = createExplorer() - const result = await explorer.search(tmpDir) - assert.ok(result != null, 'search() should find the config file') - assert.ok(result!.config != null, 'result.config should be the parsed YAML object') - assert.equal(result!.config['current_context'], 'local') - }) + it('parses a YAML config file', async () => { + const filePath = join(tmpDir, 'config.yml') + await writeFile(filePath, VALID_CONFIG_YAML) + const result = await loadConfigFile(filePath) as Record + assert.equal(result['current_context'], 'local') }) - describe('load()', () => { - let tmpDir: string - let configPath: string - before(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-test-')) - configPath = join(tmpDir, 'myconfig.yml') - await writeFile(configPath, VALID_CONFIG_YAML) - }) - after(async () => rm(tmpDir, { recursive: true })) + it('parses a JSON config file', async () => { + const filePath = join(tmpDir, 'config.json') + await writeFile(filePath, JSON.stringify(VALID_CONFIG_OBJECT)) + const result = await loadConfigFile(filePath) as Record + assert.equal(result['current_context'], 'local') + }) - it('loads a config file from an explicit path', async () => { - const explorer = createExplorer() - const result = await explorer.load(configPath) - assert.ok(result != null, 'load() should return a result for a valid path') - assert.equal(result!.config['current_context'], 'local') - }) + it('parses an extensionless file as YAML', async () => { + const filePath = join(tmpDir, '.elasticrc') + await writeFile(filePath, VALID_CONFIG_YAML) + const result = await loadConfigFile(filePath) as Record + assert.equal(result['current_context'], 'local') + }) - it('returns the absolute file path in the result', async () => { - const explorer = createExplorer() - const result = await explorer.load(configPath) - assert.ok(result != null) - assert.equal(result!.filepath, configPath) - }) + it('throws for nonexistent file', async () => { + await assert.rejects(() => loadConfigFile(join(tmpDir, 'nope.yml'))) + }) +}) + +// --------------------------------------------------------------------------- + +describe('discoverConfigFile', () => { + it('discovers a .elasticrc.yml in the given directory', async () => { + const tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-test-')) + await writeFile(join(tmpDir, '.elasticrc.yml'), VALID_CONFIG_YAML) + const found = await discoverConfigFile(tmpDir) + assert.ok(found != null) + assert.ok(found!.endsWith('.elasticrc.yml')) + await rm(tmpDir, { recursive: true }) + }) + + it('returns null when no config exists', async () => { + const emptyDir = await mkdtemp(join(tmpdir(), 'elastic-cli-empty-')) + const found = await discoverConfigFile(emptyDir) + assert.equal(found, null) + await rm(emptyDir, { recursive: true }) + }) + + it('does NOT discover config in parent directories (security regression)', async () => { + const parentDir = await mkdtemp(join(tmpdir(), 'elastic-cli-parent-')) + await writeFile(join(parentDir, '.elasticrc.yml'), VALID_CONFIG_YAML) + const childDir = join(parentDir, 'subdir') + await mkdir(childDir, { recursive: true }) + const found = await discoverConfigFile(childDir) + assert.equal(found, null, 'must not walk up to parent directories') + await rm(parentDir, { recursive: true }) + }) + + it('prefers earlier file names within the same directory', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-order-')) + await writeFile(join(dir, '.elasticrc.json'), JSON.stringify(VALID_CONFIG_OBJECT)) + await writeFile(join(dir, '.elasticrc.yml'), VALID_CONFIG_YAML) + const found = await discoverConfigFile(dir) + assert.ok(found!.endsWith('.elasticrc.json')) + await rm(dir, { recursive: true }) + }) + + it('returns null for a nonexistent directory', async () => { + const found = await discoverConfigFile('/nonexistent/path') + assert.equal(found, null) }) }) @@ -151,14 +180,16 @@ describe('loadConfig -- default current_context', () => { }) let tmpDir: string + let configPath: string before(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-test-')) - await writeFile(join(tmpDir, '.elasticrc.yml'), VALID_CONFIG_YAML) + configPath = join(tmpDir, '.elasticrc.yml') + await writeFile(configPath, VALID_CONFIG_YAML) }) after(async () => rm(tmpDir, { recursive: true })) it('discovers, validates, and resolves the default current_context', async () => { - const result = await loadConfig({ searchFrom: tmpDir }) + const result = await loadConfig({ configPath }) assert.ok(result.ok, `loadConfig should succeed, got: ${!result.ok ? result.error : ''}`) if (!result.ok) return assert.deepEqual(result.value, { @@ -176,14 +207,16 @@ describe('loadConfig -- default current_context', () => { describe('loadConfig -- --use-context override', () => { let tmpDir: string + let configPath: string before(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-test-')) - await writeFile(join(tmpDir, '.elasticrc.yml'), VALID_CONFIG_YAML) + configPath = join(tmpDir, '.elasticrc.yml') + await writeFile(configPath, VALID_CONFIG_YAML) }) after(async () => rm(tmpDir, { recursive: true })) it('uses the supplied contextName vs current_context', async () => { - const result = await loadConfig({ searchFrom: tmpDir, contextName: 'staging' }) + const result = await loadConfig({ configPath, contextName: 'staging' }) assert.ok(result.ok, `loadConfig should succeed with --use-context staging`) if (!result.ok) return assert.deepEqual(result.value, { @@ -194,7 +227,7 @@ describe('loadConfig -- --use-context override', () => { }) it('returns an error when the overridden context name does not exist', async () => { - const result = await loadConfig({ searchFrom: tmpDir, contextName: 'nonexistent' }) + const result = await loadConfig({ configPath, contextName: 'nonexistent' }) assert.ok(!result.ok, 'loadConfig should fail for a nonexistent context override') if (result.ok) return assert.ok(result.error.message.includes('nonexistent'), 'error message should name the missing context') @@ -224,7 +257,7 @@ describe('loadConfig -- --config-file override', () => { ])) it('loads from the explicit configPath, bypassing discovery', async () => { - const result = await loadConfig({ searchFrom: discoveryDir, configPath: explicitConfigPath }) + const result = await loadConfig({ configPath: explicitConfigPath }) assert.ok(result.ok, `loadConfig should succeed with explicit --config-file path, got: ${!result.ok ? result.error.message : ''}`) if (!result.ok) return assert.deepEqual(result.value, { @@ -236,11 +269,93 @@ describe('loadConfig -- --config-file override', () => { }) it('returns an error when the explicit path does not exist', async () => { - const result = await loadConfig({ searchFrom: discoveryDir, configPath: join(tmpDir, 'does-not-exist.yml') }) + const result = await loadConfig({ configPath: join(tmpDir, 'does-not-exist.yml') }) assert.ok(!result.ok, 'loadConfig should fail for a nonexistent explicit config path') }) }) +// --------------------------------------------------------------------------- + +describe('loadConfig -- ELASTIC_CLI_CONFIG_FILE env var', () => { + let tmpDir: string + let envConfigPath: string + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-cli-envvar-')) + envConfigPath = join(tmpDir, 'env-config.yml') + await writeFile(envConfigPath, VALID_CONFIG_YAML) + }) + after(async () => rm(tmpDir, { recursive: true })) + + it('loads config from ELASTIC_CLI_CONFIG_FILE when set', async () => { + const original = process.env['ELASTIC_CLI_CONFIG_FILE'] + try { + process.env['ELASTIC_CLI_CONFIG_FILE'] = envConfigPath + const result = await loadConfig({}) + assert.ok(result.ok, `loadConfig should succeed via env var`) + if (!result.ok) return + assert.deepEqual(result.value, { + context: { + elasticsearch: { url: 'http://localhost:9200', auth: { username: 'elastic', password: 'changeme' } }, + kibana: { url: 'http://localhost:5601', auth: { api_key: 'kb-key-123' } }, + }, + } satisfies ResolvedConfig) + } finally { + if (original === undefined) delete process.env['ELASTIC_CLI_CONFIG_FILE'] + else process.env['ELASTIC_CLI_CONFIG_FILE'] = original + } + }) + + it('--config-file flag takes precedence over ELASTIC_CLI_CONFIG_FILE', async () => { + const otherDir = await mkdtemp(join(tmpdir(), 'elastic-cli-other-')) + const flagConfigPath = join(otherDir, 'flag-config.yml') + const envYaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://env-host:9200 + auth: + api_key: env-key +`.trimStart() + const flagYaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://flag-host:9200 + auth: + api_key: flag-key +`.trimStart() + await writeFile(envConfigPath, envYaml) + await writeFile(flagConfigPath, flagYaml) + + const original = process.env['ELASTIC_CLI_CONFIG_FILE'] + try { + process.env['ELASTIC_CLI_CONFIG_FILE'] = envConfigPath + const result = await loadConfig({ configPath: flagConfigPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.context.elasticsearch!.url, 'http://flag-host:9200') + } finally { + if (original === undefined) delete process.env['ELASTIC_CLI_CONFIG_FILE'] + else process.env['ELASTIC_CLI_CONFIG_FILE'] = original + await rm(otherDir, { recursive: true }) + } + }) + + it('returns error when ELASTIC_CLI_CONFIG_FILE points to nonexistent file', async () => { + const original = process.env['ELASTIC_CLI_CONFIG_FILE'] + try { + process.env['ELASTIC_CLI_CONFIG_FILE'] = '/nonexistent/config.yml' + const result = await loadConfig({}) + assert.ok(!result.ok) + } finally { + if (original === undefined) delete process.env['ELASTIC_CLI_CONFIG_FILE'] + else process.env['ELASTIC_CLI_CONFIG_FILE'] = original + } + }) +}) + // --------------------------------------------------------------------------- // T019 — commands policy threading through loadConfig and resolveContext // --------------------------------------------------------------------------- @@ -341,14 +456,13 @@ describe('security: executable config formats are rejected', () => { }) after(async () => rm(tmpDir, { recursive: true })) - describe('createExplorer rejects executable loaders', () => { + describe('loadConfigFile rejects executable formats', () => { for (const ext of ['.js', '.ts', '.mjs', '.cjs']) { it(`throws for .elasticrc${ext}`, async () => { const filePath = join(tmpDir, `.elasticrc${ext}`) await writeFile(filePath, 'export default {}') - const explorer = createExplorer() await assert.rejects( - () => explorer.load(filePath), + () => loadConfigFile(filePath), (err: Error) => { assert.match(err.message, /not supported.*security/) return true @@ -360,19 +474,15 @@ describe('security: executable config formats are rejected', () => { it('still loads .yml files', async () => { const filePath = join(tmpDir, '.elasticrc.yml') await writeFile(filePath, VALID_CONFIG_YAML) - const explorer = createExplorer() - const result = await explorer.load(filePath) - assert.ok(result != null) - assert.equal(result!.config['current_context'], 'local') + const result = await loadConfigFile(filePath) as Record + assert.equal(result['current_context'], 'local') }) it('still loads .json files', async () => { const filePath = join(tmpDir, '.elasticrc.json') await writeFile(filePath, JSON.stringify(VALID_CONFIG_OBJECT)) - const explorer = createExplorer() - const result = await explorer.load(filePath) - assert.ok(result != null) - assert.equal(result!.config['current_context'], 'local') + const result = await loadConfigFile(filePath) as Record + assert.equal(result['current_context'], 'local') }) }) @@ -389,27 +499,24 @@ describe('security: executable config formats are rejected', () => { } }) - describe('search does not discover executable config files', () => { - for (const name of ['elastic.config.js', 'elastic.config.mjs', 'elastic.config.cjs', 'elastic.config.ts']) { - it(`skips ${name}`, async () => { - const searchDir = await mkdtemp(join(tmpdir(), 'elastic-cli-search-')) - const filePath = join(searchDir, name) - await writeFile(filePath, 'export default {}') - const explorer = createExplorer() - const result = await explorer.search(searchDir) - assert.ok(result == null || result.filepath !== filePath) - await rm(searchDir, { recursive: true }) + describe('discoverConfigFile ignores executable file names', () => { + for (const name of ['.elasticrc.js', '.elasticrc.ts', '.elasticrc.mjs', '.elasticrc.cjs']) { + it(`does not discover ${name}`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-exec-')) + await writeFile(join(dir, name), 'export default {}') + const found = await discoverConfigFile(dir) + assert.equal(found, null, `should not discover ${name}`) + await rm(dir, { recursive: true }) }) } it('still discovers .elasticrc.yml', async () => { - const searchDir = await mkdtemp(join(tmpdir(), 'elastic-cli-yml-')) - await writeFile(join(searchDir, '.elasticrc.yml'), VALID_CONFIG_YAML) - const explorer = createExplorer() - const result = await explorer.search(searchDir) - assert.ok(result != null) - assert.equal(result!.config['current_context'], 'local') - await rm(searchDir, { recursive: true }) + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-yml-')) + await writeFile(join(dir, '.elasticrc.yml'), VALID_CONFIG_YAML) + const found = await discoverConfigFile(dir) + assert.ok(found != null) + assert.ok(found!.endsWith('.elasticrc.yml')) + await rm(dir, { recursive: true }) }) }) })