Merge pull request #33036 from backstage/rugvip/cli-execute-loader

cli: add lazy loader pattern for command execution
This commit is contained in:
Patrik Oldsberg
2026-02-27 15:36:12 +01:00
committed by GitHub
32 changed files with 490 additions and 401 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Internal refactor of CLI modularization, moving individual commands to be implemented with cleye.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/repo-tools': patch
---
Updated the CLI report parser to support cleye-style help output, and strip ANSI escape codes from captured output.
@@ -577,3 +577,4 @@ zsh
resizable
enums
LLMs
cleye
+42 -51
View File
@@ -57,81 +57,75 @@ Commands:
### `backstage-cli config docs`
```
Usage: backstage-cli config docs [options]
Usage: backstage-cli config docs
Options:
--package <name>
--package <string>
-h, --help
```
### `backstage-cli config schema`
```
Usage: <none>
Usage: backstage-cli config schema
Options:
--format
--help
--format <string>
--merge
--no-merge
--package
--version
--package <string>
-h, --help
```
### `backstage-cli config:check`
```
Usage: <none>
Usage: backstage-cli config:check
Options:
--config
--config <string>
--deprecated
--frontend
--help
--lax
--package
--package <string>
--strict
--version
-h, --help
```
### `backstage-cli config:docs`
```
Usage: program [options]
Usage: backstage-cli config:docs
Options:
--package <name>
--package <string>
-h, --help
```
### `backstage-cli config:print`
```
Usage: <none>
Usage: backstage-cli config:print
Options:
--config
--format
--config <string>
--format <string>
--frontend
--help
--lax
--package
--version
--package <string>
--with-secrets
-h, --help
```
### `backstage-cli config:schema`
```
Usage: <none>
Usage: backstage-cli config:schema
Options:
--format
--help
--format <string>
--merge
--no-merge
--package
--version
--package <string>
-h, --help
```
### `backstage-cli create-github-app`
@@ -146,13 +140,12 @@ Options:
### `backstage-cli info`
```
Usage: <none>
Usage: backstage-cli info
Options:
--format
--help
--include
--version
--format <string>
--include <string>
-h, --help
```
### `backstage-cli migrate`
@@ -175,7 +168,7 @@ Commands:
### `backstage-cli migrate package-exports`
```
Usage: program [options]
Usage: backstage-cli migrate package-exports
Options:
-h, --help
@@ -184,7 +177,7 @@ Options:
### `backstage-cli migrate package-lint-configs`
```
Usage: program [options]
Usage: backstage-cli migrate package-lint-configs
Options:
-h, --help
@@ -193,7 +186,7 @@ Options:
### `backstage-cli migrate package-roles`
```
Usage: program [options]
Usage: backstage-cli migrate package-roles
Options:
-h, --help
@@ -202,7 +195,7 @@ Options:
### `backstage-cli migrate package-scripts`
```
Usage: program [options]
Usage: backstage-cli migrate package-scripts
Options:
-h, --help
@@ -211,7 +204,7 @@ Options:
### `backstage-cli migrate react-router-deps`
```
Usage: program [options]
Usage: backstage-cli migrate react-router-deps
Options:
-h, --help
@@ -271,7 +264,7 @@ Options:
### `backstage-cli package clean`
```
Usage: program [options]
Usage: backstage-cli package clean
Options:
-h, --help
@@ -293,7 +286,7 @@ Options:
### `backstage-cli package postpack`
```
Usage: program [options]
Usage: backstage-cli package postpack
Options:
-h, --help
@@ -302,7 +295,7 @@ Options:
### `backstage-cli package prepack`
```
Usage: program [options]
Usage: backstage-cli package prepack
Options:
-h, --help
@@ -472,7 +465,7 @@ Options:
### `backstage-cli repo clean`
```
Usage: program [options]
Usage: backstage-cli repo clean
Options:
-h, --help
@@ -560,25 +553,23 @@ Commands:
### `backstage-cli translations export`
```
Usage: <none>
Usage: backstage-cli translations export
Options:
--help
--output
--pattern
--version
--output <string>
--pattern <string>
-h, --help
```
### `backstage-cli translations import`
```
Usage: <none>
Usage: backstage-cli translations import
Options:
--help
--input
--output
--version
--input <string>
--output <string>
-h, --help
```
### `backstage-cli versions:bump`
+1
View File
@@ -81,6 +81,7 @@
"buffer": "^6.0.3",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
"cleye": "^2.2.1",
"commander": "^14.0.3",
"cross-fetch": "^4.0.0",
"cross-spawn": "^7.0.3",
@@ -14,11 +14,14 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import fs from 'fs-extra';
import { targetPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
export default async function clean() {
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
await fs.remove(targetPaths.resolve('dist'));
await fs.remove(targetPaths.resolve('dist-types'));
await fs.remove(targetPaths.resolve('coverage'));
}
};
@@ -14,13 +14,15 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { run, targetPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
export async function command(): Promise<void> {
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
await fs.remove(targetPaths.resolveRoot('dist'));
@@ -48,4 +50,4 @@ export async function command(): Promise<void> {
}
}),
);
}
};
+13 -28
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { Command, Option } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
@@ -206,52 +207,36 @@ export const buildPlugin = createCliPlugin({
reg.addCommand({
path: ['package', 'clean'],
description: 'Delete cache directories',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/clean'), 'default'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/package/clean'),
},
});
reg.addCommand({
path: ['package', 'prepack'],
description: 'Prepares a package for packaging before publishing',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/pack'), 'pre'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: async ({ args, info }) => {
cli({ help: info }, undefined, args);
const { pre } = await import('./commands/package/pack');
await pre();
},
});
reg.addCommand({
path: ['package', 'postpack'],
description: 'Restores the changes made by the prepack command',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/package/pack'), 'post'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: async ({ args, info }) => {
cli({ help: info }, undefined, args);
const { post } = await import('./commands/package/pack');
await post();
},
});
reg.addCommand({
path: ['repo', 'clean'],
description: 'Delete cache and output directories',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/repo/clean'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/repo/clean'),
},
});
@@ -14,20 +14,38 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { JsonObject } from '@backstage/types';
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 chalk from 'chalk';
import { loadCliConfig } from '../lib/config';
import type { CommandContext } from '../../../wiring/types';
const DOCS_URL = 'https://config.backstage.io';
export default async (opts: OptionValues) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { package: pkg },
} = cli(
{
help: info,
flags: {
package: {
type: String,
description:
'Only include the schema that applies to the given package',
},
},
},
undefined,
args,
);
const { schema: appSchemas } = await loadCliConfig({
args: [],
fromPackage: opts.package,
fromPackage: pkg,
mockEnv: true,
});
@@ -14,36 +14,67 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { stringify as stringifyYaml } from 'yaml';
import { AppConfig, ConfigReader } from '@backstage/config';
import { loadCliConfig } from '../lib/config';
import { ConfigSchema, ConfigVisibility } from '@backstage/config-loader';
import type { CommandContext } from '../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { config, lax, frontend, withSecrets, format, package: pkg },
} = cli(
{
help: info,
flags: {
package: { type: String, description: 'Package to print config for' },
lax: {
type: Boolean,
description: 'Do not require environment variables to be set',
},
frontend: { type: Boolean, description: 'Only print frontend config' },
withSecrets: {
type: Boolean,
description: 'Include secrets in the output',
},
format: { type: String, description: 'Output format (yaml or json)' },
config: {
type: [String],
description: 'Config files to load instead of app-config.yaml',
},
},
},
undefined,
args,
);
export default async (opts: OptionValues) => {
const { schema, appConfigs } = await loadCliConfig({
args: opts.config,
fromPackage: opts.package,
mockEnv: opts.lax,
fullVisibility: !opts.frontend,
args: config,
fromPackage: pkg,
mockEnv: lax,
fullVisibility: !frontend,
});
const visibility = getVisibilityOption(opts);
const visibility = getVisibilityOption(frontend, withSecrets);
const data = serializeConfigData(appConfigs, schema, visibility);
if (opts.format === 'json') {
if (format === 'json') {
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
} else {
process.stdout.write(`${stringifyYaml(data)}\n`);
}
};
function getVisibilityOption(opts: OptionValues): ConfigVisibility {
if (opts.frontend && opts.withSecrets) {
function getVisibilityOption(
frontend: boolean | undefined,
withSecrets: boolean | undefined,
): ConfigVisibility {
if (frontend && withSecrets) {
throw new Error('Not allowed to combine frontend and secret config');
}
if (opts.frontend) {
if (frontend) {
return 'frontend';
} else if (opts.withSecrets) {
} else if (withSecrets) {
return 'secret';
}
return 'backend';
@@ -14,22 +14,41 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { JSONSchema7 as JSONSchema } from 'json-schema';
import { stringify as stringifyYaml } from 'yaml';
import { loadCliConfig } from '../lib/config';
import { JsonObject } from '@backstage/types';
import { mergeConfigSchemas } from '@backstage/config-loader';
import type { CommandContext } from '../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { merge, format, package: pkg },
} = cli(
{
help: info,
flags: {
package: { type: String, description: 'Package to print schema for' },
format: { type: String, description: 'Output format (yaml or json)' },
merge: {
type: Boolean,
description: 'Merge all schemas into a single schema',
},
},
},
undefined,
args,
);
export default async (opts: OptionValues) => {
const { schema } = await loadCliConfig({
args: [],
fromPackage: opts.package,
fromPackage: pkg,
mockEnv: true,
});
let configSchema: JsonObject | JSONSchema;
if (opts.merge) {
if (merge) {
configSchema = mergeConfigSchemas(
(schema.serialize().schemas as JsonObject[]).map(
_ => _.value as JSONSchema,
@@ -42,7 +61,7 @@ export default async (opts: OptionValues) => {
configSchema = schema.serialize();
}
if (opts.format === 'json') {
if (format === 'json') {
process.stdout.write(`${JSON.stringify(configSchema, null, 2)}\n`);
} else {
process.stdout.write(`${stringifyYaml(configSchema)}\n`);
@@ -14,16 +14,47 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { loadCliConfig } from '../lib/config';
import type { CommandContext } from '../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { config, lax, frontend, deprecated, strict, package: pkg },
} = cli(
{
help: info,
flags: {
package: {
type: String,
description: 'Package to validate config for',
},
lax: {
type: Boolean,
description: 'Do not require environment variables to be set',
},
frontend: {
type: Boolean,
description: 'Only validate frontend config',
},
deprecated: { type: Boolean, description: 'Output deprecated keys' },
strict: { type: Boolean, description: 'Enable strict validation' },
config: {
type: [String],
description: 'Config files to load instead of app-config.yaml',
},
},
},
undefined,
args,
);
export default async (opts: OptionValues) => {
await loadCliConfig({
args: opts.config,
fromPackage: opts.package,
mockEnv: opts.lax,
fullVisibility: !opts.frontend,
withDeprecatedKeys: opts.deprecated,
strict: opts.strict,
args: config,
fromPackage: pkg,
mockEnv: lax,
fullVisibility: !frontend,
withDeprecatedKeys: deprecated,
strict,
});
};
+6 -84
View File
@@ -14,9 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import yargs from 'yargs';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export const configOption = [
'--config <path>',
@@ -31,108 +28,33 @@ export default createCliPlugin({
reg.addCommand({
path: ['config:docs'],
description: 'Browse the configuration reference documentation',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--package <name>',
'Only include the schema that applies to the given package',
)
.description('Browse the configuration reference documentation')
.action(lazy(() => import('./commands/docs'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/docs') },
});
reg.addCommand({
path: ['config', 'docs'],
description: 'Browse the configuration reference documentation',
execute: async ({ args, info }) => {
await new Command(info.usage)
.option(
'--package <name>',
'Only include the schema that applies to the given package',
)
.description(info.description)
.action(lazy(() => import('./commands/docs'), 'default'))
.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/docs') },
});
reg.addCommand({
path: ['config:print'],
description: 'Print the app configuration for the current package',
execute: async ({ args, info }) => {
const argv = await yargs()
.options({
package: { type: 'string' },
lax: { type: 'boolean' },
frontend: { type: 'boolean' },
'with-secrets': { type: 'boolean' },
format: { type: 'string' },
config: { type: 'string', array: true, default: [] },
})
.usage('$0', info.description)
.help()
.parse(args);
await lazy(() => import('./commands/print'), 'default')(argv);
},
execute: { loader: () => import('./commands/print') },
});
reg.addCommand({
path: ['config:check'],
description:
'Validate that the given configuration loads and matches schema',
execute: async ({ args }) => {
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,
default: [],
},
})
.help()
.parse(args);
await lazy(() => import('./commands/validate'), 'default')(argv);
},
execute: { loader: () => import('./commands/validate') },
});
reg.addCommand({
path: ['config:schema'],
description: 'Print the JSON schema for the given configuration',
execute: async ({ args }) => {
const argv = await yargs()
.options({
package: { type: 'string' },
format: { type: 'string' },
merge: { type: 'boolean' },
'no-merge': { type: 'boolean' },
})
.help()
.parse(args);
await lazy(() => import('./commands/schema'), 'default')(argv);
},
execute: { loader: () => import('./commands/schema') },
});
reg.addCommand({
path: ['config', 'schema'],
description: 'Print the JSON schema for the given configuration',
execute: async ({ args }) => {
const argv = await yargs()
.options({
package: { type: 'string' },
format: { type: 'string' },
merge: { type: 'boolean' },
'no-merge': { type: 'boolean' },
})
.help()
.parse(args);
await lazy(() => import('./commands/schema'), 'default')(argv);
},
execute: { loader: () => import('./commands/schema') },
});
},
});
+27 -6
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { version as cliVersion } from '../../../../package.json';
import os from 'node:os';
import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common';
@@ -24,11 +25,7 @@ import {
} from '@backstage/cli-node';
import { minimatch } from 'minimatch';
import fs from 'fs-extra';
interface InfoOptions {
include: string[];
format: 'text' | 'json';
}
import type { CommandContext } from '../../../wiring/types';
/**
* Attempts to read package.json from node_modules for a given package name.
@@ -55,7 +52,31 @@ function hasBackstageField(packageName: string, targetPath: string): boolean {
return pkg?.backstage !== undefined;
}
export default async (options: InfoOptions) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { include, format },
} = cli(
{
help: info,
flags: {
include: {
type: [String],
description:
'Glob patterns for additional packages to include (e.g., @spotify/backstage*)',
},
format: {
type: String,
description: 'Output format (text or json)',
default: 'text',
},
},
},
undefined,
args,
);
const options = { include, format: format as 'text' | 'json' };
await new Promise(async () => {
const yarnVersion = await runOutput(['yarn', '--version']);
/* eslint-disable-next-line no-restricted-syntax */
+1 -23
View File
@@ -13,9 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import yargs from 'yargs';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'info',
@@ -23,27 +21,7 @@ export default createCliPlugin({
reg.addCommand({
path: ['info'],
description: 'Show helpful information for debugging and reporting bugs',
execute: async ({ args }) => {
const argv = await yargs()
.options({
include: {
type: 'string',
array: true,
default: [],
description:
'Glob patterns for additional packages to include (e.g., @spotify/backstage*)',
},
format: {
type: 'string',
choices: ['text', 'json'],
default: 'text',
description: 'Output format (text or json)',
},
})
.help()
.parse(args);
await lazy(() => import('./commands/info'), 'default')(argv);
},
execute: { loader: () => import('./commands/info') },
});
},
});
@@ -14,8 +14,12 @@
* limitations under the License.
*/
export async function command() {
import { cli } from 'cleye';
import type { CommandContext } from '../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
throw new Error(
'The `migrate package-exports` command has been removed, use `repo fix` instead.',
);
}
};
@@ -14,14 +14,17 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { runOutput } from '@backstage/cli-common';
import type { CommandContext } from '../../../wiring/types';
const PREFIX = `module.exports = require('@backstage/cli/config/eslint-factory')`;
export async function command() {
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
const oldConfigs = [
@@ -86,4 +89,4 @@ export async function command() {
if (hasPrettier) {
await runOutput(['prettier', '--write', ...configPaths]);
}
}
};
@@ -14,13 +14,16 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { getPackages } from '@manypkg/get-packages';
import { PackageRoles } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../wiring/types';
export default async () => {
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
const { packages } = await getPackages(targetPaths.dir);
await Promise.all(
@@ -14,15 +14,18 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph, PackageRoles, PackageRole } from '@backstage/cli-node';
import type { CommandContext } from '../../../wiring/types';
const configArgPattern = /--config[=\s][^\s$]+/;
const noStartRoles: PackageRole[] = ['cli', 'common-library'];
export async function command() {
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
await Promise.all(
@@ -56,14 +59,14 @@ export async function command() {
// For test scripts we keep all existing flags except for --passWithNoTests, since that's now default
const testCmd = ['test'];
if (scripts.test?.startsWith('backstage-cli test')) {
const args = scripts.test
const testArgs = scripts.test
.slice('backstage-cli test'.length)
.split(' ')
.filter(Boolean);
if (args.includes('--passWithNoTests')) {
args.splice(args.indexOf('--passWithNoTests'), 1);
if (testArgs.includes('--passWithNoTests')) {
testArgs.splice(testArgs.indexOf('--passWithNoTests'), 1);
}
testCmd.push(...args);
testCmd.push(...testArgs);
}
const expectedScripts = {
@@ -104,4 +107,4 @@ export async function command() {
}
}),
);
}
};
@@ -14,14 +14,17 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph, PackageRoles } from '@backstage/cli-node';
import type { CommandContext } from '../../../wiring/types';
const REACT_ROUTER_DEPS = ['react-router', 'react-router-dom'];
const REACT_ROUTER_RANGE = '6.0.0-beta.0 || ^6.3.0';
export async function command() {
export default async ({ args, info }: CommandContext) => {
cli({ help: info }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
await Promise.all(
@@ -56,4 +59,4 @@ export async function command() {
}
}),
);
}
};
+10 -35
View File
@@ -68,39 +68,24 @@ export default createCliPlugin({
reg.addCommand({
path: ['migrate', 'package-roles'],
description: `Add package role field to packages that don't have it`,
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/packageRole'), 'default'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/packageRole'),
},
});
reg.addCommand({
path: ['migrate', 'package-scripts'],
description: 'Set package scripts according to each package role',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/packageScripts'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/packageScripts'),
},
});
reg.addCommand({
path: ['migrate', 'package-exports'],
description: 'Synchronize package subpath export definitions',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/packageExports'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/packageExports'),
},
});
@@ -108,13 +93,8 @@ export default createCliPlugin({
path: ['migrate', 'package-lint-configs'],
description:
'Migrates all packages to use @backstage/cli/config/eslint-factory',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/packageLintConfigs'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/packageLintConfigs'),
},
});
@@ -122,13 +102,8 @@ export default createCliPlugin({
path: ['migrate', 'react-router-deps'],
description:
'Migrates the react-router dependencies for all packages to be peer dependencies',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command.action(
lazy(() => import('./commands/reactRouterDeps'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
execute: {
loader: () => import('./commands/reactRouterDeps'),
},
});
},
@@ -87,11 +87,6 @@ export default async (_opts: OptionValues, cmd: Command) => {
}--no-node-snapshot`;
}
// This ensures that the process doesn't exit too early before stdout is flushed
if (args.includes('--help')) {
(process.stdout as any)._handle.setBlocking(true);
}
// Because of the ongoing migration to v30 of jest, jest is no longer hard-depended to allow
// opt-in migration. Users instead need to add jest as a devDependency themselves and specify
// the version they want. This prints a helpful error message if jest is not found, i.e. they
@@ -304,11 +304,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}--no-node-snapshot`;
}
// This ensures that the process doesn't exit too early before stdout is flushed
if (args.includes('--jest-help')) {
removeOptionArg(args, '--jest-help');
args.push('--help');
(process.stdout as any)._handle.setBlocking(true);
}
// This code path is enabled by the --successCache flag, which is specific to
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import fs from 'fs-extra';
import { dirname, resolve as resolvePath } from 'node:path';
@@ -28,16 +29,38 @@ import {
} from '../lib/extractTranslations';
import {
DEFAULT_LANGUAGE,
DEFAULT_MESSAGE_PATTERN,
formatMessagePath,
validatePattern,
} from '../lib/messageFilePath';
import type { CommandContext } from '../../../wiring/types';
interface ExportOptions {
output: string;
pattern: string;
}
export default async ({ args, info }: CommandContext) => {
const {
flags: { output, pattern },
} = cli(
{
help: info,
flags: {
output: {
type: String,
default: 'translations',
description: 'Output directory for exported messages and manifest',
},
pattern: {
type: String,
default: DEFAULT_MESSAGE_PATTERN,
description:
'File path pattern for message files, with {id} and {lang} placeholders',
},
},
},
undefined,
args,
);
const options = { output, pattern };
export default async (options: ExportOptions) => {
validatePattern(options.pattern);
const targetPackageJson = await readTargetPackage(
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import fs from 'fs-extra';
import {
@@ -27,11 +28,7 @@ import {
createMessagePathParser,
formatMessagePath,
} from '../lib/messageFilePath';
interface ImportOptions {
input: string;
output: string;
}
import type { CommandContext } from '../../../wiring/types';
interface ManifestRefEntry {
package: string;
@@ -44,7 +41,31 @@ interface Manifest {
refs: Record<string, ManifestRefEntry>;
}
export default async (options: ImportOptions) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { input, output },
} = cli(
{
help: info,
flags: {
input: {
type: String,
default: 'translations',
description:
'Input directory containing the manifest and translated message files',
},
output: {
type: String,
default: 'src/translations/resources.ts',
description: 'Output path for the generated wiring module',
},
},
},
undefined,
args,
);
const options = { input, output };
await readTargetPackage(targetPaths.dir, targetPaths.rootDir);
const inputDir = resolvePath(targetPaths.dir, options.input);
+2 -42
View File
@@ -13,10 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import yargs from 'yargs';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath';
export default createCliPlugin({
pluginId: 'translations',
@@ -25,51 +22,14 @@ export default createCliPlugin({
path: ['translations', 'export'],
description:
'Export translation messages from an app and all of its frontend plugins to JSON files',
execute: async ({ args }) => {
const argv = await yargs()
.options({
output: {
type: 'string',
default: 'translations',
description:
'Output directory for exported messages and manifest',
},
pattern: {
type: 'string',
default: DEFAULT_MESSAGE_PATTERN,
description:
'File path pattern for message files, with {id} and {lang} placeholders',
},
})
.help()
.parse(args);
await lazy(() => import('./commands/export'), 'default')(argv);
},
execute: { loader: () => import('./commands/export') },
});
reg.addCommand({
path: ['translations', 'import'],
description:
'Generate translation resource wiring from translated JSON files',
execute: async ({ args }) => {
const argv = await yargs()
.options({
input: {
type: 'string',
default: 'translations',
description:
'Input directory containing the manifest and translated message files',
},
output: {
type: 'string',
default: 'src/translations/resources.ts',
description: 'Output path for the generated wiring module',
},
})
.help()
.parse(args);
await lazy(() => import('./commands/import'), 'default')(argv);
},
execute: { loader: () => import('./commands/import') },
});
},
});
@@ -67,6 +67,31 @@ describe('CliInitializer', () => {
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should run commands using a loader', async () => {
expect.assertions(2);
process.argv = ['node', 'cli', 'test', '--verbose'];
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
init: async reg =>
reg.addCommand({
path: ['test'],
description: 'test',
execute: {
loader: async () => ({
default: async ({ args }) => {
expect(args).toEqual(['--verbose']);
},
}),
},
}),
}),
);
await initializer.run();
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should pass positional args to the subcommand if nested', async () => {
expect.assertions(2);
process.argv = [
+15 -3
View File
@@ -118,13 +118,25 @@ export class CliInitializer {
}
positionalArgs.push(nonProcessArgs[argIndex]);
}
await node.command.execute({
const context = {
args: [...positionalArgs, ...args.unknown],
info: {
usage: [programName, ...node.command.path].join(' '),
description: node.command.description,
},
});
};
if (typeof node.command.execute === 'function') {
await node.command.execute(context);
} else {
const mod = await node.command.execute.loader();
// Handle CJS double-wrapping of default exports
const fn =
typeof mod.default === 'function'
? mod.default
: (mod.default as any).default;
await fn(context);
}
process.exit(0);
} catch (error: unknown) {
exitWithError(error);
@@ -144,7 +156,7 @@ export class CliInitializer {
exitWithError(new ForwardedError('Unhandled rejection', rejection));
});
program.parse(process.argv);
await program.parseAsync(process.argv);
}
}
+21 -13
View File
@@ -15,23 +15,31 @@
*/
import { OpaqueType } from '@internal/opaque';
export interface CommandContext {
args: string[];
info: {
/**
* The usage string of the current command, for example: "backstage-cli repo test"
*/
usage: string;
/**
* The description provided for the command
*/
description: string;
};
}
export type CommandExecuteFn = (context: CommandContext) => Promise<void>;
export interface BackstageCommand {
path: string[];
description: string;
deprecated?: boolean;
execute: (context: {
args: string[];
info: {
/**
* The usage string of the current command, for example: "backstage-cli repo test"
*/
usage: string;
/**
* The description provided for the command
*/
description: string;
};
}) => Promise<void>;
execute:
| CommandExecuteFn
| {
loader: () => Promise<{ default: CommandExecuteFn }>;
};
}
export type CliFeature = CliPlugin;
@@ -27,7 +27,14 @@ import { generateCliReport } from './generateCliReport';
import { logApiReportInstructions } from '../common';
function parseHelpPage(helpPageContent: string) {
const [, usage] = helpPageContent.match(/^\s*Usage: (.*)$/im) ?? [];
let usage: string | undefined;
// Commander format: "Usage: backstage-cli ..."
const commanderUsage = helpPageContent.match(/^\s*Usage: (.*)$/im);
if (commanderUsage) {
usage = commanderUsage[1];
}
const lines = helpPageContent.split(/\r?\n/);
let options = new Array<string>();
@@ -39,8 +46,8 @@ function parseHelpPage(helpPageContent: string) {
lines.shift();
}
if (lines.length > 0) {
// Start of a new section, e.g. "Options:"
const sectionName = lines.shift();
// Start of a new section, e.g. "Options:" or "FLAGS:"
const sectionName = lines.shift()?.toLocaleLowerCase('en-US');
// Take lines until we hit the next section or the end
const sectionEndIndex = lines.findIndex(
line => line && !line.match(/^\s/),
@@ -53,12 +60,18 @@ function parseHelpPage(helpPageContent: string) {
.map(line => line.match(/^\s{1,8}(.*?)\s\s+/)?.[1])
.filter(Boolean) as string[];
if (sectionName?.toLocaleLowerCase('en-US') === 'options:') {
if (sectionName === 'options:' || sectionName === 'flags:') {
options = sectionItems;
} else if (sectionName?.toLocaleLowerCase('en-US') === 'commands:') {
} else if (sectionName === 'commands:') {
commands = sectionItems;
} else if (sectionName?.toLocaleLowerCase('en-US') === 'arguments:') {
} else if (sectionName === 'arguments:') {
commandArguments = sectionItems;
} else if (sectionName === 'usage:') {
// cleye format: usage line is inside the USAGE: section
const usageLine = sectionLines.find(l => l.trim().length > 0)?.trim();
if (usageLine) {
usage = usageLine;
}
} else {
throw new Error(`Unknown CLI section: ${sectionName}`);
}
+51 -46
View File
@@ -14,58 +14,63 @@
* limitations under the License.
*/
import { spawn } from 'node:child_process';
import os from 'node:os';
import pLimit from 'p-limit';
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { openSync, closeSync, readFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// Some commands launch full node processes doing heavy work, which at high
// concurrency levels risk exhausting system resources. Placing the limiter here
// at the root level ensures that the concurrency boundary applies globally, not
// just per-runner.
const limiter = pLimit(os.cpus().length);
// Matches ANSI SGR escape sequences (e.g. bold, color, reset)
const ansiPattern = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g');
/**
* Redirect stdout to a temp file so that Node.js creates a SyncWriteStream
* (synchronous writes) in the child instead of an async pipe stream. This
* prevents data loss when child processes call process.exit() before the
* async stream buffer has been flushed.
*
* Uses spawnSync which blocks the event loop, so no concurrency limiter is
* needed each call naturally runs sequentially.
*/
export function createBinRunner(cwd: string, path: string) {
return async (...command: string[]) =>
limiter(
() =>
new Promise<string>((resolve, reject) => {
// Handle the case where path is empty and the script path is the first command argument
const args = path ? [path, ...command] : command;
const child = spawn('node', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
return async (...command: string[]) => {
const args = path ? [path, ...command] : command;
const outPath = join(tmpdir(), `backstage-cli-out-${randomUUID()}.txt`);
const outFd = openSync(outPath, 'w');
let stdout = '';
let stderr = '';
try {
const result = spawnSync('node', args, {
cwd,
env: { ...process.env, NO_COLOR: '1' },
stdio: ['ignore', outFd, 'pipe'],
});
child.stdout?.on('data', data => {
stdout += data.toString();
});
closeSync(outFd);
const stdout = readFileSync(outPath, 'utf8').replace(ansiPattern, '');
child.stderr?.on('data', data => {
stderr += data.toString();
});
if (result.error) {
throw new Error(`Process error: ${result.error.message}`);
}
child.on('error', err => {
reject(new Error(`Process error: ${err.message}`));
});
const stderr = result.stderr?.toString() ?? '';
child.on('close', (code, signal) => {
if (signal) {
reject(
new Error(
`Process was killed with signal ${signal}\n${stderr}`,
),
);
} else if (code !== 0) {
reject(new Error(`Process exited with code ${code}\n${stderr}`));
} else if (stderr.trim()) {
reject(new Error(`Command printed error output: ${stderr}`));
} else {
resolve(stdout);
}
});
}),
);
if (result.signal) {
throw new Error(
`Process was killed with signal ${result.signal}\n${stderr}`,
);
} else if (result.status !== 0) {
throw new Error(`Process exited with code ${result.status}\n${stderr}`);
} else if (stderr.trim()) {
throw new Error(`Command printed error output: ${stderr}`);
}
return stdout;
} finally {
try {
unlinkSync(outPath);
} catch {
/* ignore cleanup errors */
}
}
};
}
+25
View File
@@ -3352,6 +3352,7 @@ __metadata:
buffer: "npm:^6.0.3"
chalk: "npm:^4.0.0"
chokidar: "npm:^3.3.1"
cleye: "npm:^2.2.1"
commander: "npm:^14.0.3"
cross-fetch: "npm:^4.0.0"
cross-spawn: "npm:^7.0.3"
@@ -27246,6 +27247,16 @@ __metadata:
languageName: node
linkType: hard
"cleye@npm:^2.2.1":
version: 2.2.1
resolution: "cleye@npm:2.2.1"
dependencies:
terminal-columns: "npm:^2.0.0"
type-flag: "npm:^4.0.3"
checksum: 10/4d24a7e4863d38b9a40c01ca332baa7526b320c14fc488e7fb2d3f54b0ad5baa8c5defa6fc6adc079f50f629409056d9da74be6611f6394b709c9062d39b2216
languageName: node
linkType: hard
"cli-boxes@npm:^2.2.0, cli-boxes@npm:^2.2.1":
version: 2.2.1
resolution: "cli-boxes@npm:2.2.1"
@@ -48464,6 +48475,13 @@ __metadata:
languageName: node
linkType: hard
"terminal-columns@npm:^2.0.0":
version: 2.0.0
resolution: "terminal-columns@npm:2.0.0"
checksum: 10/f958993886e09d7c0780f47833316532a0c7dd7db2fb43da9d34a88c821633408a6e7084af59f99c20b045776814748f14a8e33573886186be7a30174092964f
languageName: node
linkType: hard
"terser-webpack-plugin@npm:*, terser-webpack-plugin@npm:^5.1.3, terser-webpack-plugin@npm:^5.3.16":
version: 5.3.16
resolution: "terser-webpack-plugin@npm:5.3.16"
@@ -49323,6 +49341,13 @@ __metadata:
languageName: node
linkType: hard
"type-flag@npm:^4.0.3":
version: 4.0.3
resolution: "type-flag@npm:4.0.3"
checksum: 10/ffa9e56e50ed73ed802aa45b8d47eeba7677d2d66785e9153bfbc758c2613b6ba78b621d7766b713a287dbbe6758a61000f57d2ce8afc94e1876a37de914c73c
languageName: node
linkType: hard
"type-is@npm:^1.6.18, type-is@npm:~1.6.18":
version: 1.6.18
resolution: "type-is@npm:1.6.18"