cli: split loadCliConfig into separate build and config implementations

Split the shared loadCliConfig function into two separate implementations
to remove the cross-module dependency from build to config.

The build module's version keeps the watch/streaming capability needed by
the dev server, while the config module's version is simplified to
one-shot loading since no config commands need watching.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-25 16:11:57 +01:00
parent c7d2bf99ee
commit c85ac86117
5 changed files with 141 additions and 30 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Internal refactor to split `loadCliConfig` into separate implementations for the build and config CLI modules, removing a cross-module dependency.
@@ -18,7 +18,7 @@ import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { buildBundle, getModuleFederationRemoteOptions } from './bundler';
import { BackstagePackageJson } from '@backstage/cli-node';
import { loadCliConfig } from '../../config/lib/config';
import { loadCliConfig } from './config';
interface BuildAppOptions {
targetDir: string;
@@ -24,7 +24,7 @@ import { RspackDevServer } from '@rspack/dev-server';
import { targetPaths } from '@backstage/cli-common';
import { loadCliConfig } from '../../../config/lib/config';
import { loadCliConfig } from '../config';
import { createConfig, resolveBaseUrl, resolveEndpoint } from './config';
import { createDetectedModulesEntryPoint } from './packageDetection';
import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths';
@@ -0,0 +1,128 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ConfigSources, loadConfigSchema } from '@backstage/config-loader';
import { AppConfig, ConfigReader } from '@backstage/config';
import { targetPaths } from '@backstage/cli-common';
import { getPackages } from '@manypkg/get-packages';
import { PackageGraph } from '@backstage/cli-node';
import { resolve as resolvePath } from 'node:path';
type Options = {
args: string[];
targetDir?: string;
fromPackage?: string;
withFilteredKeys?: boolean;
watch?: (newFrontendAppConfigs: AppConfig[]) => void;
};
export async function loadCliConfig(options: Options) {
const targetDir = options.targetDir ?? targetPaths.dir;
const { packages } = await getPackages(targetDir);
let localPackageNames;
if (options.fromPackage) {
if (packages.length) {
const graph = PackageGraph.fromPackages(packages);
localPackageNames = Array.from(
graph.collectPackageNames([options.fromPackage], node => {
// Workaround for Backstage main repo only, since the CLI has some artificial devDependencies
if (node.name === '@backstage/cli') {
return undefined;
}
return node.localDependencies.keys();
}),
);
} else {
localPackageNames = [options.fromPackage];
}
} else {
localPackageNames = packages.map(p => p.packageJson.name);
}
const schema = await loadConfigSchema({
dependencies: localPackageNames,
packagePaths: [targetPaths.resolveRoot('package.json')],
});
const source = ConfigSources.default({
allowMissingDefaultConfig: true,
watch: Boolean(options.watch),
rootDir: targetPaths.rootDir,
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
});
const appConfigs = await new Promise<AppConfig[]>((resolve, reject) => {
async function loadConfigReaderLoop() {
let loaded = false;
try {
const abortController = new AbortController();
for await (const { configs } of source.readConfigData({
signal: abortController.signal,
})) {
if (loaded) {
const newFrontendAppConfigs = schema.process(configs, {
visibility: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
ignoreSchemaErrors: true,
});
options.watch?.(newFrontendAppConfigs);
} else {
resolve(configs);
loaded = true;
if (!options.watch) {
abortController.abort();
}
}
}
} catch (error) {
if (loaded) {
console.error(`Failed to reload configuration, ${error}`);
} else {
reject(error);
}
}
}
loadConfigReaderLoop();
});
const configurationLoadedMessage = appConfigs.length
? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`
: `No configuration files found, running without config`;
process.stderr.write(`${configurationLoadedMessage}\n`);
const frontendAppConfigs = schema.process(appConfigs, {
visibility: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
ignoreSchemaErrors: true,
});
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
const fullConfig = ConfigReader.fromConfigs(appConfigs);
return {
schema,
appConfigs,
frontendConfig,
frontendAppConfigs,
fullConfig,
};
}
+6 -28
View File
@@ -27,11 +27,9 @@ type Options = {
targetDir?: string;
fromPackage?: string;
mockEnv?: boolean;
withFilteredKeys?: boolean;
withDeprecatedKeys?: boolean;
fullVisibility?: boolean;
strict?: boolean;
watch?: (newFrontendAppConfigs: AppConfig[]) => void;
};
export async function loadCliConfig(options: Options) {
@@ -73,48 +71,29 @@ export async function loadCliConfig(options: Options) {
substitutionFunc: options.mockEnv
? async name => process.env[name] || 'x'
: undefined,
watch: Boolean(options.watch),
rootDir: targetPaths.rootDir,
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
});
const appConfigs = await new Promise<AppConfig[]>((resolve, reject) => {
async function loadConfigReaderLoop() {
async function readConfig() {
let loaded = false;
try {
const abortController = new AbortController();
for await (const { configs } of source.readConfigData({
signal: abortController.signal,
})) {
if (loaded) {
const newFrontendAppConfigs = schema.process(configs, {
visibility: options.fullVisibility
? ['frontend', 'backend', 'secret']
: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
withDeprecatedKeys: options.withDeprecatedKeys,
ignoreSchemaErrors: !options.strict,
});
options.watch?.(newFrontendAppConfigs);
} else {
resolve(configs);
loaded = true;
if (!options.watch) {
abortController.abort();
}
}
resolve(configs);
loaded = true;
abortController.abort();
}
} catch (error) {
if (loaded) {
console.error(`Failed to reload configuration, ${error}`);
} else {
if (!loaded) {
reject(error);
}
}
}
loadConfigReaderLoop();
readConfig();
});
const configurationLoadedMessage = appConfigs.length
@@ -130,7 +109,6 @@ export async function loadCliConfig(options: Options) {
visibility: options.fullVisibility
? ['frontend', 'backend', 'secret']
: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
withDeprecatedKeys: options.withDeprecatedKeys,
ignoreSchemaErrors: !options.strict,
});