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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-module-auth': patch
---
Fixed `auth login` clearing previously configured action sources and other instance metadata when re-authenticating.
@@ -0,0 +1,5 @@
---
'@backstage/cli-module-actions': minor
---
Added improved CLI output formatting and UX for the actions module. The `list` command now groups actions by plugin source with colored headers and action titles. The `execute --help` command renders full action details including markdown descriptions. Complex schema types like objects, arrays, and union types are now accepted as JSON flags. Error messages from the server are now surfaced directly. The `sources add` and `sources remove` commands accept multiple plugin IDs at once.
+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 };
}
@@ -0,0 +1,182 @@
/*
* 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 mockUpsertInstance = jest.fn();
const mockGetInstanceByName = jest.fn();
const mockWithMetadataLock = jest
.fn()
.mockImplementation((fn: Function) => fn());
const mockSecretStoreSet = jest.fn();
jest.mock('../lib/storage', () => ({
upsertInstance: (...args: any[]) => mockUpsertInstance(...args),
getInstanceByName: (...args: any[]) => mockGetInstanceByName(...args),
withMetadataLock: (...args: any[]) => mockWithMetadataLock(...args),
getAllInstances: jest
.fn()
.mockResolvedValue({ instances: [], selected: undefined }),
}));
jest.mock('@internal/cli', () => ({
getSecretStore: jest.fn().mockResolvedValue({
set: (...args: any[]) => mockSecretStoreSet(...args),
}),
getAuthInstanceService: jest.fn().mockReturnValue('test-service'),
}));
jest.mock('cleye', () => ({
cli: jest.fn().mockReturnValue({
flags: {
backendUrl: 'https://backstage.example.com',
noBrowser: true,
instance: 'test-instance',
},
}),
}));
const mockWaitForCode = jest.fn().mockResolvedValue({
code: 'test-code',
state: 'test-state',
});
const mockClose = jest.fn();
jest.mock('../lib/localServer', () => ({
startCallbackServer: jest.fn().mockResolvedValue({
url: 'http://localhost:9999/callback',
waitForCode: () => mockWaitForCode(),
close: () => mockClose(),
}),
}));
jest.mock('../lib/pkce', () => ({
generateVerifier: jest.fn().mockReturnValue('test-verifier'),
challengeFromVerifier: jest.fn().mockReturnValue('test-challenge'),
}));
jest.mock('../lib/http', () => ({
httpJson: jest.fn().mockResolvedValue({
access_token: 'new-access-token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token',
}),
}));
jest.mock('node:crypto', () => ({
randomBytes: jest.fn().mockReturnValue({
toString: () => 'test-state',
}),
}));
jest.mock('node:child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs-extra', () => ({ readFile: jest.fn() }));
jest.mock('glob', () => ({ sync: jest.fn().mockReturnValue([]) }));
jest.mock('yaml', () => ({ parse: jest.fn() }));
jest.mock('inquirer', () => ({ prompt: jest.fn() }));
import loginCommand from './login';
// Mock global fetch for the metadata endpoint check
const originalFetch = global.fetch;
beforeAll(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
}) as any;
});
afterAll(() => {
global.fetch = originalFetch;
});
const baseContext: CliCommandContext = {
args: [],
info: { name: 'login', description: 'Log in to Backstage' },
} as unknown as CliCommandContext;
describe('login command - metadata preservation', () => {
let stdoutSpy: jest.SpiedFunction<typeof process.stdout.write>;
let stderrSpy: jest.SpiedFunction<typeof process.stderr.write>;
beforeEach(() => {
jest.clearAllMocks();
mockWithMetadataLock.mockImplementation((fn: Function) => fn());
stdoutSpy = jest
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
stderrSpy = jest
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});
it('preserves metadata and selected flag when re-logging into an existing instance', async () => {
mockGetInstanceByName.mockResolvedValue({
name: 'test-instance',
baseUrl: 'https://backstage.example.com',
clientId: 'old-client',
issuedAt: 1000,
accessTokenExpiresAt: 2000,
selected: true,
metadata: { pluginSources: ['catalog', 'scaffolder'] },
});
await loginCommand({
...baseContext,
args: [
'--backendUrl',
'https://backstage.example.com',
'--instance',
'test-instance',
'--noBrowser',
],
});
expect(mockUpsertInstance).toHaveBeenCalledWith(
expect.objectContaining({
name: 'test-instance',
selected: true,
metadata: { pluginSources: ['catalog', 'scaffolder'] },
}),
);
});
it('sets metadata and selected to undefined for a new instance', async () => {
mockGetInstanceByName.mockRejectedValue(new Error('Not found'));
await loginCommand({
...baseContext,
args: [
'--backendUrl',
'https://backstage.example.com',
'--instance',
'new-instance',
'--noBrowser',
],
});
expect(mockUpsertInstance).toHaveBeenCalledWith(
expect.objectContaining({
selected: undefined,
metadata: undefined,
}),
);
});
});
@@ -343,6 +343,7 @@ async function persistInstance(options: {
issuedAt: Date.now(),
accessTokenExpiresAt: Date.now() + token.expires_in * 1000,
selected: existing?.selected,
metadata: existing?.metadata,
});
});
}
+144 -10
View File
@@ -2832,7 +2832,11 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/cli-node": "workspace:^"
"@backstage/errors": "workspace:^"
chalk: "npm:^4.0.0"
cleye: "npm:^2.3.0"
marked: "npm:^15.0.12"
marked-terminal: "npm:^7.3.0"
strip-ansi: "npm:^7.1.0"
zod: "npm:^3.25.76 || ^4.0.0"
bin:
cli-module-actions: bin/backstage-cli-module-actions
@@ -8389,6 +8393,13 @@ __metadata:
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
checksum: 10/9d226461c1e91e95f067be2bdc5e6f99cfe55a721f45afb44122e23e4b8602eeac4ff7325af6b5a369f36396ee1514d3809af3f57769066d80d83790d8e53339
languageName: node
linkType: hard
"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0":
version: 1.6.0
resolution: "@colors/colors@npm:1.6.0"
@@ -18043,10 +18054,10 @@ __metadata:
languageName: node
linkType: hard
"@sindresorhus/is@npm:^4.0.0":
version: 4.0.0
resolution: "@sindresorhus/is@npm:4.0.0"
checksum: 10/850804ccabb3e85b8c1395777a3b0e816bfcc308d4b7a65142bfd657f0753c31866ae5245f7a6d74d08191a4e63edab59c75b96bd19140d37b104efd683709d1
"@sindresorhus/is@npm:^4.0.0, @sindresorhus/is@npm:^4.6.0":
version: 4.6.0
resolution: "@sindresorhus/is@npm:4.6.0"
checksum: 10/e7f36ed72abfcd5e0355f7423a72918b9748bb1ef370a59f3e5ad8d40b728b85d63b272f65f63eec1faf417cda89dcb0aeebe94015647b6054659c1442fe5ce0
languageName: node
linkType: hard
@@ -24538,7 +24549,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-regex@npm:*, ansi-regex@npm:^6.0.1, ansi-regex@npm:^6.2.2":
"ansi-regex@npm:*, ansi-regex@npm:^6.0.1, ansi-regex@npm:^6.1.0, ansi-regex@npm:^6.2.2":
version: 6.2.2
resolution: "ansi-regex@npm:6.2.2"
checksum: 10/9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f
@@ -26476,6 +26487,13 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^5.4.1":
version: 5.6.2
resolution: "chalk@npm:5.6.2"
checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0
languageName: node
linkType: hard
"char-regex@npm:^1.0.2":
version: 1.0.2
resolution: "char-regex@npm:1.0.2"
@@ -26743,6 +26761,22 @@ __metadata:
languageName: node
linkType: hard
"cli-highlight@npm:^2.1.11":
version: 2.1.11
resolution: "cli-highlight@npm:2.1.11"
dependencies:
chalk: "npm:^4.0.0"
highlight.js: "npm:^10.7.1"
mz: "npm:^2.4.0"
parse5: "npm:^5.1.1"
parse5-htmlparser2-tree-adapter: "npm:^6.0.0"
yargs: "npm:^16.0.0"
bin:
highlight: bin/highlight
checksum: 10/05d2b5beb8a4d3259f693517d013bf53d04ad20f470b77c3d02e051963092fae388388e3127f67d3679884a0c32cb855bf590292017c5e68c0f8d86f4b8e146e
languageName: node
linkType: hard
"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.9.2":
version: 2.9.2
resolution: "cli-spinners@npm:2.9.2"
@@ -26762,6 +26796,19 @@ __metadata:
languageName: node
linkType: hard
"cli-table3@npm:^0.6.5":
version: 0.6.5
resolution: "cli-table3@npm:0.6.5"
dependencies:
"@colors/colors": "npm:1.5.0"
string-width: "npm:^4.2.0"
dependenciesMeta:
"@colors/colors":
optional: true
checksum: 10/8dca71256f6f1367bab84c33add3f957367c7c43750a9828a4212ebd31b8df76bd7419d386e3391ac7419698a8540c25f1a474584028f35b170841cde2e055c5
languageName: node
linkType: hard
"cli-table@npm:^0.3.1, cli-table@npm:^0.3.11":
version: 0.3.11
resolution: "cli-table@npm:0.3.11"
@@ -29492,6 +29539,13 @@ __metadata:
languageName: node
linkType: hard
"emojilib@npm:^2.4.0":
version: 2.4.0
resolution: "emojilib@npm:2.4.0"
checksum: 10/bef767eca49acaa881388d91bee6936ea57ae367d603d5227ff0a9da3e2d1e774a61c447e5f2f4901797d023c4b5239bc208285b6172a880d3655024a0f44980
languageName: node
linkType: hard
"emojis-list@npm:^3.0.0":
version: 3.0.0
resolution: "emojis-list@npm:3.0.0"
@@ -33319,7 +33373,7 @@ __metadata:
languageName: node
linkType: hard
"highlight.js@npm:^10.1.0, highlight.js@npm:^10.4.1, highlight.js@npm:^10.7.2, highlight.js@npm:~10.7.0":
"highlight.js@npm:^10.1.0, highlight.js@npm:^10.4.1, highlight.js@npm:^10.7.1, highlight.js@npm:^10.7.2, highlight.js@npm:~10.7.0":
version: 10.7.3
resolution: "highlight.js@npm:10.7.3"
checksum: 10/db8d10a541936b058e221dbde77869664b2b45bca75d660aa98065be2cd29f3924755fbc7348213f17fd931aefb6e6597448ba6fe82afba6d8313747a91983ee
@@ -37809,6 +37863,32 @@ __metadata:
languageName: node
linkType: hard
"marked-terminal@npm:^7.3.0":
version: 7.3.0
resolution: "marked-terminal@npm:7.3.0"
dependencies:
ansi-escapes: "npm:^7.0.0"
ansi-regex: "npm:^6.1.0"
chalk: "npm:^5.4.1"
cli-highlight: "npm:^2.1.11"
cli-table3: "npm:^0.6.5"
node-emoji: "npm:^2.2.0"
supports-hyperlinks: "npm:^3.1.0"
peerDependencies:
marked: ">=1 <16"
checksum: 10/1dfdfe752a4ebe6aec8de4a51180612a5f29982026b104a86215efb46b82b2a1942531a6bb840163c8d827e3eadc5cf93272e6eb29ec549f72b73b8b2eb97cfe
languageName: node
linkType: hard
"marked@npm:^15.0.12":
version: 15.0.12
resolution: "marked@npm:15.0.12"
bin:
marked: bin/marked.js
checksum: 10/deeb619405c0c46af00c99b18b3365450abeb309104b24e3658f46142344f6b7c4117608c3b5834084d8738e92f81240c19f596e6ee369260f96e52b3457eaee
languageName: node
linkType: hard
"marked@npm:^4.0.14":
version: 4.3.0
resolution: "marked@npm:4.3.0"
@@ -39400,7 +39480,7 @@ __metadata:
languageName: node
linkType: hard
"mz@npm:^2.7.0":
"mz@npm:^2.4.0, mz@npm:^2.7.0":
version: 2.7.0
resolution: "mz@npm:2.7.0"
dependencies:
@@ -39676,6 +39756,18 @@ __metadata:
languageName: node
linkType: hard
"node-emoji@npm:^2.2.0":
version: 2.2.0
resolution: "node-emoji@npm:2.2.0"
dependencies:
"@sindresorhus/is": "npm:^4.6.0"
char-regex: "npm:^1.0.2"
emojilib: "npm:^2.4.0"
skin-tone: "npm:^2.0.0"
checksum: 10/2548668f5cc9f781c94dc39971a630b2887111e0970c29fc523e924819d1b39b53a2694a4d1046861adf538c4462d06ee0269c48717ccad30336a918d9a911d5
languageName: node
linkType: hard
"node-fetch-commonjs@npm:^3.3.2":
version: 3.3.2
resolution: "node-fetch-commonjs@npm:3.3.2"
@@ -41206,7 +41298,23 @@ __metadata:
languageName: node
linkType: hard
"parse5@npm:^6.0.0":
"parse5-htmlparser2-tree-adapter@npm:^6.0.0":
version: 6.0.1
resolution: "parse5-htmlparser2-tree-adapter@npm:6.0.1"
dependencies:
parse5: "npm:^6.0.1"
checksum: 10/3400a2cd1ad450b2fe148544154f86ea53d3ed6b6eab56c78bb43b9629d3dfe9f580dffd75bbf32be134ffef645b68081fc764bf75c210f236ab9c5c8c38c252
languageName: node
linkType: hard
"parse5@npm:^5.1.1":
version: 5.1.1
resolution: "parse5@npm:5.1.1"
checksum: 10/5b509744cfe81488a33be05578df490c460690e64519fa67f0a0acb9c1bca05914e8acad17a977e2cf5964a000e43959b40024f0c243dd6595dd0cca8a32f71b
languageName: node
linkType: hard
"parse5@npm:^6.0.0, parse5@npm:^6.0.1":
version: 6.0.1
resolution: "parse5@npm:6.0.1"
checksum: 10/dfb110581f62bd1425725a7c784ae022a24669bd0efc24b58c71fc731c4d868193e2ebd85b74cde2dbb965e4dcf07059b1e651adbec1b3b5267531bd132fdb75
@@ -46147,6 +46255,15 @@ __metadata:
languageName: node
linkType: hard
"skin-tone@npm:^2.0.0":
version: 2.0.0
resolution: "skin-tone@npm:2.0.0"
dependencies:
unicode-emoji-modifier-base: "npm:^1.0.0"
checksum: 10/19de157586b8019cacc55eb25d9d640f00fc02415761f3e41a4527142970fd4e7f6af0333bc90e879858766c20a976107bb386ffd4c812289c01d51f2c8d182c
languageName: node
linkType: hard
"slash@npm:^3.0.0":
version: 3.0.0
resolution: "slash@npm:3.0.0"
@@ -47384,7 +47501,7 @@ __metadata:
languageName: node
linkType: hard
"supports-color@npm:^7, supports-color@npm:^7.1.0":
"supports-color@npm:^7, supports-color@npm:^7.0.0, supports-color@npm:^7.1.0":
version: 7.2.0
resolution: "supports-color@npm:7.2.0"
dependencies:
@@ -47393,6 +47510,16 @@ __metadata:
languageName: node
linkType: hard
"supports-hyperlinks@npm:^3.1.0":
version: 3.2.0
resolution: "supports-hyperlinks@npm:3.2.0"
dependencies:
has-flag: "npm:^4.0.0"
supports-color: "npm:^7.0.0"
checksum: 10/f7924de6049fc30bc808f98d3561318c1a4e3d55d786f9fede5e23dc5a7b0f625485bd1143135b496d521ccd0110463f2c077eb71a4ce0cf783b8b31f7909242
languageName: node
linkType: hard
"supports-preserve-symlinks-flag@npm:^1.0.0":
version: 1.0.0
resolution: "supports-preserve-symlinks-flag@npm:1.0.0"
@@ -49018,6 +49145,13 @@ __metadata:
languageName: node
linkType: hard
"unicode-emoji-modifier-base@npm:^1.0.0":
version: 1.0.0
resolution: "unicode-emoji-modifier-base@npm:1.0.0"
checksum: 10/6e1521d35fa69493207eb8b41f8edb95985d8b3faf07c01d820a1830b5e8403e20002563e2f84683e8e962a49beccae789f0879356bf92a4ec7a4dd8e2d16fdb
languageName: node
linkType: hard
"unified@npm:^10.0.0":
version: 10.1.0
resolution: "unified@npm:10.1.0"
@@ -50725,7 +50859,7 @@ __metadata:
languageName: node
linkType: hard
"yargs@npm:^16.2.0":
"yargs@npm:^16.0.0, yargs@npm:^16.2.0":
version: 16.2.0
resolution: "yargs@npm:16.2.0"
dependencies: