fix: Support workspaces in CLIs

Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>
This commit is contained in:
Gabriel Dugny
2026-01-29 10:24:31 +01:00
parent ebd4630702
commit a9d23c4a32
13 changed files with 79 additions and 62 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/cli-common': patch
'@backstage/create-app': patch
'@backstage/cli-node': patch
'@backstage/cli': patch
---
Properly support `package.json` `workspaces` field
+3
View File
@@ -23,6 +23,9 @@ export class ExitCodeError extends CustomErrorBase {
// @public
export function findPaths(searchDir: string): Paths;
// @public
export function getWorkspacesPatterns(pkgJson: any): string[];
// @public
export function isChildPath(base: string, path: string): boolean;
+1 -1
View File
@@ -20,7 +20,7 @@
* @packageDocumentation
*/
export { findPaths, BACKSTAGE_JSON } from './paths';
export { findPaths, getWorkspacesPatterns, BACKSTAGE_JSON } from './paths';
export { isChildPath } from './isChildPath';
export type { Paths, ResolveFunc } from './paths';
export { bootstrapEnvProxyAgents } from './proxyBootstrap';
+20 -1
View File
@@ -107,6 +107,25 @@ export function findOwnRootDir(ownDir: string) {
return resolvePath(ownDir, '../..');
}
/**
* Gets the workspaces pattern from a package.json object.
* @param pkgJson - The package.json object to get the workspaces pattern from.
* @returns The workspaces patterns (glob not resolved).
* @public
*/
export function getWorkspacesPatterns(pkgJson: any): string[] {
if (Array.isArray(pkgJson.workspaces)) {
return pkgJson.workspaces;
} else if (
typeof pkgJson.workspaces === 'object' &&
pkgJson.workspaces !== null &&
Array.isArray(pkgJson.workspaces.packages)
) {
return pkgJson.workspaces.packages;
}
return [];
}
/**
* Find paths related to a package and its execution context.
*
@@ -141,7 +160,7 @@ export function findPaths(searchDir: string): Paths {
try {
const content = fs.readFileSync(path, 'utf8');
const data = JSON.parse(content);
return Boolean(data.workspaces);
return getWorkspacesPatterns(data).length > 0;
} catch (error) {
throw new Error(
`Failed to parse package.json file while searching for root, ${error}`,
+2 -1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { getWorkspacesPatterns } from '@backstage/cli-common';
import { paths } from '../paths';
import fs from 'fs-extra';
@@ -26,7 +27,7 @@ export async function isMonoRepo(): Promise<boolean> {
const rootPackageJsonPath = paths.resolveTargetRoot('package.json');
try {
const pkg = await fs.readJson(rootPackageJsonPath);
return Boolean(pkg?.workspaces?.packages);
return getWorkspacesPatterns(pkg).length > 0;
} catch (error) {
return false;
}
@@ -43,7 +43,7 @@ describe('PackageManager', () => {
expect(mockYarnCreate).toHaveBeenCalled();
});
it('should detect via root package.json workspaces', async () => {
it('should detect via root package.json workspaces with legacy workspaces.packages field', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
name: 'foo',
@@ -55,6 +55,16 @@ describe('PackageManager', () => {
await detectPackageManager();
expect(mockYarnCreate).toHaveBeenCalled();
});
it('should detect via root package.json workspaces', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
name: 'foo',
workspaces: [],
}),
});
await detectPackageManager();
expect(mockYarnCreate).toHaveBeenCalled();
});
it('should detect via root package.json packageManager', async () => {
mockDir.setContent({
@@ -17,7 +17,7 @@
import { Yarn } from './yarn';
import { Lockfile } from './Lockfile';
import { paths } from '../paths';
import { RunOptions } from '@backstage/cli-common';
import { getWorkspacesPatterns, RunOptions } from '@backstage/cli-common';
import fs from 'fs-extra';
/**
@@ -101,7 +101,7 @@ export async function detectPackageManager(): Promise<PackageManager> {
const packageJson = await fs.readJson(
paths.resolveTargetRoot('package.json'),
);
if (packageJson.workspaces) {
if (getWorkspacesPatterns(packageJson).length > 0) {
// technically this could be NPM as well
return await Yarn.create();
}
+10 -1
View File
@@ -30,7 +30,7 @@ const allYarnVersions = [yarnClassic, yarnBerry];
describe('Yarn', () => {
describe.each(allYarnVersions)('%s.getMonorepoPackages', yarn => {
it('should detect a monorepo', async () => {
it('should detect a monorepo with workspaces.packages field', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
name: 'foo',
@@ -41,6 +41,15 @@ describe('Yarn', () => {
});
await expect(yarn.getMonorepoPackages()).resolves.toEqual(['packages/*']);
});
it('should detect a monorepo', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
name: 'foo',
workspaces: ['packages/*'],
}),
});
await expect(yarn.getMonorepoPackages()).resolves.toEqual(['packages/*']);
});
it('should detect a non-monorepo', async () => {
mockDir.setContent({
+2 -1
View File
@@ -25,6 +25,7 @@ import { YarnVersion } from './types';
import fs from 'fs-extra';
import { paths } from '../../paths';
import { run, runOutput, RunOptions } from '@backstage/cli-common';
import { getWorkspacesPatterns } from '@backstage/cli-common';
export class Yarn implements PackageManager {
constructor(private readonly yarnVersion: YarnVersion) {}
@@ -50,7 +51,7 @@ export class Yarn implements PackageManager {
const rootPackageJsonPath = paths.resolveTargetRoot('package.json');
try {
const pkg = await fs.readJson(rootPackageJsonPath);
return pkg?.workspaces?.packages || [];
return getWorkspacesPatterns(pkg);
} catch (error) {
return [];
}
+3 -3
View File
@@ -20,6 +20,7 @@ const crypto = require('node:crypto');
const glob = require('node:util').promisify(require('glob'));
const { version } = require('../package.json');
const paths = require('@backstage/cli-common').findPaths(process.cwd());
const { getWorkspacesPatterns } = require('@backstage/cli-common');
const {
getJestEnvironment,
getJestMajorVersion,
@@ -336,11 +337,10 @@ async function getRootConfig() {
rejectFrontendNetworkRequests,
};
const workspacePatterns =
rootPkgJson.workspaces && rootPkgJson.workspaces.packages;
const workspacePatterns = getWorkspacesPatterns(rootPkgJson);
// Check if we're running within a specific monorepo package. In that case just get the single project config.
if (!workspacePatterns || paths.targetRoot !== paths.targetDir) {
if (workspacePatterns.length === 0 || paths.targetRoot !== paths.targetDir) {
return getProjectConfig(
paths.targetDir,
{
@@ -104,9 +104,7 @@ describe('mapDependencies', () => {
it('should read dependencies', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: {
packages: ['pkgs/*'],
},
workspaces: ['pkgs/*'],
}),
pkgs: {
a: {
@@ -160,9 +160,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -255,9 +253,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -353,9 +349,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -459,9 +453,7 @@ describe('bump', () => {
'.yarnrc.yml': yarnRcMock,
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -572,9 +564,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -644,9 +634,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -750,9 +738,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': customLockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -865,9 +851,7 @@ describe('bump', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -1099,9 +1083,7 @@ describe('environment variables', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -1192,9 +1174,7 @@ describe('environment variables', () => {
'custom-manifest.json': JSON.stringify(customManifest),
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -1262,9 +1242,7 @@ describe('environment variables', () => {
'.yarnrc.yml': yarnRcMock,
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -1337,9 +1315,7 @@ describe('environment variables', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -1362,9 +1338,7 @@ describe('environment variables', () => {
mockDir.setContent({
'yarn.lock': lockfileMock,
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
packages: {
a: {
@@ -73,9 +73,7 @@ describe('versions:migrate', () => {
it('should bump to the moved version when the package is moved', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
node_modules: {
'@backstage': {
@@ -177,9 +175,7 @@ describe('versions:migrate', () => {
it('should replace the occurrences of the moved package in files inside the correct package', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
node_modules: {
'@backstage': {
@@ -264,9 +260,7 @@ describe('versions:migrate', () => {
it('should replace occurrences of changed packages, and is careful', async () => {
mockDir.setContent({
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
workspaces: ['packages/*'],
}),
node_modules: {
'@backstage': {