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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user