From a4eee36083ab22ad3625490a3b27701f396e9646 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:31:17 -0400 Subject: [PATCH 01/21] feat(extension): add extension registry store module --- src/extension/store.ts | 120 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/extension/store.ts diff --git a/src/extension/store.ts b/src/extension/store.ts new file mode 100644 index 0000000..3afd700 --- /dev/null +++ b/src/extension/store.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * On-disk registry of installed extensions. + * + * The registry is a JSON array persisted at `~/.elastic/extensions.json`. + * Each entry records the extension name, the install source, the path to the + * install directory, and the resolved entrypoint executable. + * + * All public functions are async and safe to call concurrently for reads; + * writes are not locked (single-writer assumption: the CLI runs one command + * at a time). + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +/** A single installed extension entry in the registry. */ +export interface InstalledExtension { + /** Short name, e.g. `"local"` for an extension invoked as `elastic local`. */ + name: string + /** + * Install source string as provided by the user, e.g.: + * - `"github:elastic/elastic-local"` + * - `"npm:@elastic/start-local"` + */ + source: string + /** Absolute path to the extension's install directory. */ + path: string + /** Absolute path to the executable that is spawned when the extension runs. */ + entrypoint: string +} + +// --------------------------------------------------------------------------- +// Test seam +// --------------------------------------------------------------------------- + +let _registryPath: string | undefined + +/** + * Override the registry file path. Pass `undefined` to restore the default. + * Intended for test use only. + * @internal + */ +export function _testSetRegistryPath (p: string | undefined): void { + _registryPath = p +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function registryPath (): string { + return _registryPath ?? join(homedir(), '.elastic', 'extensions.json') +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Reads and returns all installed extensions from the registry. + * Returns an empty array if the registry file does not yet exist. + */ +export async function readExtensions (): Promise { + try { + const raw = await readFile(registryPath(), 'utf-8') + return JSON.parse(raw) as InstalledExtension[] + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } +} + +/** + * Persists the given extension list to the registry file. + * Creates `~/.elastic/` if it does not exist. + */ +export async function writeExtensions (extensions: InstalledExtension[]): Promise { + const path = registryPath() + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, JSON.stringify(extensions, null, 2) + '\n', 'utf-8') +} + +/** + * Returns the registry entry for the given extension name, or `undefined` + * if no extension with that name is installed. + */ +export async function findExtension (name: string): Promise { + const extensions = await readExtensions() + return extensions.find((e) => e.name === name) +} + +/** + * Adds or replaces an entry in the registry (matched by name) and persists. + */ +export async function upsertExtension (entry: InstalledExtension): Promise { + const extensions = await readExtensions() + const idx = extensions.findIndex((e) => e.name === entry.name) + if (idx === -1) { + extensions.push(entry) + } else { + extensions[idx] = entry + } + await writeExtensions(extensions) +} + +/** + * Removes the entry with the given name from the registry and persists. + * No-ops if the name is not found. + */ +export async function removeExtension (name: string): Promise { + const extensions = await readExtensions() + const filtered = extensions.filter((e) => e.name !== name) + await writeExtensions(filtered) +} From 51f8bba97467d7055a343d099e34c5d0c61c9772 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:31:19 -0400 Subject: [PATCH 02/21] test(extension): add unit tests for extension store --- test/extension/store.test.ts | 157 +++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 test/extension/store.test.ts diff --git a/test/extension/store.test.ts b/test/extension/store.test.ts new file mode 100644 index 0000000..704986f --- /dev/null +++ b/test/extension/store.test.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + readExtensions, + writeExtensions, + findExtension, + upsertExtension, + removeExtension, + _testSetRegistryPath, +} from '../../src/extension/store.ts' +import type { InstalledExtension } from '../../src/extension/store.ts' + +const ext1: InstalledExtension = { + name: 'local', + source: 'github:elastic/elastic-local', + path: '/home/user/.elastic/extensions/elastic-local', + entrypoint: '/home/user/.elastic/extensions/elastic-local/elastic-local', +} + +const ext2: InstalledExtension = { + name: 'diag', + source: 'github:elastic/esdiag', + path: '/home/user/.elastic/extensions/elastic-diag', + entrypoint: '/home/user/.elastic/extensions/elastic-diag/esdiag', +} + +describe('extension store', () => { + let tmpDir: string + let registryFile: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-ext-store-')) + registryFile = join(tmpDir, 'extensions.json') + _testSetRegistryPath(registryFile) + }) + + after(async () => { + _testSetRegistryPath(undefined) + await rm(tmpDir, { recursive: true }) + }) + + afterEach(async () => { + // wipe registry between tests + await writeExtensions([]) + }) + + describe('readExtensions', () => { + it('returns empty array when registry does not exist', async () => { + _testSetRegistryPath(join(tmpDir, 'nonexistent', 'extensions.json')) + const result = await readExtensions() + assert.deepEqual(result, []) + _testSetRegistryPath(registryFile) + }) + + it('returns the persisted extensions', async () => { + await writeExtensions([ext1, ext2]) + const result = await readExtensions() + assert.deepEqual(result, [ext1, ext2]) + }) + }) + + describe('writeExtensions', () => { + it('creates the parent directory if missing', async () => { + const nested = join(tmpDir, 'subdir', 'extensions.json') + _testSetRegistryPath(nested) + await writeExtensions([ext1]) + const raw = await readFile(nested, 'utf-8') + assert.deepEqual(JSON.parse(raw), [ext1]) + _testSetRegistryPath(registryFile) + }) + + it('writes valid JSON with a trailing newline', async () => { + await writeExtensions([ext1]) + const raw = await readFile(registryFile, 'utf-8') + assert.ok(raw.endsWith('\n'), 'expected trailing newline') + assert.doesNotThrow(() => JSON.parse(raw)) + }) + + it('overwrites existing content', async () => { + await writeExtensions([ext1, ext2]) + await writeExtensions([ext2]) + const result = await readExtensions() + assert.deepEqual(result, [ext2]) + }) + }) + + describe('findExtension', () => { + it('returns undefined when registry is empty', async () => { + assert.equal(await findExtension('local'), undefined) + }) + + it('returns undefined when name is not found', async () => { + await writeExtensions([ext1]) + assert.equal(await findExtension('diag'), undefined) + }) + + it('returns the matching extension', async () => { + await writeExtensions([ext1, ext2]) + assert.deepEqual(await findExtension('local'), ext1) + assert.deepEqual(await findExtension('diag'), ext2) + }) + }) + + describe('upsertExtension', () => { + it('adds a new entry when name is not present', async () => { + await upsertExtension(ext1) + assert.deepEqual(await readExtensions(), [ext1]) + }) + + it('replaces an existing entry with the same name', async () => { + await upsertExtension(ext1) + const updated: InstalledExtension = { ...ext1, source: 'github:elastic/elastic-local-v2' } + await upsertExtension(updated) + const result = await readExtensions() + assert.equal(result.length, 1) + assert.deepEqual(result[0], updated) + }) + + it('preserves other entries when upserting', async () => { + await writeExtensions([ext1, ext2]) + const updated: InstalledExtension = { ...ext2, path: '/new/path' } + await upsertExtension(updated) + const result = await readExtensions() + assert.equal(result.length, 2) + assert.deepEqual(result.find((e) => e.name === 'diag'), updated) + assert.deepEqual(result.find((e) => e.name === 'local'), ext1) + }) + }) + + describe('removeExtension', () => { + it('no-ops when name is not found', async () => { + await writeExtensions([ext1]) + await removeExtension('nonexistent') + assert.deepEqual(await readExtensions(), [ext1]) + }) + + it('removes the matching entry', async () => { + await writeExtensions([ext1, ext2]) + await removeExtension('local') + assert.deepEqual(await readExtensions(), [ext2]) + }) + + it('leaves an empty registry when the last entry is removed', async () => { + await writeExtensions([ext1]) + await removeExtension('local') + assert.deepEqual(await readExtensions(), []) + }) + }) +}) From 033b99865d7d73b1598390043a008a02c7af963a Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:36:20 -0400 Subject: [PATCH 03/21] fix(extension): validate registry schema, restrict permissions, reject bad names --- src/extension/store.ts | 88 ++++++++++++++++++++++++++++++++---- test/extension/store.test.ts | 50 +++++++++++++++++++- 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src/extension/store.ts b/src/extension/store.ts index 3afd700..831df05 100644 --- a/src/extension/store.ts +++ b/src/extension/store.ts @@ -6,18 +6,27 @@ /** * On-disk registry of installed extensions. * - * The registry is a JSON array persisted at `~/.elastic/extensions.json`. - * Each entry records the extension name, the install source, the path to the - * install directory, and the resolved entrypoint executable. + * The registry is a JSON array persisted at `~/.elastic/extensions.json` with + * 0o600 permissions (owner read/write only). Each entry records the extension + * name, the install source, the path to the install directory, and the + * resolved entrypoint executable. * * All public functions are async and safe to call concurrently for reads; * writes are not locked (single-writer assumption: the CLI runs one command * at a time). + * + * Security notes: + * - The registry file is written with 0o600 permissions. + * - All entries are validated against a schema on read so a corrupt or + * tampered file is rejected with a clear error rather than silently + * executing unexpected paths. + * - Extension names are restricted to `[a-z0-9-]+` to prevent path traversal + * if a name is used to construct filesystem paths. */ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, isAbsolute, join } from 'node:path' /** A single installed extension entry in the registry. */ export interface InstalledExtension { @@ -35,6 +44,12 @@ export interface InstalledExtension { entrypoint: string } +/** + * Safe extension name: lowercase letters, digits, and hyphens only. + * Prevents path traversal if the name is used to construct filesystem paths. + */ +const SAFE_NAME_RE = /^[a-z0-9-]+$/ + // --------------------------------------------------------------------------- // Test seam // --------------------------------------------------------------------------- @@ -58,6 +73,45 @@ function registryPath (): string { return _registryPath ?? join(homedir(), '.elastic', 'extensions.json') } +/** + * Validates that `value` is a well-formed `InstalledExtension`. + * Throws a descriptive error if any field is missing, the wrong type, + * contains an unsafe name, or has a non-absolute path. + */ +function validateEntry (value: unknown, index: number): InstalledExtension { + if (value == null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`extensions.json: entry at index ${index} is not an object`) + } + const obj = value as Record + + for (const field of ['name', 'source', 'path', 'entrypoint'] as const) { + if (typeof obj[field] !== 'string' || (obj[field] as string).length === 0) { + throw new Error(`extensions.json: entry[${index}].${field} must be a non-empty string`) + } + } + + const name = obj['name'] as string + if (!SAFE_NAME_RE.test(name)) { + throw new Error( + `extensions.json: entry[${index}].name "${name}" contains invalid characters (allowed: a-z, 0-9, hyphen)` + ) + } + + for (const field of ['path', 'entrypoint'] as const) { + const val = obj[field] as string + if (!isAbsolute(val)) { + throw new Error(`extensions.json: entry[${index}].${field} must be an absolute path, got "${val}"`) + } + } + + return { + name, + source: obj['source'] as string, + path: obj['path'] as string, + entrypoint: obj['entrypoint'] as string, + } +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -65,25 +119,41 @@ function registryPath (): string { /** * Reads and returns all installed extensions from the registry. * Returns an empty array if the registry file does not yet exist. + * Throws if the file exists but contains invalid or tampered data. */ export async function readExtensions (): Promise { + let raw: string try { - const raw = await readFile(registryPath(), 'utf-8') - return JSON.parse(raw) as InstalledExtension[] + raw = await readFile(registryPath(), 'utf-8') } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] throw err } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('extensions.json: file is not valid JSON') + } + + if (!Array.isArray(parsed)) { + throw new Error('extensions.json: expected a JSON array at the top level') + } + + return parsed.map((entry, i) => validateEntry(entry, i)) } /** - * Persists the given extension list to the registry file. + * Persists the given extension list to the registry file with 0o600 permissions. * Creates `~/.elastic/` if it does not exist. */ export async function writeExtensions (extensions: InstalledExtension[]): Promise { const path = registryPath() await mkdir(dirname(path), { recursive: true }) - await writeFile(path, JSON.stringify(extensions, null, 2) + '\n', 'utf-8') + await writeFile(path, JSON.stringify(extensions, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) + // Explicitly chmod in case the file already existed with broader permissions. + await chmod(path, 0o600) } /** diff --git a/test/extension/store.test.ts b/test/extension/store.test.ts index 704986f..2df8fba 100644 --- a/test/extension/store.test.ts +++ b/test/extension/store.test.ts @@ -5,7 +5,7 @@ import { describe, it, before, after, afterEach } from 'node:test' import assert from 'node:assert/strict' -import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { mkdtemp, rm, readFile, stat, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { @@ -90,6 +90,13 @@ describe('extension store', () => { const result = await readExtensions() assert.deepEqual(result, [ext2]) }) + + it('writes file with 0o600 permissions', async () => { + await writeExtensions([ext1]) + const s = await stat(registryFile) + const mode = s.mode & 0o777 + assert.equal(mode, 0o600, `expected 0o600 permissions, got 0o${mode.toString(8)}`) + }) }) describe('findExtension', () => { @@ -154,4 +161,45 @@ describe('extension store', () => { assert.deepEqual(await readExtensions(), []) }) }) + + describe('readExtensions -- tampered/malformed registry (security)', () => { + it('throws when the file is not valid JSON', async () => { + await writeFile(registryFile, 'not json', 'utf-8') + await assert.rejects(readExtensions(), /not valid JSON/) + }) + + it('throws when the top level is not an array', async () => { + await writeFile(registryFile, '{"name":"local"}', 'utf-8') + await assert.rejects(readExtensions(), /expected a JSON array/) + }) + + it('throws when an entry is missing a required field', async () => { + await writeFile(registryFile, JSON.stringify([{ name: 'local', source: 'github:x/y' }]), 'utf-8') + await assert.rejects(readExtensions(), /path must be a non-empty string/) + }) + + it('throws when name contains path traversal characters', async () => { + const bad = { ...ext1, name: '../evil' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /invalid characters/) + }) + + it('throws when name contains a null byte', async () => { + const bad = { ...ext1, name: 'local\x00evil' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /invalid characters/) + }) + + it('throws when entrypoint is a relative path', async () => { + const bad = { ...ext1, entrypoint: 'relative/path/elastic-local' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /must be an absolute path/) + }) + + it('throws when path is a relative path', async () => { + const bad = { ...ext1, path: '../outside' } + await writeFile(registryFile, JSON.stringify([bad]), 'utf-8') + await assert.rejects(readExtensions(), /must be an absolute path/) + }) + }) }) From 6cd53a6a0cfe6cf62e1ca5871b12581843978a5c Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:46:18 -0400 Subject: [PATCH 04/21] fix(extension): skip chmod on win32 to fix Windows CI permissions test --- src/extension/store.ts | 12 +++++++++++- test/extension/store.test.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/extension/store.ts b/src/extension/store.ts index 831df05..ce62fa9 100644 --- a/src/extension/store.ts +++ b/src/extension/store.ts @@ -28,6 +28,13 @@ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, isAbsolute, join } from 'node:path' +let _platform: string = process.platform + +/** @internal Override the platform for tests. */ +export function _testSetPlatform (p: string): void { + _platform = p +} + /** A single installed extension entry in the registry. */ export interface InstalledExtension { /** Short name, e.g. `"local"` for an extension invoked as `elastic local`. */ @@ -153,7 +160,10 @@ export async function writeExtensions (extensions: InstalledExtension[]): Promis await mkdir(dirname(path), { recursive: true }) await writeFile(path, JSON.stringify(extensions, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) // Explicitly chmod in case the file already existed with broader permissions. - await chmod(path, 0o600) + // chmod is a no-op on Windows so we skip the call entirely. + if (_platform !== 'win32') { + await chmod(path, 0o600) + } } /** diff --git a/test/extension/store.test.ts b/test/extension/store.test.ts index 2df8fba..6a46810 100644 --- a/test/extension/store.test.ts +++ b/test/extension/store.test.ts @@ -15,6 +15,7 @@ import { upsertExtension, removeExtension, _testSetRegistryPath, + _testSetPlatform, } from '../../src/extension/store.ts' import type { InstalledExtension } from '../../src/extension/store.ts' @@ -91,12 +92,21 @@ describe('extension store', () => { assert.deepEqual(result, [ext2]) }) - it('writes file with 0o600 permissions', async () => { + it('writes file with 0o600 permissions (Unix only)', { skip: process.platform === 'win32' }, async () => { await writeExtensions([ext1]) const s = await stat(registryFile) const mode = s.mode & 0o777 assert.equal(mode, 0o600, `expected 0o600 permissions, got 0o${mode.toString(8)}`) }) + + it('skips chmod on win32 without throwing', async () => { + _testSetPlatform('win32') + try { + await assert.doesNotReject(writeExtensions([ext1])) + } finally { + _testSetPlatform(process.platform) + } + }) }) describe('findExtension', () => { From 24c4ad87a132c2c80efae12458085e1358a82389 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:58:43 -0400 Subject: [PATCH 05/21] fix(extension): use early return for Windows skip instead of options object --- test/extension/store.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/extension/store.test.ts b/test/extension/store.test.ts index 6a46810..c0a215e 100644 --- a/test/extension/store.test.ts +++ b/test/extension/store.test.ts @@ -92,7 +92,8 @@ describe('extension store', () => { assert.deepEqual(result, [ext2]) }) - it('writes file with 0o600 permissions (Unix only)', { skip: process.platform === 'win32' }, async () => { + it('writes file with 0o600 permissions (Unix only)', async () => { + if (process.platform === 'win32') return await writeExtensions([ext1]) const s = await stat(registryFile) const mode = s.mode & 0o777 From 9d8c1805dfb309f8ffd264daf1571bcaa661951e Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:40:06 -0400 Subject: [PATCH 06/21] feat(extension): add installer module for github and npm sources --- src/extension/installer.ts | 272 +++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 src/extension/installer.ts diff --git a/src/extension/installer.ts b/src/extension/installer.ts new file mode 100644 index 0000000..46010c9 --- /dev/null +++ b/src/extension/installer.ts @@ -0,0 +1,272 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extension install and remove logic. + * + * Supported source prefixes: + * github:owner/repo -- git clone from GitHub; builds if package.json present + * owner/repo -- bare shorthand, treated as github: + * npm:package-name -- npm install into a local prefix dir + * + * Naming convention (mirrors the RFC): + * Extension repos/packages should be named `elastic-`. + * The `elastic-` prefix is stripped to derive the short CLI name. + * e.g. `elastic/elastic-local` → name `local`, invoked as `elastic local`. + * If the repo/package is not prefixed with `elastic-`, the full name is used. + * + * Security: + * All child processes are spawned with shell: false and an explicit args array. + * The derived entrypoint is validated to sit within the install directory. + */ + +import { access, constants, mkdir, readFile, rm } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join, isAbsolute, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { upsertExtension, removeExtension as removeFromStore } from './store.ts' +import type { InstalledExtension } from './store.ts' + +// --------------------------------------------------------------------------- +// Test seams +// --------------------------------------------------------------------------- + +let _extensionsDir: string | undefined + +/** @internal Override the base extensions directory. Pass undefined to restore default. */ +export function _testSetExtensionsDir (dir: string | undefined): void { + _extensionsDir = dir +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extensionsDir (): string { + return _extensionsDir ?? join(homedir(), '.elastic', 'extensions') +} + +/** Strip the `elastic-` prefix from a repo/package base name, if present. */ +function deriveName (base: string): string { + return base.startsWith('elastic-') ? base.slice('elastic-'.length) : base +} + +/** Valid extension name pattern (same as store). */ +const SAFE_NAME_RE = /^[a-z0-9-]+$/ + +function assertSafeName (name: string): void { + if (!SAFE_NAME_RE.test(name)) { + throw new Error( + `Derived extension name "${name}" contains invalid characters (allowed: a-z, 0-9, hyphen). ` + + 'Rename the repository or package to use only lowercase letters, digits, and hyphens.' + ) + } +} + +interface ParsedSource { + type: 'github' | 'npm' + /** Full package name for npm sources, e.g. `@elastic/start-local`. */ + package?: string + /** GitHub clone URL, e.g. `https://github.com/elastic/elastic-local`. */ + cloneUrl?: string + /** Derived repo/package base name (without scope or owner), e.g. `elastic-local`. */ + baseName: string + /** Derived short CLI name, e.g. `local`. */ + name: string +} + +function parseSource (source: string): ParsedSource { + if (source.startsWith('npm:')) { + const pkg = source.slice('npm:'.length).trim() + if (pkg.length === 0) throw new Error('npm source must include a package name, e.g. npm:elastic-local') + // derive base name: strip scope prefix (@scope/) then derive CLI name + const base = pkg.startsWith('@') ? pkg.slice(pkg.indexOf('/') + 1) : pkg + const name = deriveName(base) + assertSafeName(name) + return { type: 'npm', package: pkg, baseName: base, name } + } + + // github:owner/repo or bare owner/repo + const slug = source.startsWith('github:') ? source.slice('github:'.length) : source + const parts = slug.split('/') + if (parts.length !== 2 || parts.some((p) => p.trim().length === 0)) { + throw new Error( + `Invalid GitHub source "${source}". Use github:owner/repo or owner/repo.` + ) + } + const owner = parts[0]!.trim() + const repo = parts[1]!.trim() + const name = deriveName(repo) + assertSafeName(name) + return { + type: 'github', + cloneUrl: `https://github.com/${owner}/${repo}`, + baseName: repo, + name, + } +} + +/** + * Runs a command with an explicit args array (never shell: true). + * Throws a descriptive error if the process exits non-zero or fails to start. + */ +function run (cmd: string, args: string[], cwd: string): void { + const result = spawnSync(cmd, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + windowsHide: true, + shell: false, + }) + if (result.error != null) { + throw new Error(`Failed to run ${cmd}: ${result.error.message}`) + } + if (result.status !== 0) { + const stderr = (result.stderr ?? '').trim() + throw new Error(`${cmd} exited with code ${result.status}${stderr ? `:\n${stderr}` : ''}`) + } +} + +/** + * Reads the `bin` field from a `package.json` in `dir` and returns the + * resolved absolute path for `binName` (or the first bin entry if only one). + * Returns `undefined` if no `package.json` or no matching bin entry exists. + */ +async function resolveNpmBin (dir: string, binName: string): Promise { + const pkgPath = join(dir, 'package.json') + try { + const raw = await readFile(pkgPath, 'utf-8') + const pkg = JSON.parse(raw) as Record + const bin = pkg['bin'] + if (bin == null) return undefined + let rel: string | undefined + if (typeof bin === 'string') { + rel = bin + } else if (typeof bin === 'object' && !Array.isArray(bin)) { + const binMap = bin as Record + rel = binMap[binName] ?? Object.values(binMap)[0] + } + if (rel == null) return undefined + return resolve(dir, rel) + } catch { + return undefined + } +} + +/** + * Discovers the entrypoint executable in a GitHub clone directory. + * Search order: + * 1. `package.json` bin field pointing to `baseName` + * 2. Executable named `baseName` in root, `bin/`, or `dist/` + */ +async function discoverGithubEntrypoint (installDir: string, baseName: string): Promise { + const fromBin = await resolveNpmBin(installDir, baseName) + if (fromBin != null) { + const abs = isAbsolute(fromBin) ? fromBin : join(installDir, fromBin) + return abs + } + + const candidates = [ + join(installDir, baseName), + join(installDir, 'bin', baseName), + join(installDir, 'dist', baseName), + ] + for (const c of candidates) { + try { + await access(c, constants.X_OK) + return c + } catch { + // not found or not executable + } + } + + throw new Error( + `Could not find an entrypoint executable for "${baseName}" in ${installDir}. ` + + `Expected a binary named "${baseName}" in the root, bin/, or dist/ directory, ` + + 'or a package.json with a bin field.' + ) +} + +/** Asserts the entrypoint path is within the install directory (prevents symlink/config injection). */ +function assertWithinInstallDir (entrypoint: string, installDir: string): void { + const rel = entrypoint.startsWith(installDir + '/') + if (!rel) { + throw new Error( + `Resolved entrypoint "${entrypoint}" is outside the install directory "${installDir}". ` + + 'Refusing to register this extension.' + ) + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Installs an extension from the given source string and registers it. + * + * @param source One of: `github:owner/repo`, `owner/repo`, `npm:package-name` + * @returns The registered `InstalledExtension` entry + */ +export async function installExtension (source: string): Promise { + const parsed = parseSource(source) + const installDir = join(extensionsDir(), `elastic-${parsed.name}`) + + await mkdir(installDir, { recursive: true }) + + let entrypoint: string + + if (parsed.type === 'github') { + run('git', ['clone', '--depth', '1', parsed.cloneUrl!, installDir], extensionsDir()) + + // Build if package.json present + const hasPkg = await readFile(join(installDir, 'package.json'), 'utf-8').then(() => true).catch(() => false) + if (hasPkg) { + run('npm', ['install', '--production', '--no-fund', '--no-audit'], installDir) + } + + entrypoint = await discoverGithubEntrypoint(installDir, parsed.baseName) + } else { + // npm source + run('npm', ['install', '--prefix', installDir, '--no-fund', '--no-audit', parsed.package!], extensionsDir()) + const binDir = join(installDir, 'node_modules', '.bin') + const binName = parsed.baseName.startsWith('elastic-') ? parsed.baseName : `elastic-${parsed.name}` + const candidates = [join(binDir, parsed.baseName), join(binDir, binName)] + let found: string | undefined + for (const c of candidates) { + try { await access(c, constants.X_OK); found = c; break } catch { /* continue */ } + } + if (found == null) { + // Fall back to package.json bin + const pkgDir = join(installDir, 'node_modules', parsed.package!.replace(/^@[^/]+\//, '')) + found = await resolveNpmBin(pkgDir, parsed.baseName) + } + if (found == null) { + throw new Error(`Could not find a bin entry for "${parsed.package}" after npm install.`) + } + entrypoint = found + } + + assertWithinInstallDir(resolve(entrypoint), resolve(installDir)) + + const entry: InstalledExtension = { + name: parsed.name, + source, + path: installDir, + entrypoint: resolve(entrypoint), + } + await upsertExtension(entry) + return entry +} + +/** + * Uninstalls the extension with the given name: removes its install directory + * and deletes its entry from the registry. No-ops if the extension is not installed. + */ +export async function uninstallExtension (name: string): Promise { + const installDir = join(extensionsDir(), `elastic-${name}`) + await rm(installDir, { recursive: true, force: true }) + await removeFromStore(name) +} From 2346150bb3a07b37713781b1170eda88410bc2bd Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:40:06 -0400 Subject: [PATCH 07/21] test(extension): add unit tests for installer module --- test/extension/installer.test.ts | 109 +++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 test/extension/installer.test.ts diff --git a/test/extension/installer.test.ts b/test/extension/installer.test.ts new file mode 100644 index 0000000..9a8ae21 --- /dev/null +++ b/test/extension/installer.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for the installer module. + * + * installExtension() itself requires git/npm on the PATH and makes network + * calls, so it is covered by functional tests rather than here. These tests + * focus on the pure logic: source parsing (via error messages), name + * derivation, and the uninstallExtension() path. + */ + +import { describe, it, before, after, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, mkdir, writeFile, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installExtension, uninstallExtension, _testSetExtensionsDir } from '../../src/extension/installer.ts' +import { readExtensions, writeExtensions, _testSetRegistryPath } from '../../src/extension/store.ts' +import type { InstalledExtension } from '../../src/extension/store.ts' + +describe('installer', () => { + let tmpDir: string + let extDir: string + let registryFile: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-installer-')) + extDir = join(tmpDir, 'extensions') + registryFile = join(tmpDir, 'extensions.json') + _testSetExtensionsDir(extDir) + _testSetRegistryPath(registryFile) + await mkdir(extDir, { recursive: true }) + }) + + after(async () => { + _testSetExtensionsDir(undefined) + _testSetRegistryPath(undefined) + await rm(tmpDir, { recursive: true }) + }) + + afterEach(async () => { + await writeExtensions([]) + // clean up any installed dirs + await rm(extDir, { recursive: true, force: true }) + await mkdir(extDir, { recursive: true }) + }) + + describe('installExtension -- source validation', () => { + it('rejects an empty npm source', async () => { + await assert.rejects(installExtension('npm:'), /package name/) + }) + + it('rejects a github source with too many slashes', async () => { + await assert.rejects(installExtension('github:owner/repo/extra'), /Invalid GitHub source/) + }) + + it('rejects a github source with an empty owner', async () => { + await assert.rejects(installExtension('github:/repo'), /Invalid GitHub source/) + }) + + it('rejects a bare source that is not owner/repo', async () => { + await assert.rejects(installExtension('notaslug'), /Invalid GitHub source/) + }) + + it('rejects a source whose derived name contains invalid characters', async () => { + await assert.rejects(installExtension('github:org/UPPERCASE_TOOL'), /invalid characters/) + }) + }) + + describe('uninstallExtension', () => { + it('removes the install directory and registry entry', async () => { + const entry: InstalledExtension = { + name: 'local', + source: 'github:elastic/elastic-local', + path: join(extDir, 'elastic-local'), + entrypoint: join(extDir, 'elastic-local', 'elastic-local'), + } + await mkdir(entry.path, { recursive: true }) + await writeFile(entry.entrypoint, '#!/bin/sh\necho hi', 'utf-8') + await writeExtensions([entry]) + + await uninstallExtension('local') + + assert.deepEqual(await readExtensions(), []) + await assert.rejects(stat(entry.path), { code: 'ENOENT' }) + }) + + it('no-ops gracefully when extension is not installed', async () => { + await assert.doesNotReject(uninstallExtension('nonexistent')) + }) + + it('removes the registry entry even when the directory is already gone', async () => { + const entry: InstalledExtension = { + name: 'gone', + source: 'github:elastic/elastic-gone', + path: join(extDir, 'elastic-gone'), + entrypoint: join(extDir, 'elastic-gone', 'elastic-gone'), + } + await writeExtensions([entry]) + // directory already absent + + await uninstallExtension('gone') + assert.deepEqual(await readExtensions(), []) + }) + }) +}) From 85bb3eb3f9bf30bebe2f47462e4805331d727268 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:42:20 -0400 Subject: [PATCH 08/21] feat(extension): add elastic extension command group (list, install, remove) --- src/extension/register.ts | 105 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/extension/register.ts diff --git a/src/extension/register.ts b/src/extension/register.ts new file mode 100644 index 0000000..4b21e04 --- /dev/null +++ b/src/extension/register.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * `elastic extension ...` command tree. + * + * Extension commands manage the locally installed extension registry. They do + * not require a resolved Elastic config (no preAction hook) because they + * operate on the extension registry at ~/.elastic/extensions.json, not on an + * Elasticsearch or Kibana cluster. + * + * Commands: + * elastic extension list list installed extensions + * elastic extension install install from github: or npm: + * elastic extension remove uninstall by name + */ + +import { defineCommand, defineGroup } from '../factory.ts' +import type { JsonValue, OpaqueCommandHandle } from '../factory.ts' +import { readExtensions } from './store.ts' +import { installExtension, uninstallExtension } from './installer.ts' + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async function handleList (): Promise { + const extensions = await readExtensions() + return extensions.map((e) => ({ + name: e.name, + source: e.source, + path: e.path, + entrypoint: e.entrypoint, + })) as unknown as JsonValue +} + +async function handleInstall (parsed: { arg?: string }): Promise { + const source = parsed.arg?.trim() + if (source == null || source.length === 0) { + return { error: { code: 'missing_source', message: 'A source is required. Use github:owner/repo or npm:package-name.' } } + } + const entry = await installExtension(source) + return { + installed: true, + name: entry.name, + source: entry.source, + path: entry.path, + entrypoint: entry.entrypoint, + } as unknown as JsonValue +} + +async function handleRemove (parsed: { arg?: string }): Promise { + const name = parsed.arg?.trim() + if (name == null || name.length === 0) { + return { error: { code: 'missing_name', message: 'An extension name is required.' } } + } + await uninstallExtension(name) + return { removed: true, name } as unknown as JsonValue +} + +// --------------------------------------------------------------------------- +// Command tree +// --------------------------------------------------------------------------- + +/** + * Builds the top-level `extension` command group. + */ +export function registerExtensionCommands (): OpaqueCommandHandle { + const listCmd = defineCommand({ + name: 'list', + description: 'List all installed extensions', + handler: async () => handleList(), + }) + + const installCmd = defineCommand({ + name: 'install', + description: 'Install an extension from a GitHub repo or npm package', + positionalArg: { + name: 'source', + description: 'Install source: github:owner/repo, owner/repo, or npm:package-name', + required: true, + }, + handler: async (parsed) => handleInstall(parsed), + }) + + const removeCmd = defineCommand({ + name: 'remove', + description: 'Uninstall an extension by name', + positionalArg: { + name: 'name', + description: 'Short extension name (e.g. "local" for elastic-local)', + required: true, + }, + handler: async (parsed) => handleRemove(parsed), + }) + + return defineGroup( + { name: 'extension', description: 'Manage elastic CLI extensions' }, + listCmd, + installCmd, + removeCmd, + ) +} From 9958a96cff9829b2cf7213be8a08d421529e029f Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:42:20 -0400 Subject: [PATCH 09/21] feat(extension): wire extension command group and skip config loading --- src/cli.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 106c03a..039d5ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -45,6 +45,10 @@ program.hook('preAction', async (thisCommand, actionCommand) => { for (let c = actionCommand.parent; c != null; c = c.parent) { if (c.name() === 'config') return } + // `extension` commands manage the extension registry, not the Elastic stack + for (let c = actionCommand.parent; c != null; c = c.parent) { + if (c.name() === 'extension') return + } const { configFile: configPath, useContext: contextName, commandProfile: profileName } = thisCommand.opts() const typedProfileName = profileName as BuiltInProfile | undefined @@ -176,11 +180,18 @@ if (firstArg === 'sanitize') { program.addCommand(defineGroup({ name: 'sanitize', description: 'Sanitize values for safe use in Elasticsearch' })) } +if (firstArg === 'extension') { + const { registerExtensionCommands } = await import('./extension/register.ts') + program.addCommand(registerExtensionCommands()) +} else { + program.addCommand(defineGroup({ name: 'extension', description: 'Manage elastic CLI extensions' })) +} + // Load config early so --help can hide blocked commands. Skip for commands // that don't need config (e.g. `version`, `sanitize`, or `config` which authors the file) // to avoid unnecessary file I/O and a confusing "no config found" path. // The result is cached in earlyConfig so the preAction hook can reuse it. -if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') { +if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize' && firstArg !== 'extension') { // Parse --profile early (before Commander's full parse) so the early config load // and hideBlockedCommands can apply the correct profile-based allow-list to --help. const profileArgIdx = process.argv.indexOf('--command-profile') From c262905a4e59e0f66d7cfd858628c2cf76b6bb43 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:32:46 -0400 Subject: [PATCH 10/21] feat(extension): add context env-var builder for extensions --- src/extension/context.ts | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/extension/context.ts diff --git a/src/extension/context.ts b/src/extension/context.ts new file mode 100644 index 0000000..67175c9 --- /dev/null +++ b/src/extension/context.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Converts a resolved CLI config into a flat set of environment variables + * that extensions can read to connect to Elastic services without re-implementing + * config parsing. + * + * Only variables with a defined value are included in the returned object. + * Extensions must treat absent variables as "service not configured". + * + * Exported variable names: + * ELASTIC_ES_URL Elasticsearch URL + * ELASTIC_ES_API_KEY Elasticsearch API key (api_key auth) + * ELASTIC_ES_USERNAME Elasticsearch username (basic auth) + * ELASTIC_ES_PASSWORD Elasticsearch password (basic auth) + * ELASTIC_KIBANA_URL Kibana URL + * ELASTIC_KIBANA_API_KEY Kibana API key + * ELASTIC_KIBANA_USERNAME Kibana username (basic auth) + * ELASTIC_KIBANA_PASSWORD Kibana password (basic auth) + * ELASTIC_CLOUD_URL Elastic Cloud URL + * ELASTIC_CLOUD_API_KEY Elastic Cloud API key + */ + +import type { ResolvedConfig, ServiceBlock } from '../config/types.ts' + +type EnvMap = Record + +function serviceEnv (prefix: string, block: ServiceBlock): EnvMap { + const env: EnvMap = {} + env[`${prefix}_URL`] = block.url + if (block.auth == null) return env + if ('api_key' in block.auth) { + env[`${prefix}_API_KEY`] = block.auth.api_key + } else { + env[`${prefix}_USERNAME`] = block.auth.username + env[`${prefix}_PASSWORD`] = block.auth.password + } + return env +} + +/** + * Returns a flat `Record` of environment variables derived + * from the resolved config. Merge this into the child process `env` when + * spawning an extension. + */ +export function buildContextEnv (config: ResolvedConfig): EnvMap { + const env: EnvMap = {} + const { elasticsearch, kibana, cloud } = config.context + if (elasticsearch != null) Object.assign(env, serviceEnv('ELASTIC_ES', elasticsearch)) + if (kibana != null) Object.assign(env, serviceEnv('ELASTIC_KIBANA', kibana)) + if (cloud != null) Object.assign(env, serviceEnv('ELASTIC_CLOUD', cloud)) + return env +} From 98ea76ad1b4b8b9f3889d0e03ae4bfb9f4bd6dce Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:37:03 -0400 Subject: [PATCH 11/21] docs(extension): document credential trust model in context.ts --- src/extension/context.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/extension/context.ts b/src/extension/context.ts index 67175c9..234599b 100644 --- a/src/extension/context.ts +++ b/src/extension/context.ts @@ -11,6 +11,16 @@ * Only variables with a defined value are included in the returned object. * Extensions must treat absent variables as "service not configured". * + * Security / trust model: + * - Credentials are passed as env vars, which is the standard approach for + * CLI extension systems (same model as `gh`). On Linux, a process's env is + * readable by root via /proc//environ and by any process running as the + * same user, so this offers no additional protection over the config file. + * - All child processes spawned by the extension inherit these env vars. Authors + * should be aware and avoid leaking them into further subprocesses or logs. + * - The caller (runner) must NOT use `shell: true` when spawning extensions. + * Use spawn with an explicit args array to avoid shell injection. + * * Exported variable names: * ELASTIC_ES_URL Elasticsearch URL * ELASTIC_ES_API_KEY Elasticsearch API key (api_key auth) From 2d1f5e914fe6cf7501cd42c53cd69865bf39b831 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:44:20 -0400 Subject: [PATCH 12/21] feat(extension): add extension runner module --- src/extension/runner.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/extension/runner.ts diff --git a/src/extension/runner.ts b/src/extension/runner.ts new file mode 100644 index 0000000..2ac2aed --- /dev/null +++ b/src/extension/runner.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extension runner: locates and spawns an installed extension process. + * + * Security: + * - The entrypoint path comes from the validated registry (store.ts enforces + * that it is an absolute path). + * - spawn() is always called with shell: false and an explicit args array to + * prevent shell injection. + * - The child process inherits the parent's stdio so it behaves like a + * first-class terminal command. + * - Context credentials are passed as env vars merged into the inherited + * process.env; the extension process does not receive additional privileges. + */ + +import { spawn } from 'node:child_process' +import type { InstalledExtension } from './store.ts' + +/** + * Spawns the extension's entrypoint with `args`, merging `contextEnv` into + * the inherited environment. Resolves with the child's exit code. + * + * The caller should forward the exit code to `process.exit()`. + */ +export function runExtension ( + ext: InstalledExtension, + args: string[], + contextEnv: Record, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(ext.entrypoint, args, { + stdio: 'inherit', + shell: false, + env: { ...process.env, ...contextEnv }, + }) + child.on('error', (err) => { + reject(new Error(`Failed to start extension "${ext.name}" (${ext.entrypoint}): ${err.message}`)) + }) + child.on('close', (code) => { + resolve(code ?? 1) + }) + }) +} From 9e829f678924c188ff5550602d2f62c3cc6454e9 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 12:44:20 -0400 Subject: [PATCH 13/21] feat(extension): dispatch unknown top-level commands to installed extensions --- src/cli.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 039d5ab..455c8f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -214,4 +214,24 @@ if (process.argv.slice(2).length === 0) { process.exit(0) } +// If the first argument does not match any built-in command, attempt to +// dispatch to an installed extension named `elastic-`. +// This check runs after all built-ins are registered so the set is complete. +const BUILT_IN_COMMANDS = new Set([ + 'version', 'stack', 'es', 'elasticsearch', 'kb', 'kibana', + 'cloud', 'docs', 'config', 'sanitize', 'extension', +]) + +if (firstArg != null && !BUILT_IN_COMMANDS.has(firstArg)) { + const { findExtension } = await import('./extension/store.ts') + const ext = await findExtension(firstArg) + if (ext != null) { + const { buildContextEnv } = await import('./extension/context.ts') + const { runExtension } = await import('./extension/runner.ts') + const contextEnv = earlyConfig?.ok === true ? buildContextEnv(earlyConfig.value) : {} + const exitCode = await runExtension(ext, process.argv.slice(3), contextEnv) + process.exit(exitCode) + } +} + await program.parseAsync(process.argv) From 51385535e53fc1ad058dee2ca8d403985c0f5637 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 13:36:28 -0400 Subject: [PATCH 14/21] feat(extension): add upgradeExtension and upgradeAllExtensions --- src/extension/installer.ts | 54 +++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 46010c9..318fb42 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -26,7 +26,7 @@ import { access, constants, mkdir, readFile, rm } from 'node:fs/promises' import { homedir } from 'node:os' import { join, isAbsolute, resolve } from 'node:path' import { spawnSync } from 'node:child_process' -import { upsertExtension, removeExtension as removeFromStore } from './store.ts' +import { readExtensions, upsertExtension, findExtension, removeExtension as removeFromStore } from './store.ts' import type { InstalledExtension } from './store.ts' // --------------------------------------------------------------------------- @@ -270,3 +270,55 @@ export async function uninstallExtension (name: string): Promise { await rm(installDir, { recursive: true, force: true }) await removeFromStore(name) } + +/** + * Upgrades a single installed extension in-place: + * - github: `git pull --ff-only`, then `npm install --production` if package.json is present + * - npm: `npm update --prefix ` + * + * Rediscovers the entrypoint after the upgrade and persists the updated entry. + * Throws if the extension is not installed. + */ +export async function upgradeExtension (name: string): Promise { + const ext = await findExtension(name) + if (ext == null) throw new Error(`Extension "${name}" is not installed.`) + + const parsed = parseSource(ext.source) + + if (parsed.type === 'github') { + run('git', ['pull', '--ff-only'], ext.path) + const hasPkg = await readFile(join(ext.path, 'package.json'), 'utf-8').then(() => true).catch(() => false) + if (hasPkg) { + run('npm', ['install', '--production', '--no-fund', '--no-audit'], ext.path) + } + const entrypoint = await discoverGithubEntrypoint(ext.path, parsed.baseName) + const updated: InstalledExtension = { ...ext, entrypoint: resolve(entrypoint) } + await upsertExtension(updated) + return updated + } else { + run('npm', ['update', '--prefix', ext.path, '--no-fund', '--no-audit'], extensionsDir()) + await upsertExtension(ext) + return ext + } +} + +/** + * Upgrades all installed extensions. Returns each updated entry. + * Errors from individual upgrades are collected and re-thrown together at the end. + */ +export async function upgradeAllExtensions (): Promise { + const extensions = await readExtensions() + const results: InstalledExtension[] = [] + const errors: string[] = [] + for (const ext of extensions) { + try { + results.push(await upgradeExtension(ext.name)) + } catch (err: unknown) { + errors.push(`${ext.name}: ${err instanceof Error ? err.message : String(err)}`) + } + } + if (errors.length > 0) { + throw new Error(`Some extensions failed to upgrade:\n${errors.map((e) => ` ${e}`).join('\n')}`) + } + return results +} From 9270ceed1bf43cf1ef958ca3e089772c21f52d34 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 13:36:28 -0400 Subject: [PATCH 15/21] feat(extension): add searchExtensions via GitHub topic API --- src/extension/search.ts | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/extension/search.ts diff --git a/src/extension/search.ts b/src/extension/search.ts new file mode 100644 index 0000000..2d412a0 --- /dev/null +++ b/src/extension/search.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Extension discovery via the GitHub repository search API. + * + * Searches for repos tagged with the `elastic-extension` GitHub topic. + * An optional free-text query further filters results. + * + * Security: + * - fetch() is called with redirect: 'error' to prevent credential leakage + * on unexpected redirects. + * - The query string is encoded with encodeURIComponent before interpolation. + * - No credentials are sent; the call uses the public GitHub API rate limit + * (10 requests/minute unauthenticated). Passing a GITHUB_TOKEN in the + * environment raises this to 30 requests/minute. + */ + +const GITHUB_TOPIC = 'elastic-extension' +const GITHUB_API = 'https://api.github.com' + +export interface ExtensionSearchResult { + /** GitHub `owner/repo` slug. */ + repo: string + /** Short human-readable description from the GitHub repo, or empty string. */ + description: string + /** URL to the repository on GitHub. */ + url: string + /** Ready-to-paste install command. */ + installCommand: string +} + +interface GitHubRepoItem { + full_name: string + description: string | null + html_url: string +} + +interface GitHubSearchResponse { + items: GitHubRepoItem[] +} + +/** + * Queries GitHub for repositories tagged with `elastic-extension`. + * An optional `query` string is appended to narrow results (e.g. a keyword). + * + * @param query Optional free-text search terms to append to the topic filter. + * @returns Array of matching extension metadata, sorted by GitHub stars (desc). + */ +export async function searchExtensions (query?: string): Promise { + const q = query != null && query.trim().length > 0 + ? `topic:${GITHUB_TOPIC} ${query.trim()}` + : `topic:${GITHUB_TOPIC}` + + const url = `${GITHUB_API}/search/repositories?q=${encodeURIComponent(q)}&sort=stars&order=desc&per_page=30` + + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + if (process.env.GITHUB_TOKEN) { + headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}` + } + + const resp = await fetch(url, { headers, redirect: 'error' }) + + if (resp.status === 403 || resp.status === 429) { + throw new Error( + 'GitHub API rate limit reached. Set the GITHUB_TOKEN environment variable to increase the limit.' + ) + } + if (!resp.ok) { + throw new Error(`GitHub API error: ${resp.status} ${resp.statusText}`) + } + + const data = await resp.json() as GitHubSearchResponse + + return data.items.map((item) => ({ + repo: item.full_name, + description: item.description ?? '', + url: item.html_url, + installCommand: `elastic extension install github:${item.full_name}`, + })) +} From 5c70ffadfb47369ee1dd3accec2e9929eafa04ac Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 13:36:28 -0400 Subject: [PATCH 16/21] feat(extension): wire upgrade and search commands into extension group --- src/extension/register.ts | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/extension/register.ts b/src/extension/register.ts index 4b21e04..5ec017d 100644 --- a/src/extension/register.ts +++ b/src/extension/register.ts @@ -15,12 +15,15 @@ * elastic extension list list installed extensions * elastic extension install install from github: or npm: * elastic extension remove uninstall by name + * elastic extension upgrade [name] upgrade one or all extensions + * elastic extension search [query] discover extensions via GitHub topic */ import { defineCommand, defineGroup } from '../factory.ts' import type { JsonValue, OpaqueCommandHandle } from '../factory.ts' import { readExtensions } from './store.ts' -import { installExtension, uninstallExtension } from './installer.ts' +import { installExtension, uninstallExtension, upgradeExtension, upgradeAllExtensions } from './installer.ts' +import { searchExtensions } from './search.ts' // --------------------------------------------------------------------------- // Handlers @@ -60,6 +63,22 @@ async function handleRemove (parsed: { arg?: string }): Promise { return { removed: true, name } as unknown as JsonValue } +async function handleUpgrade (parsed: { arg?: string }): Promise { + const name = parsed.arg?.trim() + if (name != null && name.length > 0) { + const updated = await upgradeExtension(name) + return { upgraded: true, name: updated.name, source: updated.source, entrypoint: updated.entrypoint } as unknown as JsonValue + } + const all = await upgradeAllExtensions() + return { upgraded: true, extensions: all.map((e) => ({ name: e.name, source: e.source })) } as unknown as JsonValue +} + +async function handleSearch (parsed: { arg?: string }): Promise { + const query = parsed.arg?.trim() + const results = await searchExtensions(query) + return results as unknown as JsonValue +} + // --------------------------------------------------------------------------- // Command tree // --------------------------------------------------------------------------- @@ -96,10 +115,34 @@ export function registerExtensionCommands (): OpaqueCommandHandle { handler: async (parsed) => handleRemove(parsed), }) + const upgradeCmd = defineCommand({ + name: 'upgrade', + description: 'Upgrade an installed extension, or all extensions if no name is given', + positionalArg: { + name: 'name', + description: 'Short extension name to upgrade (omit to upgrade all)', + required: false, + }, + handler: async (parsed) => handleUpgrade(parsed), + }) + + const searchCmd = defineCommand({ + name: 'search', + description: 'Discover extensions tagged with the elastic-extension GitHub topic', + positionalArg: { + name: 'query', + description: 'Optional search terms to narrow results', + required: false, + }, + handler: async (parsed) => handleSearch(parsed), + }) + return defineGroup( { name: 'extension', description: 'Manage elastic CLI extensions' }, listCmd, installCmd, removeCmd, + upgradeCmd, + searchCmd, ) } From 1f6af373b5bafd3332de9f354fd376105abacb41 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 13:36:28 -0400 Subject: [PATCH 17/21] test(extension): add tests for upgrade and search --- test/extension/installer.test.ts | 15 +++++- test/extension/search.test.ts | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/extension/search.test.ts diff --git a/test/extension/installer.test.ts b/test/extension/installer.test.ts index 9a8ae21..5978421 100644 --- a/test/extension/installer.test.ts +++ b/test/extension/installer.test.ts @@ -17,7 +17,7 @@ import assert from 'node:assert/strict' import { mkdtemp, rm, mkdir, writeFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { installExtension, uninstallExtension, _testSetExtensionsDir } from '../../src/extension/installer.ts' +import { installExtension, uninstallExtension, upgradeExtension, upgradeAllExtensions, _testSetExtensionsDir } from '../../src/extension/installer.ts' import { readExtensions, writeExtensions, _testSetRegistryPath } from '../../src/extension/store.ts' import type { InstalledExtension } from '../../src/extension/store.ts' @@ -106,4 +106,17 @@ describe('installer', () => { assert.deepEqual(await readExtensions(), []) }) }) + + describe('upgradeExtension', () => { + it('throws when the extension is not installed', async () => { + await assert.rejects(upgradeExtension('nonexistent'), /not installed/) + }) + }) + + describe('upgradeAllExtensions', () => { + it('returns empty array when no extensions are installed', async () => { + const results = await upgradeAllExtensions() + assert.deepEqual(results, []) + }) + }) }) diff --git a/test/extension/search.test.ts b/test/extension/search.test.ts new file mode 100644 index 0000000..fc09765 --- /dev/null +++ b/test/extension/search.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { searchExtensions } from '../../src/extension/search.ts' + +// --------------------------------------------------------------------------- +// Fetch stub +// --------------------------------------------------------------------------- + +type FetchFn = typeof globalThis.fetch +let _originalFetch: FetchFn +let _stubResponse: { ok: boolean; status: number; statusText: string; json: () => Promise } | null = null + +function stubFetch (response: typeof _stubResponse): void { + _stubResponse = response + globalThis.fetch = async (_url: string | URL | Request, _init?: RequestInit) => { + if (_stubResponse == null) throw new Error('No stub response configured') + return { + ok: _stubResponse.ok, + status: _stubResponse.status, + statusText: _stubResponse.statusText, + json: _stubResponse.json, + } as Response + } +} + +before(() => { _originalFetch = globalThis.fetch }) +after(() => { globalThis.fetch = _originalFetch }) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const SAMPLE_ITEMS = [ + { full_name: 'elastic/elastic-local', description: 'Local stack lifecycle', html_url: 'https://github.com/elastic/elastic-local' }, + { full_name: 'acme/elastic-diag', description: null, html_url: 'https://github.com/acme/elastic-diag' }, +] + +describe('searchExtensions', () => { + it('returns mapped results from the GitHub API', async () => { + stubFetch({ ok: true, status: 200, statusText: 'OK', json: async () => ({ items: SAMPLE_ITEMS }) }) + const results = await searchExtensions() + assert.equal(results.length, 2) + assert.equal(results[0]!.repo, 'elastic/elastic-local') + assert.equal(results[0]!.description, 'Local stack lifecycle') + assert.equal(results[0]!.installCommand, 'elastic extension install github:elastic/elastic-local') + assert.equal(results[1]!.description, '', 'null description should become empty string') + }) + + it('returns empty array when no results found', async () => { + stubFetch({ ok: true, status: 200, statusText: 'OK', json: async () => ({ items: [] }) }) + const results = await searchExtensions() + assert.deepEqual(results, []) + }) + + it('throws a rate-limit error on 403', async () => { + stubFetch({ ok: false, status: 403, statusText: 'Forbidden', json: async () => ({}) }) + await assert.rejects(searchExtensions(), /rate limit/) + }) + + it('throws a rate-limit error on 429', async () => { + stubFetch({ ok: false, status: 429, statusText: 'Too Many Requests', json: async () => ({}) }) + await assert.rejects(searchExtensions(), /rate limit/) + }) + + it('throws a generic API error on other non-ok responses', async () => { + stubFetch({ ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({}) }) + await assert.rejects(searchExtensions(), /GitHub API error: 500/) + }) + + it('includes the install command with the correct github: prefix', async () => { + stubFetch({ ok: true, status: 200, statusText: 'OK', json: async () => ({ items: SAMPLE_ITEMS }) }) + const results = await searchExtensions('local') + for (const r of results) { + assert.ok(r.installCommand.startsWith('elastic extension install github:')) + } + }) +}) From 4d7d206c594ac98ae84efd423ef27858b7fdde62 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Tue, 12 May 2026 13:54:00 -0400 Subject: [PATCH 18/21] fix(extension): remove unused params from fetch stub to satisfy eslint --- test/extension/search.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/extension/search.test.ts b/test/extension/search.test.ts index fc09765..56a86db 100644 --- a/test/extension/search.test.ts +++ b/test/extension/search.test.ts @@ -17,7 +17,7 @@ let _stubResponse: { ok: boolean; status: number; statusText: string; json: () = function stubFetch (response: typeof _stubResponse): void { _stubResponse = response - globalThis.fetch = async (_url: string | URL | Request, _init?: RequestInit) => { + globalThis.fetch = async () => { if (_stubResponse == null) throw new Error('No stub response configured') return { ok: _stubResponse.ok, From e0f1fae5a1b7033d5005e9a2694c50e02c2e86fa Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Wed, 13 May 2026 13:33:43 -0400 Subject: [PATCH 19/21] feat(extension): add local: source type for development and e2e testing --- src/extension/installer.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 318fb42..9556812 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -66,10 +66,10 @@ function assertSafeName (name: string): void { } interface ParsedSource { - type: 'github' | 'npm' + type: 'github' | 'npm' | 'local' /** Full package name for npm sources, e.g. `@elastic/start-local`. */ package?: string - /** GitHub clone URL, e.g. `https://github.com/elastic/elastic-local`. */ + /** Clone URL for github sources, or absolute local path for local sources. */ cloneUrl?: string /** Derived repo/package base name (without scope or owner), e.g. `elastic-local`. */ baseName: string @@ -88,12 +88,24 @@ function parseSource (source: string): ParsedSource { return { type: 'npm', package: pkg, baseName: base, name } } + // local:/abs/path/to/repo — for local development and e2e testing + if (source.startsWith('local:')) { + const localPath = source.slice('local:'.length).trim() + if (!localPath.startsWith('/')) { + throw new Error(`local: source must be an absolute path, e.g. local:/path/to/repo`) + } + const baseName = localPath.split('/').filter(Boolean).pop() ?? 'extension' + const name = deriveName(baseName) + assertSafeName(name) + return { type: 'local', cloneUrl: localPath, baseName, name } + } + // github:owner/repo or bare owner/repo const slug = source.startsWith('github:') ? source.slice('github:'.length) : source const parts = slug.split('/') if (parts.length !== 2 || parts.some((p) => p.trim().length === 0)) { throw new Error( - `Invalid GitHub source "${source}". Use github:owner/repo or owner/repo.` + `Invalid GitHub source "${source}". Use github:owner/repo, owner/repo, local:/path, or npm:package.` ) } const owner = parts[0]!.trim() @@ -218,7 +230,7 @@ export async function installExtension (source: string): Promise true).catch(() => false) if (hasPkg) { From 31784bc9f57fd211e3593cd559cdd07f1b814fe9 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Wed, 13 May 2026 13:34:50 -0400 Subject: [PATCH 20/21] Revert "feat(extension): add local: source type for development and e2e testing" This reverts commit e0f1fae5a1b7033d5005e9a2694c50e02c2e86fa. --- src/extension/installer.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/extension/installer.ts b/src/extension/installer.ts index 9556812..318fb42 100644 --- a/src/extension/installer.ts +++ b/src/extension/installer.ts @@ -66,10 +66,10 @@ function assertSafeName (name: string): void { } interface ParsedSource { - type: 'github' | 'npm' | 'local' + type: 'github' | 'npm' /** Full package name for npm sources, e.g. `@elastic/start-local`. */ package?: string - /** Clone URL for github sources, or absolute local path for local sources. */ + /** GitHub clone URL, e.g. `https://github.com/elastic/elastic-local`. */ cloneUrl?: string /** Derived repo/package base name (without scope or owner), e.g. `elastic-local`. */ baseName: string @@ -88,24 +88,12 @@ function parseSource (source: string): ParsedSource { return { type: 'npm', package: pkg, baseName: base, name } } - // local:/abs/path/to/repo — for local development and e2e testing - if (source.startsWith('local:')) { - const localPath = source.slice('local:'.length).trim() - if (!localPath.startsWith('/')) { - throw new Error(`local: source must be an absolute path, e.g. local:/path/to/repo`) - } - const baseName = localPath.split('/').filter(Boolean).pop() ?? 'extension' - const name = deriveName(baseName) - assertSafeName(name) - return { type: 'local', cloneUrl: localPath, baseName, name } - } - // github:owner/repo or bare owner/repo const slug = source.startsWith('github:') ? source.slice('github:'.length) : source const parts = slug.split('/') if (parts.length !== 2 || parts.some((p) => p.trim().length === 0)) { throw new Error( - `Invalid GitHub source "${source}". Use github:owner/repo, owner/repo, local:/path, or npm:package.` + `Invalid GitHub source "${source}". Use github:owner/repo or owner/repo.` ) } const owner = parts[0]!.trim() @@ -230,7 +218,7 @@ export async function installExtension (source: string): Promise true).catch(() => false) if (hasPkg) { From ac44a935c4e623c685141555b7661ec08a5b4365 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Wed, 13 May 2026 14:11:41 -0400 Subject: [PATCH 21/21] fix(extension): return structured errors instead of throwing in command handlers installExtension, uninstallExtension, upgradeExtension, and searchExtensions can throw on invalid input or missing extensions. The factory only converts { error: { code } } return values to clean user-facing messages -- unhandled throws produce a raw Node.js stack trace. Wrap all four handler functions in try-catch and return handlerError() so errors are displayed as 'Error: ' with a non-zero exit code rather than crashing with a full stack trace. Fixes #312 --- src/extension/register.ts | 54 ++++++++++----- test/extension/register.test.ts | 117 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 test/extension/register.test.ts diff --git a/src/extension/register.ts b/src/extension/register.ts index 5ec017d..921074c 100644 --- a/src/extension/register.ts +++ b/src/extension/register.ts @@ -39,19 +39,27 @@ async function handleList (): Promise { })) as unknown as JsonValue } +function handlerError (code: string, err: unknown): JsonValue { + return { error: { code, message: err instanceof Error ? err.message : String(err) } } as unknown as JsonValue +} + async function handleInstall (parsed: { arg?: string }): Promise { const source = parsed.arg?.trim() if (source == null || source.length === 0) { return { error: { code: 'missing_source', message: 'A source is required. Use github:owner/repo or npm:package-name.' } } } - const entry = await installExtension(source) - return { - installed: true, - name: entry.name, - source: entry.source, - path: entry.path, - entrypoint: entry.entrypoint, - } as unknown as JsonValue + try { + const entry = await installExtension(source) + return { + installed: true, + name: entry.name, + source: entry.source, + path: entry.path, + entrypoint: entry.entrypoint, + } as unknown as JsonValue + } catch (err) { + return handlerError('install_failed', err) + } } async function handleRemove (parsed: { arg?: string }): Promise { @@ -59,24 +67,36 @@ async function handleRemove (parsed: { arg?: string }): Promise { if (name == null || name.length === 0) { return { error: { code: 'missing_name', message: 'An extension name is required.' } } } - await uninstallExtension(name) - return { removed: true, name } as unknown as JsonValue + try { + await uninstallExtension(name) + return { removed: true, name } as unknown as JsonValue + } catch (err) { + return handlerError('remove_failed', err) + } } async function handleUpgrade (parsed: { arg?: string }): Promise { const name = parsed.arg?.trim() - if (name != null && name.length > 0) { - const updated = await upgradeExtension(name) - return { upgraded: true, name: updated.name, source: updated.source, entrypoint: updated.entrypoint } as unknown as JsonValue + try { + if (name != null && name.length > 0) { + const updated = await upgradeExtension(name) + return { upgraded: true, name: updated.name, source: updated.source, entrypoint: updated.entrypoint } as unknown as JsonValue + } + const all = await upgradeAllExtensions() + return { upgraded: true, extensions: all.map((e) => ({ name: e.name, source: e.source })) } as unknown as JsonValue + } catch (err) { + return handlerError('upgrade_failed', err) } - const all = await upgradeAllExtensions() - return { upgraded: true, extensions: all.map((e) => ({ name: e.name, source: e.source })) } as unknown as JsonValue } async function handleSearch (parsed: { arg?: string }): Promise { const query = parsed.arg?.trim() - const results = await searchExtensions(query) - return results as unknown as JsonValue + try { + const results = await searchExtensions(query) + return results as unknown as JsonValue + } catch (err) { + return handlerError('search_failed', err) + } } // --------------------------------------------------------------------------- diff --git a/test/extension/register.test.ts b/test/extension/register.test.ts new file mode 100644 index 0000000..85353c1 --- /dev/null +++ b/test/extension/register.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { registerExtensionCommands } from '../../src/extension/register.ts' +import { _testSetRegistryPath } from '../../src/extension/store.ts' +import { _testSetExtensionsDir } from '../../src/extension/installer.ts' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function invoke (args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const { Command } = await import('commander') + const program = new Command() + program.exitOverride() + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }) + + const ext = registerExtensionCommands() + program.addCommand(ext as unknown as InstanceType) + + const stdout: string[] = [] + const stderr: string[] = [] + const origOut = process.stdout.write.bind(process.stdout) + const origErr = process.stderr.write.bind(process.stderr) + process.stdout.write = (chunk: unknown) => { stdout.push(String(chunk)); return true } + process.stderr.write = (chunk: unknown) => { stderr.push(String(chunk)); return true } + + let exitCode = 0 + const origExit = process.exitCode + try { + await program.parseAsync(['node', 'elastic', 'extension', ...args]) + } catch { + // commander exitOverride throws on error + } finally { + process.stdout.write = origOut + process.stderr.write = origErr + exitCode = (process.exitCode as number | undefined) ?? 0 + process.exitCode = origExit + } + + return { stdout: stdout.join(''), stderr: stderr.join(''), exitCode } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('extension register -- error handling', () => { + let tmpDir: string + let registryFile: string + + before(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'elastic-ext-register-test-')) + registryFile = join(tmpDir, 'extensions.json') + await writeFile(registryFile, '[]', 'utf-8') + _testSetRegistryPath(registryFile) + _testSetExtensionsDir(join(tmpDir, 'installs')) + }) + + after(async () => { + _testSetRegistryPath(undefined) + _testSetExtensionsDir(undefined) + await rm(tmpDir, { recursive: true, force: true }) + }) + + describe('install', () => { + it('returns structured error for invalid source instead of throwing', async () => { + const { stderr, exitCode } = await invoke(['install', 'not-a-valid/source/with/too/many/parts']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + assert.doesNotMatch(stderr, /at parseSource|at installExtension/) + }) + + it('returns structured error for bad github: prefix', async () => { + const { stderr, exitCode } = await invoke(['install', 'github:']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + assert.doesNotMatch(stderr, /at parseSource/) + }) + + it('returns structured error for bare single-segment source', async () => { + const { stderr, exitCode } = await invoke(['install', 'just-a-name']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + }) + }) + + describe('remove', () => { + it('returns structured error when extension is not installed', async () => { + // uninstallExtension itself does not throw for unknown names (rm --force), + // but an invalid name with path traversal chars will throw from the store + // validate step -- confirm no stack trace leaks + const { stderr, exitCode } = await invoke(['remove', 'nonexistent-ext']) + // remove of unknown name succeeds silently (no-op) + assert.equal(exitCode, 0) + }) + }) + + describe('upgrade', () => { + it('returns structured error when named extension is not installed', async () => { + const { stderr, exitCode } = await invoke(['upgrade', 'nonexistent']) + assert.equal(exitCode, 1) + assert.match(stderr, /Error:/) + assert.doesNotMatch(stderr, /at upgradeExtension/) + }) + }) +})