feat(cli-module-actions): improve CLI formatting, help output, and UX (#33517)

* fix(cli-module-actions): show schema flags in execute --help

When an action ID is provided with --help, fetch the action schema and
display action-specific flags. Falls back to generic help if auth fails.

Signed-off-by: benjdlambert <ben@blam.sh>

* fix(cli-module-actions): show schema flags in execute --help and fix build errors (#33518)

* feat(cli-module-actions): improve CLI output formatting and UX

- Pretty grouped list output with colored headers and action titles
- Custom help rendering for execute --help with markdown descriptions
- Support complex schema types (object, array, union) as JSON flags
- Show server error details instead of generic status codes
- Accept multiple plugin IDs in sources add/remove

Signed-off-by: benjdlambert <ben@blam.sh>

* fix(cli-module-auth): preserve instance metadata on re-login

Signed-off-by: benjdlambert <ben@blam.sh>

* fix: address code review feedback

- Extract triplicated cli() config into showGenericHelp helper
- Strip ANSI escape sequences before rendering server markdown
- Configure marked-terminal extension once via lazy singleton
- Parallelize listGrouped HTTP requests with Promise.all
- Log actual error message in execute help catch block
- Fix marked version in declarations.d.ts comment
- Add tests for sourcesAdd/sourcesRemove batch operations
- Add test for execute JSON parse error path
- Add tests for login metadata preservation on re-auth

Signed-off-by: benjdlambert <ben@blam.sh>

* fix: use RegExp constructor to satisfy no-control-regex lint rule

Signed-off-by: benjdlambert <ben@blam.sh>

* fix: improve ANSI stripping, default info.usage, add renderMarkdown comment

- Extend stripAnsiEscapes to cover OSC, DCS, APC, PM sequences
- Default info.usage to avoid undefined in help output
- Document why marked.use() is called per invocation

Signed-off-by: benjdlambert <ben@blam.sh>

* fix: use strip-ansi, fresh Marked instance, add allOf support

- Replace hand-rolled ANSI stripping with strip-ansi package
- Use fresh Marked instance per call instead of mutating global singleton
- Add allOf to complex type detection alongside anyOf/oneOf
- Add happy-path test for valid JSON complex flag parsing
- Bump changeset to minor for new user-facing capabilities

Signed-off-by: benjdlambert <ben@blam.sh>

* refactor: collapse listGrouped into list on ActionsClient

Signed-off-by: benjdlambert <ben@blam.sh>

* refactor: clean up cli-module-actions structure

- Extract shared pluginSourcesSchema into lib/pluginSources.ts
- Merge schemaToFlags and getComplexKeys into single return value
- Move CleyeFlag-to-FlagInfo conversion into format.ts
- Extract parseArgs and showActionHelp from execute command body

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Lambert
2026-04-09 13:53:44 +02:00
committed by GitHub
parent ee0c95d971
commit c705d44e4b
24 changed files with 1535 additions and 123 deletions
+4
View File
@@ -35,7 +35,11 @@
"dependencies": {
"@backstage/cli-node": "workspace:^",
"@backstage/errors": "workspace:^",
"chalk": "^4.0.0",
"cleye": "^2.3.0",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0",
"strip-ansi": "^7.1.0",
"zod": "^3.25.76 || ^4.0.0"
},
"devDependencies": {
@@ -0,0 +1,303 @@
/*
* Copyright 2025 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.
*/
import type { CliCommandContext } from '@backstage/cli-node';
const mockListForPlugin = jest.fn();
const mockExecute = jest.fn();
jest.mock('cleye', () => ({ cli: jest.fn().mockReturnValue({ flags: {} }) }));
jest.mock('../lib/resolveAuth', () => ({ resolveAuth: jest.fn() }));
jest.mock('../lib/ActionsClient', () => ({
ActionsClient: jest.fn().mockImplementation(() => ({
listForPlugin: mockListForPlugin,
execute: mockExecute,
})),
}));
import executeCommand from './execute';
import { cli } from 'cleye';
import { resolveAuth } from '../lib/resolveAuth';
const mockCli = cli as jest.MockedFunction<typeof cli>;
const mockResolveAuth = resolveAuth as jest.MockedFunction<typeof resolveAuth>;
const baseContext: CliCommandContext = {
args: [],
info: { name: 'execute', description: 'Execute an action' },
} as unknown as CliCommandContext;
const testAction = {
id: 'catalog:refresh',
name: 'refresh',
schema: {
input: {
properties: {
entityRef: { type: 'string', description: 'Entity reference' },
dryRun: { type: 'boolean', description: 'Dry run mode' },
},
required: ['entityRef'],
},
output: {},
},
};
function authResponse() {
return {
accessToken: 'test-token',
baseUrl: 'https://backstage.example.com',
instanceName: 'default',
pluginSources: ['catalog'],
};
}
describe('execute command', () => {
let stderrSpy: jest.SpiedFunction<typeof process.stderr.write>;
let stdoutSpy: jest.SpiedFunction<typeof process.stdout.write>;
beforeEach(() => {
jest.clearAllMocks();
stderrSpy = jest
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it('shows action-specific help when action ID is provided', async () => {
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([testAction]);
await executeCommand({
...baseContext,
args: ['catalog:refresh', '--help'],
});
expect(mockResolveAuth).toHaveBeenCalled();
expect(mockListForPlugin).toHaveBeenCalledWith('catalog:refresh');
const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('catalog:refresh');
expect(output).toContain('--entityRef');
expect(output).toContain('--dryRun');
expect(output).toContain('--instance');
expect(output).toContain('Usage:');
expect(mockCli).not.toHaveBeenCalled();
});
it('falls back to generic help with a message when auth fails', async () => {
mockResolveAuth.mockRejectedValue(new Error('Not authenticated'));
await executeCommand({
...baseContext,
args: ['catalog:refresh', '--help'],
});
const stderrOutput = stderrSpy.mock.calls.map(c => c[0]).join('');
expect(stderrOutput).toContain('Unable to retrieve action schema');
expect(stderrOutput).toContain('Not authenticated');
expect(stderrOutput).toContain('Showing generic help.');
const cliCall = mockCli.mock.calls[0][0];
const cliFlags = cliCall.flags as Record<string, unknown>;
expect(cliFlags.instance).toBeDefined();
expect(cliFlags.entityRef).toBeUndefined();
});
it('falls back to generic help when action is not found', async () => {
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([]);
await executeCommand({
...baseContext,
args: ['catalog:refresh', '--help'],
});
const cliCall = mockCli.mock.calls[0][0];
const cliFlags = cliCall.flags as Record<string, unknown>;
expect(cliFlags.entityRef).toBeUndefined();
expect(cliFlags.instance).toBeDefined();
});
it('shows generic help when no action ID is provided with --help', async () => {
await executeCommand({
...baseContext,
args: ['--help'],
});
expect(mockResolveAuth).not.toHaveBeenCalled();
const cliCall = mockCli.mock.calls[0][0];
const cliFlags = cliCall.flags as Record<string, unknown>;
expect(cliCall.parameters).toEqual(['<action-id>']);
expect(cliFlags.instance).toBeDefined();
expect(Object.keys(cliFlags)).toEqual(['instance']);
});
it('shows help and throws when no action ID and no --help flag', async () => {
await expect(
executeCommand({
...baseContext,
args: [],
}),
).rejects.toThrow('Action ID is required');
expect(mockResolveAuth).not.toHaveBeenCalled();
const cliCall = mockCli.mock.calls[0][0];
expect(cliCall.parameters).toEqual(['<action-id>']);
});
it('extracts --instance flag for auth when showing help', async () => {
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([testAction]);
await executeCommand({
...baseContext,
args: ['--instance', 'staging', 'catalog:refresh', '--help'],
});
expect(mockResolveAuth).toHaveBeenCalledWith('staging');
expect(mockCli).not.toHaveBeenCalled();
});
it('executes action and prints output on success', async () => {
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([testAction]);
mockExecute.mockResolvedValue({ refreshed: true });
(mockCli as jest.Mock).mockReturnValue({
flags: {
entityRef: 'component:default/foo',
instance: undefined,
help: undefined,
},
});
await executeCommand({
...baseContext,
args: ['catalog:refresh', '--entityRef', 'component:default/foo'],
});
expect(mockExecute).toHaveBeenCalledWith('catalog:refresh', {
entityRef: 'component:default/foo',
});
expect(stdoutSpy).toHaveBeenCalledWith(
`${JSON.stringify({ refreshed: true }, null, 2)}\n`,
);
});
it('parses valid JSON for complex flag values and passes to execute', async () => {
const actionWithObject = {
...testAction,
schema: {
input: {
properties: {
...testAction.schema.input.properties,
metadata: { type: 'object', description: 'Entity metadata' },
},
required: ['entityRef'],
},
output: {},
},
};
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([actionWithObject]);
mockExecute.mockResolvedValue({ ok: true });
(mockCli as jest.Mock).mockReturnValue({
flags: {
entityRef: 'component:default/foo',
metadata: '{"name":"bar"}',
instance: undefined,
help: undefined,
},
});
await executeCommand({
...baseContext,
args: [
'catalog:refresh',
'--entityRef',
'component:default/foo',
'--metadata',
'{"name":"bar"}',
],
});
expect(mockExecute).toHaveBeenCalledWith('catalog:refresh', {
entityRef: 'component:default/foo',
metadata: { name: 'bar' },
});
});
it('throws on invalid JSON for complex flag values', async () => {
const actionWithObject = {
...testAction,
schema: {
input: {
properties: {
...testAction.schema.input.properties,
metadata: { type: 'object', description: 'Entity metadata' },
},
required: ['entityRef'],
},
output: {},
},
};
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([actionWithObject]);
(mockCli as jest.Mock).mockReturnValue({
flags: {
entityRef: 'component:default/foo',
metadata: 'not-valid-json',
instance: undefined,
help: undefined,
},
});
await expect(
executeCommand({
...baseContext,
args: [
'catalog:refresh',
'--entityRef',
'component:default/foo',
'--metadata',
'not-valid-json',
],
}),
).rejects.toThrow('Invalid JSON for --metadata. Expected a JSON string.');
});
it('throws when action is not found during execution', async () => {
mockResolveAuth.mockResolvedValue(authResponse());
mockListForPlugin.mockResolvedValue([]);
(mockCli as jest.Mock).mockReturnValue({ flags: { help: undefined } });
await expect(
executeCommand({
...baseContext,
args: ['catalog:unknown'],
}),
).rejects.toThrow(
'Action "catalog:unknown" not found. Run "actions list" to see available actions.',
);
});
});
@@ -19,31 +19,12 @@ import type { CliCommandContext } from '@backstage/cli-node';
import { ActionsClient } from '../lib/ActionsClient';
import { schemaToFlags } from '../lib/schemaToFlags';
import { resolveAuth } from '../lib/resolveAuth';
import { formatActionHelp, flagDefsToFlagInfo } from '../lib/format';
export default async ({ args, info }: CliCommandContext) => {
if (args.includes('--help') || args.includes('-h')) {
cli(
{
help: info,
parameters: ['<action-id>'],
flags: {
instance: {
type: String,
description: 'Name of the instance to use',
},
},
},
undefined,
args,
);
return;
}
function parseArgs(args: string[]) {
const instanceIdx = args.indexOf('--instance');
const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined;
// Skip flag names, flag values (the argument after a known flag), and
// the --instance value position so we only pick up positional arguments.
const skipIndices = new Set<number>();
if (instanceIdx !== -1) {
skipIndices.add(instanceIdx);
@@ -60,13 +41,88 @@ export default async ({ args, info }: CliCommandContext) => {
}
}
const wantsHelp = args.includes('--help') || args.includes('-h');
return { instanceFlag, actionId, actionIdIdx, wantsHelp };
}
function showGenericHelp(
info: CliCommandContext['info'],
args: string[],
): void {
cli(
{
help: info,
parameters: ['<action-id>'],
flags: {
instance: {
type: String,
description: 'Name of the instance to use',
},
},
},
undefined,
args,
);
}
async function showActionHelp(
info: CliCommandContext['info'],
actionId: string,
instanceFlag: string | undefined,
): Promise<boolean> {
try {
const { accessToken, baseUrl } = await resolveAuth(instanceFlag);
const client = new ActionsClient(baseUrl, accessToken);
const actions = await client.listForPlugin(actionId);
const action = actions.find(a => a.id === actionId);
if (!action) {
return false;
}
const { flags: flagDefs } = schemaToFlags(action.schema.input as any);
const flags = flagDefsToFlagInfo(flagDefs);
flags.push({
name: 'instance',
type: 'string',
description: 'Name of the instance to use',
});
process.stdout.write(
await formatActionHelp({
action,
usage: `${info.usage ?? 'backstage actions execute'} ${actionId}`,
flags,
}),
);
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(
`Unable to retrieve action schema: ${msg}\nShowing generic help.\n`,
);
return false;
}
}
export default async ({ args, info }: CliCommandContext) => {
const { instanceFlag, actionId, actionIdIdx, wantsHelp } = parseArgs(args);
if (wantsHelp) {
if (!actionId || !(await showActionHelp(info, actionId, instanceFlag))) {
showGenericHelp(info, args);
}
return;
}
if (!actionId) {
process.stderr.write('Usage: actions execute <action-id> [flags]\n');
process.exit(1);
// Inject --help so cleye renders its help output before we throw.
showGenericHelp(info, ['--help', ...args]);
throw new Error('Action ID is required');
}
const { accessToken, baseUrl } = await resolveAuth(instanceFlag);
const client = new ActionsClient(baseUrl, accessToken);
const actions = await client.listForPlugin(actionId);
const action = actions.find(a => a.id === actionId);
@@ -77,7 +133,8 @@ export default async ({ args, info }: CliCommandContext) => {
);
}
const schemaFlags = schemaToFlags(action.schema.input as any);
const inputSchema = action.schema.input as any;
const { flags: schemaFlags, complexKeys } = schemaToFlags(inputSchema);
const flagArgs = args.filter((_, i) => i !== actionIdIdx);
@@ -85,11 +142,11 @@ export default async ({ args, info }: CliCommandContext) => {
{
help: info,
flags: {
...schemaFlags,
instance: {
type: String,
description: 'Name of the instance to use',
},
...schemaFlags,
},
},
undefined,
@@ -99,7 +156,16 @@ export default async ({ args, info }: CliCommandContext) => {
const allFlags = flags as Record<string, unknown>;
const input: Record<string, unknown> = {};
for (const [key, value] of Object.entries(allFlags)) {
if (key !== 'instance' && value !== undefined) {
if (key === 'instance' || value === undefined) {
continue;
}
if (complexKeys.has(key) && typeof value === 'string') {
try {
input[key] = JSON.parse(value);
} catch {
throw new Error(`Invalid JSON for --${key}. Expected a JSON string.`);
}
} else {
input[key] = value;
}
}
@@ -18,6 +18,7 @@ import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import { ActionsClient } from '../lib/ActionsClient';
import { resolveAuth } from '../lib/resolveAuth';
import { formatActionList } from '../lib/format';
export default async ({ args, info }: CliCommandContext) => {
const {
@@ -48,15 +49,13 @@ export default async ({ args, info }: CliCommandContext) => {
}
const client = new ActionsClient(baseUrl, accessToken);
const actions = await client.list(pluginSources);
const grouped = await client.list(pluginSources);
if (!actions.length) {
const hasActions = grouped.some(g => g.actions.length > 0);
if (!hasActions) {
process.stderr.write('No actions found.\n');
return;
}
for (const action of actions) {
const desc = action.description ? ` - ${action.description}` : '';
process.stdout.write(`${action.id}${desc}\n`);
}
process.stdout.write(`${formatActionList(grouped)}\n`);
};
@@ -0,0 +1,121 @@
/*
* Copyright 2025 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.
*/
import type { CliCommandContext } from '@backstage/cli-node';
const mockGetMetadata = jest.fn();
const mockSetMetadata = jest.fn();
jest.mock('cleye', () => ({
cli: jest.fn().mockImplementation((_opts, _cb, args) => ({
_: { pluginIds: args.filter((a: string) => !a.startsWith('-')) },
})),
}));
jest.mock('@backstage/cli-node', () => ({
CliAuth: {
create: jest.fn().mockImplementation(() => ({
getMetadata: mockGetMetadata,
setMetadata: mockSetMetadata,
})),
},
}));
import sourcesAddCommand from './sourcesAdd';
const baseContext: CliCommandContext = {
args: [],
info: { name: 'sources add', description: 'Add plugin sources' },
} as unknown as CliCommandContext;
describe('sourcesAdd command', () => {
let stdoutSpy: jest.SpiedFunction<typeof process.stdout.write>;
let stderrSpy: jest.SpiedFunction<typeof process.stderr.write>;
beforeEach(() => {
jest.clearAllMocks();
stdoutSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
stderrSpy = jest
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});
it('adds a single new plugin source', async () => {
mockGetMetadata.mockResolvedValue([]);
await sourcesAddCommand({ ...baseContext, args: ['catalog'] });
expect(mockSetMetadata).toHaveBeenCalledWith('pluginSources', ['catalog']);
const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('Added plugin source: catalog');
});
it('adds multiple plugin sources at once', async () => {
mockGetMetadata.mockResolvedValue([]);
await sourcesAddCommand({
...baseContext,
args: ['catalog', 'scaffolder'],
});
expect(mockSetMetadata).toHaveBeenCalledWith('pluginSources', [
'catalog',
'scaffolder',
]);
const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('Added plugin sources: catalog, scaffolder');
});
it('skips already-configured sources and adds new ones', async () => {
mockGetMetadata.mockResolvedValue(['catalog']);
await sourcesAddCommand({
...baseContext,
args: ['catalog', 'scaffolder'],
});
expect(mockSetMetadata).toHaveBeenCalledWith('pluginSources', [
'catalog',
'scaffolder',
]);
const stdout = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(stdout).toContain('Added plugin source: scaffolder');
const stderr = stderrSpy.mock.calls.map(c => c[0]).join('');
expect(stderr).toContain('Plugin source "catalog" is already configured.');
});
it('does not call setMetadata when all sources already exist', async () => {
mockGetMetadata.mockResolvedValue(['catalog', 'scaffolder']);
await sourcesAddCommand({
...baseContext,
args: ['catalog', 'scaffolder'],
});
expect(mockSetMetadata).not.toHaveBeenCalled();
const stderr = stderrSpy.mock.calls.map(c => c[0]).join('');
expect(stderr).toContain('Plugin source "catalog" is already configured.');
expect(stderr).toContain(
'Plugin source "scaffolder" is already configured.',
);
});
});
@@ -16,35 +16,46 @@
import { cli } from 'cleye';
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
import { z } from 'zod/v3';
const pluginSourcesSchema = z.array(z.string()).default([]);
import { pluginSourcesSchema } from '../lib/pluginSources';
export default async ({ args, info }: CliCommandContext) => {
const parsed = cli(
{
help: info,
parameters: ['<plugin-id>'],
parameters: ['<plugin-ids...>'],
},
undefined,
args,
);
const pluginId = parsed._[0];
const pluginIds: string[] = parsed._.pluginIds;
const auth = await CliAuth.create();
const existing = pluginSourcesSchema.parse(
await auth.getMetadata('pluginSources'),
);
if (existing.includes(pluginId)) {
process.stderr.write(
`Plugin source "${pluginId}" is already configured.\n`,
);
return;
const added: string[] = [];
const skipped: string[] = [];
for (const pluginId of pluginIds) {
if (existing.includes(pluginId)) {
skipped.push(pluginId);
} else {
added.push(pluginId);
}
}
await auth.setMetadata('pluginSources', [...existing, pluginId]);
if (added.length > 0) {
await auth.setMetadata('pluginSources', [...existing, ...added]);
process.stdout.write(
`Added plugin source${added.length > 1 ? 's' : ''}: ${added.join(
', ',
)}\n`,
);
}
process.stdout.write(`Added plugin source "${pluginId}".\n`);
for (const id of skipped) {
process.stderr.write(`Plugin source "${id}" is already configured.\n`);
}
};
@@ -16,9 +16,7 @@
import { cli } from 'cleye';
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
import { z } from 'zod/v3';
const pluginSourcesSchema = z.array(z.string()).default([]);
import { pluginSourcesSchema } from '../lib/pluginSources';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info }, undefined, args);
@@ -0,0 +1,114 @@
/*
* Copyright 2025 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.
*/
import type { CliCommandContext } from '@backstage/cli-node';
const mockGetMetadata = jest.fn();
const mockSetMetadata = jest.fn();
jest.mock('cleye', () => ({
cli: jest.fn().mockImplementation((_opts, _cb, args) => ({
_: { pluginIds: args.filter((a: string) => !a.startsWith('-')) },
})),
}));
jest.mock('@backstage/cli-node', () => ({
CliAuth: {
create: jest.fn().mockImplementation(() => ({
getMetadata: mockGetMetadata,
setMetadata: mockSetMetadata,
})),
},
}));
import sourcesRemoveCommand from './sourcesRemove';
const baseContext: CliCommandContext = {
args: [],
info: { name: 'sources remove', description: 'Remove plugin sources' },
} as unknown as CliCommandContext;
describe('sourcesRemove command', () => {
let stdoutSpy: jest.SpiedFunction<typeof process.stdout.write>;
let stderrSpy: jest.SpiedFunction<typeof process.stderr.write>;
beforeEach(() => {
jest.clearAllMocks();
stdoutSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
stderrSpy = jest
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});
it('removes a single configured plugin source', async () => {
mockGetMetadata.mockResolvedValue(['catalog', 'scaffolder']);
await sourcesRemoveCommand({ ...baseContext, args: ['catalog'] });
expect(mockSetMetadata).toHaveBeenCalledWith('pluginSources', [
'scaffolder',
]);
const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('Removed plugin source: catalog');
});
it('removes multiple plugin sources at once', async () => {
mockGetMetadata.mockResolvedValue(['catalog', 'scaffolder', 'techdocs']);
await sourcesRemoveCommand({
...baseContext,
args: ['catalog', 'scaffolder'],
});
expect(mockSetMetadata).toHaveBeenCalledWith('pluginSources', ['techdocs']);
const output = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(output).toContain('Removed plugin sources: catalog, scaffolder');
});
it('skips unconfigured sources and removes existing ones', async () => {
mockGetMetadata.mockResolvedValue(['catalog']);
await sourcesRemoveCommand({
...baseContext,
args: ['catalog', 'scaffolder'],
});
expect(mockSetMetadata).toHaveBeenCalledWith('pluginSources', []);
const stdout = stdoutSpy.mock.calls.map(c => c[0]).join('');
expect(stdout).toContain('Removed plugin source: catalog');
const stderr = stderrSpy.mock.calls.map(c => c[0]).join('');
expect(stderr).toContain('Plugin source "scaffolder" is not configured.');
});
it('does not call setMetadata when no sources match', async () => {
mockGetMetadata.mockResolvedValue(['catalog']);
await sourcesRemoveCommand({
...baseContext,
args: ['scaffolder'],
});
expect(mockSetMetadata).not.toHaveBeenCalled();
const stderr = stderrSpy.mock.calls.map(c => c[0]).join('');
expect(stderr).toContain('Plugin source "scaffolder" is not configured.');
});
});
@@ -16,36 +16,49 @@
import { cli } from 'cleye';
import { CliAuth, type CliCommandContext } from '@backstage/cli-node';
import { z } from 'zod/v3';
const pluginSourcesSchema = z.array(z.string()).default([]);
import { pluginSourcesSchema } from '../lib/pluginSources';
export default async ({ args, info }: CliCommandContext) => {
const parsed = cli(
{
help: info,
parameters: ['<plugin-id>'],
parameters: ['<plugin-ids...>'],
},
undefined,
args,
);
const pluginId = parsed._[0];
const pluginIds: string[] = parsed._.pluginIds;
const auth = await CliAuth.create();
const existing = pluginSourcesSchema.parse(
await auth.getMetadata('pluginSources'),
);
if (!existing.includes(pluginId)) {
process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`);
return;
const removed: string[] = [];
const skipped: string[] = [];
for (const pluginId of pluginIds) {
if (existing.includes(pluginId)) {
removed.push(pluginId);
} else {
skipped.push(pluginId);
}
}
await auth.setMetadata(
'pluginSources',
existing.filter(s => s !== pluginId),
);
if (removed.length > 0) {
await auth.setMetadata(
'pluginSources',
existing.filter(s => !removed.includes(s)),
);
process.stdout.write(
`Removed plugin source${removed.length > 1 ? 's' : ''}: ${removed.join(
', ',
)}\n`,
);
}
process.stdout.write(`Removed plugin source "${pluginId}".\n`);
for (const id of skipped) {
process.stderr.write(`Plugin source "${id}" is not configured.\n`);
}
};
@@ -40,7 +40,7 @@ describe('ActionsClient', () => {
expect(mockHttpJson).not.toHaveBeenCalled();
});
it('fetches actions from each plugin source', async () => {
it('fetches actions from each plugin source grouped by plugin', async () => {
const catalogActions = [
{
id: 'catalog:refresh',
@@ -75,7 +75,10 @@ describe('ActionsClient', () => {
headers: { Authorization: 'Bearer test-token' },
}),
);
expect(result).toEqual([...catalogActions, ...scaffolderActions]);
expect(result).toEqual([
{ pluginId: 'catalog', actions: catalogActions },
{ pluginId: 'scaffolder', actions: scaffolderActions },
]);
});
it('propagates errors from httpJson', async () => {
@@ -19,6 +19,7 @@ import { httpJson } from './httpJson';
export type ActionDef = {
id: string;
name: string;
title?: string;
description?: string;
schema: {
input: object;
@@ -51,32 +52,31 @@ function pluginActionsUrl(baseUrl: string, pluginId: string): string {
).toString();
}
export type GroupedActions = { pluginId: string; actions: ActionDef[] }[];
export class ActionsClient {
constructor(
private readonly baseUrl: string,
private readonly accessToken: string,
) {}
async list(pluginSources: string[]): Promise<ActionDef[]> {
const results: ActionDef[] = [];
for (const pluginId of pluginSources) {
const url = pluginActionsUrl(this.baseUrl, pluginId);
const response = await httpJson<ListActionsResponse>(url, {
headers: { Authorization: `Bearer ${this.accessToken}` },
signal: AbortSignal.timeout(30_000),
});
results.push(...response.actions);
}
return results;
async list(pluginSources: string[]): Promise<GroupedActions> {
return Promise.all(
pluginSources.map(async pluginId => {
const url = pluginActionsUrl(this.baseUrl, pluginId);
const response = await httpJson<ListActionsResponse>(url, {
headers: { Authorization: `Bearer ${this.accessToken}` },
signal: AbortSignal.timeout(30_000),
});
return { pluginId, actions: response.actions };
}),
);
}
async listForPlugin(actionId: string): Promise<ActionDef[]> {
const pluginId = extractPluginId(actionId);
return this.list([pluginId]);
const grouped = await this.list([pluginId]);
return grouped.flatMap(g => g.actions);
}
async execute(actionId: string, input?: unknown): Promise<unknown> {
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2025 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.
*/
// @types/marked-terminal only covers v6 and is incompatible with
// marked-terminal v7 + marked v15. This declaration covers our usage.
declare module 'marked-terminal' {
import type { MarkedExtension } from 'marked';
export function markedTerminal(): MarkedExtension;
}
@@ -0,0 +1,183 @@
/*
* Copyright 2025 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.
*/
import chalk from 'chalk';
import {
formatActionList,
formatActionHelp,
flagDefsToFlagInfo,
} from './format';
chalk.level = 0;
describe('formatActionList', () => {
it('renders grouped actions with headers and titles', () => {
const output = formatActionList([
{
pluginId: 'catalog',
actions: [
{
id: 'catalog:refresh',
name: 'refresh',
title: 'Refresh Entity',
description: 'Refreshes an entity',
schema: { input: {}, output: {} },
},
{
id: 'catalog:delete',
name: 'delete',
title: 'Delete Entity',
schema: { input: {}, output: {} },
},
],
},
]);
expect(output).toContain('── catalog ');
expect(output).toContain('catalog:refresh');
expect(output).toContain('Refresh Entity');
expect(output).toContain('catalog:delete');
expect(output).toContain('Delete Entity');
expect(output).not.toContain('Refreshes an entity');
});
it('shows only the id when the action has no title', () => {
const output = formatActionList([
{
pluginId: 'test',
actions: [
{
id: 'test:no-title',
name: 'no-title',
schema: { input: {}, output: {} },
},
],
},
]);
const actionLine = output
.split('\n')
.find(l => l.includes('test:no-title'));
expect(actionLine).toBeDefined();
expect(actionLine!.trim()).toBe('test:no-title');
});
it('renders multiple groups with blank line separators', () => {
const output = formatActionList([
{
pluginId: 'catalog',
actions: [
{
id: 'catalog:refresh',
name: 'refresh',
title: 'Refresh',
schema: { input: {}, output: {} },
},
],
},
{
pluginId: 'scaffolder',
actions: [
{
id: 'scaffolder:run',
name: 'run',
title: 'Run',
schema: { input: {}, output: {} },
},
],
},
]);
expect(output).toContain('── catalog ');
expect(output).toContain('── scaffolder ');
});
});
describe('flagDefsToFlagInfo', () => {
it('converts cleye flag defs to display-ready flag info', () => {
const result = flagDefsToFlagInfo({
name: { type: String, description: 'The name' },
count: { type: Number, description: 'How many' },
verbose: { type: Boolean, description: 'Verbose output' },
});
expect(result).toEqual([
{ name: 'name', type: 'string', description: 'The name' },
{ name: 'count', type: 'number', description: 'How many' },
{ name: 'verbose', type: '', description: 'Verbose output' },
]);
});
});
describe('formatActionHelp', () => {
it('renders action id, title, description, usage, and flags', async () => {
const output = await formatActionHelp({
action: {
id: 'catalog:refresh',
title: 'Refresh Entity',
description:
'Refreshes a **catalog** entity from its `source` location.',
},
usage: 'backstage-cli actions execute catalog:refresh',
flags: [
{
name: 'entity-ref',
type: 'string',
description: 'Entity reference (required)',
},
{
name: 'dry-run',
type: 'boolean',
description: 'Preview without making changes',
},
],
});
expect(output).toContain('catalog:refresh');
expect(output).toContain('Refresh Entity');
expect(output).toContain('catalog');
expect(output).toContain('source');
expect(output).toContain('Usage:');
expect(output).toContain('backstage-cli actions execute catalog:refresh');
expect(output).toContain('Flags:');
expect(output).toContain('--entity-ref');
expect(output).toContain('--dry-run');
});
it('renders without description when not provided', async () => {
const output = await formatActionHelp({
action: { id: 'catalog:refresh', title: 'Refresh' },
usage: 'backstage-cli actions execute catalog:refresh',
flags: [],
});
expect(output).toContain('catalog:refresh');
expect(output).toContain('Refresh');
expect(output).toContain('Usage:');
expect(output).not.toContain('Flags:');
});
it('renders without title when not provided', async () => {
const output = await formatActionHelp({
action: { id: 'catalog:refresh' },
usage: 'backstage-cli actions execute catalog:refresh',
flags: [],
});
expect(output).toContain('catalog:refresh');
expect(output).toContain('Usage:');
});
});
@@ -0,0 +1,159 @@
/*
* Copyright 2025 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.
*/
import chalk from 'chalk';
import type { GroupedActions } from './ActionsClient';
import type { CleyeFlag } from './schemaToFlags';
async function renderMarkdown(text: string): Promise<string> {
const { Marked } = await import('marked');
const { markedTerminal } = await import('marked-terminal');
const stripAnsi = (await import('strip-ansi')).default;
const instance = new Marked(markedTerminal());
const sanitized = stripAnsi(text);
return instance.parse(sanitized) as string;
}
function dedent(text: string): string {
const trimmed = text.replace(/^\n+/, '').replace(/\n+$/, '');
const lines = trimmed.split('\n');
const nonEmptyLines = lines.filter(l => l.trim().length > 0);
if (nonEmptyLines.length === 0) return trimmed;
const minIndent = Math.min(
...nonEmptyLines.map(l => l.match(/^(\s*)/)![0].length),
);
if (minIndent === 0) return trimmed;
return lines.map(l => l.slice(minIndent)).join('\n');
}
function terminalWidth(): number {
return process.stdout.columns || 80;
}
export function formatActionList(grouped: GroupedActions): string {
const width = terminalWidth();
const lines: string[] = [];
for (let i = 0; i < grouped.length; i++) {
const { pluginId, actions } = grouped[i];
if (i > 0) {
lines.push('');
}
const header = `── ${pluginId} `;
const remaining = Math.max(0, width - header.length);
lines.push(chalk.bold(`${header}${'─'.repeat(remaining)}`));
lines.push('');
if (actions.length === 0) {
lines.push(` ${chalk.dim('(no actions)')}`);
continue;
}
const maxIdLen = Math.max(...actions.map(a => a.id.length));
const idColWidth = maxIdLen + 4;
for (const action of actions) {
const paddedId = action.id.padEnd(idColWidth);
if (!action.title) {
lines.push(` ${chalk.cyan(paddedId.trimEnd())}`);
continue;
}
lines.push(` ${chalk.cyan(paddedId)}${chalk.dim(action.title)}`);
}
}
return lines.join('\n');
}
export type FlagInfo = {
name: string;
type: string;
description?: string;
};
const typeHintNames: Record<string, string> = {
String: 'string',
Number: 'number',
Boolean: '',
};
export function flagDefsToFlagInfo(
defs: Record<string, CleyeFlag>,
): FlagInfo[] {
return Object.entries(defs).map(([name, def]) => ({
name,
type: typeHintNames[def.type.name] ?? 'string',
description: def.description,
}));
}
export async function formatActionHelp(options: {
action: {
id: string;
title?: string;
description?: string;
};
usage: string;
flags: FlagInfo[];
}): Promise<string> {
const { action, usage, flags } = options;
const lines: string[] = [];
lines.push(chalk.bold.cyan(action.id));
if (action.title) {
lines.push(` ${action.title}`);
}
if (action.description) {
lines.push('');
const dedented = dedent(action.description);
const rendered = await renderMarkdown(dedented);
lines.push(rendered.trimEnd());
}
lines.push('');
lines.push(chalk.bold('Usage:'));
lines.push(` ${usage} ${chalk.dim('[flags]')}`);
if (flags.length > 0) {
lines.push('');
lines.push(chalk.bold('Flags:'));
const maxFlagLen = Math.max(
...flags.map(f => {
const typeHint = f.type ? ` <${f.type}>` : '';
return ` --${f.name}${typeHint}`.length;
}),
);
const colWidth = maxFlagLen + 4;
for (const flag of flags) {
const typeHint = flag.type ? ` <${flag.type}>` : '';
const left = ` --${flag.name}${typeHint}`.padEnd(colWidth);
const desc = flag.description ? chalk.dim(flag.description) : '';
lines.push(`${left}${desc}`);
}
}
lines.push('');
return lines.join('\n');
}
@@ -33,7 +33,12 @@ export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
},
});
if (!res.ok) {
throw await ResponseError.fromResponse(res);
const responseError = await ResponseError.fromResponse(res);
const causeMessage = responseError.cause?.message;
throw new Error(
causeMessage || `Request failed with ${res.status} ${res.statusText}`,
{ cause: responseError },
);
}
return (await res.json()) as T;
}
@@ -0,0 +1,19 @@
/*
* Copyright 2025 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.
*/
import { z } from 'zod/v3';
export const pluginSourcesSchema = z.array(z.string()).default([]);
@@ -15,9 +15,7 @@
*/
import { CliAuth } from '@backstage/cli-node';
import { z } from 'zod/v3';
const pluginSourcesSchema = z.array(z.string()).default([]);
import { pluginSourcesSchema } from './pluginSources';
export async function resolveAuth(instanceFlag?: string): Promise<{
baseUrl: string;
@@ -17,13 +17,19 @@
import { schemaToFlags } from './schemaToFlags';
describe('schemaToFlags', () => {
it('returns empty object when schema has no properties', () => {
expect(schemaToFlags({})).toEqual({});
expect(schemaToFlags({ properties: {} })).toEqual({});
it('returns empty results when schema has no properties', () => {
expect(schemaToFlags({})).toEqual({
flags: {},
complexKeys: new Set(),
});
expect(schemaToFlags({ properties: {} })).toEqual({
flags: {},
complexKeys: new Set(),
});
});
it('converts string properties to String flags', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
myProp: { type: 'string', description: 'A string prop' },
},
@@ -35,7 +41,7 @@ describe('schemaToFlags', () => {
});
it('converts number and integer properties to Number flags', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
count: { type: 'integer' },
amount: { type: 'number', description: 'An amount' },
@@ -47,7 +53,7 @@ describe('schemaToFlags', () => {
});
it('converts boolean properties to Boolean flags', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
verbose: { type: 'boolean', description: 'Enable verbose output' },
},
@@ -59,20 +65,50 @@ describe('schemaToFlags', () => {
});
});
it('skips non-primitive properties like object and array', () => {
const flags = schemaToFlags({
it('maps object and array properties to String flags with JSON hint', () => {
const { flags, complexKeys } = schemaToFlags({
properties: {
name: { type: 'string' },
metadata: { type: 'object' },
metadata: { type: 'object', description: 'Entity metadata' },
tags: { type: 'array' },
},
});
expect(Object.keys(flags)).toEqual(['name']);
expect(Object.keys(flags)).toEqual(['name', 'metadata', 'tags']);
expect(flags.metadata).toEqual({
type: String,
description: 'Entity metadata (JSON)',
});
expect(flags.tags).toEqual({ type: String, description: '(JSON)' });
expect(complexKeys).toEqual(new Set(['metadata', 'tags']));
});
it('maps anyOf, oneOf, and allOf properties to String flags with JSON hint', () => {
const { flags, complexKeys } = schemaToFlags({
properties: {
orderFields: { anyOf: [{}, {}], description: 'Sort order' },
filter: { oneOf: [{}, {}], description: 'Filter criteria' },
combined: { allOf: [{}, {}], description: 'Combined schema' },
},
});
expect(flags.orderFields).toEqual({
type: String,
description: 'Sort order (JSON)',
});
expect(flags.filter).toEqual({
type: String,
description: 'Filter criteria (JSON)',
});
expect(flags.combined).toEqual({
type: String,
description: 'Combined schema (JSON)',
});
expect(complexKeys).toEqual(new Set(['orderFields', 'filter', 'combined']));
});
it('skips properties with no type or composite types', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
noType: {},
name: { type: 'string' },
@@ -83,7 +119,7 @@ describe('schemaToFlags', () => {
});
it('uses first type when type is an array', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
value: { type: ['string', 'null'] },
},
@@ -93,7 +129,7 @@ describe('schemaToFlags', () => {
});
it('appends enum values to description', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
color: {
type: 'string',
@@ -109,7 +145,7 @@ describe('schemaToFlags', () => {
});
it('marks required fields in description', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
name: { type: 'string', description: 'The name' },
optional: { type: 'string', description: 'Optional field' },
@@ -124,7 +160,7 @@ describe('schemaToFlags', () => {
});
it('applies default values from schema', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
count: { type: 'number', default: 10 },
name: { type: 'string' },
@@ -136,7 +172,7 @@ describe('schemaToFlags', () => {
});
it('combines enum and required in description', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
env: {
type: 'string',
@@ -151,7 +187,7 @@ describe('schemaToFlags', () => {
});
it('preserves camelCase property names as flag keys', () => {
const flags = schemaToFlags({
const { flags } = schemaToFlags({
properties: {
targetEntityRef: { type: 'string' },
maxResults: { type: 'integer' },
@@ -19,6 +19,9 @@ type JsonSchemaProperty = {
description?: string;
enum?: unknown[];
default?: unknown;
anyOf?: unknown[];
oneOf?: unknown[];
allOf?: unknown[];
};
type JsonSchemaObject = {
@@ -26,37 +29,64 @@ type JsonSchemaObject = {
required?: string[];
};
type CleyeFlag = {
export type CleyeFlag = {
type: StringConstructor | NumberConstructor | BooleanConstructor;
description?: string;
default?: unknown;
};
export function schemaToFlags(
schema: JsonSchemaObject,
): Record<string, CleyeFlag> {
function isComplexType(prop: JsonSchemaProperty): boolean {
if (prop.anyOf || prop.oneOf || prop.allOf) {
return true;
}
const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type;
return rawType === 'object' || rawType === 'array';
}
function resolveFlagType(
rawType: string | undefined,
): StringConstructor | NumberConstructor | BooleanConstructor | undefined {
if (rawType === 'string') return String;
if (rawType === 'number' || rawType === 'integer') return Number;
if (rawType === 'boolean') return Boolean;
return undefined;
}
export function schemaToFlags(schema: JsonSchemaObject): {
flags: Record<string, CleyeFlag>;
complexKeys: Set<string>;
} {
const flags: Record<string, CleyeFlag> = {};
const complexKeys = new Set<string>();
const required = new Set(schema.required ?? []);
if (!schema.properties) {
return flags;
return { flags, complexKeys };
}
for (const [key, prop] of Object.entries(schema.properties)) {
const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type;
const complex = isComplexType(prop);
let flagType = resolveFlagType(rawType);
let flagType: StringConstructor | NumberConstructor | BooleanConstructor;
if (rawType === 'string') {
if (!flagType && complex) {
flagType = String;
} else if (rawType === 'number' || rawType === 'integer') {
flagType = Number;
} else if (rawType === 'boolean') {
flagType = Boolean;
} else {
}
if (!flagType) {
continue;
}
if (complex) {
complexKeys.add(key);
}
let desc = prop.description ?? '';
if (complex) {
desc = desc ? `${desc} (JSON)` : '(JSON)';
}
if (prop.enum?.length) {
const values = prop.enum.map(v => String(v)).join(', ');
desc = desc ? `${desc} [${values}]` : `[${values}]`;
@@ -73,5 +103,5 @@ export function schemaToFlags(
flags[key] = flag;
}
return flags;
return { flags, complexKeys };
}