Merge pull request #26469 from backstage/rugvip/inline
add support for inline packages
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli-node': patch
|
||||
---
|
||||
|
||||
Add definition for the new `backstage.inline` field in `package.json`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/frontend-test-utils': patch
|
||||
---
|
||||
|
||||
Internal refactor
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/cli': patch
|
||||
---
|
||||
|
||||
The build commands now support the new `backstage.inline` flag in `package.json`, which causes the contents of private packages to be inlined into the consuming package, rather than be treated as an external dependency.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/eslint-plugin': patch
|
||||
---
|
||||
|
||||
Added support for linting dependencies on workspace packages with the `backstage.inline` flag.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/repo-tools': patch
|
||||
---
|
||||
|
||||
Avoid generating API reports for packages with `backstage.inline` set.
|
||||
@@ -156,6 +156,26 @@ This field indicates that a package has been renamed and moved to a new location
|
||||
}
|
||||
```
|
||||
|
||||
### `backstage.inline`
|
||||
|
||||
If set to `true` this field indicates that monorepo package is private and should be inlined into dependent packages rather than being treated as a dependency. This effectively means that all imported code from the inlined package will be copied into the consuming package, once for each package.
|
||||
|
||||
This flag affects various parts of the Backstage tooling, for example the way the Backstage CLI builds work, the way that `@backstage/eslint-plugin` lints dependencies on the package, and how it's treated by `@backstage/repo-tools`.
|
||||
|
||||
The `backstage.inline` field is primarily intended to aid in the implementation of the Backstage core framework in the main Backstage repository, but it can be used in other projects as well.
|
||||
|
||||
Setting this flag also requires the top-level `private` field to be set as well, since inline packages should not be published.
|
||||
|
||||
```js title="Example usage of the backstage.moved field"
|
||||
{
|
||||
"name": "@internal/utils",
|
||||
"backstage": {
|
||||
"inline": true
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Metadata for Published Packages
|
||||
|
||||
When publishing a package with the help of the Backstage CLI, there are a number of metadata checks that are performed to ensure that the package is correctly set up for the Backstage ecosystem. These checks are performed by the `backstage-cli package prepack` command, which is used to prepare a package for publishing. These checks can all also be verified separately using the `backstage-cli repo fix --publish` command, and in many cases the required metadata can be generated automatically. It is therefore important to make running the `fix` command part of your workflow in any project that is publishing Backstage packages.
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BackstagePackageJson {
|
||||
backstage?: {
|
||||
role?: PackageRole;
|
||||
moved?: string;
|
||||
inline?: boolean;
|
||||
pluginId?: string | null;
|
||||
pluginPackage?: string;
|
||||
pluginPackages?: string[];
|
||||
|
||||
@@ -47,6 +47,15 @@ export interface BackstagePackageJson {
|
||||
role?: PackageRole;
|
||||
moved?: string;
|
||||
|
||||
/**
|
||||
* If set to `true`, the package will be treated as an internal package
|
||||
* where any imports will be inlined into the consuming package.
|
||||
*
|
||||
* When set to `true`, the top-level `private` field must be set to `true`
|
||||
* as well.
|
||||
*/
|
||||
inline?: boolean;
|
||||
|
||||
/**
|
||||
* The ID of the plugin if this is a plugin package. Must always be set for plugin and module packages, and may be set for library packages. A `null` value means that the package is explicitly not a plugin package.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ import tar, { CreateOptions } from 'tar';
|
||||
import { createDistWorkspace } from '../../lib/packager';
|
||||
import { getEnvironmentParallelism } from '../../lib/parallel';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
|
||||
const BUNDLE_FILE = 'bundle.tar.gz';
|
||||
const SKELETON_FILE = 'skeleton.tar.gz';
|
||||
@@ -42,6 +43,7 @@ export async function buildBackend(options: BuildBackendOptions) {
|
||||
packageJson: pkg,
|
||||
outputs: new Set([Output.cjs]),
|
||||
minify,
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
|
||||
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { OptionValues } from 'commander';
|
||||
import { buildPackage, Output } from '../../lib/builder';
|
||||
import { findRoleFromCommand } from '../../lib/role';
|
||||
import { PackageRoles } from '@backstage/cli-node';
|
||||
import { PackageGraph, PackageRoles } from '@backstage/cli-node';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { buildFrontend } from './buildFrontend';
|
||||
import { buildBackend } from './buildBackend';
|
||||
@@ -82,5 +82,6 @@ export async function command(opts: OptionValues): Promise<void> {
|
||||
return buildPackage({
|
||||
outputs,
|
||||
minify: Boolean(opts.minify),
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
|
||||
outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
|
||||
minify: buildOptions.minify,
|
||||
workspacePackages: packages,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('makeRollupConfigs', () => {
|
||||
version: '0.0.0',
|
||||
main: './src/index.ts',
|
||||
},
|
||||
workspacePackages: [],
|
||||
});
|
||||
const external = config.external as Exclude<
|
||||
ExternalOption,
|
||||
|
||||
@@ -53,6 +53,21 @@ function isFileImport(source: string) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildInternalImportPattern(options: BuildOptions) {
|
||||
const inlinedPackages = options.workspacePackages.filter(
|
||||
pkg => pkg.packageJson.backstage?.inline,
|
||||
);
|
||||
for (const { packageJson } of inlinedPackages) {
|
||||
if (!packageJson.private) {
|
||||
throw new Error(
|
||||
`Inlined package ${packageJson.name} must be marked as private`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const names = inlinedPackages.map(pkg => pkg.packageJson.name);
|
||||
return new RegExp(`^(?:${names.join('|')})(?:$|/)`);
|
||||
}
|
||||
|
||||
export async function makeRollupConfigs(
|
||||
options: BuildOptions,
|
||||
): Promise<RollupOptions[]> {
|
||||
@@ -83,6 +98,19 @@ export async function makeRollupConfigs(
|
||||
SCRIPT_EXTS.includes(e.ext),
|
||||
);
|
||||
|
||||
const internalImportPattern = buildInternalImportPattern(options);
|
||||
const external = (
|
||||
source: string,
|
||||
importer: string | undefined,
|
||||
isResolved: boolean,
|
||||
) =>
|
||||
Boolean(
|
||||
importer &&
|
||||
!isResolved &&
|
||||
!internalImportPattern.test(source) &&
|
||||
!isFileImport(source),
|
||||
);
|
||||
|
||||
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
|
||||
const output = new Array<OutputOptions>();
|
||||
const mainFields = ['module', 'main'];
|
||||
@@ -121,8 +149,7 @@ export async function makeRollupConfigs(
|
||||
makeAbsoluteExternalsRelative: false,
|
||||
preserveEntrySignatures: 'strict',
|
||||
// All module imports are always marked as external
|
||||
external: (source, importer, isResolved) =>
|
||||
Boolean(importer && !isResolved && !isFileImport(source)),
|
||||
external,
|
||||
plugins: [
|
||||
resolve({ mainFields }),
|
||||
commonjs({
|
||||
@@ -191,18 +218,11 @@ export async function makeRollupConfigs(
|
||||
chunkFileNames: `types/[name]-[hash].d.ts`,
|
||||
format: 'es',
|
||||
},
|
||||
external: [
|
||||
/\.css$/,
|
||||
/\.scss$/,
|
||||
/\.sass$/,
|
||||
/\.svg$/,
|
||||
/\.eot$/,
|
||||
/\.woff$/,
|
||||
/\.woff2$/,
|
||||
/\.ttf$/,
|
||||
],
|
||||
external: (source, importer, isResolved) =>
|
||||
/\.css|scss|sass|svg|eot|woff|woff2|ttf$/.test(source) ||
|
||||
external(source, importer, isResolved),
|
||||
onwarn,
|
||||
plugins: [dts()],
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { BackstagePackage, BackstagePackageJson } from '@backstage/cli-node';
|
||||
|
||||
export enum Output {
|
||||
esm,
|
||||
@@ -28,4 +28,5 @@ export type BuildOptions = {
|
||||
packageJson?: BackstagePackageJson;
|
||||
outputs: Set<Output>;
|
||||
minify?: boolean;
|
||||
workspacePackages: BackstagePackage[];
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ export async function createDistWorkspace(
|
||||
outputs: outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
|
||||
minify: options.minify,
|
||||
workspacePackages: packages,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const manypkg = require('@manypkg/get-packages');
|
||||
|
||||
/**
|
||||
* @typedef ExtendedPackage
|
||||
* @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string> }}} packageJson
|
||||
* @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string>, backstage?: { inline?: boolean } }}} packageJson
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,11 +22,11 @@ const visitImports = require('../lib/visitImports');
|
||||
const minimatch = require('minimatch');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const depFields = {
|
||||
const depFields = /** @type {const} */ ({
|
||||
dep: 'dependencies',
|
||||
dev: 'devDependencies',
|
||||
peer: 'peerDependencies',
|
||||
};
|
||||
});
|
||||
|
||||
const devModulePatterns = [
|
||||
new minimatch.Minimatch('!src/**'),
|
||||
@@ -154,6 +154,124 @@ function addVersionQuery(name, flag, packages) {
|
||||
return `${name}@${mostCommonRange}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add missing package imports
|
||||
* @param {Array<{name: string, flag: string, node: import('estree').Node}>} toAdd
|
||||
* @param {import('../lib/getPackages').PackageMap} packages
|
||||
* @param {import('../lib/getPackages').ExtendedPackage} localPkg
|
||||
*/
|
||||
function addMissingImports(toAdd, packages, localPkg) {
|
||||
/** @type Record<string, Set<string>> */
|
||||
const byFlag = {};
|
||||
|
||||
for (const { name, flag } of toAdd) {
|
||||
byFlag[flag] = byFlag[flag] ?? new Set();
|
||||
byFlag[flag].add(name);
|
||||
}
|
||||
|
||||
for (const name of byFlag[''] ?? []) {
|
||||
byFlag['--dev']?.delete(name);
|
||||
}
|
||||
for (const name of byFlag['--peer'] ?? []) {
|
||||
byFlag['']?.delete(name);
|
||||
byFlag['--dev']?.delete(name);
|
||||
}
|
||||
|
||||
for (const [flag, names] of Object.entries(byFlag)) {
|
||||
// Look up existing version queries in the repo for the same dependency
|
||||
const namesWithQuery = [...names].map(name =>
|
||||
addVersionQuery(name, flag, packages),
|
||||
);
|
||||
|
||||
// The security implication of this is a bit interesting, as crafted add-import
|
||||
// directives could be used to install malicious packages. However, the same is true
|
||||
// for adding malicious packages to package.json, so there's no significant difference.
|
||||
execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...namesWithQuery], {
|
||||
cwd: localPkg.dir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes dependency entries pointing to inlined workspace packages.
|
||||
* @param {Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}>} toInline
|
||||
* @param {import('../lib/getPackages').ExtendedPackage} localPkg
|
||||
*/
|
||||
function removeInlineImports(toInline, localPkg) {
|
||||
/** @type Set<string> */
|
||||
const toRemove = new Set();
|
||||
|
||||
for (const { pkg } of toInline) {
|
||||
const name = pkg.packageJson.name;
|
||||
for (const depType of Object.values(depFields)) {
|
||||
if (localPkg.packageJson[depType]?.[name]) {
|
||||
toRemove.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (toRemove.size > 0) {
|
||||
execFileSync('yarn', ['remove', ...toRemove], {
|
||||
cwd: localPkg.dir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds dependencies that are not properly forwarded from inline dependencies.
|
||||
* @param {Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}>} toInline
|
||||
* @param {import('../lib/getPackages').ExtendedPackage} localPkg
|
||||
*/
|
||||
function addForwardedInlineImports(toInline, localPkg) {
|
||||
const declaredProdDeps = new Set([
|
||||
...Object.keys(localPkg.packageJson.dependencies ?? {}),
|
||||
...Object.keys(localPkg.packageJson.peerDependencies ?? {}),
|
||||
localPkg.packageJson.name, // include self
|
||||
]);
|
||||
|
||||
/** @type Map<string, Map<string, string>> */
|
||||
const byFlagByName = new Map();
|
||||
|
||||
for (const { pkg } of toInline) {
|
||||
for (const depType of /** @type {const} */ ([
|
||||
'dependencies',
|
||||
'peerDependencies',
|
||||
])) {
|
||||
for (const [depName, depQuery] of Object.entries(
|
||||
pkg.packageJson[depType] ?? {},
|
||||
)) {
|
||||
if (!declaredProdDeps.has(depName)) {
|
||||
const flag = getAddFlagForDepsField(depType);
|
||||
const byName = byFlagByName.get(flag);
|
||||
if (byName) {
|
||||
const query = byName.get(depName);
|
||||
if (query && query !== depQuery) {
|
||||
throw new Error(
|
||||
`Conflicting dependency queries for inlined package dep ${depName}, got ${query} and ${depQuery}`,
|
||||
);
|
||||
} else {
|
||||
byName.set(depName, depQuery);
|
||||
}
|
||||
} else {
|
||||
byFlagByName.set(flag, new Map([[depName, depQuery]]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [flag, byName] of byFlagByName) {
|
||||
const namesWithQuery = [...byName.entries()].map(
|
||||
([name, query]) => `${name}@${query}`,
|
||||
);
|
||||
execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...namesWithQuery], {
|
||||
cwd: localPkg.dir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
module.exports = {
|
||||
meta: {
|
||||
@@ -165,6 +283,8 @@ module.exports = {
|
||||
switch:
|
||||
'{{ packageName }} is declared in {{ oldDepsField }}, but should be moved to {{ depsField }} in {{ packageJsonPath }}.',
|
||||
switchBack: 'Switch back to import declaration',
|
||||
inlineDirect: `The dependency on the inline package {{ packageName }} must not be declared in package dependencies.`,
|
||||
inlineMissing: `Each production dependency from the inline package {{ packageName }} must be re-declared by this package, the following dependencies are missing: {{ missingDeps }}`,
|
||||
},
|
||||
docs: {
|
||||
description:
|
||||
@@ -189,57 +309,48 @@ module.exports = {
|
||||
/** @type Array<{name: string, flag: string, node: import('estree').Node}> */
|
||||
const importsToAdd = [];
|
||||
|
||||
/** @type Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}> */
|
||||
const importsToInline = [];
|
||||
|
||||
return {
|
||||
// All missing imports that we detect are collected as we traverse, and then we use
|
||||
// the program exit to execute all install directives that have been found.
|
||||
['Program:exit']() {
|
||||
/** @type Record<string, Set<string>> */
|
||||
const byFlag = {};
|
||||
if (importsToAdd.length > 0) {
|
||||
addMissingImports(importsToAdd, packages, localPkg);
|
||||
|
||||
for (const { name, flag } of importsToAdd) {
|
||||
byFlag[flag] = byFlag[flag] ?? new Set();
|
||||
byFlag[flag].add(name);
|
||||
// This switches all import directives back to the original import.
|
||||
for (const added of importsToAdd) {
|
||||
context.report({
|
||||
node: added.node,
|
||||
messageId: 'switchBack',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(added.node, `'${added.name}'`);
|
||||
},
|
||||
});
|
||||
}
|
||||
importsToAdd.length = 0;
|
||||
}
|
||||
|
||||
for (const name of byFlag[''] ?? []) {
|
||||
byFlag['--dev']?.delete(name);
|
||||
}
|
||||
for (const name of byFlag['--peer'] ?? []) {
|
||||
byFlag['']?.delete(name);
|
||||
byFlag['--dev']?.delete(name);
|
||||
if (importsToInline.length > 0) {
|
||||
removeInlineImports(importsToInline, localPkg);
|
||||
addForwardedInlineImports(importsToInline, localPkg);
|
||||
|
||||
for (const inlined of importsToInline) {
|
||||
context.report({
|
||||
node: inlined.node,
|
||||
messageId: 'switchBack',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(
|
||||
inlined.node,
|
||||
`'${inlined.pkg.packageJson.name}'`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
importsToInline.length = 0;
|
||||
}
|
||||
|
||||
for (const [flag, names] of Object.entries(byFlag)) {
|
||||
// Look up existing version queries in the repo for the same dependency
|
||||
const namesWithQuery = [...names].map(name =>
|
||||
addVersionQuery(name, flag, packages),
|
||||
);
|
||||
|
||||
// The security implication of this is a bit interesting, as crafted add-import
|
||||
// directives could be used to install malicious packages. However, the same is true
|
||||
// for adding malicious packages to package.json, so there's no significant difference.
|
||||
execFileSync(
|
||||
'yarn',
|
||||
['add', ...(flag ? [flag] : []), ...namesWithQuery],
|
||||
{
|
||||
cwd: localPkg.dir,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// This switches all import directives back to the original import.
|
||||
for (const added of importsToAdd) {
|
||||
context.report({
|
||||
node: added.node,
|
||||
messageId: 'switchBack',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(added.node, `'${added.name}'`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
importsToAdd.length = 0;
|
||||
packages.clearCache();
|
||||
},
|
||||
...visitImports(context, (node, imp) => {
|
||||
@@ -255,22 +366,99 @@ module.exports = {
|
||||
|
||||
// Any import directive that is found is collected for processing later
|
||||
if (imp.type === 'directive') {
|
||||
const parts = imp.path.split(':');
|
||||
if (parts[1] !== 'add-import') {
|
||||
return;
|
||||
}
|
||||
const [type, name] = parts.slice(2);
|
||||
if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) {
|
||||
throw new Error(
|
||||
`Invalid package name to add as dependency: '${name}'`,
|
||||
);
|
||||
const [, directive, ...args] = imp.path.split(':');
|
||||
|
||||
if (directive === 'add-import') {
|
||||
const [type, name] = args;
|
||||
if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) {
|
||||
throw new Error(
|
||||
`Invalid package name to add as dependency: '${name}'`,
|
||||
);
|
||||
}
|
||||
|
||||
importsToAdd.push({
|
||||
flag: getAddFlagForDepsField(type).trim(),
|
||||
name,
|
||||
node: imp.node,
|
||||
});
|
||||
}
|
||||
|
||||
if (directive === 'inline-imports') {
|
||||
const [name] = args;
|
||||
const pkg = packages.map.get(name);
|
||||
if (!pkg) {
|
||||
throw new Error(`Unexpectedly missing inline package: ${name}`);
|
||||
}
|
||||
|
||||
importsToInline.push({
|
||||
pkg: pkg,
|
||||
node: imp.node,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Importing an internal inlined package, whose imports are inlined too
|
||||
if (
|
||||
imp.type === 'internal' &&
|
||||
imp.package.packageJson.backstage?.inline
|
||||
) {
|
||||
for (const depType of Object.values(depFields)) {
|
||||
if (localPkg.packageJson[depType]?.[imp.packageName]) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'inlineDirect',
|
||||
data: {
|
||||
packageName: imp.packageName,
|
||||
},
|
||||
fix: fixer => {
|
||||
return fixer.replaceText(
|
||||
imp.node,
|
||||
`'directive:inline-imports:${imp.packageName}'`,
|
||||
);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const missingDeps = [];
|
||||
const declaredProdDeps = new Set([
|
||||
...Object.keys(localPkg.packageJson.dependencies ?? {}),
|
||||
...Object.keys(localPkg.packageJson.peerDependencies ?? {}),
|
||||
localPkg.packageJson.name, // include self
|
||||
]);
|
||||
for (const depType of /** @type {const} */ ([
|
||||
'dependencies',
|
||||
'peerDependencies',
|
||||
])) {
|
||||
for (const depName of Object.keys(
|
||||
imp.package.packageJson[depType] ?? {},
|
||||
)) {
|
||||
if (!declaredProdDeps.has(depName)) {
|
||||
missingDeps.push(depName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDeps.length > 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'inlineMissing',
|
||||
data: {
|
||||
packageName: imp.packageName,
|
||||
missingDeps: missingDeps.join(', '),
|
||||
},
|
||||
fix: fixer => {
|
||||
return fixer.replaceText(
|
||||
imp.node,
|
||||
`'directive:inline-imports:${imp.packageName}'`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
importsToAdd.push({
|
||||
flag: getAddFlagForDepsField(type).trim(),
|
||||
name,
|
||||
node: imp.node,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "@internal/bar",
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./BarPage": "./src/components/Bar.tsx",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"backstage": {
|
||||
"role": "frontend-plugin"
|
||||
},
|
||||
"dependencies": {
|
||||
"inline-dep": "*",
|
||||
"react-router": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@internal/inline-dep-direct",
|
||||
"files": [
|
||||
"dist",
|
||||
"type-utils"
|
||||
],
|
||||
"dependencies": {
|
||||
"@internal/inline": "workspace:^",
|
||||
"@internal/inline-dep-valid": "workspace:^",
|
||||
"react": "*"
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@internal/inline-dep-missing",
|
||||
"files": [
|
||||
"dist",
|
||||
"type-utils"
|
||||
]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@internal/inline-dep-valid",
|
||||
"files": [
|
||||
"dist",
|
||||
"type-utils"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@internal/inline",
|
||||
"backstage": {
|
||||
"inline": true
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"type-utils"
|
||||
],
|
||||
"dependencies": {
|
||||
"@internal/inline-dep-valid": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,12 @@ const ERR_SWITCHED = (
|
||||
const ERR_SWITCH_BACK = () => ({
|
||||
message: 'Switch back to import declaration',
|
||||
});
|
||||
const ERR_INLINE_DIRECT = (name: string) => ({
|
||||
message: `The dependency on the inline package ${name} must not be declared in package dependencies.`,
|
||||
});
|
||||
const ERR_INLINE_MISSING = (name: string, missing: string) => ({
|
||||
message: `Each production dependency from the inline package ${name} must be re-declared by this package, the following dependencies are missing: ${missing}`,
|
||||
});
|
||||
|
||||
// cwd must be restored
|
||||
const origDir = process.cwd();
|
||||
@@ -102,6 +108,17 @@ ruleTester.run(RULE, rule, {
|
||||
code: `require('lod' + 'ash')`,
|
||||
filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
|
||||
},
|
||||
{
|
||||
code: `import '@internal/inline'`,
|
||||
filename: joinPath(FIXTURE, 'packages/inline-dep-valid/src/index.ts'),
|
||||
},
|
||||
{
|
||||
code: `import '@internal/inline'`,
|
||||
filename: joinPath(
|
||||
FIXTURE,
|
||||
'packages/inline-dep-valid/src/index.test.ts',
|
||||
),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
@@ -264,6 +281,29 @@ ruleTester.run(RULE, rule, {
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `import '@internal/inline'`,
|
||||
output: `import 'directive:inline-imports:@internal/inline'`,
|
||||
filename: joinPath(
|
||||
FIXTURE,
|
||||
'packages/inline-dep-invalid-direct/src/index.ts',
|
||||
),
|
||||
errors: [ERR_INLINE_DIRECT('@internal/inline')],
|
||||
},
|
||||
{
|
||||
code: `import '@internal/inline'`,
|
||||
output: `import 'directive:inline-imports:@internal/inline'`,
|
||||
filename: joinPath(
|
||||
FIXTURE,
|
||||
'packages/inline-dep-invalid-missing/src/index.ts',
|
||||
),
|
||||
errors: [
|
||||
ERR_INLINE_MISSING(
|
||||
'@internal/inline',
|
||||
'@internal/inline-dep-valid, react',
|
||||
),
|
||||
],
|
||||
},
|
||||
|
||||
// Switching back to original import declarations
|
||||
{
|
||||
@@ -302,5 +342,14 @@ ruleTester.run(RULE, rule, {
|
||||
filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'),
|
||||
errors: [ERR_SWITCH_BACK()],
|
||||
},
|
||||
{
|
||||
code: `import 'directive:inline-imports:@internal/inline'`,
|
||||
output: `import '@internal/inline'`,
|
||||
filename: joinPath(
|
||||
FIXTURE,
|
||||
'packages/inline-dep-invalid-direct/src/index.ts',
|
||||
),
|
||||
errors: [ERR_SWITCH_BACK()],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
|
||||
rules: {
|
||||
'@backstage/no-top-level-material-ui-4-imports': 'error',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
# @internal/frontend
|
||||
|
||||
This is an internal package used by the other frontend packages. It does not get published to NPM, but instead inlined into consuming packages due to the `backstage.inline` flag in `package.json`.
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: internal-frontend
|
||||
title: '@internal/frontend'
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-web-library
|
||||
owner: maintainers
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@internal/frontend",
|
||||
"version": "0.0.0",
|
||||
"backstage": {
|
||||
"role": "web-library",
|
||||
"inline": true
|
||||
},
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/backstage/backstage",
|
||||
"directory": "packages/frontend-internal"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"sideEffects": false,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "backstage-cli package lint",
|
||||
"test": "backstage-cli package test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/frontend-app-api": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@testing-library/react": "^15.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
export * from './wiring';
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2024 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 {
|
||||
AnyExtensionDataRef,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
ExtensionDataValue,
|
||||
ExtensionDefinition,
|
||||
ExtensionDefinitionParameters,
|
||||
ExtensionInput,
|
||||
PortableSchema,
|
||||
ResolvedExtensionInputs,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
export type InternalExtensionDefinition<
|
||||
T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
|
||||
> = ExtensionDefinition<T> & {
|
||||
readonly kind?: string;
|
||||
readonly namespace?: string;
|
||||
readonly name?: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<T['config'], T['configInput']>;
|
||||
} & (
|
||||
| {
|
||||
readonly version: 'v1';
|
||||
readonly inputs: {
|
||||
[inputName in string]: {
|
||||
$$type: '@backstage/ExtensionInput';
|
||||
extensionData: {
|
||||
[name in string]: AnyExtensionDataRef;
|
||||
};
|
||||
config: { optional: boolean; singleton: boolean };
|
||||
};
|
||||
};
|
||||
readonly output: {
|
||||
[name in string]: AnyExtensionDataRef;
|
||||
};
|
||||
factory(context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: object;
|
||||
inputs: {
|
||||
[inputName in string]: unknown;
|
||||
};
|
||||
}): {
|
||||
[inputName in string]: unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
readonly version: 'v2';
|
||||
readonly inputs: {
|
||||
[inputName in string]: ExtensionInput<
|
||||
AnyExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
>;
|
||||
};
|
||||
readonly output: Array<AnyExtensionDataRef>;
|
||||
factory(context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: object;
|
||||
inputs: ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
AnyExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
>;
|
||||
}>;
|
||||
}): Iterable<ExtensionDataValue<any, any>>;
|
||||
}
|
||||
);
|
||||
|
||||
/** @internal */
|
||||
export function toInternalExtensionDefinition<
|
||||
T extends ExtensionDefinitionParameters,
|
||||
>(overrides: ExtensionDefinition<T>): InternalExtensionDefinition<T> {
|
||||
const internal = overrides as InternalExtensionDefinition<T>;
|
||||
if (internal.$$type !== '@backstage/ExtensionDefinition') {
|
||||
throw new Error(
|
||||
`Invalid extension definition instance, bad type '${internal.$$type}'`,
|
||||
);
|
||||
}
|
||||
const version = internal.version;
|
||||
if (version !== 'v1' && version !== 'v2') {
|
||||
throw new Error(
|
||||
`Invalid extension definition instance, bad version '${version}'`,
|
||||
);
|
||||
}
|
||||
return internal;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2024 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.
|
||||
*/
|
||||
|
||||
export {
|
||||
toInternalExtensionDefinition,
|
||||
type InternalExtensionDefinition,
|
||||
} from './InternalExtensionDefinition';
|
||||
@@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import { ApiHolder, AppNode } from '../apis';
|
||||
import { PortableSchema } from '../schema';
|
||||
import { Expand } from '../types';
|
||||
import {
|
||||
ResolveInputValueOverrides,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
import { ExtensionInput } from './createExtensionInput';
|
||||
import { z } from 'zod';
|
||||
import { createSchemaFromZod } from '../schema/createSchemaFromZod';
|
||||
import { InternalExtensionDefinition } from '@internal/frontend';
|
||||
|
||||
/**
|
||||
* Convert a single extension input into a matching resolved input.
|
||||
@@ -235,84 +235,6 @@ export type ExtensionDefinition<
|
||||
}>;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export type InternalExtensionDefinition<
|
||||
T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
|
||||
> = ExtensionDefinition<T> & {
|
||||
readonly kind?: string;
|
||||
readonly namespace?: string;
|
||||
readonly name?: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<T['config'], T['configInput']>;
|
||||
} & (
|
||||
| {
|
||||
readonly version: 'v1';
|
||||
readonly inputs: {
|
||||
[inputName in string]: {
|
||||
$$type: '@backstage/ExtensionInput';
|
||||
extensionData: {
|
||||
[name in string]: AnyExtensionDataRef;
|
||||
};
|
||||
config: { optional: boolean; singleton: boolean };
|
||||
};
|
||||
};
|
||||
readonly output: {
|
||||
[name in string]: AnyExtensionDataRef;
|
||||
};
|
||||
factory(context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: object;
|
||||
inputs: {
|
||||
[inputName in string]: unknown;
|
||||
};
|
||||
}): {
|
||||
[inputName in string]: unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
readonly version: 'v2';
|
||||
readonly inputs: {
|
||||
[inputName in string]: ExtensionInput<
|
||||
AnyExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
>;
|
||||
};
|
||||
readonly output: Array<AnyExtensionDataRef>;
|
||||
factory(context: {
|
||||
node: AppNode;
|
||||
apis: ApiHolder;
|
||||
config: object;
|
||||
inputs: ResolvedExtensionInputs<{
|
||||
[inputName in string]: ExtensionInput<
|
||||
AnyExtensionDataRef,
|
||||
{ optional: boolean; singleton: boolean }
|
||||
>;
|
||||
}>;
|
||||
}): Iterable<ExtensionDataValue<any, any>>;
|
||||
}
|
||||
);
|
||||
|
||||
/** @internal */
|
||||
export function toInternalExtensionDefinition<
|
||||
T extends ExtensionDefinitionParameters,
|
||||
>(overrides: ExtensionDefinition<T>): InternalExtensionDefinition<T> {
|
||||
const internal = overrides as InternalExtensionDefinition<T>;
|
||||
if (internal.$$type !== '@backstage/ExtensionDefinition') {
|
||||
throw new Error(
|
||||
`Invalid extension definition instance, bad type '${internal.$$type}'`,
|
||||
);
|
||||
}
|
||||
const version = internal.version;
|
||||
if (version !== 'v1' && version !== 'v2') {
|
||||
throw new Error(
|
||||
`Invalid extension definition instance, bad version '${version}'`,
|
||||
);
|
||||
}
|
||||
return internal;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export function createExtension<
|
||||
UOutput extends AnyExtensionDataRef,
|
||||
|
||||
@@ -27,11 +27,9 @@ import {
|
||||
} from './createExtensionDataRef';
|
||||
import { createExtensionInput } from './createExtensionInput';
|
||||
import { RouteRef } from '../routing';
|
||||
import {
|
||||
ExtensionDefinition,
|
||||
toInternalExtensionDefinition,
|
||||
} from './createExtension';
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import { createExtensionDataContainer } from './createExtensionDataContainer';
|
||||
import { toInternalExtensionDefinition } from '@internal/frontend';
|
||||
|
||||
function unused(..._any: any[]) {}
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ExtensionDefinition,
|
||||
InternalExtensionDefinition,
|
||||
toInternalExtensionDefinition,
|
||||
} from './createExtension';
|
||||
} from '@internal/frontend';
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import {
|
||||
Extension,
|
||||
resolveExtensionDefinition,
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ExtensionDefinition,
|
||||
InternalExtensionDefinition,
|
||||
toInternalExtensionDefinition,
|
||||
} from './createExtension';
|
||||
} from '@internal/frontend';
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import {
|
||||
Extension,
|
||||
ResolveExtensionId,
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ExtensionDefinition,
|
||||
ExtensionDefinitionParameters,
|
||||
ResolvedExtensionInputs,
|
||||
toInternalExtensionDefinition,
|
||||
} from './createExtension';
|
||||
import { PortableSchema } from '../schema';
|
||||
import { ExtensionInput } from './createExtensionInput';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
AnyExtensionDataRef,
|
||||
ExtensionDataValue,
|
||||
} from './createExtensionDataRef';
|
||||
import { toInternalExtensionDefinition } from '@internal/frontend';
|
||||
|
||||
/** @public */
|
||||
export interface Extension<TConfig, TConfigInput = TConfig> {
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
|
||||
@@ -29,8 +29,6 @@ import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveAppTree } from '../../../frontend-app-api/src/tree/resolveAppTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { resolveAppNodeSpecs } from '../../../frontend-app-api/src/tree/resolveAppNodeSpecs';
|
||||
@@ -39,6 +37,7 @@ import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/insta
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import { toInternalExtensionDefinition } from '@internal/frontend';
|
||||
|
||||
/** @public */
|
||||
export class ExtensionQuery<UOutput extends AnyExtensionDataRef> {
|
||||
|
||||
@@ -1232,6 +1232,12 @@ export async function categorizePackageDirs(packageDirs: string[]) {
|
||||
if (!role) {
|
||||
return; // Ignore packages without roles
|
||||
}
|
||||
// TODO(Rugvip): Inlined packages are ignored because we can't handle @internal exports
|
||||
// gracefully, and we don't want to have to mark all exports @public etc.
|
||||
// It would be good if we could include these packages though.
|
||||
if (pkgJson?.backstage?.inline) {
|
||||
return;
|
||||
}
|
||||
if (role === 'cli') {
|
||||
cliPackageDirs.push(dir);
|
||||
} else if (role !== 'frontend' && role !== 'backend') {
|
||||
|
||||
@@ -4558,8 +4558,10 @@ __metadata:
|
||||
"@backstage/plugin-app": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
"@testing-library/jest-dom": ^6.0.0
|
||||
"@types/react": "*"
|
||||
zod: ^3.22.4
|
||||
peerDependencies:
|
||||
"@testing-library/react": ^15.0.0
|
||||
react: ^16.13.1 || ^17.0.0 || ^18.0.0
|
||||
@@ -9923,6 +9925,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@internal/frontend@workspace:packages/frontend-internal":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@internal/frontend@workspace:packages/frontend-internal"
|
||||
dependencies:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/frontend-app-api": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
"@testing-library/jest-dom": ^6.0.0
|
||||
"@testing-library/react": ^15.0.0
|
||||
zod: ^3.22.4
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@internal/plugin-todo-list-backend@workspace:plugins/example-todo-list-backend":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@internal/plugin-todo-list-backend@workspace:plugins/example-todo-list-backend"
|
||||
|
||||
Reference in New Issue
Block a user