From ffb868954466b4a8aeb416e31a5f3d8b047a74ef Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sat, 19 Oct 2024 11:23:17 -0400 Subject: [PATCH 1/8] feat: move config to its own "module" Signed-off-by: aramissennyeydd --- .../cli/src/commands/build/buildFrontend.ts | 2 +- packages/cli/src/commands/index.ts | 92 ++----------------- packages/cli/src/lib/bundler/server.ts | 2 +- packages/cli/src/lib/lazy.ts | 35 +++++++ .../config/commands}/docs.ts | 2 +- .../config/commands}/print.ts | 2 +- .../config/commands}/schema.ts | 2 +- .../config/commands}/validate.ts | 2 +- packages/cli/src/modules/config/index.ts | 87 ++++++++++++++++++ .../src/{ => modules/config}/lib/config.ts | 2 +- 10 files changed, 135 insertions(+), 93 deletions(-) create mode 100644 packages/cli/src/lib/lazy.ts rename packages/cli/src/{commands/config => modules/config/commands}/docs.ts (96%) rename packages/cli/src/{commands/config => modules/config/commands}/print.ts (97%) rename packages/cli/src/{commands/config => modules/config/commands}/schema.ts (97%) rename packages/cli/src/{commands/config => modules/config/commands}/validate.ts (94%) create mode 100644 packages/cli/src/modules/config/index.ts rename packages/cli/src/{ => modules/config}/lib/config.ts (99%) diff --git a/packages/cli/src/commands/build/buildFrontend.ts b/packages/cli/src/commands/build/buildFrontend.ts index 59f3225738..0ba0669c32 100644 --- a/packages/cli/src/commands/build/buildFrontend.ts +++ b/packages/cli/src/commands/build/buildFrontend.ts @@ -18,7 +18,7 @@ import fs from 'fs-extra'; import { resolve as resolvePath } from 'path'; import { buildBundle, getModuleFederationOptions } from '../../lib/bundler'; import { getEnvironmentParallelism } from '../../lib/parallel'; -import { loadCliConfig } from '../../lib/config'; +import { loadCliConfig } from '../../modules/config/lib/config'; interface BuildAppOptions { targetDir: string; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 2a77d527c1..f249563a34 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -15,15 +15,11 @@ */ import { Command, Option } from 'commander'; -import { assertError } from '@backstage/errors'; -import { exitWithError } from '../lib/errors'; - -const configOption = [ - '--config ', - 'Config files to load instead of app-config.yaml', - (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), - Array(), -] as const; +import { lazy } from '../lib/lazy'; +import { + configOption, + registerCommands as registerConfigCommands, +} from '../modules/config'; export function registerRepoCommand(program: Command) { const command = program @@ -279,66 +275,7 @@ export function registerCommands(program: Command) { .option('--no-private', 'Do not mark new packages as private') .action(lazy(() => import('./new/new').then(m => m.default))); - program - .command('config:docs') - .option( - '--package ', - 'Only include the schema that applies to the given package', - ) - .description('Browse the configuration reference documentation') - .action(lazy(() => import('./config/docs').then(m => m.default))); - - program - .command('config:print') - .option( - '--package ', - 'Only load config schema that applies to the given package', - ) - .option('--lax', 'Do not require environment variables to be set') - .option('--frontend', 'Print only the frontend configuration') - .option('--with-secrets', 'Include secrets in the printed configuration') - .option( - '--format ', - 'Format to print the configuration in, either json or yaml [yaml]', - ) - .option(...configOption) - .description('Print the app configuration for the current package') - .action(lazy(() => import('./config/print').then(m => m.default))); - - program - .command('config:check') - .option( - '--package ', - 'Only load config schema that applies to the given package', - ) - .option('--lax', 'Do not require environment variables to be set') - .option('--frontend', 'Only validate the frontend configuration') - .option('--deprecated', 'Output deprecated configuration settings') - .option( - '--strict', - 'Enable strict config validation, forbidding errors and unknown keys', - ) - .option(...configOption) - .description( - 'Validate that the given configuration loads and matches schema', - ) - .action(lazy(() => import('./config/validate').then(m => m.default))); - - program - .command('config:schema') - .option( - '--package ', - 'Only output config schema that applies to the given package', - ) - .option( - '--format ', - 'Format to print the schema in, either json or yaml [yaml]', - ) - .option('--merge', 'Print the config schemas merged', true) - .option('--no-merge', 'Print the config schemas not merged') - .description('Print configuration schema') - .action(lazy(() => import('./config/schema').then(m => m.default))); - + registerConfigCommands(program); registerRepoCommand(program); registerScriptCommand(program); registerMigrateCommand(program); @@ -444,20 +381,3 @@ function removed(message?: string) { process.exit(1); }; } - -// Wraps an action function so that it always exits and handles errors -function lazy( - getActionFunc: () => Promise<(...args: any[]) => Promise>, -): (...args: any[]) => Promise { - return async (...args: any[]) => { - try { - const actionFunc = await getActionFunc(); - await actionFunc(...args); - - process.exit(0); - } catch (error) { - assertError(error); - exitWithError(error); - } - }; -} diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 44d6d54379..f3863283e0 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -22,7 +22,7 @@ import webpack from 'webpack'; import WebpackDevServer from 'webpack-dev-server'; import { paths as libPaths } from '../../lib/paths'; -import { loadCliConfig } from '../config'; +import { loadCliConfig } from '../../modules/config/lib/config'; import { createConfig, resolveBaseUrl, resolveEndpoint } from './config'; import { createDetectedModulesEntryPoint } from './packageDetection'; import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths'; diff --git a/packages/cli/src/lib/lazy.ts b/packages/cli/src/lib/lazy.ts new file mode 100644 index 0000000000..6d2cb1cd4f --- /dev/null +++ b/packages/cli/src/lib/lazy.ts @@ -0,0 +1,35 @@ +/* + * 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 { assertError } from '@backstage/errors'; +import { exitWithError } from '../lib/errors'; + +// Wraps an action function so that it always exits and handles errors +export function lazy( + getActionFunc: () => Promise<(...args: any[]) => Promise>, +): (...args: any[]) => Promise { + return async (...args: any[]) => { + try { + const actionFunc = await getActionFunc(); + await actionFunc(...args); + + process.exit(0); + } catch (error) { + assertError(error); + exitWithError(error); + } + }; +} diff --git a/packages/cli/src/commands/config/docs.ts b/packages/cli/src/modules/config/commands/docs.ts similarity index 96% rename from packages/cli/src/commands/config/docs.ts rename to packages/cli/src/modules/config/commands/docs.ts index 98e683e87c..25e3a864f4 100644 --- a/packages/cli/src/commands/config/docs.ts +++ b/packages/cli/src/modules/config/commands/docs.ts @@ -19,7 +19,7 @@ import { mergeConfigSchemas } from '@backstage/config-loader'; import { OptionValues } from 'commander'; import { JSONSchema7 as JSONSchema } from 'json-schema'; import openBrowser from 'react-dev-utils/openBrowser'; -import { loadCliConfig } from '../../lib/config'; +import { loadCliConfig } from '../lib/config'; const DOCS_URL = 'https://config.backstage.io'; diff --git a/packages/cli/src/commands/config/print.ts b/packages/cli/src/modules/config/commands/print.ts similarity index 97% rename from packages/cli/src/commands/config/print.ts rename to packages/cli/src/modules/config/commands/print.ts index 9ef725ebf8..98833a7946 100644 --- a/packages/cli/src/commands/config/print.ts +++ b/packages/cli/src/modules/config/commands/print.ts @@ -17,7 +17,7 @@ import { OptionValues } from 'commander'; import { stringify as stringifyYaml } from 'yaml'; import { AppConfig, ConfigReader } from '@backstage/config'; -import { loadCliConfig } from '../../lib/config'; +import { loadCliConfig } from '../lib/config'; import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader'; export default async (opts: OptionValues) => { diff --git a/packages/cli/src/commands/config/schema.ts b/packages/cli/src/modules/config/commands/schema.ts similarity index 97% rename from packages/cli/src/commands/config/schema.ts rename to packages/cli/src/modules/config/commands/schema.ts index 08bc46d1cc..73eb487d60 100644 --- a/packages/cli/src/commands/config/schema.ts +++ b/packages/cli/src/modules/config/commands/schema.ts @@ -17,7 +17,7 @@ import { OptionValues } from 'commander'; import { JSONSchema7 as JSONSchema } from 'json-schema'; import { stringify as stringifyYaml } from 'yaml'; -import { loadCliConfig } from '../../lib/config'; +import { loadCliConfig } from '../lib/config'; import { JsonObject } from '@backstage/types'; import { mergeConfigSchemas } from '@backstage/config-loader'; diff --git a/packages/cli/src/commands/config/validate.ts b/packages/cli/src/modules/config/commands/validate.ts similarity index 94% rename from packages/cli/src/commands/config/validate.ts rename to packages/cli/src/modules/config/commands/validate.ts index c81d82832e..ac0b9d15e0 100644 --- a/packages/cli/src/commands/config/validate.ts +++ b/packages/cli/src/modules/config/commands/validate.ts @@ -15,7 +15,7 @@ */ import { OptionValues } from 'commander'; -import { loadCliConfig } from '../../lib/config'; +import { loadCliConfig } from '../lib/config'; export default async (opts: OptionValues) => { await loadCliConfig({ diff --git a/packages/cli/src/modules/config/index.ts b/packages/cli/src/modules/config/index.ts new file mode 100644 index 0000000000..0c6a79577d --- /dev/null +++ b/packages/cli/src/modules/config/index.ts @@ -0,0 +1,87 @@ +/* + * 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 { Command } from 'commander'; +import { lazy } from '../../lib/lazy'; + +export const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]), + Array(), +] as const; + +export function registerCommands(program: Command) { + program + .command('config:docs') + .option( + '--package ', + 'Only include the schema that applies to the given package', + ) + .description('Browse the configuration reference documentation') + .action(lazy(() => import('./commands/docs').then(m => m.default))); + + program + .command('config:print') + .option( + '--package ', + 'Only load config schema that applies to the given package', + ) + .option('--lax', 'Do not require environment variables to be set') + .option('--frontend', 'Print only the frontend configuration') + .option('--with-secrets', 'Include secrets in the printed configuration') + .option( + '--format ', + 'Format to print the configuration in, either json or yaml [yaml]', + ) + .option(...configOption) + .description('Print the app configuration for the current package') + .action(lazy(() => import('./commands/print').then(m => m.default))); + + program + .command('config:check') + .option( + '--package ', + 'Only load config schema that applies to the given package', + ) + .option('--lax', 'Do not require environment variables to be set') + .option('--frontend', 'Only validate the frontend configuration') + .option('--deprecated', 'Output deprecated configuration settings') + .option( + '--strict', + 'Enable strict config validation, forbidding errors and unknown keys', + ) + .option(...configOption) + .description( + 'Validate that the given configuration loads and matches schema', + ) + .action(lazy(() => import('./commands/validate').then(m => m.default))); + + program + .command('config:schema') + .option( + '--package ', + 'Only output config schema that applies to the given package', + ) + .option( + '--format ', + 'Format to print the schema in, either json or yaml [yaml]', + ) + .option('--merge', 'Print the config schemas merged', true) + .option('--no-merge', 'Print the config schemas not merged') + .description('Print configuration schema') + .action(lazy(() => import('./commands/schema').then(m => m.default))); +} diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/modules/config/lib/config.ts similarity index 99% rename from packages/cli/src/lib/config.ts rename to packages/cli/src/modules/config/lib/config.ts index aab16a8670..afed53af6f 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/modules/config/lib/config.ts @@ -16,7 +16,7 @@ import { ConfigSources, loadConfigSchema } from '@backstage/config-loader'; import { AppConfig, ConfigReader } from '@backstage/config'; -import { paths } from './paths'; +import { paths } from '../../../lib/paths'; import { getPackages } from '@manypkg/get-packages'; import { PackageGraph } from '@backstage/cli-node'; From a47433ddb79b5ecaf8ebd3faa08f5965c0e205d3 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sat, 19 Oct 2024 13:03:49 -0400 Subject: [PATCH 2/8] quick idea around arg parsing + sparse tree Signed-off-by: aramissennyeydd --- packages/cli/src/modules/config/alpha.ts | 66 ++++++++++++ packages/cli/src/wiring/Command.ts | 132 +++++++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 packages/cli/src/modules/config/alpha.ts create mode 100644 packages/cli/src/wiring/Command.ts 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); + } +} From ccaa63762a2e6cea695d5fe301173e99959a81e8 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sat, 19 Oct 2024 15:07:24 -0400 Subject: [PATCH 3/8] a bit more wiring and adding an alpha entry point Signed-off-by: aramissennyeydd --- packages/cli/bin/backstage-cli-alpha | 19 ++++ packages/cli/src/alpha.ts | 23 ++++ packages/cli/src/modules/config/alpha.ts | 102 +++++++++--------- packages/cli/src/wiring/CliInitializer.ts | 101 +++++++++++++++++ .../wiring/{Command.ts => CommandGraph.ts} | 62 +++-------- packages/cli/src/wiring/CommandRegistry.ts | 30 ++++++ packages/cli/src/wiring/factory.ts | 32 ++++++ packages/cli/src/wiring/types.ts | 48 +++++++++ 8 files changed, 322 insertions(+), 95 deletions(-) create mode 100755 packages/cli/bin/backstage-cli-alpha create mode 100644 packages/cli/src/alpha.ts create mode 100644 packages/cli/src/wiring/CliInitializer.ts rename packages/cli/src/wiring/{Command.ts => CommandGraph.ts} (64%) create mode 100644 packages/cli/src/wiring/CommandRegistry.ts create mode 100644 packages/cli/src/wiring/factory.ts create mode 100644 packages/cli/src/wiring/types.ts 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; From 9b0d1176aeb70942a2e0e212777e6933cb4348d3 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Mon, 28 Oct 2024 21:10:05 -0400 Subject: [PATCH 4/8] add yargs examples - added a config docs to test nesting logic Signed-off-by: aramissennyeydd --- packages/cli/package.json | 3 +- packages/cli/src/alpha.ts | 6 ++ packages/cli/src/modules/config/alpha.ts | 110 +++++++++++++++------ packages/cli/src/wiring/CliInitializer.ts | 88 ++++++++++++----- packages/cli/src/wiring/CommandGraph.ts | 21 ++-- packages/cli/src/wiring/CommandRegistry.ts | 4 +- packages/cli/src/wiring/types.ts | 6 +- yarn.lock | 1 + 8 files changed, 166 insertions(+), 73 deletions(-) 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 From afe647a81b3187d69141af431db0ba6ebfae1982 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 29 Oct 2024 10:20:17 -0400 Subject: [PATCH 5/8] update to use commander at the tree level Signed-off-by: aramissennyeydd --- packages/cli/src/modules/config/alpha.ts | 26 ++++--- packages/cli/src/wiring/CliInitializer.ts | 83 +++++++++-------------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/modules/config/alpha.ts b/packages/cli/src/modules/config/alpha.ts index 0caad2afee..4ced91e6b2 100644 --- a/packages/cli/src/modules/config/alpha.ts +++ b/packages/cli/src/modules/config/alpha.ts @@ -15,6 +15,8 @@ */ import { createCliPlugin } from '../../wiring/factory'; import yargs from 'yargs'; +import { Command } from 'commander'; +import { lazy } from '../../lib/lazy'; export default createCliPlugin({ pluginId: 'config', @@ -23,21 +25,17 @@ export default createCliPlugin({ 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); - }, + console.log(args); + const command = new Command(); + const defaultCommand = command + .option( + '--package ', + 'Only include the schema that applies to the given package', ) - .help() - .parse(args); + .description('Browse the configuration reference documentation') + .action(lazy(() => import('./commands/docs').then(m => m.default))); + + await defaultCommand.parseAsync(args, { from: 'user' }); }, }); reg.addCommand({ diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index 7eeaca83cd..c52e8c313d 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -14,25 +14,15 @@ * limitations under the License. */ -import yargs from 'yargs'; import { CommandGraph } from './CommandGraph'; import { CliFeature, InternalCliFeature, InternalCliPlugin } from './types'; import { CommandRegistry } from './CommandRegistry'; +import { program } from 'commander'; +import { version } from '../lib/version'; +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); @@ -62,61 +52,52 @@ export class CliInitializer { */ async run() { await this.#doInit(); - - const root = yargs.usage('usage: $0 ').wrap(null).help('help'); - const rootArgv = process.argv.slice(2); + program + .name('backstage-cli') + .version(version) + .allowUnknownOption(true) + .allowExcessArguments(true); const queue = this.graph.atDepth(0).map(node => ({ node, - argParser: root, - depth: 0, + argParser: program, })); while (queue.length) { - const { node, argParser, depth } = queue.shift()!; + const { node, argParser } = 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, - ); - }); + const treeParser = argParser + .command(`${node.name} [command]`) + .description(node.name); queue.push( ...node.children.map(child => ({ node: child, - argParser, - depth: depth + 1, + argParser: treeParser, })), ); } else { - argParser.command( - node.name, - node.command.description, - async nestedYargs => { - nestedYargs.help(false); - }, - async argv => { + argParser + .command(node.name) + .description(node.command.description) + .helpOption(false) + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { await node.command.execute({ - args: process.argv.slice(2 + depth + 1), + args: program.parseOptions(process.argv).unknown, }); - checkCommands(argParser, argv, 1); - }, - ); + }); } } - const argv = await root - .demandCommand() - .recommendCommands() - .strictCommands() - .parse(rootArgv); - checkCommands(root, argv, 1); + program.on('command:*', () => { + console.log(); + console.log(chalk.red(`Invalid command: ${program.args.join(' ')}`)); + console.log(); + program.outputHelp(); + process.exit(1); + }); + + program.parse(process.argv); } } From 104a1499fee23c900456d2c1f3dba2e97f5997b3 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 10 Nov 2024 10:21:21 -0500 Subject: [PATCH 6/8] use the default error handling for the CLI Signed-off-by: aramissennyeydd --- packages/cli/src/modules/config/alpha.ts | 48 +++++++---------------- packages/cli/src/wiring/CliInitializer.ts | 22 +++++++++-- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/modules/config/alpha.ts b/packages/cli/src/modules/config/alpha.ts index 4ced91e6b2..c32982086a 100644 --- a/packages/cli/src/modules/config/alpha.ts +++ b/packages/cli/src/modules/config/alpha.ts @@ -25,7 +25,6 @@ export default createCliPlugin({ path: ['config:docs'], description: 'Browse the configuration reference documentation', execute: async ({ args }) => { - console.log(args); const command = new Command(); const defaultCommand = command .option( @@ -42,28 +41,21 @@ export default createCliPlugin({ path: ['config', 'docs'], description: 'Browse the configuration reference documentation', execute: async ({ args }) => { - await yargs + const argv = await yargs .options({ package: { type: 'string' }, }) - .command( - '$0', - '', - async () => {}, - async argv => { - const m = await import('./commands/docs'); - await m.default(argv); - }, - ) .help() .parse(args); + const m = await import('./commands/docs'); + await m.default(argv); }, }); reg.addCommand({ path: ['config:print'], description: 'Print the app configuration for the current package', execute: async ({ args }) => { - await yargs + const argv = await yargs .options({ package: { type: 'string' }, lax: { type: 'boolean' }, @@ -72,17 +64,10 @@ export default createCliPlugin({ 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); + const m = await import('./commands/print'); + await m.default(argv); }, }); reg.addCommand({ @@ -90,26 +75,23 @@ export default createCliPlugin({ description: 'Validate that the given configuration loads and matches schema', execute: async ({ args }) => { - await yargs + const argv = 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); + strict: { type: 'boolean', required: true }, + config: { + type: 'string', + array: true, + default: [], }, - ) + }) .help() .parse(args); + const m = await import('./commands/validate'); + await m.default(argv); }, }); }, diff --git a/packages/cli/src/wiring/CliInitializer.ts b/packages/cli/src/wiring/CliInitializer.ts index c52e8c313d..9fe8bb8084 100644 --- a/packages/cli/src/wiring/CliInitializer.ts +++ b/packages/cli/src/wiring/CliInitializer.ts @@ -20,6 +20,8 @@ import { CommandRegistry } from './CommandRegistry'; import { program } from 'commander'; import { version } from '../lib/version'; import chalk from 'chalk'; +import { exitWithError } from '../lib/errors'; +import { assertError } from '@backstage/errors'; type UninitializedFeature = CliFeature | Promise; @@ -83,9 +85,15 @@ export class CliInitializer { .allowUnknownOption(true) .allowExcessArguments(true) .action(async () => { - await node.command.execute({ - args: program.parseOptions(process.argv).unknown, - }); + try { + await node.command.execute({ + args: program.parseOptions(process.argv).unknown, + }); + process.exit(0); + } catch (error) { + assertError(error); + exitWithError(error); + } }); } } @@ -97,6 +105,14 @@ export class CliInitializer { process.exit(1); }); + process.on('unhandledRejection', rejection => { + if (rejection instanceof Error) { + exitWithError(rejection); + } else { + exitWithError(new Error(`Unknown rejection: '${rejection}'`)); + } + }); + program.parse(process.argv); } } From 68166ae52bbe0c7c8f75da588bcba5fd088fc7ed Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 10 Nov 2024 11:20:57 -0500 Subject: [PATCH 7/8] remove bin Signed-off-by: aramissennyeydd --- packages/cli/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 6eb1acb723..d886bdde0b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,8 +20,7 @@ "license": "Apache-2.0", "main": "dist/index.cjs.js", "bin": { - "backstage-cli": "bin/backstage-cli", - "backstage-cli-alpha": "bin/backstage-cli-alpha" + "backstage-cli": "bin/backstage-cli" }, "files": [ "asset-types", From 06a0a2f27b1e7c32c01b6444f6983b8651003973 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 10 Nov 2024 11:22:07 -0500 Subject: [PATCH 8/8] fix lockfile Signed-off-by: aramissennyeydd --- yarn.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 3f1ece0773..50761f8d9f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4101,7 +4101,6 @@ __metadata: optional: true bin: backstage-cli: bin/backstage-cli - backstage-cli-alpha: bin/backstage-cli-alpha languageName: unknown linkType: soft