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:
@@ -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> {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user