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