Refine cli-plugin-api: use packageJson, inline types, rename context fields

Replace pluginId with packageJson input for createCliPlugin, remove
CommandExecuteFn type alias by inlining it, and rename
CommandContext.info.description to info.name.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-11 08:38:58 +01:00
parent 2e5f189cfa
commit 7e6ad01a21
23 changed files with 60 additions and 111 deletions
@@ -21,10 +21,8 @@ export const OpaqueCliPlugin = OpaqueType.create<{
public: CliPlugin;
versions: {
readonly version: 'v1';
readonly description: string;
init: (registry: {
addCommand: (command: BackstageCommand) => void;
}) => Promise<void>;
readonly packageName: string;
readonly commands: Promise<ReadonlyArray<BackstageCommand>>;
};
}>({
type: '@backstage/CliPlugin',
+5 -8
View File
@@ -11,10 +11,10 @@ export interface BackstageCommand {
description: string;
// (undocumented)
execute:
| CommandExecuteFn
| ((context: CommandContext) => Promise<void>)
| {
loader: () => Promise<{
default: CommandExecuteFn;
default: (context: CommandContext) => Promise<void>;
}>;
};
// (undocumented)
@@ -30,8 +30,6 @@ export type CliFeature = CliPlugin;
export interface CliPlugin {
// (undocumented)
readonly $$type: '@backstage/CliPlugin';
// (undocumented)
readonly pluginId: string;
}
// @public
@@ -45,12 +43,11 @@ export interface CommandContext {
};
}
// @public
export type CommandExecuteFn = (context: CommandContext) => Promise<void>;
// @public
export function createCliPlugin(options: {
pluginId: string;
packageJson: {
name: string;
};
init: (registry: {
addCommand: (command: BackstageCommand) => void;
}) => Promise<void>;
@@ -15,7 +15,6 @@
*/
import { OpaqueCliPlugin } from '@internal/cli';
import { describeParentCallSite } from './describeParentCallSite';
import { BackstageCommand, CliPlugin } from './types';
/**
@@ -24,14 +23,18 @@ import { BackstageCommand, CliPlugin } from './types';
* @public
*/
export function createCliPlugin(options: {
pluginId: string;
packageJson: { name: string };
init: (registry: {
addCommand: (command: BackstageCommand) => void;
}) => Promise<void>;
}): CliPlugin {
const commands: BackstageCommand[] = [];
const commandsPromise = options
.init({ addCommand: command => commands.push(command) })
.then(() => commands);
return OpaqueCliPlugin.createInstance('v1', {
pluginId: options.pluginId,
init: options.init,
description: `created at '${describeParentCallSite()}'`,
packageName: options.packageJson.name,
commands: commandsPromise,
});
}
@@ -1,54 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const MESSAGE_MARKER = 'eHgtF5hmbrXyiEvo';
/**
* Internal helper that describes the location of the parent caller.
* @internal
*/
export function describeParentCallSite(
ErrorConstructor: { new (message: string): Error } = Error,
): string {
const { stack } = new ErrorConstructor(MESSAGE_MARKER);
if (!stack) {
return '<unknown>';
}
const startIndex = stack.includes(MESSAGE_MARKER)
? stack.indexOf('\n') + 1
: 0;
const secondEntryStart =
stack.indexOf('\n', stack.indexOf('\n', startIndex) + 1) + 1;
const secondEntryEnd = stack.indexOf('\n', secondEntryStart);
const line = stack.substring(secondEntryStart, secondEntryEnd).trim();
if (!line) {
return 'unknown';
}
// Chrome
if (line.includes('(')) {
return line.substring(line.indexOf('(') + 1, line.indexOf(')'));
}
// Safari & Firefox
if (line.includes('@')) {
return line.substring(line.indexOf('@') + 1);
}
return line;
}
-1
View File
@@ -18,7 +18,6 @@ export { createCliPlugin } from './createCliPlugin';
export type {
BackstageCommand,
CommandContext,
CommandExecuteFn,
CliPlugin,
CliFeature,
} from './types';
+6 -12
View File
@@ -27,19 +27,12 @@ export interface CommandContext {
*/
usage: string;
/**
* The description provided for the command
* The name of the command, for example: "repo test"
*/
description: string;
name: string;
};
}
/**
* A function that executes a CLI command.
*
* @public
*/
export type CommandExecuteFn = (context: CommandContext) => Promise<void>;
/**
* A command definition for a Backstage CLI plugin.
*
@@ -51,9 +44,11 @@ export interface BackstageCommand {
deprecated?: boolean;
experimental?: boolean;
execute:
| CommandExecuteFn
| ((context: CommandContext) => Promise<void>)
| {
loader: () => Promise<{ default: CommandExecuteFn }>;
loader: () => Promise<{
default: (context: CommandContext) => Promise<void>;
}>;
};
}
@@ -70,6 +65,5 @@ export type CliFeature = CliPlugin;
* @public
*/
export interface CliPlugin {
readonly pluginId: string;
readonly $$type: '@backstage/CliPlugin';
}
+2 -1
View File
@@ -15,9 +15,10 @@
*/
import { createCliPlugin } from '../../wiring/factory';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'auth',
packageJson,
init: async reg => {
reg.addCommand({
path: ['auth', 'login'],
+2 -1
View File
@@ -15,9 +15,10 @@
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export const buildPlugin = createCliPlugin({
pluginId: 'build',
packageJson,
init: async reg => {
reg.addCommand({
path: ['package', 'build'],
+2 -1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export const configOption = [
'--config <path>',
@@ -23,7 +24,7 @@ export const configOption = [
] as const;
export default createCliPlugin({
pluginId: 'config',
packageJson,
init: async reg => {
reg.addCommand({
path: ['config:docs'],
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'new',
packageJson,
init: async reg => {
reg.addCommand({
path: ['create-github-app'],
+2 -1
View File
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'info',
packageJson,
init: async reg => {
reg.addCommand({
path: ['info'],
+2 -1
View File
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'lint',
packageJson,
init: async reg => {
reg.addCommand({
path: ['package', 'lint'],
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'maintenance',
packageJson,
init: async reg => {
reg.addCommand({
path: ['repo', 'fix'],
@@ -125,7 +125,7 @@ const expectLogsToMatch = (
expect(receivedLogs.filter(Boolean).sort()).toEqual(expected.sort());
};
const info = { usage: 'backstage-cli versions:bump', description: '' };
const info = { usage: 'backstage-cli versions:bump', name: 'versions:bump' };
describe('bump', () => {
const mockDir = createMockDirectory();
@@ -123,7 +123,7 @@ describe('versions:migrate', () => {
});
const { warn, log: logs } = await withLogCollector(async () => {
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
await migrate({ args: [], info: { usage: 'test', name: 'test' } });
});
expectLogsToMatch(logs, [
@@ -229,7 +229,7 @@ describe('versions:migrate', () => {
});
await withLogCollector(async () => {
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
await migrate({ args: [], info: { usage: 'test', name: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
@@ -311,7 +311,7 @@ describe('versions:migrate', () => {
});
await withLogCollector(async () => {
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
await migrate({ args: [], info: { usage: 'test', name: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
+2 -1
View File
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'migrate',
packageJson,
init: async reg => {
reg.addCommand({
path: ['versions:migrate'],
@@ -41,7 +41,7 @@ describe.each([
}
const context: CommandContext = {
args,
info: { usage: 'backstage-cli new', description: 'test' },
info: { usage: 'backstage-cli new', name: 'new' },
};
await newCommand(context);
expect(createNewPackage).toHaveBeenCalledWith(
+2 -1
View File
@@ -15,9 +15,10 @@
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import { NotImplementedError } from '@backstage/errors';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'new',
packageJson,
init: async reg => {
reg.addCommand({
path: ['new'],
+2 -1
View File
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'test',
packageJson,
init: async reg => {
reg.addCommand({
path: ['repo', 'test'],
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { createCliPlugin } from '@backstage/cli-plugin-api';
import packageJson from '../../../package.json';
export default createCliPlugin({
pluginId: 'translations',
packageJson,
init: async reg => {
reg.addCommand({
path: ['translations', 'export'],
@@ -29,7 +29,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg =>
reg.addCommand({
path: ['test'],
@@ -51,7 +51,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg =>
reg.addCommand({
path: ['test'],
@@ -73,7 +73,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg =>
reg.addCommand({
path: ['test'],
@@ -98,7 +98,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg => {
reg.addCommand({
path: ['visible'],
@@ -125,7 +125,7 @@ describe('CliInitializer', () => {
const initializer2 = new CliInitializer();
initializer2.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg => {
reg.addCommand({
path: ['visible'],
@@ -153,7 +153,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg => {
reg.addCommand({
path: ['visible'],
@@ -188,7 +188,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg => {
reg.addCommand({
path: ['group', 'alpha'],
@@ -224,7 +224,7 @@ describe('CliInitializer', () => {
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
packageJson: { name: '@backstage/test' },
init: async reg =>
reg.addCommand({
path: ['test', 'nested', 'command'],
+4 -2
View File
@@ -56,7 +56,9 @@ export class CliInitializer {
async #register(feature: CliFeature) {
if (OpaqueCliPlugin.isType(feature)) {
const internal = OpaqueCliPlugin.toInternal(feature);
await internal.init(this.commandRegistry);
for (const command of await internal.commands) {
this.commandRegistry.addCommand(command);
}
} else {
throw new Error(`Unsupported feature type: ${(feature as any).$$type}`);
}
@@ -138,7 +140,7 @@ export class CliInitializer {
args: [...positionalArgs, ...args.unknown],
info: {
usage: [programName, ...node.command.path].join(' '),
description: node.command.description,
name: node.command.path.join(' '),
},
};
-1
View File
@@ -16,7 +16,6 @@
export type {
CommandContext,
CommandExecuteFn,
BackstageCommand,
CliFeature,
CliPlugin,