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,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.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-defaults': patch
|
||||
---
|
||||
|
||||
Added `@backstage/cli-module-actions` to the default set of CLI modules.
|
||||
+2
-8
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-7
@@ -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 () => {
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user