diff --git a/.changeset/cli-hidden-commands.md b/.changeset/cli-hidden-commands.md new file mode 100644 index 0000000000..57d7468cc8 --- /dev/null +++ b/.changeset/cli-hidden-commands.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Internal refactor of the CLI command registration diff --git a/packages/cli/src/wiring/CliInitializer.test.ts b/packages/cli/src/wiring/CliInitializer.test.ts index e04a429ede..10de6d8e09 100644 --- a/packages/cli/src/wiring/CliInitializer.test.ts +++ b/packages/cli/src/wiring/CliInitializer.test.ts @@ -92,6 +92,124 @@ describe('CliInitializer', () => { expect(process.exit).toHaveBeenCalledWith(0); }); + it('should run experimental commands but exclude them from help output', async () => { + expect.assertions(3); + process.argv = ['node', 'cli', 'secret']; + const initializer = new CliInitializer(); + initializer.add( + createCliPlugin({ + pluginId: 'test', + init: async reg => { + reg.addCommand({ + path: ['visible'], + description: 'A visible command', + execute: () => Promise.resolve(), + }); + reg.addCommand({ + path: ['secret'], + description: 'An experimental command', + experimental: true, + execute: ({ args }) => { + expect(args).toEqual([]); + return Promise.resolve(); + }, + }); + }, + }), + ); + await initializer.run(); + expect(process.exit).toHaveBeenCalledWith(0); + + process.argv = ['node', 'cli', '--help']; + const writeSpy = jest.spyOn(process.stdout, 'write'); + const initializer2 = new CliInitializer(); + initializer2.add( + createCliPlugin({ + pluginId: 'test', + init: async reg => { + reg.addCommand({ + path: ['visible'], + description: 'A visible command', + execute: () => Promise.resolve(), + }); + reg.addCommand({ + path: ['secret'], + description: 'An experimental command', + experimental: true, + execute: () => Promise.resolve(), + }); + }, + }), + ); + await initializer2.run(); + const helpOutput = writeSpy.mock.calls.map(c => c[0]).join(''); + expect(helpOutput).not.toContain('secret'); + writeSpy.mockRestore(); + }); + + it('should hide tree nodes when all children are experimental', async () => { + process.argv = ['node', 'cli', '--help']; + const writeSpy = jest.spyOn(process.stdout, 'write'); + const initializer = new CliInitializer(); + initializer.add( + createCliPlugin({ + pluginId: 'test', + init: async reg => { + reg.addCommand({ + path: ['visible'], + description: 'A visible command', + execute: () => Promise.resolve(), + }); + reg.addCommand({ + path: ['group', 'alpha'], + description: 'First experimental command', + experimental: true, + execute: () => Promise.resolve(), + }); + reg.addCommand({ + path: ['group', 'beta'], + description: 'Second experimental command', + experimental: true, + execute: () => Promise.resolve(), + }); + }, + }), + ); + await initializer.run(); + const helpOutput = writeSpy.mock.calls.map(c => c[0]).join(''); + expect(helpOutput).toContain('visible'); + expect(helpOutput).not.toContain('group'); + writeSpy.mockRestore(); + }); + + it('should show tree nodes when some children are visible', async () => { + process.argv = ['node', 'cli', '--help']; + const writeSpy = jest.spyOn(process.stdout, 'write'); + const initializer = new CliInitializer(); + initializer.add( + createCliPlugin({ + pluginId: 'test', + init: async reg => { + reg.addCommand({ + path: ['group', 'alpha'], + description: 'A visible nested command', + execute: () => Promise.resolve(), + }); + reg.addCommand({ + path: ['group', 'beta'], + description: 'An experimental nested command', + experimental: true, + execute: () => Promise.resolve(), + }); + }, + }), + ); + await initializer.run(); + const helpOutput = writeSpy.mock.calls.map(c => c[0]).join(''); + expect(helpOutput).toContain('group'); + writeSpy.mockRestore(); + }); + it('should pass positional args to the subcommand if nested', async () => { expect.assertions(2); process.argv = [ diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index c07fbb26f3..d9af1e8a4d 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -15,7 +15,7 @@ */ import { CommandGraph } from './CommandGraph'; -import { CliFeature, OpaqueCliPlugin } from './types'; +import { BackstageCommand, CliFeature, OpaqueCliPlugin } from './types'; import { CommandRegistry } from './CommandRegistry'; import { Command } from 'commander'; import { version } from './version'; @@ -24,6 +24,17 @@ import { exitWithError } from './errors'; import { ForwardedError } from '@backstage/errors'; import { isPromise } from 'node:util/types'; +function isNodeHidden( + node: + | { $$type: '@tree/leaf'; command: BackstageCommand } + | { $$type: '@tree/root'; children: unknown[] }, +): boolean { + if (node.$$type === '@tree/leaf') { + return !!node.command.deprecated || !!node.command.experimental; + } + return node.children.every(child => isNodeHidden(child as any)); +} + type UninitializedFeature = CliFeature | Promise<{ default: CliFeature }>; export class CliInitializer { @@ -80,7 +91,9 @@ export class CliInitializer { const { node, argParser } = queue.shift()!; if (node.$$type === '@tree/root') { const treeParser = argParser - .command(`${node.name} [command]`) + .command(`${node.name} [command]`, { + hidden: isNodeHidden(node), + }) .description(node.name); queue.push( @@ -91,7 +104,9 @@ export class CliInitializer { ); } else { argParser - .command(node.name, { hidden: !!node.command.deprecated }) + .command(node.name, { + hidden: !!node.command.deprecated || !!node.command.experimental, + }) .description(node.command.description) .helpOption(false) .allowUnknownOption(true) diff --git a/packages/cli/src/wiring/types.ts b/packages/cli/src/wiring/types.ts index e0f697990f..fa3d2341f0 100644 --- a/packages/cli/src/wiring/types.ts +++ b/packages/cli/src/wiring/types.ts @@ -35,6 +35,7 @@ export interface BackstageCommand { path: string[]; description: string; deprecated?: boolean; + experimental?: boolean; execute: | CommandExecuteFn | {