Merge branch 'master' into package-workspaces
Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com> # Conflicts: # packages/cli-node/src/pacman/yarn/Yarn.test.ts # packages/cli-node/src/pacman/yarn/Yarn.ts
This commit is contained in:
@@ -1,5 +1,34 @@
|
||||
# @backstage/cli
|
||||
|
||||
## 0.35.5-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 246877a: Updated dependency `bfj` to `^9.0.2`.
|
||||
- bba2e49: Internal refactor to use new concurrency utilities from `@backstage/cli-node`.
|
||||
- fd50cb3: Added `translations export` and `translations import` commands for managing translation files.
|
||||
|
||||
The `translations export` command discovers all `TranslationRef` definitions across frontend plugin dependencies and exports their default messages as JSON files. The `translations import` command generates `TranslationResource` wiring code from translated JSON files, ready to be plugged into the app.
|
||||
|
||||
Both commands support a `--pattern` option for controlling the message file layout, for example `--pattern '{lang}/{id}.json'` for language-based directory grouping.
|
||||
|
||||
- 6738cf0: build(deps): bump `minimatch` from 9.0.5 to 10.2.1
|
||||
- 70fc178: Migrated from deprecated `findPaths` to `targetPaths` and `findOwnPaths` from `@backstage/cli-common`.
|
||||
- de62a9d: Upgraded `commander` dependency from `^12.0.0` to `^14.0.3` across all CLI packages.
|
||||
- 092b41f: Updated dependency `webpack` to `~5.105.0`.
|
||||
- Updated dependencies
|
||||
- @backstage/cli-common@0.2.0-next.0
|
||||
- @backstage/cli-node@0.2.19-next.0
|
||||
- @backstage/eslint-plugin@0.2.2-next.0
|
||||
- @backstage/integration@1.21.0-next.0
|
||||
- @backstage/config-loader@1.10.9-next.0
|
||||
- @backstage/catalog-model@1.7.6
|
||||
- @backstage/config@1.3.6
|
||||
- @backstage/errors@1.2.7
|
||||
- @backstage/module-federation-common@0.1.0
|
||||
- @backstage/release-manifests@0.0.13
|
||||
- @backstage/types@1.2.2
|
||||
|
||||
## 0.35.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -25,6 +25,7 @@ Commands:
|
||||
new
|
||||
package [command]
|
||||
repo [command]
|
||||
translations [command]
|
||||
versions:bump
|
||||
versions:migrate
|
||||
```
|
||||
@@ -542,6 +543,44 @@ Options:
|
||||
-h, --help
|
||||
```
|
||||
|
||||
### `backstage-cli translations`
|
||||
|
||||
```
|
||||
Usage: backstage-cli translations [options] [command] [command]
|
||||
|
||||
Options:
|
||||
-h, --help
|
||||
|
||||
Commands:
|
||||
export
|
||||
help [command]
|
||||
import
|
||||
```
|
||||
|
||||
### `backstage-cli translations export`
|
||||
|
||||
```
|
||||
Usage: <none>
|
||||
|
||||
Options:
|
||||
--help
|
||||
--output
|
||||
--pattern
|
||||
--version
|
||||
```
|
||||
|
||||
### `backstage-cli translations import`
|
||||
|
||||
```
|
||||
Usage: <none>
|
||||
|
||||
Options:
|
||||
--help
|
||||
--input
|
||||
--output
|
||||
--version
|
||||
```
|
||||
|
||||
### `backstage-cli versions:bump`
|
||||
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"version": "0.35.4",
|
||||
"version": "0.35.5-next.0",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"backstage": {
|
||||
"role": "cli"
|
||||
|
||||
@@ -27,5 +27,6 @@ import { CliInitializer } from './wiring/CliInitializer';
|
||||
initializer.add(import('./modules/migrate'));
|
||||
initializer.add(import('./modules/new'));
|
||||
initializer.add(import('./modules/test'));
|
||||
initializer.add(import('./modules/translations'));
|
||||
await initializer.run();
|
||||
})();
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { paths } from '../paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const DEFAULT_CACHE_BASE_PATH = 'node_modules/.cache/backstage-cli';
|
||||
|
||||
@@ -31,7 +31,7 @@ export class SuccessCache {
|
||||
* location.
|
||||
*/
|
||||
static trimPaths(input: string) {
|
||||
return input.replaceAll(paths.targetRoot, '');
|
||||
return input.replaceAll(targetPaths.rootDir, '');
|
||||
}
|
||||
|
||||
constructor(name: string, basePath?: string) {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
/*
|
||||
* 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 os from 'node:os';
|
||||
import {
|
||||
parseParallelismOption,
|
||||
getEnvironmentParallelism,
|
||||
runParallelWorkers,
|
||||
runWorkerQueueThreads,
|
||||
runWorkerThreads,
|
||||
} from './parallel';
|
||||
|
||||
const defaultParallelism = Math.ceil(os.cpus().length / 2);
|
||||
|
||||
describe('parseParallelismOption', () => {
|
||||
it('coerces false no parallelism', () => {
|
||||
expect(parseParallelismOption(false)).toBe(1);
|
||||
expect(parseParallelismOption('false')).toBe(1);
|
||||
});
|
||||
|
||||
it('coerces true or undefined to default parallelism', () => {
|
||||
expect(parseParallelismOption(true)).toBe(defaultParallelism);
|
||||
expect(parseParallelismOption('true')).toBe(defaultParallelism);
|
||||
expect(parseParallelismOption(undefined)).toBe(defaultParallelism);
|
||||
expect(parseParallelismOption(null)).toBe(defaultParallelism);
|
||||
});
|
||||
|
||||
it('coerces number string to number', () => {
|
||||
expect(parseParallelismOption('2')).toBe(2);
|
||||
});
|
||||
|
||||
it.each([['on'], [2.5], ['2.5']])('throws error for %p', value => {
|
||||
expect(() => parseParallelismOption(value as any)).toThrow(
|
||||
`Parallel option value '${value}' is not a boolean or integer`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnvironmentParallelism', () => {
|
||||
it('reads the parallelism setting from the environment', () => {
|
||||
process.env.BACKSTAGE_CLI_BUILD_PARALLEL = '2';
|
||||
expect(getEnvironmentParallelism()).toBe(2);
|
||||
|
||||
process.env.BACKSTAGE_CLI_BUILD_PARALLEL = 'true';
|
||||
expect(getEnvironmentParallelism()).toBe(defaultParallelism);
|
||||
|
||||
process.env.BACKSTAGE_CLI_BUILD_PARALLEL = 'false';
|
||||
expect(getEnvironmentParallelism()).toBe(1);
|
||||
|
||||
delete process.env.BACKSTAGE_CLI_BUILD_PARALLEL;
|
||||
expect(getEnvironmentParallelism()).toBe(defaultParallelism);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runParallelWorkers', () => {
|
||||
it('executes work in parallel', async () => {
|
||||
const started = new Array<number>();
|
||||
const done = new Array<number>();
|
||||
const waiting = new Array<() => void>();
|
||||
|
||||
const work = runParallelWorkers({
|
||||
items: [0, 1, 2, 3, 4],
|
||||
parallelismSetting: 4,
|
||||
parallelismFactor: 0.5, // 2 at a time
|
||||
worker: async item => {
|
||||
started.push(item);
|
||||
await new Promise<void>(resolve => {
|
||||
waiting[item] = resolve;
|
||||
});
|
||||
done.push(item);
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1]);
|
||||
expect(done).toEqual([]);
|
||||
waiting[0]();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1, 2]);
|
||||
expect(done).toEqual([0]);
|
||||
waiting[1]();
|
||||
waiting[2]();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1, 2, 3, 4]);
|
||||
expect(done).toEqual([0, 1, 2]);
|
||||
waiting[3]();
|
||||
waiting[4]();
|
||||
|
||||
await work;
|
||||
expect(done).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('executes work sequentially', async () => {
|
||||
const started = new Array<number>();
|
||||
const done = new Array<number>();
|
||||
const waiting = new Array<() => void>();
|
||||
|
||||
const work = runParallelWorkers({
|
||||
items: [0, 1, 2, 3, 4],
|
||||
parallelismFactor: 0, // 1 at a time
|
||||
worker: async item => {
|
||||
started.push(item);
|
||||
await new Promise<void>(resolve => {
|
||||
waiting[item] = resolve;
|
||||
});
|
||||
done.push(item);
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0]);
|
||||
expect(done).toEqual([]);
|
||||
waiting[0]();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1]);
|
||||
expect(done).toEqual([0]);
|
||||
waiting[1]();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1, 2]);
|
||||
waiting[2]();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1, 2, 3]);
|
||||
waiting[3]();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(started).toEqual([0, 1, 2, 3, 4]);
|
||||
waiting[4]();
|
||||
|
||||
await work;
|
||||
expect(done).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWorkerQueueThreads', () => {
|
||||
it('should execute work in parallel', async () => {
|
||||
const sharedData = new SharedArrayBuffer(10);
|
||||
const sharedView = new Uint8Array(sharedData);
|
||||
|
||||
const results = await runWorkerQueueThreads({
|
||||
threadCount: 4,
|
||||
workerData: sharedData,
|
||||
items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
workerFactory: data => {
|
||||
const view = new Uint8Array(data);
|
||||
|
||||
return async (i: number) => {
|
||||
view[i] = 10 + i;
|
||||
return 20 + i;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(Array.from(sharedView)).toEqual([
|
||||
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||
]);
|
||||
expect(results).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWorkerThreads', () => {
|
||||
it('should run a single thread without items', async () => {
|
||||
const [result] = await runWorkerThreads({
|
||||
threadCount: 1,
|
||||
workerData: 'foo',
|
||||
worker: async data => `${data}bar`,
|
||||
});
|
||||
|
||||
expect(result).toBe('foobar');
|
||||
});
|
||||
|
||||
it('should run multiple threads without items', async () => {
|
||||
const results = await runWorkerThreads({
|
||||
threadCount: 4,
|
||||
worker: async () => 'foo',
|
||||
});
|
||||
|
||||
expect(results).toEqual(['foo', 'foo', 'foo', 'foo']);
|
||||
});
|
||||
|
||||
it('should send messages', async () => {
|
||||
const messages = new Array<string>();
|
||||
|
||||
await runWorkerThreads({
|
||||
threadCount: 2,
|
||||
worker: async (_data, sendMessage) => {
|
||||
sendMessage('a');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
sendMessage('b');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
sendMessage('c');
|
||||
},
|
||||
onMessage: (message: string) => messages.push(message),
|
||||
});
|
||||
|
||||
expect(messages.sort()).toEqual(['a', 'a', 'b', 'b', 'c', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -1,342 +0,0 @@
|
||||
/*
|
||||
* 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 os from 'node:os';
|
||||
import { ErrorLike } from '@backstage/errors';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
const defaultParallelism = Math.ceil(os.cpus().length / 2);
|
||||
|
||||
const PARALLEL_ENV_VAR = 'BACKSTAGE_CLI_BUILD_PARALLEL';
|
||||
|
||||
export type ParallelismOption = boolean | string | number | null | undefined;
|
||||
|
||||
export function parseParallelismOption(parallel: ParallelismOption): number {
|
||||
if (parallel === undefined || parallel === null) {
|
||||
return defaultParallelism;
|
||||
} else if (typeof parallel === 'boolean') {
|
||||
return parallel ? defaultParallelism : 1;
|
||||
} else if (typeof parallel === 'number' && Number.isInteger(parallel)) {
|
||||
if (parallel < 1) {
|
||||
return 1;
|
||||
}
|
||||
return parallel;
|
||||
} else if (typeof parallel === 'string') {
|
||||
if (parallel === 'true') {
|
||||
return parseParallelismOption(true);
|
||||
} else if (parallel === 'false') {
|
||||
return parseParallelismOption(false);
|
||||
}
|
||||
const parsed = Number(parallel);
|
||||
if (Number.isInteger(parsed)) {
|
||||
return parseParallelismOption(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
throw Error(
|
||||
`Parallel option value '${parallel}' is not a boolean or integer`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEnvironmentParallelism() {
|
||||
return parseParallelismOption(process.env[PARALLEL_ENV_VAR]);
|
||||
}
|
||||
|
||||
type ParallelWorkerOptions<TItem> = {
|
||||
/**
|
||||
* Decides the number of parallel workers by multiplying
|
||||
* this with the configured parallelism, which defaults to 4.
|
||||
*
|
||||
* Defaults to 1.
|
||||
*/
|
||||
parallelismFactor?: number;
|
||||
parallelismSetting?: ParallelismOption;
|
||||
items: Iterable<TItem>;
|
||||
worker: (item: TItem) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function runParallelWorkers<TItem>(
|
||||
options: ParallelWorkerOptions<TItem>,
|
||||
) {
|
||||
const { parallelismFactor = 1, parallelismSetting, items, worker } = options;
|
||||
const parallelism = parallelismSetting
|
||||
? parseParallelismOption(parallelismSetting)
|
||||
: getEnvironmentParallelism();
|
||||
|
||||
const sharedIterator = items[Symbol.iterator]();
|
||||
const sharedIterable = {
|
||||
[Symbol.iterator]: () => sharedIterator,
|
||||
};
|
||||
|
||||
const workerCount = Math.max(Math.floor(parallelismFactor * parallelism), 1);
|
||||
return Promise.all(
|
||||
Array(workerCount)
|
||||
.fill(0)
|
||||
.map(async () => {
|
||||
for (const value of sharedIterable) {
|
||||
await worker(value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type WorkerThreadMessage =
|
||||
| {
|
||||
type: 'done';
|
||||
}
|
||||
| {
|
||||
type: 'item';
|
||||
index: number;
|
||||
item: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'start';
|
||||
}
|
||||
| {
|
||||
type: 'result';
|
||||
index: number;
|
||||
result: unknown;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
error: ErrorLike;
|
||||
}
|
||||
| {
|
||||
type: 'message';
|
||||
message: unknown;
|
||||
};
|
||||
|
||||
export type WorkerQueueThreadsOptions<TItem, TResult, TData> = {
|
||||
/** The items to process */
|
||||
items: Iterable<TItem>;
|
||||
/**
|
||||
* A function that will be called within each worker thread at startup,
|
||||
* which should return the worker function that will be called for each item.
|
||||
*
|
||||
* This function must be defined as an arrow function or using the
|
||||
* function keyword, and must be entirely self contained, not referencing
|
||||
* any variables outside of its scope. This is because the function source
|
||||
* is stringified and evaluated in the worker thread.
|
||||
*
|
||||
* To pass data to the worker, use the `workerData` option and `items`, but
|
||||
* note that they are both copied by value into the worker thread, except for
|
||||
* types that are explicitly shareable across threads, such as `SharedArrayBuffer`.
|
||||
*/
|
||||
workerFactory: (
|
||||
data: TData,
|
||||
) =>
|
||||
| ((item: TItem) => Promise<TResult>)
|
||||
| Promise<(item: TItem) => Promise<TResult>>;
|
||||
/** Data supplied to each worker factory */
|
||||
workerData?: TData;
|
||||
/** Number of threads, defaults to half of the number of available CPUs */
|
||||
threadCount?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spawns one or more worker threads using the `worker_threads` module.
|
||||
* Each thread processes one item at a time from the provided `options.items`.
|
||||
*/
|
||||
export async function runWorkerQueueThreads<TItem, TResult, TData>(
|
||||
options: WorkerQueueThreadsOptions<TItem, TResult, TData>,
|
||||
): Promise<TResult[]> {
|
||||
const items = Array.from(options.items);
|
||||
const {
|
||||
workerFactory,
|
||||
workerData,
|
||||
threadCount = Math.min(getEnvironmentParallelism(), items.length),
|
||||
} = options;
|
||||
|
||||
const iterator = items[Symbol.iterator]();
|
||||
const results = new Array<TResult>();
|
||||
let itemIndex = 0;
|
||||
|
||||
await Promise.all(
|
||||
Array(threadCount)
|
||||
.fill(0)
|
||||
.map(async () => {
|
||||
const thread = new Worker(`(${workerQueueThread})(${workerFactory})`, {
|
||||
eval: true,
|
||||
workerData,
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
thread.on('message', (message: WorkerThreadMessage) => {
|
||||
if (message.type === 'start' || message.type === 'result') {
|
||||
if (message.type === 'result') {
|
||||
results[message.index] = message.result as TResult;
|
||||
}
|
||||
const { value, done } = iterator.next();
|
||||
if (done) {
|
||||
thread.postMessage({ type: 'done' });
|
||||
} else {
|
||||
thread.postMessage({
|
||||
type: 'item',
|
||||
index: itemIndex,
|
||||
item: value,
|
||||
});
|
||||
itemIndex += 1;
|
||||
}
|
||||
} else if (message.type === 'error') {
|
||||
const error = new Error(message.error.message);
|
||||
error.name = message.error.name;
|
||||
error.stack = message.error.stack;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
thread.on('error', reject);
|
||||
thread.on('exit', (code: number) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Worker thread exited with code ${code}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
function workerQueueThread(
|
||||
workerFuncFactory: (
|
||||
data: unknown,
|
||||
) => Promise<(item: unknown) => Promise<unknown>>,
|
||||
) {
|
||||
const { parentPort, workerData } = require('node:worker_threads');
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => workerFuncFactory(workerData))
|
||||
.then(
|
||||
workerFunc => {
|
||||
parentPort.on('message', async (message: WorkerThreadMessage) => {
|
||||
if (message.type === 'done') {
|
||||
parentPort.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === 'item') {
|
||||
try {
|
||||
const result = await workerFunc(message.item);
|
||||
parentPort.postMessage({
|
||||
type: 'result',
|
||||
index: message.index,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: 'error', error });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
parentPort.postMessage({ type: 'start' });
|
||||
},
|
||||
error => parentPort.postMessage({ type: 'error', error }),
|
||||
);
|
||||
}
|
||||
|
||||
export type WorkerThreadsOptions<TResult, TData, TMessage> = {
|
||||
/**
|
||||
* A function that is called by each worker thread to produce a result.
|
||||
*
|
||||
* This function must be defined as an arrow function or using the
|
||||
* function keyword, and must be entirely self contained, not referencing
|
||||
* any variables outside of its scope. This is because the function source
|
||||
* is stringified and evaluated in the worker thread.
|
||||
*
|
||||
* To pass data to the worker, use the `workerData` option, but
|
||||
* note that they are both copied by value into the worker thread, except for
|
||||
* types that are explicitly shareable across threads, such as `SharedArrayBuffer`.
|
||||
*/
|
||||
worker: (
|
||||
data: TData,
|
||||
sendMessage: (message: TMessage) => void,
|
||||
) => Promise<TResult>;
|
||||
/** Data supplied to each worker */
|
||||
workerData?: TData;
|
||||
/** Number of threads, defaults to 1 */
|
||||
threadCount?: number;
|
||||
/** An optional handler for messages posted from the worker thread */
|
||||
onMessage?: (message: TMessage) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spawns one or more worker threads using the `worker_threads` module.
|
||||
*/
|
||||
export async function runWorkerThreads<TResult, TData, TMessage>(
|
||||
options: WorkerThreadsOptions<TResult, TData, TMessage>,
|
||||
): Promise<TResult[]> {
|
||||
const { worker, workerData, threadCount = 1, onMessage } = options;
|
||||
|
||||
return Promise.all(
|
||||
Array(threadCount)
|
||||
.fill(0)
|
||||
.map(async () => {
|
||||
const thread = new Worker(`(${workerThread})(${worker})`, {
|
||||
eval: true,
|
||||
workerData,
|
||||
});
|
||||
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
thread.on('message', (message: WorkerThreadMessage) => {
|
||||
if (message.type === 'result') {
|
||||
resolve(message.result as TResult);
|
||||
} else if (message.type === 'error') {
|
||||
reject(message.error);
|
||||
} else if (message.type === 'message') {
|
||||
onMessage?.(message.message as TMessage);
|
||||
}
|
||||
});
|
||||
|
||||
thread.on('error', reject);
|
||||
thread.on('exit', (code: number) => {
|
||||
reject(
|
||||
new Error(`Unexpected worker thread exit with code ${code}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* istanbul ignore next */
|
||||
function workerThread(
|
||||
workerFunc: (
|
||||
data: unknown,
|
||||
sendMessage: (message: unknown) => void,
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
const { parentPort, workerData } = require('node:worker_threads');
|
||||
|
||||
const sendMessage = (message: unknown) => {
|
||||
parentPort.postMessage({ type: 'message', message });
|
||||
};
|
||||
|
||||
workerFunc(workerData, sendMessage).then(
|
||||
result => {
|
||||
parentPort.postMessage({
|
||||
type: 'result',
|
||||
index: 0,
|
||||
result,
|
||||
});
|
||||
},
|
||||
error => {
|
||||
parentPort.postMessage({ type: 'error', error });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* 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 { findPaths } from '@backstage/cli-common';
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
export const paths = findPaths(__dirname);
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
} from '@backstage/cli-node';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph';
|
||||
import { paths } from './paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export const createTypeDistProject = async () => {
|
||||
return new Project({
|
||||
tsConfigFilePath: paths.resolveTargetRoot('tsconfig.json'),
|
||||
tsConfigFilePath: targetPaths.resolveRoot('tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import semver from 'semver';
|
||||
import { paths } from './paths';
|
||||
import { findOwnPaths } from '@backstage/cli-common';
|
||||
import { Lockfile } from './versioning';
|
||||
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const ownPaths = findOwnPaths(__dirname);
|
||||
|
||||
/* eslint-disable @backstage/no-relative-monorepo-imports */
|
||||
/*
|
||||
This is a list of all packages used by the templates. If dependencies are added or removed,
|
||||
@@ -82,12 +85,12 @@ export const packageVersions: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function findVersion() {
|
||||
const pkgContent = fs.readFileSync(paths.resolveOwn('package.json'), 'utf8');
|
||||
const pkgContent = fs.readFileSync(ownPaths.resolve('package.json'), 'utf8');
|
||||
return JSON.parse(pkgContent).version;
|
||||
}
|
||||
|
||||
export const version = findVersion();
|
||||
export const isDev = fs.pathExistsSync(paths.resolveOwn('src'));
|
||||
export const isDev = fs.pathExistsSync(ownPaths.resolve('src'));
|
||||
|
||||
export function createPackageVersionProvider(
|
||||
lockfile?: Lockfile,
|
||||
|
||||
@@ -15,17 +15,11 @@
|
||||
*/
|
||||
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import { getHasYarnPlugin } from './yarnPlugin';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
jest.mock('./paths', () => ({
|
||||
paths: {
|
||||
resolveTargetRoot(filename: string) {
|
||||
return mockDir.resolve(filename);
|
||||
},
|
||||
},
|
||||
}));
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
describe('getHasYarnPlugin', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import z from 'zod';
|
||||
import { paths } from './paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const yarnRcSchema = z.object({
|
||||
plugins: z
|
||||
@@ -35,7 +35,7 @@ const yarnRcSchema = z.object({
|
||||
* @returns Promise<boolean> - true if the plugin is installed, false otherwise
|
||||
*/
|
||||
export async function getHasYarnPlugin(): Promise<boolean> {
|
||||
const yarnRcPath = paths.resolveTargetRoot('.yarnrc.yml');
|
||||
const yarnRcPath = targetPaths.resolveRoot('.yarnrc.yml');
|
||||
const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => {
|
||||
if (e.code === 'ENOENT') {
|
||||
// gracefully continue in case the file doesn't exist
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
} from '@backstage/cli-node';
|
||||
import { paths } from '../../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { buildFrontend } from '../../../lib/buildFrontend';
|
||||
import { buildBackend } from '../../../lib/buildBackend';
|
||||
import { isValidUrl } from '../../../lib/urls';
|
||||
@@ -41,19 +42,19 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
if (isValidUrl(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return paths.resolveTarget(arg);
|
||||
return targetPaths.resolve(arg);
|
||||
});
|
||||
|
||||
if (role === 'frontend') {
|
||||
return buildFrontend({
|
||||
targetDir: paths.targetDir,
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths,
|
||||
writeStats: Boolean(opts.stats),
|
||||
webpack,
|
||||
});
|
||||
}
|
||||
return buildBackend({
|
||||
targetDir: paths.targetDir,
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths,
|
||||
skipBuildDependencies: Boolean(opts.skipBuildDependencies),
|
||||
minify: Boolean(opts.minify),
|
||||
@@ -76,7 +77,7 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
if (isModuleFederationRemote) {
|
||||
console.log('Building package as a module federation remote');
|
||||
return buildFrontend({
|
||||
targetDir: paths.targetDir,
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths: [],
|
||||
writeStats: Boolean(opts.stats),
|
||||
isModuleFederationRemote,
|
||||
@@ -99,7 +100,7 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
}
|
||||
|
||||
const packageJson = (await fs.readJson(
|
||||
paths.resolveTarget('package.json'),
|
||||
targetPaths.resolve('package.json'),
|
||||
)) as BackstagePackageJson;
|
||||
|
||||
return buildPackage({
|
||||
|
||||
@@ -18,13 +18,13 @@ import { OptionValues } from 'commander';
|
||||
import { startPackage } from './startPackage';
|
||||
import { resolveLinkedWorkspace } from './resolveLinkedWorkspace';
|
||||
import { findRoleFromCommand } from '../../../lib/role';
|
||||
import { paths } from '../../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export async function command(opts: OptionValues): Promise<void> {
|
||||
await startPackage({
|
||||
role: await findRoleFromCommand(opts),
|
||||
entrypoint: opts.entrypoint,
|
||||
targetDir: paths.targetDir,
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths: opts.config as string[],
|
||||
checksEnabled: Boolean(opts.check),
|
||||
linkedWorkspace: await resolveLinkedWorkspace(opts.link),
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { paths } from '../../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { runBackend } from '../../../lib/runner';
|
||||
|
||||
interface StartBackendOptions {
|
||||
@@ -43,7 +44,7 @@ export async function startBackend(options: StartBackendOptions) {
|
||||
|
||||
export async function startBackendPlugin(options: StartBackendOptions) {
|
||||
const hasDevIndexEntry = await fs.pathExists(
|
||||
resolvePath(options.targetDir ?? paths.targetDir, 'dev/index.ts'),
|
||||
resolvePath(options.targetDir ?? targetPaths.dir, 'dev/index.ts'),
|
||||
);
|
||||
if (!hasDevIndexEntry) {
|
||||
console.warn(
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
getModuleFederationRemoteOptions,
|
||||
serveBundle,
|
||||
} from '../../../../build/lib/bundler';
|
||||
import { paths } from '../../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { hasReactDomClient } from '../../../../build/lib/bundler/hasReactDomClient';
|
||||
|
||||
@@ -38,7 +39,7 @@ interface StartAppOptions {
|
||||
|
||||
export async function startFrontend(options: StartAppOptions) {
|
||||
const packageJson = (await readJson(
|
||||
resolvePath(options.targetDir ?? paths.targetDir, 'package.json'),
|
||||
resolvePath(options.targetDir ?? targetPaths.dir, 'package.json'),
|
||||
)) as BackstagePackageJson;
|
||||
|
||||
if (!hasReactDomClient()) {
|
||||
@@ -58,7 +59,7 @@ export async function startFrontend(options: StartAppOptions) {
|
||||
moduleFederationRemote: options.isModuleFederationRemote
|
||||
? await getModuleFederationRemoteOptions(
|
||||
packageJson,
|
||||
resolvePath(paths.targetDir),
|
||||
resolvePath(targetPaths.dir),
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -18,13 +18,14 @@ import chalk from 'chalk';
|
||||
import { Command, OptionValues } from 'commander';
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { buildPackages, getOutputsForRole } from '../../lib/builder';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
BackstagePackage,
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import { runParallelWorkers } from '../../../../lib/parallel';
|
||||
import { buildFrontend } from '../../lib/buildFrontend';
|
||||
import { buildBackend } from '../../lib/buildBackend';
|
||||
import { createScriptOptionsParser } from '../../../../lib/optionsParser';
|
||||
@@ -89,7 +90,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
targetDir: pkg.dir,
|
||||
packageJson: pkg.packageJson,
|
||||
outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
|
||||
logPrefix: `${chalk.cyan(relativePath(targetPaths.rootDir, pkg.dir))}: `,
|
||||
workspacePackages: packages,
|
||||
minify: opts.minify ?? buildOptions.minify,
|
||||
};
|
||||
@@ -100,9 +101,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
|
||||
if (opts.all) {
|
||||
console.log('Building apps');
|
||||
await runParallelWorkers({
|
||||
await runConcurrentTasks({
|
||||
items: apps,
|
||||
parallelismFactor: 1 / 2,
|
||||
concurrencyFactor: 1 / 2,
|
||||
worker: async pkg => {
|
||||
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
|
||||
if (!buildOptions) {
|
||||
@@ -121,9 +122,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
});
|
||||
|
||||
console.log('Building backends');
|
||||
await runParallelWorkers({
|
||||
await runConcurrentTasks({
|
||||
items: backends,
|
||||
parallelismFactor: 1 / 2,
|
||||
concurrencyFactor: 1 / 2,
|
||||
worker: async pkg => {
|
||||
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
|
||||
if (!buildOptions) {
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { findTargetPackages } from './start';
|
||||
import { posix } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
|
||||
overrideTargetPaths('/root');
|
||||
|
||||
const mocks = {
|
||||
app: {
|
||||
@@ -97,11 +98,6 @@ const mocks = {
|
||||
describe('findTargetPackages', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest
|
||||
.spyOn(paths, 'resolveTargetRoot')
|
||||
.mockImplementation((...parts: string[]) => {
|
||||
return posix.resolve('/root', ...parts);
|
||||
});
|
||||
});
|
||||
|
||||
it('should select default packages', async () => {
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
PackageRole,
|
||||
} from '@backstage/cli-node';
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { resolveLinkedWorkspace } from '../package/start/resolveLinkedWorkspace';
|
||||
import { startPackage } from '../package/start/startPackage';
|
||||
import { parseArgs } from 'node:util';
|
||||
@@ -95,7 +96,7 @@ export async function findTargetPackages(
|
||||
pkg => nameOrPath === pkg.packageJson.name,
|
||||
);
|
||||
if (!matchingPackage) {
|
||||
const absPath = paths.resolveTargetRoot(nameOrPath);
|
||||
const absPath = targetPaths.resolveRoot(nameOrPath);
|
||||
matchingPackage = packages.find(
|
||||
pkg => relativePath(pkg.dir, absPath) === '',
|
||||
);
|
||||
@@ -117,7 +118,7 @@ export async function findTargetPackages(
|
||||
);
|
||||
if (matchingPackages.length > 1) {
|
||||
// Final fallback is to check for the package path within the monorepo, packages/app or packages/backend
|
||||
const expectedPath = paths.resolveTargetRoot(
|
||||
const expectedPath = targetPaths.resolveRoot(
|
||||
role === 'frontend' ? 'packages/app' : 'packages/backend',
|
||||
);
|
||||
const matchByPath = matchingPackages.find(
|
||||
|
||||
@@ -19,7 +19,6 @@ import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
import { createDistWorkspace } from './packager';
|
||||
import { getEnvironmentParallelism } from '../../../lib/parallel';
|
||||
import { buildPackage, Output } from './builder';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
|
||||
@@ -53,7 +52,6 @@ export async function buildBackend(options: BuildBackendOptions) {
|
||||
configPaths,
|
||||
buildDependencies: !skipBuildDependencies,
|
||||
buildExcludes: [pkg.name],
|
||||
parallelism: getEnvironmentParallelism(),
|
||||
skeleton: SKELETON_FILE,
|
||||
minify,
|
||||
});
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { buildBundle, getModuleFederationRemoteOptions } from './bundler';
|
||||
import { getEnvironmentParallelism } from '../../../lib/parallel';
|
||||
import { loadCliConfig } from '../../config/lib/config';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { loadCliConfig } from '../../config/lib/config';
|
||||
|
||||
interface BuildAppOptions {
|
||||
targetDir: string;
|
||||
@@ -37,7 +36,6 @@ export async function buildFrontend(options: BuildAppOptions) {
|
||||
await buildBundle({
|
||||
targetDir,
|
||||
entry: 'src/index',
|
||||
parallelism: getEnvironmentParallelism(),
|
||||
statsJsonEnabled: writeStats,
|
||||
moduleFederationRemote: options.isModuleFederationRemote
|
||||
? await getModuleFederationRemoteOptions(
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
|
||||
import { forwardFileImports, cssEntryPoints } from './plugins';
|
||||
import { BuildOptions, Output } from './types';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { readEntryPoints } from '../entryPoints';
|
||||
|
||||
@@ -116,7 +117,7 @@ export async function makeRollupConfigs(
|
||||
options: BuildOptions,
|
||||
): Promise<RollupOptions[]> {
|
||||
const configs = new Array<RollupOptions>();
|
||||
const targetDir = options.targetDir ?? paths.targetDir;
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
|
||||
let targetPkg = options.packageJson;
|
||||
if (!targetPkg) {
|
||||
@@ -284,9 +285,9 @@ export async function makeRollupConfigs(
|
||||
const input = Object.fromEntries(
|
||||
scriptEntryPoints.map(e => [
|
||||
e.name,
|
||||
paths.resolveTargetRoot(
|
||||
targetPaths.resolveRoot(
|
||||
'dist-types',
|
||||
relativePath(paths.targetRoot, targetDir),
|
||||
relativePath(targetPaths.rootDir, targetDir),
|
||||
e.path.replace(/\.(?:ts|tsx)$/, '.d.ts'),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -18,11 +18,11 @@ import fs from 'fs-extra';
|
||||
import { rollup, RollupOptions } from 'rollup';
|
||||
import chalk from 'chalk';
|
||||
import { relative as relativePath, resolve as resolvePath } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { makeRollupConfigs } from './config';
|
||||
import { BuildOptions, Output } from './types';
|
||||
import { PackageRoles } from '@backstage/cli-node';
|
||||
import { runParallelWorkers } from '../../../../lib/parallel';
|
||||
import { PackageRoles, runConcurrentTasks } from '@backstage/cli-node';
|
||||
|
||||
export function formatErrorMessage(error: any) {
|
||||
let msg = '';
|
||||
@@ -34,7 +34,7 @@ export function formatErrorMessage(error: any) {
|
||||
msg += `\n\n`;
|
||||
for (const { text, location } of error.errors) {
|
||||
const { line, column } = location;
|
||||
const path = relativePath(paths.targetDir, error.id);
|
||||
const path = relativePath(targetPaths.dir, error.id);
|
||||
const loc = chalk.cyan(`${path}:${line}:${column}`);
|
||||
|
||||
if (text === 'Unexpected "<"' && error.id.endsWith('.js')) {
|
||||
@@ -53,11 +53,11 @@ export function formatErrorMessage(error: any) {
|
||||
} else {
|
||||
// Generic rollup errors, log what's available
|
||||
if (error.loc) {
|
||||
const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
|
||||
const file = `${targetPaths.resolve((error.loc.file || error.id)!)}`;
|
||||
const pos = `${error.loc.line}:${error.loc.column}`;
|
||||
msg += `${file} [${pos}]\n`;
|
||||
} else if (error.id) {
|
||||
msg += `${paths.resolveTarget(error.id)}\n`;
|
||||
msg += `${targetPaths.resolve(error.id)}\n`;
|
||||
}
|
||||
|
||||
msg += `${error}\n`;
|
||||
@@ -90,7 +90,7 @@ async function rollupBuild(config: RollupOptions) {
|
||||
export const buildPackage = async (options: BuildOptions) => {
|
||||
try {
|
||||
const { resolutions } = await fs.readJson(
|
||||
paths.resolveTargetRoot('package.json'),
|
||||
targetPaths.resolveRoot('package.json'),
|
||||
);
|
||||
if (resolutions?.esbuild) {
|
||||
console.warn(
|
||||
@@ -107,7 +107,7 @@ export const buildPackage = async (options: BuildOptions) => {
|
||||
|
||||
const rollupConfigs = await makeRollupConfigs(options);
|
||||
|
||||
const targetDir = options.targetDir ?? paths.targetDir;
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
await fs.remove(resolvePath(targetDir, 'dist'));
|
||||
|
||||
const buildTasks = rollupConfigs.map(rollupBuild);
|
||||
@@ -127,7 +127,7 @@ export const buildPackages = async (options: BuildOptions[]) => {
|
||||
|
||||
const buildTasks = rollupConfigs.flat().map(opts => () => rollupBuild(opts));
|
||||
|
||||
await runParallelWorkers({
|
||||
await runConcurrentTasks({
|
||||
items: buildTasks,
|
||||
worker: async task => task(),
|
||||
});
|
||||
|
||||
@@ -25,11 +25,12 @@ import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
|
||||
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
|
||||
import { paths as cliPaths } from '../../../../lib/paths';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { optimization as optimizationConfig } from './optimization';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import { runOutput } from '@backstage/cli-common';
|
||||
import { runOutput, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { transforms } from './transforms';
|
||||
import { version } from '../../../../lib/version';
|
||||
import yn from 'yn';
|
||||
@@ -96,7 +97,7 @@ async function readBuildInfo() {
|
||||
}
|
||||
|
||||
const { version: packageVersion } = await fs.readJson(
|
||||
cliPaths.resolveTarget('package.json'),
|
||||
targetPaths.resolve('package.json'),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export function hasReactDomClient() {
|
||||
try {
|
||||
require.resolve('react-dom/client', {
|
||||
paths: [paths.targetDir],
|
||||
paths: [targetPaths.dir],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { getPackages } from '@manypkg/get-packages';
|
||||
import { rspack } from '@rspack/core';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
/**
|
||||
* This returns of collection of plugins that links a separate workspace into
|
||||
@@ -52,7 +52,7 @@ export async function createWorkspaceLinkingPlugins(
|
||||
/^react(?:-router)?(?:-dom)?$/,
|
||||
resource => {
|
||||
if (!relativePath(linkedRoot.dir, resource.context).startsWith('..')) {
|
||||
resource.context = paths.targetDir;
|
||||
resource.context = targetPaths.dir;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
HostSharedDependencies,
|
||||
RuntimeSharedDependenciesGlobal,
|
||||
} from '@backstage/module-federation-common';
|
||||
import { dirname, join as joinPath, resolve as resolvePath } from 'path';
|
||||
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path';
|
||||
import fs from 'fs-extra';
|
||||
import chokidar from 'chokidar';
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
@@ -20,7 +20,7 @@ import chokidar from 'chokidar';
|
||||
import fs from 'fs-extra';
|
||||
import PQueue from 'p-queue';
|
||||
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path';
|
||||
import { paths as cliPaths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const DETECTED_MODULES_MODULE_NAME = '__backstage-autodetected-plugins__';
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function createDetectedModulesEntryPoint(options: {
|
||||
// Previous versions of the CLI would write the detected modules file to the
|
||||
// root `node_modules`, this makes sure that doesn't exist to minimize risk of conflicts
|
||||
const legacyDetectedModulesPath = joinPath(
|
||||
cliPaths.targetRoot,
|
||||
targetPaths.rootDir,
|
||||
'node_modules',
|
||||
`${DETECTED_MODULES_MODULE_NAME}.js`,
|
||||
);
|
||||
|
||||
@@ -16,19 +16,19 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths, findOwnPaths } from '@backstage/cli-common';
|
||||
|
||||
export type BundlingPathsOptions = {
|
||||
// bundle entrypoint, e.g. 'src/index'
|
||||
entry: string;
|
||||
// Target directory, defaulting to paths.targetDir
|
||||
// Target directory, defaulting to targetPaths.dir
|
||||
targetDir?: string;
|
||||
// Relative dist directory, defaulting to 'dist'
|
||||
dist?: string;
|
||||
};
|
||||
|
||||
export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
const { entry, targetDir = paths.targetDir } = options;
|
||||
const { entry, targetDir = targetPaths.dir } = options;
|
||||
|
||||
const resolveTargetModule = (pathString: string) => {
|
||||
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
|
||||
@@ -49,7 +49,10 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
} else {
|
||||
targetHtml = resolvePath(targetDir, `${entry}.html`);
|
||||
if (!fs.pathExistsSync(targetHtml)) {
|
||||
targetHtml = paths.resolveOwn('templates/serve_index.html');
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
targetHtml = findOwnPaths(__dirname).resolve(
|
||||
'templates/serve_index.html',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +70,10 @@ export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
targetSrc: resolvePath(targetDir, 'src'),
|
||||
targetDev: resolvePath(targetDir, 'dev'),
|
||||
targetEntry: resolveTargetModule(entry),
|
||||
targetTsConfig: paths.resolveTargetRoot('tsconfig.json'),
|
||||
targetTsConfig: targetPaths.resolveRoot('tsconfig.json'),
|
||||
targetPackageJson: resolvePath(targetDir, 'package.json'),
|
||||
rootNodeModules: paths.resolveTargetRoot('node_modules'),
|
||||
root: paths.targetRoot,
|
||||
rootNodeModules: targetPaths.resolveRoot('node_modules'),
|
||||
root: targetPaths.rootDir,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { rspack } from '@rspack/core';
|
||||
import { RspackDevServer } from '@rspack/dev-server';
|
||||
|
||||
import { paths as libPaths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { loadCliConfig } from '../../../config/lib/config';
|
||||
import { createConfig, resolveBaseUrl, resolveEndpoint } from './config';
|
||||
import { createDetectedModulesEntryPoint } from './packageDetection';
|
||||
@@ -53,7 +54,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
|
||||
checkReactVersion();
|
||||
|
||||
const { name } = await fs.readJson(
|
||||
resolvePath(options.targetDir ?? libPaths.targetDir, 'package.json'),
|
||||
resolvePath(options.targetDir ?? targetPaths.dir, 'package.json'),
|
||||
);
|
||||
|
||||
let devServer: RspackDevServer | undefined = undefined;
|
||||
@@ -271,7 +272,7 @@ function checkReactVersion() {
|
||||
try {
|
||||
// Make sure we're looking at the root of the target repo
|
||||
const reactPkgPath = require.resolve('react/package.json', {
|
||||
paths: [libPaths.targetRoot],
|
||||
paths: [targetPaths.rootDir],
|
||||
});
|
||||
const reactPkg = require(reactPkgPath);
|
||||
if (reactPkg.version.startsWith('16.')) {
|
||||
|
||||
@@ -36,7 +36,6 @@ export type BundlingOptions = {
|
||||
isDev: boolean;
|
||||
frontendConfig: Config;
|
||||
getFrontendAppConfigs(): AppConfig[];
|
||||
parallelism?: number;
|
||||
additionalEntryPoints?: string[];
|
||||
// Path to append to the detected public path, e.g. '/public'
|
||||
publicSubPath?: string;
|
||||
@@ -63,7 +62,6 @@ export type BuildOptions = BundlingPathsOptions & {
|
||||
// Target directory, defaulting to paths.targetDir
|
||||
targetDir?: string;
|
||||
statsJsonEnabled: boolean;
|
||||
parallelism?: number;
|
||||
schema?: ConfigSchema;
|
||||
frontendConfig: Config;
|
||||
frontendAppConfigs: AppConfig[];
|
||||
@@ -75,7 +73,6 @@ export type BuildOptions = BundlingPathsOptions & {
|
||||
export type BackendBundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
parallelism?: number;
|
||||
inspectEnabled: boolean;
|
||||
inspectBrkEnabled: boolean;
|
||||
require?: string;
|
||||
|
||||
@@ -24,8 +24,9 @@ import {
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as tar from 'tar';
|
||||
import partition from 'lodash/partition';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { run } from '@backstage/cli-common';
|
||||
|
||||
import { run, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
dependencies as cliDependencies,
|
||||
devDependencies as cliDevDependencies,
|
||||
@@ -41,8 +42,8 @@ import {
|
||||
PackageRoles,
|
||||
PackageGraph,
|
||||
PackageGraphNode,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import { runParallelWorkers } from '../../../../lib/parallel';
|
||||
import { createTypeDistProject } from '../../../../lib/typeDistProject';
|
||||
|
||||
// These packages aren't safe to pack in parallel since the CLI depends on them
|
||||
@@ -86,11 +87,6 @@ type Options = {
|
||||
*/
|
||||
buildExcludes?: string[];
|
||||
|
||||
/**
|
||||
* Controls amount of parallelism in some build steps.
|
||||
*/
|
||||
parallelism?: number;
|
||||
|
||||
/**
|
||||
* If set, creates a skeleton tarball that contains all package.json files
|
||||
* with the same structure as the workspace dir.
|
||||
@@ -215,7 +211,9 @@ export async function createDistWorkspace(
|
||||
targetDir: pkg.dir,
|
||||
packageJson: pkg.packageJson,
|
||||
outputs: outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
|
||||
logPrefix: `${chalk.cyan(
|
||||
relativePath(targetPaths.rootDir, pkg.dir),
|
||||
)}: `,
|
||||
minify: options.minify,
|
||||
workspacePackages: packages,
|
||||
});
|
||||
@@ -225,7 +223,7 @@ export async function createDistWorkspace(
|
||||
await buildPackages(standardBuilds);
|
||||
|
||||
if (customBuild.length > 0) {
|
||||
await runParallelWorkers({
|
||||
await runConcurrentTasks({
|
||||
items: customBuild,
|
||||
worker: async ({ name, dir, args }) => {
|
||||
await run(['yarn', 'run', 'build', ...(args || [])], {
|
||||
@@ -250,13 +248,13 @@ export async function createDistWorkspace(
|
||||
for (const file of files) {
|
||||
const src = typeof file === 'string' ? file : file.src;
|
||||
const dest = typeof file === 'string' ? file : file.dest;
|
||||
await fs.copy(paths.resolveTargetRoot(src), resolvePath(targetDir, dest));
|
||||
await fs.copy(targetPaths.resolveRoot(src), resolvePath(targetDir, dest));
|
||||
}
|
||||
|
||||
if (options.skeleton) {
|
||||
const skeletonFiles = targets
|
||||
.map(target => {
|
||||
const dir = relativePath(paths.targetRoot, target.dir);
|
||||
const dir = relativePath(targetPaths.rootDir, target.dir);
|
||||
return joinPath(dir, 'package.json');
|
||||
})
|
||||
.sort();
|
||||
@@ -305,7 +303,7 @@ async function moveToDistWorkspace(
|
||||
fastPackPackages.map(async target => {
|
||||
console.log(`Moving ${target.name} into dist workspace`);
|
||||
|
||||
const outputDir = relativePath(paths.targetRoot, target.dir);
|
||||
const outputDir = relativePath(targetPaths.rootDir, target.dir);
|
||||
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
|
||||
await productionPack({
|
||||
packageDir: target.dir,
|
||||
@@ -325,7 +323,7 @@ async function moveToDistWorkspace(
|
||||
cwd: target.dir,
|
||||
}).waitForExit();
|
||||
|
||||
const outputDir = relativePath(paths.targetRoot, target.dir);
|
||||
const outputDir = relativePath(targetPaths.rootDir, target.dir);
|
||||
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
|
||||
await fs.ensureDir(absoluteOutputPath);
|
||||
|
||||
@@ -368,7 +366,7 @@ async function moveToDistWorkspace(
|
||||
}
|
||||
|
||||
// Repacking in parallel is much faster and safe for all packages outside of the Backstage repo
|
||||
await runParallelWorkers({
|
||||
await runConcurrentTasks({
|
||||
items: safePackages.map((target, index) => ({ target, index })),
|
||||
worker: async ({ target, index }) => {
|
||||
await pack(target, `temp-package-${index}.tgz`);
|
||||
|
||||
@@ -15,18 +15,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();
|
||||
|
||||
jest.mock('../../../lib/paths', () => ({
|
||||
paths: {
|
||||
resolveTarget(filename: string) {
|
||||
return mockDir.resolve(filename);
|
||||
},
|
||||
},
|
||||
}));
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
describe('findRoleFromCommand', () => {
|
||||
function mkCommand(args?: string) {
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { OptionValues } from 'commander';
|
||||
import { paths } from '../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { PackageRoles, PackageRole } from '@backstage/cli-node';
|
||||
|
||||
export async function findRoleFromCommand(
|
||||
@@ -26,7 +27,7 @@ export async function findRoleFromCommand(
|
||||
return PackageRoles.getRoleInfo(opts.role)?.role;
|
||||
}
|
||||
|
||||
const pkg = await fs.readJson(paths.resolveTarget('package.json'));
|
||||
const pkg = await fs.readJson(targetPaths.resolve('package.json'));
|
||||
const info = PackageRoles.getRoleFromPackage(pkg);
|
||||
if (!info) {
|
||||
throw new Error(`Target package must have 'backstage.role' set`);
|
||||
|
||||
@@ -21,7 +21,8 @@ import { IpcServer, ServerDataStore } from '../ipc';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute as isAbsolutePath } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
const loaderArgs = [
|
||||
@@ -135,7 +136,7 @@ export async function runBackend(options: RunBackendOptions) {
|
||||
...process.env,
|
||||
BACKSTAGE_CLI_LINKED_WORKSPACE: options.linkedWorkspace,
|
||||
BACKSTAGE_CLI_CHANNEL: '1',
|
||||
ESBK_TSCONFIG_PATH: paths.resolveTargetRoot('tsconfig.json'),
|
||||
ESBK_TSCONFIG_PATH: targetPaths.resolveRoot('tsconfig.json'),
|
||||
},
|
||||
serialization: 'advanced',
|
||||
},
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import { ConfigSources, loadConfigSchema } from '@backstage/config-loader';
|
||||
import { AppConfig, ConfigReader } from '@backstage/config';
|
||||
import { paths } from '../../../lib/paths';
|
||||
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';
|
||||
@@ -34,7 +35,7 @@ type Options = {
|
||||
};
|
||||
|
||||
export async function loadCliConfig(options: Options) {
|
||||
const targetDir = options.targetDir ?? paths.targetDir;
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
|
||||
// Consider all packages in the monorepo when loading in config
|
||||
const { packages } = await getPackages(targetDir);
|
||||
@@ -63,7 +64,7 @@ export async function loadCliConfig(options: Options) {
|
||||
const schema = await loadConfigSchema({
|
||||
dependencies: localPackageNames,
|
||||
// Include the package.json in the project root if it exists
|
||||
packagePaths: [paths.resolveTargetRoot('package.json')],
|
||||
packagePaths: [targetPaths.resolveRoot('package.json')],
|
||||
noUndeclaredProperties: options.strict,
|
||||
});
|
||||
|
||||
@@ -73,7 +74,7 @@ export async function loadCliConfig(options: Options) {
|
||||
? async name => process.env[name] || 'x'
|
||||
: undefined,
|
||||
watch: Boolean(options.watch),
|
||||
rootDir: paths.targetRoot,
|
||||
rootDir: targetPaths.rootDir,
|
||||
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import fs from 'fs-extra';
|
||||
import chalk from 'chalk';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import inquirer, { Question, Answers } from 'inquirer';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { GithubCreateAppServer } from './GithubCreateAppServer';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
|
||||
@@ -62,7 +63,7 @@ export default async (org: string) => {
|
||||
|
||||
const fileName = `github-app-${slug}-credentials.yaml`;
|
||||
const content = `# Name: ${name}\n${stringifyYaml(config)}`;
|
||||
await fs.writeFile(paths.resolveTargetRoot(fileName), content);
|
||||
await fs.writeFile(targetPaths.resolveRoot(fileName), content);
|
||||
console.log(`GitHub App configuration written to ${chalk.cyan(fileName)}`);
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
import { version as cliVersion } from '../../../../package.json';
|
||||
import os from 'node:os';
|
||||
import { runOutput } from '@backstage/cli-common';
|
||||
import { paths } from '../../../lib/paths';
|
||||
import { runOutput, targetPaths, findOwnPaths } from '@backstage/cli-common';
|
||||
import { Lockfile } from '../../../lib/versioning';
|
||||
import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node';
|
||||
import { minimatch } from 'minimatch';
|
||||
@@ -56,9 +55,10 @@ function hasBackstageField(packageName: string, targetPath: string): boolean {
|
||||
export default async (options: InfoOptions) => {
|
||||
await new Promise(async () => {
|
||||
const yarnVersion = await runOutput(['yarn', '--version']);
|
||||
const isLocal = fs.existsSync(paths.resolveOwn('./src'));
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
const isLocal = fs.existsSync(findOwnPaths(__dirname).resolve('./src'));
|
||||
|
||||
const backstageFile = paths.resolveTargetRoot('backstage.json');
|
||||
const backstageFile = targetPaths.resolveRoot('backstage.json');
|
||||
let backstageVersion = 'N/A';
|
||||
if (fs.existsSync(backstageFile)) {
|
||||
try {
|
||||
@@ -83,9 +83,9 @@ export default async (options: InfoOptions) => {
|
||||
backstage: backstageVersion,
|
||||
};
|
||||
|
||||
const lockfilePath = paths.resolveTargetRoot('yarn.lock');
|
||||
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
|
||||
const lockfile = await Lockfile.load(lockfilePath);
|
||||
const targetPath = paths.targetRoot;
|
||||
const targetPath = targetPaths.rootDir;
|
||||
|
||||
// Get workspace package names and their versions
|
||||
const workspacePackages = new Map<string, string>();
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { OptionValues } from 'commander';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { ESLint } from 'eslint';
|
||||
|
||||
export default async (directories: string[], opts: OptionValues) => {
|
||||
const eslint = new ESLint({
|
||||
cwd: paths.targetDir,
|
||||
cwd: targetPaths.dir,
|
||||
fix: opts.fix,
|
||||
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
|
||||
});
|
||||
@@ -47,14 +48,14 @@ export default async (directories: string[], opts: OptionValues) => {
|
||||
|
||||
// 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') {
|
||||
process.chdir(paths.targetRoot);
|
||||
process.chdir(targetPaths.rootDir);
|
||||
}
|
||||
|
||||
const resultText = await formatter.format(results);
|
||||
|
||||
if (resultText) {
|
||||
if (opts.outputFile) {
|
||||
await fs.writeFile(paths.resolveTarget(opts.outputFile), resultText);
|
||||
await fs.writeFile(targetPaths.resolve(opts.outputFile), resultText);
|
||||
} else {
|
||||
console.log(resultText);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
PackageGraph,
|
||||
BackstagePackageJson,
|
||||
Lockfile,
|
||||
runWorkerQueueThreads,
|
||||
} from '@backstage/cli-node';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { runWorkerQueueThreads } from '../../../../lib/parallel';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { createScriptOptionsParser } from '../../../../lib/optionsParser';
|
||||
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
|
||||
|
||||
@@ -44,7 +45,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
const cacheContext = opts.successCache
|
||||
? {
|
||||
entries: await cache.read(),
|
||||
lockfile: await Lockfile.load(paths.resolveTargetRoot('yarn.lock')),
|
||||
lockfile: await Lockfile.load(targetPaths.resolveRoot('yarn.lock')),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -62,7 +63,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
|
||||
// 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') {
|
||||
process.chdir(paths.targetRoot);
|
||||
process.chdir(targetPaths.rootDir);
|
||||
}
|
||||
|
||||
// Make sure lint output is colored unless the user explicitly disabled it
|
||||
@@ -77,7 +78,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
const lintOptions = parseLintScript(pkg.packageJson.scripts?.lint);
|
||||
const base = {
|
||||
fullDir: pkg.dir,
|
||||
relativeDir: relativePath(paths.targetRoot, pkg.dir),
|
||||
relativeDir: relativePath(targetPaths.rootDir, pkg.dir),
|
||||
lintOptions,
|
||||
parentHash: undefined,
|
||||
};
|
||||
@@ -105,15 +106,15 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
}),
|
||||
);
|
||||
|
||||
const resultsList = await runWorkerQueueThreads({
|
||||
const { results: resultsList } = await runWorkerQueueThreads({
|
||||
items: items.filter(item => item.lintOptions), // Filter out packages without lint script
|
||||
workerData: {
|
||||
context: {
|
||||
fix: Boolean(opts.fix),
|
||||
format: opts.format as string | undefined,
|
||||
shouldCache: Boolean(cacheContext),
|
||||
maxWarnings: opts.maxWarnings ?? -1,
|
||||
successCache: cacheContext?.entries,
|
||||
rootDir: paths.targetRoot,
|
||||
rootDir: targetPaths.rootDir,
|
||||
},
|
||||
workerFactory: async ({
|
||||
fix,
|
||||
@@ -263,7 +264,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
}
|
||||
|
||||
if (opts.outputFile && errorOutput) {
|
||||
await fs.writeFile(paths.resolveTargetRoot(opts.outputFile), errorOutput);
|
||||
await fs.writeFile(targetPaths.resolveRoot(opts.outputFile), errorOutput);
|
||||
}
|
||||
|
||||
if (cacheContext) {
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export default async function clean() {
|
||||
await fs.remove(paths.resolveTarget('dist'));
|
||||
await fs.remove(paths.resolveTarget('dist-types'));
|
||||
await fs.remove(paths.resolveTarget('coverage'));
|
||||
await fs.remove(targetPaths.resolve('dist'));
|
||||
await fs.remove(targetPaths.resolve('dist-types'));
|
||||
await fs.remove(targetPaths.resolve('coverage'));
|
||||
}
|
||||
|
||||
@@ -18,23 +18,24 @@ import {
|
||||
productionPack,
|
||||
revertProductionPack,
|
||||
} from '../../../../modules/build/lib/packager/productionPack';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { publishPreflightCheck } from '../../lib/publishing';
|
||||
import { createTypeDistProject } from '../../../../lib/typeDistProject';
|
||||
|
||||
export const pre = async () => {
|
||||
publishPreflightCheck({
|
||||
dir: paths.targetDir,
|
||||
packageJson: await fs.readJson(paths.resolveTarget('package.json')),
|
||||
dir: targetPaths.dir,
|
||||
packageJson: await fs.readJson(targetPaths.resolve('package.json')),
|
||||
});
|
||||
|
||||
await productionPack({
|
||||
packageDir: paths.targetDir,
|
||||
packageDir: targetPaths.dir,
|
||||
featureDetectionProject: await createTypeDistProject(),
|
||||
});
|
||||
};
|
||||
|
||||
export const post = async () => {
|
||||
await revertProductionPack(paths.targetDir);
|
||||
await revertProductionPack(targetPaths.dir);
|
||||
};
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { run } from '@backstage/cli-common';
|
||||
|
||||
import { run, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export async function command(): Promise<void> {
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
await fs.remove(paths.resolveTargetRoot('dist'));
|
||||
await fs.remove(paths.resolveTargetRoot('dist-types'));
|
||||
await fs.remove(paths.resolveTargetRoot('coverage'));
|
||||
await fs.remove(targetPaths.resolveRoot('dist'));
|
||||
await fs.remove(targetPaths.resolveRoot('dist-types'));
|
||||
await fs.remove(targetPaths.resolveRoot('coverage'));
|
||||
|
||||
await Promise.all(
|
||||
Array.from(Array(10), async () => {
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
relative as relativePath,
|
||||
extname,
|
||||
} from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { publishPreflightCheck } from '../../lib/publishing';
|
||||
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
|
||||
@@ -49,7 +50,7 @@ export async function readFixablePackages(): Promise<FixablePackage[]> {
|
||||
export function printPackageFixHint(packages: FixablePackage[]) {
|
||||
const changed = packages.filter(pkg => pkg.changed);
|
||||
if (changed.length > 0) {
|
||||
const rootPkg = require(paths.resolveTargetRoot('package.json'));
|
||||
const rootPkg = require(targetPaths.resolveRoot('package.json'));
|
||||
const fixCmd =
|
||||
rootPkg.scripts?.fix === 'backstage-cli repo fix'
|
||||
? 'fix'
|
||||
@@ -216,7 +217,7 @@ export function fixSideEffects(pkg: FixablePackage) {
|
||||
}
|
||||
|
||||
export function createRepositoryFieldFixer() {
|
||||
const rootPkg = require(paths.resolveTargetRoot('package.json'));
|
||||
const rootPkg = require(targetPaths.resolveRoot('package.json'));
|
||||
const rootRepoField = rootPkg.repository;
|
||||
if (!rootRepoField) {
|
||||
return () => {};
|
||||
@@ -229,7 +230,7 @@ export function createRepositoryFieldFixer() {
|
||||
return (pkg: FixablePackage) => {
|
||||
const expectedPath = posix.join(
|
||||
rootDir,
|
||||
relativePath(paths.targetRoot, pkg.dir),
|
||||
relativePath(targetPaths.rootDir, pkg.dir),
|
||||
);
|
||||
const repoField = pkg.packageJson.repository;
|
||||
|
||||
@@ -318,7 +319,7 @@ export function fixPluginId(pkg: FixablePackage) {
|
||||
role === 'backend-plugin-module')
|
||||
) {
|
||||
const path = relativePath(
|
||||
paths.targetRoot,
|
||||
targetPaths.rootDir,
|
||||
resolvePath(pkg.dir, 'package.json'),
|
||||
);
|
||||
const msg = `Failed to guess plugin ID for "${pkg.packageJson.name}", please set the 'backstage.pluginId' field manually in "${path}"`;
|
||||
@@ -414,7 +415,7 @@ export function fixPluginPackages(
|
||||
return;
|
||||
}
|
||||
const path = relativePath(
|
||||
paths.targetRoot,
|
||||
targetPaths.rootDir,
|
||||
resolvePath(pkg.dir, 'package.json'),
|
||||
);
|
||||
const suggestedRole =
|
||||
@@ -463,7 +464,7 @@ export function fixPeerModules(pkg: FixablePackage) {
|
||||
}
|
||||
|
||||
const packagePath = relativePath(
|
||||
paths.targetRoot,
|
||||
targetPaths.rootDir,
|
||||
resolvePath(pkg.dir, 'package.json'),
|
||||
);
|
||||
|
||||
|
||||
@@ -19,20 +19,20 @@ import { ESLint } from 'eslint';
|
||||
import { OptionValues } from 'commander';
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export async function command(opts: OptionValues) {
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
const eslint = new ESLint({
|
||||
cwd: paths.targetDir,
|
||||
cwd: targetPaths.dir,
|
||||
overrideConfig: {
|
||||
plugins: ['deprecation'],
|
||||
rules: {
|
||||
'deprecation/deprecation': 'error',
|
||||
},
|
||||
parserOptions: {
|
||||
project: [paths.resolveTargetRoot('tsconfig.json')],
|
||||
project: [targetPaths.resolveRoot('tsconfig.json')],
|
||||
},
|
||||
},
|
||||
extensions: ['jsx', 'ts', 'tsx', 'mjs', 'cjs'],
|
||||
@@ -52,7 +52,7 @@ export async function command(opts: OptionValues) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = relativePath(paths.targetRoot, result.filePath);
|
||||
const path = relativePath(targetPaths.rootDir, result.filePath);
|
||||
deprecations.push({
|
||||
path,
|
||||
message: message.message,
|
||||
|
||||
@@ -18,10 +18,10 @@ 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 { paths } from '../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export default async () => {
|
||||
const { packages } = await getPackages(paths.targetDir);
|
||||
const { packages } = await getPackages(targetPaths.dir);
|
||||
|
||||
await Promise.all(
|
||||
packages.map(async ({ dir, packageJson: pkg }) => {
|
||||
|
||||
@@ -16,16 +16,14 @@
|
||||
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 { registerMswTestHooks, withLogCollector } from '@backstage/test-utils';
|
||||
import { YarnInfoInspectData } from '../../../../lib/versioning/packages';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { rest } from 'msw';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
createMockDirectory,
|
||||
MockDirectory,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
// Avoid mutating the global agents used in other tests
|
||||
jest.mock('global-agent', () => ({
|
||||
@@ -59,19 +57,10 @@ jest.mock('ora', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
let mockDir: MockDirectory;
|
||||
jest.mock('@backstage/cli-common', () => {
|
||||
const actual = jest.requireActual('@backstage/cli-common');
|
||||
return {
|
||||
...actual,
|
||||
findPaths: () => ({
|
||||
resolveTargetRoot(filename: string) {
|
||||
return mockDir.resolve(filename);
|
||||
},
|
||||
get targetDir() {
|
||||
return mockDir.path;
|
||||
},
|
||||
}),
|
||||
run: jest.fn().mockReturnValue({
|
||||
exitCode: null,
|
||||
waitForExit: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -138,9 +127,10 @@ const expectLogsToMatch = (
|
||||
};
|
||||
|
||||
describe('bump', () => {
|
||||
mockDir = createMockDirectory();
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
overrideTargetPaths(mockDir.path);
|
||||
mockFetchPackageInfo.mockImplementation(async name => ({
|
||||
name: name,
|
||||
'dist-tags': {
|
||||
@@ -928,7 +918,11 @@ describe('bump', () => {
|
||||
});
|
||||
|
||||
describe('bumpBackstageJsonVersion', () => {
|
||||
mockDir = createMockDirectory();
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
overrideTargetPaths(mockDir.path);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
@@ -1062,9 +1056,15 @@ describe('createVersionFinder', () => {
|
||||
});
|
||||
|
||||
describe('environment variables', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
const worker = setupServer();
|
||||
registerMswTestHooks(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
overrideTargetPaths(mockDir.path);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.BACKSTAGE_MANIFEST_FILE;
|
||||
process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com';
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { BACKSTAGE_JSON, bootstrapEnvProxyAgents } from '@backstage/cli-common';
|
||||
import {
|
||||
BACKSTAGE_JSON,
|
||||
bootstrapEnvProxyAgents,
|
||||
targetPaths,
|
||||
} from '@backstage/cli-common';
|
||||
|
||||
bootstrapEnvProxyAgents();
|
||||
|
||||
@@ -25,7 +29,7 @@ import semver from 'semver';
|
||||
import { OptionValues } from 'commander';
|
||||
import { isError, NotFoundError } from '@backstage/errors';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
import {
|
||||
fetchPackageInfo,
|
||||
@@ -33,7 +37,7 @@ import {
|
||||
mapDependencies,
|
||||
YarnInfoInspectData,
|
||||
} from '../../../../lib/versioning';
|
||||
import { runParallelWorkers } from '../../../../lib/parallel';
|
||||
import { runConcurrentTasks } from '@backstage/cli-node';
|
||||
import {
|
||||
getManifestByReleaseLine,
|
||||
getManifestByVersion,
|
||||
@@ -68,7 +72,7 @@ function extendsDefaultPattern(pattern: string): boolean {
|
||||
}
|
||||
|
||||
export default async (opts: OptionValues) => {
|
||||
const lockfilePath = paths.resolveTargetRoot('yarn.lock');
|
||||
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
|
||||
const lockfile = await Lockfile.load(lockfilePath);
|
||||
const hasYarnPlugin = await getHasYarnPlugin();
|
||||
|
||||
@@ -140,13 +144,13 @@ export default async (opts: OptionValues) => {
|
||||
}
|
||||
|
||||
// First we discover all Backstage dependencies within our own repo
|
||||
const dependencyMap = await mapDependencies(paths.targetDir, pattern);
|
||||
const dependencyMap = await mapDependencies(targetPaths.dir, pattern);
|
||||
|
||||
// Next check with the package registry to see which dependency ranges we need to bump
|
||||
const versionBumps = new Map<string, PkgVersionInfo[]>();
|
||||
|
||||
await runParallelWorkers({
|
||||
parallelismFactor: 4,
|
||||
await runConcurrentTasks({
|
||||
concurrencyFactor: 4,
|
||||
items: dependencyMap.entries(),
|
||||
async worker([name, pkgs]) {
|
||||
let target: string;
|
||||
@@ -182,8 +186,8 @@ export default async (opts: OptionValues) => {
|
||||
console.log();
|
||||
|
||||
const breakingUpdates = new Map<string, { from: string; to: string }>();
|
||||
await runParallelWorkers({
|
||||
parallelismFactor: 4,
|
||||
await runConcurrentTasks({
|
||||
concurrencyFactor: 4,
|
||||
items: versionBumps.entries(),
|
||||
async worker([name, deps]) {
|
||||
const pkgPath = resolvePath(deps[0].location, 'package.json');
|
||||
@@ -417,7 +421,7 @@ export function createVersionFinder(options: {
|
||||
}
|
||||
|
||||
function getBackstageJsonPath() {
|
||||
return paths.resolveTargetRoot(BACKSTAGE_JSON);
|
||||
return targetPaths.resolveRoot(BACKSTAGE_JSON);
|
||||
}
|
||||
|
||||
async function getBackstageJson() {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
createMockDirectory,
|
||||
} from '@backstage/backend-test-utils';
|
||||
import * as runObj from '@backstage/cli-common';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import migrate from './migrate';
|
||||
import { withLogCollector } from '@backstage/test-utils';
|
||||
import fs from 'fs-extra';
|
||||
@@ -38,9 +39,7 @@ jest.mock('@backstage/cli-common', () => {
|
||||
return {
|
||||
...actual,
|
||||
findPaths: () => ({
|
||||
resolveTargetRoot(filename: string) {
|
||||
return mockDir.resolve(filename);
|
||||
},
|
||||
resolveTargetRoot: (...args: string[]) => mockDir.resolve(...args),
|
||||
get targetDir() {
|
||||
return mockDir.path;
|
||||
},
|
||||
@@ -58,6 +57,7 @@ function expectLogsToMatch(receivedLogs: String[], expected: String[]): void {
|
||||
|
||||
describe('versions:migrate', () => {
|
||||
mockDir = createMockDirectory();
|
||||
beforeAll(() => overrideTargetPaths(mockDir.path));
|
||||
|
||||
beforeEach(() => {
|
||||
(runObj.run as jest.Mock).mockReturnValue({
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
|
||||
const USER_ID_RE = /^@[-\w]+$/;
|
||||
@@ -82,7 +82,7 @@ export async function addCodeownersEntry(
|
||||
|
||||
let filePath = codeownersFilePath;
|
||||
if (!filePath) {
|
||||
filePath = await getCodeownersFilePath(paths.targetRoot);
|
||||
filePath = await getCodeownersFilePath(targetPaths.rootDir);
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import upperCase from 'lodash/upperCase';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import lowerFirst from 'lodash/lowerFirst';
|
||||
import { Lockfile } from '../../../../lib/versioning';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { createPackageVersionProvider } from '../../../../lib/version';
|
||||
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
|
||||
|
||||
@@ -49,7 +50,7 @@ export class PortableTemplater {
|
||||
static async create(options: CreatePortableTemplaterOptions = {}) {
|
||||
let lockfile: Lockfile | undefined;
|
||||
try {
|
||||
lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock'));
|
||||
lockfile = await Lockfile.load(targetPaths.resolveRoot('yarn.lock'));
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
import fs from 'fs-extra';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { Task } from '../tasks';
|
||||
import { PortableTemplateInput } from '../types';
|
||||
|
||||
@@ -52,7 +53,7 @@ export async function installNewPackage(input: PortableTemplateInput) {
|
||||
}
|
||||
|
||||
async function addDependency(input: PortableTemplateInput, path: string) {
|
||||
const pkgJsonPath = paths.resolveTargetRoot(path);
|
||||
const pkgJsonPath = targetPaths.resolveRoot(path);
|
||||
|
||||
const pkgJson = await fs.readJson(pkgJsonPath).catch(error => {
|
||||
if (error.code === 'ENOENT') {
|
||||
@@ -84,7 +85,7 @@ async function tryAddFrontendLegacy(input: PortableTemplateInput) {
|
||||
);
|
||||
}
|
||||
|
||||
const appDefinitionPath = paths.resolveTargetRoot('packages/app/src/App.tsx');
|
||||
const appDefinitionPath = targetPaths.resolveRoot('packages/app/src/App.tsx');
|
||||
if (!(await fs.pathExists(appDefinitionPath))) {
|
||||
return;
|
||||
}
|
||||
@@ -120,7 +121,7 @@ async function tryAddFrontendLegacy(input: PortableTemplateInput) {
|
||||
}
|
||||
|
||||
async function tryAddBackend(input: PortableTemplateInput) {
|
||||
const backendIndexPath = paths.resolveTargetRoot(
|
||||
const backendIndexPath = targetPaths.resolveRoot(
|
||||
'packages/backend/src/index.ts',
|
||||
);
|
||||
if (!(await fs.pathExists(backendIndexPath))) {
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { writeTemplateContents } from './writeTemplateContents';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
const baseConfig = {
|
||||
version: '0.1.0',
|
||||
@@ -26,14 +29,14 @@ const baseConfig = {
|
||||
};
|
||||
|
||||
describe('writeTemplateContents', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: { packages: ['packages/*', 'plugins/*'] },
|
||||
}),
|
||||
});
|
||||
jest.resetAllMocks();
|
||||
jest
|
||||
.spyOn(paths, 'resolveTargetRoot')
|
||||
.mockImplementation((...args) => mockDir.resolve(...args));
|
||||
});
|
||||
|
||||
it('should write an empty template', async () => {
|
||||
@@ -53,7 +56,11 @@ describe('writeTemplateContents', () => {
|
||||
);
|
||||
|
||||
expect(relativePath(mockDir.path, targetDir)).toBe('plugins/plugin-test');
|
||||
expect(mockDir.content()).toEqual({});
|
||||
expect(mockDir.content()).toEqual({
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: { packages: ['packages/*', 'plugins/*'] },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should write template with various files', async () => {
|
||||
@@ -87,6 +94,9 @@ describe('writeTemplateContents', () => {
|
||||
);
|
||||
|
||||
expect(mockDir.content()).toEqual({
|
||||
'package.json': JSON.stringify({
|
||||
workspaces: { packages: ['packages/*', 'plugins/*'] },
|
||||
}),
|
||||
out: {
|
||||
'test.txt': 'test',
|
||||
'plugin.txt': 'id=test',
|
||||
|
||||
@@ -17,18 +17,17 @@
|
||||
import fs from 'fs-extra';
|
||||
import { dirname, resolve as resolvePath } from 'node:path';
|
||||
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { PortableTemplate, PortableTemplateInput } from '../types';
|
||||
import { ForwardedError, InputError } from '@backstage/errors';
|
||||
import { isMonoRepo as getIsMonoRepo } from '@backstage/cli-node';
|
||||
import { PortableTemplater } from './PortableTemplater';
|
||||
import { isChildPath } from '@backstage/cli-common';
|
||||
import { isChildPath, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export async function writeTemplateContents(
|
||||
template: PortableTemplate,
|
||||
input: PortableTemplateInput,
|
||||
): Promise<{ targetDir: string }> {
|
||||
const targetDir = paths.resolveTargetRoot(input.packagePath);
|
||||
const targetDir = targetPaths.resolveRoot(input.packagePath);
|
||||
|
||||
if (await fs.pathExists(targetDir)) {
|
||||
throw new InputError(`Package '${input.packagePath}' already exists`);
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import inquirer, { DistinctQuestion } from 'inquirer';
|
||||
import { getCodeownersFilePath, parseOwnerIds } from '../codeowners';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
PortableTemplateConfig,
|
||||
PortableTemplateInput,
|
||||
@@ -38,7 +39,7 @@ export async function collectPortableTemplateInput(
|
||||
): Promise<PortableTemplateInput> {
|
||||
const { config, template, prefilledParams } = options;
|
||||
|
||||
const codeOwnersFilePath = await getCodeownersFilePath(paths.targetRoot);
|
||||
const codeOwnersFilePath = await getCodeownersFilePath(targetPaths.rootDir);
|
||||
|
||||
const prompts = getPromptsForRole(template.role);
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ import recursiveReaddir from 'recursive-readdir';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'node:path';
|
||||
import { dirname } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
PortableTemplateFile,
|
||||
PortableTemplatePointer,
|
||||
@@ -46,7 +47,7 @@ export async function loadPortableTemplate(
|
||||
throw new Error('Remote templates are not supported yet');
|
||||
}
|
||||
const templateContent = await fs
|
||||
.readFile(paths.resolveTargetRoot(pointer.target), 'utf-8')
|
||||
.readFile(targetPaths.resolveRoot(pointer.target), 'utf-8')
|
||||
.catch(error => {
|
||||
throw new ForwardedError(
|
||||
`Failed to load template definition from '${pointer.target}'`,
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath, dirname, isAbsolute } from 'node:path';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { defaultTemplates } from '../defaultTemplates';
|
||||
import {
|
||||
PortableTemplateConfig,
|
||||
@@ -90,7 +91,7 @@ export async function loadPortableTemplateConfig(
|
||||
): Promise<PortableTemplateConfig> {
|
||||
const { overrides = {} } = options;
|
||||
const pkgPath =
|
||||
options.packagePath ?? paths.resolveTargetRoot('package.json');
|
||||
options.packagePath ?? targetPaths.resolveRoot('package.json');
|
||||
const pkgJson = await fs.readJson(pkgPath);
|
||||
|
||||
const parsed = pkgJsonWithNewConfigSchema.safeParse(pkgJson);
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
*/
|
||||
|
||||
import { Command, OptionValues } from 'commander';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { runCheck } from '@backstage/cli-common';
|
||||
|
||||
import { runCheck, findOwnPaths } from '@backstage/cli-common';
|
||||
|
||||
function includesAnyOf(hayStack: string[], ...needles: string[]) {
|
||||
for (const needle of needles) {
|
||||
@@ -38,7 +38,8 @@ export default async (_opts: OptionValues, cmd: Command) => {
|
||||
|
||||
// Only include our config if caller isn't passing their own config
|
||||
if (!includesAnyOf(args, '-c', '--config')) {
|
||||
args.push('--config', paths.resolveOwn('config/jest.js'));
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
args.push('--config', findOwnPaths(__dirname).resolve('config/jest.js'));
|
||||
}
|
||||
|
||||
if (!includesAnyOf(args, '--no-passWithNoTests', '--passWithNoTests=false')) {
|
||||
|
||||
@@ -23,9 +23,14 @@ 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 } from '@backstage/cli-node';
|
||||
import { paths } from '../../../../lib/paths';
|
||||
import { runCheck, runOutput } from '@backstage/cli-common';
|
||||
import { isChildPath } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
runCheck,
|
||||
runOutput,
|
||||
targetPaths,
|
||||
findOwnPaths,
|
||||
isChildPath,
|
||||
} from '@backstage/cli-common';
|
||||
import { SuccessCache } from '../../../../lib/cache/SuccessCache';
|
||||
|
||||
type JestProject = {
|
||||
@@ -63,7 +68,7 @@ interface TestGlobal extends Global {
|
||||
async function readPackageTreeHashes(graph: PackageGraph) {
|
||||
const pkgs = Array.from(graph.values()).map(pkg => ({
|
||||
...pkg,
|
||||
path: relativePath(paths.targetRoot, pkg.dir),
|
||||
path: relativePath(targetPaths.rootDir, pkg.dir),
|
||||
}));
|
||||
const output = await runOutput([
|
||||
'git',
|
||||
@@ -162,7 +167,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
|
||||
// Only include our config if caller isn't passing their own config
|
||||
if (!hasFlags('-c', '--config')) {
|
||||
args.push('--config', paths.resolveOwn('config/jest.js'));
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
args.push('--config', findOwnPaths(__dirname).resolve('config/jest.js'));
|
||||
}
|
||||
|
||||
if (!hasFlags('--passWithNoTests')) {
|
||||
@@ -341,7 +347,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
async filterConfigs(projectConfigs, globalRootConfig) {
|
||||
const cacheEntries = await cache.read();
|
||||
const lockfile = await Lockfile.load(
|
||||
paths.resolveTargetRoot('yarn.lock'),
|
||||
targetPaths.resolveRoot('yarn.lock'),
|
||||
);
|
||||
const getPackageTreeHash = await readPackageTreeHashes(graph);
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 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 { targetPaths } from '@backstage/cli-common';
|
||||
import fs from 'fs-extra';
|
||||
import { dirname, resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
discoverFrontendPackages,
|
||||
readTargetPackage,
|
||||
} from '../lib/discoverPackages';
|
||||
import {
|
||||
createTranslationProject,
|
||||
extractTranslationRefsFromSourceFile,
|
||||
TranslationRefInfo,
|
||||
} from '../lib/extractTranslations';
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
formatMessagePath,
|
||||
validatePattern,
|
||||
} from '../lib/messageFilePath';
|
||||
|
||||
interface ExportOptions {
|
||||
output: string;
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
export default async (options: ExportOptions) => {
|
||||
validatePattern(options.pattern);
|
||||
|
||||
const targetPackageJson = await readTargetPackage(
|
||||
targetPaths.dir,
|
||||
targetPaths.rootDir,
|
||||
);
|
||||
|
||||
const outputDir = resolvePath(targetPaths.dir, options.output);
|
||||
const manifestPath = resolvePath(outputDir, 'manifest.json');
|
||||
|
||||
const tsconfigPath = targetPaths.resolveRoot('tsconfig.json');
|
||||
if (!(await fs.pathExists(tsconfigPath))) {
|
||||
throw new Error(
|
||||
`No tsconfig.json found at ${tsconfigPath}. ` +
|
||||
'The translations export command requires a tsconfig.json in the repo root.',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Discovering frontend dependencies of ${targetPackageJson.name}...`,
|
||||
);
|
||||
const packages = await discoverFrontendPackages(
|
||||
targetPackageJson,
|
||||
targetPaths.dir,
|
||||
);
|
||||
console.log(`Found ${packages.length} frontend packages to scan`);
|
||||
|
||||
console.log('Creating TypeScript project...');
|
||||
const project = createTranslationProject(tsconfigPath);
|
||||
|
||||
const allRefs: TranslationRefInfo[] = [];
|
||||
|
||||
for (const pkg of packages) {
|
||||
for (const [exportPath, filePath] of pkg.entryPoints) {
|
||||
try {
|
||||
const sourceFile = project.addSourceFileAtPath(filePath);
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
pkg.name,
|
||||
exportPath,
|
||||
);
|
||||
allRefs.push(...refs);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
` Warning: failed to process ${pkg.name} (${exportPath}): ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allRefs.length === 0) {
|
||||
console.log('No translation refs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${allRefs.length} translation ref(s):`);
|
||||
for (const ref of allRefs) {
|
||||
const messageCount = Object.keys(ref.messages).length;
|
||||
console.log(` ${ref.id} (${ref.packageName}, ${messageCount} messages)`);
|
||||
}
|
||||
|
||||
// Write message files using the configured pattern
|
||||
for (const ref of allRefs) {
|
||||
const relPath = formatMessagePath(
|
||||
options.pattern,
|
||||
ref.id,
|
||||
DEFAULT_LANGUAGE,
|
||||
);
|
||||
const filePath = resolvePath(outputDir, relPath);
|
||||
await fs.ensureDir(dirname(filePath));
|
||||
await fs.writeJson(filePath, ref.messages, { spaces: 2 });
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
const manifest: Record<string, object> = {};
|
||||
for (const ref of allRefs) {
|
||||
manifest[ref.id] = {
|
||||
package: ref.packageName,
|
||||
exportPath: ref.exportPath,
|
||||
exportName: ref.exportName,
|
||||
};
|
||||
}
|
||||
await fs.writeJson(
|
||||
manifestPath,
|
||||
{ pattern: options.pattern, refs: manifest },
|
||||
{ spaces: 2 },
|
||||
);
|
||||
|
||||
const examplePath = formatMessagePath(
|
||||
options.pattern,
|
||||
'<ref-id>',
|
||||
DEFAULT_LANGUAGE,
|
||||
);
|
||||
console.log(
|
||||
`\nExported ${allRefs.length} translation ref(s) to ${options.output}/`,
|
||||
);
|
||||
console.log(` Messages: ${options.output}/${examplePath}`);
|
||||
console.log(` Manifest: ${options.output}/manifest.json`);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2026 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 { targetPaths } from '@backstage/cli-common';
|
||||
import fs from 'fs-extra';
|
||||
import {
|
||||
resolve as resolvePath,
|
||||
relative as relativePath,
|
||||
sep,
|
||||
} from 'node:path';
|
||||
import { readTargetPackage } from '../lib/discoverPackages';
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
createMessagePathParser,
|
||||
formatMessagePath,
|
||||
} from '../lib/messageFilePath';
|
||||
|
||||
interface ImportOptions {
|
||||
input: string;
|
||||
output: string;
|
||||
}
|
||||
|
||||
interface ManifestRefEntry {
|
||||
package: string;
|
||||
exportPath: string;
|
||||
exportName: string;
|
||||
}
|
||||
|
||||
interface Manifest {
|
||||
pattern?: string;
|
||||
refs: Record<string, ManifestRefEntry>;
|
||||
}
|
||||
|
||||
export default async (options: ImportOptions) => {
|
||||
await readTargetPackage(targetPaths.dir, targetPaths.rootDir);
|
||||
|
||||
const inputDir = resolvePath(targetPaths.dir, options.input);
|
||||
const manifestPath = resolvePath(inputDir, 'manifest.json');
|
||||
const outputPath = resolvePath(targetPaths.dir, options.output);
|
||||
|
||||
if (!(await fs.pathExists(manifestPath))) {
|
||||
throw new Error(
|
||||
`No manifest.json found at ${manifestPath}. ` +
|
||||
'Run "backstage-cli translations export" first.',
|
||||
);
|
||||
}
|
||||
|
||||
const manifest: Manifest = await fs.readJson(manifestPath);
|
||||
|
||||
if (!manifest.pattern) {
|
||||
throw new Error(
|
||||
'No pattern found in manifest.json. Re-run "backstage-cli translations export" to regenerate it.',
|
||||
);
|
||||
}
|
||||
const pattern = manifest.pattern;
|
||||
|
||||
const parsePath = createMessagePathParser(pattern);
|
||||
|
||||
// Discover all JSON files under the translations directory
|
||||
const allFiles = (await collectJsonFiles(inputDir)).filter(
|
||||
f => f !== 'manifest.json',
|
||||
);
|
||||
|
||||
// Parse each file to extract id + lang, filtering out default language files
|
||||
const translationsByRef = new Map<
|
||||
string,
|
||||
Array<{ lang: string; relPath: string }>
|
||||
>();
|
||||
let skipped = 0;
|
||||
|
||||
for (const relPath of allFiles) {
|
||||
const parsed = parsePath(relPath);
|
||||
if (!parsed) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.lang === DEFAULT_LANGUAGE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!manifest.refs[parsed.id]) {
|
||||
console.warn(
|
||||
` Warning: skipping ${relPath} - ref '${parsed.id}' not found in manifest`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = translationsByRef.get(parsed.id) ?? [];
|
||||
existing.push({ lang: parsed.lang, relPath });
|
||||
translationsByRef.set(parsed.id, existing);
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
console.warn(
|
||||
` Warning: ${skipped} file(s) did not match the pattern '${pattern}'`,
|
||||
);
|
||||
}
|
||||
|
||||
if (translationsByRef.size === 0) {
|
||||
console.log('No translated message files found.');
|
||||
const example = formatMessagePath(pattern, '<ref-id>', 'sv');
|
||||
console.log(
|
||||
`Add translated files as ${example} in the translations directory.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate the wiring module
|
||||
const importLines: string[] = [];
|
||||
const resourceLines: string[] = [];
|
||||
|
||||
importLines.push(
|
||||
"import { createTranslationResource } from '@backstage/frontend-plugin-api';",
|
||||
);
|
||||
|
||||
for (const [refId, entries] of [...translationsByRef.entries()].sort(
|
||||
([a], [b]) => a.localeCompare(b),
|
||||
)) {
|
||||
const refEntry = manifest.refs[refId];
|
||||
const importPath =
|
||||
refEntry.exportPath === '.'
|
||||
? refEntry.package
|
||||
: `${refEntry.package}/${refEntry.exportPath.replace(/^\.\//, '')}`;
|
||||
|
||||
importLines.push(`import { ${refEntry.exportName} } from '${importPath}';`);
|
||||
|
||||
const translationEntries = entries
|
||||
.sort((a, b) => a.lang.localeCompare(b.lang))
|
||||
.map(({ lang, relPath }) => {
|
||||
const jsonRelPath = relativePath(
|
||||
resolvePath(outputPath, '..'),
|
||||
resolvePath(inputDir, relPath),
|
||||
)
|
||||
.split(sep)
|
||||
.join('/');
|
||||
return ` ${JSON.stringify(lang)}: () => import('./${jsonRelPath}'),`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
resourceLines.push(
|
||||
[
|
||||
` createTranslationResource({`,
|
||||
` ref: ${refEntry.exportName},`,
|
||||
` translations: {`,
|
||||
translationEntries,
|
||||
` },`,
|
||||
` }),`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const fileContent = [
|
||||
'// This file is auto-generated by backstage-cli translations import',
|
||||
'// Do not edit manually.',
|
||||
'',
|
||||
...importLines,
|
||||
'',
|
||||
'export default [',
|
||||
...resourceLines,
|
||||
'];',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await fs.ensureDir(resolvePath(outputPath, '..'));
|
||||
await fs.writeFile(outputPath, fileContent, 'utf8');
|
||||
|
||||
const totalFiles = [...translationsByRef.values()].reduce(
|
||||
(sum, e) => sum + e.length,
|
||||
0,
|
||||
);
|
||||
console.log(`Generated translation resources at ${options.output}`);
|
||||
console.log(
|
||||
` ${translationsByRef.size} ref(s), ${totalFiles} translation file(s)`,
|
||||
);
|
||||
console.log(
|
||||
'\nImport this file in your app and pass the resources to your translation API setup.',
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively collects all .json files under a directory, returning paths
|
||||
* relative to that directory using forward slashes.
|
||||
*/
|
||||
async function collectJsonFiles(dir: string, prefix = ''): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
results.push(
|
||||
...(await collectJsonFiles(resolvePath(dir, entry.name), relPath)),
|
||||
);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
results.push(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import yargs from 'yargs';
|
||||
import { createCliPlugin } from '../../wiring/factory';
|
||||
import { lazy } from '../../lib/lazy';
|
||||
import { DEFAULT_MESSAGE_PATTERN } from './lib/messageFilePath';
|
||||
|
||||
export default createCliPlugin({
|
||||
pluginId: 'translations',
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
BackstagePackageJson,
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
} from '@backstage/cli-node';
|
||||
import { dirname, resolve as resolvePath } from 'node:path';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
/** A discovered package with its entry points resolved to file paths. */
|
||||
export interface DiscoveredPackage {
|
||||
/** The package name, e.g. '@backstage/plugin-org' */
|
||||
name: string;
|
||||
/** The directory of the package */
|
||||
dir: string;
|
||||
/** Map of export subpath (e.g. '.', './alpha') to the resolved file path */
|
||||
entryPoints: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the package.json from the given directory and validates that it
|
||||
* is a workspace package (not the repo root).
|
||||
*/
|
||||
export async function readTargetPackage(
|
||||
packageDir: string,
|
||||
repoRoot: string,
|
||||
): Promise<BackstagePackageJson> {
|
||||
const packageJsonPath = resolvePath(packageDir, 'package.json');
|
||||
|
||||
if (!(await fs.pathExists(packageJsonPath))) {
|
||||
throw new Error(
|
||||
'No package.json found in the current directory. ' +
|
||||
'The translations commands must be run from within a package directory.',
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvePath(packageDir) === resolvePath(repoRoot)) {
|
||||
throw new Error(
|
||||
'The translations commands must be run from within a package directory, ' +
|
||||
'not from the repository root. For example: cd packages/app && backstage-cli translations export',
|
||||
);
|
||||
}
|
||||
|
||||
return fs.readJson(packageJsonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers frontend packages that are transitive dependencies of the given
|
||||
* target package and resolves their entry point file paths. Walks both
|
||||
* workspace packages (source) and npm-installed packages (declaration files).
|
||||
*/
|
||||
export async function discoverFrontendPackages(
|
||||
targetPackageJson: BackstagePackageJson,
|
||||
targetDir: string,
|
||||
): Promise<DiscoveredPackage[]> {
|
||||
// Build a lookup of workspace packages for preferring source over dist
|
||||
let workspaceByName: Map<
|
||||
string,
|
||||
{ packageJson: BackstagePackageJson; dir: string }
|
||||
>;
|
||||
try {
|
||||
const workspacePackages = await PackageGraph.listTargetPackages();
|
||||
workspaceByName = new Map(
|
||||
workspacePackages.map(p => [p.packageJson.name, p]),
|
||||
);
|
||||
} catch {
|
||||
workspaceByName = new Map();
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const result: DiscoveredPackage[] = [];
|
||||
|
||||
async function visit(
|
||||
packageJson: BackstagePackageJson,
|
||||
pkgDir: string,
|
||||
includeDevDeps: boolean,
|
||||
) {
|
||||
const deps: Record<string, string> = {
|
||||
...packageJson.dependencies,
|
||||
...(includeDevDeps ? packageJson.devDependencies ?? {} : {}),
|
||||
};
|
||||
|
||||
for (const depName of Object.keys(deps)) {
|
||||
if (visited.has(depName)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(depName);
|
||||
|
||||
let depPkgJson: BackstagePackageJson;
|
||||
let depDir: string;
|
||||
let isWorkspace: boolean;
|
||||
|
||||
// Prefer workspace package (has source files) over npm-installed
|
||||
const workspacePkg = workspaceByName.get(depName);
|
||||
if (workspacePkg) {
|
||||
depPkgJson = workspacePkg.packageJson;
|
||||
depDir = workspacePkg.dir;
|
||||
isWorkspace = true;
|
||||
} else {
|
||||
try {
|
||||
const pkgJsonPath = require.resolve(`${depName}/package.json`, {
|
||||
paths: [pkgDir],
|
||||
});
|
||||
depPkgJson = await fs.readJson(pkgJsonPath);
|
||||
depDir = dirname(pkgJsonPath);
|
||||
isWorkspace = false;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Only recurse into Backstage ecosystem packages
|
||||
if (!depPkgJson.backstage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = depPkgJson.backstage?.role;
|
||||
if (role && isFrontendRole(role)) {
|
||||
const entryPoints = resolveEntryPoints(depPkgJson, depDir, isWorkspace);
|
||||
if (entryPoints.size > 0) {
|
||||
result.push({ name: depName, dir: depDir, entryPoints });
|
||||
}
|
||||
}
|
||||
|
||||
// Walk this package's production dependencies for transitive refs
|
||||
await visit(depPkgJson, depDir, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Start from the target, including its devDependencies
|
||||
await visit(targetPackageJson, targetDir, true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the entry points of a package to absolute file paths.
|
||||
* For workspace packages, prefers source entry points (import/default).
|
||||
* For npm packages, prefers type declaration entry points (.d.ts).
|
||||
*/
|
||||
function resolveEntryPoints(
|
||||
packageJson: BackstagePackageJson,
|
||||
packageDir: string,
|
||||
isWorkspace: boolean,
|
||||
): Map<string, string> {
|
||||
const entryPoints = new Map<string, string>();
|
||||
|
||||
const exports = (packageJson as any).exports as
|
||||
| Record<string, string | Record<string, string>>
|
||||
| undefined;
|
||||
|
||||
if (exports) {
|
||||
for (const [subpath, target] of Object.entries(exports)) {
|
||||
if (subpath === './package.json') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filePath: string | undefined;
|
||||
if (typeof target === 'string') {
|
||||
filePath = target;
|
||||
} else if (isWorkspace) {
|
||||
// Workspace: exports point to source .ts files
|
||||
filePath = target?.import ?? target?.types ?? target?.default;
|
||||
} else {
|
||||
// npm: prefer .d.ts for type-based extraction
|
||||
filePath = target?.types ?? target?.import ?? target?.default;
|
||||
}
|
||||
|
||||
if (typeof filePath === 'string') {
|
||||
entryPoints.set(subpath, resolvePath(packageDir, filePath));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: prefer types for npm, source for workspace
|
||||
const main = isWorkspace
|
||||
? packageJson.main ?? packageJson.types
|
||||
: packageJson.types ?? packageJson.main;
|
||||
if (main) {
|
||||
entryPoints.set('.', resolvePath(packageDir, main));
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints;
|
||||
}
|
||||
|
||||
function isFrontendRole(role: string): boolean {
|
||||
try {
|
||||
return PackageRoles.getRoleInfo(role).platform === 'web';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2026 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 { resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
createTranslationProject,
|
||||
extractTranslationRefsFromSourceFile,
|
||||
} from './extractTranslations';
|
||||
|
||||
describe('extractTranslations', () => {
|
||||
it('extracts translation refs from the org plugin', () => {
|
||||
const project = createTranslationProject(
|
||||
resolvePath(__dirname, '../../../../../../tsconfig.json'),
|
||||
);
|
||||
|
||||
const sourceFile = project.addSourceFileAtPath(
|
||||
resolvePath(__dirname, '../../../../../..', 'plugins/org/src/alpha.tsx'),
|
||||
);
|
||||
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
'@backstage/plugin-org',
|
||||
'./alpha',
|
||||
);
|
||||
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0]).toMatchObject({
|
||||
id: 'org',
|
||||
packageName: '@backstage/plugin-org',
|
||||
exportPath: './alpha',
|
||||
exportName: 'orgTranslationRef',
|
||||
});
|
||||
|
||||
expect(refs[0].messages).toBeDefined();
|
||||
expect(Object.keys(refs[0].messages)).not.toHaveLength(0);
|
||||
|
||||
// Verify some well-known keys exist without pinning exact wording
|
||||
expect(refs[0].messages).toHaveProperty(['groupProfileCard.groupNotFound']);
|
||||
expect(refs[0].messages).toHaveProperty(['membersListCard.title']);
|
||||
|
||||
// Verify interpolation placeholders are preserved
|
||||
expect(refs[0].messages['membersListCard.subtitle']).toContain(
|
||||
'{{groupName}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores non-TranslationRef exports', () => {
|
||||
const project = createTranslationProject(
|
||||
resolvePath(__dirname, '../../../../../../tsconfig.json'),
|
||||
);
|
||||
|
||||
// The main entry of org plugin exports components but no translation ref
|
||||
const sourceFile = project.addSourceFileAtPath(
|
||||
resolvePath(__dirname, '../../../../../..', 'plugins/org/src/index.ts'),
|
||||
);
|
||||
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
'@backstage/plugin-org',
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(refs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('extracts from the test fixtures translation ref', () => {
|
||||
const project = createTranslationProject(
|
||||
resolvePath(__dirname, '../../../../../../tsconfig.json'),
|
||||
);
|
||||
|
||||
const sourceFile = project.addSourceFileAtPath(
|
||||
resolvePath(
|
||||
__dirname,
|
||||
'../../../../../..',
|
||||
'packages/frontend-plugin-api/src/translation/__fixtures__/refs.ts',
|
||||
),
|
||||
);
|
||||
|
||||
const refs = extractTranslationRefsFromSourceFile(
|
||||
sourceFile,
|
||||
'@backstage/frontend-plugin-api',
|
||||
'.',
|
||||
);
|
||||
|
||||
expect(refs).toHaveLength(2);
|
||||
|
||||
const counting = refs.find(r => r.id === 'counting');
|
||||
expect(counting).toMatchObject({
|
||||
messages: {
|
||||
one: 'one',
|
||||
two: 'two',
|
||||
three: 'three',
|
||||
},
|
||||
});
|
||||
|
||||
const fruits = refs.find(r => r.id === 'fruits');
|
||||
expect(fruits).toMatchObject({
|
||||
messages: {
|
||||
apple: 'apple',
|
||||
orange: 'orange',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 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 { Node, Project, SourceFile, Type, ts } from 'ts-morph';
|
||||
|
||||
/** Information about a discovered translation ref. */
|
||||
export interface TranslationRefInfo {
|
||||
/** The ref ID, e.g. 'org' */
|
||||
id: string;
|
||||
/** The package name, e.g. '@backstage/plugin-org' */
|
||||
packageName: string;
|
||||
/** The subpath export where this ref is accessible, e.g. './alpha' or '.' */
|
||||
exportPath: string;
|
||||
/** The exported symbol name, e.g. 'orgTranslationRef' */
|
||||
exportName: string;
|
||||
/** Flattened message map: key -> default message string */
|
||||
messages: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a ts-morph SourceFile, finds all exported TranslationRef symbols
|
||||
* and extracts their id and messages from the type system.
|
||||
*/
|
||||
export function extractTranslationRefsFromSourceFile(
|
||||
sourceFile: SourceFile,
|
||||
packageName: string,
|
||||
exportPath: string,
|
||||
): TranslationRefInfo[] {
|
||||
const results: TranslationRefInfo[] = [];
|
||||
|
||||
for (const exportSymbol of sourceFile.getExportSymbols()) {
|
||||
const declarations = exportSymbol.getDeclarations();
|
||||
if (declarations.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const declaration = declarations[0];
|
||||
const exportType = declaration.getType();
|
||||
|
||||
const refInfo = extractTranslationRefFromType(exportType, declaration);
|
||||
if (!refInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
...refInfo,
|
||||
packageName,
|
||||
exportPath,
|
||||
exportName: exportSymbol.getName(),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a type is a TranslationRef by inspecting the $$type
|
||||
* property on the target type, then extracts the id and messages from
|
||||
* the type arguments of the generic instantiation.
|
||||
*/
|
||||
function extractTranslationRefFromType(
|
||||
type: Type<ts.Type>,
|
||||
declaration: Node,
|
||||
): Pick<TranslationRefInfo, 'id' | 'messages'> | undefined {
|
||||
// Check the $$type property on the uninstantiated (target) type
|
||||
const resolvedType = type.getTargetType() ?? type;
|
||||
const $$typeProperty = resolvedType
|
||||
.getProperties()
|
||||
.find(p => p.getName() === '$$type');
|
||||
if (!$$typeProperty) {
|
||||
return undefined;
|
||||
}
|
||||
const $$typeDecl = $$typeProperty.getValueDeclaration();
|
||||
if (!$$typeDecl) {
|
||||
return undefined;
|
||||
}
|
||||
if (!$$typeDecl.getText().includes("'@backstage/TranslationRef'")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The type is TranslationRef<TId, TMessages> - extract the type arguments
|
||||
const typeArgs = type.getTypeArguments();
|
||||
if (typeArgs.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [idType, messagesType] = typeArgs;
|
||||
|
||||
if (!idType.isStringLiteral()) {
|
||||
return undefined;
|
||||
}
|
||||
const id = idType.getLiteralValueOrThrow() as string;
|
||||
|
||||
// Extract messages from the TMessages type argument
|
||||
const messages: Record<string, string> = {};
|
||||
for (const messageProp of messagesType.getProperties()) {
|
||||
const key = messageProp.getName();
|
||||
// Resolve the property type in the context of the declaration
|
||||
const propType = messageProp.getTypeAtLocation(declaration);
|
||||
if (propType.isStringLiteral()) {
|
||||
messages[key] = propType.getLiteralValueOrThrow() as string;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(messages).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { id, messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ts-morph Project using the target repo's tsconfig.json.
|
||||
*/
|
||||
export function createTranslationProject(tsconfigPath: string): Project {
|
||||
return new Project({
|
||||
tsConfigFilePath: tsconfigPath,
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 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 {
|
||||
formatMessagePath,
|
||||
createMessagePathParser,
|
||||
messagePatternToGlob,
|
||||
patternHasSubdirectories,
|
||||
DEFAULT_MESSAGE_PATTERN,
|
||||
} from './messageFilePath';
|
||||
|
||||
describe('messageFilePath', () => {
|
||||
describe('formatMessagePath', () => {
|
||||
it('formats the default pattern', () => {
|
||||
expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'org', 'en')).toBe(
|
||||
'messages/org.en.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats with a different language', () => {
|
||||
expect(formatMessagePath(DEFAULT_MESSAGE_PATTERN, 'catalog', 'sv')).toBe(
|
||||
'messages/catalog.sv.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats a language-directory pattern', () => {
|
||||
expect(formatMessagePath('{lang}/{id}.json', 'org', 'sv')).toBe(
|
||||
'sv/org.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats a pattern with lang first in filename', () => {
|
||||
expect(formatMessagePath('{lang}.{id}.json', 'org', 'de')).toBe(
|
||||
'de.org.json',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMessagePathParser', () => {
|
||||
it('parses the default pattern', () => {
|
||||
const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN);
|
||||
expect(parse('messages/org.en.json')).toEqual({ id: 'org', lang: 'en' });
|
||||
});
|
||||
|
||||
it('parses dotted ref IDs in the default pattern', () => {
|
||||
const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN);
|
||||
expect(parse('messages/plugin.notifications.sv.json')).toEqual({
|
||||
id: 'plugin.notifications',
|
||||
lang: 'sv',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a language-directory pattern', () => {
|
||||
const parse = createMessagePathParser('{lang}/{id}.json');
|
||||
expect(parse('sv/org.json')).toEqual({ id: 'org', lang: 'sv' });
|
||||
});
|
||||
|
||||
it('returns undefined for non-matching paths', () => {
|
||||
const parse = createMessagePathParser(DEFAULT_MESSAGE_PATTERN);
|
||||
expect(parse('not-a-match.txt')).toBeUndefined();
|
||||
expect(parse('other/org.en.json')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for invalid language code', () => {
|
||||
const parse = createMessagePathParser('{lang}/{id}.json');
|
||||
expect(parse('123/org.json')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws on pattern missing {id}', () => {
|
||||
expect(() => createMessagePathParser('{lang}.json')).toThrow(
|
||||
'must contain {id}',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on pattern missing {lang}', () => {
|
||||
expect(() => createMessagePathParser('{id}.json')).toThrow(
|
||||
'must contain {lang}',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on pattern not ending with .json', () => {
|
||||
expect(() => createMessagePathParser('{id}.{lang}.yaml')).toThrow(
|
||||
'must end with .json',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messagePatternToGlob', () => {
|
||||
it('converts the default pattern', () => {
|
||||
expect(messagePatternToGlob(DEFAULT_MESSAGE_PATTERN)).toBe(
|
||||
'messages/*.*.json',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts a language-directory pattern', () => {
|
||||
expect(messagePatternToGlob('{lang}/{id}.json')).toBe('*/*.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('patternHasSubdirectories', () => {
|
||||
it('returns true for the default pattern', () => {
|
||||
expect(patternHasSubdirectories(DEFAULT_MESSAGE_PATTERN)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for patterns with directories', () => {
|
||||
expect(patternHasSubdirectories('{lang}/{id}.json')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2026 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.
|
||||
*/
|
||||
|
||||
// The default language for exported translation messages.
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
// Default file path pattern for translation message files relative to the
|
||||
// translations directory. Supported placeholders: {id} and {lang}.
|
||||
export const DEFAULT_MESSAGE_PATTERN = 'messages/{id}.{lang}.json';
|
||||
|
||||
/** Formats a message file pattern into a concrete relative path. */
|
||||
export function formatMessagePath(
|
||||
pattern: string,
|
||||
id: string,
|
||||
lang: string,
|
||||
): string {
|
||||
return pattern.replace(/\{id\}/g, id).replace(/\{lang\}/g, lang);
|
||||
}
|
||||
|
||||
/** Creates a parser that extracts id and lang from a relative file path. */
|
||||
export function createMessagePathParser(
|
||||
pattern: string,
|
||||
): (relativePath: string) => { id: string; lang: string } | undefined {
|
||||
validatePattern(pattern);
|
||||
|
||||
// Build a regex from the pattern by escaping special chars and replacing
|
||||
// {id} and {lang} with named capture groups.
|
||||
const escaped = pattern
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\\{id\\}/g, '(?<id>[^/]+)')
|
||||
.replace(/\\{lang\\}/g, '(?<lang>[a-z]{2})');
|
||||
|
||||
const regex = new RegExp(`^${escaped}$`);
|
||||
|
||||
return (relPath: string) => {
|
||||
const match = relPath.match(regex);
|
||||
if (!match?.groups) {
|
||||
return undefined;
|
||||
}
|
||||
return { id: match.groups.id, lang: match.groups.lang };
|
||||
};
|
||||
}
|
||||
|
||||
/** Converts a message pattern into a glob string for discovering files. */
|
||||
export function messagePatternToGlob(pattern: string): string {
|
||||
return pattern.replace(/\{id\}/g, '*').replace(/\{lang\}/g, '*');
|
||||
}
|
||||
|
||||
/** Returns whether the pattern produces paths with subdirectories. */
|
||||
export function patternHasSubdirectories(pattern: string): boolean {
|
||||
return pattern.includes('/');
|
||||
}
|
||||
|
||||
export function validatePattern(pattern: string) {
|
||||
if (!pattern.includes('{id}')) {
|
||||
throw new Error(
|
||||
`Invalid message file pattern: must contain {id} placeholder. Got: ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (!pattern.includes('{lang}')) {
|
||||
throw new Error(
|
||||
`Invalid message file pattern: must contain {lang} placeholder. Got: ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (!pattern.endsWith('.json')) {
|
||||
throw new Error(
|
||||
`Invalid message file pattern: must end with .json. Got: ${pattern}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user