From 42960f1db799d9cdb289a08edb185dbd43d0ca80 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Mon, 16 Mar 2026 20:09:15 +0100 Subject: [PATCH] feat: add actions CLI module for distributed actions registry Adds @backstage/cli-module-actions with commands for listing and executing actions from the distributed actions registry. Exports auth helpers from cli-module-auth for cross-module reuse. Relaxes the actions registry auth check to allow direct user invocations from the CLI. Signed-off-by: benjdlambert --- .changeset/add-actions-cli-module.md | 5 + .changeset/allow-user-action-invocation.md | 5 + .changeset/auth-module-exports.md | 5 + .changeset/cli-defaults-actions.md | 5 + .../DefaultActionsRegistryService.ts | 10 +- .../actionsRegistryServiceFactory.test.ts | 10 +- packages/cli-defaults/package.json | 1 + packages/cli-defaults/src/index.ts | 2 + packages/cli-module-actions/.eslintrc.js | 1 + packages/cli-module-actions/catalog-info.yaml | 10 ++ packages/cli-module-actions/package.json | 42 +++++ packages/cli-module-actions/report.api.md | 13 ++ .../src/commands/execute.ts | 76 ++++++++ .../cli-module-actions/src/commands/list.ts | 62 +++++++ .../src/commands/sourcesAdd.ts | 54 ++++++ .../src/commands/sourcesList.ts | 39 +++++ .../src/commands/sourcesRemove.ts | 53 ++++++ packages/cli-module-actions/src/index.ts | 49 ++++++ .../src/lib/ActionsClient.test.ts | 129 ++++++++++++++ .../src/lib/ActionsClient.ts | 98 +++++++++++ .../cli-module-actions/src/lib/resolveAuth.ts | 48 ++++++ .../src/lib/schemaToFlags.test.ts | 163 ++++++++++++++++++ .../src/lib/schemaToFlags.ts | 77 +++++++++ packages/cli-module-auth/report.api.md | 58 +++++++ packages/cli-module-auth/src/index.ts | 14 ++ packages/cli-module-auth/src/lib/auth.ts | 2 + packages/cli-module-auth/src/lib/http.ts | 4 +- .../cli-module-auth/src/lib/secretStore.ts | 4 +- .../cli-module-auth/src/lib/storage.test.ts | 65 +++++++ packages/cli-module-auth/src/lib/storage.ts | 42 ++++- yarn.lock | 13 ++ 31 files changed, 1141 insertions(+), 18 deletions(-) create mode 100644 .changeset/add-actions-cli-module.md create mode 100644 .changeset/allow-user-action-invocation.md create mode 100644 .changeset/auth-module-exports.md create mode 100644 .changeset/cli-defaults-actions.md create mode 100644 packages/cli-module-actions/.eslintrc.js create mode 100644 packages/cli-module-actions/catalog-info.yaml create mode 100644 packages/cli-module-actions/package.json create mode 100644 packages/cli-module-actions/report.api.md create mode 100644 packages/cli-module-actions/src/commands/execute.ts create mode 100644 packages/cli-module-actions/src/commands/list.ts create mode 100644 packages/cli-module-actions/src/commands/sourcesAdd.ts create mode 100644 packages/cli-module-actions/src/commands/sourcesList.ts create mode 100644 packages/cli-module-actions/src/commands/sourcesRemove.ts create mode 100644 packages/cli-module-actions/src/index.ts create mode 100644 packages/cli-module-actions/src/lib/ActionsClient.test.ts create mode 100644 packages/cli-module-actions/src/lib/ActionsClient.ts create mode 100644 packages/cli-module-actions/src/lib/resolveAuth.ts create mode 100644 packages/cli-module-actions/src/lib/schemaToFlags.test.ts create mode 100644 packages/cli-module-actions/src/lib/schemaToFlags.ts diff --git a/.changeset/add-actions-cli-module.md b/.changeset/add-actions-cli-module.md new file mode 100644 index 0000000000..538509547f --- /dev/null +++ b/.changeset/add-actions-cli-module.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-actions': patch +--- + +Added `actions` CLI module for listing and executing actions from the distributed actions registry. Includes `actions list`, `actions execute`, and `actions sources` commands for managing plugin sources. diff --git a/.changeset/allow-user-action-invocation.md b/.changeset/allow-user-action-invocation.md new file mode 100644 index 0000000000..299c38b41b --- /dev/null +++ b/.changeset/allow-user-action-invocation.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The actions registry invoke endpoint now accepts direct user credentials in addition to service principals, enabling CLI and other direct user clients to invoke actions. diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md new file mode 100644 index 0000000000..0b6dc46ac3 --- /dev/null +++ b/.changeset/auth-module-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-auth': patch +--- + +Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`. diff --git a/.changeset/cli-defaults-actions.md b/.changeset/cli-defaults-actions.md new file mode 100644 index 0000000000..1c46f96ead --- /dev/null +++ b/.changeset/cli-defaults-actions.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-defaults': patch +--- + +Added `@backstage/cli-module-actions` to the default set of CLI modules. diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts index ffe74f81d9..2dfcf4dbef 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/DefaultActionsRegistryService.ts @@ -97,15 +97,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService { '/.backstage/actions/v1/actions/:actionId/invoke', async (req, res) => { const credentials = await this.httpAuth.credentials(req); - if (this.auth.isPrincipal(credentials, 'user')) { - if (!credentials.principal.actor) { - throw new NotAllowedError( - `Actions must be invoked by a service, not a user`, - ); - } - } else if (this.auth.isPrincipal(credentials, 'none')) { + if (this.auth.isPrincipal(credentials, 'none')) { throw new NotAllowedError( - `Actions must be invoked by a service, not an anonymous request`, + `Actions must be invoked by an authenticated principal, not an anonymous request`, ); } diff --git a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts index 1e7c27960e..6c25d4853b 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/actionsRegistry/actionsRegistryServiceFactory.test.ts @@ -439,7 +439,7 @@ describe('actionsRegistryServiceFactory', () => { }); }); - it('should throw an error if the action is invoked by a user', async () => { + it('should allow actions to be invoked by a user', async () => { const testServices = [ actionsRegistryServiceFactory, httpRouterServiceFactory, @@ -460,12 +460,8 @@ describe('actionsRegistryServiceFactory', () => { name: 'test', }); - expect(status).toBe(403); - expect(body).toMatchObject({ - error: { - message: 'Actions must be invoked by a service, not a user', - }, - }); + expect(status).toBe(200); + expect(body).toMatchObject({ output: { ok: true } }); }); it('should validate the output of the action if provided', async () => { diff --git a/packages/cli-defaults/package.json b/packages/cli-defaults/package.json index 8a065fd4d7..7b1016d0d6 100644 --- a/packages/cli-defaults/package.json +++ b/packages/cli-defaults/package.json @@ -30,6 +30,7 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { + "@backstage/cli-module-actions": "workspace:^", "@backstage/cli-module-auth": "workspace:^", "@backstage/cli-module-build": "workspace:^", "@backstage/cli-module-config": "workspace:^", diff --git a/packages/cli-defaults/src/index.ts b/packages/cli-defaults/src/index.ts index dd7aed0c70..f167c3c424 100644 --- a/packages/cli-defaults/src/index.ts +++ b/packages/cli-defaults/src/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import actions from '@backstage/cli-module-actions'; import auth from '@backstage/cli-module-auth'; import build from '@backstage/cli-module-build'; import config from '@backstage/cli-module-config'; @@ -31,6 +32,7 @@ import translations from '@backstage/cli-module-translations'; * @public */ export default [ + actions, auth, build, config, diff --git a/packages/cli-module-actions/.eslintrc.js b/packages/cli-module-actions/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/cli-module-actions/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/cli-module-actions/catalog-info.yaml b/packages/cli-module-actions/catalog-info.yaml new file mode 100644 index 0000000000..fd282ca6a3 --- /dev/null +++ b/packages/cli-module-actions/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-cli-module-actions + title: '@backstage/cli-module-actions' + description: CLI module for executing distributed actions +spec: + lifecycle: experimental + type: backstage-cli-module + owner: tooling-maintainers diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json new file mode 100644 index 0000000000..ca3c423999 --- /dev/null +++ b/packages/cli-module-actions/package.json @@ -0,0 +1,42 @@ +{ + "name": "@backstage/cli-module-actions", + "version": "0.0.0", + "description": "CLI module for executing distributed actions", + "backstage": { + "role": "cli-module" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/cli-module-actions" + }, + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/cli-module-auth": "workspace:^", + "@backstage/cli-node": "workspace:^", + "cleye": "^2.3.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^" + } +} diff --git a/packages/cli-module-actions/report.api.md b/packages/cli-module-actions/report.api.md new file mode 100644 index 0000000000..180186482d --- /dev/null +++ b/packages/cli-module-actions/report.api.md @@ -0,0 +1,13 @@ +## API Report File for "@backstage/cli-module-actions" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { CliModule } from '@backstage/cli-node'; + +// @public (undocumented) +const _default: CliModule; +export default _default; + +// (No @packageDocumentation comment for this package) +``` diff --git a/packages/cli-module-actions/src/commands/execute.ts b/packages/cli-module-actions/src/commands/execute.ts new file mode 100644 index 0000000000..fccb1e1595 --- /dev/null +++ b/packages/cli-module-actions/src/commands/execute.ts @@ -0,0 +1,76 @@ +/* + * 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { ActionsClient } from '../lib/ActionsClient'; +import { schemaToFlags } from '../lib/schemaToFlags'; +import { resolveAuth } from '../lib/resolveAuth'; + +export default async ({ args, info }: CliCommandContext) => { + const instanceIdx = args.indexOf('--instance'); + const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined; + + const actionId = args.find( + (a, i) => !a.startsWith('-') && i !== instanceIdx + 1, + ); + if (!actionId) { + process.stderr.write('Usage: actions execute [flags]\n'); + process.exit(1); + } + + const { accessToken, instance } = await resolveAuth(instanceFlag); + + const client = new ActionsClient(instance.baseUrl, accessToken); + const actions = await client.listForPlugin(actionId); + const action = actions.find(a => a.id === actionId); + + if (!action) { + throw new Error( + `Action "${actionId}" not found. Run "actions list" to see available actions.`, + ); + } + + const schemaFlags = schemaToFlags(action.schema.input as any); + + const flagArgs = args.filter(a => a !== actionId); + + const { flags } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to use', + }, + ...schemaFlags, + }, + }, + undefined, + flagArgs, + ); + + const allFlags = flags as Record; + const input: Record = {}; + for (const [key, value] of Object.entries(allFlags)) { + if (key !== 'instance' && value !== undefined) { + input[key] = value; + } + } + + const output = await client.execute(actionId, input); + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +}; diff --git a/packages/cli-module-actions/src/commands/list.ts b/packages/cli-module-actions/src/commands/list.ts new file mode 100644 index 0000000000..601fa6d2c7 --- /dev/null +++ b/packages/cli-module-actions/src/commands/list.ts @@ -0,0 +1,62 @@ +/* + * 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { ActionsClient } from '../lib/ActionsClient'; +import { resolveAuth } from '../lib/resolveAuth'; + +export default async ({ args, info }: CliCommandContext) => { + const { + flags: { instance: instanceFlag }, + } = cli( + { + help: info, + flags: { + instance: { + type: String, + description: 'Name of the instance to use', + }, + }, + }, + undefined, + args, + ); + + const { accessToken, pluginSources, instance } = await resolveAuth( + instanceFlag, + ); + + if (!pluginSources.length) { + process.stderr.write( + 'No plugin sources configured. Run "actions sources add " to add one.\n', + ); + return; + } + + const client = new ActionsClient(instance.baseUrl, accessToken); + const actions = await client.list(pluginSources); + + if (!actions.length) { + 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`); + } +}; diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts new file mode 100644 index 0000000000..2a8135b63f --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -0,0 +1,54 @@ +/* + * 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { + getSelectedInstance, + getInstanceConfig, + updateInstanceConfig, +} from '@backstage/cli-module-auth'; + +export default async ({ args, info }: CliCommandContext) => { + const parsed = cli( + { + help: info, + parameters: [''], + }, + undefined, + args, + ); + + const pluginId = parsed._[0]; + + const instance = await getSelectedInstance(); + const existing = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + if (existing.includes(pluginId)) { + process.stderr.write( + `Plugin source "${pluginId}" is already configured.\n`, + ); + return; + } + + await updateInstanceConfig(instance.name, 'pluginSources', [ + ...existing, + pluginId, + ]); + + process.stdout.write(`Added plugin source "${pluginId}".\n`); +}; diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts new file mode 100644 index 0000000000..68b368efd5 --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -0,0 +1,39 @@ +/* + * 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { + getSelectedInstance, + getInstanceConfig, +} from '@backstage/cli-module-auth'; + +export default async ({ args, info }: CliCommandContext) => { + cli({ help: info }, undefined, args); + + const instance = await getSelectedInstance(); + const sources = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + if (!sources.length) { + process.stderr.write('No plugin sources configured.\n'); + return; + } + + for (const source of sources) { + process.stdout.write(`${source}\n`); + } +}; diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts new file mode 100644 index 0000000000..731abe790d --- /dev/null +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -0,0 +1,53 @@ +/* + * 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 { cli } from 'cleye'; +import type { CliCommandContext } from '@backstage/cli-node'; +import { + getSelectedInstance, + getInstanceConfig, + updateInstanceConfig, +} from '@backstage/cli-module-auth'; + +export default async ({ args, info }: CliCommandContext) => { + const parsed = cli( + { + help: info, + parameters: [''], + }, + undefined, + args, + ); + + const pluginId = parsed._[0]; + + const instance = await getSelectedInstance(); + const existing = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + if (!existing.includes(pluginId)) { + process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); + return; + } + + await updateInstanceConfig( + instance.name, + 'pluginSources', + existing.filter(s => s !== pluginId), + ); + + process.stdout.write(`Removed plugin source "${pluginId}".\n`); +}; diff --git a/packages/cli-module-actions/src/index.ts b/packages/cli-module-actions/src/index.ts new file mode 100644 index 0000000000..57b2d741e3 --- /dev/null +++ b/packages/cli-module-actions/src/index.ts @@ -0,0 +1,49 @@ +/* + * 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 { createCliModule } from '@backstage/cli-node'; +import packageJson from '../package.json'; + +export default createCliModule({ + packageJson, + init: async reg => { + reg.addCommand({ + path: ['actions', 'list'], + description: 'List available actions from configured plugin sources', + execute: { loader: () => import('./commands/list') }, + }); + reg.addCommand({ + path: ['actions', 'execute'], + description: 'Execute an action', + execute: { loader: () => import('./commands/execute') }, + }); + reg.addCommand({ + path: ['actions', 'sources', 'add'], + description: 'Add a plugin source for action discovery', + execute: { loader: () => import('./commands/sourcesAdd') }, + }); + reg.addCommand({ + path: ['actions', 'sources', 'list'], + description: 'List configured plugin sources', + execute: { loader: () => import('./commands/sourcesList') }, + }); + reg.addCommand({ + path: ['actions', 'sources', 'remove'], + description: 'Remove a plugin source', + execute: { loader: () => import('./commands/sourcesRemove') }, + }); + }, +}); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts new file mode 100644 index 0000000000..61526873c9 --- /dev/null +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -0,0 +1,129 @@ +/* + * 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 { ActionsClient } from './ActionsClient'; +import { httpJson } from '@backstage/cli-module-auth'; + +jest.mock('@backstage/cli-module-auth', () => ({ + httpJson: jest.fn(), +})); + +const mockHttpJson = httpJson as jest.MockedFunction; + +describe('ActionsClient', () => { + const baseUrl = 'https://backstage.example.com'; + const accessToken = 'test-token'; + let client: ActionsClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new ActionsClient(baseUrl, accessToken); + }); + + describe('list', () => { + it('returns empty array when no plugin sources provided', async () => { + const result = await client.list([]); + expect(result).toEqual([]); + expect(mockHttpJson).not.toHaveBeenCalled(); + }); + + it('fetches actions from each plugin source', async () => { + const catalogActions = [ + { + id: 'catalog:refresh', + name: 'refresh', + schema: { input: {}, output: {} }, + }, + ]; + const scaffolderActions = [ + { + id: 'scaffolder:run', + name: 'run', + schema: { input: {}, output: {} }, + }, + ]; + + mockHttpJson + .mockResolvedValueOnce({ actions: catalogActions }) + .mockResolvedValueOnce({ actions: scaffolderActions }); + + const result = await client.list(['catalog', 'scaffolder']); + + expect(mockHttpJson).toHaveBeenCalledTimes(2); + expect(mockHttpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }), + ); + expect(mockHttpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/scaffolder/.backstage/actions/v1/actions', + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }), + ); + expect(result).toEqual([...catalogActions, ...scaffolderActions]); + }); + + it('propagates errors from httpJson', async () => { + mockHttpJson.mockRejectedValue(new Error('Network error')); + + await expect(client.list(['catalog'])).rejects.toThrow('Network error'); + }); + }); + + describe('execute', () => { + it('posts to the correct invoke endpoint', async () => { + mockHttpJson.mockResolvedValue({ output: { result: 'ok' } }); + + const output = await client.execute('catalog:refresh', { + entityRef: 'component:default/foo', + }); + + expect(mockHttpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions/catalog%3Arefresh/invoke', + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer test-token' }, + body: { entityRef: 'component:default/foo' }, + }), + ); + expect(output).toEqual({ result: 'ok' }); + }); + + it('sends empty object when no input provided', async () => { + mockHttpJson.mockResolvedValue({ output: null }); + + await client.execute('catalog:refresh'); + + expect(mockHttpJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: {} }), + ); + }); + + it('extracts pluginId from actionId to build correct URL', async () => { + mockHttpJson.mockResolvedValue({ output: {} }); + + await client.execute('my-plugin:some-action'); + + expect(mockHttpJson).toHaveBeenCalledWith( + expect.stringContaining('/api/my-plugin/'), + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts new file mode 100644 index 0000000000..aa04902b2f --- /dev/null +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -0,0 +1,98 @@ +/* + * 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 { httpJson } from '@backstage/cli-module-auth'; + +export type ActionDef = { + id: string; + name: string; + description?: string; + schema: { + input: object; + output: object; + }; +}; + +type ListActionsResponse = { + actions: ActionDef[]; +}; + +type InvokeResponse = { + output: unknown; +}; + +function extractPluginId(actionId: string): string { + const colonIndex = actionId.indexOf(':'); + if (colonIndex === -1) { + throw new Error( + `Invalid action ID "${actionId}". Expected format "pluginId:actionName".`, + ); + } + return actionId.substring(0, colonIndex); +} + +function pluginActionsUrl(baseUrl: string, pluginId: string): string { + return new URL( + `/api/${encodeURIComponent(pluginId)}/.backstage/actions/v1/actions`, + baseUrl, + ).toString(); +} + +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 listForPlugin(actionId: string): Promise { + const pluginId = extractPluginId(actionId); + return this.list([pluginId]); + } + + async execute(actionId: string, input?: unknown): Promise { + const pluginId = extractPluginId(actionId); + const url = `${pluginActionsUrl( + this.baseUrl, + pluginId, + )}/${encodeURIComponent(actionId)}/invoke`; + + const response = await httpJson(url, { + method: 'POST', + headers: { Authorization: `Bearer ${this.accessToken}` }, + body: input ?? {}, + signal: AbortSignal.timeout(30_000), + }); + + return response.output; + } +} diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts new file mode 100644 index 0000000000..11fb646545 --- /dev/null +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -0,0 +1,48 @@ +/* + * 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 { + getSelectedInstance, + getInstanceConfig, + accessTokenNeedsRefresh, + refreshAccessToken, + getSecretStore, + type StoredInstance, +} from '@backstage/cli-module-auth'; + +export async function resolveAuth(instanceFlag?: string): Promise<{ + instance: StoredInstance; + accessToken: string; + pluginSources: string[]; +}> { + let instance = await getSelectedInstance(instanceFlag); + + if (accessTokenNeedsRefresh(instance)) { + instance = await refreshAccessToken(instance.name); + } + + const secretStore = await getSecretStore(); + const service = `backstage-cli:auth-instance:${instance.name}`; + const accessToken = await secretStore.get(service, 'accessToken'); + if (!accessToken) { + throw new Error('No access token found. Run "auth login" to authenticate.'); + } + + const pluginSources = + (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + + return { instance, accessToken, pluginSources }; +} diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.test.ts b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts new file mode 100644 index 0000000000..fa9f446aae --- /dev/null +++ b/packages/cli-module-actions/src/lib/schemaToFlags.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { schemaToFlags } from './schemaToFlags'; + +describe('schemaToFlags', () => { + it('returns empty object when schema has no properties', () => { + expect(schemaToFlags({})).toEqual({}); + expect(schemaToFlags({ properties: {} })).toEqual({}); + }); + + it('converts string properties to String flags', () => { + const flags = schemaToFlags({ + properties: { + myProp: { type: 'string', description: 'A string prop' }, + }, + }); + + expect(flags).toEqual({ + myProp: { type: String, description: 'A string prop' }, + }); + }); + + it('converts number and integer properties to Number flags', () => { + const flags = schemaToFlags({ + properties: { + count: { type: 'integer' }, + amount: { type: 'number', description: 'An amount' }, + }, + }); + + expect(flags.count).toEqual({ type: Number, description: undefined }); + expect(flags.amount).toEqual({ type: Number, description: 'An amount' }); + }); + + it('converts boolean properties to Boolean flags', () => { + const flags = schemaToFlags({ + properties: { + verbose: { type: 'boolean', description: 'Enable verbose output' }, + }, + }); + + expect(flags.verbose).toEqual({ + type: Boolean, + description: 'Enable verbose output', + }); + }); + + it('skips non-primitive properties like object and array', () => { + const flags = schemaToFlags({ + properties: { + name: { type: 'string' }, + metadata: { type: 'object' }, + tags: { type: 'array' }, + }, + }); + + expect(Object.keys(flags)).toEqual(['name']); + }); + + it('skips properties with no type or composite types', () => { + const flags = schemaToFlags({ + properties: { + noType: {}, + name: { type: 'string' }, + }, + }); + + expect(Object.keys(flags)).toEqual(['name']); + }); + + it('uses first type when type is an array', () => { + const flags = schemaToFlags({ + properties: { + value: { type: ['string', 'null'] }, + }, + }); + + expect(flags.value).toEqual({ type: String, description: undefined }); + }); + + it('appends enum values to description', () => { + const flags = schemaToFlags({ + properties: { + color: { + type: 'string', + description: 'Pick a color', + enum: ['red', 'green', 'blue'], + }, + bare: { type: 'string', enum: ['a', 'b'] }, + }, + }); + + expect(flags.color.description).toBe('Pick a color [red, green, blue]'); + expect(flags.bare.description).toBe('[a, b]'); + }); + + it('marks required fields in description', () => { + const flags = schemaToFlags({ + properties: { + name: { type: 'string', description: 'The name' }, + optional: { type: 'string', description: 'Optional field' }, + bare: { type: 'string' }, + }, + required: ['name', 'bare'], + }); + + expect(flags.name.description).toBe('The name (required)'); + expect(flags.optional.description).toBe('Optional field'); + expect(flags.bare.description).toBe('(required)'); + }); + + it('applies default values from schema', () => { + const flags = schemaToFlags({ + properties: { + count: { type: 'number', default: 10 }, + name: { type: 'string' }, + }, + }); + + expect(flags.count.default).toBe(10); + expect(flags.name.default).toBeUndefined(); + }); + + it('combines enum and required in description', () => { + const flags = schemaToFlags({ + properties: { + env: { + type: 'string', + description: 'Target env', + enum: ['dev', 'prod'], + }, + }, + required: ['env'], + }); + + expect(flags.env.description).toBe('Target env [dev, prod] (required)'); + }); + + it('preserves camelCase property names as flag keys', () => { + const flags = schemaToFlags({ + properties: { + targetEntityRef: { type: 'string' }, + maxResults: { type: 'integer' }, + }, + }); + + expect(Object.keys(flags)).toEqual(['targetEntityRef', 'maxResults']); + }); +}); diff --git a/packages/cli-module-actions/src/lib/schemaToFlags.ts b/packages/cli-module-actions/src/lib/schemaToFlags.ts new file mode 100644 index 0000000000..026613edaf --- /dev/null +++ b/packages/cli-module-actions/src/lib/schemaToFlags.ts @@ -0,0 +1,77 @@ +/* + * 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. + */ + +type JsonSchemaProperty = { + type?: string | string[]; + description?: string; + enum?: unknown[]; + default?: unknown; +}; + +type JsonSchemaObject = { + properties?: Record; + required?: string[]; +}; + +type CleyeFlag = { + type: StringConstructor | NumberConstructor | BooleanConstructor; + description?: string; + default?: unknown; +}; + +export function schemaToFlags( + schema: JsonSchemaObject, +): Record { + const flags: Record = {}; + const required = new Set(schema.required ?? []); + + if (!schema.properties) { + return flags; + } + + for (const [key, prop] of Object.entries(schema.properties)) { + const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type; + + let flagType: StringConstructor | NumberConstructor | BooleanConstructor; + if (rawType === 'string') { + flagType = String; + } else if (rawType === 'number' || rawType === 'integer') { + flagType = Number; + } else if (rawType === 'boolean') { + flagType = Boolean; + } else { + continue; + } + + let desc = prop.description ?? ''; + if (prop.enum?.length) { + const values = prop.enum.map(v => String(v)).join(', '); + desc = desc ? `${desc} [${values}]` : `[${values}]`; + } + if (required.has(key)) { + desc = desc ? `${desc} (required)` : '(required)'; + } + + const flag: CleyeFlag = { type: flagType, description: desc || undefined }; + if (prop.default !== undefined) { + flag.default = prop.default; + } + + flags[key] = flag; + } + + return flags; +} diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 510bcaabe7..a3b98e2f02 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -5,9 +5,67 @@ ```ts import { CliModule } from '@backstage/cli-node'; +// @public (undocumented) +export function accessTokenNeedsRefresh(instance: StoredInstance): boolean; + // @public (undocumented) const _default: CliModule; export default _default; +// @public (undocumented) +export function getInstanceConfig( + instanceName: string, + key: string, +): Promise; + +// @public (undocumented) +export function getSecretStore(): Promise; + +// @public (undocumented) +export function getSelectedInstance( + instanceName?: string, +): Promise; + +// @public (undocumented) +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +// @public (undocumented) +export function httpJson(url: string, init?: HttpInit): Promise; + +// @public (undocumented) +export function refreshAccessToken( + instanceName: string, +): Promise; + +// @public (undocumented) +export type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +// @public (undocumented) +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + +// @public (undocumented) +export function updateInstanceConfig( + instanceName: string, + key: string, + value: unknown, +): Promise; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index fef74ec8a5..38567e2174 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -52,3 +52,17 @@ export default createCliModule({ }); }, }); + +/** @public */ +export { + getSelectedInstance, + getInstanceConfig, + updateInstanceConfig, + type StoredInstance, +} from './lib/storage'; +/** @public */ +export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth'; +/** @public */ +export { getSecretStore, type SecretStore } from './lib/secretStore'; +/** @public */ +export { httpJson, type HttpInit } from './lib/http'; diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index 3ea7baef5c..0344d4636e 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -31,10 +31,12 @@ const TokenResponseSchema = z.object({ refresh_token: z.string().min(1).optional(), }); +/** @public */ export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration } +/** @public */ export async function refreshAccessToken( instanceName: string, ): Promise { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index 307d7d2334..789c627606 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -17,13 +17,15 @@ import fetch from 'cross-fetch'; import { ResponseError } from '@backstage/errors'; -type HttpInit = { +/** @public */ +export type HttpInit = { headers?: Record; method?: string; body?: any; signal?: AbortSignal; }; +/** @public */ export async function httpJson(url: string, init?: HttpInit): Promise { const res = await fetch(url, { ...init, diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts index 55fac878b7..a0f3c81d51 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-module-auth/src/lib/secretStore.ts @@ -18,7 +18,8 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; -type SecretStore = { +/** @public */ +export type SecretStore = { get(service: string, account: string): Promise; set(service: string, account: string, secret: string): Promise; delete(service: string, account: string): Promise; @@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore { let singleton: SecretStore | undefined; +/** @public */ export async function getSecretStore(): Promise { if (!singleton) { const keytar = await loadKeytar(); diff --git a/packages/cli-module-auth/src/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts index 07ffba9510..9264c6bb89 100644 --- a/packages/cli-module-auth/src/lib/storage.test.ts +++ b/packages/cli-module-auth/src/lib/storage.test.ts @@ -22,6 +22,8 @@ import { getAllInstances, getSelectedInstance, getInstanceByName, + getInstanceConfig, + updateInstanceConfig, upsertInstance, removeInstance, setSelectedInstance, @@ -357,6 +359,69 @@ describe('storage', () => { }); }); + describe('getInstanceConfig', () => { + it('should return undefined when no config set', async () => { + await upsertInstance(mockInstance1); + + const result = await getInstanceConfig('production', 'someKey'); + expect(result).toBeUndefined(); + }); + + it('should return config value for a key', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'myKey', 'myValue'); + + const result = await getInstanceConfig('production', 'myKey'); + expect(result).toBe('myValue'); + }); + + it('should throw NotFoundError for unknown instance', async () => { + await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('updateInstanceConfig', () => { + it('should set a config value', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'key1', 'value1'); + + const result = await getInstanceConfig('production', 'key1'); + expect(result).toBe('value1'); + }); + + it('should preserve existing config keys', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'key1', 'value1'); + await updateInstanceConfig('production', 'key2', 'value2'); + + const result1 = await getInstanceConfig('production', 'key1'); + const result2 = await getInstanceConfig('production', 'key2'); + expect(result1).toBe('value1'); + expect(result2).toBe('value2'); + }); + + it('should throw NotFoundError for unknown instance', async () => { + await expect( + updateInstanceConfig('nonexistent', 'key', 'value'), + ).rejects.toThrow(NotFoundError); + }); + + it('should remove instance along with its config', async () => { + await upsertInstance(mockInstance1); + await updateInstanceConfig('production', 'key1', 'value1'); + await removeInstance('production'); + + const { instances } = await getAllInstances(); + expect(instances.find(i => i.name === 'production')).toBeUndefined(); + + await upsertInstance(mockInstance1); + const result = await getInstanceConfig('production', 'key1'); + expect(result).toBeUndefined(); + }); + }); + describe('file path resolution', () => { it('should use XDG_CONFIG_HOME when set', async () => { const customConfigHome = mockDir.resolve('custom-config'); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index c2eb153112..9d2551a4f9 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -36,9 +36,19 @@ const storedInstanceSchema = z.object({ issuedAt: z.number().int().nonnegative(), accessTokenExpiresAt: z.number().int().nonnegative(), selected: z.boolean().optional(), + config: z.record(z.string(), z.unknown()).optional(), }); -export type StoredInstance = z.infer; +/** @public */ +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; const authYamlSchema = z.object({ instances: z.array(storedInstanceSchema).default([]), @@ -98,6 +108,7 @@ export async function getAllInstances(): Promise<{ }; } +/** @public */ export async function getSelectedInstance( instanceName?: string, ): Promise { @@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise { }); } +/** @public */ +export async function getInstanceConfig( + instanceName: string, + key: string, +): Promise { + const instance = await getInstanceByName(instanceName); + return instance.config?.[key] as T | undefined; +} + +/** @public */ +export async function updateInstanceConfig( + instanceName: string, + key: string, + value: unknown, +): Promise { + return withMetadataLock(async () => { + const data = await readAll(); + const idx = data.instances.findIndex(i => i.name === instanceName); + if (idx === -1) { + throw new NotFoundError(`Instance '${instanceName}' not found`); + } + data.instances[idx] = { + ...data.instances[idx], + config: { ...data.instances[idx].config, [key]: value }, + }; + await writeAll(data); + }); +} + export async function withMetadataLock(fn: () => Promise): Promise { const file = getMetadataFilePath(); await fs.ensureDir(path.dirname(file)); diff --git a/yarn.lock b/yarn.lock index b533dd3afc..daff483d5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2806,6 +2806,7 @@ __metadata: resolution: "@backstage/cli-defaults@workspace:packages/cli-defaults" dependencies: "@backstage/cli": "workspace:^" + "@backstage/cli-module-actions": "workspace:^" "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-module-build": "workspace:^" "@backstage/cli-module-config": "workspace:^" @@ -2820,6 +2821,18 @@ __metadata: languageName: unknown linkType: soft +"@backstage/cli-module-actions@workspace:^, @backstage/cli-module-actions@workspace:packages/cli-module-actions": + version: 0.0.0-use.local + resolution: "@backstage/cli-module-actions@workspace:packages/cli-module-actions" + dependencies: + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/cli-module-auth": "workspace:^" + "@backstage/cli-node": "workspace:^" + cleye: "npm:^2.3.0" + languageName: unknown + linkType: soft + "@backstage/cli-module-auth@workspace:^, @backstage/cli-module-auth@workspace:packages/cli-module-auth": version: 0.0.0-use.local resolution: "@backstage/cli-module-auth@workspace:packages/cli-module-auth"