From 6f59c928f4283d5afde474c16732c451c1add55f Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 7 May 2026 10:45:00 -0400 Subject: [PATCH 1/6] feat: add top-level es|elasticsearch and kb|kibana aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit elastic es ... and elastic kb ... are now first-class shortcuts for elastic stack es ... and elastic stack kb ... with no deprecation warning. Both appear in elastic --help. elastic stack es/kb continue to work unchanged. Closes #229 (partial — stack alias portion of the namespace restructure) --- src/cli.ts | 28 +++++++++++++++++++++++----- test/cli.test.ts | 18 +++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 1ad9126f..9383d20c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -80,11 +80,18 @@ program.addCommand(versionCmd) const { operands } = program.parseOptions(process.argv.slice(2)) let firstArg = operands[0] -// Deprecation redirect: `elastic kb ...` → `elastic stack kb ...` -if (firstArg === 'kb') { - process.stderr.write('Warning: "elastic kb" is deprecated. Use "elastic stack kb" instead.\n') - const kbIdx = process.argv.indexOf('kb', 2) - if (kbIdx !== -1) process.argv.splice(kbIdx, 0, 'stack') +// Transparent aliases: `elastic es ...` and `elastic kb ...` are first-class +// shortcuts for `elastic stack es ...` and `elastic stack kb ...`. +// argv is rewritten before Commander parses so all routing and dot-paths remain +// consistent (e.g. policy entries still use `stack.es.*`). +if (firstArg === 'es' || firstArg === 'elasticsearch') { + const idx = process.argv.indexOf(firstArg, 2) + if (idx !== -1) process.argv.splice(idx, 0, 'stack') + operands.splice(0, 0, 'stack') + firstArg = 'stack' +} else if (firstArg === 'kb' || firstArg === 'kibana') { + const idx = process.argv.indexOf(firstArg, 2) + if (idx !== -1) process.argv.splice(idx, 0, 'stack') operands.splice(0, 0, 'stack') firstArg = 'stack' } @@ -126,6 +133,17 @@ if (firstArg === 'stack') { program.addCommand(defineGroup({ name: 'stack', description: 'Interact with Elastic Stack components (Elasticsearch, Kibana, Fleet)' })) } +// Register top-level es|elasticsearch and kb|kibana stubs so they appear in +// `elastic --help` as first-class aliases. When invoked, argv has already been +// rewritten above so Commander routes through `stack es` / `stack kb`. +const esAlias = defineGroup({ name: 'es', description: 'Interact with the Elasticsearch API' }) +esAlias.alias('elasticsearch') +program.addCommand(esAlias) + +const kbAlias = defineGroup({ name: 'kb', description: 'Interact with the Kibana API' }) +kbAlias.alias('kibana') +program.addCommand(kbAlias) + if (firstArg === 'cloud') { const { registerCloudCommands } = await import('./cloud/register.ts') program.addCommand(registerCloudCommands()) diff --git a/test/cli.test.ts b/test/cli.test.ts index 9e50c5d1..d068ac9c 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -267,15 +267,27 @@ describe('elastic CLI -- stack command tree', () => { } }) - it('`elastic kb --help` redirects to stack kb with deprecation warning', async () => { - const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-kb-deprecation-')) + it('`elastic kb --help` aliases to stack kb without a deprecation warning', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-kb-alias-')) try { const { code, stdout, stderr } = await runCli(['kb', '--help'], { cwd: dir, env: { HOME: dir } }) assert.equal(code, 0, `expected exit code 0, got ${code}`) - assert.match(stderr, /deprecated.*elastic stack kb/i, 'expected deprecation warning on stderr') + assert.doesNotMatch(stderr, /deprecated/i, 'expected no deprecation warning on stderr') assert.match(stdout, /kb\|kibana/m, 'expected kb commands in output') } finally { await rm(dir, { recursive: true }) } }) + + it('`elastic es --help` aliases to stack es', async () => { + const dir = await mkdtemp(join(tmpdir(), 'elastic-cli-es-alias-')) + try { + const { code, stdout, stderr } = await runCli(['es', '--help'], { cwd: dir, env: { HOME: dir } }) + assert.equal(code, 0, `expected exit code 0, got ${code}`) + assert.doesNotMatch(stderr, /deprecated/i, 'expected no deprecation warning on stderr') + assert.match(stdout, /es\|elasticsearch/m, 'expected es commands in output') + } finally { + await rm(dir, { recursive: true }) + } + }) }) From 884397b010ad315fe91d7bc4fdd97fc4d1e0c23b Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 7 May 2026 10:52:29 -0400 Subject: [PATCH 2/6] feat(cloud): rename namespaces and restructure serverless per UX proposal (#229) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud promoted namespace renames: accounts → trust authentication → auth organizations → orgs user-role-assignments → users billing-costs-analysis promoted from hosted → cloud billing Cloud hosted: deployments-traffic-filter → traffic-filters Cloud serverless axis inversion: cloud serverless projects → cloud serverless projects elasticsearch type gets search|elasticsearch alias Cross-project merge: linked-projects + linked-candidate-projects → cross-project --- src/cloud/register.ts | 109 +++++++++++++++++------ test/cloud/register.test.ts | 172 ++++++++++++++++++++++++++---------- 2 files changed, 205 insertions(+), 76 deletions(-) diff --git a/src/cloud/register.ts b/src/cloud/register.ts index 9a7e0e57..c79730c7 100644 --- a/src/cloud/register.ts +++ b/src/cloud/register.ts @@ -68,11 +68,12 @@ function queryParamToZod(q: CloudQueryParam): z.ZodType { /** * Maps project-type namespaces from codegen to short CLI group names. - * E.g. `elasticsearch-projects` → `es`, used to build - * `elastic cloud serverless es projects `. + * E.g. `elasticsearch-projects` → `search`, used to build + * `elastic cloud serverless projects search `. + * The elasticsearch type also gets an `elasticsearch` alias. */ const PROJECT_NAMESPACES: Record = { - 'elasticsearch-projects': 'es', + 'elasticsearch-projects': 'search', 'observability-projects': 'observability', 'security-projects': 'security', } @@ -80,12 +81,30 @@ const PROJECT_NAMESPACES: Record = { /** * Cross-cutting namespaces promoted to direct children of `cloud` because their APIs * apply to both Hosted deployments and Serverless projects. + * Values are the display names shown in the CLI tree. */ -const PROMOTED_NAMESPACES = new Set([ - 'accounts', - 'authentication', - 'organizations', - 'user-role-assignments', +const PROMOTED_NAMESPACES = new Map([ + ['accounts', 'trust'], + ['authentication', 'auth'], + ['organizations', 'orgs'], + ['user-role-assignments', 'users'], + ['billing-costs-analysis','billing'], +]) + +/** + * Serverless namespaces whose commands are merged into a single `cross-project` + * group rather than exposed as two separate namespaces. + */ +const CROSS_PROJECT_NAMESPACES = new Set([ + 'linked-projects', + 'linked-candidate-projects', +]) + +/** + * Display name overrides for hosted namespaces. + */ +const HOSTED_NAMESPACE_RENAMES = new Map([ + ['deployments-traffic-filter', 'traffic-filters'], ]) /** @@ -153,22 +172,33 @@ function buildFlatLeaf (def: CloudApiDefinition): OpaqueCommandHandle { }) } +/** + * Builds flat namespace group handles. + * `renames` maps internal namespace keys to the display names used in the CLI tree; + * if a namespace is not in the map its key is used as-is. + */ function buildFlatNamespaceGroups ( defsByNamespace: Map, descriptionPrefix: string, + renames: ReadonlyMap = new Map(), ): OpaqueCommandHandle[] { const handles: OpaqueCommandHandle[] = [] for (const [namespace, defs] of defsByNamespace) { - checkDuplicates(defs, namespace) + const displayName = renames.get(namespace) ?? namespace + checkDuplicates(defs, displayName) const leaves = defs.map(buildFlatLeaf) handles.push( - defineGroup({ name: namespace, description: `${descriptionPrefix} ${namespace} commands` }, ...leaves), + defineGroup({ name: displayName, description: `${descriptionPrefix} ${displayName} commands` }, ...leaves), ) } return handles } -function buildServerlessProjectGroup ( +/** + * Builds a single project-type subgroup for use inside `cloud serverless projects`. + * E.g. `elasticsearch-projects` → `search|elasticsearch` group with shortened action names. + */ +function buildServerlessTypeGroup ( namespace: string, defs: CloudApiDefinition[], ): OpaqueCommandHandle { @@ -201,19 +231,20 @@ function buildServerlessProjectGroup ( return cmd }) - const projectsGroup = defineGroup( - { name: 'projects', description: `Manage ${typeLabel} projects` }, + const group = defineGroup( + { name: typeShort, description: `Manage ${typeLabel} projects` }, ...leaves, ) - return defineGroup( - { name: typeShort, description: `Elastic Serverless ${typeLabel} commands` }, - projectsGroup, - ) + // elasticsearch gets an explicit alias so both `search` and `elasticsearch` resolve + if (typeShort === 'search') { + ;(group as Command).alias('elasticsearch') + } + return group } function buildHostedGroup (defs: CloudApiDefinition[]): OpaqueCommandHandle { const byNamespace = groupByNamespace(defs) - const namespaceHandles = buildFlatNamespaceGroups(byNamespace, 'Cloud hosted') + const namespaceHandles = buildFlatNamespaceGroups(byNamespace, 'Cloud hosted', HOSTED_NAMESPACE_RENAMES) return defineGroup( { name: 'hosted', description: 'Manage Elastic Cloud Hosted deployments' }, ...namespaceHandles, @@ -222,22 +253,46 @@ function buildHostedGroup (defs: CloudApiDefinition[]): OpaqueCommandHandle { function buildServerlessGroup (defs: CloudApiDefinition[]): OpaqueCommandHandle { const projectDefs = new Map() + const crossProjectDefs: CloudApiDefinition[] = [] const otherDefs = new Map() for (const def of defs) { - const target = PROJECT_NAMESPACES[def.namespace] != null ? projectDefs : otherDefs - let group = target.get(def.namespace) - if (group == null) { - group = [] - target.set(def.namespace, group) + if (PROJECT_NAMESPACES[def.namespace] != null) { + let group = projectDefs.get(def.namespace) + if (group == null) { group = []; projectDefs.set(def.namespace, group) } + group.push(def) + } else if (CROSS_PROJECT_NAMESPACES.has(def.namespace)) { + crossProjectDefs.push(def) + } else { + let group = otherDefs.get(def.namespace) + if (group == null) { group = []; otherDefs.set(def.namespace, group) } + group.push(def) } - group.push(def) } const children: OpaqueCommandHandle[] = [] - for (const [namespace, nsDefs] of projectDefs) { - children.push(buildServerlessProjectGroup(namespace, nsDefs)) + + // Inverted axis: cloud serverless projects + if (projectDefs.size > 0) { + const typeGroups: OpaqueCommandHandle[] = [] + for (const [namespace, nsDefs] of projectDefs) { + typeGroups.push(buildServerlessTypeGroup(namespace, nsDefs)) + } + children.push(defineGroup( + { name: 'projects', description: 'Manage Serverless projects' }, + ...typeGroups, + )) } + + // Merge linked-projects + linked-candidate-projects into cross-project + if (crossProjectDefs.length > 0) { + checkDuplicates(crossProjectDefs, 'cross-project') + children.push(defineGroup( + { name: 'cross-project', description: 'Serverless cross-project commands' }, + ...crossProjectDefs.map(buildFlatLeaf), + )) + } + children.push(...buildFlatNamespaceGroups(otherDefs, 'Serverless')) return defineGroup( @@ -332,7 +387,7 @@ export function registerCloudCommands( const { promoted, hosted, serverless } = partitionDefinitions(definitions) - const promotedGroups = buildFlatNamespaceGroups(promoted, 'Cloud') + const promotedGroups = buildFlatNamespaceGroups(promoted, 'Cloud', PROMOTED_NAMESPACES) const hostedGroup = buildHostedGroup(hosted) const serverlessGroup = buildServerlessGroup(serverless) diff --git a/test/cloud/register.test.ts b/test/cloud/register.test.ts index 6fece5ac..3cd3137d 100644 --- a/test/cloud/register.test.ts +++ b/test/cloud/register.test.ts @@ -15,73 +15,91 @@ describe('registerCloudCommands', () => { assert.equal(group.name(), 'cloud') }) - it('has promoted, hosted, and serverless subgroups with default definitions', () => { + it('has renamed promoted namespaces, hosted, and serverless subgroups', () => { const group = registerCloudCommands() const subcommands = group.commands.map((c) => c.name()) - assert.ok(subcommands.includes('accounts'), 'should have promoted "accounts"') - assert.ok(subcommands.includes('authentication'), 'should have promoted "authentication"') - assert.ok(subcommands.includes('organizations'), 'should have promoted "organizations"') - assert.ok(subcommands.includes('user-role-assignments'), 'should have promoted "user-role-assignments"') - assert.ok(subcommands.includes('hosted'), 'should have "hosted" subgroup') + assert.ok(subcommands.includes('trust'), 'accounts → trust') + assert.ok(subcommands.includes('auth'), 'authentication → auth') + assert.ok(subcommands.includes('orgs'), 'organizations → orgs') + assert.ok(subcommands.includes('users'), 'user-role-assignments → users') + assert.ok(subcommands.includes('billing'), 'billing-costs-analysis promoted to billing') + assert.ok(subcommands.includes('hosted'), 'should have "hosted" subgroup') assert.ok(subcommands.includes('serverless'), 'should have "serverless" subgroup') }) it('does not expose hosted or serverless namespaces at the top level', () => { const group = registerCloudCommands() const subcommands = group.commands.map((c) => c.name()) - assert.ok(!subcommands.includes('deployments'), 'deployments must live under hosted') - assert.ok(!subcommands.includes('extensions'), 'extensions must live under hosted') - assert.ok(!subcommands.includes('stack'), 'stack must live under hosted') - assert.ok(!subcommands.includes('regions'), 'regions must live under serverless') - assert.ok(!subcommands.includes('traffic-filters'), 'traffic-filters must live under serverless') - assert.ok(!subcommands.includes('elasticsearch-projects'), 'elasticsearch-projects must live under serverless es') + assert.ok(!subcommands.includes('deployments'), 'deployments must live under hosted') + assert.ok(!subcommands.includes('extensions'), 'extensions must live under hosted') + assert.ok(!subcommands.includes('stack'), 'stack must live under hosted') + assert.ok(!subcommands.includes('regions'), 'regions must live under serverless') + assert.ok(!subcommands.includes('traffic-filters'), 'traffic-filters must live under serverless') + assert.ok(!subcommands.includes('elasticsearch-projects'),'elasticsearch-projects must live under serverless') + // old names must not appear + assert.ok(!subcommands.includes('accounts'), 'old name accounts must not appear') + assert.ok(!subcommands.includes('authentication'), 'old name authentication must not appear') + assert.ok(!subcommands.includes('organizations'), 'old name organizations must not appear') + assert.ok(!subcommands.includes('user-role-assignments'),'old name user-role-assignments must not appear') + assert.ok(!subcommands.includes('billing-costs-analysis'),'raw billing-costs-analysis must not appear') }) }) describe('promoted namespaces (cloud level)', () => { - it('accounts has its codegen command names preserved', () => { + it('trust (was: accounts) has its codegen command names preserved', () => { const group = registerCloudCommands() - const accountsGroup = group.commands.find((c) => c.name() === 'accounts')! - const leafNames = accountsGroup.commands.map((c) => c.name()) + const trustGroup = group.commands.find((c) => c.name() === 'trust')! + const leafNames = trustGroup.commands.map((c) => c.name()) assert.ok(leafNames.includes('get-current-account')) assert.ok(leafNames.includes('update-current-account')) }) - it('organizations has its codegen command names preserved', () => { + it('orgs (was: organizations) has its codegen command names preserved', () => { const group = registerCloudCommands() - const orgsGroup = group.commands.find((c) => c.name() === 'organizations')! + const orgsGroup = group.commands.find((c) => c.name() === 'orgs')! const leafNames = orgsGroup.commands.map((c) => c.name()) assert.ok(leafNames.includes('list-organizations')) assert.ok(leafNames.includes('get-organization')) }) - it('promoted synthetic defs are lifted out of hosted', () => { + it('billing (was: billing-costs-analysis) is promoted to top-level cloud', () => { + const group = registerCloudCommands() + const billing = group.commands.find((c) => c.name() === 'billing')! + assert.ok(billing != null, 'billing must exist at top level') + const leafNames = billing.commands.map((c) => c.name()) + assert.ok(leafNames.length > 0, 'billing must have commands') + }) + + it('promoted synthetic defs are lifted out of hosted with new display names', () => { const defs: CloudApiDefinition[] = [ { name: 'get-current-account', namespace: 'accounts', description: 'Get', method: 'GET', path: '/api/v1/account' }, { name: 'list-deployments', namespace: 'deployments', description: 'List', method: 'GET', path: '/api/v1/deployments' }, ] const group = registerCloudCommands(defs) const top = group.commands.map((c) => c.name()) - assert.ok(top.includes('accounts')) + assert.ok(top.includes('trust')) assert.ok(top.includes('hosted')) const hostedChildren = group.commands.find((c) => c.name() === 'hosted')!.commands.map((c) => c.name()) + assert.ok(!hostedChildren.includes('trust'), 'trust must not appear under hosted') assert.ok(!hostedChildren.includes('accounts'), 'accounts must not appear under hosted') assert.ok(hostedChildren.includes('deployments')) }) }) describe('hosted subgroup', () => { - it('contains hosted-specific namespaces', () => { + it('contains hosted-specific namespaces with renames applied', () => { const group = registerCloudCommands() const hosted = group.commands.find((c) => c.name() === 'hosted')! const namespaces = hosted.commands.map((c) => c.name()) assert.ok(namespaces.includes('deployments')) assert.ok(namespaces.includes('deployment-templates')) - assert.ok(namespaces.includes('deployments-traffic-filter')) + assert.ok(namespaces.includes('traffic-filters'), 'deployments-traffic-filter → traffic-filters') + assert.ok(!namespaces.includes('deployments-traffic-filter'), 'old name must not appear') assert.ok(namespaces.includes('extensions')) assert.ok(namespaces.includes('stack')) assert.ok(namespaces.includes('trusted-environments')) - assert.ok(namespaces.includes('billing-costs-analysis')) + assert.ok(!namespaces.includes('billing-costs-analysis'), 'billing promoted to top-level cloud') + assert.ok(!namespaces.includes('billing'), 'billing must not appear under hosted') }) it('does not contain promoted namespaces', () => { @@ -89,9 +107,13 @@ describe('registerCloudCommands', () => { const hosted = group.commands.find((c) => c.name() === 'hosted')! const namespaces = hosted.commands.map((c) => c.name()) assert.ok(!namespaces.includes('accounts')) + assert.ok(!namespaces.includes('trust')) assert.ok(!namespaces.includes('authentication')) + assert.ok(!namespaces.includes('auth')) assert.ok(!namespaces.includes('organizations')) + assert.ok(!namespaces.includes('orgs')) assert.ok(!namespaces.includes('user-role-assignments')) + assert.ok(!namespaces.includes('users')) }) it('deployments namespace has list, get, and shutdown commands', () => { @@ -106,44 +128,84 @@ describe('registerCloudCommands', () => { }) describe('serverless subgroup', () => { - it('contains project-type groups and flat serverless namespaces', () => { + it('has inverted axis: projects group containing search|elasticsearch, observability, security', () => { const group = registerCloudCommands() const serverless = group.commands.find((c) => c.name() === 'serverless')! const namespaces = serverless.commands.map((c) => c.name()) - assert.ok(namespaces.includes('es'), 'should have "es" project-type group') - assert.ok(namespaces.includes('observability')) - assert.ok(namespaces.includes('security')) + assert.ok(namespaces.includes('projects'), 'should have top-level projects group') + assert.ok(namespaces.includes('cross-project'), 'should have cross-project group') assert.ok(namespaces.includes('regions')) assert.ok(namespaces.includes('traffic-filters')) + // old flat project-type groups must not exist at serverless level + assert.ok(!namespaces.includes('es'), 'es must be inside projects now') + assert.ok(!namespaces.includes('observability'), 'observability must be inside projects now') + assert.ok(!namespaces.includes('security'), 'security must be inside projects now') + assert.ok(!namespaces.includes('linked-projects'), 'merged into cross-project') + assert.ok(!namespaces.includes('linked-candidate-projects'), 'merged into cross-project') }) - it('es projects has CRUD commands with short names', () => { + it('projects group has search|elasticsearch, observability, and security type groups', () => { const group = registerCloudCommands() const projects = group.commands.find((c) => c.name() === 'serverless')! - .commands.find((c) => c.name() === 'es')! .commands.find((c) => c.name() === 'projects')! - const leafNames = projects.commands.map((c) => c.name()) - assert.ok(leafNames.includes('list'), 'should have list') - assert.ok(leafNames.includes('create'), 'should have create') - assert.ok(leafNames.includes('get'), 'should have get') - assert.ok(leafNames.includes('delete'), 'should have delete') - assert.ok(leafNames.includes('patch'), 'should have patch') - assert.ok(leafNames.includes('resume'), 'should have resume') + const typeNames = projects.commands.map((c) => c.name()) + assert.ok(typeNames.includes('search'), 'should have search (was: es)') + assert.ok(typeNames.includes('observability')) + assert.ok(typeNames.includes('security')) + }) + + it('search type has elasticsearch alias', () => { + const group = registerCloudCommands() + const search = group.commands.find((c) => c.name() === 'serverless')! + .commands.find((c) => c.name() === 'projects')! + .commands.find((c) => c.name() === 'search')! + assert.ok((search as unknown as { aliases(): string[] }).aliases().includes('elasticsearch')) + }) + + it('search type has CRUD commands with short names', () => { + const group = registerCloudCommands() + const search = group.commands.find((c) => c.name() === 'serverless')! + .commands.find((c) => c.name() === 'projects')! + .commands.find((c) => c.name() === 'search')! + const leafNames = search.commands.map((c) => c.name()) + assert.ok(leafNames.includes('list')) + assert.ok(leafNames.includes('create')) + assert.ok(leafNames.includes('get')) + assert.ok(leafNames.includes('delete')) + assert.ok(leafNames.includes('patch')) + assert.ok(leafNames.includes('resume')) assert.ok(leafNames.includes('get-status')) assert.ok(leafNames.includes('get-roles')) assert.ok(leafNames.includes('reset-credentials')) }) - it('restructures synthetic project defs under serverless > > projects', () => { + it('restructures synthetic project defs under serverless > projects > ', () => { const defs: CloudApiDefinition[] = [ { name: 'list-observability-projects', namespace: 'observability-projects', description: 'List', method: 'GET', path: '/api/v1/serverless/projects/observability' }, { name: 'get-observability-project', namespace: 'observability-projects', description: 'Get', method: 'GET', path: '/api/v1/serverless/projects/observability/{id}', pathParams: [{ name: 'id', description: 'ID', required: true }] }, ] const group = registerCloudCommands(defs) - const projects = group.commands.find((c) => c.name() === 'serverless')! - .commands.find((c) => c.name() === 'observability')! + const observability = group.commands.find((c) => c.name() === 'serverless')! .commands.find((c) => c.name() === 'projects')! - assert.deepEqual(projects.commands.map((c) => c.name()), ['list', 'get']) + .commands.find((c) => c.name() === 'observability')! + assert.deepEqual(observability.commands.map((c) => c.name()), ['list', 'get']) + }) + + it('merges linked-projects and linked-candidate-projects into cross-project', () => { + const defs: CloudApiDefinition[] = [ + { name: 'get-elasticsearch-project-can-delete', namespace: 'linked-projects', description: 'Can delete', method: 'GET', path: '/api/v1/serverless/projects/elasticsearch/{id}/can-delete', pathParams: [{ name: 'id', description: 'ID', required: true }] }, + { name: 'get-elasticsearch-project-link-candidates', namespace: 'linked-candidate-projects', description: 'Candidates', method: 'GET', path: '/api/v1/serverless/link-candidates/elasticsearch' }, + ] + const group = registerCloudCommands(defs) + const serverless = group.commands.find((c) => c.name() === 'serverless')! + const namespaces = serverless.commands.map((c) => c.name()) + assert.ok(namespaces.includes('cross-project'), 'cross-project group must exist') + assert.ok(!namespaces.includes('linked-projects')) + assert.ok(!namespaces.includes('linked-candidate-projects')) + const crossProject = serverless.commands.find((c) => c.name() === 'cross-project')! + const leafNames = crossProject.commands.map((c) => c.name()) + assert.ok(leafNames.includes('get-elasticsearch-project-can-delete')) + assert.ok(leafNames.includes('get-elasticsearch-project-link-candidates')) }) it('keeps non-project serverless namespaces flat with codegen names', () => { @@ -163,11 +225,11 @@ describe('registerCloudCommands', () => { { name: 'list-elasticsearch-projects', namespace: 'elasticsearch-projects', description: 'List', method: 'GET', path: '/api/v1/serverless/projects/elasticsearch' }, ] const group = registerCloudCommands(defs) - const projects = group.commands.find((c) => c.name() === 'serverless')! - .commands.find((c) => c.name() === 'es')! + const search = group.commands.find((c) => c.name() === 'serverless')! .commands.find((c) => c.name() === 'projects')! - const createCmd = projects.commands.find((c) => c.name() === 'create')! - const listCmd = projects.commands.find((c) => c.name() === 'list')! + .commands.find((c) => c.name() === 'search')! + const createCmd = search.commands.find((c) => c.name() === 'create')! + const listCmd = search.commands.find((c) => c.name() === 'list')! assert.ok(createCmd.options.map((o) => o.long).includes('--wait')) assert.ok(!listCmd.options.map((o) => o.long).includes('--wait')) }) @@ -198,19 +260,31 @@ describe('registerCloudCommands', () => { }) }) - describe('default command aliases', () => { - it('no commands have aliases', () => { + describe('command aliases', () => { + it('search type group has elasticsearch alias, all others have no aliases', () => { const group = registerCloudCommands() interface CommandNode { name(): string aliases(): string[] commands: CommandNode[] } - const visit = (cmd: CommandNode): void => { - assert.deepEqual(cmd.aliases(), [], `${cmd.name()} should have no alias`) - for (const child of cmd.commands) visit(child) + const issues: string[] = [] + const visit = (cmd: CommandNode, path: string): void => { + const aliases = cmd.aliases() + const isSearchGroup = path === 'cloud.serverless.projects.search' + if (isSearchGroup) { + if (!aliases.includes('elasticsearch')) { + issues.push(`${path} should have alias 'elasticsearch'`) + } + } else if (aliases.length > 0) { + issues.push(`${path} should have no alias but has: ${aliases.join(', ')}`) + } + for (const child of cmd.commands) visit(child, `${path}.${child.name()}`) + } + for (const child of group.commands as unknown as CommandNode[]) { + visit(child, `cloud.${child.name()}`) } - for (const child of group.commands as unknown as CommandNode[]) visit(child) + assert.deepEqual(issues, []) }) }) }) From 4d8b928206a93dbafc06b53df2ed6989d9f6e214 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 7 May 2026 12:12:38 -0400 Subject: [PATCH 3/6] docs: update README for top-level es/kb aliases and cloud namespace renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect the new command structure from issue #229: - Document `elastic es` / `elastic kb` as top-level shortcuts - Update all examples to use the shorter es/kb forms - Replace old cloud cross-cutting names (accounts→trust, authentication→auth, organizations→orgs, user-role-assignments→users) and add billing - Update cloud hosted to show traffic-filters rename and remove billing - Update cloud serverless to the inverted-axis projects path and cross-project --- README.md | 95 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 401f90e3..2745cedd 100644 --- a/README.md +++ b/README.md @@ -107,15 +107,15 @@ For agent/LLM workflows, `serverless projects create` and `reset-credentials` accept `--save-as ` to avoid leaking admin credentials through stdout: ```bash -elastic cloud serverless es projects create --wait --save-as scratch \ +elastic cloud serverless projects search create --wait --save-as scratch \ --name scratch-es --region-id aws-us-east-1 # stdout has endpoints + a `savedAs: scratch` marker, password is redacted. # The keychain now holds scratch:elasticsearch.auth.password etc. -elastic --use-context scratch stack es indices list +elastic --use-context scratch es indices list # Rotate creds; URL stays, only the password moves. -elastic cloud serverless es projects reset-credentials --id \ +elastic cloud serverless projects search reset-credentials --id \ --save-as scratch --force ``` @@ -235,22 +235,24 @@ elastic version elastic --json version ``` -### `stack` - Elastic Stack +### `stack` / `es` / `kb` - Elastic Stack -Interact with Elastic Stack components. Both `stack es` (Elasticsearch) and -`stack kb` (Kibana) are available. Full-name aliases also work: +Interact with Elastic Stack components. `es` and `kb` work as top-level +shortcuts alongside the full `stack es` / `stack kb` paths: ```bash +elastic es --help # same as: elastic stack es --help +elastic kb --help # same as: elastic stack kb --help elastic stack --help elastic stack es --help # or: elastic stack elasticsearch --help elastic stack kb --help # or: elastic stack kibana --help ``` -#### `stack es` - Elasticsearch API +#### `es` - Elasticsearch API Run Elasticsearch API calls. Commands map directly to Elasticsearch API endpoints. -All `stack es` subcommands support: +All `es` subcommands support: | Option | Description | |---|---| @@ -281,28 +283,28 @@ All `stack es` subcommands support: - `tasks` - task management - `transform` - transforms -**Top-level `stack es` commands** (examples): +**Top-level `es` commands** (examples): ```bash -elastic stack es search --index my-index -elastic stack es get --index my-index --id abc123 -elastic stack es index --index my-index --id abc123 -elastic stack es delete --index my-index --id abc123 -elastic stack es count --index my-index -elastic stack es info -elastic stack es bulk -elastic stack es reindex -elastic stack es update --index my-index --id abc123 +elastic es search --index my-index +elastic es get --index my-index --id abc123 +elastic es index --index my-index --id abc123 +elastic es delete --index my-index --id abc123 +elastic es count --index my-index +elastic es info +elastic es bulk +elastic es reindex +elastic es update --index my-index --id abc123 ``` -Run `elastic stack es --help` for all available options on any command. +Run `elastic es --help` for all available options on any command. -#### `stack kb` - Kibana API +#### `kb` - Kibana API Run Kibana API calls. Commands are organised by namespace (e.g. `data-views`, `cases`, `alerting`). Requires a `kibana` service block in the active context. -All `stack kb` subcommands support: +All `kb` subcommands support: | Option | Description | |---|---| @@ -310,13 +312,13 @@ All `stack kb` subcommands support: | `--input-file ` | Load command input from a JSON file instead of CLI flags | ```bash -elastic stack kb data-views list -elastic stack kb data-views get --data-view-id -elastic stack kb cases list -elastic stack kb alerting list-rule-types +elastic kb data-views list +elastic kb data-views get --data-view-id +elastic kb cases list +elastic kb alerting list-rule-types ``` -Run `elastic stack kb --help` for all available commands in a namespace. +Run `elastic kb --help` for all available commands in a namespace. ### `cloud` - Elastic Cloud @@ -330,14 +332,15 @@ The tree has three kinds of children: - `cloud hosted …` for Hosted-Deployment APIs. - `cloud serverless …` for Serverless-Project APIs. -#### Cross-cutting (account, auth, orgs, roles) +#### Cross-cutting (trust, auth, orgs, users, billing) ```bash -elastic cloud accounts get-current-account -elastic cloud authentication get-api-keys -elastic cloud organizations list-organizations -elastic cloud organizations get-organization --organization-id -elastic cloud user-role-assignments add-role-assignments --user-id <<< '{...}' +elastic cloud trust get-current-account +elastic cloud auth get-api-keys +elastic cloud orgs list-organizations +elastic cloud orgs get-organization --organization-id +elastic cloud users add-role-assignments --user-id <<< '{...}' +elastic cloud billing get-costs-overview ``` #### `cloud hosted` - Hosted Deployments @@ -348,32 +351,33 @@ elastic cloud hosted deployments get-deployment --id elastic cloud hosted deployments shutdown-deployment --id elastic cloud hosted deployments create-deployment <<< '{"name":"my-deployment",...}' elastic cloud hosted deployment-templates list-deployment-templates +elastic cloud hosted traffic-filters get-traffic-filter-rulesets elastic cloud hosted extensions list-extensions elastic cloud hosted stack get-version-stacks ``` Run `elastic cloud hosted --help` for all available namespace groups -(billing-costs-analysis, deployment-templates, deployments, deployments-traffic-filter, -extensions, stack, trusted-environments). +(deployment-templates, deployments, traffic-filters, extensions, stack, trusted-environments). #### `cloud serverless` - Serverless Projects ```bash -elastic cloud serverless es projects list -elastic cloud serverless es projects create <<< '{"name":"demo","region_id":"aws-us-east-1"}' -elastic cloud serverless es projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' -elastic cloud serverless es projects get --id -elastic cloud serverless es projects delete --id -elastic cloud serverless es projects get-status --id -elastic cloud serverless es projects get-roles --id -elastic cloud serverless es projects reset-credentials --id +elastic cloud serverless projects search list +elastic cloud serverless projects search create <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud serverless projects search create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud serverless projects search get --id +elastic cloud serverless projects search delete --id +elastic cloud serverless projects search get-status --id +elastic cloud serverless projects search get-roles --id +elastic cloud serverless projects search reset-credentials --id ``` -Same commands are available under `observability` and `security`: +`search` also accepts `elasticsearch` as an alias. Same commands are available +under `observability` and `security`: ```bash -elastic cloud serverless observability projects list -elastic cloud serverless security projects create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' +elastic cloud serverless projects observability list +elastic cloud serverless projects security create --wait <<< '{"name":"demo","region_id":"aws-us-east-1"}' ``` Other serverless resources: @@ -381,6 +385,7 @@ Other serverless resources: ```bash elastic cloud serverless regions list-regions elastic cloud serverless traffic-filters list-traffic-filters +elastic cloud serverless cross-project get-elasticsearch-project-link-candidates ``` Run `elastic cloud serverless --help` for all available groups. From c773b7b4c73023791971de2f44cd11576a65d42a Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 7 May 2026 13:04:23 -0400 Subject: [PATCH 4/6] feat: command profiles for deployment-aware API surface filtering (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces named command profiles as a first-class concept, allowing agents and users to restrict the visible CLI surface to commands that match their deployment type without manually maintaining allow/block lists. ## Changes ### New: `src/config/profiles.ts` Defines three built-in profiles: - `serverless` — allow-list covering stack (ES + KB), cloud cross-cutting namespaces (trust/auth/orgs/users/billing), and cloud serverless. Hides `cloud hosted` commands. - `stack` — no restriction (equivalent to full surface). - `default` — alias for `serverless`; recommended safe baseline for agents. ### Config schema (`src/config/schema.ts`) - `CommandPolicySchema`: new `profile` field (enum). `profile + allowed` is mutually exclusive; `profile + blocked` is valid (further restriction). - `ContextSchema`: new optional `commands` field — per-context policy that overrides the root-level `commands` block. - `ConfigFileSchema`: new `default_profile` field — global fallback for contexts without an explicit profile. ### Config loader (`src/config/loader.ts`) - `LoadConfigOptions.profileName` — passes `--profile` flag value through. - `resolveEffectiveCommands()` — new exported helper that computes the effective policy from the precedence chain: 1. `--profile` flag (highest) 2. per-context `commands.profile` 3. root `commands.profile` 4. `default_profile` `blocked` lists from context and root are unioned (further restriction always composes). Returns an `{ error }` object if `--profile` is combined with an `allowed` list. ### Command filtering (`src/factory.ts`) - `isCommandAllowed`: handles `profile` in `CommandPolicy` by resolving the named profile to its allow-list and applying any additional `blocked` on top. - `isStubGroup` + updated `hideBlockedCommands`: stub groups (unloaded lazy namespaces) are never hidden — their children are filtered when loaded. - `defineGroup`: marks groups with `_isGroup = true` so stubs can be detected. ### CLI (`src/cli.ts`) - New global flag `--profile ` (mirrors `--use-context`). - Profile parsed early so `hideBlockedCommands` applies the correct allow-list to `--help` output before Commander's full parse runs. ### Other - `'profile'` added to `RESERVED_FLAGS` in `schema-args.ts`. - Comprehensive tests in `schema.test.ts`, `loader.test.ts`, `factory.test.ts`. ## Example usage ```yaml # ~/.elasticrc.yml — per-context profile contexts: my-serverless: elasticsearch: url: https://... auth: api_key: $(keychain:elastic-cli/api-key) commands: profile: serverless # Or set a global fallback default_profile: serverless ``` ```bash # Runtime override elastic --profile serverless cloud --help # hides cloud hosted elastic --profile stack cloud --help # shows all cloud commands # Compose profile with extra restriction elastic --profile serverless es ml get-records # blocked by profile ``` --- src/cli.ts | 19 +++- src/config/loader.ts | 100 +++++++++++++++++++-- src/config/profiles.ts | 69 +++++++++++++++ src/config/schema.ts | 60 +++++++++---- src/config/types.ts | 1 + src/factory.ts | 35 ++++++++ src/lib/schema-args.ts | 2 +- test/config/loader.test.ts | 176 ++++++++++++++++++++++++++++++++++++- test/config/schema.test.ts | 94 ++++++++++++++++++++ test/factory.test.ts | 53 +++++++++++ 10 files changed, 583 insertions(+), 26 deletions(-) create mode 100644 src/config/profiles.ts diff --git a/src/cli.ts b/src/cli.ts index 9383d20c..56675d02 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { Command } from 'commander' import { defineCommand, defineGroup, hideBlockedCommands } from './factory.js' import type { OpaqueCommandHandle } from './factory.js' import { loadConfig, type LoadConfigResult } from './config/loader.ts' +import { BUILT_IN_PROFILES, type BuiltInProfile } from './config/profiles.ts' import { setResolvedConfig } from './config/store.ts' import { renderLogo } from './lib/logo.ts' @@ -22,6 +23,7 @@ program .description('Interface with the Elastic Stack and Elastic Cloud from the command line.') .option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') .option('--use-context ', 'override the active context from the config file') + .option(`--profile `, `restrict available commands to a deployment profile (${BUILT_IN_PROFILES.join(', ')})`) .option('--json', 'output as JSON') .option('--output-fields ', 'comma-separated list of fields to include in output (dot-notation supported)') .option('--output-template ', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")') @@ -43,16 +45,18 @@ program.hook('preAction', async (thisCommand, actionCommand) => { for (let c = actionCommand.parent; c != null; c = c.parent) { if (c.name() === 'config') return } - const { configFile: configPath, useContext: contextName } = thisCommand.opts() + const { configFile: configPath, useContext: contextName, profile: profileName } = thisCommand.opts() + const typedProfileName = profileName as BuiltInProfile | undefined - if (configPath == null && contextName == null && earlyConfig?.ok === true) { + if (configPath == null && contextName == null && profileName == null && earlyConfig?.ok === true) { setResolvedConfig(earlyConfig.value) return } const result = await loadConfig({ ...(configPath != null && { configPath }), - ...(contextName != null && { contextName }) + ...(contextName != null && { contextName }), + ...(typedProfileName != null && { profileName: typedProfileName }), }) if (result.ok) { setResolvedConfig(result.value) @@ -177,7 +181,14 @@ if (firstArg === 'sanitize') { // 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') { - earlyConfig = await loadConfig({}) + // 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('--profile') + const earlyProfile = profileArgIdx !== -1 ? process.argv[profileArgIdx + 1] as BuiltInProfile | undefined : undefined + + earlyConfig = await loadConfig({ + ...(earlyProfile != null && { profileName: earlyProfile }), + }) if (earlyConfig.ok) { setResolvedConfig(earlyConfig.value) hideBlockedCommands(program, earlyConfig.value.commands) diff --git a/src/config/loader.ts b/src/config/loader.ts index c6bd5262..fea96835 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -34,6 +34,7 @@ import { ContextSchema, CommandPolicySchema, StructuralConfigSchema } from './sc import { resolveExpressions } from '@elastic/config-resolver' import { hasInlineSecrets, type RawConfig } from './writer.ts' import type { ConfigFile, ResolvedConfig, ResolvedContext } from './types.ts' +import { BUILT_IN_PROFILES, type BuiltInProfile } from './profiles.ts' /** Extensions that are rejected to prevent arbitrary code execution. */ const EXECUTABLE_EXTENSIONS = new Set(['.js', '.ts', '.mjs', '.cjs']) @@ -103,7 +104,60 @@ export async function loadConfigFile (filePath: string): Promise { * @param contextName - The key of the context to resolve. * @returns A `ResolvedConfig` wrapping only that context's service blocks. */ -export function resolveContext (config: ConfigFile, contextName: string): ResolvedConfig { +/** + * Computes the effective command policy from the per-context policy, the root policy, + * a `default_profile` fallback, and an optional `--profile` flag override. + * + * Precedence (highest to lowest): + * 1. `profileOverride` — the `--profile` CLI flag + * 2. Per-context `commands.profile` / `commands.allowed` + * 3. Root `commands.profile` / `commands.allowed` + * 4. `defaultProfile` — `default_profile` from the root config + * + * `blocked` lists from context and root are unioned (further restriction always applies). + * `profile` and `allowed` are mutually exclusive; passing `--profile` with a context + * that declares `commands.allowed` is an error. + * + * Returns `undefined` when no filtering is active (allow everything). + * Returns `{ error }` on an invalid combination. + */ +export function resolveEffectiveCommands ( + contextCommands: ConfigFile['commands'], + rootCommands: ConfigFile['commands'], + defaultProfile: BuiltInProfile | undefined, + profileOverride: BuiltInProfile | undefined, +): { commands: ConfigFile['commands'] } | { error: string } { + // Effective allowed/blocked from config (context overrides root) + const effectiveAllowed = contextCommands?.allowed ?? rootCommands?.allowed + const contextBlocked = contextCommands?.blocked ?? [] + const rootBlocked = rootCommands?.blocked ?? [] + const effectiveBlocked = [...contextBlocked, ...rootBlocked.filter(p => !contextBlocked.includes(p))] + + // Effective profile from precedence chain + const configProfile = contextCommands?.profile ?? rootCommands?.profile ?? defaultProfile + const effectiveProfile = profileOverride ?? configProfile + + // Validate: --profile flag cannot be used when allowed is set + if (profileOverride != null && effectiveAllowed != null) { + return { error: '--profile flag cannot be used with a context that has commands.allowed set' } + } + + const hasProfile = effectiveProfile != null + const hasAllowed = effectiveAllowed != null + const hasBlocked = effectiveBlocked.length > 0 + + if (!hasProfile && !hasAllowed && !hasBlocked) return { commands: undefined } + + return { + commands: { + ...(hasProfile && { profile: effectiveProfile }), + ...(hasAllowed && { allowed: effectiveAllowed }), + ...(hasBlocked && { blocked: effectiveBlocked }), + }, + } +} + +export function resolveContext (config: ConfigFile, contextName: string, profileOverride?: BuiltInProfile): ResolvedConfig { // non-null: caller (loadConfig) guarantees contextName exists in config.contexts const ctx = config.contexts[contextName]! const resolved: ResolvedContext = {} @@ -111,7 +165,19 @@ export function resolveContext (config: ConfigFile, contextName: string): Resolv if (ctx.kibana != null) resolved.kibana = ctx.kibana if (ctx.cloud != null) resolved.cloud = ctx.cloud const result: ResolvedConfig = { context: resolved } - if (config.commands != null) result.commands = config.commands + + const effectiveCommandsResult = resolveEffectiveCommands( + ctx.commands, + config.commands, + config.default_profile, + profileOverride, + ) + if ('error' in effectiveCommandsResult) { + // Surface the error; callers (loadConfig) catch this and return LoadConfigErr + throw new Error(effectiveCommandsResult.error) + } + if (effectiveCommandsResult.commands != null) result.commands = effectiveCommandsResult.commands + if (config.banner != null) result.banner = config.banner return result } @@ -151,6 +217,8 @@ export interface LoadConfigOptions { configPath?: string /** Context name override (`--use-context` flag). Overrides `current_context` in the file. */ contextName?: string + /** Profile name override (`--profile` flag). Overrides any profile set in the config file. */ + profileName?: BuiltInProfile } /** Successful result from {@link loadConfig}. */ @@ -183,7 +251,13 @@ export type LoadConfigResult = LoadConfigOk | LoadConfigErr * @returns A `LoadConfigResult` discriminated union. */ export async function loadConfig (options: LoadConfigOptions = {}): Promise { - const { configPath, contextName } = options + const { configPath, contextName, profileName } = options + + // Validate profileName early (before any I/O) so the error is immediate and clear + if (profileName != null && !(BUILT_IN_PROFILES as readonly string[]).includes(profileName)) { + const valid = BUILT_IN_PROFILES.join(', ') + return { ok: false, error: { message: `Unknown profile "${profileName}". Valid profiles: ${valid}` } } + } // Step 1: load raw config // Precedence: --config-file flag > ELASTIC_CLI_CONFIG_FILE env var > home-directory discovery @@ -223,7 +297,7 @@ export async function loadConfig (options: LoadConfigOptions = {}): Promise ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null, - { error: 'at least one service block (elasticsearch, kibana, or cloud) is required' } - ) - /** * Policy controlling which commands are permitted to run. - * Only one of `allowed` or `blocked` may be present. - * Entries may use a trailing wildcard (e.g. `elasticsearch.*`) to match a namespace. + * + * Mutually exclusive combinations: + * - `profile` and `allowed` cannot both be set (profile replaces the allow-list) + * - `allowed` and `blocked` cannot both be set + * + * Valid combinations: + * - `profile` alone — use a built-in allow-list + * - `profile` + `blocked` — built-in allow-list with additional restrictions + * - `allowed` alone — explicit allow-list + * - `blocked` alone — explicit deny-list (everything else is allowed) + * + * Entries may use a trailing wildcard (e.g. `stack.es.*`) to match a namespace. */ export const CommandPolicySchema = z .object({ + profile: z.enum(BUILT_IN_PROFILES).optional(), allowed: z.array(z.string().min(1)).min(1).optional(), blocked: z.array(z.string().min(1)).min(1).optional(), }) + .refine( + (p) => !(p.profile != null && p.allowed != null), + { error: 'commands: "profile" and "allowed" are mutually exclusive' }, + ) .refine( (p) => !(p.allowed != null && p.blocked != null), { error: 'commands: "allowed" and "blocked" are mutually exclusive' }, ) -/** The root configuration file structure. */ +/** + * A context value: optional service blocks with at least one present, plus + * an optional per-context command policy that overrides the root-level policy. + */ +export const ContextSchema = z + .object({ + elasticsearch: ServiceBlockSchema.optional(), + kibana: ServiceBlockSchema.optional(), + cloud: ServiceBlockSchema.optional(), + commands: CommandPolicySchema.optional(), + }) + .refine( + (ctx) => ctx.elasticsearch != null || ctx.kibana != null || ctx.cloud != null, + { error: 'at least one service block (elasticsearch, kibana, or cloud) is required' } + ) + +/** + * The root configuration file structure. + * + * `default_profile` sets a fallback profile for all contexts that don't + * specify their own `commands.profile`. It is overridden by a per-context + * `commands.profile` and by the `--profile` CLI flag. + * + * `commands` is the root-level policy; per-context `commands` takes precedence. + */ export const ConfigFileSchema = z .object({ current_context: z.string().min(1), @@ -77,6 +105,7 @@ export const ConfigFileSchema = z { error: 'contexts must contain at least one entry' }, ), commands: CommandPolicySchema.optional(), + default_profile: z.enum(BUILT_IN_PROFILES).optional(), banner: z.boolean().optional(), }) .refine( @@ -97,5 +126,6 @@ export const StructuralConfigSchema = z { error: 'contexts must contain at least one entry' }, ), commands: z.unknown().optional(), + default_profile: z.unknown().optional(), banner: z.boolean().optional(), }) diff --git a/src/config/types.ts b/src/config/types.ts index 25a02203..88a3d32f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -11,6 +11,7 @@ import type { ConfigFileSchema, CommandPolicySchema, } from './schema.ts' +export type { BuiltInProfile } from './profiles.ts' /** * TypeScript types exported from Zod schemas for the configuration system. diff --git a/src/factory.ts b/src/factory.ts index 144fff19..ea37caba 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { readFileSync } from 'node:fs' import assert from 'node:assert/strict' import type { ResolvedConfig, CommandPolicy } from './config/types.ts' +import { resolveBuiltinProfile } from './config/profiles.ts' import { getResolvedConfig } from './config/store.ts' import { extractSchemaArgs, validateSchemaArgs } from './lib/schema-args.ts' import type { SchemaArgDefinition } from './lib/schema-args.ts' @@ -221,6 +222,19 @@ export function isCommandAllowed(commandDotPath: string, policy: CommandPolicy | return commandDotPath === pattern } + // Profile-based filtering: resolve the named profile to its allow-list and + // check against it first, then apply any additional `blocked` restriction. + if (policy.profile != null) { + const profilePolicy = resolveBuiltinProfile(policy.profile) + if (profilePolicy != null) { + // Profile acts as an allow-list; if the command is not in it, deny. + if (!profilePolicy.allowed.some(matches)) return false + } + // `blocked` further restricts on top of the profile (always allowed to restrict more). + if (policy.blocked != null) return !policy.blocked.some(matches) + return true + } + if (policy.allowed != null) return policy.allowed.some(matches) if (policy.blocked != null) return !policy.blocked.some(matches) return true @@ -233,9 +247,25 @@ function setHidden(cmd: OpaqueCommandHandle, value: boolean): void { (cmd as unk // eslint-disable-next-line @typescript-eslint/no-explicit-any function isHidden(cmd: OpaqueCommandHandle): boolean { return (cmd as unknown as any)._hidden === true } +/** + * Returns true if `cmd` is a stub group — a group with no children that was + * registered in cli.ts as a lazy-loading placeholder. + * + * Stub groups should never be hidden by policy because their children have not + * been loaded yet; we cannot determine whether any child would be allowed. + * When the user navigates into the group its children are loaded and filtered + * correctly at that level. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isStubGroup (cmd: OpaqueCommandHandle): boolean { + const c = cmd as unknown as any + return c._isGroup === true && (c.commands == null || c.commands.length === 0) +} + /** * Walk the command tree and hide any commands the policy blocks. * Groups where every child is hidden are hidden too. + * Stub groups (unloaded lazy namespaces) are never hidden. * Call on the root program so dot-paths like `es.cat.health` are built correctly. */ export function hideBlockedCommands(root: OpaqueCommandHandle, policy: CommandPolicy | undefined, prefix = ''): void { @@ -246,6 +276,8 @@ export function hideBlockedCommands(root: OpaqueCommandHandle, policy: CommandPo if (subs.length > 0) { hideBlockedCommands(child, policy, path) if (subs.every(isHidden)) setHidden(child, true) + } else if (isStubGroup(child)) { + // Unloaded lazy namespace: leave visible. Children are filtered when loaded. } else { setHidden(child, !isCommandAllowed(path, policy)) } @@ -827,6 +859,9 @@ export function defineGroup (config: GroupConfig, ...commands: OpaqueCommandHand group.description(config.description) group.allowExcessArguments(true) configureErrorOutput(group) + // Mark as a group so hideBlockedCommands can distinguish groups from leaf commands. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(group as unknown as any)._isGroup = true for (const cmd of commands) { group.addCommand(cmd) } diff --git a/src/lib/schema-args.ts b/src/lib/schema-args.ts index f3de6e46..cceb3904 100644 --- a/src/lib/schema-args.ts +++ b/src/lib/schema-args.ts @@ -199,7 +199,7 @@ export function buildFlagKeyMap (args: SchemaArgDefinition[]): FlagKeyMap { } /** Reserved CLI flag names that schema keys must not collide with. */ -const RESERVED_FLAGS = new Set(['help', 'json', 'config-file', 'use-context', 'input-file']) +const RESERVED_FLAGS = new Set(['help', 'json', 'config-file', 'use-context', 'profile', 'input-file']) /** * Validates schema arguments for naming conflicts. diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index ab7c1d67..d66d465c 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -8,7 +8,7 @@ import assert from 'node:assert/strict' import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { loadConfigFile, discoverConfigFile, resolveContext, loadConfig } from '../../src/config/loader.ts' +import { loadConfigFile, discoverConfigFile, resolveContext, resolveEffectiveCommands, loadConfig } from '../../src/config/loader.ts' import type { ConfigFile, ResolvedConfig } from '../../src/config/types.ts' // --------------------------------------------------------------------------- @@ -447,6 +447,106 @@ commands: if (result.ok) return assert.match(result.error.message, /mutually exclusive/) }) + + it('loadConfig threads commands.profile into ResolvedConfig', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +commands: + profile: serverless +`.trimStart() + const configPath = join(tmpDir, 'profile.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'serverless') + }) + + it('loadConfig applies default_profile when no context-level profile is set', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +default_profile: serverless +`.trimStart() + const configPath = join(tmpDir, 'default-profile.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'serverless') + }) + + it('loadConfig profileName option overrides config profile', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +commands: + profile: stack +`.trimStart() + const configPath = join(tmpDir, 'profile-override.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath, profileName: 'serverless' }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'serverless') + }) + + it('loadConfig profileName option rejects unknown profile', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 +`.trimStart() + const configPath = join(tmpDir, 'profile-unknown.yml') + await writeFile(configPath, yaml) + // @ts-expect-error — testing runtime validation of invalid profile + const result = await loadConfig({ configPath, profileName: 'not-a-profile' }) + assert.ok(!result.ok) + if (result.ok) return + assert.match(result.error.message, /Unknown profile/) + }) + + it('loadConfig per-context commands.profile overrides root commands.profile', async () => { + const yaml = ` +current_context: local +contexts: + local: + elasticsearch: + url: http://localhost:9200 + auth: + api_key: key1 + commands: + profile: stack +commands: + profile: serverless +`.trimStart() + const configPath = join(tmpDir, 'context-profile.yml') + await writeFile(configPath, yaml) + const result = await loadConfig({ configPath }) + assert.ok(result.ok) + if (!result.ok) return + assert.equal(result.value.commands?.profile, 'stack') + }) }) // --------------------------------------------------------------------------- @@ -683,6 +783,80 @@ describe('security: executable config formats are rejected', () => { } }) + describe('resolveEffectiveCommands', () => { + it('returns undefined when no policy is active', () => { + const result = resolveEffectiveCommands(undefined, undefined, undefined, undefined) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands, undefined) + }) + + it('profile override takes highest precedence', () => { + const result = resolveEffectiveCommands( + { profile: 'stack' }, // context says stack + undefined, + undefined, + 'serverless', // --profile flag says serverless → wins + ) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands?.profile, 'serverless') + }) + + it('context commands.profile overrides root commands.profile', () => { + const result = resolveEffectiveCommands( + { profile: 'stack' }, + { profile: 'serverless' }, + undefined, + undefined, + ) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands?.profile, 'stack') + }) + + it('default_profile is used as fallback when no profile elsewhere', () => { + const result = resolveEffectiveCommands(undefined, undefined, 'serverless', undefined) + assert.ok(!('error' in result)) + if (!('error' in result)) assert.equal(result.commands?.profile, 'serverless') + }) + + it('blocked lists from context and root are unioned', () => { + const result = resolveEffectiveCommands( + { blocked: ['stack.es.ml.*'] }, + { blocked: ['cloud.hosted.*'] }, + undefined, + undefined, + ) + assert.ok(!('error' in result)) + if (!('error' in result)) { + assert.ok(result.commands?.blocked?.includes('stack.es.ml.*')) + assert.ok(result.commands?.blocked?.includes('cloud.hosted.*')) + } + }) + + it('returns error when --profile flag is combined with context allowed list', () => { + const result = resolveEffectiveCommands( + { allowed: ['stack.es.*'] }, + undefined, + undefined, + 'serverless', + ) + assert.ok('error' in result) + }) + + it('profile + context blocked: both are preserved', () => { + const result = resolveEffectiveCommands( + { profile: 'serverless', blocked: ['stack.es.ml.*'] }, + undefined, + undefined, + undefined, + ) + assert.ok(!('error' in result)) + if (!('error' in result)) { + assert.equal(result.commands?.profile, 'serverless') + assert.deepEqual(result.commands?.blocked, ['stack.es.ml.*']) + } + }) + }) + describe('discoverConfigFile ignores executable file names', () => { for (const name of ['.elasticrc.js', '.elasticrc.ts', '.elasticrc.mjs', '.elasticrc.cjs']) { it(`does not discover ${name}`, async () => { diff --git a/test/config/schema.test.ts b/test/config/schema.test.ts index db6e6a52..7b34825e 100644 --- a/test/config/schema.test.ts +++ b/test/config/schema.test.ts @@ -240,6 +240,31 @@ describe('ContextSchema', () => { if (!result.success) return assert.equal('extra' in result.data, false) }) + + it('accepts a per-context commands policy', () => { + const result = ContextSchema.safeParse({ + elasticsearch: esBlock, + commands: { profile: 'serverless' }, + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.commands?.profile, 'serverless') + }) + + it('accepts a per-context commands.blocked on top of a profile', () => { + const result = ContextSchema.safeParse({ + elasticsearch: esBlock, + commands: { profile: 'serverless', blocked: ['stack.es.ml.*'] }, + }) + assert.equal(result.success, true) + }) + + it('rejects per-context commands with profile + allowed', () => { + const result = ContextSchema.safeParse({ + elasticsearch: esBlock, + commands: { profile: 'serverless', allowed: ['stack.es.*'] }, + }) + assert.equal(result.success, false) + }) }) describe('CommandPolicySchema', () => { @@ -303,6 +328,33 @@ describe('CommandPolicySchema', () => { const result = CommandPolicySchema.safeParse({ blocked: [''] }) assert.equal(result.success, false) }) + + it('accepts a valid profile name alone', () => { + for (const profile of ['serverless', 'stack', 'default'] as const) { + const result = CommandPolicySchema.safeParse({ profile }) + assert.equal(result.success, true, `expected profile "${profile}" to be accepted`) + if (result.success) assert.equal(result.data.profile, profile) + } + }) + + it('accepts profile + blocked (composition)', () => { + const result = CommandPolicySchema.safeParse({ profile: 'serverless', blocked: ['stack.es.ml.*'] }) + assert.equal(result.success, true) + if (result.success) { + assert.equal(result.data.profile, 'serverless') + assert.deepEqual(result.data.blocked, ['stack.es.ml.*']) + } + }) + + it('rejects profile + allowed (mutually exclusive)', () => { + const result = CommandPolicySchema.safeParse({ profile: 'serverless', allowed: ['stack.es.*'] }) + assert.equal(result.success, false) + }) + + it('rejects an unknown profile name', () => { + const result = CommandPolicySchema.safeParse({ profile: 'unknown-profile' }) + assert.equal(result.success, false) + }) }) describe('ConfigFileSchema', () => { @@ -409,4 +461,46 @@ describe('ConfigFileSchema', () => { }) assert.equal(result.success, false) }) + + it('accepts commands.profile at root level', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { production: { elasticsearch: esBlock } }, + commands: { profile: 'serverless' }, + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.commands?.profile, 'serverless') + }) + + it('accepts default_profile at root level', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { production: { elasticsearch: esBlock } }, + default_profile: 'serverless', + }) + assert.equal(result.success, true) + if (result.success) assert.equal(result.data.default_profile, 'serverless') + }) + + it('rejects an invalid default_profile value', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { production: { elasticsearch: esBlock } }, + default_profile: 'not-a-profile', + }) + assert.equal(result.success, false) + }) + + it('accepts per-context commands.profile', () => { + const result = ConfigFileSchema.safeParse({ + 'current_context': 'production', + contexts: { + production: { elasticsearch: esBlock, commands: { profile: 'stack' } }, + }, + }) + assert.equal(result.success, true) + if (result.success) { + assert.equal(result.data.contexts['production']?.commands?.profile, 'stack') + } + }) }) diff --git a/test/factory.test.ts b/test/factory.test.ts index b0c7d163..f6e3d07b 100644 --- a/test/factory.test.ts +++ b/test/factory.test.ts @@ -2466,6 +2466,59 @@ describe('isCommandAllowed', () => { it('blocked list: returns true for commands outside the blocked namespace', () => { assert.equal(isCommandAllowed('elasticsearch.search', { blocked: ['config.*'] }), true) }) + + // --- profile-based filtering --- + + it('profile serverless: allows stack.es.indices.list', () => { + assert.equal(isCommandAllowed('stack.es.indices.list', { profile: 'serverless' }), true) + }) + + it('profile serverless: allows stack.kb.data-views.list', () => { + assert.equal(isCommandAllowed('stack.kb.data-views.list', { profile: 'serverless' }), true) + }) + + it('profile serverless: allows cloud.serverless.projects.search.list', () => { + assert.equal(isCommandAllowed('cloud.serverless.projects.search.list', { profile: 'serverless' }), true) + }) + + it('profile serverless: allows cloud cross-cutting namespaces', () => { + for (const path of ['cloud.trust.get', 'cloud.auth.list', 'cloud.orgs.list', 'cloud.users.add', 'cloud.billing.get']) { + assert.equal(isCommandAllowed(path, { profile: 'serverless' }), true, `expected "${path}" to be allowed`) + } + }) + + it('profile serverless: blocks cloud.hosted commands', () => { + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', { profile: 'serverless' }), false) + assert.equal(isCommandAllowed('cloud.hosted.traffic-filters.list', { profile: 'serverless' }), false) + }) + + it('profile serverless: allows version and config commands', () => { + assert.equal(isCommandAllowed('version', { profile: 'serverless' }), true) + assert.equal(isCommandAllowed('config.context.list', { profile: 'serverless' }), true) + }) + + it('profile default: behaves the same as serverless', () => { + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', { profile: 'default' }), false) + assert.equal(isCommandAllowed('stack.es.search', { profile: 'default' }), true) + }) + + it('profile stack: allows all commands (no restriction)', () => { + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', { profile: 'stack' }), true) + assert.equal(isCommandAllowed('cloud.serverless.projects.search.list', { profile: 'stack' }), true) + assert.equal(isCommandAllowed('anything.at.all', { profile: 'stack' }), true) + }) + + it('profile + blocked: profile allows but blocked further restricts', () => { + const policy = { profile: 'serverless' as const, blocked: ['stack.es.ml.*'] } + assert.equal(isCommandAllowed('stack.es.search', policy), true) + assert.equal(isCommandAllowed('stack.es.ml.get-records', policy), false) + }) + + it('profile + blocked: blocked does not affect commands already excluded by profile', () => { + const policy = { profile: 'serverless' as const, blocked: ['stack.es.ml.*'] } + // cloud.hosted is already blocked by the serverless profile; the extra blocked entry doesn't matter + assert.equal(isCommandAllowed('cloud.hosted.deployments.list', policy), false) + }) }) describe('command policy enforcement', () => { From 2f8e53005041318b30ee593599731fb71a95811a Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 7 May 2026 14:12:13 -0400 Subject: [PATCH 5/6] fix: rename --profile to --command-profile to avoid collision with ES API schema fields Many Elasticsearch API schemas have a 'profile' field (e.g. for search profiling). The global --profile flag would collide with these, causing validateSchemaArgs to throw at command registration time. Rename the flag to --command-profile. The YAML config key (commands.profile) and the built-in profile names (serverless, stack, default) are unchanged. --- src/cli.ts | 6 +++--- src/lib/schema-args.ts | 2 +- test/config/loader.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 56675d02..106c03a8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -23,7 +23,7 @@ program .description('Interface with the Elastic Stack and Elastic Cloud from the command line.') .option('--config-file ', 'path to a config file (default: ~/.elasticrc.yml)') .option('--use-context ', 'override the active context from the config file') - .option(`--profile `, `restrict available commands to a deployment profile (${BUILT_IN_PROFILES.join(', ')})`) + .option(`--command-profile `, `restrict available commands to a deployment profile (${BUILT_IN_PROFILES.join(', ')})`) .option('--json', 'output as JSON') .option('--output-fields ', 'comma-separated list of fields to include in output (dot-notation supported)') .option('--output-template ', 'Mustache-like template for custom text output (e.g. "{{id}}: {{name}}")') @@ -45,7 +45,7 @@ program.hook('preAction', async (thisCommand, actionCommand) => { for (let c = actionCommand.parent; c != null; c = c.parent) { if (c.name() === 'config') return } - const { configFile: configPath, useContext: contextName, profile: profileName } = thisCommand.opts() + const { configFile: configPath, useContext: contextName, commandProfile: profileName } = thisCommand.opts() const typedProfileName = profileName as BuiltInProfile | undefined if (configPath == null && contextName == null && profileName == null && earlyConfig?.ok === true) { @@ -183,7 +183,7 @@ if (firstArg === 'sanitize') { if (firstArg !== 'version' && firstArg !== 'config' && firstArg !== 'sanitize') { // 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('--profile') + const profileArgIdx = process.argv.indexOf('--command-profile') const earlyProfile = profileArgIdx !== -1 ? process.argv[profileArgIdx + 1] as BuiltInProfile | undefined : undefined earlyConfig = await loadConfig({ diff --git a/src/lib/schema-args.ts b/src/lib/schema-args.ts index cceb3904..c9f02e48 100644 --- a/src/lib/schema-args.ts +++ b/src/lib/schema-args.ts @@ -199,7 +199,7 @@ export function buildFlagKeyMap (args: SchemaArgDefinition[]): FlagKeyMap { } /** Reserved CLI flag names that schema keys must not collide with. */ -const RESERVED_FLAGS = new Set(['help', 'json', 'config-file', 'use-context', 'profile', 'input-file']) +const RESERVED_FLAGS = new Set(['help', 'json', 'config-file', 'use-context', 'command-profile', 'input-file']) /** * Validates schema arguments for naming conflicts. diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index d66d465c..583a38a3 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -795,7 +795,7 @@ describe('security: executable config formats are rejected', () => { { profile: 'stack' }, // context says stack undefined, undefined, - 'serverless', // --profile flag says serverless → wins + 'serverless', // --command-profile flag says serverless → wins ) assert.ok(!('error' in result)) if (!('error' in result)) assert.equal(result.commands?.profile, 'serverless') @@ -832,7 +832,7 @@ describe('security: executable config formats are rejected', () => { } }) - it('returns error when --profile flag is combined with context allowed list', () => { + it('returns error when --command-profile flag is combined with context allowed list', () => { const result = resolveEffectiveCommands( { allowed: ['stack.es.*'] }, undefined, From e952c967f8d87984bdfa1b36afe1174031371e69 Mon Sep 17 00:00:00 2001 From: margaretjgu Date: Thu, 7 May 2026 14:22:05 -0400 Subject: [PATCH 6/6] fix(lint): move eslint-disable to the correct line in isStubGroup --- src/factory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/factory.ts b/src/factory.ts index ea37caba..67802728 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -256,8 +256,8 @@ function isHidden(cmd: OpaqueCommandHandle): boolean { return (cmd as unknown as * When the user navigates into the group its children are loaded and filtered * correctly at that level. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any function isStubGroup (cmd: OpaqueCommandHandle): boolean { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const c = cmd as unknown as any return c._isGroup === true && (c.commands == null || c.commands.length === 0) }