Migrate remaining CLI commands from commander to cleye

All command handlers in the CLI now use cleye or node:util.parseArgs
for argument parsing instead of commander. The top-level command
registration in CliInitializer still uses commander, but all individual
command implementations are now independent of it.

Modules migrated: build, lint, test, maintenance, migrate, new, and
create-github-app. Also replaced createScriptOptionsParser in both
build and lint modules to use node:util.parseArgs instead of commander.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-28 00:46:54 +01:00
parent 9562f04d7f
commit ff4a45ac01
32 changed files with 707 additions and 696 deletions
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Migrated remaining CLI command handlers from `commander` to `cleye` for argument parsing.
@@ -15,20 +15,39 @@
*/
import fs from 'fs-extra';
import { cli } from 'cleye';
import { createDistWorkspace } from '../lib/packager';
import type { CommandContext } from '../../../wiring/types';
type Options = {
alwaysPack?: boolean;
};
export default async ({ args, info }: CommandContext) => {
const {
flags: { alwaysPack },
_: positionals,
} = cli(
{
help: info,
parameters: ['<workspace-dir>', '[packages...]'],
flags: {
alwaysPack: {
type: Boolean,
description:
'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)',
},
},
},
undefined,
args,
);
const [dir, ...packages] = positionals;
export default async (dir: string, packages: string[], options: Options) => {
if (!(await fs.pathExists(dir))) {
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
}
await createDistWorkspace(packages, {
targetDir: dir,
alwaysPack: options.alwaysPack,
alwaysPack,
enableFeatureDetection: true,
});
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import fs from 'fs-extra';
import { buildPackage, Output } from '../../../lib/builder';
import { findRoleFromCommand } from '../../../lib/role';
@@ -29,40 +29,90 @@ import { buildFrontend } from '../../../lib/buildFrontend';
import { buildBackend } from '../../../lib/buildBackend';
import { isValidUrl } from '../../../lib/urls';
import chalk from 'chalk';
import type { CommandContext } from '../../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: {
role,
minify,
skipBuildDependencies,
stats,
config,
moduleFederation,
},
} = cli(
{
help: info,
flags: {
role: {
type: String,
description: 'Run the command with an explicit package role',
},
minify: {
type: Boolean,
description:
'Minify the generated code. Does not apply to app package (app is minified by default).',
},
skipBuildDependencies: {
type: Boolean,
description:
'Skip the automatic building of local dependencies. Applies to backend packages only.',
},
stats: {
type: Boolean,
description:
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
},
config: {
type: [String],
description:
'Config files to load instead of app-config.yaml. Applies to app packages only.',
default: [],
},
moduleFederation: {
type: Boolean,
description:
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
},
},
},
undefined,
args,
);
export async function command(opts: OptionValues): Promise<void> {
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
const role = await findRoleFromCommand(opts);
const resolvedRole = await findRoleFromCommand({ role });
if (role === 'frontend' || role === 'backend') {
const configPaths = (opts.config as string[]).map(arg => {
if (resolvedRole === 'frontend' || resolvedRole === 'backend') {
const configPaths = config.map(arg => {
if (isValidUrl(arg)) {
return arg;
}
return targetPaths.resolve(arg);
});
if (role === 'frontend') {
if (resolvedRole === 'frontend') {
return buildFrontend({
targetDir: targetPaths.dir,
configPaths,
writeStats: Boolean(opts.stats),
writeStats: Boolean(stats),
webpack,
});
}
return buildBackend({
targetDir: targetPaths.dir,
configPaths,
skipBuildDependencies: Boolean(opts.skipBuildDependencies),
minify: Boolean(opts.minify),
skipBuildDependencies: Boolean(skipBuildDependencies),
minify: Boolean(minify),
});
}
let isModuleFederationRemote: boolean | undefined = undefined;
if ((role as string) === 'frontend-dynamic-container') {
if ((resolvedRole as string) === 'frontend-dynamic-container') {
console.log(
chalk.yellow(
`⚠️ WARNING: The 'frontend-dynamic-container' package role is experimental and will receive immediate breaking changes in the future.`,
@@ -70,7 +120,7 @@ export async function command(opts: OptionValues): Promise<void> {
);
isModuleFederationRemote = true;
}
if (opts.moduleFederation) {
if (moduleFederation) {
isModuleFederationRemote = true;
}
@@ -79,13 +129,13 @@ export async function command(opts: OptionValues): Promise<void> {
return buildFrontend({
targetDir: targetPaths.dir,
configPaths: [],
writeStats: Boolean(opts.stats),
writeStats: Boolean(stats),
isModuleFederationRemote,
webpack,
});
}
const roleInfo = PackageRoles.getRoleInfo(role);
const roleInfo = PackageRoles.getRoleInfo(resolvedRole);
const outputs = new Set<Output>();
@@ -106,7 +156,7 @@ export async function command(opts: OptionValues): Promise<void> {
return buildPackage({
outputs,
packageJson,
minify: Boolean(opts.minify),
minify: Boolean(minify),
workspacePackages: await PackageGraph.listTargetPackages(),
});
}
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { command } from './command';
export { default } from './command';
@@ -14,22 +14,79 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { startPackage } from './startPackage';
import { resolveLinkedWorkspace } from './resolveLinkedWorkspace';
import { findRoleFromCommand } from '../../../lib/role';
import { targetPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: {
config,
role,
check,
inspect,
inspectBrk,
require: requirePaths,
link,
entrypoint,
},
} = cli(
{
help: info,
flags: {
config: {
type: [String],
description: 'Config files to load instead of app-config.yaml',
default: [],
},
role: {
type: String,
description: 'Run the command with an explicit package role',
},
check: {
type: Boolean,
description: 'Enable type checking and linting if available',
},
inspect: {
type: String,
description: 'Enable debugger in Node.js environments',
},
inspectBrk: {
type: String,
description:
'Enable debugger in Node.js environments, breaking before code starts',
},
require: {
type: [String],
description: 'Add a --require argument to the node process',
},
link: {
type: String,
description: 'Link an external workspace for module resolution',
},
entrypoint: {
type: String,
description:
'The entrypoint to start from, relative to the package root. Can point to either a file (without extension) or a directory (in which case the index file in that directory is used). Defaults to "dev"',
},
},
},
undefined,
args,
);
export async function command(opts: OptionValues): Promise<void> {
await startPackage({
role: await findRoleFromCommand(opts),
entrypoint: opts.entrypoint,
role: await findRoleFromCommand({ role }),
entrypoint,
targetDir: targetPaths.dir,
configPaths: opts.config as string[],
checksEnabled: Boolean(opts.check),
linkedWorkspace: await resolveLinkedWorkspace(opts.link),
inspectEnabled: opts.inspect,
inspectBrkEnabled: opts.inspectBrk,
require: opts.require,
configPaths: config,
checksEnabled: Boolean(check),
linkedWorkspace: await resolveLinkedWorkspace(link),
inspectEnabled: inspect,
inspectBrkEnabled: inspectBrk,
require: requirePaths?.[0],
});
}
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { command } from './command';
export { default } from './command';
@@ -15,7 +15,7 @@
*/
import chalk from 'chalk';
import { Command, OptionValues } from 'commander';
import { cli } from 'cleye';
import { relative as relativePath } from 'node:path';
import { buildPackages, getOutputsForRole } from '../../lib/builder';
import { targetPaths } from '@backstage/cli-common';
@@ -29,18 +29,46 @@ import {
import { buildFrontend } from '../../lib/buildFrontend';
import { buildBackend } from '../../lib/buildBackend';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { all, since, minify },
} = cli(
{
help: info,
flags: {
all: {
type: Boolean,
description:
'Build all packages, including bundled app and backend packages.',
},
since: {
type: String,
description:
'Only build packages and their dev dependents that changed since the specified ref',
},
minify: {
type: Boolean,
description:
'Minify the generated code. Does not apply to app package (app is minified by default).',
},
},
},
undefined,
args,
);
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
if (opts.since) {
if (since) {
const graph = PackageGraph.fromPackages(packages);
const changedPackages = await graph.listChangedPackages({
ref: opts.since,
ref: since,
analyzeLockfile: true,
});
const withDevDependents = graph.collectPackageNames(
@@ -53,7 +81,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const apps = new Array<BackstagePackage>();
const backends = new Array<BackstagePackage>();
const parseBuildScript = createScriptOptionsParser(cmd, ['package', 'build']);
const parseBuildScript = createScriptOptionsParser(['package', 'build'], {
role: { type: 'string' },
minify: { type: 'boolean' },
'skip-build-dependencies': { type: 'boolean' },
stats: { type: 'boolean' },
config: { type: 'string', multiple: true },
'module-federation': { type: 'boolean' },
});
const options = packages.flatMap(pkg => {
const role =
@@ -92,14 +127,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
outputs,
logPrefix: `${chalk.cyan(relativePath(targetPaths.rootDir, pkg.dir))}: `,
workspacePackages: packages,
minify: opts.minify ?? buildOptions.minify,
minify: minify ?? Boolean(buildOptions.minify),
};
});
console.log('Building packages');
await buildPackages(options);
if (opts.all) {
if (all) {
console.log('Building apps');
await runConcurrentTasks({
items: apps,
@@ -112,9 +147,12 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
);
return;
}
const configPaths = buildOptions.config;
await buildFrontend({
targetDir: pkg.dir,
configPaths: (buildOptions.config as string[]) ?? [],
configPaths: Array.isArray(configPaths)
? (configPaths as string[])
: [],
writeStats: Boolean(buildOptions.stats),
webpack,
});
@@ -136,9 +174,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
await buildBackend({
targetDir: pkg.dir,
skipBuildDependencies: true,
minify: opts.minify ?? buildOptions.minify,
minify: minify ?? Boolean(buildOptions.minify),
});
},
});
}
}
};
@@ -21,10 +21,12 @@ import {
} from '@backstage/cli-node';
import { relative as relativePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { cli } from 'cleye';
import { resolveLinkedWorkspace } from '../package/start/resolveLinkedWorkspace';
import { startPackage } from '../package/start/startPackage';
import { parseArgs } from 'node:util';
import type { CommandContext } from '../../../../wiring/types';
const ACCEPTED_PACKAGE_ROLES: Array<PackageRole | undefined> = [
'frontend',
@@ -33,19 +35,60 @@ const ACCEPTED_PACKAGE_ROLES: Array<PackageRole | undefined> = [
'backend-plugin',
];
type CommandOptions = {
plugin: string[];
config: string[];
inspect?: boolean | string;
inspectBrk?: boolean | string;
require?: string;
link?: string;
};
export default async ({ args, info }: CommandContext) => {
const {
flags: { plugin, config, inspect, inspectBrk, require: requirePath, link },
_: namesOrPaths,
} = cli(
{
help: info,
flags: {
plugin: {
type: [String],
description:
'Start the dev entry-point for any matching plugin package in the repo',
default: [],
},
config: {
type: [String],
description: 'Config files to load instead of app-config.yaml',
default: [],
},
inspect: {
type: String,
description:
'Enable debugger in Node.js environments. Applies to backend package only',
},
inspectBrk: {
type: String,
description:
'Enable debugger in Node.js environments, breaking before code starts. Applies to backend package only',
},
require: {
type: String,
description:
'Add a --require argument to the node process. Applies to backend package only',
},
link: {
type: String,
description: 'Link an external workspace for module resolution',
},
},
},
undefined,
args,
);
export async function command(namesOrPaths: string[], options: CommandOptions) {
const targetPackages = await findTargetPackages(namesOrPaths, options.plugin);
const targetPackages = await findTargetPackages(namesOrPaths, plugin);
const packageOptions = await resolvePackageOptions(targetPackages, options);
const packageOptions = await resolvePackageOptions(targetPackages, {
plugin,
config,
inspect,
inspectBrk,
require: requirePath,
link,
});
if (packageOptions.length === 0) {
console.log('No packages found to start');
@@ -60,7 +103,7 @@ export async function command(namesOrPaths: string[], options: CommandOptions) {
// Each of these block until interrupted by user
await Promise.all(packageOptions.map(entry => startPackage(entry.options)));
}
};
export async function findTargetPackages(
namesOrPaths: string[],
@@ -165,6 +208,15 @@ export async function findTargetPackages(
);
}
type CommandOptions = {
plugin: string[];
config: string[];
inspect?: string;
inspectBrk?: string;
require?: string;
link?: string;
};
async function resolvePackageOptions(
targetPackages: BackstagePackage[],
options: CommandOptions,
+5 -182
View File
@@ -14,46 +14,7 @@
* limitations under the License.
*/
import { Command, Option } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
const configOption = [
'--config <path>',
'Config files to load instead of app-config.yaml',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
] as const;
export function registerPackageCommands(command: Command) {
command
.command('build')
.description('Build a package for production deployment or publishing')
.option('--role <name>', 'Run the command with an explicit package role')
.option(
'--minify',
'Minify the generated code. Does not apply to app package (app is minified by default).',
)
.option(
'--skip-build-dependencies',
'Skip the automatic building of local dependencies. Applies to backend packages only.',
)
.option(
'--stats',
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
)
.option(
'--config <path>',
'Config files to load instead of app-config.yaml. Applies to app packages only.',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
)
.option(
'--module-federation',
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
)
.action(lazy(() => import('./commands/package/build'), 'command'));
}
export const buildPlugin = createCliPlugin({
pluginId: 'build',
@@ -61,146 +22,26 @@ export const buildPlugin = createCliPlugin({
reg.addCommand({
path: ['package', 'build'],
description: 'Build a package for production deployment or publishing',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--role <name>',
'Run the command with an explicit package role',
)
.option(
'--minify',
'Minify the generated code. Does not apply to app package (app is minified by default).',
)
.option(
'--skip-build-dependencies',
'Skip the automatic building of local dependencies. Applies to backend packages only.',
)
.option(
'--stats',
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
)
.option(
'--config <path>',
'Config files to load instead of app-config.yaml. Applies to app packages only.',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
)
.option(
'--module-federation',
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
)
.action(lazy(() => import('./commands/package/build'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/build') },
});
reg.addCommand({
path: ['repo', 'build'],
description:
'Build packages in the project, excluding bundled app and backend packages.',
execute: async ({ args }) => {
const command = new Command();
// This command expect `package build` to be registered, as its used to parse
// individual plugins' package build scripts.
registerPackageCommands(command.command('package'));
const defaultCommand = command
.option(
'--all',
'Build all packages, including bundled app and backend packages.',
)
.option(
'--since <ref>',
'Only build packages and their dev dependents that changed since the specified ref',
)
.option(
'--minify',
'Minify the generated code. Does not apply to app package (app is minified by default).',
)
.action(lazy(() => import('./commands/repo/build'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/build') },
});
reg.addCommand({
path: ['package', 'start'],
description: 'Start a package for local development',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(...configOption)
.option(
'--role <name>',
'Run the command with an explicit package role',
)
.option('--check', 'Enable type checking and linting if available')
.option('--inspect [host]', 'Enable debugger in Node.js environments')
.option(
'--inspect-brk [host]',
'Enable debugger in Node.js environments, breaking before code starts',
)
.option(
'--require <path...>',
'Add a --require argument to the node process',
)
.option(
'--link <path>',
'Link an external workspace for module resolution',
)
.option(
'--entrypoint <path>',
'The entrypoint to start from, relative to the package root. Can point to either a file (without extension) or a directory (in which case the index file in that directory is used). Defaults to "dev"',
)
.action(lazy(() => import('./commands/package/start'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/start') },
});
reg.addCommand({
path: ['repo', 'start'],
description: 'Starts packages in the repo for local development',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.argument(
'[packageNameOrPath...]',
'Run the specified package instead of the defaults.',
)
.option(
'--plugin <pluginId>',
'Start the dev entry-point for any matching plugin package in the repo',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
)
.option(...configOption)
.option(
'--inspect [host]',
'Enable debugger in Node.js environments. Applies to backend package only',
)
.option(
'--inspect-brk [host]',
'Enable debugger in Node.js environments, breaking before code starts. Applies to backend package only',
)
.option(
'--require <path...>',
'Add a --require argument to the node process. Applies to backend package only',
)
.option(
'--link <path>',
'Link an external workspace for module resolution',
)
.action(
lazy(() => import('../build/commands/repo/start'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/start') },
});
reg.addCommand({
@@ -239,25 +80,7 @@ export const buildPlugin = createCliPlugin({
path: ['build-workspace'],
description:
'Builds a temporary dist workspace from the provided packages',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.arguments('<workspace-dir> [packages...]')
.addOption(
new Option(
'--alwaysYarnPack',
'Alias for --alwaysPack for backwards compatibility.',
)
.implies({ alwaysPack: true })
.hideHelp(true),
)
.option(
'--alwaysPack',
'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)',
)
.action(lazy(() => import('./commands/buildWorkspace'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/buildWorkspace') },
});
},
});
@@ -13,34 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { parseArgs, type ParseArgsConfig } from 'node:util';
export function createScriptOptionsParser(
anyCmd: Command,
commandPath: string[],
options: ParseArgsConfig['options'],
) {
// Regardless of what command instance is passed in we want to find
// the root command and resolve the path from there
let rootCmd = anyCmd;
while (rootCmd.parent) {
rootCmd = rootCmd.parent;
}
// Now find the command that was requested
let targetCmd = rootCmd as Command | undefined;
for (const name of commandPath) {
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
| Command
| undefined;
}
if (!targetCmd) {
throw new Error(
`Could not find package command '${commandPath.join(' ')}'`,
);
}
const cmd = targetCmd;
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
@@ -49,22 +27,9 @@ export function createScriptOptionsParser(
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
const args = argsStr ? argsStr.split(/\s+/) : [];
// Can't clone or copy or even use commands as prototype, so we mutate
// the necessary members instead, and then reset them once we're done
const currentOpts = (cmd as any)._optionValues;
const currentStore = (cmd as any)._storeOptionsAsProperties;
const result: Record<string, any> = {};
(cmd as any)._storeOptionsAsProperties = false;
(cmd as any)._optionValues = result;
// Triggers the writing of options to the result object
cmd.parseOptions(argsStr.split(' '));
(cmd as any)._optionValues = currentOpts;
(cmd as any)._storeOptionsAsProperties = currentStore;
return result;
const { values } = parseArgs({ args, strict: false, options });
return values;
};
}
@@ -16,20 +16,12 @@
import { createMockDirectory } from '@backstage/backend-test-utils';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import { Command } from 'commander';
import { findRoleFromCommand } from './role';
const mockDir = createMockDirectory();
overrideTargetPaths(mockDir.path);
describe('findRoleFromCommand', () => {
function mkCommand(args?: string) {
const parsed = new Command()
.option('--role <role>', 'test role')
.parse(args?.split(' ') ?? [], { from: 'user' });
return parsed.opts();
}
beforeEach(() => {
mockDir.setContent({
'package.json': JSON.stringify({
@@ -42,16 +34,14 @@ describe('findRoleFromCommand', () => {
});
it('provides role info by role', async () => {
await expect(findRoleFromCommand(mkCommand())).resolves.toEqual(
'web-library',
);
await expect(findRoleFromCommand({})).resolves.toEqual('web-library');
await expect(
findRoleFromCommand(mkCommand('--role node-library')),
findRoleFromCommand({ role: 'node-library' }),
).resolves.toEqual('node-library');
await expect(
findRoleFromCommand(mkCommand('--role invalid')),
).rejects.toThrow(`Unknown package role 'invalid'`);
await expect(findRoleFromCommand({ role: 'invalid' })).rejects.toThrow(
`Unknown package role 'invalid'`,
);
});
});
+3 -4
View File
@@ -15,14 +15,13 @@
*/
import fs from 'fs-extra';
import { OptionValues } from 'commander';
import { targetPaths } from '@backstage/cli-common';
import { PackageRoles, PackageRole } from '@backstage/cli-node';
export async function findRoleFromCommand(
opts: OptionValues,
): Promise<PackageRole> {
export async function findRoleFromCommand(opts: {
role?: string;
}): Promise<PackageRole> {
if (opts.role) {
return PackageRoles.getRoleInfo(opts.role)?.role;
}
@@ -19,14 +19,27 @@ import chalk from 'chalk';
import { stringify as stringifyYaml } from 'yaml';
import inquirer, { Question, Answers } from 'inquirer';
import { targetPaths } from '@backstage/cli-common';
import { cli } from 'cleye';
import { GithubCreateAppServer } from './GithubCreateAppServer';
import openBrowser from 'react-dev-utils/openBrowser';
import type { CommandContext } from '../../../../wiring/types';
// This is an experimental command that at this point does not support GitHub Enterprise
// due to lacking support for creating apps from manifests.
// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest
export default async (org: string) => {
export default async ({ args, info }: CommandContext) => {
const { _: positionals } = cli(
{
help: info,
parameters: ['<github-org>'],
},
undefined,
args,
);
const org = positionals[0];
const answers: Answers = await inquirer.prompt({
name: 'appType',
type: 'checkbox',
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'new',
@@ -23,16 +21,7 @@ export default createCliPlugin({
reg.addCommand({
path: ['create-github-app'],
description: 'Create new GitHub App in your organization.',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.argument('<github-org>')
.action(
lazy(() => import('./commands/create-github-app'), 'default'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/create-github-app') },
});
},
});
@@ -15,15 +15,46 @@
*/
import fs from 'fs-extra';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import { ESLint } from 'eslint';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { fix, format, outputFile, maxWarnings },
_: directories,
} = cli(
{
help: info,
flags: {
fix: {
type: Boolean,
description: 'Attempt to automatically fix violations',
},
format: {
type: String,
description: 'Lint report output format',
default: 'eslint-formatter-friendly',
},
outputFile: {
type: String,
description: 'Write the lint report to a file instead of stdout',
},
maxWarnings: {
type: String,
description:
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
},
},
},
undefined,
args,
);
export default async (directories: string[], opts: OptionValues) => {
const eslint = new ESLint({
cwd: targetPaths.dir,
fix: opts.fix,
fix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
@@ -31,31 +62,31 @@ export default async (directories: string[], opts: OptionValues) => {
directories.length ? directories : ['.'],
);
const maxWarnings = opts.maxWarnings ?? -1;
const ignoreWarnings = +maxWarnings === -1;
const maxWarningsNum = maxWarnings ? +maxWarnings : -1;
const ignoreWarnings = maxWarningsNum === -1;
const failed =
results.some(r => r.errorCount > 0) ||
(!ignoreWarnings &&
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarnings);
maxWarningsNum);
if (opts.fix) {
if (fix) {
await ESLint.outputFixes(results);
}
const formatter = await eslint.loadFormatter(opts.format);
const formatter = await eslint.loadFormatter(format);
// This formatter uses the cwd to format file paths, so let's have that happen from the root instead
if (opts.format === 'eslint-formatter-friendly') {
if (format === 'eslint-formatter-friendly') {
process.chdir(targetPaths.rootDir);
}
const resultText = await formatter.format(results);
if (resultText) {
if (opts.outputFile) {
await fs.writeFile(targetPaths.resolve(opts.outputFile), resultText);
if (outputFile) {
await fs.writeFile(targetPaths.resolve(outputFile), resultText);
} else {
console.log(resultText);
}
@@ -16,7 +16,7 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import { Command, OptionValues } from 'commander';
import { cli } from 'cleye';
import { createHash } from 'node:crypto';
import { relative as relativePath } from 'node:path';
import {
@@ -29,6 +29,7 @@ import {
import { targetPaths } from '@backstage/cli-common';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import type { CommandContext } from '../../../../wiring/types';
function depCount(pkg: BackstagePackageJson) {
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
@@ -38,24 +39,77 @@ function depCount(pkg: BackstagePackageJson) {
return deps + devDeps;
}
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
export default async ({ args, info }: CommandContext) => {
const {
flags: {
fix,
format,
outputFile,
successCache: useSuccessCache,
successCacheDir,
since,
maxWarnings,
},
} = cli(
{
help: info,
flags: {
fix: {
type: Boolean,
description: 'Attempt to automatically fix violations',
},
format: {
type: String,
description: 'Lint report output format',
default: 'eslint-formatter-friendly',
},
outputFile: {
type: String,
description: 'Write the lint report to a file instead of stdout',
},
successCache: {
type: Boolean,
description:
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
},
successCacheDir: {
type: String,
description:
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
},
since: {
type: String,
description:
'Only lint packages that changed since the specified ref',
},
maxWarnings: {
type: String,
description:
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
},
},
},
undefined,
args,
);
let packages = await PackageGraph.listTargetPackages();
const cache = SuccessCache.create({
name: 'lint',
basePath: opts.successCacheDir,
basePath: successCacheDir,
});
const cacheContext = opts.successCache
const cacheContext = useSuccessCache
? {
entries: await cache.read(),
lockfile: await Lockfile.load(targetPaths.resolveRoot('yarn.lock')),
}
: undefined;
if (opts.since) {
if (since) {
const graph = PackageGraph.fromPackages(packages);
packages = await graph.listChangedPackages({
ref: opts.since,
ref: since,
analyzeLockfile: true,
});
}
@@ -65,7 +119,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
packages.sort((a, b) => depCount(b.packageJson) - depCount(a.packageJson));
// This formatter uses the cwd to format file paths, so let's have that happen from the root instead
if (opts.format === 'eslint-formatter-friendly') {
if (format === 'eslint-formatter-friendly') {
process.chdir(targetPaths.rootDir);
}
@@ -74,7 +128,12 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
process.env.FORCE_COLOR = '1';
}
const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']);
const parseLintScript = createScriptOptionsParser(['package', 'lint'], {
fix: { type: 'boolean' },
format: { type: 'string' },
'output-file': { type: 'string' },
'max-warnings': { type: 'string' },
});
const items = await Promise.all(
packages.map(async pkg => {
@@ -112,20 +171,20 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const { results: resultsList } = await runWorkerQueueThreads({
items: items.filter(item => item.lintOptions), // Filter out packages without lint script
context: {
fix: Boolean(opts.fix),
format: opts.format as string | undefined,
fix: Boolean(fix),
format: format as string | undefined,
shouldCache: Boolean(cacheContext),
maxWarnings: opts.maxWarnings ?? -1,
maxWarnings: maxWarnings ?? '-1',
successCache: cacheContext?.entries,
rootDir: targetPaths.rootDir,
},
workerFactory: async ({
fix,
format,
fix: workerFix,
format: workerFormat,
shouldCache,
successCache,
rootDir,
maxWarnings,
maxWarnings: workerMaxWarnings,
}) => {
const { ESLint } = require('eslint') as typeof import('eslint');
const crypto = require('node:crypto') as typeof import('crypto');
@@ -151,7 +210,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const start = Date.now();
const eslint = new ESLint({
cwd: fullDir,
fix,
fix: workerFix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
@@ -192,7 +251,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
}
const formatter = await eslint.loadFormatter(format);
const formatter = await eslint.loadFormatter(workerFormat);
const results = await eslint.lintFiles(['.']);
@@ -200,18 +259,18 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const time = ((Date.now() - start) / 1000).toFixed(2);
console.log(`Checked ${count} files in ${relativeDir} ${time}s`);
if (fix) {
if (workerFix) {
await ESLint.outputFixes(results);
}
const ignoreWarnings = +maxWarnings === -1;
const ignoreWarnings = +workerMaxWarnings === -1;
const resultText = formatter.format(results) as string;
const failed =
results.some(r => r.errorCount > 0) ||
(!ignoreWarnings &&
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarnings);
+workerMaxWarnings);
return {
relativeDir,
@@ -242,8 +301,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
// When doing repo lint, only list the results if the lint failed to avoid a log
// dump of all warnings that might be irrelevant
if (resultText) {
if (opts.outputFile) {
if (opts.format === 'json') {
if (outputFile) {
if (format === 'json') {
jsonResults.push(resultText);
} else {
errorOutput += `${resultText}\n`;
@@ -258,7 +317,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
}
if (opts.format === 'json') {
if (format === 'json') {
let mergedJsonResults: any[] = [];
for (const jsonResult of jsonResults) {
mergedJsonResults = mergedJsonResults.concat(JSON.parse(jsonResult));
@@ -266,8 +325,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
errorOutput = JSON.stringify(mergedJsonResults, null, 2);
}
if (opts.outputFile && errorOutput) {
await fs.writeFile(targetPaths.resolveRoot(opts.outputFile), errorOutput);
if (outputFile && errorOutput) {
await fs.writeFile(targetPaths.resolveRoot(outputFile), errorOutput);
}
if (cacheContext) {
@@ -277,4 +336,4 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
if (failed) {
process.exit(1);
}
}
};
+2 -64
View File
@@ -14,28 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export function registerPackageLintCommand(command: Command) {
command.arguments('[directories...]');
command.option('--fix', 'Attempt to automatically fix violations');
command.option(
'--format <format>',
'Lint report output format',
'eslint-formatter-friendly',
);
command.option(
'--output-file <path>',
'Write the lint report to a file instead of stdout',
);
command.option(
'--max-warnings <number>',
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
);
command.description('Lint a package');
command.action(lazy(() => import('./commands/package/lint'), 'default'));
}
export default createCliPlugin({
pluginId: 'lint',
@@ -43,53 +21,13 @@ export default createCliPlugin({
reg.addCommand({
path: ['package', 'lint'],
description: 'Lint a package',
execute: async ({ args }) => {
const command = new Command();
registerPackageLintCommand(command);
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/lint') },
});
reg.addCommand({
path: ['repo', 'lint'],
description: 'Lint a repository',
execute: async ({ args }) => {
const command = new Command();
registerPackageLintCommand(command.command('package').command('lint'));
command.option('--fix', 'Attempt to automatically fix violations');
command.option(
'--format <format>',
'Lint report output format',
'eslint-formatter-friendly',
);
command.option(
'--output-file <path>',
'Write the lint report to a file instead of stdout',
);
command.option(
'--successCache',
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
);
command.option(
'--successCacheDir <path>',
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
);
command.option(
'--since <ref>',
'Only lint packages that changed since the specified ref',
);
command.option(
'--max-warnings <number>',
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
);
command.description('Lint a repository');
command.action(lazy(() => import('./commands/repo/lint'), 'command'));
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/lint') },
});
},
});
@@ -13,34 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { parseArgs, type ParseArgsConfig } from 'node:util';
export function createScriptOptionsParser(
anyCmd: Command,
commandPath: string[],
options: ParseArgsConfig['options'],
) {
// Regardless of what command instance is passed in we want to find
// the root command and resolve the path from there
let rootCmd = anyCmd;
while (rootCmd.parent) {
rootCmd = rootCmd.parent;
}
// Now find the command that was requested
let targetCmd = rootCmd as Command | undefined;
for (const name of commandPath) {
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
| Command
| undefined;
}
if (!targetCmd) {
throw new Error(
`Could not find package command '${commandPath.join(' ')}'`,
);
}
const cmd = targetCmd;
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
@@ -49,22 +27,9 @@ export function createScriptOptionsParser(
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
const args = argsStr ? argsStr.split(/\s+/) : [];
// Can't clone or copy or even use commands as prototype, so we mutate
// the necessary members instead, and then reset them once we're done
const currentOpts = (cmd as any)._optionValues;
const currentStore = (cmd as any)._storeOptionsAsProperties;
const result: Record<string, any> = {};
(cmd as any)._storeOptionsAsProperties = false;
(cmd as any)._optionValues = result;
// Triggers the writing of options to the result object
cmd.parseOptions(argsStr.split(' '));
(cmd as any)._optionValues = currentOpts;
(cmd as any)._storeOptionsAsProperties = currentStore;
return result;
const { values } = parseArgs({ args, strict: false, options });
return values;
};
}
@@ -21,7 +21,7 @@ import {
PackageRole,
PackageRoles,
} from '@backstage/cli-node';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import fs from 'fs-extra';
import {
resolve as resolvePath,
@@ -492,14 +492,38 @@ export function fixPeerModules(pkg: FixablePackage) {
type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void;
export async function command(opts: OptionValues): Promise<void> {
export default async ({
args,
info,
}: import('../../../../wiring/types').CommandContext) => {
const {
flags: { publish, check },
} = cli(
{
help: info,
flags: {
publish: {
type: Boolean,
description:
'Enable additional fixes that only apply when publishing packages',
},
check: {
type: Boolean,
description:
'Fail if any packages would have been changed by the command',
},
},
},
undefined,
args,
);
const packages = await readFixablePackages();
const fixRepositoryField = createRepositoryFieldFixer();
const fixers: PackageFixer[] = [fixPackageExports, fixSideEffects];
// Fixers that only apply to repos that publish packages
if (opts.publish) {
if (publish) {
fixers.push(
fixRepositoryField,
fixPluginId,
@@ -514,11 +538,11 @@ export async function command(opts: OptionValues): Promise<void> {
}
}
if (opts.check) {
if (check) {
if (printPackageFixHint(packages)) {
process.exit(1);
}
} else {
await writeFixedPackages(packages);
}
}
};
@@ -16,12 +16,26 @@
import chalk from 'chalk';
import { ESLint } from 'eslint';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { relative as relativePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { json },
} = cli(
{
help: info,
flags: {
json: { type: Boolean, description: 'Output as JSON' },
},
},
undefined,
args,
);
export async function command(opts: OptionValues) {
const packages = await PackageGraph.listTargetPackages();
const eslint = new ESLint({
@@ -74,7 +88,7 @@ export async function command(opts: OptionValues) {
stderr.cursorTo(0);
}
if (opts.json) {
if (json) {
console.log(JSON.stringify(deprecations, null, 2));
} else {
for (const d of deprecations) {
@@ -87,4 +101,4 @@ export async function command(opts: OptionValues) {
if (deprecations.length > 0) {
process.exit(1);
}
}
};
+2 -27
View File
@@ -13,9 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'maintenance',
@@ -23,36 +21,13 @@ export default createCliPlugin({
reg.addCommand({
path: ['repo', 'fix'],
description: 'Automatically fix packages in the project',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--publish',
'Enable additional fixes that only apply when publishing packages',
)
.option(
'--check',
'Fail if any packages would have been changed by the command',
)
.action(lazy(() => import('./commands/repo/fix'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/fix') },
});
reg.addCommand({
path: ['repo', 'list-deprecations'],
description: 'List deprecations',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option('--json', 'Output as JSON')
.action(
lazy(() => import('./commands/repo/list-deprecations'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/list-deprecations') },
});
},
});
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import fs from 'fs-extra';
import { Command } from 'commander';
import * as runObj from '@backstage/cli-common';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump';
import type { CommandContext } from '../../../../wiring/types';
import { registerMswTestHooks, withLogCollector } from '@backstage/test-utils';
import { YarnInfoInspectData } from '../../lib/versioning/packages';
import { setupServer } from 'msw/node';
@@ -126,6 +126,28 @@ const expectLogsToMatch = (
expect(receivedLogs.filter(Boolean).sort()).toEqual(expected.sort());
};
function callBump(flags: Record<string, string | boolean | null>) {
const args: string[] = [];
for (const [key, value] of Object.entries(flags)) {
if (value === null || value === undefined) {
continue;
}
const flag = `--${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}`;
if (typeof value === 'boolean') {
if (value) {
args.push(flag);
}
} else {
args.push(flag, value);
}
}
const context: CommandContext = {
args,
info: { usage: 'backstage-cli versions:bump', description: 'test' },
};
return bump(context);
}
describe('bump', () => {
const mockDir = createMockDirectory();
@@ -190,7 +212,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -283,11 +305,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({
pattern: null,
release: 'main',
skipInstall: true,
} as unknown as Command);
await callBump({ release: 'main', skipInstall: true });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -389,7 +407,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -493,7 +511,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -588,9 +606,9 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await expect(
bump({ pattern: null, release: '999.0.1' } as unknown as Command),
).rejects.toThrow('No release found for 999.0.1 version');
await expect(callBump({ release: '999.0.1' })).rejects.toThrow(
'No release found for 999.0.1 version',
);
});
expect(logs.filter(Boolean)).toEqual([
'Using default pattern glob @backstage/*',
@@ -694,7 +712,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'next' } as unknown as Command);
await callBump({ release: 'next' });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -773,10 +791,10 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({
await callBump({
pattern: '@{backstage,backstage-extra}/*',
release: 'main',
} as any);
});
});
expectLogsToMatch(logs, [
'Using custom pattern glob @{backstage,backstage-extra}/*',
@@ -882,7 +900,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -1121,7 +1139,7 @@ describe('environment variables', () => {
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
@@ -1195,7 +1213,7 @@ describe('environment variables', () => {
} as any);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
@@ -1280,7 +1298,7 @@ describe('environment variables', () => {
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await callBump({ release: 'main' });
});
expectLogsToMatch(logs, [
@@ -1329,9 +1347,7 @@ describe('environment variables', () => {
},
});
await expect(
bump({ pattern: null, release: 'main' } as unknown as Command),
).rejects.toThrow();
await expect(callBump({ release: 'main' })).rejects.toThrow();
});
it('should handle network errors when using custom base URL', async () => {
@@ -1359,8 +1375,6 @@ describe('environment variables', () => {
),
);
await expect(
bump({ pattern: null, release: 'main' } as unknown as Command),
).rejects.toThrow();
await expect(callBump({ release: 'main' })).rejects.toThrow();
});
});
@@ -26,7 +26,7 @@ import fs from 'fs-extra';
import chalk from 'chalk';
import { minimatch } from 'minimatch';
import semver from 'semver';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { isError, NotFoundError } from '@backstage/errors';
import { resolve as resolvePath } from 'node:path';
@@ -48,6 +48,7 @@ import {
import { migrateMovedPackages } from './migrate';
import { runYarnInstall } from '../../lib/utils';
import { run } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
const DEP_TYPES = [
'dependencies',
@@ -73,12 +74,41 @@ function extendsDefaultPattern(pattern: string): boolean {
return minimatch('@backstage/', pattern.slice(0, -1));
}
export default async (opts: OptionValues) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { pattern: patternFlag, release, skipInstall, skipMigrate },
} = cli(
{
help: info,
flags: {
pattern: {
type: String,
description: 'Override glob for matching packages to upgrade',
},
release: {
type: String,
description: 'Bump to a specific Backstage release line or version',
default: 'main',
},
skipInstall: {
type: Boolean,
description: 'Skips yarn install step',
},
skipMigrate: {
type: Boolean,
description: 'Skips migration of any moved packages',
},
},
},
undefined,
args,
);
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const yarnPluginEnabled = await hasBackstageYarnPlugin();
let pattern = opts.pattern;
let pattern = patternFlag;
if (!pattern) {
console.log(`Using default pattern glob ${DEFAULT_PATTERN_GLOB}`);
@@ -97,15 +127,15 @@ export default async (opts: OptionValues) => {
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else if (semver.valid(opts.release)) {
} else if (semver.valid(release)) {
// Specific release specified. Be strict when resolving versions
releaseManifest = await getManifestByVersion({ version: opts.release });
releaseManifest = await getManifestByVersion({ version: release! });
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else {
// Release line specified. Be lenient when resolving versions.
if (opts.release === 'next') {
if (release === 'next') {
const next = await getManifestByReleaseLine({
releaseLine: 'next',
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
@@ -120,12 +150,12 @@ export default async (opts: OptionValues) => {
: main;
} else {
releaseManifest = await getManifestByReleaseLine({
releaseLine: opts.release,
releaseLine: release!,
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
}
findTargetVersion = createVersionFinder({
releaseLine: opts.releaseLine,
releaseLine: release,
releaseManifest,
});
}
@@ -264,7 +294,7 @@ export default async (opts: OptionValues) => {
);
}
if (!opts.skipInstall) {
if (!skipInstall) {
await runYarnInstall();
} else {
console.log();
@@ -272,14 +302,14 @@ export default async (opts: OptionValues) => {
console.log(chalk.yellow(`Skipping yarn install`));
}
if (!opts.skipMigrate) {
if (!skipMigrate) {
console.log();
const changed = await migrateMovedPackages({
pattern: opts.pattern,
pattern: patternFlag,
});
if (changed && !opts.skipInstall) {
if (changed && !skipInstall) {
await runYarnInstall();
}
}
@@ -123,7 +123,7 @@ describe('versions:migrate', () => {
});
const { warn, log: logs } = await withLogCollector(async () => {
await migrate({});
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
});
expectLogsToMatch(logs, [
@@ -229,7 +229,7 @@ describe('versions:migrate', () => {
});
await withLogCollector(async () => {
await migrate({});
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
@@ -311,7 +311,7 @@ describe('versions:migrate', () => {
});
await withLogCollector(async () => {
await migrate({});
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
@@ -16,11 +16,12 @@
import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node';
import chalk from 'chalk';
import { resolve as resolvePath, join as joinPath } from 'node:path';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { readJson, writeJson } from 'fs-extra';
import { minimatch } from 'minimatch';
import { runYarnInstall } from '../../lib/utils';
import replace from 'replace-in-file';
import type { CommandContext } from '../../../../wiring/types';
declare module 'replace-in-file' {
export default function (config: {
@@ -38,10 +39,30 @@ declare module 'replace-in-file' {
>;
}
export default async (options: OptionValues) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { pattern, skipCodeChanges },
} = cli(
{
help: info,
flags: {
pattern: {
type: String,
description: 'Override glob for matching packages to upgrade',
},
skipCodeChanges: {
type: Boolean,
description: 'Skip code changes and only update package.json files',
},
},
},
undefined,
args,
);
const changed = await migrateMovedPackages({
pattern: options.pattern,
skipCodeChanges: options.skipCodeChanges,
pattern,
skipCodeChanges,
});
if (changed) {
+2 -36
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'migrate',
@@ -24,45 +22,13 @@ export default createCliPlugin({
path: ['versions:migrate'],
description:
'Migrate any plugins that have been moved to the @backstage-community namespace automatically',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--pattern <glob>',
'Override glob for matching packages to upgrade',
)
.option(
'--skip-code-changes',
'Skip code changes and only update package.json files',
)
.action(lazy(() => import('./commands/versions/migrate'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/versions/migrate') },
});
reg.addCommand({
path: ['versions:bump'],
description: 'Bump Backstage packages to the latest versions',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--pattern <glob>',
'Override glob for matching packages to upgrade',
)
.option(
'--release <version|next|main>',
'Bump to a specific Backstage release line or version',
'main',
)
.option('--skip-install', 'Skips yarn install step')
.option('--skip-migrate', 'Skips migration of any moved packages')
.action(lazy(() => import('./commands/versions/bump'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/versions/bump') },
});
reg.addCommand({
@@ -16,6 +16,7 @@
import { createNewPackage } from '../lib/createNewPackage';
import { default as newCommand } from './new';
import type { CommandContext } from '../../../wiring/types';
jest.mock('../lib/createNewPackage');
@@ -34,13 +35,21 @@ describe.each([
});
it(`should generate naming options for --scope=${scope}`, async () => {
await newCommand({ scope, option: [], skipInstall: false });
const args = ['--skip-install'];
if (scope) {
args.push('--scope', scope);
}
const context: CommandContext = {
args,
info: { usage: 'backstage-cli new', description: 'test' },
};
await newCommand(context);
expect(createNewPackage).toHaveBeenCalledWith(
expect.objectContaining({
configOverrides: {
configOverrides: expect.objectContaining({
packageNamePrefix: prefix,
packageNamePluginInfix: infix,
},
}),
}),
);
});
+65 -26
View File
@@ -14,28 +14,67 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { createNewPackage } from '../lib/createNewPackage';
import type { CommandContext } from '../../../wiring/types';
type ArgOptions = {
option: string[];
select?: string;
skipInstall: boolean;
private?: boolean;
npmRegistry?: string;
scope?: string;
license?: string;
baseVersion?: string;
};
export default async (opts: ArgOptions) => {
export default async ({ args, info }: CommandContext) => {
const {
option: rawArgOptions,
select: preselectedTemplateId,
skipInstall,
scope,
private: isPrivate,
...otherGlobals
} = opts;
flags: {
select,
option: rawArgOptions,
skipInstall,
scope,
npmRegistry,
baseVersion,
license,
private: isPrivate,
},
} = cli(
{
help: info,
flags: {
select: {
type: String,
description: 'Select the thing you want to be creating upfront',
},
option: {
type: [String],
description: 'Pre-fill options for the creation process',
default: [],
},
skipInstall: {
type: Boolean,
description: `Skips running 'yarn install' and 'yarn lint --fix'`,
},
scope: {
type: String,
description: 'The scope to use for new packages',
},
npmRegistry: {
type: String,
description: 'The package registry to use for new packages',
},
baseVersion: {
type: String,
description:
'The version to use for any new packages (default: 0.1.0)',
},
license: {
type: String,
description:
'The license to use for any new packages (default: Apache-2.0)',
},
private: {
type: Boolean,
description: 'Mark new packages as private',
default: true,
},
},
},
undefined,
args,
);
const prefilledParams = parseParams(rawArgOptions);
@@ -50,8 +89,8 @@ export default async (opts: ArgOptions) => {
}
if (
isPrivate === false || // set to false with --no-private flag
Object.values(otherGlobals).filter(Boolean).length !== 0
isPrivate === false ||
[npmRegistry, baseVersion, license].filter(Boolean).length !== 0
) {
console.warn(
`Global template configuration via CLI flags is deprecated, see https://backstage.io/docs/cli/new for information on how to configure package templating`,
@@ -60,16 +99,16 @@ export default async (opts: ArgOptions) => {
await createNewPackage({
prefilledParams,
preselectedTemplateId,
preselectedTemplateId: select,
configOverrides: {
license: otherGlobals.license,
version: otherGlobals.baseVersion,
license,
version: baseVersion,
private: isPrivate,
publishRegistry: otherGlobals.npmRegistry,
publishRegistry: npmRegistry,
packageNamePrefix: packagePrefix,
packageNamePluginInfix: pluginInfix,
},
skipInstall,
skipInstall: Boolean(skipInstall),
});
};
+1 -41
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
import { NotImplementedError } from '@backstage/errors';
export default createCliPlugin({
@@ -25,45 +23,7 @@ export default createCliPlugin({
path: ['new'],
description:
'Open up an interactive guide to creating new things in your app',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.storeOptionsAsProperties(false)
.description(
'Open up an interactive guide to creating new things in your app',
)
.option(
'--select <name>',
'Select the thing you want to be creating upfront',
)
.option(
'--option <name>=<value>',
'Pre-fill options for the creation process',
(opt, arr: string[]) => [...arr, opt],
[],
)
.option(
'--skip-install',
`Skips running 'yarn install' and 'yarn lint --fix'`,
)
.option('--scope <scope>', 'The scope to use for new packages')
.option(
'--npm-registry <URL>',
'The package registry to use for new packages',
)
.option(
'--baseVersion <version>',
'The version to use for any new packages (default: 0.1.0)',
)
.option(
'--license <license>',
'The license to use for any new packages (default: Apache-2.0)',
)
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./commands/new'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/new') },
});
reg.addCommand({
@@ -14,9 +14,8 @@
* limitations under the License.
*/
import { Command, OptionValues } from 'commander';
import { runCheck, findOwnPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
function includesAnyOf(hayStack: string[], ...needles: string[]) {
for (const needle of needles) {
@@ -27,15 +26,7 @@ function includesAnyOf(hayStack: string[], ...needles: string[]) {
return false;
}
export default async (_opts: OptionValues, cmd: Command) => {
// all args are forwarded to jest
let parent = cmd;
while (parent.parent) {
parent = parent.parent;
}
const allArgs = parent.args as string[];
const args = allArgs.slice(allArgs.indexOf('test') + 1);
export default async ({ args }: CommandContext) => {
// Only include our config if caller isn't passing their own config
if (!includesAnyOf(args, '-c', '--config')) {
/* eslint-disable-next-line no-restricted-syntax */
@@ -16,12 +16,12 @@
import os from 'node:os';
import crypto from 'node:crypto';
import { parseArgs } from 'node:util';
import yargs from 'yargs';
// 'jest-cli' is included with jest and should be kept in sync with the installed jest version
// eslint-disable-next-line @backstage/no-undeclared-imports
import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli';
import { relative as relativePath } from 'node:path';
import { Command, OptionValues } from 'commander';
import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node';
import {
@@ -31,6 +31,7 @@ import {
findOwnPaths,
isChildPath,
} from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
type JestProject = {
displayName: string;
@@ -148,18 +149,23 @@ function removeOptionArg(args: string[], option: string, size: number = 2) {
} while (changed);
}
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
export default async ({ args }: CommandContext) => {
const testGlobal = global as TestGlobal;
// all args are forwarded to jest
let parent = cmd;
while (parent.parent) {
parent = parent.parent;
}
const allArgs = parent.args as string[];
const args = allArgs.slice(allArgs.indexOf('test') + 1);
// Parse our own flags from the raw args using strict: false to allow Jest flags through
const { values: opts } = parseArgs({
args,
strict: false,
options: {
since: { type: 'string' },
successCache: { type: 'boolean' },
successCacheDir: { type: 'string' },
'jest-help': { type: 'boolean' },
},
});
const hasFlags = createFlagFinder(args);
const sinceRef = typeof opts.since === 'string' ? opts.since : undefined;
// Parse the args to ensure that no file filters are provided, in which case we refuse to run
const { _: parsedArgs } = await yargs(args).options(jestYargsOptions).argv;
@@ -177,7 +183,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
// Run in watch mode unless in CI, coverage mode, or running all tests
let isSingleWatchMode = args.includes('--watch');
if (
!opts.since &&
!sinceRef &&
!process.env.CI &&
!hasFlags('--coverage', '--watch', '--watchAll')
) {
@@ -261,10 +267,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
let selectedProjects: string[] | undefined = undefined;
if (opts.since && !hasFlags('--selectProjects')) {
if (sinceRef && !hasFlags('--selectProjects')) {
const graph = await getPackageGraph();
const changedPackages = await graph.listChangedPackages({
ref: opts.since,
ref: sinceRef,
analyzeLockfile: true,
});
@@ -304,7 +310,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}--no-node-snapshot`;
}
if (args.includes('--jest-help')) {
if (opts['jest-help']) {
removeOptionArg(args, '--jest-help');
args.push('--help');
}
@@ -332,7 +338,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const cache = SuccessCache.create({
name: 'test',
basePath: opts.successCacheDir,
basePath: opts.successCacheDir as string | undefined,
});
const graph = await getPackageGraph();
@@ -439,4 +445,4 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
await runJest(args);
}
};
+2 -33
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'test',
@@ -24,43 +22,14 @@ export default createCliPlugin({
path: ['repo', 'test'],
description:
'Run tests, forwarding args to Jest, defaulting to watch mode',
execute: async ({ args }) => {
const command = new Command();
command.allowUnknownOption(true);
command.allowExcessArguments(true);
command.option(
'--since <ref>',
'Only test packages that changed since the specified ref',
);
command.option('--successCache', 'Enable success caching');
command.option(
'--successCacheDir <path>',
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
);
command.option(
'--jest-help',
'Show help for Jest CLI options, which are passed through',
);
command.action(lazy(() => import('./commands/repo/test'), 'command'));
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/test') },
});
reg.addCommand({
path: ['package', 'test'],
description:
'Run tests, forwarding args to Jest, defaulting to watch mode',
execute: async ({ args }) => {
const command = new Command();
command.allowUnknownOption(true);
command.allowExcessArguments(true);
command.helpOption('--backstage-cli-help');
command.action(
lazy(() => import('./commands/package/test'), 'default'),
);
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/test') },
});
},
});