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
+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));