diff --git a/packages/cli/package.json b/packages/cli/package.json index d886bdde0b..6eb1acb723 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,8 @@ "license": "Apache-2.0", "main": "dist/index.cjs.js", "bin": { - "backstage-cli": "bin/backstage-cli" + "backstage-cli": "bin/backstage-cli", + "backstage-cli-alpha": "bin/backstage-cli-alpha" }, "files": [ "asset-types", diff --git a/packages/cli/src/alpha.ts b/packages/cli/src/alpha.ts index 03da8151d4..4b2cfa4842 100644 --- a/packages/cli/src/alpha.ts +++ b/packages/cli/src/alpha.ts @@ -15,8 +15,14 @@ */ import { CliInitializer } from './wiring/CliInitializer'; +import chalk from 'chalk'; (async () => { + console.warn( + chalk.yellow( + 'THIS ENTRYPOINT IS IN ALPHA AND MAY CHANGE IN THE FUTURE - DO NOT USE THIS FOR NORMAL DEVELOPMENT', + ), + ); 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 5f5c062d51..0caad2afee 100644 --- a/packages/cli/src/modules/config/alpha.ts +++ b/packages/cli/src/modules/config/alpha.ts @@ -13,9 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { z } from 'zod'; import { createCliPlugin } from '../../wiring/factory'; +import yargs from 'yargs'; export default createCliPlugin({ pluginId: 'config', @@ -23,45 +22,96 @@ export default createCliPlugin({ 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); + execute: async ({ args }) => { + await yargs + .options({ + package: { type: 'string' }, + }) + .command( + '$0', + '', + async () => {}, + async argv => { + const m = await import('./commands/docs'); + await m.default(argv); + }, + ) + .help() + .parse(args); + }, + }); + reg.addCommand({ + path: ['config', 'docs'], + description: 'Browse the configuration reference documentation', + execute: async ({ args }) => { + await yargs + .options({ + package: { type: 'string' }, + }) + .command( + '$0', + '', + async () => {}, + async argv => { + const m = await import('./commands/docs'); + await m.default(argv); + }, + ) + .help() + .parse(args); }, }); 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); + execute: async ({ args }) => { + await yargs + .options({ + package: { type: 'string' }, + lax: { type: 'boolean' }, + frontend: { type: 'boolean' }, + 'with-secrets': { type: 'boolean' }, + format: { type: 'string' }, + config: { type: 'string', array: true }, + }) + .command( + '$0', + '', + async () => {}, + async argv => { + const m = await import('./commands/print'); + await m.default(argv); + }, + ) + .help() + .parse(args); }, }); 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); + execute: async ({ args }) => { + await yargs + .options({ + package: { type: 'string' }, + lax: { type: 'boolean' }, + frontend: { type: 'boolean' }, + deprecated: { type: 'boolean' }, + strict: { type: 'boolean' }, + config: { type: 'string', array: true }, + }) + .command( + '$0', + '', + async () => {}, + async argv => { + const m = await import('./commands/validate'); + await m.default(argv); + }, + ) + .help() + .parse(args); }, }); }, diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index b96dd52b36..7eeaca83cd 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -18,10 +18,21 @@ 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; +function checkCommands( + nestedYargs: yargs.Argv, + argv: Awaited, + numRequired: number, +) { + if (argv._.length < numRequired) { + nestedYargs.showHelp(); + } else { + // check for unknown command + } +} + export class CliInitializer { private graph = new CommandGraph(); private commandRegistry = new CommandRegistry(this.graph); @@ -52,31 +63,60 @@ export class CliInitializer { 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); - } + const root = yargs.usage('usage: $0 ').wrap(null).help('help'); + const rootArgv = process.argv.slice(2); + + const queue = this.graph.atDepth(0).map(node => ({ + node, + argParser: root, + depth: 0, + })); + while (queue.length) { + const { node, argParser, depth } = queue.shift()!; + if (node.$$type === '@tree/root') { + let commandYargs = undefined; + argParser + .recommendCommands() + .demandCommand() + .command(node.name, node.name, async nestedYargs => { + commandYargs = nestedYargs; + nestedYargs.usage(`usage: $0 ${node.name}`).wrap(null).help('help'); + checkCommands( + nestedYargs, + await nestedYargs.argv, + node.children.length, + ); + }); + + queue.push( + ...node.children.map(child => ({ + node: child, + argParser, + depth: depth + 1, + })), + ); + } else { + argParser.command( + node.name, + node.command.description, + async nestedYargs => { + nestedYargs.help(false); + }, + async argv => { + await node.command.execute({ + args: process.argv.slice(2 + depth + 1), + }); + checkCommands(argParser, argv, 1); + }, + ); } - process.exit(1); } - const parsedOptions = command.schema.parse(options); - await command?.execute(parsedOptions); + const argv = await root + .demandCommand() + .recommendCommands() + .strictCommands() + .parse(rootArgv); + checkCommands(root, argv, 1); } } diff --git a/packages/cli/src/wiring/CommandGraph.ts b/packages/cli/src/wiring/CommandGraph.ts index 1e3f80291c..a0a2de3305 100644 --- a/packages/cli/src/wiring/CommandGraph.ts +++ b/packages/cli/src/wiring/CommandGraph.ts @@ -13,10 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ZodRawShape } from 'zod'; import { BackstageCommand } from './types'; -type Node = TreeNode | LeafNode; +type Node = TreeNode | LeafNode; interface TreeNode { $$type: '@tree/root'; @@ -24,10 +23,10 @@ interface TreeNode { children: TreeNode[]; } -interface LeafNode { +interface LeafNode { $$type: '@tree/leaf'; name: string; - command: BackstageCommand; + command: BackstageCommand; } /** @@ -40,7 +39,7 @@ export class CommandGraph { * 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) { + add(command: BackstageCommand) { const path = command.path; let current = this.graph; for (let i = 0; i < path.length - 1; i++) { @@ -56,7 +55,7 @@ export class CommandGraph { } current = next.children; } - const last = current.find(n => n.name === path[path.length - 1]) as Node; + const last = current.find(n => n.name === path[path.length - 1]); if (last && last.$$type === '@tree/leaf') { throw new Error( `Command already exists at path: "${path.slice(0, -1).join(' ')}"`, @@ -66,14 +65,14 @@ export class CommandGraph { $$type: '@tree/leaf', name: path[path.length - 1], command, - } as Node); + }); } } /** * Given a path, try to find a command that matches it. */ - find(path: string[]): BackstageCommand | undefined { + find(path: string[]): BackstageCommand | undefined { let current = this.graph; for (let i = 0; i < path.length - 1; i++) { const name = path[i]; @@ -85,20 +84,20 @@ export class CommandGraph { } current = next.children; } - const last = current.find(n => n.name === path[path.length - 1]) as Node; + const last = current.find(n => n.name === path[path.length - 1]); if (!last || last.$$type === '@tree/root') { return undefined; } return last?.command; } - atDepth(depth: number): Node[] { + 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 : [], ); } - return current as Node[]; + return current; } } diff --git a/packages/cli/src/wiring/CommandRegistry.ts b/packages/cli/src/wiring/CommandRegistry.ts index 3e232173a0..9e8ff9646c 100644 --- a/packages/cli/src/wiring/CommandRegistry.ts +++ b/packages/cli/src/wiring/CommandRegistry.ts @@ -13,8 +13,6 @@ * 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'; @@ -24,7 +22,7 @@ export class CommandRegistry { this.graph = graph; } - addCommand(command: BackstageCommand) { + addCommand(command: BackstageCommand) { this.graph.add(command); } } diff --git a/packages/cli/src/wiring/types.ts b/packages/cli/src/wiring/types.ts index 472427787b..d9237a4c3a 100644 --- a/packages/cli/src/wiring/types.ts +++ b/packages/cli/src/wiring/types.ts @@ -13,14 +13,12 @@ * 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 { +export interface BackstageCommand { path: string[]; description: string; - schema: ZodObject; - execute: (options: T) => Promise; + execute: (options: { args: string[] }) => Promise; } export interface CliFeature { diff --git a/yarn.lock b/yarn.lock index 50761f8d9f..3f1ece0773 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4101,6 +4101,7 @@ __metadata: optional: true bin: backstage-cli: bin/backstage-cli + backstage-cli-alpha: bin/backstage-cli-alpha languageName: unknown linkType: soft