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:
benjdlambert
2026-03-16 20:09:15 +01:00
parent 3f2788e1ef
commit 42960f1db7
31 changed files with 1141 additions and 18 deletions
+5
View File
@@ -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.
@@ -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.
+5
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-defaults': patch
---
Added `@backstage/cli-module-actions` to the default set of CLI modules.
@@ -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`,
);
}
@@ -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 () => {
+1
View File
@@ -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:^",
+2
View File
@@ -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,
+1
View File
@@ -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
+42
View File
@@ -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:^"
}
}
+13
View File
@@ -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`);
};
+49
View File
@@ -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;
}
+58
View File
@@ -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<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined>;
// @public (undocumented)
export function getSecretStore(): Promise<SecretStore>;
// @public (undocumented)
export function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
// @public (undocumented)
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
// @public (undocumented)
export function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type SecretStore = {
get(service: string, account: string): Promise<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
};
// @public (undocumented)
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
// @public (undocumented)
export function updateInstanceConfig(
instanceName: string,
key: string,
value: unknown,
): Promise<void>;
// (No @packageDocumentation comment for this package)
```
+14
View File
@@ -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';
+2
View File
@@ -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<StoredInstance> {
+3 -1
View File
@@ -17,13 +17,15 @@
import fetch from 'cross-fetch';
import { ResponseError } from '@backstage/errors';
type HttpInit = {
/** @public */
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
/** @public */
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
const res = await fetch(url, {
...init,
@@ -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<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
@@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore {
let singleton: SecretStore | undefined;
/** @public */
export async function getSecretStore(): Promise<SecretStore> {
if (!singleton) {
const keytar = await loadKeytar();
@@ -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');
+41 -1
View File
@@ -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<typeof storedInstanceSchema>;
/** @public */
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
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<StoredInstance> {
@@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise<void> {
});
}
/** @public */
export async function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined> {
const instance = await getInstanceByName(instanceName);
return instance.config?.[key] as T | undefined;
}
/** @public */
export async function updateInstanceConfig(
instanceName: string,
key: string,
value: unknown,
): Promise<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
const file = getMetadataFilePath();
await fs.ensureDir(path.dirname(file));
+13
View File
@@ -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"