diff --git a/.changeset/fix-auth-login-metadata.md b/.changeset/fix-auth-login-metadata.md new file mode 100644 index 0000000000..7cb64b1474 --- /dev/null +++ b/.changeset/fix-auth-login-metadata.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-auth': patch +--- + +Fixed `auth login` clearing previously configured action sources and other instance metadata when re-authenticating. diff --git a/.changeset/fix-execute-help-schema-flags.md b/.changeset/fix-execute-help-schema-flags.md new file mode 100644 index 0000000000..fb42d89d78 --- /dev/null +++ b/.changeset/fix-execute-help-schema-flags.md @@ -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. diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json index f24944be01..b8012df0ea 100644 --- a/packages/cli-module-actions/package.json +++ b/packages/cli-module-actions/package.json @@ -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": { diff --git a/packages/cli-module-actions/src/commands/execute.test.ts b/packages/cli-module-actions/src/commands/execute.test.ts new file mode 100644 index 0000000000..dddbc2aba3 --- /dev/null +++ b/packages/cli-module-actions/src/commands/execute.test.ts @@ -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; +const mockResolveAuth = resolveAuth as jest.MockedFunction; + +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; + let stdoutSpy: jest.SpiedFunction; + + 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; + 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; + 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; + expect(cliCall.parameters).toEqual(['']); + 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(['']); + }); + + 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.', + ); + }); +}); diff --git a/packages/cli-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts index f130c597ce..27bf10d361 100644 --- a/packages/cli-module-actions/src/commands/execute.ts +++ b/packages/cli-module-actions/src/commands/execute.ts @@ -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: [''], - 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(); 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: [''], + 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 { + 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 [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; const input: Record = {}; 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; } } diff --git a/packages/cli-module-actions/src/commands/list.ts b/packages/cli-module-actions/src/commands/list.ts index 2049fc3e44..aac4fa9864 100644 --- a/packages/cli-module-actions/src/commands/list.ts +++ b/packages/cli-module-actions/src/commands/list.ts @@ -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`); }; diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.test.ts b/packages/cli-module-actions/src/commands/sourcesAdd.test.ts new file mode 100644 index 0000000000..ab8685903d --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesAdd.test.ts @@ -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; + let stderrSpy: jest.SpiedFunction; + + 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.', + ); + }); +}); diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts index fee3007637..2e0d755e6e 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -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: [''], + parameters: [''], }, 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`); + } }; diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts index 7557922c06..fe5f7c4080 100644 --- a/packages/cli-module-actions/src/commands/sourcesList.ts +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -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); diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.test.ts b/packages/cli-module-actions/src/commands/sourcesRemove.test.ts new file mode 100644 index 0000000000..04227475fa --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesRemove.test.ts @@ -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; + let stderrSpy: jest.SpiedFunction; + + 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.'); + }); +}); diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts index 9225228503..eb88da5a59 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -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: [''], + parameters: [''], }, 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`); + } }; diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts index 3aa2e06dc7..ffe0c9a698 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.test.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -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 () => { diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts index 736a3b7f37..5a293563d5 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -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 { - const results: ActionDef[] = []; - - for (const pluginId of pluginSources) { - const url = pluginActionsUrl(this.baseUrl, pluginId); - - const response = await httpJson(url, { - headers: { Authorization: `Bearer ${this.accessToken}` }, - signal: AbortSignal.timeout(30_000), - }); - - results.push(...response.actions); - } - - return results; + async list(pluginSources: string[]): Promise { + return Promise.all( + pluginSources.map(async pluginId => { + const url = pluginActionsUrl(this.baseUrl, pluginId); + const response = await httpJson(url, { + headers: { Authorization: `Bearer ${this.accessToken}` }, + signal: AbortSignal.timeout(30_000), + }); + return { pluginId, actions: response.actions }; + }), + ); } async listForPlugin(actionId: string): Promise { 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 { diff --git a/packages/cli-module-actions/src/lib/declarations.d.ts b/packages/cli-module-actions/src/lib/declarations.d.ts new file mode 100644 index 0000000000..7476d1a197 --- /dev/null +++ b/packages/cli-module-actions/src/lib/declarations.d.ts @@ -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; +} diff --git a/packages/cli-module-actions/src/lib/format.test.ts b/packages/cli-module-actions/src/lib/format.test.ts new file mode 100644 index 0000000000..27ebbe6514 --- /dev/null +++ b/packages/cli-module-actions/src/lib/format.test.ts @@ -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:'); + }); +}); diff --git a/packages/cli-module-actions/src/lib/format.ts b/packages/cli-module-actions/src/lib/format.ts new file mode 100644 index 0000000000..b99f5435ec --- /dev/null +++ b/packages/cli-module-actions/src/lib/format.ts @@ -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 { + 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', + Number: 'number', + Boolean: '', +}; + +export function flagDefsToFlagInfo( + defs: Record, +): 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 { + 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'); +} diff --git a/packages/cli-module-actions/src/lib/httpJson.ts b/packages/cli-module-actions/src/lib/httpJson.ts index 0bea9dab94..28fb5118de 100644 --- a/packages/cli-module-actions/src/lib/httpJson.ts +++ b/packages/cli-module-actions/src/lib/httpJson.ts @@ -33,7 +33,12 @@ export async function httpJson(url: string, init?: HttpInit): Promise { }, }); 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; } diff --git a/packages/cli-module-actions/src/lib/pluginSources.ts b/packages/cli-module-actions/src/lib/pluginSources.ts new file mode 100644 index 0000000000..cc766efbdf --- /dev/null +++ b/packages/cli-module-actions/src/lib/pluginSources.ts @@ -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([]); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts index 568bd4cdcd..0e52817733 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -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; diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.test.ts b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts index fa9f446aae..e7ea533c36 100644 --- a/packages/cli-module-actions/src/lib/schemaToFlags.test.ts +++ b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts @@ -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' }, diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.ts b/packages/cli-module-actions/src/lib/schemaToFlags.ts index 026613edaf..646eb8769f 100644 --- a/packages/cli-module-actions/src/lib/schemaToFlags.ts +++ b/packages/cli-module-actions/src/lib/schemaToFlags.ts @@ -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 { +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; + complexKeys: Set; +} { const flags: Record = {}; + const complexKeys = new Set(); 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 }; } diff --git a/packages/cli-module-auth/src/commands/login.test.ts b/packages/cli-module-auth/src/commands/login.test.ts new file mode 100644 index 0000000000..fcf3a2de59 --- /dev/null +++ b/packages/cli-module-auth/src/commands/login.test.ts @@ -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; + let stderrSpy: jest.SpiedFunction; + + 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, + }), + ); + }); +}); diff --git a/packages/cli-module-auth/src/commands/login.ts b/packages/cli-module-auth/src/commands/login.ts index 5320bae726..b3d8114cff 100644 --- a/packages/cli-module-auth/src/commands/login.ts +++ b/packages/cli-module-auth/src/commands/login.ts @@ -343,6 +343,7 @@ async function persistInstance(options: { issuedAt: Date.now(), accessTokenExpiresAt: Date.now() + token.expires_in * 1000, selected: existing?.selected, + metadata: existing?.metadata, }); }); } diff --git a/yarn.lock b/yarn.lock index 6e84574e2c..278883c4ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: