diff --git a/packages/cli/bin/backstage-cli-alpha b/packages/cli/bin/backstage-cli-alpha new file mode 100755 index 0000000000..38a2df3319 --- /dev/null +++ b/packages/cli/bin/backstage-cli-alpha @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/* + * Copyright 2020 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. + */ + +require('@backstage/cli/config/nodeTransform.cjs'); +require('../src/alpha'); diff --git a/packages/cli/src/alpha.ts b/packages/cli/src/alpha.ts new file mode 100644 index 0000000000..03da8151d4 --- /dev/null +++ b/packages/cli/src/alpha.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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 { CliInitializer } from './wiring/CliInitializer'; + +(async () => { + const initializer = new CliInitializer(); + initializer.add(import('./modules/config/alpha').then(m => m.default)); + await initializer.run(); +})(); diff --git a/packages/cli/src/modules/config/alpha.ts b/packages/cli/src/modules/config/alpha.ts index 7dd5be94ed..5f5c062d51 100644 --- a/packages/cli/src/modules/config/alpha.ts +++ b/packages/cli/src/modules/config/alpha.ts @@ -13,54 +13,56 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CliInitializer } from '../../wiring/Command'; -import { z } from 'zod'; -(async () => { - const initializer = new CliInitializer(); - initializer.addCommand({ - path: ['config:docs'], - description: 'Browse the configuration reference documentation', - schema: z.object({ - package: z.string().optional(), - }), - execute: async options => { - const m = await import('./commands/docs'); - await m.default(options); - }, - }); - initializer.addCommand({ - path: ['config:print'], - description: 'Print the app configuration for the current package', - schema: z.object({ - package: z.string().optional(), - lax: z.boolean().optional(), - frontend: z.boolean().optional(), - 'with-secrets': z.boolean().optional(), - format: z.enum(['json', 'yaml']).optional(), - config: z.array(z.string()).optional(), - }), - execute: async options => { - const m = await import('./commands/print'); - await m.default(options); - }, - }); - initializer.addCommand({ - path: ['config:check'], - description: - 'Validate that the given configuration loads and matches schema', - schema: z.object({ - package: z.string().optional(), - lax: z.boolean().optional(), - frontend: z.boolean().optional(), - deprecated: z.boolean().optional(), - strict: z.boolean().optional(), - config: z.array(z.string()).optional(), - }), - execute: async options => { - const m = await import('./commands/validate'); - await m.default(options); - }, - }); - await initializer.run(); -})(); +import { z } from 'zod'; +import { createCliPlugin } from '../../wiring/factory'; + +export default createCliPlugin({ + pluginId: 'config', + init: async reg => { + reg.addCommand({ + path: ['config:docs'], + description: 'Browse the configuration reference documentation', + schema: z.object({ + package: z.string().optional(), + }), + execute: async options => { + const m = await import('./commands/docs'); + await m.default(options); + }, + }); + reg.addCommand({ + path: ['config:print'], + description: 'Print the app configuration for the current package', + schema: z.object({ + package: z.string().optional(), + lax: z.boolean().optional(), + frontend: z.boolean().optional(), + 'with-secrets': z.boolean().optional(), + format: z.enum(['json', 'yaml']).optional(), + config: z.array(z.string()).optional(), + }), + execute: async options => { + const m = await import('./commands/print'); + await m.default(options); + }, + }); + reg.addCommand({ + path: ['config:check'], + description: + 'Validate that the given configuration loads and matches schema', + schema: z.object({ + package: z.string().optional(), + lax: z.boolean().optional(), + frontend: z.boolean().optional(), + deprecated: z.boolean().optional(), + strict: z.boolean().optional(), + config: z.array(z.string()).optional(), + }), + execute: async options => { + const m = await import('./commands/validate'); + await m.default(options); + }, + }); + }, +}); diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts new file mode 100644 index 0000000000..b96dd52b36 --- /dev/null +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2024 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 yargs from 'yargs'; +import { CommandGraph } from './CommandGraph'; +import { CliFeature, InternalCliFeature, InternalCliPlugin } from './types'; +import { CommandRegistry } from './CommandRegistry'; +import chalk from 'chalk'; + +type UninitializedFeature = CliFeature | Promise; + +export class CliInitializer { + private graph = new CommandGraph(); + private commandRegistry = new CommandRegistry(this.graph); + #uninitiazedFeatures: Promise[] = []; + + add(module: UninitializedFeature) { + this.#uninitiazedFeatures.push(Promise.resolve(module)); + } + + async #register(feature: CliFeature) { + if (isCliPlugin(feature)) { + await feature.init(this.commandRegistry); + } else { + throw new Error(`Unsupported feature type: ${feature.$$type}`); + } + } + + async #doInit() { + const features = await Promise.all(this.#uninitiazedFeatures); + for (const feature of features) { + await this.#register(feature); + } + } + + /** + * Actually parse argv and pass it to the command. + */ + async run() { + await this.#doInit(); + + const { + _: commandName, + $0: binaryName, + ...options + } = await yargs(process.argv.slice(2)).parse(); + const command = this.graph.find(commandName.map(String)); + if (!command) { + console.error(chalk.red(`Command not found: "${commandName.join(' ')}"`)); + const possibleCommands = this.graph.atDepth(commandName.length - 1); + if (possibleCommands.length > 0) { + console.log('Available commands:'); + for (const node of possibleCommands) { + let text = `\t${node.name}`; + if (node.$$type === '@tree/root') { + text += ' [command]'; + } else { + text += ` [options]\t\t${node.command.description}`; + } + console.log(text); + } + } + process.exit(1); + } + const parsedOptions = command.schema.parse(options); + await command?.execute(parsedOptions); + } +} + +function toInternalCliFeature(feature: CliFeature): InternalCliFeature { + if (feature.$$type !== '@backstage/CliFeature') { + throw new Error(`Invalid CliFeature, bad type '${feature.$$type}'`); + } + const internal = feature as InternalCliFeature; + if (internal.version !== 'v1') { + throw new Error(`Invalid CliFeature, bad version '${internal.version}'`); + } + return internal; +} + +function isCliPlugin(feature: CliFeature): feature is InternalCliPlugin { + const internal = toInternalCliFeature(feature); + if (internal.featureType === 'plugin') { + return true; + } + // Backwards compatibility for v1 registrations that use duck typing + return 'plugin' in internal; +} diff --git a/packages/cli/src/wiring/Command.ts b/packages/cli/src/wiring/CommandGraph.ts similarity index 64% rename from packages/cli/src/wiring/Command.ts rename to packages/cli/src/wiring/CommandGraph.ts index 4fa9d1d59e..1e3f80291c 100644 --- a/packages/cli/src/wiring/Command.ts +++ b/packages/cli/src/wiring/CommandGraph.ts @@ -13,27 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import yargs from 'yargs'; -import { ZodObject, ZodRawShape } from 'zod'; - -interface BackstageCommand { - path: string[]; - description: string; - schema: ZodObject; - execute: (options: T) => Promise; -} +import { ZodRawShape } from 'zod'; +import { BackstageCommand } from './types'; type Node = TreeNode | LeafNode; interface TreeNode { - type: '$$treeNode'; + $$type: '@tree/root'; name: string; children: TreeNode[]; } interface LeafNode { - type: '$$leafNode'; + $$type: '@tree/leaf'; name: string; command: BackstageCommand; } @@ -55,9 +47,9 @@ export class CommandGraph { const name = path[i]; let next = current.find(n => n.name === name); if (!next) { - next = { type: '$$treeNode', name, children: [] }; + next = { $$type: '@tree/root', name, children: [] }; current.push(next); - } else if (next.type === '$$leafNode') { + } else if (next.$$type === '@tree/leaf') { throw new Error( `Command already exists at path: "${path.slice(0, i).join(' ')}"`, ); @@ -65,13 +57,13 @@ export class CommandGraph { current = next.children; } const last = current.find(n => n.name === path[path.length - 1]) as Node; - if (last && last.type === '$$leafNode') { + if (last && last.$$type === '@tree/leaf') { throw new Error( `Command already exists at path: "${path.slice(0, -1).join(' ')}"`, ); } else { current.push({ - type: '$$leafNode', + $$type: '@tree/leaf', name: path[path.length - 1], command, } as Node); @@ -88,45 +80,25 @@ export class CommandGraph { const next = current.find(n => n.name === name); if (!next) { return undefined; - } else if (next.type === '$$leafNode') { + } else if (next.$$type === '@tree/leaf') { return undefined; } current = next.children; } const last = current.find(n => n.name === path[path.length - 1]) as Node; - if (!last || last.type === '$$treeNode') { + if (!last || last.$$type === '@tree/root') { return undefined; } return last?.command; } -} -export class CliInitializer { - private graph = new CommandGraph(); - - /** - * Add a command to the CLI. This will be mostly used to prevent command overlaps - * and to create `help` commands. - */ - addCommand(command: BackstageCommand) { - this.graph.add(command); - } - - /** - * Actually parse argv and pass it to the command. - */ - async run() { - const { - _: commandName, - $0: binaryName, - ...options - } = await yargs(process.argv.slice(2)).parse(); - const command = this.graph.find(commandName.map(String)); - if (!command) { - console.error(`Command not found: "${commandName.join(' ')}"`); - process.exit(1); + atDepth(depth: number): Node[] { + let current = this.graph; + for (let i = 0; i < depth; i++) { + current = current.flatMap(n => + n.$$type === '@tree/root' ? n.children : [], + ); } - const parsedOptions = command.schema.parse(options); - await command?.execute(parsedOptions); + return current as Node[]; } } diff --git a/packages/cli/src/wiring/CommandRegistry.ts b/packages/cli/src/wiring/CommandRegistry.ts new file mode 100644 index 0000000000..3e232173a0 --- /dev/null +++ b/packages/cli/src/wiring/CommandRegistry.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2024 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 { ZodRawShape } from 'zod'; +import { CommandGraph } from './CommandGraph'; +import { BackstageCommand } from './types'; + +export class CommandRegistry { + private graph: CommandGraph; + constructor(graph: CommandGraph) { + this.graph = graph; + } + + addCommand(command: BackstageCommand) { + this.graph.add(command); + } +} diff --git a/packages/cli/src/wiring/factory.ts b/packages/cli/src/wiring/factory.ts new file mode 100644 index 0000000000..eec058e180 --- /dev/null +++ b/packages/cli/src/wiring/factory.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2024 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 { CommandRegistry } from './CommandRegistry'; +import { InternalCliPlugin } from './types'; + +export function createCliPlugin(options: { + pluginId: string; + init: (registry: CommandRegistry) => Promise; +}): InternalCliPlugin { + return { + id: options.pluginId, + init: options.init, + $$type: '@backstage/CliFeature', + version: 'v1', + featureType: 'plugin', + description: 'A Backstage CLI plugin', + }; +} diff --git a/packages/cli/src/wiring/types.ts b/packages/cli/src/wiring/types.ts new file mode 100644 index 0000000000..472427787b --- /dev/null +++ b/packages/cli/src/wiring/types.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2024 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 { ZodObject, ZodRawShape } from 'zod'; +import { CommandRegistry } from './CommandRegistry'; + +export interface BackstageCommand { + path: string[]; + description: string; + schema: ZodObject; + execute: (options: T) => Promise; +} + +export interface CliFeature { + $$type: '@backstage/CliFeature'; +} + +export interface CliPlugin { + id: string; + init: (registry: CommandRegistry) => Promise; + $$type: '@backstage/CliFeature'; +} + +/** + * @public + */ +export interface InternalCliPlugin extends CliFeature { + version: 'v1'; + featureType: 'plugin'; + description: string; + id: string; + init: (registry: CommandRegistry) => Promise; +} + +/** @internal */ +export type InternalCliFeature = InternalCliPlugin;