diff --git a/packages/cli/src/modules/config/alpha.ts b/packages/cli/src/modules/config/alpha.ts new file mode 100644 index 0000000000..7dd5be94ed --- /dev/null +++ b/packages/cli/src/modules/config/alpha.ts @@ -0,0 +1,66 @@ +/* + * 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/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(); +})(); diff --git a/packages/cli/src/wiring/Command.ts b/packages/cli/src/wiring/Command.ts new file mode 100644 index 0000000000..4fa9d1d59e --- /dev/null +++ b/packages/cli/src/wiring/Command.ts @@ -0,0 +1,132 @@ +/* + * 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 { ZodObject, ZodRawShape } from 'zod'; + +interface BackstageCommand { + path: string[]; + description: string; + schema: ZodObject; + execute: (options: T) => Promise; +} + +type Node = TreeNode | LeafNode; + +interface TreeNode { + type: '$$treeNode'; + name: string; + children: TreeNode[]; +} + +interface LeafNode { + type: '$$leafNode'; + name: string; + command: BackstageCommand; +} + +/** + * A sparse graph of commands. + */ +export class CommandGraph { + private graph: Node[] = []; + + /** + * Adds a command to the graph. The graph is sparse, so we use the path to determine the nodes + * to traverse. Only leaf nodes should have a command/action. + */ + add(command: BackstageCommand) { + const path = command.path; + let current = this.graph; + for (let i = 0; i < path.length - 1; i++) { + const name = path[i]; + let next = current.find(n => n.name === name); + if (!next) { + next = { type: '$$treeNode', name, children: [] }; + current.push(next); + } else if (next.type === '$$leafNode') { + throw new Error( + `Command already exists at path: "${path.slice(0, i).join(' ')}"`, + ); + } + current = next.children; + } + const last = current.find(n => n.name === path[path.length - 1]) as Node; + if (last && last.type === '$$leafNode') { + throw new Error( + `Command already exists at path: "${path.slice(0, -1).join(' ')}"`, + ); + } else { + current.push({ + type: '$$leafNode', + name: path[path.length - 1], + command, + } as Node); + } + } + + /** + * Given a path, try to find a command that matches it. + */ + find(path: string[]): BackstageCommand | undefined { + let current = this.graph; + for (let i = 0; i < path.length - 1; i++) { + const name = path[i]; + const next = current.find(n => n.name === name); + if (!next) { + return undefined; + } else if (next.type === '$$leafNode') { + return undefined; + } + current = next.children; + } + const last = current.find(n => n.name === path[path.length - 1]) as Node; + if (!last || last.type === '$$treeNode') { + 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); + } + const parsedOptions = command.schema.parse(options); + await command?.execute(parsedOptions); + } +}