Add support for experimental commands in the CLI module API

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-05 22:01:30 +01:00
parent e359d84d1c
commit a4e59024c0
4 changed files with 142 additions and 3 deletions
@@ -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 = [
+18 -3
View File
@@ -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)
+1
View File
@@ -35,6 +35,7 @@ export interface BackstageCommand {
path: string[];
description: string;
deprecated?: boolean;
experimental?: boolean;
execute:
| CommandExecuteFn
| {