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 <ben@blam.sh>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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
|
||||
@@ -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:^"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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 <action-id> [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<string, unknown>;
|
||||
const input: Record<string, unknown> = {};
|
||||
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`);
|
||||
};
|
||||
@@ -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 <plugin-id>" 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`);
|
||||
}
|
||||
};
|
||||
@@ -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: ['<plugin-id>'],
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const pluginId = parsed._[0];
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const existing =
|
||||
(await getInstanceConfig<string[]>(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`);
|
||||
};
|
||||
@@ -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<string[]>(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`);
|
||||
}
|
||||
};
|
||||
@@ -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: ['<plugin-id>'],
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const pluginId = parsed._[0];
|
||||
|
||||
const instance = await getSelectedInstance();
|
||||
const existing =
|
||||
(await getInstanceConfig<string[]>(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`);
|
||||
};
|
||||
@@ -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') },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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<typeof httpJson>;
|
||||
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ActionDef[]> {
|
||||
const results: ActionDef[] = [];
|
||||
|
||||
for (const pluginId of pluginSources) {
|
||||
const url = pluginActionsUrl(this.baseUrl, pluginId);
|
||||
|
||||
const response = await httpJson<ListActionsResponse>(url, {
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
results.push(...response.actions);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async listForPlugin(actionId: string): Promise<ActionDef[]> {
|
||||
const pluginId = extractPluginId(actionId);
|
||||
return this.list([pluginId]);
|
||||
}
|
||||
|
||||
async execute(actionId: string, input?: unknown): Promise<unknown> {
|
||||
const pluginId = extractPluginId(actionId);
|
||||
const url = `${pluginActionsUrl(
|
||||
this.baseUrl,
|
||||
pluginId,
|
||||
)}/${encodeURIComponent(actionId)}/invoke`;
|
||||
|
||||
const response = await httpJson<InvokeResponse>(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
body: input ?? {},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
return response.output;
|
||||
}
|
||||
}
|
||||
@@ -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<string[]>(instance.name, 'pluginSources')) ?? [];
|
||||
|
||||
return { instance, accessToken, pluginSources };
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string, JsonSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
type CleyeFlag = {
|
||||
type: StringConstructor | NumberConstructor | BooleanConstructor;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
export function schemaToFlags(
|
||||
schema: JsonSchemaObject,
|
||||
): Record<string, CleyeFlag> {
|
||||
const flags: Record<string, CleyeFlag> = {};
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user