Split CLI modules into separate packages
Extract each CLI module from packages/cli/src/modules/ into its own package under packages/cli-module-*. This enables independent versioning and clearer dependency boundaries for each CLI capability. Module mapping: - auth → @backstage/cli-module-auth - build → @backstage/cli-module-build - config → @backstage/cli-module-config - create-github-app → @backstage/cli-module-create-github-app - info → @backstage/cli-module-info - lint → @backstage/cli-module-lint - maintenance → @backstage/cli-module-maintenance - migrate → @backstage/cli-module-migrate - new → @backstage/cli-module-new - test → @backstage/cli-module-test-jest - translations → @backstage/cli-module-translations Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -49,6 +49,17 @@
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "workspace:^",
|
||||
"@backstage/cli-common": "workspace:^",
|
||||
"@backstage/cli-module-auth": "workspace:^",
|
||||
"@backstage/cli-module-build": "workspace:^",
|
||||
"@backstage/cli-module-config": "workspace:^",
|
||||
"@backstage/cli-module-create-github-app": "workspace:^",
|
||||
"@backstage/cli-module-info": "workspace:^",
|
||||
"@backstage/cli-module-lint": "workspace:^",
|
||||
"@backstage/cli-module-maintenance": "workspace:^",
|
||||
"@backstage/cli-module-migrate": "workspace:^",
|
||||
"@backstage/cli-module-new": "workspace:^",
|
||||
"@backstage/cli-module-test-jest": "workspace:^",
|
||||
"@backstage/cli-module-translations": "workspace:^",
|
||||
"@backstage/cli-node": "workspace:^",
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/config-loader": "workspace:^",
|
||||
|
||||
+11
-11
@@ -18,16 +18,16 @@ import { CliInitializer } from './wiring/CliInitializer';
|
||||
|
||||
(async () => {
|
||||
const initializer = new CliInitializer();
|
||||
initializer.add(import('./modules/build'));
|
||||
initializer.add(import('./modules/config'));
|
||||
initializer.add(import('./modules/create-github-app'));
|
||||
initializer.add(import('./modules/info'));
|
||||
initializer.add(import('./modules/lint'));
|
||||
initializer.add(import('./modules/maintenance'));
|
||||
initializer.add(import('./modules/migrate'));
|
||||
initializer.add(import('./modules/new'));
|
||||
initializer.add(import('./modules/test'));
|
||||
initializer.add(import('./modules/translations'));
|
||||
initializer.add(import('./modules/auth'));
|
||||
initializer.add(import('@backstage/cli-module-build'));
|
||||
initializer.add(import('@backstage/cli-module-config'));
|
||||
initializer.add(import('@backstage/cli-module-create-github-app'));
|
||||
initializer.add(import('@backstage/cli-module-info'));
|
||||
initializer.add(import('@backstage/cli-module-lint'));
|
||||
initializer.add(import('@backstage/cli-module-maintenance'));
|
||||
initializer.add(import('@backstage/cli-module-migrate'));
|
||||
initializer.add(import('@backstage/cli-module-new'));
|
||||
initializer.add(import('@backstage/cli-module-test-jest'));
|
||||
initializer.add(import('@backstage/cli-module-translations'));
|
||||
initializer.add(import('@backstage/cli-module-auth'));
|
||||
await initializer.run();
|
||||
})();
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
import { getAllInstances } from '../lib/storage';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info }, undefined, args);
|
||||
|
||||
const { instances, selected } = await getAllInstances();
|
||||
if (!instances.length) {
|
||||
process.stderr.write('No instances found\n');
|
||||
return;
|
||||
}
|
||||
for (const inst of instances) {
|
||||
const mark = inst.name === selected?.name ? '* ' : ' ';
|
||||
process.stdout.write(`${mark}${inst.name} - ${inst.baseUrl}\n`);
|
||||
}
|
||||
};
|
||||
@@ -1,383 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
import { startCallbackServer } from '../lib/localServer';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { challengeFromVerifier, generateVerifier } from '../lib/pkce';
|
||||
import { httpJson } from '../lib/http';
|
||||
import {
|
||||
upsertInstance,
|
||||
withMetadataLock,
|
||||
getAllInstances,
|
||||
getInstanceByName,
|
||||
StoredInstance,
|
||||
} from '../lib/storage';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import glob from 'glob';
|
||||
import YAML from 'yaml';
|
||||
import inquirer from 'inquirer';
|
||||
|
||||
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { backendUrl, noBrowser, instance: instanceFlag },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
backendUrl: { type: String, description: 'Backend base URL' },
|
||||
noBrowser: {
|
||||
type: Boolean,
|
||||
description: 'Do not open browser automatically',
|
||||
},
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name for this instance (used by other auth commands)',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const { instances, selected } = await getAllInstances();
|
||||
|
||||
let backendBaseUrl: string;
|
||||
let instanceName: string;
|
||||
|
||||
if (instanceFlag) {
|
||||
instanceName = instanceFlag;
|
||||
const targetInstance = instances.find(i => i.name === instanceFlag);
|
||||
if (targetInstance) {
|
||||
backendBaseUrl = normalizeUrl(backendUrl) ?? targetInstance.baseUrl;
|
||||
} else {
|
||||
backendBaseUrl = normalizeUrl(backendUrl) ?? (await pickBaseUrl());
|
||||
}
|
||||
} else if (backendUrl) {
|
||||
backendBaseUrl = normalizeUrl(backendUrl);
|
||||
instanceName = deriveInstanceName(backendBaseUrl);
|
||||
} else if (instances.length > 0) {
|
||||
const choice = await promptForInstance(instances, selected);
|
||||
if (choice === '__new__') {
|
||||
backendBaseUrl = await pickBaseUrl();
|
||||
instanceName = deriveInstanceName(backendBaseUrl);
|
||||
} else {
|
||||
const targetInstance = instances.find(i => i.name === choice);
|
||||
if (!targetInstance) {
|
||||
throw new Error('Instance not found');
|
||||
}
|
||||
backendBaseUrl = targetInstance.baseUrl;
|
||||
instanceName = targetInstance.name;
|
||||
}
|
||||
} else {
|
||||
backendBaseUrl = await pickBaseUrl();
|
||||
instanceName = deriveInstanceName(backendBaseUrl);
|
||||
}
|
||||
|
||||
const authBaseUrl = `${backendBaseUrl}/api/auth`;
|
||||
const clientId = `${authBaseUrl}/.well-known/oauth-client/cli.json`;
|
||||
|
||||
const metadataResponse = await fetch(clientId, {
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!metadataResponse.ok) {
|
||||
throw new Error(
|
||||
`Server does not support CLI authentication. Ensure CIMD is enabled on the backend.`,
|
||||
);
|
||||
}
|
||||
|
||||
const { verifier, challenge, state } = createPkceState();
|
||||
const callback = await startCallbackServer({ state });
|
||||
|
||||
try {
|
||||
const authorizeUrl = buildAuthorizeUrl({
|
||||
authBaseUrl,
|
||||
clientId,
|
||||
redirectUri: callback.url,
|
||||
state,
|
||||
challenge,
|
||||
});
|
||||
|
||||
await openBrowserOrPrint(authorizeUrl, noBrowser);
|
||||
|
||||
const code = await waitForAuthorizationCode(callback, state);
|
||||
|
||||
const token = await exchangeAuthorizationCode({
|
||||
authBaseUrl,
|
||||
code,
|
||||
redirectUri: callback.url,
|
||||
verifier,
|
||||
});
|
||||
|
||||
await persistInstance({
|
||||
instanceName,
|
||||
backendBaseUrl,
|
||||
clientId,
|
||||
token,
|
||||
});
|
||||
|
||||
process.stdout.write('Login successful\n');
|
||||
} finally {
|
||||
await callback.close();
|
||||
}
|
||||
};
|
||||
|
||||
async function promptForInstance(
|
||||
instances: StoredInstance[],
|
||||
selected: StoredInstance | undefined,
|
||||
): Promise<string> {
|
||||
const choices = instances.map(i => ({
|
||||
name: `${i.name === selected?.name ? '* ' : ' '}${i.name} (${i.baseUrl})`,
|
||||
value: i.name,
|
||||
}));
|
||||
|
||||
choices.push({
|
||||
name: 'Add new instance...',
|
||||
value: '__new__',
|
||||
});
|
||||
|
||||
const { choice } = await inquirer.prompt<{ choice: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Select instance to authenticate:',
|
||||
choices,
|
||||
default: selected?.name ?? '__new__',
|
||||
},
|
||||
]);
|
||||
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function pickBaseUrl() {
|
||||
const cwd = process.cwd();
|
||||
const candidates: Array<{ url: string; file: string }> = [];
|
||||
|
||||
const patterns = [
|
||||
'app-config.yaml',
|
||||
'app-config.*.yaml',
|
||||
'packages/*/app-config.yaml',
|
||||
'packages/*/app-config.*.yaml',
|
||||
];
|
||||
const files = patterns.flatMap(p => glob.sync(p, { cwd, nodir: true }));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.readFile(path.resolve(cwd, file), 'utf8');
|
||||
const doc = YAML.parse(content);
|
||||
const url = doc?.backend?.baseUrl as string | undefined;
|
||||
if (url) {
|
||||
candidates.push({ url: normalizeUrl(url), file });
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
const list = [...new Map(candidates.map(c => [c.url, c])).values()];
|
||||
if (list.length === 0) {
|
||||
const { manual } = await inquirer.prompt<{ manual: string }>([
|
||||
{ type: 'input', name: 'manual', message: 'Enter backend base URL' },
|
||||
]);
|
||||
return normalizeUrl(manual);
|
||||
}
|
||||
if (list.length === 1) {
|
||||
return list[0].url;
|
||||
}
|
||||
|
||||
const { picked } = await inquirer.prompt<{ picked: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'picked',
|
||||
message: 'Select backend base URL',
|
||||
choices: [
|
||||
...list.map(e => ({ name: `${e.url} (${e.file})`, value: e.url })),
|
||||
{ name: 'Enter manually', value: '__manual__' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
if (picked === '__manual__') {
|
||||
const { manual } = await inquirer.prompt<{ manual: string }>([
|
||||
{ type: 'input', name: 'manual', message: 'Enter backend base URL' },
|
||||
]);
|
||||
return normalizeUrl(manual);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
function normalizeUrl(u: string): string;
|
||||
function normalizeUrl(u: string | undefined): string | undefined;
|
||||
function normalizeUrl(u: string | undefined): string | undefined {
|
||||
if (u === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(u);
|
||||
return url.toString().replace(/\/$/, '');
|
||||
} catch {
|
||||
throw new Error(`'${u}' is not a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveInstanceName(url: string): string {
|
||||
return new URL(url).host;
|
||||
}
|
||||
|
||||
function createPkceState() {
|
||||
const verifier = generateVerifier();
|
||||
const challenge = challengeFromVerifier(verifier);
|
||||
const state = cryptoRandom();
|
||||
return { verifier, challenge, state };
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(options: {
|
||||
authBaseUrl: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
challenge: string;
|
||||
}): string {
|
||||
const { authBaseUrl, clientId, redirectUri, state, challenge } = options;
|
||||
const authorize = new URL(`${authBaseUrl}/v1/authorize`);
|
||||
authorize.searchParams.set('client_id', clientId);
|
||||
authorize.searchParams.set('redirect_uri', redirectUri);
|
||||
authorize.searchParams.set('response_type', 'code');
|
||||
authorize.searchParams.set('scope', 'openid offline_access');
|
||||
authorize.searchParams.set('state', state);
|
||||
authorize.searchParams.set('code_challenge', challenge);
|
||||
authorize.searchParams.set('code_challenge_method', 'S256');
|
||||
return authorize.toString();
|
||||
}
|
||||
|
||||
async function openBrowserOrPrint(url: string, noBrowser?: boolean) {
|
||||
if (noBrowser) {
|
||||
process.stdout.write(`Open this URL to continue: ${url}\n`);
|
||||
} else {
|
||||
process.stdout.write(`Opening the following URL: ${url}\n`);
|
||||
openInBrowser(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAuthorizationCode(
|
||||
callback: Awaited<ReturnType<typeof startCallbackServer>>,
|
||||
expectedState: string,
|
||||
) {
|
||||
const { code, state } = await callback.waitForCode();
|
||||
if (state !== expectedState) {
|
||||
throw new Error('State mismatch');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(options: {
|
||||
authBaseUrl: string;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
verifier: string;
|
||||
}) {
|
||||
const { authBaseUrl, code, redirectUri, verifier } = options;
|
||||
return await httpJson<{
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
id_token?: string;
|
||||
refresh_token?: string;
|
||||
}>(`${authBaseUrl}/v1/token`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
},
|
||||
signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
async function persistInstance(options: {
|
||||
instanceName: string;
|
||||
backendBaseUrl: string;
|
||||
clientId: string;
|
||||
token: { access_token: string; refresh_token?: string; expires_in: number };
|
||||
}) {
|
||||
const { instanceName, backendBaseUrl, clientId, token } = options;
|
||||
const secretStore = await getSecretStore();
|
||||
await withMetadataLock(async () => {
|
||||
const service = `backstage-cli:auth-instance:${instanceName}`;
|
||||
await secretStore.set(service, 'accessToken', token.access_token);
|
||||
if (token.refresh_token) {
|
||||
await secretStore.set(service, 'refreshToken', token.refresh_token);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
'Warning: No refresh token received. You will need to re-authenticate when the access token expires.\n',
|
||||
);
|
||||
}
|
||||
let existing: StoredInstance | undefined;
|
||||
try {
|
||||
existing = await getInstanceByName(instanceName);
|
||||
} catch {
|
||||
// new instance
|
||||
}
|
||||
await upsertInstance({
|
||||
name: instanceName,
|
||||
baseUrl: backendBaseUrl,
|
||||
clientId,
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + token.expires_in * 1000,
|
||||
selected: existing?.selected,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function cryptoRandom(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// The react-dev-utils/openBrowser breaks the login URL by encoding the URL parameters again
|
||||
export function openInBrowser(url: string): void {
|
||||
const handleError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
process.stderr.write(
|
||||
`Warning: Failed to open browser automatically: ${message}\n`,
|
||||
);
|
||||
process.stderr.write(`Please open this URL manually: ${url}\n`);
|
||||
};
|
||||
|
||||
const spawnOpts = { detached: true, stdio: 'ignore' } as const;
|
||||
let child;
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
child = spawn('open', [url], spawnOpts);
|
||||
} else if (process.platform === 'win32') {
|
||||
child = spawn(
|
||||
'powershell',
|
||||
['-Command', `Start-Process '${url.replace(/'/g, "''")}'`],
|
||||
spawnOpts,
|
||||
);
|
||||
} else {
|
||||
child = spawn('xdg-open', [url], spawnOpts);
|
||||
}
|
||||
child.unref();
|
||||
child.on('error', handleError);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
import {
|
||||
removeInstance,
|
||||
withMetadataLock,
|
||||
getInstanceByName,
|
||||
} from '../lib/storage';
|
||||
import { httpJson } from '../lib/http';
|
||||
import { pickInstance } from '../lib/prompt';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { instance: instanceFlag },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to log out',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const { name: instanceName } = await pickInstance(instanceFlag);
|
||||
|
||||
await withMetadataLock(async () => {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instanceName}`;
|
||||
const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? '';
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const authBaseUrl = new URL('/api/auth', instance.baseUrl)
|
||||
.toString()
|
||||
.replace(/\/$/, '');
|
||||
await httpJson(`${authBaseUrl}/v1/revoke`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch {
|
||||
// ignore errors per RFC 7009
|
||||
}
|
||||
}
|
||||
|
||||
await secretStore.delete(service, 'accessToken');
|
||||
await secretStore.delete(service, 'refreshToken');
|
||||
await removeInstance(instance.name);
|
||||
});
|
||||
|
||||
process.stdout.write('Logged out\n');
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
|
||||
import { getSelectedInstance } from '../lib/storage';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { instance: instanceFlag },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to use',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
let instance = await getSelectedInstance(instanceFlag);
|
||||
|
||||
if (accessTokenNeedsRefresh(instance)) {
|
||||
instance = await refreshAccessToken(instance.name);
|
||||
}
|
||||
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instance.name}`;
|
||||
const accessToken = await secretStore.get(service, 'accessToken');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token found. Run "auth login" to authenticate.');
|
||||
}
|
||||
|
||||
process.stdout.write(`${accessToken}\n`);
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
import { setSelectedInstance } from '../lib/storage';
|
||||
import { pickInstance } from '../lib/prompt';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { instance: instanceFlag },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to select',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const instance = await pickInstance(instanceFlag);
|
||||
|
||||
await setSelectedInstance(instance.name);
|
||||
process.stderr.write(`Selected instance '${instance.name}'\n`);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { cli } from 'cleye';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
import { httpJson } from '../lib/http';
|
||||
import { getSelectedInstance } from '../lib/storage';
|
||||
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
|
||||
import { getSecretStore } from '../lib/secretStore';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { instance: instanceFlag },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
flags: {
|
||||
instance: {
|
||||
type: String,
|
||||
description: 'Name of the instance to show',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
let instance = await getSelectedInstance(instanceFlag);
|
||||
|
||||
if (accessTokenNeedsRefresh(instance)) {
|
||||
process.stdout.write('Refreshing access token...\n');
|
||||
instance = await refreshAccessToken(instance.name);
|
||||
}
|
||||
const authBase = new URL('/api/auth', instance.baseUrl)
|
||||
.toString()
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const secretStore = await getSecretStore();
|
||||
const service = `backstage-cli:auth-instance:${instance.name}`;
|
||||
const accessToken = await secretStore.get(service, 'accessToken');
|
||||
if (!accessToken) {
|
||||
throw new Error('No access token found. Run "auth login" to authenticate.');
|
||||
}
|
||||
|
||||
const userinfo = await httpJson<{ claims: { sub: string; ent: string[] } }>(
|
||||
`${authBase}/v1/userinfo`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
|
||||
process.stdout.write(`User: ${userinfo.claims.sub}\n`);
|
||||
process.stdout.write(`\n`);
|
||||
process.stdout.write(`Ownership:\n`);
|
||||
for (const ent of userinfo.claims.ent ?? []) {
|
||||
process.stdout.write(` - ${ent}\n`);
|
||||
}
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { createCliModule } from '../../wiring/factory';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
export default createCliModule({
|
||||
packageJson,
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
path: ['auth', 'login'],
|
||||
description: 'Log in the CLI to a Backstage instance',
|
||||
execute: { loader: () => import('./commands/login') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['auth', 'logout'],
|
||||
description: 'Log out the CLI and clear stored credentials',
|
||||
execute: { loader: () => import('./commands/logout') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['auth', 'show'],
|
||||
description: 'Show details of an authenticated instance',
|
||||
execute: { loader: () => import('./commands/show') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['auth', 'list'],
|
||||
description: 'List authenticated instances',
|
||||
execute: { loader: () => import('./commands/list') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['auth', 'print-token'],
|
||||
description: 'Print an access token to stdout (auto-refresh if needed)',
|
||||
execute: { loader: () => import('./commands/printToken') },
|
||||
});
|
||||
reg.addCommand({
|
||||
path: ['auth', 'select'],
|
||||
description: 'Select the default instance',
|
||||
execute: { loader: () => import('./commands/select') },
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,336 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { accessTokenNeedsRefresh, refreshAccessToken } from './auth';
|
||||
import * as storage from './storage';
|
||||
import * as secretStore from './secretStore';
|
||||
import * as http from './http';
|
||||
|
||||
jest.mock('./storage');
|
||||
jest.mock('./secretStore');
|
||||
jest.mock('./http');
|
||||
|
||||
const mockStorage = storage as jest.Mocked<typeof storage>;
|
||||
const mockSecretStore = secretStore as jest.Mocked<typeof secretStore>;
|
||||
const mockHttp = http as jest.Mocked<typeof http>;
|
||||
|
||||
describe('auth', () => {
|
||||
describe('accessTokenNeedsRefresh', () => {
|
||||
it('should return true if token expires within 2 minutes', () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client',
|
||||
issuedAt: now,
|
||||
|
||||
accessTokenExpiresAt: now + 60_000, // 1 minute from now
|
||||
};
|
||||
|
||||
expect(accessTokenNeedsRefresh(instance)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true if token has already expired', () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client',
|
||||
issuedAt: now - 3600_000,
|
||||
|
||||
accessTokenExpiresAt: now - 60_000, // expired 1 minute ago
|
||||
};
|
||||
|
||||
expect(accessTokenNeedsRefresh(instance)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if token is valid for more than 2 minutes', () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client',
|
||||
issuedAt: now,
|
||||
|
||||
accessTokenExpiresAt: now + 5 * 60_000, // 5 minutes from now
|
||||
};
|
||||
|
||||
expect(accessTokenNeedsRefresh(instance)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true at exactly 2 minutes before expiration', () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client',
|
||||
issuedAt: now,
|
||||
|
||||
accessTokenExpiresAt: now + 2 * 60_000, // exactly 2 minutes from now
|
||||
};
|
||||
|
||||
expect(accessTokenNeedsRefresh(instance)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshAccessToken', () => {
|
||||
const mockSecretStoreInstance = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockSecretStore.getSecretStore.mockResolvedValue(mockSecretStoreInstance);
|
||||
});
|
||||
|
||||
it('should successfully refresh access token', async () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client-id',
|
||||
issuedAt: now - 3600_000,
|
||||
|
||||
accessTokenExpiresAt: now - 60_000,
|
||||
};
|
||||
|
||||
mockStorage.withMetadataLock.mockImplementation(
|
||||
async (fn: () => Promise<any>) => fn(),
|
||||
);
|
||||
mockStorage.getInstanceByName.mockResolvedValue(instance);
|
||||
mockSecretStoreInstance.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'clientSecret') return 'test-secret';
|
||||
if (account === 'refreshToken') return 'old-refresh-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const tokenResponse = {
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token',
|
||||
};
|
||||
|
||||
mockHttp.httpJson.mockResolvedValue(tokenResponse);
|
||||
mockStorage.upsertInstance.mockResolvedValue();
|
||||
|
||||
const result = await refreshAccessToken('test');
|
||||
|
||||
expect(mockStorage.getInstanceByName).toHaveBeenCalledWith('test');
|
||||
expect(mockSecretStoreInstance.get).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:test',
|
||||
'refreshToken',
|
||||
);
|
||||
expect(mockHttp.httpJson).toHaveBeenCalledWith(
|
||||
'http://localhost:7007/api/auth/v1/token',
|
||||
{
|
||||
signal: expect.any(AbortSignal),
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'old-refresh-token',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(mockSecretStoreInstance.set).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:test',
|
||||
'accessToken',
|
||||
'new-access-token',
|
||||
);
|
||||
expect(mockSecretStoreInstance.set).toHaveBeenCalledWith(
|
||||
'backstage-cli:auth-instance:test',
|
||||
'refreshToken',
|
||||
'new-refresh-token',
|
||||
);
|
||||
expect(mockStorage.upsertInstance).toHaveBeenCalled();
|
||||
expect(result.accessTokenExpiresAt).toBeGreaterThan(now);
|
||||
});
|
||||
|
||||
it('should throw error if refresh token is missing', async () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client-id',
|
||||
issuedAt: now - 3600_000,
|
||||
|
||||
accessTokenExpiresAt: now - 60_000,
|
||||
};
|
||||
|
||||
mockStorage.withMetadataLock.mockImplementation(
|
||||
async (fn: () => Promise<any>) => fn(),
|
||||
);
|
||||
mockStorage.getInstanceByName.mockResolvedValue(instance);
|
||||
mockSecretStoreInstance.get.mockResolvedValue(undefined);
|
||||
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow(
|
||||
'Access token is expired and no refresh token is available',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use metadata lock during refresh', async () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client-id',
|
||||
issuedAt: now - 3600_000,
|
||||
|
||||
accessTokenExpiresAt: now - 60_000,
|
||||
};
|
||||
|
||||
let lockAcquired = false;
|
||||
mockStorage.withMetadataLock.mockImplementation(
|
||||
async (fn: () => Promise<any>) => {
|
||||
lockAcquired = true;
|
||||
return fn();
|
||||
},
|
||||
);
|
||||
mockStorage.getInstanceByName.mockResolvedValue(instance);
|
||||
mockSecretStoreInstance.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'clientSecret') return 'test-secret';
|
||||
if (account === 'refreshToken') return 'refresh-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const tokenResponse = {
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token',
|
||||
};
|
||||
|
||||
mockHttp.httpJson.mockResolvedValue(tokenResponse);
|
||||
mockStorage.upsertInstance.mockResolvedValue();
|
||||
|
||||
await refreshAccessToken('test');
|
||||
|
||||
expect(lockAcquired).toBe(true);
|
||||
expect(mockStorage.withMetadataLock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle HTTP and network errors during refresh', async () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client-id',
|
||||
issuedAt: now - 3600_000,
|
||||
|
||||
accessTokenExpiresAt: now - 60_000,
|
||||
};
|
||||
|
||||
const errorCases = [
|
||||
new Error('Request failed with 401 Unauthorized'),
|
||||
new Error('Network error'),
|
||||
];
|
||||
|
||||
for (const error of errorCases) {
|
||||
mockStorage.withMetadataLock.mockImplementation(
|
||||
async (fn: () => Promise<any>) => fn(),
|
||||
);
|
||||
mockStorage.getInstanceByName.mockResolvedValue(instance);
|
||||
mockSecretStoreInstance.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'clientSecret') return 'test-secret';
|
||||
if (account === 'refreshToken') return 'refresh-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
mockHttp.httpJson.mockRejectedValue(error);
|
||||
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate token response and reject malformed responses', async () => {
|
||||
const now = Date.now();
|
||||
const instance = {
|
||||
name: 'test',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'test-client-id',
|
||||
issuedAt: now - 3600_000,
|
||||
|
||||
accessTokenExpiresAt: now - 60_000,
|
||||
};
|
||||
|
||||
mockStorage.withMetadataLock.mockImplementation(
|
||||
async (fn: () => Promise<any>) => fn(),
|
||||
);
|
||||
mockStorage.getInstanceByName.mockResolvedValue(instance);
|
||||
mockSecretStoreInstance.get.mockImplementation(
|
||||
async (_service: string, account: string) => {
|
||||
if (account === 'clientSecret') return 'test-secret';
|
||||
if (account === 'refreshToken') return 'refresh-token';
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// Test missing access_token
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new-refresh-token',
|
||||
} as any);
|
||||
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow(
|
||||
'Invalid token response',
|
||||
);
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow('access_token');
|
||||
|
||||
// Test missing expires_in
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'new-refresh-token',
|
||||
} as any);
|
||||
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow(
|
||||
'Invalid token response',
|
||||
);
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow('expires_in');
|
||||
|
||||
// Test missing refresh_token still succeeds and preserves existing token
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
} as any);
|
||||
|
||||
await expect(refreshAccessToken('test')).resolves.toBeDefined();
|
||||
|
||||
// Test invalid expires_in (non-positive)
|
||||
mockHttp.httpJson.mockResolvedValue({
|
||||
access_token: 'new-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 0,
|
||||
refresh_token: 'new-refresh-token',
|
||||
} as any);
|
||||
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow(
|
||||
'Invalid token response',
|
||||
);
|
||||
await expect(refreshAccessToken('test')).rejects.toThrow('expires_in');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { z } from 'zod';
|
||||
import {
|
||||
StoredInstance,
|
||||
upsertInstance,
|
||||
withMetadataLock,
|
||||
getInstanceByName,
|
||||
} from './storage';
|
||||
import { getSecretStore } from './secretStore';
|
||||
import { httpJson } from './http';
|
||||
|
||||
const TokenResponseSchema = z.object({
|
||||
access_token: z.string().min(1),
|
||||
token_type: z.string().min(1),
|
||||
expires_in: z.number().positive().finite(),
|
||||
refresh_token: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
|
||||
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
instanceName: string,
|
||||
): Promise<StoredInstance> {
|
||||
const secretStore = await getSecretStore();
|
||||
|
||||
return withMetadataLock(async () => {
|
||||
const instance = await getInstanceByName(instanceName);
|
||||
|
||||
const service = `backstage-cli:auth-instance:${instanceName}`;
|
||||
const refreshToken = (await secretStore.get(service, 'refreshToken')) ?? '';
|
||||
if (!refreshToken) {
|
||||
throw new Error(
|
||||
'Access token is expired and no refresh token is available',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await httpJson<unknown>(
|
||||
`${instance.baseUrl}/api/auth/v1/token`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = TokenResponseSchema.safeParse(response);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid token response: ${parsed.error.message}`);
|
||||
}
|
||||
const token = parsed.data;
|
||||
|
||||
await secretStore.set(service, 'accessToken', token.access_token);
|
||||
if (token.refresh_token) {
|
||||
await secretStore.set(service, 'refreshToken', token.refresh_token);
|
||||
}
|
||||
const newInstance = {
|
||||
...instance,
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + token.expires_in * 1000,
|
||||
};
|
||||
await upsertInstance(newInstance);
|
||||
return newInstance;
|
||||
});
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 fetch from 'cross-fetch';
|
||||
import { httpJson } from './http';
|
||||
|
||||
jest.mock('cross-fetch');
|
||||
|
||||
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
describe('http', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('httpJson', () => {
|
||||
it('should make successful GET request and parse JSON', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ data: 'test' }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await httpJson('https://example.com/api');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/api',
|
||||
expect.objectContaining({
|
||||
body: undefined,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ data: 'test' });
|
||||
});
|
||||
|
||||
it('should make POST request with JSON body', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const body = { username: 'test', password: 'secret' };
|
||||
const result = await httpJson('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/api',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should include and merge custom headers', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ data: 'test' }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
// Test custom headers without body
|
||||
await httpJson('https://example.com/api', {
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
'X-Custom': 'value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/api',
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
'X-Custom': 'value',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Test merging headers with content-type when body is present
|
||||
await httpJson('https://example.com/api', {
|
||||
method: 'POST',
|
||||
body: { data: 'test' },
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/api',
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: 'Bearer token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ResponseError for non-ok responses', async () => {
|
||||
const errorCases = [
|
||||
{ status: 404, statusText: 'Not Found' },
|
||||
{ status: 401, statusText: 'Unauthorized' },
|
||||
{ status: 500, statusText: 'Internal Server Error' },
|
||||
];
|
||||
|
||||
for (const { status, statusText } of errorCases) {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status,
|
||||
statusText,
|
||||
url: 'https://example.com/api',
|
||||
text: jest.fn().mockResolvedValue('Error'),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
await expect(httpJson('https://example.com/api')).rejects.toThrow(
|
||||
`Request failed with ${status} ${statusText}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should pass through abort signal from caller', async () => {
|
||||
const abortController = new AbortController();
|
||||
let rejectFn: (error: Error) => void;
|
||||
const mockResponse = new Promise((_, reject) => {
|
||||
rejectFn = reject;
|
||||
setTimeout(() => {
|
||||
reject(new Error('Request should have been aborted'));
|
||||
}, 60000); // 60 seconds
|
||||
});
|
||||
|
||||
mockFetch.mockImplementation((_url, options) => {
|
||||
const signal = options?.signal as AbortSignal;
|
||||
signal?.addEventListener('abort', () => {
|
||||
rejectFn(new Error('The operation was aborted'));
|
||||
});
|
||||
return mockResponse as any;
|
||||
});
|
||||
|
||||
const requestPromise = httpJson('https://example.com/api', {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Abort the request
|
||||
abortController.abort();
|
||||
|
||||
await expect(requestPromise).rejects.toThrow('The operation was aborted');
|
||||
});
|
||||
|
||||
it('should handle JSON parsing errors gracefully', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
await expect(httpJson('https://example.com/api')).rejects.toThrow(
|
||||
'Invalid JSON',
|
||||
);
|
||||
});
|
||||
|
||||
it('should support different HTTP methods', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ success: true }),
|
||||
};
|
||||
|
||||
for (const method of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']) {
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
await httpJson('https://example.com/api', { method });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/api',
|
||||
expect.objectContaining({
|
||||
method,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle various response body types', async () => {
|
||||
const testCases = [
|
||||
{ body: null, expected: null },
|
||||
{ body: [1, 2, 3], expected: [1, 2, 3] },
|
||||
{ body: { data: 'test' }, expected: { data: 'test' } },
|
||||
];
|
||||
|
||||
for (const { body, expected } of testCases) {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(body),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await httpJson('https://example.com/api');
|
||||
expect(result).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const networkError = new Error('Network error');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(httpJson('https://example.com/api')).rejects.toThrow(
|
||||
'Network error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom abort signal if provided', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ data: 'test' }),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const customController = new AbortController();
|
||||
await httpJson('https://example.com/api', {
|
||||
signal: customController.signal,
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://example.com/api',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed URLs gracefully', async () => {
|
||||
const networkError = new TypeError('Failed to parse URL');
|
||||
mockFetch.mockRejectedValue(networkError);
|
||||
|
||||
await expect(httpJson('not-a-valid-url')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle very large response bodies', async () => {
|
||||
const largeData = { items: Array(10000).fill({ data: 'x'.repeat(100) }) };
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue(largeData),
|
||||
};
|
||||
mockFetch.mockResolvedValue(mockResponse as any);
|
||||
|
||||
const result = await httpJson('https://example.com/api');
|
||||
expect(result).toEqual(largeData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 fetch from 'cross-fetch';
|
||||
import { ResponseError } from '@backstage/errors';
|
||||
|
||||
type HttpInit = {
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
body?: any;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
body: init?.body ? JSON.stringify(init.body) : undefined,
|
||||
headers: {
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { startCallbackServer } from './localServer';
|
||||
|
||||
describe('localServer', () => {
|
||||
it('should start on port 8055, handle requests, and resolve the code', async () => {
|
||||
const { url, waitForCode, close } = await startCallbackServer({
|
||||
state: 'test-state',
|
||||
});
|
||||
|
||||
expect(url).toBe('http://127.0.0.1:8055/callback');
|
||||
|
||||
// 404 for non-callback paths
|
||||
const notFoundResponse = await fetch(
|
||||
url.replace('/callback', '/other-path'),
|
||||
);
|
||||
expect(notFoundResponse.status).toBe(404);
|
||||
|
||||
// 400 for missing code
|
||||
const missingCodeResponse = await fetch(`${url}?state=test-state`);
|
||||
expect(missingCodeResponse.status).toBe(400);
|
||||
expect(await missingCodeResponse.text()).toBe('Missing code');
|
||||
|
||||
// 400 for mismatched state
|
||||
const mismatchResponse = await fetch(
|
||||
`${url}?code=test-code&state=wrong-state`,
|
||||
);
|
||||
expect(mismatchResponse.status).toBe(400);
|
||||
expect(await mismatchResponse.text()).toBe('State mismatch');
|
||||
|
||||
// 200 for valid callback with matching state
|
||||
const codePromise = waitForCode();
|
||||
const specialCode = 'test-code+with/special=chars';
|
||||
const successResponse = await fetch(
|
||||
`${url}?code=${encodeURIComponent(
|
||||
specialCode,
|
||||
)}&state=${encodeURIComponent('test-state')}`,
|
||||
);
|
||||
expect(successResponse.status).toBe(200);
|
||||
expect(await successResponse.text()).toBe('You may now close this window.');
|
||||
expect(successResponse.headers.get('content-type')).toBe(
|
||||
'text/plain; charset=utf-8',
|
||||
);
|
||||
|
||||
const result = await codePromise;
|
||||
expect(result.code).toBe(specialCode);
|
||||
expect(result.state).toBe('test-state');
|
||||
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 http from 'node:http';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
const CALLBACK_PORT = 8055;
|
||||
|
||||
export async function startCallbackServer(options: { state: string }): Promise<{
|
||||
url: string;
|
||||
waitForCode: () => Promise<{ code: string; state?: string }>;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const server = http.createServer();
|
||||
|
||||
let resolveResult:
|
||||
| ((v: { code: string; state?: string }) => void)
|
||||
| undefined;
|
||||
const resultPromise = new Promise<{ code: string; state?: string }>(
|
||||
resolve => {
|
||||
resolveResult = resolve;
|
||||
},
|
||||
);
|
||||
|
||||
server.on('request', (req, res) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad Request');
|
||||
return;
|
||||
}
|
||||
const u = new URL(req.url, 'http://127.0.0.1');
|
||||
if (u.pathname !== '/callback') {
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
const code = u.searchParams.get('code') ?? undefined;
|
||||
const state = u.searchParams.get('state') ?? undefined;
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.end('Missing code');
|
||||
return;
|
||||
}
|
||||
if (state !== options.state) {
|
||||
res.statusCode = 400;
|
||||
res.end('State mismatch');
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end('You may now close this window.');
|
||||
resolveResult?.({ code, state });
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
reject(
|
||||
new Error(
|
||||
`Port ${CALLBACK_PORT} is already in use. Close the application using it and try again.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
server.listen(CALLBACK_PORT, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (typeof address === 'object' && address && 'port' in address) {
|
||||
resolve(address.port);
|
||||
} else {
|
||||
reject(new Error('Failed to bind local server'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/callback`,
|
||||
waitForCode: () => resultPromise,
|
||||
close: async () => {
|
||||
server.closeAllConnections();
|
||||
return new Promise<void>(resolve => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 crypto from 'node:crypto';
|
||||
import { generateVerifier, challengeFromVerifier } from './pkce';
|
||||
|
||||
describe('pkce', () => {
|
||||
describe('generateVerifier', () => {
|
||||
it('should generate verifiers with proper encoding and length', () => {
|
||||
// Test default length
|
||||
const defaultVerifier = generateVerifier();
|
||||
expect(defaultVerifier).toBeDefined();
|
||||
expect(typeof defaultVerifier).toBe('string');
|
||||
expect(defaultVerifier.length).toBeGreaterThan(0);
|
||||
|
||||
// Test custom lengths
|
||||
const shortVerifier = generateVerifier(32);
|
||||
const longVerifier = generateVerifier(96);
|
||||
expect(shortVerifier).toBeDefined();
|
||||
expect(longVerifier).toBeDefined();
|
||||
expect(shortVerifier.length).toBeGreaterThan(0);
|
||||
expect(longVerifier.length).toBeGreaterThan(shortVerifier.length);
|
||||
|
||||
// Test base64url encoding (no padding, proper characters)
|
||||
const verifier = generateVerifier();
|
||||
expect(verifier).not.toContain('=');
|
||||
expect(verifier).not.toContain('+');
|
||||
expect(verifier).not.toContain('/');
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
// Test uniqueness
|
||||
const verifier1 = generateVerifier();
|
||||
const verifier2 = generateVerifier();
|
||||
expect(verifier1).not.toBe(verifier2);
|
||||
});
|
||||
|
||||
it('should enforce minimum and maximum length constraints', () => {
|
||||
// Test minimum length enforcement
|
||||
const minVerifier = generateVerifier(10); // Less than minimum
|
||||
expect(minVerifier).toBeDefined();
|
||||
expect(minVerifier.length).toBeGreaterThanOrEqual(43); // 32 bytes = 43 base64url chars
|
||||
|
||||
// Test maximum length enforcement
|
||||
const maxVerifier = generateVerifier(200); // More than maximum
|
||||
expect(maxVerifier).toBeDefined();
|
||||
expect(maxVerifier.length).toBeLessThanOrEqual(128); // 96 bytes = 128 base64url chars
|
||||
});
|
||||
|
||||
it('should produce consistent results for same byte sequence', () => {
|
||||
// Mock crypto.randomBytes to return predictable values
|
||||
const mockBytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
jest.spyOn(crypto, 'randomBytes').mockImplementation(_count => mockBytes);
|
||||
|
||||
const verifier1 = generateVerifier(8);
|
||||
const verifier2 = generateVerifier(8);
|
||||
|
||||
expect(verifier1).toBe(verifier2);
|
||||
|
||||
(crypto.randomBytes as jest.Mock).mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('challengeFromVerifier', () => {
|
||||
it('should generate challenges with proper encoding and consistency', () => {
|
||||
const verifier = 'test-verifier-string';
|
||||
const challenge = challengeFromVerifier(verifier);
|
||||
|
||||
// Basic properties
|
||||
expect(challenge).toBeDefined();
|
||||
expect(typeof challenge).toBe('string');
|
||||
expect(challenge.length).toBe(43); // SHA-256 = 32 bytes = 43 base64url chars
|
||||
|
||||
// Base64url encoding (no padding, proper characters)
|
||||
expect(challenge).not.toContain('=');
|
||||
expect(challenge).not.toContain('+');
|
||||
expect(challenge).not.toContain('/');
|
||||
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
|
||||
// Consistency for same verifier
|
||||
const challenge1 = challengeFromVerifier(verifier);
|
||||
const challenge2 = challengeFromVerifier(verifier);
|
||||
expect(challenge1).toBe(challenge2);
|
||||
expect(challenge1).toBe(challenge);
|
||||
|
||||
// Different challenges for different verifiers
|
||||
const verifier2 = 'test-verifier-2';
|
||||
const challenge3 = challengeFromVerifier(verifier2);
|
||||
expect(challenge3).not.toBe(challenge);
|
||||
});
|
||||
|
||||
it('should handle edge cases for verifier length', () => {
|
||||
// Empty verifier
|
||||
const emptyChallenge = challengeFromVerifier('');
|
||||
expect(emptyChallenge).toBeDefined();
|
||||
expect(emptyChallenge.length).toBe(43);
|
||||
|
||||
// Very long verifier
|
||||
const longVerifier = 'a'.repeat(1000);
|
||||
const longChallenge = challengeFromVerifier(longVerifier);
|
||||
expect(longChallenge).toBeDefined();
|
||||
expect(longChallenge.length).toBe(43); // SHA-256 always produces 32 bytes
|
||||
});
|
||||
|
||||
it('should produce RFC 7636 compliant challenge', () => {
|
||||
// Test with a known verifier
|
||||
const verifier = generateVerifier();
|
||||
const challenge = challengeFromVerifier(verifier);
|
||||
|
||||
// Verify it's using SHA-256 correctly
|
||||
const expectedHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(verifier)
|
||||
.digest();
|
||||
const expectedChallenge = expectedHash
|
||||
.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
expect(challenge).toBe(expectedChallenge);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PKCE flow integration', () => {
|
||||
it('should generate valid verifier and challenge pair', () => {
|
||||
const verifier = generateVerifier();
|
||||
const challenge = challengeFromVerifier(verifier);
|
||||
|
||||
expect(verifier).toBeDefined();
|
||||
expect(challenge).toBeDefined();
|
||||
expect(verifier).not.toBe(challenge);
|
||||
|
||||
// Verifier should be longer than challenge
|
||||
expect(verifier.length).toBeGreaterThan(challenge.length);
|
||||
});
|
||||
|
||||
it('should generate multiple unique pairs', () => {
|
||||
const pair1 = {
|
||||
verifier: generateVerifier(),
|
||||
challenge: '',
|
||||
};
|
||||
pair1.challenge = challengeFromVerifier(pair1.verifier);
|
||||
|
||||
const pair2 = {
|
||||
verifier: generateVerifier(),
|
||||
challenge: '',
|
||||
};
|
||||
pair2.challenge = challengeFromVerifier(pair2.verifier);
|
||||
|
||||
expect(pair1.verifier).not.toBe(pair2.verifier);
|
||||
expect(pair1.challenge).not.toBe(pair2.challenge);
|
||||
});
|
||||
|
||||
it('should maintain one-to-one mapping between verifier and challenge', () => {
|
||||
const verifier = generateVerifier();
|
||||
const challenge1 = challengeFromVerifier(verifier);
|
||||
const challenge2 = challengeFromVerifier(verifier);
|
||||
const challenge3 = challengeFromVerifier(verifier);
|
||||
|
||||
// All challenges from same verifier should be identical
|
||||
expect(challenge1).toBe(challenge2);
|
||||
expect(challenge2).toBe(challenge3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 crypto from 'node:crypto';
|
||||
|
||||
function base64url(input: Buffer): string {
|
||||
return input
|
||||
.toString('base64')
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
export function generateVerifier(length = 64): string {
|
||||
// length in bytes ~ 48 results in 64 base64url chars; keep within 43..128 chars
|
||||
const bytes = crypto.randomBytes(Math.max(32, Math.min(96, length)));
|
||||
return base64url(bytes);
|
||||
}
|
||||
|
||||
export function challengeFromVerifier(verifier: string): string {
|
||||
const hash = crypto.createHash('sha256').update(verifier).digest();
|
||||
return base64url(hash);
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 inquirer from 'inquirer';
|
||||
import { pickInstance } from './prompt';
|
||||
import * as storage from './storage';
|
||||
|
||||
jest.mock('inquirer');
|
||||
jest.mock('./storage');
|
||||
|
||||
const mockStorage = storage as jest.Mocked<typeof storage>;
|
||||
const mockInquirer = inquirer as jest.Mocked<typeof inquirer>;
|
||||
|
||||
describe('prompt', () => {
|
||||
describe('pickInstance', () => {
|
||||
const mockInstances = [
|
||||
{
|
||||
name: 'production',
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
clientId: 'prod-client',
|
||||
issuedAt: Date.now(),
|
||||
accessToken: 'prod-token',
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
name: 'staging',
|
||||
baseUrl: 'https://staging.backstage.example.com',
|
||||
clientId: 'staging-client',
|
||||
issuedAt: Date.now(),
|
||||
accessToken: 'staging-token',
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
name: 'local',
|
||||
baseUrl: 'http://localhost:7007',
|
||||
clientId: 'local-client',
|
||||
issuedAt: Date.now(),
|
||||
accessToken: 'local-token',
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return instance by name if provided', async () => {
|
||||
mockStorage.getInstanceByName.mockResolvedValue(mockInstances[1]);
|
||||
|
||||
const result = await pickInstance('staging');
|
||||
|
||||
expect(result).toEqual(mockInstances[1]);
|
||||
expect(mockStorage.getInstanceByName).toHaveBeenCalledWith('staging');
|
||||
expect(mockInquirer.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prompt for instance and show selected instance with asterisk prefix', async () => {
|
||||
// Test with production selected
|
||||
mockStorage.getAllInstances.mockResolvedValue({
|
||||
instances: mockInstances,
|
||||
selected: mockInstances[0],
|
||||
});
|
||||
mockInquirer.prompt.mockResolvedValue({ choice: 'staging' });
|
||||
|
||||
const result = await pickInstance();
|
||||
|
||||
expect(mockInquirer.prompt).toHaveBeenCalledWith([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Select instance:',
|
||||
choices: [
|
||||
{
|
||||
name: '* production (https://backstage.example.com)',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
name: ' staging (https://staging.backstage.example.com)',
|
||||
value: 'staging',
|
||||
},
|
||||
{
|
||||
name: ' local (http://localhost:7007)',
|
||||
value: 'local',
|
||||
},
|
||||
],
|
||||
default: 'production',
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual(mockInstances[1]);
|
||||
|
||||
// Test with staging selected
|
||||
mockStorage.getAllInstances.mockResolvedValue({
|
||||
instances: mockInstances,
|
||||
selected: mockInstances[1],
|
||||
});
|
||||
mockInquirer.prompt.mockResolvedValue({ choice: 'staging' });
|
||||
|
||||
await pickInstance();
|
||||
|
||||
expect(mockInquirer.prompt).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
choices: [
|
||||
{
|
||||
name: ' production (https://backstage.example.com)',
|
||||
value: 'production',
|
||||
},
|
||||
{
|
||||
name: '* staging (https://staging.backstage.example.com)',
|
||||
value: 'staging',
|
||||
},
|
||||
{
|
||||
name: ' local (http://localhost:7007)',
|
||||
value: 'local',
|
||||
},
|
||||
],
|
||||
default: 'staging',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error if no instances are available', async () => {
|
||||
mockStorage.getAllInstances.mockResolvedValue({
|
||||
instances: [],
|
||||
selected: undefined,
|
||||
});
|
||||
|
||||
await expect(pickInstance()).rejects.toThrow(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if selected instance is not found', async () => {
|
||||
mockStorage.getAllInstances.mockResolvedValue({
|
||||
instances: mockInstances,
|
||||
selected: mockInstances[0],
|
||||
});
|
||||
mockInquirer.prompt.mockResolvedValue({ choice: 'non-existent' });
|
||||
|
||||
await expect(pickInstance()).rejects.toThrow(
|
||||
"Instance 'non-existent' not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle single instance and use selected instance as default', async () => {
|
||||
// Test single instance
|
||||
const singleInstance = [mockInstances[0]];
|
||||
mockStorage.getAllInstances.mockResolvedValue({
|
||||
instances: singleInstance,
|
||||
selected: mockInstances[0],
|
||||
});
|
||||
mockInquirer.prompt.mockResolvedValue({ choice: 'production' });
|
||||
|
||||
const result = await pickInstance();
|
||||
|
||||
expect(result).toEqual(mockInstances[0]);
|
||||
expect(mockInquirer.prompt).toHaveBeenCalled();
|
||||
|
||||
// Test default selection matches selected instance
|
||||
const selectedInstance = mockInstances[2];
|
||||
mockStorage.getAllInstances.mockResolvedValue({
|
||||
instances: mockInstances,
|
||||
selected: selectedInstance,
|
||||
});
|
||||
mockInquirer.prompt.mockResolvedValue({ choice: 'local' });
|
||||
|
||||
await pickInstance();
|
||||
|
||||
expect(mockInquirer.prompt).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
default: 'local',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 inquirer from 'inquirer';
|
||||
import { getInstanceByName, getAllInstances, StoredInstance } from './storage';
|
||||
|
||||
export async function pickInstance(name?: string): Promise<StoredInstance> {
|
||||
if (name) {
|
||||
return getInstanceByName(name);
|
||||
}
|
||||
|
||||
const { instances, selected } = await getAllInstances();
|
||||
if (instances.length === 0) {
|
||||
throw new Error(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
}
|
||||
return await promptForInstance(instances, selected);
|
||||
}
|
||||
|
||||
async function promptForInstance(
|
||||
instances: StoredInstance[],
|
||||
selected: StoredInstance | undefined,
|
||||
): Promise<StoredInstance> {
|
||||
const choices = instances.map(i => ({
|
||||
name: `${i.name === selected?.name ? '* ' : ' '}${i.name} (${i.baseUrl})`,
|
||||
value: i.name,
|
||||
}));
|
||||
|
||||
const { choice } = await inquirer.prompt<{ choice: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'choice',
|
||||
message: 'Select instance:',
|
||||
choices,
|
||||
default: selected?.name,
|
||||
},
|
||||
]);
|
||||
|
||||
const instance = instances.find(i => i.name === choice);
|
||||
if (!instance) {
|
||||
throw new Error(`Instance '${choice}' not found`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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.
|
||||
*/
|
||||
|
||||
jest.mock('keytar', () => {
|
||||
throw new Error('keytar not available');
|
||||
});
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { getSecretStore, resetSecretStore } from './secretStore';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
describe('secretStore', () => {
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
process.env.XDG_DATA_HOME = mockDir.resolve('data');
|
||||
resetSecretStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
resetSecretStore();
|
||||
});
|
||||
|
||||
describe('FileSecretStore', () => {
|
||||
it('should store and retrieve secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'test-secret');
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
|
||||
expect(result).toBe('test-secret');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should delete secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'test-secret');
|
||||
|
||||
let result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBe('test-secret');
|
||||
|
||||
await store.delete('test-service', 'test-account');
|
||||
|
||||
result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not throw when deleting non-existent secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
|
||||
await expect(
|
||||
store.delete('non-existent-service', 'non-existent-account'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should create files with correct directory structure', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'test-secret');
|
||||
|
||||
const expectedDir = path.join(
|
||||
mockDir.resolve('data'),
|
||||
'backstage-cli',
|
||||
'auth-secrets',
|
||||
encodeURIComponent('test-service'),
|
||||
);
|
||||
const expectedFile = path.join(
|
||||
expectedDir,
|
||||
`${encodeURIComponent('test-account')}.secret`,
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(expectedFile)).toBe(true);
|
||||
expect(await fs.pathExists(expectedDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create files with correct permissions (0o600)', async () => {
|
||||
// File permissions are not reliably enforced on Windows
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'test-secret');
|
||||
|
||||
const expectedFile = path.join(
|
||||
mockDir.resolve('data'),
|
||||
'backstage-cli',
|
||||
'auth-secrets',
|
||||
encodeURIComponent('test-service'),
|
||||
`${encodeURIComponent('test-account')}.secret`,
|
||||
);
|
||||
|
||||
const stats = await fs.stat(expectedFile);
|
||||
const mode = stats.mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it('should encode service and account names in file path', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('my-service/test', 'my-account@test', 'test-secret');
|
||||
|
||||
const result = await store.get('my-service/test', 'my-account@test');
|
||||
expect(result).toBe('test-secret');
|
||||
|
||||
const expectedFile = path.join(
|
||||
mockDir.resolve('data'),
|
||||
'backstage-cli',
|
||||
'auth-secrets',
|
||||
encodeURIComponent('my-service/test'),
|
||||
`${encodeURIComponent('my-account@test')}.secret`,
|
||||
);
|
||||
expect(await fs.pathExists(expectedFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle unicode characters in service and account names', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('service-测试', 'account-🚀', 'test-secret');
|
||||
|
||||
const result = await store.get('service-测试', 'account-🚀');
|
||||
expect(result).toBe('test-secret');
|
||||
});
|
||||
|
||||
it('should handle multiple secrets for same service', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'account1', 'secret1');
|
||||
await store.set('test-service', 'account2', 'secret2');
|
||||
|
||||
const result1 = await store.get('test-service', 'account1');
|
||||
const result2 = await store.get('test-service', 'account2');
|
||||
|
||||
expect(result1).toBe('secret1');
|
||||
expect(result2).toBe('secret2');
|
||||
});
|
||||
|
||||
it('should handle multiple services', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('service1', 'account', 'secret1');
|
||||
await store.set('service2', 'account', 'secret2');
|
||||
|
||||
const result1 = await store.get('service1', 'account');
|
||||
const result2 = await store.get('service2', 'account');
|
||||
|
||||
expect(result1).toBe('secret1');
|
||||
expect(result2).toBe('secret2');
|
||||
});
|
||||
|
||||
it('should update existing secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'old-secret');
|
||||
await store.set('test-service', 'test-account', 'new-secret');
|
||||
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBe('new-secret');
|
||||
});
|
||||
|
||||
it('should handle empty string secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', '');
|
||||
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long secrets', async () => {
|
||||
const store = await getSecretStore();
|
||||
const longSecret = 'a'.repeat(10000);
|
||||
await store.set('test-service', 'test-account', longSecret);
|
||||
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBe(longSecret);
|
||||
});
|
||||
|
||||
it('should use XDG_DATA_HOME when set', async () => {
|
||||
const customDataHome = mockDir.resolve('custom-data');
|
||||
process.env.XDG_DATA_HOME = customDataHome;
|
||||
resetSecretStore();
|
||||
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'test-secret');
|
||||
|
||||
const expectedFile = path.join(
|
||||
customDataHome,
|
||||
'backstage-cli',
|
||||
'auth-secrets',
|
||||
encodeURIComponent('test-service'),
|
||||
`${encodeURIComponent('test-account')}.secret`,
|
||||
);
|
||||
expect(await fs.pathExists(expectedFile)).toBe(true);
|
||||
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBe('test-secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecretStore singleton', () => {
|
||||
it('should return the same instance on multiple calls', async () => {
|
||||
const store1 = await getSecretStore();
|
||||
const store2 = await getSecretStore();
|
||||
|
||||
expect(store1).toBe(store2);
|
||||
});
|
||||
|
||||
it('should create new instance after reset', async () => {
|
||||
const store1 = await getSecretStore();
|
||||
resetSecretStore();
|
||||
const store2 = await getSecretStore();
|
||||
|
||||
expect(store1).not.toBe(store2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('should fall back to FileSecretStore when keytar is not available', async () => {
|
||||
const store = await getSecretStore();
|
||||
await store.set('test-service', 'test-account', 'test-secret');
|
||||
|
||||
const result = await store.get('test-service', 'test-account');
|
||||
expect(result).toBe('test-secret');
|
||||
|
||||
const expectedFile = path.join(
|
||||
mockDir.resolve('data'),
|
||||
'backstage-cli',
|
||||
'auth-secrets',
|
||||
encodeURIComponent('test-service'),
|
||||
`${encodeURIComponent('test-account')}.secret`,
|
||||
);
|
||||
expect(await fs.pathExists(expectedFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
type SecretStore = {
|
||||
get(service: string, account: string): Promise<string | undefined>;
|
||||
set(service: string, account: string, secret: string): Promise<void>;
|
||||
delete(service: string, account: string): Promise<void>;
|
||||
};
|
||||
|
||||
async function loadKeytar(): Promise<typeof import('keytar') | undefined> {
|
||||
try {
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies, @backstage/no-undeclared-imports
|
||||
const keytar = require('keytar') as typeof import('keytar');
|
||||
if (keytar && typeof keytar.getPassword === 'function') {
|
||||
return keytar;
|
||||
}
|
||||
} catch {
|
||||
// keytar not available
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class KeytarSecretStore implements SecretStore {
|
||||
private readonly keytar: typeof import('keytar');
|
||||
constructor(keytar: typeof import('keytar')) {
|
||||
this.keytar = keytar;
|
||||
}
|
||||
async get(service: string, account: string): Promise<string | undefined> {
|
||||
const result = await this.keytar.getPassword(service, account);
|
||||
return result ?? undefined;
|
||||
}
|
||||
async set(service: string, account: string, secret: string): Promise<void> {
|
||||
await this.keytar.setPassword(service, account, secret);
|
||||
}
|
||||
async delete(service: string, account: string): Promise<void> {
|
||||
await this.keytar.deletePassword(service, account);
|
||||
}
|
||||
}
|
||||
|
||||
class FileSecretStore implements SecretStore {
|
||||
private readonly baseDir: string;
|
||||
constructor() {
|
||||
const root =
|
||||
process.env.XDG_DATA_HOME ||
|
||||
(process.platform === 'win32'
|
||||
? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
: path.join(os.homedir(), '.local', 'share'));
|
||||
this.baseDir = path.join(root, 'backstage-cli', 'auth-secrets');
|
||||
}
|
||||
private filePath(service: string, account: string): string {
|
||||
return path.join(
|
||||
this.baseDir,
|
||||
encodeURIComponent(service),
|
||||
`${encodeURIComponent(account)}.secret`,
|
||||
);
|
||||
}
|
||||
async get(service: string, account: string): Promise<string | undefined> {
|
||||
const file = this.filePath(service, account);
|
||||
if (!(await fs.pathExists(file))) return undefined;
|
||||
return await fs.readFile(file, 'utf8');
|
||||
}
|
||||
async set(service: string, account: string, secret: string): Promise<void> {
|
||||
const file = this.filePath(service, account);
|
||||
await fs.ensureDir(path.dirname(file));
|
||||
await fs.writeFile(file, secret, { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
async delete(service: string, account: string): Promise<void> {
|
||||
const file = this.filePath(service, account);
|
||||
await fs.remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: SecretStore | undefined;
|
||||
|
||||
export async function getSecretStore(): Promise<SecretStore> {
|
||||
if (!singleton) {
|
||||
const keytar = await loadKeytar();
|
||||
if (keytar) {
|
||||
singleton = new KeytarSecretStore(keytar);
|
||||
} else {
|
||||
singleton = new FileSecretStore();
|
||||
}
|
||||
}
|
||||
return singleton;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (for testing purposes only)
|
||||
* @internal
|
||||
*/
|
||||
export function resetSecretStore(): void {
|
||||
singleton = undefined;
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import { NotFoundError } from '@backstage/errors';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import {
|
||||
getAllInstances,
|
||||
getSelectedInstance,
|
||||
getInstanceByName,
|
||||
upsertInstance,
|
||||
removeInstance,
|
||||
setSelectedInstance,
|
||||
withMetadataLock,
|
||||
StoredInstance,
|
||||
} from './storage';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
describe('storage', () => {
|
||||
const mockInstance1: StoredInstance = {
|
||||
name: 'production',
|
||||
baseUrl: 'https://backstage.example.com',
|
||||
clientId: 'prod-client',
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
selected: true,
|
||||
};
|
||||
|
||||
const mockInstance2: StoredInstance = {
|
||||
name: 'staging',
|
||||
baseUrl: 'https://staging.backstage.example.com',
|
||||
clientId: 'staging-client',
|
||||
issuedAt: Date.now(),
|
||||
accessTokenExpiresAt: Date.now() + 3600_000,
|
||||
selected: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockDir.clear();
|
||||
process.env.XDG_CONFIG_HOME = mockDir.resolve('config');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
});
|
||||
|
||||
describe('getAllInstances', () => {
|
||||
it('should return empty array if file does not exist or is empty', async () => {
|
||||
const result1 = await getAllInstances();
|
||||
expect(result1).toEqual({ instances: [], selected: undefined });
|
||||
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': '',
|
||||
});
|
||||
|
||||
const result2 = await getAllInstances();
|
||||
expect(result2).toEqual({ instances: [], selected: undefined });
|
||||
});
|
||||
|
||||
it('should parse and return instances from YAML', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
selected: true
|
||||
- name: staging
|
||||
baseUrl: https://staging.backstage.example.com
|
||||
clientId: staging-client
|
||||
issuedAt: ${mockInstance2.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getAllInstances();
|
||||
|
||||
expect(result.instances).toHaveLength(2);
|
||||
expect(result.selected?.name).toBe('production');
|
||||
});
|
||||
|
||||
it('should select first instance if none marked as selected', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
- name: staging
|
||||
baseUrl: https://staging.backstage.example.com
|
||||
clientId: staging-client
|
||||
issuedAt: ${mockInstance2.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getAllInstances();
|
||||
|
||||
expect(result.selected?.name).toBe('production');
|
||||
});
|
||||
|
||||
it('should return empty array if YAML parsing fails', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': 'invalid: yaml: [',
|
||||
});
|
||||
|
||||
const result = await getAllInstances();
|
||||
|
||||
expect(result).toEqual({ instances: [], selected: undefined });
|
||||
});
|
||||
|
||||
it('should normalize selected property across instances', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
selected: true
|
||||
- name: staging
|
||||
baseUrl: https://staging.backstage.example.com
|
||||
clientId: staging-client
|
||||
issuedAt: ${mockInstance2.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
|
||||
selected: true
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getAllInstances();
|
||||
|
||||
const selectedCount = result.instances.filter(i => i.selected).length;
|
||||
expect(selectedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSelectedInstance', () => {
|
||||
it('should return instance by name if provided', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getSelectedInstance('production');
|
||||
|
||||
expect(result.name).toBe('production');
|
||||
});
|
||||
|
||||
it('should return selected instance if no name provided', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
- name: staging
|
||||
baseUrl: https://staging.backstage.example.com
|
||||
clientId: staging-client
|
||||
issuedAt: ${mockInstance2.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
|
||||
selected: true
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getSelectedInstance();
|
||||
|
||||
expect(result.name).toBe('staging');
|
||||
});
|
||||
|
||||
it('should throw error if no instances exist', async () => {
|
||||
await expect(getSelectedInstance()).rejects.toThrow(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstanceByName', () => {
|
||||
it('should return instance with matching name', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
const result = await getInstanceByName('production');
|
||||
|
||||
expect(result.name).toBe('production');
|
||||
});
|
||||
|
||||
it('should throw NotFoundError if instance does not exist', async () => {
|
||||
await expect(getInstanceByName('nonexistent')).rejects.toThrow(
|
||||
NotFoundError,
|
||||
);
|
||||
await expect(getInstanceByName('nonexistent')).rejects.toThrow(
|
||||
"Instance 'nonexistent' not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertInstance', () => {
|
||||
it('should add new instance if it does not exist', async () => {
|
||||
await upsertInstance(mockInstance1);
|
||||
|
||||
const result = await getAllInstances();
|
||||
expect(result.instances).toHaveLength(1);
|
||||
expect(result.instances[0].name).toBe('production');
|
||||
});
|
||||
|
||||
it('should update existing instance', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
const updatedInstance = {
|
||||
...mockInstance1,
|
||||
clientId: 'updated-client',
|
||||
};
|
||||
|
||||
await upsertInstance(updatedInstance);
|
||||
|
||||
const result = await getInstanceByName('production');
|
||||
expect(result.clientId).toBe('updated-client');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeInstance', () => {
|
||||
it('should remove instance with matching name', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
- name: staging
|
||||
baseUrl: https://staging.backstage.example.com
|
||||
clientId: staging-client
|
||||
issuedAt: ${mockInstance2.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
await removeInstance('production');
|
||||
|
||||
const result = await getAllInstances();
|
||||
expect(result.instances).toHaveLength(1);
|
||||
expect(result.instances[0].name).toBe('staging');
|
||||
});
|
||||
|
||||
it('should do nothing if instance does not exist', async () => {
|
||||
await removeInstance('nonexistent');
|
||||
|
||||
const result = await getAllInstances();
|
||||
expect(result.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSelectedInstance', () => {
|
||||
it('should set selected instance and unselect others', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
baseUrl: https://backstage.example.com
|
||||
clientId: prod-client
|
||||
issuedAt: ${mockInstance1.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance1.accessTokenExpiresAt}
|
||||
selected: true
|
||||
- name: staging
|
||||
baseUrl: https://staging.backstage.example.com
|
||||
clientId: staging-client
|
||||
issuedAt: ${mockInstance2.issuedAt}
|
||||
|
||||
accessTokenExpiresAt: ${mockInstance2.accessTokenExpiresAt}
|
||||
`,
|
||||
});
|
||||
|
||||
await setSelectedInstance('staging');
|
||||
|
||||
const result = await getAllInstances();
|
||||
expect(result.selected?.name).toBe('staging');
|
||||
|
||||
const prodInstance = result.instances.find(i => i.name === 'production');
|
||||
expect(prodInstance?.selected).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if instance does not exist', async () => {
|
||||
await expect(setSelectedInstance('nonexistent')).rejects.toThrow(
|
||||
"Unknown instance 'nonexistent'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withMetadataLock', () => {
|
||||
it('should acquire and release lock', async () => {
|
||||
const callback = jest.fn().mockResolvedValue('result');
|
||||
const result = await withMetadataLock(callback);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
expect(result).toBe('result');
|
||||
});
|
||||
|
||||
it('should release lock even if callback throws', async () => {
|
||||
const error = new Error('Test error');
|
||||
const callback = jest.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(withMetadataLock(callback)).rejects.toThrow(error);
|
||||
|
||||
// Lock should still be released, allowing subsequent calls
|
||||
const callback2 = jest.fn().mockResolvedValue('result');
|
||||
await expect(withMetadataLock(callback2)).resolves.toBe('result');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path resolution', () => {
|
||||
it('should use XDG_CONFIG_HOME when set', async () => {
|
||||
const customConfigHome = mockDir.resolve('custom-config');
|
||||
process.env.XDG_CONFIG_HOME = customConfigHome;
|
||||
|
||||
await upsertInstance(mockInstance1);
|
||||
|
||||
const result = await getAllInstances();
|
||||
expect(result.instances).toHaveLength(1);
|
||||
expect(result.instances[0].name).toBe('production');
|
||||
|
||||
// Verify file was created in custom location
|
||||
const expectedFile = path.join(
|
||||
customConfigHome,
|
||||
'backstage-cli',
|
||||
'auth-instances.yaml',
|
||||
);
|
||||
expect(await fs.pathExists(expectedFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should create files with correct permissions (0o600)', async () => {
|
||||
// File permissions are not reliably enforced on Windows
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertInstance(mockInstance1);
|
||||
|
||||
const file = path.join(
|
||||
mockDir.resolve('config'),
|
||||
'backstage-cli',
|
||||
'auth-instances.yaml',
|
||||
);
|
||||
const stats = await fs.stat(file);
|
||||
const mode = stats.mode & 0o777;
|
||||
expect(mode).toBe(0o600);
|
||||
});
|
||||
|
||||
it('should handle invalid schema and missing fields gracefully', async () => {
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: ""
|
||||
baseUrl: not-a-url
|
||||
clientId: ""
|
||||
`,
|
||||
});
|
||||
|
||||
const result1 = await getAllInstances();
|
||||
expect(result1.instances).toHaveLength(0);
|
||||
|
||||
mockDir.setContent({
|
||||
'config/backstage-cli/auth-instances.yaml': `instances:
|
||||
- name: production
|
||||
`,
|
||||
});
|
||||
|
||||
const result2 = await getAllInstances();
|
||||
expect(result2.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { NotFoundError } from '@backstage/errors';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import lockfile from 'proper-lockfile';
|
||||
import YAML from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const METADATA_FILE = 'auth-instances.yaml';
|
||||
|
||||
const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/;
|
||||
|
||||
const storedInstanceSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.regex(INSTANCE_NAME_PATTERN, 'Instance name contains invalid characters'),
|
||||
baseUrl: z.string().url(),
|
||||
clientId: z.string().min(1),
|
||||
issuedAt: z.number().int().nonnegative(),
|
||||
accessTokenExpiresAt: z.number().int().nonnegative(),
|
||||
selected: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type StoredInstance = z.infer<typeof storedInstanceSchema>;
|
||||
|
||||
const authYamlSchema = z.object({
|
||||
instances: z.array(storedInstanceSchema).default([]),
|
||||
});
|
||||
|
||||
function getMetadataFilePath(): string {
|
||||
const root =
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
(process.platform === 'win32'
|
||||
? process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
|
||||
: path.join(os.homedir(), '.config'));
|
||||
|
||||
return path.join(root, 'backstage-cli', METADATA_FILE);
|
||||
}
|
||||
|
||||
async function readAll(): Promise<{ instances: StoredInstance[] }> {
|
||||
const file = getMetadataFilePath();
|
||||
if (!(await fs.pathExists(file))) {
|
||||
return { instances: [] };
|
||||
}
|
||||
const text = await fs.readFile(file, 'utf8');
|
||||
if (!text.trim()) {
|
||||
return { instances: [] };
|
||||
}
|
||||
try {
|
||||
const doc = YAML.parse(text);
|
||||
const parsed = authYamlSchema.safeParse(doc);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
return { instances: [] };
|
||||
} catch {
|
||||
return { instances: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAll(data: { instances: StoredInstance[] }): Promise<void> {
|
||||
const file = getMetadataFilePath();
|
||||
await fs.ensureDir(path.dirname(file));
|
||||
const yaml = YAML.stringify(authYamlSchema.parse(data), { indentSeq: false });
|
||||
await fs.writeFile(file, yaml, { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
|
||||
export async function getAllInstances(): Promise<{
|
||||
instances: StoredInstance[];
|
||||
selected: StoredInstance | undefined;
|
||||
}> {
|
||||
const { instances } = await readAll();
|
||||
const selected = instances.find(i => i.selected) ?? instances[0];
|
||||
return {
|
||||
// Normalize selection prop
|
||||
instances: instances.map(i => ({
|
||||
...i,
|
||||
selected: i.name === selected.name,
|
||||
})),
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSelectedInstance(
|
||||
instanceName?: string,
|
||||
): Promise<StoredInstance> {
|
||||
if (instanceName) {
|
||||
return await getInstanceByName(instanceName);
|
||||
}
|
||||
const { selected } = await getAllInstances();
|
||||
if (!selected) {
|
||||
throw new Error(
|
||||
'No instances found. Run "auth login" to authenticate first.',
|
||||
);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
export async function getInstanceByName(name: string): Promise<StoredInstance> {
|
||||
const { instances } = await readAll();
|
||||
const instance = instances.find(i => i.name === name);
|
||||
if (!instance) {
|
||||
throw new NotFoundError(`Instance '${name}' not found`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export async function upsertInstance(instance: StoredInstance): Promise<void> {
|
||||
const data = await readAll();
|
||||
const idx = data.instances.findIndex(i => i.name === instance.name);
|
||||
if (idx === -1) {
|
||||
data.instances.push(instance);
|
||||
} else {
|
||||
data.instances[idx] = instance;
|
||||
}
|
||||
await writeAll(data);
|
||||
}
|
||||
|
||||
export async function removeInstance(name: string): Promise<void> {
|
||||
const data = await readAll();
|
||||
const next = data.instances.filter(i => i.name !== name);
|
||||
if (next.length !== data.instances.length) {
|
||||
await writeAll({ instances: next });
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSelectedInstance(name: string): Promise<void> {
|
||||
return withMetadataLock(async () => {
|
||||
const data = await readAll();
|
||||
let found = false;
|
||||
data.instances = data.instances.map(i => {
|
||||
if (i.name === name) {
|
||||
found = true;
|
||||
return { ...i, selected: true };
|
||||
}
|
||||
const { selected, ...rest } = i;
|
||||
return { ...rest, selected: false };
|
||||
});
|
||||
if (!found) {
|
||||
throw new Error(`Unknown instance '${name}'`);
|
||||
}
|
||||
await writeAll(data);
|
||||
});
|
||||
}
|
||||
|
||||
export async function withMetadataLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const file = getMetadataFilePath();
|
||||
await fs.ensureDir(path.dirname(file));
|
||||
if (!(await fs.pathExists(file))) {
|
||||
await fs.writeFile(file, '', { encoding: 'utf8', mode: 0o600 });
|
||||
}
|
||||
const release = await lockfile.lock(file, {
|
||||
retries: { retries: 5, factor: 1.5, minTimeout: 100, maxTimeout: 1000 },
|
||||
});
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +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 fs from 'fs-extra';
|
||||
import { cli } from 'cleye';
|
||||
import { createDistWorkspace } from '../lib/packager';
|
||||
import type { CliCommandContext } from '../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
// Normalize legacy --alwaysYarnPack alias (a genuinely different name, not
|
||||
// just a casing variant — type-flag handles camelCase/kebab-case natively)
|
||||
const normalizedArgs = args.map(a => {
|
||||
if (a === '--alwaysYarnPack') {
|
||||
return '--always-pack';
|
||||
}
|
||||
if (a.startsWith('--alwaysYarnPack=')) {
|
||||
return `--always-pack${a.substring('--alwaysYarnPack'.length)}`;
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
const {
|
||||
flags: { alwaysPack },
|
||||
_: positionals,
|
||||
} = cli(
|
||||
{
|
||||
help: { ...info, usage: `${info.usage} <workspace-dir> [packages...]` },
|
||||
booleanFlagNegation: true,
|
||||
parameters: ['<workspace-dir>', '[packages...]'],
|
||||
flags: {
|
||||
alwaysPack: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
normalizedArgs,
|
||||
);
|
||||
|
||||
const [dir, ...packages] = positionals;
|
||||
|
||||
if (!(await fs.pathExists(dir))) {
|
||||
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
|
||||
}
|
||||
|
||||
await createDistWorkspace(packages, {
|
||||
targetDir: dir,
|
||||
alwaysPack,
|
||||
enableFeatureDetection: true,
|
||||
});
|
||||
};
|
||||
@@ -1,163 +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 { cli } from 'cleye';
|
||||
import fs from 'fs-extra';
|
||||
import { buildPackage, Output } from '../../../lib/builder';
|
||||
import { findRoleFromCommand } from '../../../lib/role';
|
||||
import {
|
||||
BackstagePackageJson,
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
} from '@backstage/cli-node';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { buildFrontend } from '../../../lib/buildFrontend';
|
||||
import { buildBackend } from '../../../lib/buildBackend';
|
||||
import { isValidUrl } from '../../../lib/urls';
|
||||
import chalk from 'chalk';
|
||||
import type { CliCommandContext } from '../../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: {
|
||||
role,
|
||||
minify,
|
||||
skipBuildDependencies,
|
||||
stats,
|
||||
config,
|
||||
moduleFederation,
|
||||
},
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
booleanFlagNegation: true,
|
||||
flags: {
|
||||
role: {
|
||||
type: String,
|
||||
description: 'Run the command with an explicit package role',
|
||||
},
|
||||
minify: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'Minify the generated code. Does not apply to app package (app is minified by default).',
|
||||
},
|
||||
skipBuildDependencies: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'Skip the automatic building of local dependencies. Applies to backend packages only.',
|
||||
},
|
||||
stats: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
|
||||
},
|
||||
config: {
|
||||
type: [String],
|
||||
description:
|
||||
'Config files to load instead of app-config.yaml. Applies to app packages only.',
|
||||
default: [],
|
||||
},
|
||||
moduleFederation: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const webpack = process.env.LEGACY_WEBPACK_BUILD
|
||||
? (require('webpack') as typeof import('webpack'))
|
||||
: undefined;
|
||||
|
||||
const resolvedRole = await findRoleFromCommand({ role });
|
||||
|
||||
if (resolvedRole === 'frontend' || resolvedRole === 'backend') {
|
||||
const configPaths = config.map(arg => {
|
||||
if (isValidUrl(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return targetPaths.resolve(arg);
|
||||
});
|
||||
|
||||
if (resolvedRole === 'frontend') {
|
||||
return buildFrontend({
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths,
|
||||
writeStats: Boolean(stats),
|
||||
webpack,
|
||||
});
|
||||
}
|
||||
return buildBackend({
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths,
|
||||
skipBuildDependencies: Boolean(skipBuildDependencies),
|
||||
minify: Boolean(minify),
|
||||
});
|
||||
}
|
||||
|
||||
let isModuleFederationRemote: boolean | undefined = undefined;
|
||||
if ((resolvedRole as string) === 'frontend-dynamic-container') {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ WARNING: The 'frontend-dynamic-container' package role is experimental and will receive immediate breaking changes in the future.`,
|
||||
),
|
||||
);
|
||||
isModuleFederationRemote = true;
|
||||
}
|
||||
if (moduleFederation) {
|
||||
isModuleFederationRemote = true;
|
||||
}
|
||||
|
||||
if (isModuleFederationRemote) {
|
||||
console.log('Building package as a module federation remote');
|
||||
return buildFrontend({
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths: [],
|
||||
writeStats: Boolean(stats),
|
||||
isModuleFederationRemote,
|
||||
webpack,
|
||||
});
|
||||
}
|
||||
|
||||
const roleInfo = PackageRoles.getRoleInfo(resolvedRole);
|
||||
|
||||
const outputs = new Set<Output>();
|
||||
|
||||
if (roleInfo.output.includes('cjs')) {
|
||||
outputs.add(Output.cjs);
|
||||
}
|
||||
if (roleInfo.output.includes('esm')) {
|
||||
outputs.add(Output.esm);
|
||||
}
|
||||
if (roleInfo.output.includes('types')) {
|
||||
outputs.add(Output.types);
|
||||
}
|
||||
|
||||
const packageJson = (await fs.readJson(
|
||||
targetPaths.resolve('package.json'),
|
||||
)) as BackstagePackageJson;
|
||||
|
||||
return buildPackage({
|
||||
outputs,
|
||||
packageJson,
|
||||
minify: Boolean(minify),
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { default } from './command';
|
||||
@@ -1,27 +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 { cli } from 'cleye';
|
||||
import fs from 'fs-extra';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import type { CliCommandContext } from '../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info, booleanFlagNegation: true }, undefined, args);
|
||||
await fs.remove(targetPaths.resolve('dist'));
|
||||
await fs.remove(targetPaths.resolve('dist-types'));
|
||||
await fs.remove(targetPaths.resolve('coverage'));
|
||||
};
|
||||
@@ -1,25 +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 { cli } from 'cleye';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import { revertProductionPack } from '../../lib/packager/productionPack';
|
||||
import type { CliCommandContext } from '../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info, booleanFlagNegation: true }, undefined, args);
|
||||
await revertProductionPack(targetPaths.dir);
|
||||
};
|
||||
@@ -1,37 +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 { cli } from 'cleye';
|
||||
import fs from 'fs-extra';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import { productionPack } from '../../lib/packager/productionPack';
|
||||
import { publishPreflightCheck } from '../../lib/publishing';
|
||||
import { createTypeDistProject } from '../../lib/typeDistProject';
|
||||
import type { CliCommandContext } from '../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info, booleanFlagNegation: true }, undefined, args);
|
||||
|
||||
publishPreflightCheck({
|
||||
dir: targetPaths.dir,
|
||||
packageJson: await fs.readJson(targetPaths.resolve('package.json')),
|
||||
});
|
||||
|
||||
await productionPack({
|
||||
packageDir: targetPaths.dir,
|
||||
featureDetectionProject: await createTypeDistProject(),
|
||||
});
|
||||
};
|
||||
@@ -1,94 +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 { cli } from 'cleye';
|
||||
import { startPackage } from './startPackage';
|
||||
import { resolveLinkedWorkspace } from './resolveLinkedWorkspace';
|
||||
import { findRoleFromCommand } from '../../../lib/role';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import type { CliCommandContext } from '../../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: {
|
||||
config,
|
||||
role,
|
||||
check,
|
||||
require: requirePath,
|
||||
link,
|
||||
entrypoint,
|
||||
inspect,
|
||||
inspectBrk,
|
||||
},
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
booleanFlagNegation: true,
|
||||
flags: {
|
||||
config: {
|
||||
type: [String],
|
||||
description: 'Config files to load instead of app-config.yaml',
|
||||
default: [],
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
description: 'Run the command with an explicit package role',
|
||||
},
|
||||
check: {
|
||||
type: Boolean,
|
||||
description: 'Enable type checking and linting if available',
|
||||
},
|
||||
require: {
|
||||
type: String,
|
||||
description: 'Add a --require argument to the node process',
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
description: 'Link an external workspace for module resolution',
|
||||
},
|
||||
entrypoint: {
|
||||
type: String,
|
||||
description:
|
||||
'The entrypoint to start from, relative to the package root. Can point to either a file (without extension) or a directory (in which case the index file in that directory is used). Defaults to "dev"',
|
||||
},
|
||||
inspect: {
|
||||
type: String,
|
||||
description:
|
||||
'Enable the Node.js inspector, optionally at a specific host:port',
|
||||
},
|
||||
inspectBrk: {
|
||||
type: String,
|
||||
description:
|
||||
'Enable the Node.js inspector and break before user code starts',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
await startPackage({
|
||||
role: await findRoleFromCommand({ role }),
|
||||
entrypoint,
|
||||
targetDir: targetPaths.dir,
|
||||
configPaths: config,
|
||||
checksEnabled: Boolean(check),
|
||||
linkedWorkspace: await resolveLinkedWorkspace(link),
|
||||
inspectEnabled: inspect || (inspect === '' ? true : undefined),
|
||||
inspectBrkEnabled: inspectBrk || (inspectBrk === '' ? true : undefined),
|
||||
require: requirePath,
|
||||
});
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { default } from './command';
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { ForwardedError } from '@backstage/errors';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path/posix';
|
||||
|
||||
export async function resolveLinkedWorkspace(
|
||||
linkPath: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!linkPath) {
|
||||
return undefined;
|
||||
}
|
||||
const dir = resolvePath(linkPath);
|
||||
if (!fs.pathExistsSync(dir)) {
|
||||
throw new Error(`Invalid workspace link, directory does not exist: ${dir}`);
|
||||
}
|
||||
const pkgJson = await fs
|
||||
.readJson(resolvePath(dir, 'package.json'))
|
||||
.catch(error => {
|
||||
throw new ForwardedError(
|
||||
'Failed to read package.json in linked workspace',
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
if (!pkgJson.workspaces) {
|
||||
throw new Error(
|
||||
`Invalid workspace link, directory is not a workspace: ${dir}`,
|
||||
);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
@@ -1,66 +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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { runBackend } from '../../../lib/runner';
|
||||
|
||||
interface StartBackendOptions {
|
||||
targetDir: string;
|
||||
checksEnabled: boolean;
|
||||
inspectEnabled?: boolean | string;
|
||||
inspectBrkEnabled?: boolean | string;
|
||||
linkedWorkspace?: string;
|
||||
require?: string;
|
||||
}
|
||||
|
||||
export async function startBackend(options: StartBackendOptions) {
|
||||
const waitForExit = await runBackend({
|
||||
targetDir: options.targetDir,
|
||||
entry: 'src/index',
|
||||
inspectEnabled: options.inspectEnabled,
|
||||
inspectBrkEnabled: options.inspectBrkEnabled,
|
||||
linkedWorkspace: options.linkedWorkspace,
|
||||
require: options.require,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
}
|
||||
|
||||
export async function startBackendPlugin(options: StartBackendOptions) {
|
||||
const hasDevIndexEntry = await fs.pathExists(
|
||||
resolvePath(options.targetDir ?? targetPaths.dir, 'dev/index.ts'),
|
||||
);
|
||||
if (!hasDevIndexEntry) {
|
||||
console.warn(
|
||||
`The 'dev' directory is missing. Please create a proper dev/index.ts in order to start the plugin.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const waitForExit = await runBackend({
|
||||
targetDir: options.targetDir,
|
||||
entry: 'dev/index',
|
||||
inspectEnabled: options.inspectEnabled,
|
||||
inspectBrkEnabled: options.inspectBrkEnabled,
|
||||
require: options.require,
|
||||
linkedWorkspace: options.linkedWorkspace,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
}
|
||||
@@ -1,68 +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 { readJson } from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import {
|
||||
getModuleFederationRemoteOptions,
|
||||
serveBundle,
|
||||
} from '../../../lib/bundler';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { hasReactDomClient } from '../../../lib/bundler/hasReactDomClient';
|
||||
|
||||
interface StartAppOptions {
|
||||
verifyVersions?: boolean;
|
||||
entry: string;
|
||||
targetDir?: string;
|
||||
|
||||
checksEnabled: boolean;
|
||||
configPaths: string[];
|
||||
skipOpenBrowser?: boolean;
|
||||
isModuleFederationRemote?: boolean;
|
||||
linkedWorkspace?: string;
|
||||
}
|
||||
|
||||
export async function startFrontend(options: StartAppOptions) {
|
||||
const packageJson = (await readJson(
|
||||
resolvePath(options.targetDir ?? targetPaths.dir, 'package.json'),
|
||||
)) as BackstagePackageJson;
|
||||
|
||||
if (!hasReactDomClient()) {
|
||||
console.warn(
|
||||
'React 17 is now deprecated! Please follow the Backstage migration guide to update to React 18: https://backstage.io/docs/tutorials/react18-migration/',
|
||||
);
|
||||
}
|
||||
|
||||
const waitForExit = await serveBundle({
|
||||
entry: options.entry,
|
||||
targetDir: options.targetDir,
|
||||
checksEnabled: options.checksEnabled,
|
||||
configPaths: options.configPaths,
|
||||
verifyVersions: options.verifyVersions,
|
||||
skipOpenBrowser: options.skipOpenBrowser,
|
||||
linkedWorkspace: options.linkedWorkspace,
|
||||
moduleFederationRemote: options.isModuleFederationRemote
|
||||
? await getModuleFederationRemoteOptions(
|
||||
packageJson,
|
||||
resolvePath(targetPaths.dir),
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
}
|
||||
@@ -1,89 +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 { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { resolveEntryPath } from './startPackage';
|
||||
|
||||
describe('resolveEntryPath', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
afterEach(() => {
|
||||
mockDir.clear();
|
||||
});
|
||||
|
||||
it('should remove file extensions', () => {
|
||||
mockDir.setContent({
|
||||
'dev/custom.tsx': '// dev app code',
|
||||
});
|
||||
|
||||
const result = resolveEntryPath('dev/custom.tsx', mockDir.path);
|
||||
|
||||
expect(result).toBe('dev/custom');
|
||||
});
|
||||
|
||||
it('should remove trailing slashes', () => {
|
||||
mockDir.setContent({
|
||||
'dev/alpha.ts': '// dev app code',
|
||||
});
|
||||
|
||||
const result = resolveEntryPath('dev/alpha/', mockDir.path);
|
||||
|
||||
expect(result).toBe('dev/alpha');
|
||||
});
|
||||
|
||||
it('should handle multiple dots in filename', () => {
|
||||
mockDir.setContent({
|
||||
'index.alpha.ts': 'export const data = {};',
|
||||
});
|
||||
|
||||
const result = resolveEntryPath('index.alpha.ts', mockDir.path);
|
||||
|
||||
expect(result).toBe('index.alpha');
|
||||
});
|
||||
|
||||
it('should handle simple directory names', () => {
|
||||
mockDir.setContent({
|
||||
'dev/index.ts': '// dev app code',
|
||||
});
|
||||
|
||||
const result = resolveEntryPath('dev', mockDir.path);
|
||||
|
||||
expect(result).toBe('dev/index');
|
||||
});
|
||||
|
||||
it('should handle nested directory paths', () => {
|
||||
mockDir.setContent({
|
||||
'dev/alpha/index.ts': '// dev app code',
|
||||
});
|
||||
|
||||
const result = resolveEntryPath('dev/alpha', mockDir.path);
|
||||
|
||||
expect(result).toBe('dev/alpha/index');
|
||||
});
|
||||
|
||||
it('should return the file when there is a directory with the same name', () => {
|
||||
mockDir.setContent({
|
||||
'dev/alpha.ts': '// dev app code',
|
||||
'dev/app-config.yaml': '// dev app config',
|
||||
'dev/alpha/index.ts': '// dev app code',
|
||||
'dev/alpha/app-config.yaml': '// dev app config',
|
||||
});
|
||||
|
||||
const result = resolveEntryPath('dev/alpha', mockDir.path);
|
||||
|
||||
expect(result).toBe('dev/alpha');
|
||||
});
|
||||
});
|
||||
@@ -1,78 +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 { PackageRole } from '@backstage/cli-node';
|
||||
import { startBackend, startBackendPlugin } from './startBackend';
|
||||
import { startFrontend } from './startFrontend';
|
||||
import { parse, resolve, join } from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
|
||||
export function resolveEntryPath(
|
||||
entrypoint: string = 'dev',
|
||||
targetDir: string,
|
||||
): string {
|
||||
const { dir: entryDir, name: entryName } = parse(entrypoint);
|
||||
const [entryFile] = glob.sync(`${resolve(targetDir, entryDir, entryName)}.*`);
|
||||
if (entryFile) {
|
||||
return join(entryDir, entryName);
|
||||
}
|
||||
return join(entryDir, entryName, 'index');
|
||||
}
|
||||
|
||||
export async function startPackage(options: {
|
||||
role: PackageRole;
|
||||
entrypoint?: string;
|
||||
targetDir: string;
|
||||
configPaths: string[];
|
||||
checksEnabled: boolean;
|
||||
inspectEnabled?: boolean | string;
|
||||
inspectBrkEnabled?: boolean | string;
|
||||
linkedWorkspace?: string;
|
||||
require?: string;
|
||||
}): Promise<void> {
|
||||
switch (options.role) {
|
||||
case 'backend':
|
||||
return startBackend(options);
|
||||
case 'backend-plugin':
|
||||
case 'backend-plugin-module':
|
||||
case 'node-library':
|
||||
return startBackendPlugin(options);
|
||||
case 'frontend':
|
||||
return startFrontend({
|
||||
...options,
|
||||
entry: 'src/index',
|
||||
verifyVersions: true,
|
||||
});
|
||||
case 'web-library':
|
||||
case 'frontend-plugin':
|
||||
case 'frontend-plugin-module':
|
||||
return startFrontend({
|
||||
...options,
|
||||
entry: resolveEntryPath(options.entrypoint, options.targetDir),
|
||||
});
|
||||
case 'frontend-dynamic-container' as PackageRole: // experimental
|
||||
return startFrontend({
|
||||
entry: 'src/index',
|
||||
...options,
|
||||
skipOpenBrowser: true,
|
||||
isModuleFederationRemote: true,
|
||||
});
|
||||
default:
|
||||
throw new Error(
|
||||
`Start command is not supported for package role '${options.role}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,183 +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 chalk from 'chalk';
|
||||
import { cli } from 'cleye';
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { buildPackages, getOutputsForRole } from '../../lib/builder';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
BackstagePackage,
|
||||
PackageGraph,
|
||||
PackageRoles,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import { buildFrontend } from '../../lib/buildFrontend';
|
||||
import { buildBackend } from '../../lib/buildBackend';
|
||||
import { createScriptOptionsParser } from '../../lib/optionsParser';
|
||||
import type { CliCommandContext } from '../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { all, since, minify },
|
||||
} = cli(
|
||||
{
|
||||
help: info,
|
||||
booleanFlagNegation: true,
|
||||
flags: {
|
||||
all: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'Build all packages, including bundled app and backend packages.',
|
||||
},
|
||||
since: {
|
||||
type: String,
|
||||
description:
|
||||
'Only build packages and their dev dependents that changed since the specified ref',
|
||||
},
|
||||
minify: {
|
||||
type: Boolean,
|
||||
description:
|
||||
'Minify the generated code. Does not apply to app package (app is minified by default).',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
let packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
const webpack = process.env.LEGACY_WEBPACK_BUILD
|
||||
? (require('webpack') as typeof import('webpack'))
|
||||
: undefined;
|
||||
|
||||
if (since) {
|
||||
const graph = PackageGraph.fromPackages(packages);
|
||||
const changedPackages = await graph.listChangedPackages({
|
||||
ref: since,
|
||||
analyzeLockfile: true,
|
||||
});
|
||||
const withDevDependents = graph.collectPackageNames(
|
||||
changedPackages.map(pkg => pkg.name),
|
||||
pkg => pkg.localDevDependents.keys(),
|
||||
);
|
||||
packages = Array.from(withDevDependents).map(name => graph.get(name)!);
|
||||
}
|
||||
|
||||
const apps = new Array<BackstagePackage>();
|
||||
const backends = new Array<BackstagePackage>();
|
||||
|
||||
const parseBuildScript = createScriptOptionsParser(['package', 'build'], {
|
||||
role: { type: 'string' },
|
||||
minify: { type: 'boolean' },
|
||||
'skip-build-dependencies': { type: 'boolean' },
|
||||
stats: { type: 'boolean' },
|
||||
config: { type: 'string', multiple: true },
|
||||
'module-federation': { type: 'boolean' },
|
||||
});
|
||||
|
||||
const options = packages.flatMap(pkg => {
|
||||
const role =
|
||||
pkg.packageJson.backstage?.role ??
|
||||
PackageRoles.detectRoleFromPackage(pkg.packageJson);
|
||||
if (!role) {
|
||||
console.warn(`Ignored ${pkg.packageJson.name} because it has no role`);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (role === 'frontend') {
|
||||
apps.push(pkg);
|
||||
return [];
|
||||
} else if (role === 'backend') {
|
||||
backends.push(pkg);
|
||||
return [];
|
||||
}
|
||||
|
||||
const outputs = getOutputsForRole(role);
|
||||
if (outputs.size === 0) {
|
||||
console.warn(`Ignored ${pkg.packageJson.name} because it has no output`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
|
||||
if (!buildOptions) {
|
||||
console.warn(
|
||||
`Ignored ${pkg.packageJson.name} because it does not have a matching build script`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
targetDir: pkg.dir,
|
||||
packageJson: pkg.packageJson,
|
||||
outputs,
|
||||
logPrefix: `${chalk.cyan(relativePath(targetPaths.rootDir, pkg.dir))}: `,
|
||||
workspacePackages: packages,
|
||||
minify: minify ?? Boolean(buildOptions.minify),
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Building packages');
|
||||
await buildPackages(options);
|
||||
|
||||
if (all) {
|
||||
console.log('Building apps');
|
||||
await runConcurrentTasks({
|
||||
items: apps,
|
||||
concurrencyFactor: 1 / 2,
|
||||
worker: async pkg => {
|
||||
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
|
||||
if (!buildOptions) {
|
||||
console.warn(
|
||||
`Ignored ${pkg.packageJson.name} because it does not have a matching build script`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const configPaths = buildOptions.config;
|
||||
await buildFrontend({
|
||||
targetDir: pkg.dir,
|
||||
configPaths: Array.isArray(configPaths)
|
||||
? (configPaths as string[])
|
||||
: [],
|
||||
writeStats: Boolean(buildOptions.stats),
|
||||
webpack,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Building backends');
|
||||
await runConcurrentTasks({
|
||||
items: backends,
|
||||
concurrencyFactor: 1 / 2,
|
||||
worker: async pkg => {
|
||||
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
|
||||
if (!buildOptions) {
|
||||
console.warn(
|
||||
`Ignored ${pkg.packageJson.name} because it does not have a matching build script`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await buildBackend({
|
||||
targetDir: pkg.dir,
|
||||
skipBuildDependencies: true,
|
||||
minify: minify ?? Boolean(buildOptions.minify),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,53 +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 { cli } from 'cleye';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { run, targetPaths } from '@backstage/cli-common';
|
||||
import type { CliCommandContext } from '../../../../wiring/types';
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
cli({ help: info, booleanFlagNegation: true }, undefined, args);
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
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 () => {
|
||||
while (packages.length > 0) {
|
||||
const pkg = packages.pop()!;
|
||||
const cleanScript = pkg.packageJson.scripts?.clean;
|
||||
|
||||
if (
|
||||
cleanScript === 'backstage-cli clean' ||
|
||||
cleanScript === 'backstage-cli package clean'
|
||||
) {
|
||||
await fs.remove(resolvePath(pkg.dir, 'dist'));
|
||||
await fs.remove(resolvePath(pkg.dir, 'dist-types'));
|
||||
await fs.remove(resolvePath(pkg.dir, 'coverage'));
|
||||
} else if (cleanScript) {
|
||||
await run(['yarn', 'run', 'clean'], {
|
||||
cwd: pkg.dir,
|
||||
}).waitForExit();
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -1,217 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { PackageGraph } from '@backstage/cli-node';
|
||||
import { findTargetPackages } from './start';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
|
||||
overrideTargetPaths('/root');
|
||||
|
||||
const mocks = {
|
||||
app: {
|
||||
packageJson: {
|
||||
name: 'app',
|
||||
version: '0',
|
||||
backstage: { role: 'frontend' },
|
||||
},
|
||||
dir: '/root/packages/app',
|
||||
},
|
||||
backend: {
|
||||
packageJson: {
|
||||
name: 'backend',
|
||||
version: '0',
|
||||
backstage: { role: 'backend' },
|
||||
},
|
||||
dir: '/root/packages/backend',
|
||||
},
|
||||
appNext: {
|
||||
packageJson: {
|
||||
name: 'app-next',
|
||||
version: '0',
|
||||
backstage: { role: 'frontend' },
|
||||
},
|
||||
dir: '/root/packages/app-next',
|
||||
},
|
||||
backendNext: {
|
||||
packageJson: {
|
||||
name: 'backend-next',
|
||||
version: '0',
|
||||
backstage: { role: 'backend' },
|
||||
},
|
||||
dir: '/root/packages/backend-next',
|
||||
},
|
||||
otherApp: {
|
||||
packageJson: {
|
||||
name: 'other-app',
|
||||
version: '0',
|
||||
backstage: { role: 'frontend' },
|
||||
},
|
||||
dir: '/root/packages/other-app',
|
||||
},
|
||||
pluginX: {
|
||||
packageJson: {
|
||||
name: 'plugin-x',
|
||||
version: '0',
|
||||
backstage: { role: 'frontend-plugin', pluginId: 'x' },
|
||||
},
|
||||
dir: '/root/plugins/plugin-x',
|
||||
},
|
||||
pluginXBackend: {
|
||||
packageJson: {
|
||||
name: 'plugin-x-backend',
|
||||
version: '0',
|
||||
backstage: { role: 'backend-plugin', pluginId: 'x' },
|
||||
},
|
||||
dir: '/root/plugins/plugin-x-backend',
|
||||
},
|
||||
pluginY: {
|
||||
packageJson: {
|
||||
name: 'plugin-y',
|
||||
version: '0',
|
||||
backstage: { role: 'frontend-plugin', pluginId: 'y' },
|
||||
},
|
||||
dir: '/root/plugins/plugin-y',
|
||||
},
|
||||
pluginYBackend: {
|
||||
packageJson: {
|
||||
name: 'plugin-y-backend',
|
||||
version: '0',
|
||||
backstage: { role: 'backend-plugin', pluginId: 'y' },
|
||||
},
|
||||
dir: '/root/plugins/plugin-y-backend',
|
||||
},
|
||||
} as const;
|
||||
|
||||
describe('findTargetPackages', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should select default packages', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
const result = await findTargetPackages([], []);
|
||||
expect(result).toEqual([mocks.app, mocks.backend]);
|
||||
});
|
||||
|
||||
it('should select packages by plugin ID', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
const result = await findTargetPackages([], ['x']);
|
||||
expect(result).toEqual([mocks.pluginX, mocks.pluginXBackend]);
|
||||
});
|
||||
|
||||
it('should throw an error if no packages match the plugin ID', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
await expect(
|
||||
findTargetPackages([], ['nonexistent-plugin']),
|
||||
).rejects.toThrow(
|
||||
"Unable to find any plugin packages with plugin ID 'nonexistent-plugin'. Make sure backstage.pluginId is set in your package.json files by running 'yarn fix --publish'.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should select packages by explicit names', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
const result = await findTargetPackages(['other-app'], []);
|
||||
expect(result).toEqual([mocks.otherApp]);
|
||||
});
|
||||
|
||||
it('should throw an error if no package matches the explicit name', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
await expect(
|
||||
findTargetPackages(['nonexistent-package'], []),
|
||||
).rejects.toThrow("Unable to find package by name 'nonexistent-package'");
|
||||
});
|
||||
|
||||
it('should select packages by relative path', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
const result = await findTargetPackages(
|
||||
['packages/app', 'packages/backend-next'],
|
||||
[],
|
||||
);
|
||||
expect(result).toEqual([mocks.app, mocks.backendNext]);
|
||||
});
|
||||
|
||||
it('should throw an error if no package matches the relative path', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue(Object.values(mocks));
|
||||
await expect(findTargetPackages(['nonexistent/path'], [])).rejects.toThrow(
|
||||
"Unable to find package by name 'nonexistent/path'",
|
||||
);
|
||||
});
|
||||
|
||||
it('should select a single frontend or backend package if no arguments are provided', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue([mocks.app]);
|
||||
const result = await findTargetPackages([], []);
|
||||
expect(result).toEqual([mocks.app]);
|
||||
});
|
||||
|
||||
it('should throw an error if multiple frontend packages other than packages/app are found without explicit selection', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue([mocks.otherApp, mocks.appNext]);
|
||||
await expect(findTargetPackages([], [])).rejects.toThrow(
|
||||
"Found multiple packages with role 'frontend' but none of the use the default path '/root/packages/app',choose which packages you want to run by passing the package names explicitly as arguments, for example 'yarn backstage-cli repo start my-app my-backend'.",
|
||||
);
|
||||
});
|
||||
|
||||
it('should select a single plugin package if no app or backend packages are found', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue([mocks.pluginX]);
|
||||
const result = await findTargetPackages([], []);
|
||||
expect(result).toEqual([mocks.pluginX]);
|
||||
});
|
||||
|
||||
it('should select a pair of plugin packages if no app or backend packages are found', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue([mocks.pluginX, mocks.pluginXBackend]);
|
||||
const result = await findTargetPackages([], []);
|
||||
expect(result).toEqual([mocks.pluginX, mocks.pluginXBackend]);
|
||||
});
|
||||
|
||||
// Right now we're not validating this because it requires backstage.pluginId to be set, and it's a strange case anyway
|
||||
it('should select a pair of plugin packages even if they are from different plugins', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue([mocks.pluginX, mocks.pluginYBackend]);
|
||||
const result = await findTargetPackages([], []);
|
||||
expect(result).toEqual([mocks.pluginX, mocks.pluginYBackend]);
|
||||
});
|
||||
|
||||
it('should throw an error if multiple plugin packages are found without explicit selection', async () => {
|
||||
jest
|
||||
.spyOn(PackageGraph, 'listTargetPackages')
|
||||
.mockResolvedValue([mocks.pluginX, mocks.pluginY]);
|
||||
await expect(findTargetPackages([], [])).rejects.toThrow(
|
||||
"Found multiple packages with role 'frontend-plugin', please choose which packages you want to run by passing the package names explicitly as arguments, for example 'yarn backstage-cli repo start my-plugin my-plugin-backend'.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
BackstagePackage,
|
||||
PackageGraph,
|
||||
PackageRole,
|
||||
} from '@backstage/cli-node';
|
||||
import { relative as relativePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
import { cli } from 'cleye';
|
||||
|
||||
import { resolveLinkedWorkspace } from '../package/start/resolveLinkedWorkspace';
|
||||
import { startPackage } from '../package/start/startPackage';
|
||||
import { parseArgs } from 'node:util';
|
||||
import type { CliCommandContext } from '../../../../wiring/types';
|
||||
|
||||
const ACCEPTED_PACKAGE_ROLES: Array<PackageRole | undefined> = [
|
||||
'frontend',
|
||||
'backend',
|
||||
'frontend-plugin',
|
||||
'backend-plugin',
|
||||
];
|
||||
|
||||
export default async ({ args, info }: CliCommandContext) => {
|
||||
const {
|
||||
flags: { plugin, config, require: requirePath, link, inspect, inspectBrk },
|
||||
_: namesOrPaths,
|
||||
} = cli(
|
||||
{
|
||||
help: { ...info, usage: `${info.usage} [packages...]` },
|
||||
booleanFlagNegation: true,
|
||||
parameters: ['[packages...]'],
|
||||
flags: {
|
||||
plugin: {
|
||||
type: [String],
|
||||
description:
|
||||
'Start the dev entry-point for any matching plugin package in the repo',
|
||||
default: [],
|
||||
},
|
||||
config: {
|
||||
type: [String],
|
||||
description: 'Config files to load instead of app-config.yaml',
|
||||
default: [],
|
||||
},
|
||||
require: {
|
||||
type: String,
|
||||
description:
|
||||
'Add a --require argument to the node process. Applies to backend package only',
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
description: 'Link an external workspace for module resolution',
|
||||
},
|
||||
inspect: {
|
||||
type: String,
|
||||
description:
|
||||
'Enable the Node.js inspector, optionally at a specific host:port',
|
||||
},
|
||||
inspectBrk: {
|
||||
type: String,
|
||||
description:
|
||||
'Enable the Node.js inspector and break before user code starts',
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
args,
|
||||
);
|
||||
|
||||
const targetPackages = await findTargetPackages(namesOrPaths, plugin);
|
||||
|
||||
const packageOptions = await resolvePackageOptions(targetPackages, {
|
||||
plugin,
|
||||
config,
|
||||
inspect: inspect || (inspect === '' ? true : undefined),
|
||||
inspectBrk: inspectBrk || (inspectBrk === '' ? true : undefined),
|
||||
require: requirePath,
|
||||
link,
|
||||
});
|
||||
|
||||
if (packageOptions.length === 0) {
|
||||
console.log('No packages found to start');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Starting ${packageOptions
|
||||
.map(({ pkg }) => pkg.packageJson.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
// Each of these block until interrupted by user
|
||||
await Promise.all(packageOptions.map(entry => startPackage(entry.options)));
|
||||
};
|
||||
|
||||
export async function findTargetPackages(
|
||||
namesOrPaths: string[],
|
||||
pluginIds: string[],
|
||||
) {
|
||||
const targetPackages = new Array<BackstagePackage>();
|
||||
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
|
||||
// Prioritize plugin options, so that the `start` script can contain a list of packages,
|
||||
// but make them easy to override by running for example `yarn start --plugin catalog`
|
||||
for (const pluginId of pluginIds) {
|
||||
const matchingPackages = packages.filter(pkg => {
|
||||
return (
|
||||
pluginId === pkg.packageJson.backstage?.pluginId &&
|
||||
ACCEPTED_PACKAGE_ROLES.includes(pkg.packageJson.backstage.role)
|
||||
);
|
||||
});
|
||||
if (matchingPackages.length === 0) {
|
||||
throw new Error(
|
||||
`Unable to find any plugin packages with plugin ID '${pluginId}'. Make sure backstage.pluginId is set in your package.json files by running 'yarn fix --publish'.`,
|
||||
);
|
||||
}
|
||||
targetPackages.push(...matchingPackages);
|
||||
}
|
||||
if (targetPackages.length > 0) {
|
||||
return targetPackages;
|
||||
}
|
||||
|
||||
// Next check if explicit package names are provided, use them in that case.
|
||||
for (const nameOrPath of namesOrPaths) {
|
||||
let matchingPackage = packages.find(
|
||||
pkg => nameOrPath === pkg.packageJson.name,
|
||||
);
|
||||
if (!matchingPackage) {
|
||||
const absPath = targetPaths.resolveRoot(nameOrPath);
|
||||
matchingPackage = packages.find(
|
||||
pkg => relativePath(pkg.dir, absPath) === '',
|
||||
);
|
||||
}
|
||||
if (!matchingPackage) {
|
||||
throw new Error(`Unable to find package by name '${nameOrPath}'`);
|
||||
}
|
||||
targetPackages.push(matchingPackage);
|
||||
}
|
||||
|
||||
if (targetPackages.length > 0) {
|
||||
return targetPackages;
|
||||
}
|
||||
|
||||
// If no package names are provided, default to expect a single frontend and/or backend package
|
||||
for (const role of ['frontend', 'backend']) {
|
||||
const matchingPackages = packages.filter(
|
||||
pkg => pkg.packageJson.backstage?.role === role,
|
||||
);
|
||||
if (matchingPackages.length > 1) {
|
||||
// Final fallback is to check for the package path within the monorepo, packages/app or packages/backend
|
||||
const expectedPath = targetPaths.resolveRoot(
|
||||
role === 'frontend' ? 'packages/app' : 'packages/backend',
|
||||
);
|
||||
const matchByPath = matchingPackages.find(
|
||||
pkg => relativePath(expectedPath, pkg.dir) === '',
|
||||
);
|
||||
if (matchByPath) {
|
||||
targetPackages.push(matchByPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Found multiple packages with role '${role}' but none of the use the default path '${expectedPath}',` +
|
||||
`choose which packages you want to run by passing the package names explicitly ` +
|
||||
`as arguments, for example 'yarn backstage-cli repo start my-app my-backend'.`,
|
||||
);
|
||||
}
|
||||
|
||||
targetPackages.push(...matchingPackages);
|
||||
}
|
||||
if (targetPackages.length > 0) {
|
||||
return targetPackages;
|
||||
}
|
||||
|
||||
// If no app or backend packages are found, fall back to expecting single plugin packages
|
||||
for (const role of ['frontend-plugin', 'backend-plugin']) {
|
||||
const matchingPackages = packages.filter(
|
||||
pkg => pkg.packageJson.backstage?.role === role,
|
||||
);
|
||||
if (matchingPackages.length > 1) {
|
||||
throw new Error(
|
||||
`Found multiple packages with role '${role}', please choose which packages you want ` +
|
||||
`to run by passing the package names explicitly as arguments, for example ` +
|
||||
`'yarn backstage-cli repo start my-plugin my-plugin-backend'.`,
|
||||
);
|
||||
}
|
||||
targetPackages.push(...matchingPackages);
|
||||
}
|
||||
if (targetPackages.length > 0) {
|
||||
return targetPackages;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unable to find any packages with role 'frontend', 'backend', 'frontend-plugin', or 'backend-plugin'.`,
|
||||
);
|
||||
}
|
||||
|
||||
type CommandOptions = {
|
||||
plugin: string[];
|
||||
config: string[];
|
||||
inspect?: boolean | string;
|
||||
inspectBrk?: boolean | string;
|
||||
require?: string;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
async function resolvePackageOptions(
|
||||
targetPackages: BackstagePackage[],
|
||||
options: CommandOptions,
|
||||
) {
|
||||
const linkedWorkspace = await resolveLinkedWorkspace(options.link);
|
||||
|
||||
return targetPackages.flatMap(pkg => {
|
||||
const startScript = pkg.packageJson.scripts?.start;
|
||||
if (!startScript) {
|
||||
console.log(
|
||||
`No start script found for package ${pkg.packageJson.name}, skipping...`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Grab and parse --config and --require options from the start scripts, the rest are ignored
|
||||
// TODO(Rugvip): Prolly switch over to completely different arg parsing to avoid this duplication
|
||||
const { values: parsedOpts } = parseArgs({
|
||||
args: startScript.split(' '),
|
||||
strict: false,
|
||||
options: {
|
||||
config: {
|
||||
type: 'string',
|
||||
multiple: true,
|
||||
},
|
||||
require: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
});
|
||||
const parsedRequire =
|
||||
typeof parsedOpts.require === 'string' ? parsedOpts.require : undefined;
|
||||
const parsedConfig =
|
||||
parsedOpts.config?.filter(c => typeof c === 'string') ?? [];
|
||||
|
||||
return [
|
||||
{
|
||||
pkg,
|
||||
options: {
|
||||
role: pkg.packageJson.backstage?.role!,
|
||||
targetDir: pkg.dir,
|
||||
configPaths:
|
||||
options.config.length > 0 ? options.config : parsedConfig,
|
||||
checksEnabled: false,
|
||||
linkedWorkspace,
|
||||
inspectEnabled: options.inspect,
|
||||
inspectBrkEnabled: options.inspectBrk,
|
||||
require: options.require ?? parsedRequire,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* 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 { createCliModule } from '@backstage/cli-node';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
export const buildPlugin = createCliModule({
|
||||
packageJson,
|
||||
init: async reg => {
|
||||
reg.addCommand({
|
||||
path: ['package', 'build'],
|
||||
description: 'Build a package for production deployment or publishing',
|
||||
execute: { loader: () => import('./commands/package/build') },
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'build'],
|
||||
description:
|
||||
'Build packages in the project, excluding bundled app and backend packages.',
|
||||
execute: { loader: () => import('./commands/repo/build') },
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'start'],
|
||||
description: 'Start a package for local development',
|
||||
execute: { loader: () => import('./commands/package/start') },
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'start'],
|
||||
description: 'Starts packages in the repo for local development',
|
||||
execute: { loader: () => import('./commands/repo/start') },
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'clean'],
|
||||
description: 'Delete cache directories',
|
||||
execute: {
|
||||
loader: () => import('./commands/package/clean'),
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'prepack'],
|
||||
description: 'Prepares a package for packaging before publishing',
|
||||
execute: {
|
||||
loader: () => import('./commands/package/prepack'),
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['package', 'postpack'],
|
||||
description: 'Restores the changes made by the prepack command',
|
||||
execute: {
|
||||
loader: () => import('./commands/package/postpack'),
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['repo', 'clean'],
|
||||
description: 'Delete cache and output directories',
|
||||
execute: {
|
||||
loader: () => import('./commands/repo/clean'),
|
||||
},
|
||||
});
|
||||
|
||||
reg.addCommand({
|
||||
path: ['build-workspace'],
|
||||
description:
|
||||
'Builds a temporary dist workspace from the provided packages',
|
||||
execute: { loader: () => import('./commands/buildWorkspace') },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default buildPlugin;
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* 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 { PackageRole, BackstagePackageFeatureType } from '@backstage/cli-node';
|
||||
import { Project } from 'ts-morph';
|
||||
|
||||
const mockEntryPoint = 'dist/index.d.ts';
|
||||
|
||||
type CreateFeatureEnvironmentOptions = {
|
||||
$$type?: BackstagePackageFeatureType;
|
||||
format?:
|
||||
| 'DefaultExportAssignment'
|
||||
| 'DefaultExportFromFile'
|
||||
| 'DefaultExportFromFileAsDefault'
|
||||
| 'DefaultExportFromFileWithSibling';
|
||||
role?: PackageRole;
|
||||
};
|
||||
|
||||
type FeatureEnvironment = {
|
||||
project: Project;
|
||||
role: PackageRole;
|
||||
dir: string;
|
||||
entryPoint: string;
|
||||
};
|
||||
|
||||
type File = {
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const createTestType = ($$type: BackstagePackageFeatureType): File[] => [
|
||||
{
|
||||
path: './dist/createTestType.d.ts',
|
||||
content: `
|
||||
export interface TestType {
|
||||
readonly $$type: '${$$type}';
|
||||
};
|
||||
|
||||
export function createTestType(): TestType {
|
||||
return {
|
||||
$$type: '${$$type}',
|
||||
};
|
||||
};
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportAssignment = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `
|
||||
declare const _default: import("./createTestType").TestType;
|
||||
export default _default;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportFromFile = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `export { default } from './linked';`,
|
||||
},
|
||||
{
|
||||
path: './dist/linked.d.ts',
|
||||
content: `
|
||||
declare const _default: import("./createTestType").TestType;
|
||||
export default _default;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportFromFileAsDefault = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `export { test as default } from './linked';`,
|
||||
},
|
||||
{
|
||||
path: './dist/linked.d.ts',
|
||||
content: `
|
||||
export declare const test: import("./createTestType").TestType;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const createMockDefaultExportFromFileWithSibling = (): File[] => [
|
||||
{
|
||||
path: mockEntryPoint,
|
||||
content: `export { default, test } from './linked';`,
|
||||
},
|
||||
{
|
||||
path: './dist/linked.d.ts',
|
||||
content: `
|
||||
import { createTestType } from './createTestType';
|
||||
|
||||
export declare const test: import("./createTestType").TestType;
|
||||
declare const _default: import("./createTestType").TestType;
|
||||
export default _default;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const formatToFiles = {
|
||||
DefaultExportAssignment: createMockDefaultExportAssignment,
|
||||
DefaultExportFromFile: createMockDefaultExportFromFile,
|
||||
DefaultExportFromFileAsDefault: createMockDefaultExportFromFileAsDefault,
|
||||
DefaultExportFromFileWithSibling: createMockDefaultExportFromFileWithSibling,
|
||||
};
|
||||
|
||||
export default function createFeatureEnvironment(
|
||||
options?: CreateFeatureEnvironmentOptions,
|
||||
): FeatureEnvironment {
|
||||
const {
|
||||
$$type = '@backstage/BackendFeature',
|
||||
format = 'DefaultExportAssignment',
|
||||
role = 'backend-plugin',
|
||||
} = options ?? {};
|
||||
|
||||
const project = new Project();
|
||||
const files = [...createTestType($$type), ...formatToFiles[format]()];
|
||||
|
||||
for (const file of files) {
|
||||
project.createSourceFile(file.path, file.content);
|
||||
}
|
||||
|
||||
return {
|
||||
project,
|
||||
role,
|
||||
dir: project.getFileSystem().getCurrentDirectory(),
|
||||
entryPoint: mockEntryPoint,
|
||||
};
|
||||
}
|
||||
@@ -1,85 +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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import * as tar from 'tar';
|
||||
import { createDistWorkspace } from './packager';
|
||||
import { buildPackage, Output } from './builder';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
|
||||
const BUNDLE_FILE = 'bundle.tar.gz';
|
||||
const SKELETON_FILE = 'skeleton.tar.gz';
|
||||
|
||||
interface BuildBackendOptions {
|
||||
targetDir: string;
|
||||
skipBuildDependencies: boolean;
|
||||
configPaths?: string[];
|
||||
minify?: boolean;
|
||||
}
|
||||
|
||||
export async function buildBackend(options: BuildBackendOptions) {
|
||||
const { targetDir, skipBuildDependencies, configPaths, minify } = options;
|
||||
const pkg = await fs.readJson(resolvePath(targetDir, 'package.json'));
|
||||
|
||||
// We build the target package without generating type declarations.
|
||||
await buildPackage({
|
||||
targetDir,
|
||||
packageJson: pkg,
|
||||
outputs: new Set([Output.cjs]),
|
||||
minify,
|
||||
workspacePackages: await PackageGraph.listTargetPackages(),
|
||||
});
|
||||
|
||||
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
|
||||
try {
|
||||
await createDistWorkspace([pkg.name], {
|
||||
targetDir: tmpDir,
|
||||
configPaths,
|
||||
buildDependencies: !skipBuildDependencies,
|
||||
buildExcludes: [pkg.name],
|
||||
skeleton: SKELETON_FILE,
|
||||
minify,
|
||||
});
|
||||
|
||||
// We built the target backend package using the regular build process, but the result of
|
||||
// that has now been packed into the dist workspace, so clean up the dist dir.
|
||||
const distDir = resolvePath(targetDir, 'dist');
|
||||
await fs.remove(distDir);
|
||||
await fs.mkdir(distDir);
|
||||
|
||||
// Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice.
|
||||
await fs.move(
|
||||
resolvePath(tmpDir, SKELETON_FILE),
|
||||
resolvePath(distDir, SKELETON_FILE),
|
||||
);
|
||||
|
||||
// Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache.
|
||||
await tar.create(
|
||||
{
|
||||
file: resolvePath(distDir, BUNDLE_FILE),
|
||||
cwd: tmpDir,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
gzip: true,
|
||||
},
|
||||
[''],
|
||||
);
|
||||
} finally {
|
||||
await fs.remove(tmpDir);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { buildBundle, getModuleFederationRemoteOptions } from './bundler';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { loadCliConfig } from './config';
|
||||
|
||||
interface BuildAppOptions {
|
||||
targetDir: string;
|
||||
writeStats: boolean;
|
||||
configPaths: string[];
|
||||
isModuleFederationRemote?: boolean;
|
||||
webpack?: typeof import('webpack');
|
||||
}
|
||||
|
||||
export async function buildFrontend(options: BuildAppOptions) {
|
||||
const { targetDir, writeStats, configPaths, webpack } = options;
|
||||
const packageJson = (await fs.readJson(
|
||||
resolvePath(targetDir, 'package.json'),
|
||||
)) as BackstagePackageJson;
|
||||
await buildBundle({
|
||||
targetDir,
|
||||
entry: 'src/index',
|
||||
statsJsonEnabled: writeStats,
|
||||
moduleFederationRemote: options.isModuleFederationRemote
|
||||
? await getModuleFederationRemoteOptions(
|
||||
packageJson,
|
||||
resolvePath(targetDir),
|
||||
)
|
||||
: undefined,
|
||||
...(await loadCliConfig({
|
||||
args: configPaths,
|
||||
fromPackage: packageJson.name,
|
||||
})),
|
||||
webpack,
|
||||
});
|
||||
}
|
||||
@@ -1,65 +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 { ExternalOption } from 'rollup';
|
||||
import { makeRollupConfigs } from './config';
|
||||
import { Output } from './types';
|
||||
|
||||
describe('makeRollupConfigs', () => {
|
||||
it('should mark external modules correctly', async () => {
|
||||
const importerPath = '/some/path.ts'; // when specified we don't care about the path
|
||||
|
||||
const [config] = await makeRollupConfigs({
|
||||
outputs: new Set([Output.cjs]),
|
||||
packageJson: {
|
||||
name: 'test',
|
||||
version: '0.0.0',
|
||||
main: './src/index.ts',
|
||||
},
|
||||
workspacePackages: [],
|
||||
});
|
||||
const external = config.external as Exclude<
|
||||
ExternalOption,
|
||||
string | RegExp | (string | RegExp)[]
|
||||
>;
|
||||
|
||||
expect(external('foo', importerPath, false)).toBe(true);
|
||||
expect(external('./foo', importerPath, false)).toBe(false);
|
||||
expect(external('/foo', importerPath, false)).toBe(false);
|
||||
expect(external('.\\foo', importerPath, false)).toBe(false);
|
||||
expect(external('c:\\foo', importerPath, false)).toBe(false);
|
||||
expect(external('@foo/bar', importerPath, false)).toBe(true);
|
||||
expect(external('../foo', importerPath, false)).toBe(false);
|
||||
|
||||
// Modules without an importer are entry points, i.e. not external
|
||||
expect(external('foo', undefined, false)).toBe(false);
|
||||
expect(external('./foo', undefined, false)).toBe(false);
|
||||
expect(external('/foo', undefined, false)).toBe(false);
|
||||
expect(external('.\\foo', undefined, false)).toBe(false);
|
||||
expect(external('c:\\foo', undefined, false)).toBe(false);
|
||||
expect(external('@foo/bar', undefined, false)).toBe(false);
|
||||
expect(external('../foo', undefined, false)).toBe(false);
|
||||
|
||||
// After modules have been resolved they're never marked as external
|
||||
expect(external('foo', importerPath, true)).toBe(false);
|
||||
expect(external('./foo', importerPath, true)).toBe(false);
|
||||
expect(external('/foo', importerPath, true)).toBe(false);
|
||||
expect(external('.\\foo', importerPath, true)).toBe(false);
|
||||
expect(external('c:\\foo', importerPath, true)).toBe(false);
|
||||
expect(external('@foo/bar', importerPath, true)).toBe(false);
|
||||
expect(external('../foo', importerPath, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,325 +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 chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
basename,
|
||||
extname,
|
||||
relative as relativePath,
|
||||
resolve as resolvePath,
|
||||
} from 'node:path';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import esbuild from 'rollup-plugin-esbuild';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
import json from '@rollup/plugin-json';
|
||||
import yaml from '@rollup/plugin-yaml';
|
||||
import {
|
||||
RollupOptions,
|
||||
OutputOptions,
|
||||
WarningHandlerWithDefault,
|
||||
OutputPlugin,
|
||||
} from 'rollup';
|
||||
|
||||
import { forwardFileImports, cssEntryPoints } from './plugins';
|
||||
import { BuildOptions, Output } from './types';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { readEntryPoints } from '../entryPoints';
|
||||
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
||||
|
||||
const MODULE_EXTS = ['.mjs', '.mts'];
|
||||
const COMMONJS_EXTS = ['.cjs', '.cts'];
|
||||
const MOD_EXT = '.mjs';
|
||||
const CJS_EXT = '.cjs';
|
||||
const CJS_JS_EXT = '.cjs.js';
|
||||
|
||||
function isFileImport(source: string) {
|
||||
if (source.startsWith('.')) {
|
||||
return true;
|
||||
}
|
||||
if (source.startsWith('/')) {
|
||||
return true;
|
||||
}
|
||||
if (source.match(/[a-z]:/i)) {
|
||||
return true;
|
||||
}
|
||||
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('|')})(?:$|/)`);
|
||||
}
|
||||
|
||||
// This Rollup output plugin enables support for mixed CommonJS and ESM output.
|
||||
// It does it be filtering out the unwanted output files that don't match the
|
||||
// input file format, allowing the rollup configuration to have overlapping
|
||||
// output configurations for different formats.
|
||||
function multiOutputFormat(): OutputPlugin {
|
||||
return {
|
||||
name: 'backstage-multi-output-format',
|
||||
generateBundle(opts, bundle) {
|
||||
const filter: (name: string) => boolean =
|
||||
opts.format === 'cjs'
|
||||
? s => s.endsWith(MOD_EXT)
|
||||
: s => !s.endsWith(MOD_EXT);
|
||||
|
||||
// Delete any files that don't match the current output format
|
||||
for (const name in bundle) {
|
||||
if (filter(name)) {
|
||||
delete bundle[name];
|
||||
delete bundle[`${name}.map`];
|
||||
}
|
||||
}
|
||||
},
|
||||
renderDynamicImport(opts) {
|
||||
if (opts.format === 'cjs') {
|
||||
return {
|
||||
left: 'import(',
|
||||
right: ')',
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function makeRollupConfigs(
|
||||
options: BuildOptions,
|
||||
): Promise<RollupOptions[]> {
|
||||
const configs = new Array<RollupOptions>();
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
|
||||
let targetPkg = options.packageJson;
|
||||
if (!targetPkg) {
|
||||
const packagePath = resolvePath(targetDir, 'package.json');
|
||||
targetPkg = (await fs.readJson(packagePath)) as BackstagePackageJson;
|
||||
}
|
||||
|
||||
const onwarn: WarningHandlerWithDefault = ({ code, message }) => {
|
||||
if (code === 'EMPTY_BUNDLE') {
|
||||
return; // We don't care about this one
|
||||
}
|
||||
if (options.logPrefix) {
|
||||
console.log(options.logPrefix + message);
|
||||
} else {
|
||||
console.log(message);
|
||||
}
|
||||
};
|
||||
|
||||
const distDir = resolvePath(targetDir, 'dist');
|
||||
const entryPoints = readEntryPoints(targetPkg);
|
||||
|
||||
const scriptEntryPoints = entryPoints.filter(e =>
|
||||
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'];
|
||||
|
||||
// Avoid using node_modules as a directory name, since it's trimmed from published packages.
|
||||
// This can happen when inlining dependencies such as style-inject added for css injection.
|
||||
const rewriteNodeModules = (name: string) =>
|
||||
name.replaceAll('node_modules', 'node_modules_dist');
|
||||
|
||||
// For CommonJS we build both CommonJS and ESM output. Each of these outputs
|
||||
// can output both .cjs and .mjs files. The files from each of these outputs
|
||||
// will overlap, but we trim away files where the format doesn't match the
|
||||
// file extensions. That way we are left with a combination of .cjs and .mjs
|
||||
// files where the module format in the file matches the file extension.
|
||||
if (options.outputs.has(Output.cjs)) {
|
||||
const defaultExt = targetPkg.type === 'module' ? MOD_EXT : CJS_JS_EXT;
|
||||
const outputOpts: OutputOptions = {
|
||||
dir: distDir,
|
||||
entryFileNames(chunkInfo) {
|
||||
const cleanName = rewriteNodeModules(chunkInfo.name);
|
||||
|
||||
const inputId = chunkInfo.facadeModuleId;
|
||||
if (!inputId) {
|
||||
return cleanName + defaultExt;
|
||||
}
|
||||
|
||||
const inputExt = extname(inputId);
|
||||
if (MODULE_EXTS.includes(inputExt)) {
|
||||
return cleanName + MOD_EXT;
|
||||
}
|
||||
if (COMMONJS_EXTS.includes(inputExt)) {
|
||||
return cleanName + CJS_EXT;
|
||||
}
|
||||
return cleanName + defaultExt;
|
||||
},
|
||||
sourcemap: true,
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: `${targetDir}/src`,
|
||||
interop: 'compat',
|
||||
exports: 'named',
|
||||
plugins: [multiOutputFormat()],
|
||||
};
|
||||
|
||||
output.push({
|
||||
...outputOpts,
|
||||
format: 'cjs',
|
||||
});
|
||||
output.push({
|
||||
...outputOpts,
|
||||
format: 'module',
|
||||
});
|
||||
}
|
||||
if (options.outputs.has(Output.esm)) {
|
||||
output.push({
|
||||
dir: distDir,
|
||||
entryFileNames: chunkInfo =>
|
||||
`${rewriteNodeModules(chunkInfo.name)}.esm.js`,
|
||||
chunkFileNames: `esm/[name]-[hash].esm.js`,
|
||||
format: 'module',
|
||||
sourcemap: true,
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: `${targetDir}/src`,
|
||||
});
|
||||
// Assume we're building for the browser if ESM output is included
|
||||
mainFields.unshift('browser');
|
||||
}
|
||||
|
||||
configs.push({
|
||||
input: Object.fromEntries(
|
||||
scriptEntryPoints.map(e => [e.name, resolvePath(targetDir, e.path)]),
|
||||
),
|
||||
output,
|
||||
onwarn,
|
||||
makeAbsoluteExternalsRelative: false,
|
||||
preserveEntrySignatures: 'strict',
|
||||
// All module imports are always marked as external
|
||||
external,
|
||||
plugins: [
|
||||
resolve({
|
||||
mainFields,
|
||||
extensions: SCRIPT_EXTS,
|
||||
}),
|
||||
commonjs({
|
||||
include: /node_modules/,
|
||||
exclude: [/\/[^/]+\.(?:stories|test)\.[^/]+$/],
|
||||
}),
|
||||
postcss({
|
||||
modules: {
|
||||
generateScopedName(name: string, filename: string, css: string) {
|
||||
const hash = createHash('md5')
|
||||
.update(css)
|
||||
.digest('hex')
|
||||
.slice(0, 10);
|
||||
const file = basename(filename, '.module.css');
|
||||
return `${file}_${name}__${hash}`;
|
||||
},
|
||||
},
|
||||
}),
|
||||
forwardFileImports({
|
||||
exclude: /\.icon\.svg$/,
|
||||
include: [
|
||||
/\.svg$/,
|
||||
/\.png$/,
|
||||
/\.gif$/,
|
||||
/\.jpg$/,
|
||||
/\.jpeg$/,
|
||||
/\.webp$/,
|
||||
/\.eot$/,
|
||||
/\.woff$/,
|
||||
/\.woff2$/,
|
||||
/\.ttf$/,
|
||||
/\.md$/,
|
||||
],
|
||||
}),
|
||||
json(),
|
||||
yaml(),
|
||||
esbuild({
|
||||
target: 'ES2023',
|
||||
minify: options.minify,
|
||||
}),
|
||||
cssEntryPoints({ entryPoints, targetDir }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (options.outputs.has(Output.types)) {
|
||||
const input = Object.fromEntries(
|
||||
scriptEntryPoints.map(e => [
|
||||
e.name,
|
||||
targetPaths.resolveRoot(
|
||||
'dist-types',
|
||||
relativePath(targetPaths.rootDir, targetDir),
|
||||
e.path.replace(/\.(?:ts|tsx)$/, '.d.ts'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
for (const path of Object.values(input)) {
|
||||
const declarationsExist = await fs.pathExists(path);
|
||||
if (!declarationsExist) {
|
||||
const declarationPath = relativePath(targetDir, path);
|
||||
throw new Error(
|
||||
`No declaration files found at ${declarationPath}, be sure to run ${chalk.bgRed.white(
|
||||
'yarn tsc',
|
||||
)} to generate .d.ts files before packaging`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
configs.push({
|
||||
input,
|
||||
output: {
|
||||
dir: distDir,
|
||||
entryFileNames: `[name].d.ts`,
|
||||
chunkFileNames: `types/[name]-[hash].d.ts`,
|
||||
format: 'es',
|
||||
},
|
||||
external: (source, importer, isResolved) =>
|
||||
/\.css|scss|sass|svg|eot|woff|woff2|ttf$/.test(source) ||
|
||||
external(source, importer, isResolved),
|
||||
onwarn,
|
||||
plugins: [dts({ respectExternal: true })],
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
@@ -1,19 +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.
|
||||
*/
|
||||
|
||||
export { buildPackage, buildPackages, getOutputsForRole } from './packager';
|
||||
export { Output } from './types';
|
||||
export type { BuildOptions } from './types';
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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 { formatErrorMessage } from './packager';
|
||||
|
||||
describe('formatErrorMessage with esbuild plugin error', () => {
|
||||
it('given error with missing errors array then error message should be shown', () => {
|
||||
const msg = formatErrorMessage({
|
||||
code: 'PLUGIN_ERROR',
|
||||
plugin: 'esbuild',
|
||||
message: 'test',
|
||||
});
|
||||
expect(msg).toBe('test');
|
||||
});
|
||||
it('given error with errors array then error message should have new lines', () => {
|
||||
const msg = formatErrorMessage({
|
||||
code: 'PLUGIN_ERROR',
|
||||
plugin: 'esbuild',
|
||||
message: 'test',
|
||||
id: 'index.js',
|
||||
errors: [{ text: 'Sample', location: { line: 1, column: 1 } }],
|
||||
});
|
||||
expect(msg).toContain('test\n\n');
|
||||
});
|
||||
});
|
||||
@@ -1,152 +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 fs from 'fs-extra';
|
||||
import { rollup, RollupOptions } from 'rollup';
|
||||
import chalk from 'chalk';
|
||||
import { relative as relativePath, resolve as resolvePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { makeRollupConfigs } from './config';
|
||||
import { BuildOptions, Output } from './types';
|
||||
import { PackageRoles, runConcurrentTasks } from '@backstage/cli-node';
|
||||
|
||||
export function formatErrorMessage(error: any) {
|
||||
let msg = '';
|
||||
|
||||
if (error.code === 'PLUGIN_ERROR') {
|
||||
if (error.plugin === 'esbuild') {
|
||||
msg += `${error.message}`;
|
||||
if (error.errors?.length) {
|
||||
msg += `\n\n`;
|
||||
for (const { text, location } of error.errors) {
|
||||
const { line, column } = location;
|
||||
const path = relativePath(targetPaths.dir, error.id);
|
||||
const loc = chalk.cyan(`${path}:${line}:${column}`);
|
||||
|
||||
if (text === 'Unexpected "<"' && error.id.endsWith('.js')) {
|
||||
msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`;
|
||||
} else {
|
||||
msg += `${loc}: ${text}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log which plugin is causing errors to make it easier to identity.
|
||||
// If we see these in logs we likely want to provide some custom error
|
||||
// output for those plugins too.
|
||||
msg += `(plugin ${error.plugin}) ${error}\n`;
|
||||
}
|
||||
} else {
|
||||
// Generic rollup errors, log what's available
|
||||
if (error.loc) {
|
||||
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 += `${targetPaths.resolve(error.id)}\n`;
|
||||
}
|
||||
|
||||
msg += `${error}\n`;
|
||||
|
||||
if (error.url) {
|
||||
msg += `${chalk.cyan(error.url)}\n`;
|
||||
}
|
||||
|
||||
if (error.frame) {
|
||||
msg += `${chalk.dim(error.frame)}\n`;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function rollupBuild(config: RollupOptions) {
|
||||
try {
|
||||
const bundle = await rollup(config);
|
||||
if (config.output) {
|
||||
for (const output of [config.output].flat()) {
|
||||
await bundle.generate(output);
|
||||
await bundle.write(output);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(formatErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPackage = async (options: BuildOptions) => {
|
||||
try {
|
||||
const { resolutions } = await fs.readJson(
|
||||
targetPaths.resolveRoot('package.json'),
|
||||
);
|
||||
if (resolutions?.esbuild) {
|
||||
console.warn(
|
||||
chalk.red(
|
||||
'Your root package.json contains a "resolutions" entry for "esbuild". This was ' +
|
||||
'included in older @backstage/create-app templates in order to work around build ' +
|
||||
'issues that have since been fixed. Please remove the entry and run `yarn install`',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* Errors ignored, this is just a warning */
|
||||
}
|
||||
|
||||
const rollupConfigs = await makeRollupConfigs(options);
|
||||
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
await fs.remove(resolvePath(targetDir, 'dist'));
|
||||
|
||||
const buildTasks = rollupConfigs.map(rollupBuild);
|
||||
|
||||
await Promise.all(buildTasks);
|
||||
};
|
||||
|
||||
export const buildPackages = async (options: BuildOptions[]) => {
|
||||
if (options.some(opt => !opt.targetDir)) {
|
||||
throw new Error('targetDir must be set for all build options');
|
||||
}
|
||||
const rollupConfigs = await Promise.all(options.map(makeRollupConfigs));
|
||||
|
||||
await Promise.all(
|
||||
options.map(({ targetDir }) => fs.remove(resolvePath(targetDir!, 'dist'))),
|
||||
);
|
||||
|
||||
const buildTasks = rollupConfigs.flat().map(opts => () => rollupBuild(opts));
|
||||
|
||||
await runConcurrentTasks({
|
||||
items: buildTasks,
|
||||
worker: async task => task(),
|
||||
});
|
||||
};
|
||||
|
||||
export function getOutputsForRole(role: string): Set<Output> {
|
||||
const outputs = new Set<Output>();
|
||||
|
||||
for (const output of PackageRoles.getRoleInfo(role).output) {
|
||||
if (output === 'cjs') {
|
||||
outputs.add(Output.cjs);
|
||||
}
|
||||
if (output === 'esm') {
|
||||
outputs.add(Output.esm);
|
||||
}
|
||||
if (output === 'types') {
|
||||
outputs.add(Output.types);
|
||||
}
|
||||
}
|
||||
|
||||
return outputs;
|
||||
}
|
||||
@@ -1,348 +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 fs from 'fs-extra';
|
||||
import {
|
||||
NormalizedOutputOptions,
|
||||
OutputAsset,
|
||||
OutputChunk,
|
||||
PluginContext,
|
||||
} from 'rollup';
|
||||
|
||||
import { forwardFileImports, cssEntryPoints } from './plugins';
|
||||
import { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
|
||||
// Helper to call generateBundle hook which can be a function or ObjectHook
|
||||
async function callGenerateBundle(
|
||||
plugin: ReturnType<typeof cssEntryPoints>,
|
||||
ctx: PluginContext,
|
||||
options: NormalizedOutputOptions,
|
||||
bundle: Record<string, OutputChunk | OutputAsset>,
|
||||
isWrite: boolean,
|
||||
) {
|
||||
const hook = plugin.generateBundle;
|
||||
if (typeof hook === 'function') {
|
||||
await hook.call(ctx, options, bundle, isWrite);
|
||||
} else if (hook && typeof hook === 'object' && 'handler' in hook) {
|
||||
await hook.handler.call(ctx, options, bundle, isWrite);
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
meta: {
|
||||
rollupVersion: '0.0.0',
|
||||
watchMode: false,
|
||||
},
|
||||
} as PluginContext;
|
||||
|
||||
describe('forwardFileImports', () => {
|
||||
it('should be created', () => {
|
||||
const plugin = forwardFileImports({ include: /\.png$/ });
|
||||
expect(plugin.name).toBe('forward-file-imports');
|
||||
});
|
||||
|
||||
it('should call through to original external option', async () => {
|
||||
const plugin = forwardFileImports({ include: /\.png$/ });
|
||||
const external = jest.fn((id: string) => id.endsWith('external'));
|
||||
|
||||
const options = (await plugin.options?.call(context, { external }))!;
|
||||
if (typeof options.external !== 'function') {
|
||||
throw new Error('options.external is not a function');
|
||||
}
|
||||
|
||||
expect(external).toHaveBeenCalledTimes(0);
|
||||
expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(external).toHaveBeenCalledTimes(1);
|
||||
expect(options.external('./my-external', '/dev/src/index.ts', false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(external).toHaveBeenCalledTimes(2);
|
||||
expect(options.external('./my-image.png', '/dev/src/index.ts', false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(external).toHaveBeenCalledTimes(3);
|
||||
expect(options.external('./my-image.png', '/dev/src/index.ts', true)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(external).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(() =>
|
||||
(options as any).external('./my-image.png', undefined, false),
|
||||
).toThrow('Unknown importer of file module ./my-image.png');
|
||||
});
|
||||
|
||||
it('should handle original external array', async () => {
|
||||
const plugin = forwardFileImports({ include: /\.png$/ });
|
||||
|
||||
const options = (await plugin.options?.call(context, {
|
||||
external: ['my-external'],
|
||||
}))!;
|
||||
if (typeof options.external !== 'function') {
|
||||
throw new Error('options.external is not a function');
|
||||
}
|
||||
|
||||
expect(options.external('my-module', '/dev/src/index.ts', false)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(options.external('my-external', '/dev/src/index.ts', false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(options.external('my-image.png', '/dev/src/index.ts', false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
describe('with createMockDirectory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
beforeEach(() => {
|
||||
mockDir.setContent({
|
||||
dev: {
|
||||
src: {
|
||||
'my-module.ts': '',
|
||||
dir: { 'my-image.png': 'my-image' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract files', async () => {
|
||||
const plugin = forwardFileImports({ include: /\.png$/ });
|
||||
|
||||
const options = (await plugin.options?.call(context, {}))!;
|
||||
if (typeof options.external !== 'function') {
|
||||
throw new Error('options.external is not a function');
|
||||
}
|
||||
|
||||
expect(
|
||||
options.external(
|
||||
'./my-module',
|
||||
mockDir.resolve('dev/src/index.ts'),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
options.external(
|
||||
'./my-image.png',
|
||||
mockDir.resolve('dev', 'src', 'dir', 'index.ts'),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const outPath = mockDir.resolve('dev', 'dist', 'dir', 'my-image.png');
|
||||
await expect(fs.pathExists(outPath)).resolves.toBe(false);
|
||||
|
||||
await plugin.generateBundle?.call(
|
||||
context,
|
||||
{
|
||||
dir: mockDir.resolve('dev/dist'),
|
||||
} as NormalizedOutputOptions,
|
||||
{
|
||||
['index.js']: {
|
||||
type: 'chunk',
|
||||
facadeModuleId: mockDir.resolve('dev/src/index.ts'),
|
||||
} as OutputChunk,
|
||||
},
|
||||
false, // isWrite = false -> no write
|
||||
);
|
||||
await expect(fs.pathExists(outPath)).resolves.toBe(false);
|
||||
|
||||
await plugin.generateBundle?.call(
|
||||
context,
|
||||
{
|
||||
dir: mockDir.resolve('dev/dist'),
|
||||
} as NormalizedOutputOptions,
|
||||
{
|
||||
// output assets should not cause a write
|
||||
['index.js']: { type: 'asset' } as OutputAsset,
|
||||
// missing facadeModuleId should not cause a write either
|
||||
['index2.js']: { type: 'chunk' } as OutputChunk,
|
||||
},
|
||||
true,
|
||||
);
|
||||
await expect(fs.pathExists(outPath)).resolves.toBe(false);
|
||||
|
||||
// output chunk + isWrite -> generate files
|
||||
await plugin.generateBundle?.call(
|
||||
context,
|
||||
{
|
||||
dir: mockDir.resolve('dev/dist'),
|
||||
} as NormalizedOutputOptions,
|
||||
{
|
||||
['index.js']: {
|
||||
type: 'chunk',
|
||||
facadeModuleId: mockDir.resolve('dev/src/index.ts'),
|
||||
} as OutputChunk,
|
||||
},
|
||||
true,
|
||||
);
|
||||
await expect(fs.pathExists(outPath)).resolves.toBe(true);
|
||||
|
||||
// should not break when triggering another write
|
||||
await plugin.generateBundle?.call(
|
||||
context,
|
||||
{
|
||||
file: mockDir.resolve('dev/dist/my-output.js'),
|
||||
} as NormalizedOutputOptions,
|
||||
{
|
||||
['index.js']: {
|
||||
type: 'chunk',
|
||||
facadeModuleId: mockDir.resolve('dev/src/index.ts'),
|
||||
} as OutputChunk,
|
||||
},
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cssEntryPoints', () => {
|
||||
it('should be created with correct name', () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [],
|
||||
targetDir: '/dev',
|
||||
});
|
||||
expect(plugin.name).toBe('backstage-css-entry-points');
|
||||
});
|
||||
|
||||
describe('with createMockDirectory', () => {
|
||||
const mockDir = createMockDirectory();
|
||||
|
||||
const emittedFiles: Array<{ fileName: string; source: string }> = [];
|
||||
const emitContext = {
|
||||
...context,
|
||||
emitFile: (file: { fileName: string; source: string }) => {
|
||||
emittedFiles.push(file);
|
||||
return 'asset-id';
|
||||
},
|
||||
} as unknown as PluginContext;
|
||||
|
||||
beforeEach(() => {
|
||||
emittedFiles.length = 0;
|
||||
mockDir.setContent({
|
||||
dev: {
|
||||
src: {
|
||||
css: {
|
||||
'styles.css': '@import "./base.css";\n.root { color: red; }',
|
||||
'base.css': '.base { margin: 0; }',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit when isWrite is false', async () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [
|
||||
{
|
||||
mount: './css/styles.css',
|
||||
path: './src/css/styles.css',
|
||||
name: 'css/styles.css',
|
||||
ext: '.css',
|
||||
},
|
||||
],
|
||||
targetDir: mockDir.resolve('dev'),
|
||||
});
|
||||
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(emittedFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should emit only CSS entry points with resolved imports', async () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [
|
||||
// Non-CSS entry should be ignored
|
||||
{ mount: '.', path: './src/index.ts', name: 'index', ext: '.ts' },
|
||||
{
|
||||
mount: './css/styles.css',
|
||||
path: './src/css/styles.css',
|
||||
name: 'css/styles.css',
|
||||
ext: '.css',
|
||||
},
|
||||
],
|
||||
targetDir: mockDir.resolve('dev'),
|
||||
});
|
||||
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
|
||||
// Only CSS file should be emitted, not the .ts entry
|
||||
expect(emittedFiles).toHaveLength(1);
|
||||
expect(emittedFiles[0].fileName).toBe('css/styles.css');
|
||||
expect(emittedFiles[0].source).toContain('.base { margin: 0; }');
|
||||
expect(emittedFiles[0].source).toContain('.root { color: red; }');
|
||||
expect(emittedFiles[0].source).not.toContain('@import');
|
||||
});
|
||||
|
||||
it('should only emit once per output directory', async () => {
|
||||
const plugin = cssEntryPoints({
|
||||
entryPoints: [
|
||||
{
|
||||
mount: './css/styles.css',
|
||||
path: './src/css/styles.css',
|
||||
name: 'css/styles.css',
|
||||
ext: '.css',
|
||||
},
|
||||
],
|
||||
targetDir: mockDir.resolve('dev'),
|
||||
});
|
||||
|
||||
// First call should emit
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
expect(emittedFiles).toHaveLength(1);
|
||||
|
||||
// Second call to same dir should not emit again
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
expect(emittedFiles).toHaveLength(1);
|
||||
|
||||
// Call to different dir should emit
|
||||
await callGenerateBundle(
|
||||
plugin,
|
||||
emitContext,
|
||||
{ dir: mockDir.resolve('dev/dist2') } as NormalizedOutputOptions,
|
||||
{},
|
||||
true,
|
||||
);
|
||||
expect(emittedFiles).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,229 +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 fs from 'fs-extra';
|
||||
import postcss from 'postcss';
|
||||
import postcssImport from 'postcss-import';
|
||||
import {
|
||||
dirname,
|
||||
resolve as resolvePath,
|
||||
relative as relativePath,
|
||||
} from 'node:path';
|
||||
import { createFilter } from 'rollup-pluginutils';
|
||||
import {
|
||||
Plugin,
|
||||
InputOptions,
|
||||
OutputChunk,
|
||||
HasModuleSideEffects,
|
||||
} from 'rollup';
|
||||
import { EntryPoint } from '../entryPoints';
|
||||
|
||||
type ForwardFileImportsOptions = {
|
||||
include: Array<string | RegExp> | string | RegExp | null;
|
||||
exclude?: Array<string | RegExp> | string | RegExp | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* This rollup plugin leaves all encountered asset imports as-is, but
|
||||
* copies the imported files into the output directory.
|
||||
*
|
||||
* For example `import ImageUrl from './my-image.png'` inside `src/MyComponent` will
|
||||
* cause `src/MyComponent/my-image.png` to be copied to the output directory at the
|
||||
* path `dist/MyComponent/my-image.png`. The import itself will stay, but be resolved,
|
||||
* resulting in something like `import ImageUrl from './MyComponent/my-image.png'`
|
||||
*/
|
||||
export function forwardFileImports(options: ForwardFileImportsOptions) {
|
||||
const filter = createFilter(options.include, options.exclude);
|
||||
|
||||
// We collect the absolute paths to all files we want to bundle into the
|
||||
// output dir here. Resolving to relative paths in the output dir happens later.
|
||||
const exportedFiles = new Set<string>();
|
||||
|
||||
// We keep track of output directories that we've already copied files
|
||||
// into, so that we don't duplicate that work
|
||||
const generatedFor = new Set<string>();
|
||||
|
||||
return {
|
||||
name: 'forward-file-imports',
|
||||
async generateBundle(outputOptions, bundle, isWrite) {
|
||||
if (!isWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = outputOptions.dir || dirname(outputOptions.file!);
|
||||
if (generatedFor.has(dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const output of Object.values(bundle)) {
|
||||
if (output.type !== 'chunk') {
|
||||
continue;
|
||||
}
|
||||
const chunk = output as OutputChunk;
|
||||
|
||||
// This'll be an absolute path pointing to the initial index file of the
|
||||
// build, and we use it to find the location of the `src` dir
|
||||
if (!chunk.facadeModuleId) {
|
||||
continue;
|
||||
}
|
||||
generatedFor.add(dir);
|
||||
|
||||
// We're assuming that the index file is at the root of the source dir, and
|
||||
// that all assets exist within that dir.
|
||||
const srcRoot = dirname(chunk.facadeModuleId);
|
||||
|
||||
// Copy all the files we found into the dist dir
|
||||
await Promise.all(
|
||||
Array.from(exportedFiles).map(async exportedFile => {
|
||||
const outputPath = relativePath(srcRoot, exportedFile);
|
||||
const targetFile = resolvePath(dir, outputPath);
|
||||
|
||||
await fs.ensureDir(dirname(targetFile));
|
||||
await fs.copyFile(exportedFile, targetFile);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
options(inputOptions) {
|
||||
// We're in control of the config ourselves, so these are just checks to
|
||||
// make sure we don't update the config but forget about the config
|
||||
// overrides here
|
||||
const treeshake = inputOptions.treeshake;
|
||||
if (treeshake !== undefined && typeof treeshake !== 'object') {
|
||||
throw new Error(
|
||||
'Expected treeshake input config to be an object or not set',
|
||||
);
|
||||
}
|
||||
if (treeshake?.moduleSideEffects) {
|
||||
throw new Error('treeshake.moduleSideEffects must not be set');
|
||||
}
|
||||
|
||||
// All external assets are treated as being side-effect free.
|
||||
//
|
||||
// This also works around an apparent bug in rollup where the
|
||||
// `makeAbsoluteExternalsRelative: false` option sometimes caused relative
|
||||
// asset paths to be rewritten with an incorrect path. They are rewritten
|
||||
// in the first place because they are being treated as external by this
|
||||
// plugin, but that seems to be the best way to handle asset files.
|
||||
const moduleSideEffects: HasModuleSideEffects = id => {
|
||||
if (filter(id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const origExternal = inputOptions.external;
|
||||
|
||||
// We decorate any existing `external` option with our own way of determining
|
||||
// if a module should be external. The can't use `resolveId`, since asset files
|
||||
// aren't passed there, might be some better way to do this though.
|
||||
const external: InputOptions['external'] = (id, importer, isResolved) => {
|
||||
// Call to inner external option
|
||||
if (
|
||||
typeof origExternal === 'function' &&
|
||||
origExternal(id, importer, isResolved)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(origExternal) && origExternal.includes(id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The piece that we're adding
|
||||
if (!filter(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Confidence check, dunno if this can happen
|
||||
if (!importer) {
|
||||
throw new Error(`Unknown importer of file module ${id}`);
|
||||
}
|
||||
|
||||
// Resolve relative imports to the full file URL, for deduping and copying later
|
||||
const fullId = isResolved ? id : resolvePath(dirname(importer), id);
|
||||
exportedFiles.add(fullId);
|
||||
|
||||
// Treating this module as external from here, meaning rollup won't try to
|
||||
// put it in the output bundle, but still keep track of the relative imports
|
||||
// as needed in the output code.
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
...inputOptions,
|
||||
external,
|
||||
treeshake: { ...treeshake, moduleSideEffects },
|
||||
};
|
||||
},
|
||||
} satisfies Plugin;
|
||||
}
|
||||
|
||||
interface CssEntryPointsOptions {
|
||||
entryPoints: EntryPoint[];
|
||||
targetDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollup plugin that bundles CSS entry points using postcss-import.
|
||||
* CSS files declared in package.json exports are processed and emitted
|
||||
* as part of the Rollup bundle.
|
||||
*/
|
||||
export function cssEntryPoints(options: CssEntryPointsOptions): Plugin {
|
||||
const cssEntries = options.entryPoints.filter(ep => ep.ext === '.css');
|
||||
|
||||
// Track output directories we've already emitted CSS to, to avoid duplicates
|
||||
// when Rollup runs generateBundle multiple times (once per output format)
|
||||
const generatedFor = new Set<string>();
|
||||
|
||||
return {
|
||||
name: 'backstage-css-entry-points',
|
||||
|
||||
async generateBundle(outputOptions, _bundle, isWrite) {
|
||||
if (!isWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = outputOptions.dir || dirname(outputOptions.file!);
|
||||
if (generatedFor.has(dir)) {
|
||||
return;
|
||||
}
|
||||
generatedFor.add(dir);
|
||||
|
||||
for (const entryPoint of cssEntries) {
|
||||
const sourcePath = resolvePath(options.targetDir, entryPoint.path);
|
||||
// Strip the src/ prefix to create an output filename relative to the Rollup output directory
|
||||
const outputPath = entryPoint.path.replace(/^(\.\/)?src\//, '');
|
||||
|
||||
// Read source CSS
|
||||
const source = await fs.readFile(sourcePath, 'utf8');
|
||||
|
||||
// Bundle @import statements using postcss-import
|
||||
const result = await postcss([postcssImport()]).process(source, {
|
||||
from: sourcePath,
|
||||
});
|
||||
|
||||
// Emit the bundled CSS as an asset
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: outputPath,
|
||||
source: result.css,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,48 +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.
|
||||
*/
|
||||
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
import { BackstagePackage, BackstagePackageJson } from '@backstage/cli-node';
|
||||
|
||||
export const Output = {
|
||||
esm: 0,
|
||||
cjs: 1,
|
||||
types: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type Output = (typeof Output)[keyof typeof Output];
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export namespace Output {
|
||||
export type esm = typeof Output.esm;
|
||||
export type cjs = typeof Output.cjs;
|
||||
export type types = typeof Output.types;
|
||||
}
|
||||
|
||||
export type BuildOptions = {
|
||||
logPrefix?: string;
|
||||
targetDir?: string;
|
||||
packageJson?: BackstagePackageJson;
|
||||
outputs: Set<Output>;
|
||||
minify?: boolean;
|
||||
workspacePackages: BackstagePackage[];
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
* 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 { AppConfig } from '@backstage/config';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
|
||||
export class ConfigInjectingHtmlWebpackPlugin extends HtmlWebpackPlugin {
|
||||
readonly name = 'ConfigInjectingHtmlWebpackPlugin';
|
||||
readonly #getFrontendAppConfigs: () => AppConfig[];
|
||||
|
||||
constructor(
|
||||
options: HtmlWebpackPlugin.Options,
|
||||
getFrontendAppConfigs: () => AppConfig[],
|
||||
) {
|
||||
super(options);
|
||||
this.#getFrontendAppConfigs = getFrontendAppConfigs;
|
||||
}
|
||||
|
||||
apply: HtmlWebpackPlugin['apply'] = compiler => {
|
||||
super.apply(compiler);
|
||||
|
||||
compiler.hooks.compilation.tap(this.name, compilation => {
|
||||
const hooks = HtmlWebpackPlugin.getCompilationHooks(compilation);
|
||||
hooks.alterAssetTagGroups.tap(this.name, ctx => {
|
||||
if (ctx.plugin !== this) {
|
||||
return ctx;
|
||||
}
|
||||
return {
|
||||
...ctx,
|
||||
headTags: [
|
||||
...ctx.headTags,
|
||||
HtmlWebpackPlugin.createHtmlTagObject(
|
||||
'script',
|
||||
{ type: 'backstage.io/config' },
|
||||
`\n${JSON.stringify(this.#getFrontendAppConfigs(), null, 2)}\n`,
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,234 +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 yn from 'yn';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { rspack, Configuration, MultiStats } from '@rspack/core';
|
||||
import {
|
||||
measureFileSizesBeforeBuild,
|
||||
printFileSizesAfterBuild,
|
||||
} from 'react-dev-utils/FileSizeReporter';
|
||||
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
|
||||
import { createConfig } from './config';
|
||||
import { BuildOptions } from './types';
|
||||
import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths';
|
||||
import chalk from 'chalk';
|
||||
import { createDetectedModulesEntryPoint } from './packageDetection';
|
||||
import { createRuntimeSharedDependenciesEntryPoint } from './moduleFederation';
|
||||
|
||||
// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
|
||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
|
||||
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
|
||||
|
||||
function applyContextToError(error: string, moduleName: string): string {
|
||||
return `Failed to compile '${moduleName}':\n ${error}`;
|
||||
}
|
||||
|
||||
export async function buildBundle(options: BuildOptions) {
|
||||
const { statsJsonEnabled, schema: configSchema, webpack } = options;
|
||||
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const publicPaths = await resolveOptionalBundlingPaths({
|
||||
targetDir: options.targetDir,
|
||||
entry: 'src/index-public-experimental',
|
||||
dist: 'dist/public',
|
||||
});
|
||||
|
||||
const commonConfigOptions = {
|
||||
...options,
|
||||
checksEnabled: false,
|
||||
isDev: false,
|
||||
getFrontendAppConfigs: () => options.frontendAppConfigs,
|
||||
};
|
||||
|
||||
const configs: Configuration[] = [];
|
||||
if (options.moduleFederationRemote) {
|
||||
// Package detection is disabled for remote bundles
|
||||
configs.push(await createConfig(paths, commonConfigOptions));
|
||||
} else {
|
||||
const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
|
||||
config: options.fullConfig,
|
||||
targetPath: paths.targetPath,
|
||||
});
|
||||
|
||||
const moduleFederationSharedDependenciesEntryPoint =
|
||||
await createRuntimeSharedDependenciesEntryPoint({
|
||||
targetPath: paths.targetPath,
|
||||
});
|
||||
|
||||
configs.push(
|
||||
await createConfig(paths, {
|
||||
...commonConfigOptions,
|
||||
additionalEntryPoints: [
|
||||
...detectedModulesEntryPoint,
|
||||
...moduleFederationSharedDependenciesEntryPoint,
|
||||
],
|
||||
appMode: publicPaths ? 'protected' : 'public',
|
||||
}),
|
||||
);
|
||||
|
||||
if (publicPaths) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ WARNING: The app /public entry point is an experimental feature that may receive immediate breaking changes.`,
|
||||
),
|
||||
);
|
||||
configs.push(
|
||||
await createConfig(publicPaths, {
|
||||
...commonConfigOptions,
|
||||
appMode: 'public',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isCi = yn(process.env.CI, { default: false });
|
||||
|
||||
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
|
||||
const previousAuthSizes = publicPaths
|
||||
? await measureFileSizesBeforeBuild(publicPaths.targetDist)
|
||||
: undefined;
|
||||
await fs.emptyDir(paths.targetDist);
|
||||
|
||||
if (paths.targetPublic) {
|
||||
await fs.copy(paths.targetPublic, paths.targetDist, {
|
||||
dereference: true,
|
||||
filter: file => file !== paths.targetHtml,
|
||||
});
|
||||
|
||||
// If we've got a separate public index entry point, copy public content there too
|
||||
if (publicPaths) {
|
||||
await fs.copy(paths.targetPublic, publicPaths.targetDist, {
|
||||
dereference: true,
|
||||
filter: file => file !== paths.targetHtml,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (configSchema) {
|
||||
await fs.writeJson(
|
||||
resolvePath(paths.targetDist, '.config-schema.json'),
|
||||
configSchema.serialize(),
|
||||
{ spaces: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
if (webpack) {
|
||||
console.log(chalk.yellow(`⚠️ WARNING: Using legacy WebPack bundler`));
|
||||
}
|
||||
|
||||
const { stats } = await build(configs, isCi, webpack);
|
||||
|
||||
if (!stats) {
|
||||
throw new Error('No stats returned');
|
||||
}
|
||||
const [mainStats, authStats] = stats.stats;
|
||||
|
||||
if (statsJsonEnabled) {
|
||||
// No @types/bfj
|
||||
await require('bfj').write(
|
||||
resolvePath(paths.targetDist, 'bundle-stats.json'),
|
||||
mainStats.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
printFileSizesAfterBuild(
|
||||
mainStats,
|
||||
previousFileSizes,
|
||||
paths.targetDist,
|
||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
||||
);
|
||||
if (publicPaths && previousAuthSizes) {
|
||||
printFileSizesAfterBuild(
|
||||
authStats,
|
||||
previousAuthSizes,
|
||||
publicPaths.targetDist,
|
||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function build(
|
||||
configs: Configuration[],
|
||||
isCi: boolean,
|
||||
webpack?: typeof import('webpack'),
|
||||
) {
|
||||
const bundler = (webpack ?? rspack) as typeof rspack;
|
||||
|
||||
const stats = await new Promise<MultiStats | undefined>((resolve, reject) => {
|
||||
bundler(configs, (err, buildStats) => {
|
||||
if (err) {
|
||||
if (err.message) {
|
||||
const { errors } = formatWebpackMessages({
|
||||
errors: [err.message],
|
||||
warnings: new Array<string>(),
|
||||
_showErrors: true,
|
||||
_showWarnings: true,
|
||||
});
|
||||
|
||||
throw new Error(errors[0]);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
resolve(buildStats);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!stats) {
|
||||
throw new Error('Failed to compile: No stats provided');
|
||||
}
|
||||
|
||||
const serializedStats = stats.toJson({
|
||||
all: false,
|
||||
warnings: true,
|
||||
errors: true,
|
||||
});
|
||||
const { errors, warnings } = formatWebpackMessages({
|
||||
errors: serializedStats.errors,
|
||||
warnings: serializedStats.warnings,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
// Only keep the first error. Others are often indicative
|
||||
// of the same problem, but confuse the reader with noise.
|
||||
const errorWithContext = applyContextToError(
|
||||
errors[0],
|
||||
serializedStats.errors?.[0]?.moduleName ?? '',
|
||||
);
|
||||
throw new Error(errorWithContext);
|
||||
}
|
||||
if (isCi && warnings.length) {
|
||||
const warningsWithContext = warnings.map((warning, i) => {
|
||||
return applyContextToError(
|
||||
warning,
|
||||
serializedStats.warnings?.[i]?.moduleName ?? '',
|
||||
);
|
||||
});
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\nTreating warnings as errors because process.env.CI = true.\n',
|
||||
),
|
||||
);
|
||||
throw new Error(warningsWithContext.join('\n\n'));
|
||||
}
|
||||
|
||||
return { stats };
|
||||
}
|
||||
@@ -1,397 +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 { resolve as resolvePath } from 'node:path';
|
||||
import { BundlingOptions, ModuleFederationRemoteOptions } from './types';
|
||||
import { rspack, Configuration } from '@rspack/core';
|
||||
|
||||
import { BundlingPaths } from './paths';
|
||||
import { Config } from '@backstage/config';
|
||||
import ESLintRspackPlugin from 'eslint-rspack-plugin';
|
||||
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 fs from 'fs-extra';
|
||||
import { optimization as optimizationConfig } from './optimization';
|
||||
import pickBy from 'lodash/pickBy';
|
||||
import { runOutput, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { transforms } from './transforms';
|
||||
import { version } from '../../../../wiring/version';
|
||||
import yn from 'yn';
|
||||
import { hasReactDomClient } from './hasReactDomClient';
|
||||
import { createWorkspaceLinkingPlugins } from './linkWorkspaces';
|
||||
import { ConfigInjectingHtmlWebpackPlugin } from './ConfigInjectingHtmlWebpackPlugin';
|
||||
|
||||
export function resolveBaseUrl(
|
||||
config: Config,
|
||||
moduleFederationRemote?: ModuleFederationRemoteOptions,
|
||||
): URL {
|
||||
const baseUrl = config.getOptionalString('app.baseUrl');
|
||||
|
||||
const defaultBaseUrl = moduleFederationRemote
|
||||
? `http://localhost:${process.env.PORT ?? '3000'}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
try {
|
||||
return new URL(baseUrl ?? '/', defaultBaseUrl);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid app.baseUrl, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveEndpoint(
|
||||
config: Config,
|
||||
moduleFederationRemote?: ModuleFederationRemoteOptions,
|
||||
): {
|
||||
host: string;
|
||||
port: number;
|
||||
} {
|
||||
const url = resolveBaseUrl(config, moduleFederationRemote);
|
||||
|
||||
return {
|
||||
host: config.getOptionalString('app.listen.host') ?? url.hostname,
|
||||
port:
|
||||
config.getOptionalNumber('app.listen.port') ??
|
||||
Number(url.port) ??
|
||||
(url.protocol === 'https:' ? 443 : 80),
|
||||
};
|
||||
}
|
||||
|
||||
async function readBuildInfo() {
|
||||
const timestamp = Date.now();
|
||||
|
||||
let commit: string | undefined;
|
||||
try {
|
||||
commit = await runOutput(['git', 'rev-parse', 'HEAD']);
|
||||
} catch (error) {
|
||||
// ignore, see below
|
||||
}
|
||||
|
||||
let gitVersion: string | undefined;
|
||||
try {
|
||||
gitVersion = await runOutput(['git', 'describe', '--always']);
|
||||
} catch (error) {
|
||||
// ignore, see below
|
||||
}
|
||||
|
||||
if (commit === undefined || gitVersion === undefined) {
|
||||
console.info(
|
||||
'NOTE: Did not compute git version or commit hash, could not execute the git command line utility',
|
||||
);
|
||||
}
|
||||
|
||||
const { version: packageVersion } = await fs.readJson(
|
||||
targetPaths.resolve('package.json'),
|
||||
);
|
||||
|
||||
return {
|
||||
cliVersion: version,
|
||||
gitVersion: gitVersion ?? 'unknown',
|
||||
packageVersion,
|
||||
timestamp,
|
||||
commit: commit ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
export async function createConfig(
|
||||
paths: BundlingPaths,
|
||||
options: BundlingOptions,
|
||||
): Promise<Configuration> {
|
||||
const {
|
||||
checksEnabled,
|
||||
isDev,
|
||||
frontendConfig,
|
||||
moduleFederationRemote,
|
||||
publicSubPath = '',
|
||||
webpack,
|
||||
} = options;
|
||||
|
||||
const { plugins, loaders } = transforms(options);
|
||||
// Any package that is part of the monorepo but outside the monorepo root dir need
|
||||
// separate resolution logic.
|
||||
|
||||
const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederationRemote);
|
||||
let publicPath = validBaseUrl.pathname.replace(/\/$/, '');
|
||||
if (publicSubPath) {
|
||||
publicPath = `${publicPath}${publicSubPath}`.replace('//', '/');
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
const { host, port } = resolveEndpoint(
|
||||
options.frontendConfig,
|
||||
options.moduleFederationRemote,
|
||||
);
|
||||
|
||||
const refreshOptions = {
|
||||
overlay: {
|
||||
sockProtocol: 'ws',
|
||||
sockHost: host,
|
||||
sockPort: port,
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (webpack) {
|
||||
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
|
||||
plugins.push(new ReactRefreshPlugin(refreshOptions));
|
||||
} else {
|
||||
const RspackReactRefreshPlugin = require('@rspack/plugin-react-refresh');
|
||||
plugins.push(new RspackReactRefreshPlugin(refreshOptions));
|
||||
}
|
||||
}
|
||||
|
||||
if (checksEnabled) {
|
||||
const TsCheckerPlugin = webpack
|
||||
? (require('fork-ts-checker-webpack-plugin') as typeof import('fork-ts-checker-webpack-plugin'))
|
||||
: TsCheckerRspackPlugin;
|
||||
const ESLintPlugin = webpack
|
||||
? (require('eslint-webpack-plugin') as typeof import('eslint-webpack-plugin'))
|
||||
: ESLintRspackPlugin;
|
||||
plugins.push(
|
||||
new TsCheckerPlugin({
|
||||
typescript: { configFile: paths.targetTsConfig, memoryLimit: 8192 },
|
||||
}),
|
||||
new ESLintPlugin({
|
||||
cache: false, // Cache seems broken
|
||||
context: paths.targetPath,
|
||||
files: ['**/*.(ts|tsx|mts|cts|js|jsx|mjs|cjs)'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const bundler = webpack ? (webpack as unknown as typeof rspack) : rspack;
|
||||
|
||||
// TODO(blam): process is no longer auto polyfilled by webpack in v5.
|
||||
// we use the provide plugin to provide this polyfill, but lets look
|
||||
// to remove this eventually!
|
||||
plugins.push(
|
||||
new bundler.ProvidePlugin({
|
||||
process: require.resolve('process/browser'),
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
);
|
||||
|
||||
if (!options.moduleFederationRemote) {
|
||||
const templateOptions = {
|
||||
meta: {
|
||||
'backstage-app-mode': options?.appMode ?? 'public',
|
||||
},
|
||||
template: paths.targetHtml,
|
||||
templateParameters: {
|
||||
publicPath,
|
||||
config: frontendConfig,
|
||||
},
|
||||
};
|
||||
if (webpack) {
|
||||
// Config injection via index.html doesn't work across reloads with
|
||||
// WebPack, so we rely on the APP_CONFIG injection instead
|
||||
plugins.push(new HtmlWebpackPlugin(templateOptions));
|
||||
} else {
|
||||
// With Rspack we inject config via index.html, this is both because we
|
||||
// can't use APP_CONFIG due to the lack of support for runtime values, but
|
||||
// also because we are able to do it and it lines up better with what the
|
||||
// app-backend is doing.
|
||||
//
|
||||
// We still use the html plugin from WebPack, since the Rspack one won't
|
||||
// let us inject complex objects like the config.
|
||||
plugins.push(
|
||||
new ConfigInjectingHtmlWebpackPlugin(
|
||||
templateOptions,
|
||||
options.getFrontendAppConfigs,
|
||||
),
|
||||
);
|
||||
}
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
meta: {
|
||||
'backstage-app-mode': options?.appMode ?? 'public',
|
||||
// This is added to be written in the later step, and finally read by the extra entry point
|
||||
'backstage-public-path': '<%= publicPath %>/',
|
||||
},
|
||||
minify: false,
|
||||
publicPath: '<%= publicPath %>',
|
||||
filename: 'index.html.tmpl',
|
||||
template: `${require.resolve('raw-loader')}!${paths.targetHtml}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.moduleFederationRemote) {
|
||||
const AdaptedModuleFederationPlugin = webpack
|
||||
? (require('@module-federation/enhanced/webpack')
|
||||
.ModuleFederationPlugin as unknown as typeof ModuleFederationPlugin)
|
||||
: ModuleFederationPlugin;
|
||||
|
||||
const exposes = options.moduleFederationRemote.exposes
|
||||
? Object.fromEntries(
|
||||
Object.entries(options.moduleFederationRemote?.exposes).map(
|
||||
([k, v]) => [k, resolvePath(paths.targetPath, v)],
|
||||
),
|
||||
)
|
||||
: {
|
||||
'.': paths.targetEntry,
|
||||
};
|
||||
|
||||
plugins.push(
|
||||
new AdaptedModuleFederationPlugin({
|
||||
filename: 'remoteEntry.js',
|
||||
exposes,
|
||||
name: options.moduleFederationRemote.name,
|
||||
runtime: false,
|
||||
shared: options.moduleFederationRemote.sharedDependencies,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const buildInfo = await readBuildInfo();
|
||||
|
||||
plugins.push(
|
||||
webpack
|
||||
? new webpack.DefinePlugin({
|
||||
'process.env.BUILD_INFO': JSON.stringify(buildInfo),
|
||||
'process.env.APP_CONFIG': webpack.DefinePlugin.runtimeValue(
|
||||
() => JSON.stringify(options.getFrontendAppConfigs()),
|
||||
true,
|
||||
),
|
||||
// This allows for conditional imports of react-dom/client, since there's no way
|
||||
// to check for presence of it in source code without module resolution errors.
|
||||
'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(
|
||||
hasReactDomClient(),
|
||||
),
|
||||
})
|
||||
: new bundler.DefinePlugin({
|
||||
'process.env.BUILD_INFO': JSON.stringify(buildInfo),
|
||||
'process.env.APP_CONFIG': JSON.stringify([]), // Inject via index.html instead
|
||||
// This allows for conditional imports of react-dom/client, since there's no way
|
||||
// to check for presence of it in source code without module resolution errors.
|
||||
'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(
|
||||
hasReactDomClient(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
if (options.linkedWorkspace) {
|
||||
plugins.push(
|
||||
...(await createWorkspaceLinkingPlugins(
|
||||
bundler,
|
||||
options.linkedWorkspace,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
// These files are required by the transpiled code when using React Refresh.
|
||||
// They need to be excluded to the module scope plugin which ensures that files
|
||||
// that exist in the package are required.
|
||||
const reactRefreshFiles = webpack
|
||||
? [
|
||||
require.resolve(
|
||||
'@pmmmwh/react-refresh-webpack-plugin/lib/runtime/RefreshUtils.js',
|
||||
),
|
||||
require.resolve(
|
||||
'@pmmmwh/react-refresh-webpack-plugin/overlay/index.js',
|
||||
),
|
||||
require.resolve('react-refresh'),
|
||||
]
|
||||
: [];
|
||||
|
||||
const mode = isDev ? 'development' : 'production';
|
||||
const optimization = optimizationConfig(options);
|
||||
|
||||
return {
|
||||
mode,
|
||||
profile: false,
|
||||
...(isDev
|
||||
? {
|
||||
watchOptions: {
|
||||
ignored: /node_modules\/(?!__backstage-autodetected-plugins__)/,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
optimization,
|
||||
bail: false,
|
||||
performance: {
|
||||
hints: false, // we check the gzip size instead
|
||||
},
|
||||
devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map',
|
||||
context: paths.targetPath,
|
||||
entry: [
|
||||
require.resolve('@backstage/cli/config/webpack-public-path'),
|
||||
...(options.additionalEntryPoints ?? []),
|
||||
paths.targetEntry,
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json', '.wasm'],
|
||||
mainFields: ['browser', 'module', 'main'],
|
||||
fallback: {
|
||||
...pickBy(require('node-stdlib-browser')),
|
||||
module: false,
|
||||
dgram: false,
|
||||
dns: false,
|
||||
fs: false,
|
||||
http2: false,
|
||||
net: false,
|
||||
tls: false,
|
||||
child_process: false,
|
||||
|
||||
/* new ignores */
|
||||
path: false,
|
||||
https: false,
|
||||
http: false,
|
||||
util: require.resolve('util/'),
|
||||
},
|
||||
// FIXME: see also https://github.com/web-infra-dev/rspack/issues/3408
|
||||
...(webpack && {
|
||||
plugins: [
|
||||
new ModuleScopePlugin(
|
||||
[paths.targetSrc, paths.targetDev],
|
||||
[paths.targetPackageJson, ...reactRefreshFiles],
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
module: {
|
||||
rules: loaders,
|
||||
},
|
||||
output: {
|
||||
uniqueName: options.moduleFederationRemote?.name,
|
||||
path: paths.targetDist,
|
||||
publicPath: options.moduleFederationRemote ? 'auto' : `${publicPath}/`,
|
||||
filename: isDev ? '[name].js' : 'static/[name].[contenthash:8].js',
|
||||
chunkFilename: isDev
|
||||
? '[name].chunk.js'
|
||||
: 'static/[name].[contenthash:8].chunk.js',
|
||||
...(isDev
|
||||
? {
|
||||
devtoolModuleFilenameTemplate: (info: any) =>
|
||||
`file:///${resolvePath(info.absoluteResourcePath).replace(
|
||||
/\\/g,
|
||||
'/',
|
||||
)}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
experiments: {
|
||||
lazyCompilation: yn(process.env.EXPERIMENTAL_LAZY_COMPILATION),
|
||||
...(!webpack && {
|
||||
// We're still using `style-loader` for custom `insert` option
|
||||
css: false,
|
||||
}),
|
||||
},
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 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';
|
||||
|
||||
export function hasReactDomClient() {
|
||||
try {
|
||||
require.resolve('react-dom/client', {
|
||||
paths: [targetPaths.dir],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +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.
|
||||
*/
|
||||
|
||||
export { buildBundle } from './bundle';
|
||||
export { getModuleFederationRemoteOptions } from './moduleFederation';
|
||||
export { serveBundle } from './server';
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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 { relative as relativePath } from 'node:path';
|
||||
import { getPackages } from '@manypkg/get-packages';
|
||||
import { rspack } from '@rspack/core';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
/**
|
||||
* This returns of collection of plugins that links a separate workspace into
|
||||
* the target one. Any packages that are present in the linked workspaces will
|
||||
* always be used in place of the ones in the target workspace, with the exception
|
||||
* of react and react-dom which are always resolved from the target workspace.
|
||||
*/
|
||||
export async function createWorkspaceLinkingPlugins(
|
||||
bundler: typeof rspack,
|
||||
workspace: string,
|
||||
) {
|
||||
const { packages: linkedPackages, root: linkedRoot } = await getPackages(
|
||||
workspace,
|
||||
);
|
||||
|
||||
// Matches all packages in the linked workspaces, as well as sub-path exports from them
|
||||
const replacementRegex = new RegExp(
|
||||
`^(?:${linkedPackages
|
||||
.map(pkg => pkg.packageJson.name)
|
||||
.join('|')})(?:/.*)?$`,
|
||||
);
|
||||
|
||||
return [
|
||||
// Any imports of a package that is present in the linked workspace will
|
||||
// be redirected to be resolved within the context of the linked workspace
|
||||
new bundler.NormalModuleReplacementPlugin(replacementRegex, resource => {
|
||||
resource.context = linkedRoot.dir;
|
||||
}),
|
||||
// react and react-dom are always resolved from the target directory
|
||||
// Note: this often requires that the linked and target workspace use the same versions of React
|
||||
new bundler.NormalModuleReplacementPlugin(
|
||||
/^react(?:-router)?(?:-dom)?$/,
|
||||
resource => {
|
||||
if (!relativePath(linkedRoot.dir, resource.context).startsWith('..')) {
|
||||
resource.context = targetPaths.dir;
|
||||
}
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
* Copyright 2025 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 { prepareRuntimeSharedDependenciesScript } from './moduleFederation';
|
||||
import { BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL } from '@backstage/module-federation-common';
|
||||
|
||||
const GLOBAL = BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL;
|
||||
|
||||
describe('prepareRuntimeSharedDependenciesScript', () => {
|
||||
it('should generate script with a single dependency', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(`window['${GLOBAL}'] = {
|
||||
"items": [
|
||||
{
|
||||
"name": "react",
|
||||
"version": "18.2.0",
|
||||
"lib": () => import("react"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "v1"
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should generate script with multiple dependencies', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: true,
|
||||
},
|
||||
'react-dom': {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: true,
|
||||
},
|
||||
lodash: {
|
||||
version: '4.17.21',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(`window['${GLOBAL}'] = {
|
||||
"items": [
|
||||
{
|
||||
"name": "react",
|
||||
"version": "18.2.0",
|
||||
"lib": () => import("react"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "react-dom",
|
||||
"version": "18.2.0",
|
||||
"lib": () => import("react-dom"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lodash",
|
||||
"version": "4.17.21",
|
||||
"lib": () => import("lodash"),
|
||||
"shareConfig": {
|
||||
"singleton": true,
|
||||
"requiredVersion": "*",
|
||||
"eager": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"version": "v1"
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should handle custom requiredVersion', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
version: '18.2.0',
|
||||
requiredVersion: '^18.0.0',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContain('"requiredVersion": "^18.0.0"');
|
||||
});
|
||||
|
||||
it('should handle scoped package names', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({
|
||||
'@backstage/core-plugin-api': {
|
||||
version: '1.0.0',
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toContain('"name": "@backstage/core-plugin-api"');
|
||||
expect(result).toContain(
|
||||
'"lib": () => import("@backstage/core-plugin-api")',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty dependencies', () => {
|
||||
const result = prepareRuntimeSharedDependenciesScript({});
|
||||
|
||||
expect(result).toBe(`window['${GLOBAL}'] = {
|
||||
"items": [],
|
||||
"version": "v1"
|
||||
};`);
|
||||
});
|
||||
|
||||
it('should throw if version is missing', () => {
|
||||
expect(() =>
|
||||
prepareRuntimeSharedDependenciesScript({
|
||||
react: {
|
||||
requiredVersion: '*',
|
||||
singleton: true,
|
||||
eager: false,
|
||||
},
|
||||
}),
|
||||
).toThrow("Version is required for shared dependency 'react'");
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
/*
|
||||
* 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 { ModuleFederationRemoteOptions } from './types';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { readEntryPoints } from '../entryPoints';
|
||||
import {
|
||||
createTypeDistProject,
|
||||
getEntryPointDefaultFeatureType,
|
||||
} from '../typeDistProject';
|
||||
import {
|
||||
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
|
||||
defaultRemoteSharedDependencies,
|
||||
defaultHostSharedDependencies,
|
||||
HostSharedDependencies,
|
||||
RuntimeSharedDependenciesGlobal,
|
||||
} from '@backstage/module-federation-common';
|
||||
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';
|
||||
|
||||
// Remote modules management utilities
|
||||
|
||||
export async function getModuleFederationRemoteOptions(
|
||||
packageJson: BackstagePackageJson,
|
||||
packageDir: string,
|
||||
): Promise<ModuleFederationRemoteOptions | undefined> {
|
||||
let exposes: ModuleFederationRemoteOptions['exposes'];
|
||||
const packageRole = packageJson.backstage?.role;
|
||||
if (packageJson.exports && packageRole) {
|
||||
const project = await createTypeDistProject();
|
||||
exposes = Object.fromEntries(
|
||||
readEntryPoints(packageJson)
|
||||
.filter(ep => {
|
||||
if (ep.mount === './package.json') {
|
||||
return false;
|
||||
}
|
||||
if (ep.mount === '.') {
|
||||
return true;
|
||||
}
|
||||
// Include this additional entry point in the exposed modules
|
||||
// if it exports a feature as default export.
|
||||
return (
|
||||
getEntryPointDefaultFeatureType(
|
||||
packageRole,
|
||||
packageDir,
|
||||
project,
|
||||
ep.path,
|
||||
) !== null
|
||||
);
|
||||
})
|
||||
.map(ep => [ep.mount, ep.path]),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
// The default output mode requires the name to be a usable as a code
|
||||
// symbol, there might be better options here but for now we need to
|
||||
// sanitize the name.
|
||||
name: packageJson.name
|
||||
.replaceAll('@', '')
|
||||
.replaceAll('/', '__')
|
||||
.replaceAll('-', '_'),
|
||||
exposes,
|
||||
sharedDependencies: defaultRemoteSharedDependencies(),
|
||||
};
|
||||
}
|
||||
|
||||
// Module federation host management utilities
|
||||
|
||||
/**
|
||||
* Prepares the runtime shared dependencies script for the module federation host,
|
||||
* which will be written by the CLI into a Javascript file added as an additional entry point for the frontend bundler.
|
||||
* This script is used in the browser to build the list of shared dependencies provided to the module federation runtime.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function prepareRuntimeSharedDependenciesScript(
|
||||
hostSharedDependencies: HostSharedDependencies,
|
||||
) {
|
||||
const items = Object.entries(hostSharedDependencies).map(
|
||||
([name, sharedDep]) => {
|
||||
if (!sharedDep.version) {
|
||||
throw new Error(`Version is required for shared dependency '${name}'`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
version: sharedDep.version,
|
||||
lib: name as unknown as () => Promise<unknown>, // Coverted into import below
|
||||
shareConfig: {
|
||||
singleton: sharedDep.singleton,
|
||||
requiredVersion: sharedDep.requiredVersion,
|
||||
eager: sharedDep.eager,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return `window['${BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}'] = ${JSON.stringify(
|
||||
{ items, version: 'v1' } satisfies RuntimeSharedDependenciesGlobal,
|
||||
null,
|
||||
2,
|
||||
).replace(
|
||||
/"lib": ("[^"]+")/gm,
|
||||
(_, name) => `"lib": () => import(${name})`,
|
||||
)};`;
|
||||
}
|
||||
|
||||
const RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME =
|
||||
'__backstage-module-federation-runtime-shared-dependencies__';
|
||||
|
||||
// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites
|
||||
const writeQueue = new PQueue({ concurrency: 1 });
|
||||
|
||||
async function writeRuntimeSharedDependenciesModule(
|
||||
targetPath: string,
|
||||
runtimeSharedDependencies: HostSharedDependencies,
|
||||
) {
|
||||
const script = prepareRuntimeSharedDependenciesScript(
|
||||
runtimeSharedDependencies,
|
||||
);
|
||||
|
||||
await writeQueue.add(async () => {
|
||||
const path = joinPath(
|
||||
targetPath,
|
||||
'node_modules',
|
||||
`${RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME}.js`,
|
||||
);
|
||||
|
||||
await fs.ensureDir(dirname(path));
|
||||
await fs.writeFile(path, script);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSharedDependencyVersions(
|
||||
targetPath: string,
|
||||
hostSharedDependencies: HostSharedDependencies,
|
||||
): HostSharedDependencies {
|
||||
return Object.fromEntries(
|
||||
Object.entries(hostSharedDependencies)
|
||||
.filter(([_, sharedDep]) => sharedDep !== undefined)
|
||||
.flatMap(([importPath, sharedDep]) => {
|
||||
// Remove any sub-path exports from the import path
|
||||
const moduleName = importPath.startsWith('@')
|
||||
? importPath.split('/').slice(0, 2).join('/')
|
||||
: importPath.split('/')[0];
|
||||
|
||||
let version: string;
|
||||
try {
|
||||
const packagePath = require.resolve(`${moduleName}/package.json`, {
|
||||
paths: [targetPath],
|
||||
});
|
||||
version = require(packagePath).version;
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`Skipping module federation shared dependency '${importPath}' because it could not be resolved.`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[importPath, { ...sharedDep, version }]];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRuntimeSharedDependenciesEntryPoint(options: {
|
||||
targetPath: string;
|
||||
watch?: () => void;
|
||||
}): Promise<string[]> {
|
||||
const { targetPath, watch } = options;
|
||||
|
||||
const doWriteSharedDependenciesModule = async () => {
|
||||
const sharedDependencies = defaultHostSharedDependencies();
|
||||
await writeRuntimeSharedDependenciesModule(
|
||||
targetPath,
|
||||
resolveSharedDependencyVersions(targetPath, sharedDependencies),
|
||||
);
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const watcher = chokidar.watch(resolvePath(targetPath, 'package.json'));
|
||||
watcher.on('change', async () => {
|
||||
await doWriteSharedDependenciesModule();
|
||||
watch();
|
||||
});
|
||||
}
|
||||
await doWriteSharedDependenciesModule();
|
||||
|
||||
return [RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME];
|
||||
}
|
||||
@@ -1,95 +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 { BundlingOptions } from './types';
|
||||
import {
|
||||
SwcJsMinimizerRspackPlugin,
|
||||
LightningCssMinimizerRspackPlugin,
|
||||
RspackOptionsNormalized,
|
||||
} from '@rspack/core';
|
||||
|
||||
export const optimization = (
|
||||
options: BundlingOptions,
|
||||
): RspackOptionsNormalized['optimization'] => {
|
||||
const { isDev, webpack } = options;
|
||||
|
||||
const MinifyPlugin = webpack
|
||||
? require('esbuild-loader').EsbuildPlugin
|
||||
: SwcJsMinimizerRspackPlugin;
|
||||
|
||||
return {
|
||||
minimize: !isDev,
|
||||
minimizer: [
|
||||
new MinifyPlugin({
|
||||
target: 'ES2023',
|
||||
format: 'iife',
|
||||
exclude: 'remoteEntry.js',
|
||||
}),
|
||||
// Avoid iife wrapping of module federation remote entry as it breaks the variable assignment
|
||||
new MinifyPlugin({
|
||||
target: 'ES2023',
|
||||
format: undefined,
|
||||
include: 'remoteEntry.js',
|
||||
}),
|
||||
webpack ? undefined : new LightningCssMinimizerRspackPlugin(),
|
||||
],
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
automaticNameDelimiter: '-',
|
||||
cacheGroups: {
|
||||
default: false,
|
||||
// Put all vendor code needed for initial page load in individual files if they're big
|
||||
// enough, if they're smaller they end up in the main
|
||||
packages: {
|
||||
chunks: 'initial',
|
||||
test(module: any) {
|
||||
return Boolean(
|
||||
module?.resource?.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/),
|
||||
);
|
||||
},
|
||||
name(module: any) {
|
||||
// get the name. E.g. node_modules/packageName/not/this/part.js
|
||||
// or node_modules/packageName
|
||||
const packageName = module.resource.match(
|
||||
/[\\/]node_modules[\\/](.*?)([\\/]|$)/,
|
||||
)[1];
|
||||
|
||||
// npm package names are URL-safe, but some servers don't like @ symbols
|
||||
return packageName.replace('@', '');
|
||||
},
|
||||
filename: isDev
|
||||
? 'module-[name].js'
|
||||
: 'static/module-[name].[contenthash:8].js',
|
||||
priority: 10,
|
||||
minSize: 100000,
|
||||
minChunks: 1,
|
||||
...(webpack && {
|
||||
maxAsyncRequests: Infinity,
|
||||
maxInitialRequests: Infinity,
|
||||
}),
|
||||
}, // filename is not included in type, but we need it
|
||||
// Group together the smallest modules
|
||||
vendor: {
|
||||
chunks: 'initial',
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: 'vendor',
|
||||
priority: 5,
|
||||
enforce: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,175 +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 { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
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 { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
const DETECTED_MODULES_MODULE_NAME = '__backstage-autodetected-plugins__';
|
||||
|
||||
interface PackageDetectionConfig {
|
||||
include?: string[];
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
function readPackageDetectionConfig(
|
||||
config: Config,
|
||||
): PackageDetectionConfig | undefined {
|
||||
const packages = config.getOptional('app.packages');
|
||||
if (packages === undefined || packages === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof packages === 'string') {
|
||||
if (packages !== 'all') {
|
||||
throw new Error(
|
||||
`Invalid app.packages mode, got '${packages}', expected 'all'`,
|
||||
);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof packages !== 'object' || Array.isArray(packages)) {
|
||||
throw new Error("Invalid config at 'app.packages', expected object");
|
||||
}
|
||||
const packagesConfig = new ConfigReader(packages, 'app.packages');
|
||||
|
||||
return {
|
||||
include: packagesConfig.getOptionalStringArray('include'),
|
||||
exclude: packagesConfig.getOptionalStringArray('exclude'),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectPackages(
|
||||
targetPath: string,
|
||||
{ include, exclude }: PackageDetectionConfig,
|
||||
) {
|
||||
const pkg: BackstagePackageJson = await fs.readJson(
|
||||
resolvePath(targetPath, 'package.json'),
|
||||
);
|
||||
|
||||
return Object.keys(pkg.dependencies ?? {}).flatMap(depName => {
|
||||
if (exclude?.includes(depName)) {
|
||||
return [];
|
||||
}
|
||||
if (include && !include.includes(depName)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const depPackageJson: BackstagePackageJson = require(require.resolve(
|
||||
`${depName}/package.json`,
|
||||
{ paths: [targetPath] },
|
||||
));
|
||||
if (
|
||||
['frontend-plugin', 'frontend-plugin-module'].includes(
|
||||
depPackageJson.backstage?.role ?? '',
|
||||
)
|
||||
) {
|
||||
// Include alpha entry point if available. If there's no default export it will be ignored
|
||||
const exp = depPackageJson.exports;
|
||||
if (exp && typeof exp === 'object' && './alpha' in exp) {
|
||||
return [
|
||||
{ name: depName, import: depName },
|
||||
{ name: depName, export: './alpha', import: `${depName}/alpha` },
|
||||
];
|
||||
}
|
||||
return [{ name: depName, import: depName }];
|
||||
}
|
||||
} catch {
|
||||
/* ignore packages that don't make package.json available */
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites
|
||||
const writeQueue = new PQueue({ concurrency: 1 });
|
||||
|
||||
async function writeDetectedPackagesModule(
|
||||
targetPath: string,
|
||||
pkgs: { name: string; export?: string; import: string }[],
|
||||
) {
|
||||
const requirePackageScript = pkgs
|
||||
?.map(
|
||||
pkg =>
|
||||
`{ name: ${JSON.stringify(pkg.name)}, export: ${JSON.stringify(
|
||||
pkg.export,
|
||||
)}, default: require('${pkg.import}').default }`,
|
||||
)
|
||||
.join(',');
|
||||
|
||||
await writeQueue.add(async () => {
|
||||
const detectedModulesPath = joinPath(
|
||||
targetPath,
|
||||
'node_modules',
|
||||
`${DETECTED_MODULES_MODULE_NAME}.js`,
|
||||
);
|
||||
|
||||
await fs.ensureDir(dirname(detectedModulesPath));
|
||||
await fs.writeFile(
|
||||
detectedModulesPath,
|
||||
`window['__@backstage/discovered__'] = { modules: [${requirePackageScript}] };`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDetectedModulesEntryPoint(options: {
|
||||
config: Config;
|
||||
targetPath: string;
|
||||
watch?: () => void;
|
||||
}): Promise<string[]> {
|
||||
const { config, watch, targetPath } = options;
|
||||
|
||||
const detectionConfig = readPackageDetectionConfig(config);
|
||||
if (!detectionConfig) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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(
|
||||
targetPaths.rootDir,
|
||||
'node_modules',
|
||||
`${DETECTED_MODULES_MODULE_NAME}.js`,
|
||||
);
|
||||
if (await fs.pathExists(legacyDetectedModulesPath)) {
|
||||
await fs.remove(legacyDetectedModulesPath);
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
const watcher = chokidar.watch(resolvePath(targetPath, 'package.json'));
|
||||
|
||||
watcher.on('change', async () => {
|
||||
await writeDetectedPackagesModule(
|
||||
targetPath,
|
||||
await detectPackages(targetPath, detectionConfig),
|
||||
);
|
||||
watch();
|
||||
});
|
||||
}
|
||||
|
||||
await writeDetectedPackagesModule(
|
||||
targetPath,
|
||||
await detectPackages(targetPath, detectionConfig),
|
||||
);
|
||||
|
||||
return [DETECTED_MODULES_MODULE_NAME];
|
||||
}
|
||||
@@ -1,90 +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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { targetPaths, findOwnPaths } from '@backstage/cli-common';
|
||||
|
||||
export type BundlingPathsOptions = {
|
||||
// bundle entrypoint, e.g. 'src/index'
|
||||
entry: string;
|
||||
// Target directory, defaulting to targetPaths.dir
|
||||
targetDir?: string;
|
||||
// Relative dist directory, defaulting to 'dist'
|
||||
dist?: string;
|
||||
};
|
||||
|
||||
export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
const { entry, targetDir = targetPaths.dir } = options;
|
||||
|
||||
const resolveTargetModule = (pathString: string) => {
|
||||
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
|
||||
const filePath = resolvePath(targetDir, `${pathString}.${ext}`);
|
||||
if (fs.pathExistsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
return resolvePath(targetDir, `${pathString}.js`);
|
||||
};
|
||||
|
||||
let targetPublic = undefined;
|
||||
let targetHtml = resolvePath(targetDir, 'public/index.html');
|
||||
|
||||
// Prefer public folder
|
||||
if (fs.pathExistsSync(targetHtml)) {
|
||||
targetPublic = resolvePath(targetDir, 'public');
|
||||
} else {
|
||||
targetHtml = resolvePath(targetDir, `${entry}.html`);
|
||||
if (!fs.pathExistsSync(targetHtml)) {
|
||||
/* eslint-disable-next-line no-restricted-syntax */
|
||||
targetHtml = findOwnPaths(__dirname).resolve(
|
||||
'templates/serve_index.html',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Backend plugin dev run file
|
||||
const targetRunFile = resolvePath(targetDir, 'src/run.ts');
|
||||
const runFileExists = fs.pathExistsSync(targetRunFile);
|
||||
|
||||
return {
|
||||
targetHtml,
|
||||
targetPublic,
|
||||
targetPath: resolvePath(targetDir, '.'),
|
||||
targetRunFile: runFileExists ? targetRunFile : undefined,
|
||||
targetDist: resolvePath(targetDir, options.dist ?? 'dist'),
|
||||
targetAssets: resolvePath(targetDir, 'assets'),
|
||||
targetSrc: resolvePath(targetDir, 'src'),
|
||||
targetDev: resolvePath(targetDir, 'dev'),
|
||||
targetEntry: resolveTargetModule(entry),
|
||||
targetTsConfig: targetPaths.resolveRoot('tsconfig.json'),
|
||||
targetPackageJson: resolvePath(targetDir, 'package.json'),
|
||||
rootNodeModules: targetPaths.resolveRoot('node_modules'),
|
||||
root: targetPaths.rootDir,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveOptionalBundlingPaths(
|
||||
options: BundlingPathsOptions,
|
||||
) {
|
||||
const resolvedPaths = resolveBundlingPaths(options);
|
||||
if (await fs.pathExists(resolvedPaths.targetEntry)) {
|
||||
return resolvedPaths;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type BundlingPaths = ReturnType<typeof resolveBundlingPaths>;
|
||||
@@ -1,293 +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 { AppConfig } from '@backstage/config';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { rspack } from '@rspack/core';
|
||||
import { RspackDevServer } from '@rspack/dev-server';
|
||||
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { loadCliConfig } from '../config';
|
||||
import { createConfig, resolveBaseUrl, resolveEndpoint } from './config';
|
||||
import { createDetectedModulesEntryPoint } from './packageDetection';
|
||||
import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths';
|
||||
import { ServeOptions } from './types';
|
||||
import { createRuntimeSharedDependenciesEntryPoint } from './moduleFederation';
|
||||
|
||||
export async function serveBundle(options: ServeOptions) {
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const targetPkg = await fs.readJson(paths.targetPackageJson);
|
||||
|
||||
if (options.verifyVersions) {
|
||||
if (
|
||||
targetPkg.dependencies?.['react-router']?.includes('beta') ||
|
||||
targetPkg.dependencies?.['react-router-dom']?.includes('beta')
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
chalk.yellow(`
|
||||
DEPRECATION WARNING: React Router Beta is deprecated and support for it will be removed in a future release.
|
||||
Please migrate to use React Router v6 stable.
|
||||
See https://backstage.io/docs/tutorials/react-router-stable-migration
|
||||
`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkReactVersion();
|
||||
|
||||
const { name } = await fs.readJson(
|
||||
resolvePath(options.targetDir ?? targetPaths.dir, 'package.json'),
|
||||
);
|
||||
|
||||
let devServer: RspackDevServer | undefined = undefined;
|
||||
|
||||
let latestFrontendAppConfigs: AppConfig[] = [];
|
||||
|
||||
/** Triggers a full reload of all clients */
|
||||
const triggerReload = () => {
|
||||
if (devServer) {
|
||||
devServer.invalidate();
|
||||
|
||||
// For the Rspack server it's not enough to invalidate, we also need to
|
||||
// tell the browser to reload, which we do with a 'static-changed' message
|
||||
if (!process.env.LEGACY_WEBPACK_BUILD) {
|
||||
devServer.sendMessage(
|
||||
devServer.webSocketServer?.clients ?? [],
|
||||
'static-changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cliConfig = await loadCliConfig({
|
||||
args: options.configPaths,
|
||||
targetDir: options.targetDir,
|
||||
fromPackage: name,
|
||||
withFilteredKeys: true,
|
||||
watch(appConfigs) {
|
||||
latestFrontendAppConfigs = appConfigs;
|
||||
|
||||
triggerReload();
|
||||
},
|
||||
});
|
||||
latestFrontendAppConfigs = cliConfig.frontendAppConfigs;
|
||||
|
||||
const appBaseUrl = cliConfig.frontendConfig.getOptionalString('app.baseUrl');
|
||||
const backendBaseUrl =
|
||||
cliConfig.frontendConfig.getOptionalString('backend.baseUrl');
|
||||
if (appBaseUrl && appBaseUrl === backendBaseUrl) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ Conflict between app baseUrl and backend baseUrl:
|
||||
|
||||
app.baseUrl: ${appBaseUrl}
|
||||
backend.baseUrl: ${backendBaseUrl}
|
||||
|
||||
Must have unique hostname and/or ports.
|
||||
|
||||
This can be resolved by changing app.baseUrl and backend.baseUrl to point to their respective local development ports.
|
||||
`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const { frontendConfig, fullConfig } = cliConfig;
|
||||
const url = resolveBaseUrl(frontendConfig, options.moduleFederationRemote);
|
||||
const { host, port } = resolveEndpoint(
|
||||
frontendConfig,
|
||||
options.moduleFederationRemote,
|
||||
);
|
||||
|
||||
const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
|
||||
config: fullConfig,
|
||||
targetPath: paths.targetPath,
|
||||
watch() {
|
||||
triggerReload();
|
||||
},
|
||||
});
|
||||
|
||||
const moduleFederationSharedDependenciesEntryPoint =
|
||||
await createRuntimeSharedDependenciesEntryPoint({
|
||||
targetPath: paths.targetPath,
|
||||
watch() {
|
||||
triggerReload();
|
||||
},
|
||||
});
|
||||
|
||||
const webpack = process.env.LEGACY_WEBPACK_BUILD
|
||||
? (require('webpack') as typeof import('webpack'))
|
||||
: undefined;
|
||||
|
||||
const commonConfigOptions = {
|
||||
...options,
|
||||
checksEnabled: options.checksEnabled,
|
||||
isDev: true,
|
||||
baseUrl: url,
|
||||
frontendConfig,
|
||||
webpack,
|
||||
getFrontendAppConfigs: () => {
|
||||
return latestFrontendAppConfigs;
|
||||
},
|
||||
};
|
||||
|
||||
const config = await createConfig(paths, {
|
||||
...commonConfigOptions,
|
||||
additionalEntryPoints: [
|
||||
...detectedModulesEntryPoint,
|
||||
...moduleFederationSharedDependenciesEntryPoint,
|
||||
],
|
||||
moduleFederationRemote: options.moduleFederationRemote,
|
||||
});
|
||||
|
||||
const bundler = (webpack ?? rspack) as typeof rspack;
|
||||
const DevServer: typeof RspackDevServer = webpack
|
||||
? require('webpack-dev-server')
|
||||
: RspackDevServer;
|
||||
|
||||
if (webpack) {
|
||||
console.log(chalk.yellow(`⚠️ WARNING: Using legacy WebPack dev server.`));
|
||||
}
|
||||
|
||||
const publicPaths = await resolveOptionalBundlingPaths({
|
||||
entry: 'src/index-public-experimental',
|
||||
dist: 'dist/public',
|
||||
});
|
||||
if (publicPaths) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`⚠️ WARNING: The app /public entry point is an experimental feature that may receive immediate breaking changes.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const compiler = publicPaths
|
||||
? bundler([config, await createConfig(publicPaths, commonConfigOptions)])
|
||||
: bundler(config);
|
||||
|
||||
devServer = new DevServer(
|
||||
{
|
||||
hot: !process.env.CI,
|
||||
devMiddleware: {
|
||||
publicPath: config.output?.publicPath as string,
|
||||
stats: 'errors-warnings',
|
||||
},
|
||||
static: paths.targetPublic
|
||||
? {
|
||||
publicPath: config.output?.publicPath as string,
|
||||
directory: paths.targetPublic,
|
||||
}
|
||||
: undefined,
|
||||
historyApiFallback: options.moduleFederationRemote
|
||||
? false
|
||||
: {
|
||||
// Paths with dots should still use the history fallback.
|
||||
// See https://github.com/facebookincubator/create-react-app/issues/387.
|
||||
disableDotRule: true,
|
||||
|
||||
// The index needs to be rewritten relative to the new public path, including subroutes.
|
||||
index: `${config.output?.publicPath}index.html`,
|
||||
},
|
||||
server:
|
||||
url.protocol === 'https:'
|
||||
? {
|
||||
type: 'https',
|
||||
options: {
|
||||
cert: fullConfig.getOptionalString(
|
||||
'app.https.certificate.cert',
|
||||
),
|
||||
key: fullConfig.getOptionalString('app.https.certificate.key'),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
host,
|
||||
port,
|
||||
proxy: targetPkg.proxy,
|
||||
// When the dev server is behind a proxy, the host and public hostname differ
|
||||
allowedHosts: [url.hostname],
|
||||
client: {
|
||||
webSocketURL: { hostname: host, port },
|
||||
},
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers':
|
||||
'X-Requested-With, content-type, Authorization',
|
||||
},
|
||||
},
|
||||
compiler,
|
||||
);
|
||||
|
||||
await new Promise<void>(async (resolve, reject) => {
|
||||
if (devServer) {
|
||||
devServer.startCallback((err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
if (!options.skipOpenBrowser) {
|
||||
openBrowser(url.href);
|
||||
}
|
||||
|
||||
const waitForExit = async () => {
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
devServer?.stop();
|
||||
// exit instead of resolve. The process is shutting down and resolving a promise here logs an error
|
||||
process.exit();
|
||||
});
|
||||
}
|
||||
|
||||
// Block indefinitely and wait for the interrupt signal
|
||||
return new Promise(() => {});
|
||||
};
|
||||
|
||||
return waitForExit;
|
||||
}
|
||||
|
||||
function checkReactVersion() {
|
||||
try {
|
||||
// Make sure we're looking at the root of the target repo
|
||||
const reactPkgPath = require.resolve('react/package.json', {
|
||||
paths: [targetPaths.rootDir],
|
||||
});
|
||||
const reactPkg = require(reactPkgPath);
|
||||
if (reactPkg.version.startsWith('16.')) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`
|
||||
⚠️ ⚠️
|
||||
⚠️ You are using React version 16, which is deprecated for use in Backstage. ⚠️
|
||||
⚠️ Please upgrade to React 17 by updating your packages/app dependencies. ⚠️
|
||||
⚠️ ⚠️
|
||||
`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
@@ -1,196 +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 {
|
||||
RuleSetRule,
|
||||
RspackPluginInstance,
|
||||
CssExtractRspackPlugin,
|
||||
WebpackPluginInstance,
|
||||
} from '@rspack/core';
|
||||
|
||||
type Transforms = {
|
||||
loaders: RuleSetRule[];
|
||||
plugins: Array<RspackPluginInstance | WebpackPluginInstance>;
|
||||
};
|
||||
|
||||
type TransformOptions = {
|
||||
isDev: boolean;
|
||||
isBackend?: boolean;
|
||||
webpack?: typeof import('webpack').webpack;
|
||||
};
|
||||
|
||||
export const transforms = (options: TransformOptions): Transforms => {
|
||||
const { isDev, isBackend, webpack } = options;
|
||||
|
||||
const CssExtractPlugin: typeof CssExtractRspackPlugin = webpack
|
||||
? (require('mini-css-extract-plugin') as unknown as typeof CssExtractRspackPlugin)
|
||||
: CssExtractRspackPlugin;
|
||||
|
||||
// This ensures that styles inserted from the style-loader and any
|
||||
// async style chunks are always given lower priority than JSS styles.
|
||||
// Note that this function is stringified and executed in the browser
|
||||
// after transpilation, so stick to simple syntax
|
||||
function insertBeforeJssStyles(element: any) {
|
||||
const head = document.head;
|
||||
// This makes sure that any style elements we insert get put before the
|
||||
// dynamic styles from JSS, such as the ones from `makeStyles()`.
|
||||
// TODO(Rugvip): This will likely break in material-ui v5, keep an eye on it.
|
||||
const firstJssNode = head.querySelector('style[data-jss]');
|
||||
if (!firstJssNode) {
|
||||
head.appendChild(element);
|
||||
} else {
|
||||
head.insertBefore(element, firstJssNode);
|
||||
}
|
||||
}
|
||||
|
||||
const loaders = [
|
||||
{
|
||||
test: /\.(tsx?)$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: webpack
|
||||
? require.resolve('swc-loader')
|
||||
: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
target: 'es2023',
|
||||
externalHelpers: !isBackend,
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
tsx: !isBackend,
|
||||
dynamicImport: true,
|
||||
},
|
||||
transform: {
|
||||
react: isBackend
|
||||
? undefined
|
||||
: {
|
||||
runtime: 'automatic',
|
||||
refresh: isDev,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(jsx?|mjs|cjs)$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: webpack
|
||||
? require.resolve('swc-loader')
|
||||
: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
target: 'es2023',
|
||||
externalHelpers: !isBackend,
|
||||
parser: {
|
||||
syntax: 'ecmascript',
|
||||
jsx: !isBackend,
|
||||
dynamicImport: true,
|
||||
},
|
||||
transform: {
|
||||
react: isBackend
|
||||
? undefined
|
||||
: {
|
||||
runtime: 'automatic',
|
||||
refresh: isDev,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.(js|mjs|cjs)$/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: [
|
||||
/\.bmp$/,
|
||||
/\.gif$/,
|
||||
/\.jpe?g$/,
|
||||
/\.png$/,
|
||||
/\.frag$/,
|
||||
/\.vert$/,
|
||||
{ and: [/\.svg$/, { not: [/\.icon\.svg$/] }] },
|
||||
/\.xml$/,
|
||||
/\.ico$/,
|
||||
/\.webp$/,
|
||||
],
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'static/[name].[hash:8][ext]',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(eot|woff|woff2|ttf)$/i,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'static/[name].[hash][ext][query]',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.ya?ml$/,
|
||||
use: require.resolve('yml-loader'),
|
||||
},
|
||||
{
|
||||
include: /\.(md)$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'static/[name].[hash][ext][query]',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
isDev
|
||||
? {
|
||||
loader: require.resolve('style-loader'),
|
||||
options: {
|
||||
insert: insertBeforeJssStyles,
|
||||
},
|
||||
}
|
||||
: CssExtractPlugin.loader,
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
sourceMap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const plugins = new Array<RspackPluginInstance | WebpackPluginInstance>();
|
||||
|
||||
if (!isDev) {
|
||||
plugins.push(
|
||||
new CssExtractPlugin({
|
||||
filename: 'static/[name].[contenthash:8].css',
|
||||
chunkFilename: 'static/[name].[id].[contenthash:8].css',
|
||||
insert: insertBeforeJssStyles, // Only applies to async chunks
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { loaders, plugins };
|
||||
};
|
||||
@@ -1,80 +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 { AppConfig, Config } from '@backstage/config';
|
||||
import { BundlingPathsOptions } from './paths';
|
||||
import { ConfigSchema } from '@backstage/config-loader';
|
||||
import { RemoteSharedDependencies } from '@backstage/module-federation-common';
|
||||
|
||||
export type ModuleFederationRemoteOptions = {
|
||||
// Unique name for this module federation bundle
|
||||
name: string;
|
||||
exposes?: {
|
||||
/**
|
||||
* Modules that should be exposed by this container.
|
||||
*/
|
||||
[k: string]: string;
|
||||
};
|
||||
sharedDependencies: RemoteSharedDependencies;
|
||||
};
|
||||
|
||||
export type BundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
frontendConfig: Config;
|
||||
getFrontendAppConfigs(): AppConfig[];
|
||||
additionalEntryPoints?: string[];
|
||||
// Path to append to the detected public path, e.g. '/public'
|
||||
publicSubPath?: string;
|
||||
// Mode that the app is running in, 'protected' or 'public', default is 'public'
|
||||
appMode?: string;
|
||||
// An external linked workspace to include in the bundling
|
||||
linkedWorkspace?: string;
|
||||
moduleFederationRemote?: ModuleFederationRemoteOptions;
|
||||
webpack?: typeof import('webpack');
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
targetDir?: string;
|
||||
checksEnabled: boolean;
|
||||
configPaths: string[];
|
||||
verifyVersions?: boolean;
|
||||
skipOpenBrowser?: boolean;
|
||||
moduleFederationRemote?: ModuleFederationRemoteOptions;
|
||||
// An external linked workspace to include in the bundling
|
||||
linkedWorkspace?: string;
|
||||
};
|
||||
|
||||
export type BuildOptions = BundlingPathsOptions & {
|
||||
// Target directory, defaulting to paths.targetDir
|
||||
targetDir?: string;
|
||||
statsJsonEnabled: boolean;
|
||||
schema?: ConfigSchema;
|
||||
frontendConfig: Config;
|
||||
frontendAppConfigs: AppConfig[];
|
||||
fullConfig: Config;
|
||||
moduleFederationRemote?: ModuleFederationRemoteOptions;
|
||||
webpack?: typeof import('webpack');
|
||||
};
|
||||
|
||||
export type BackendBundlingOptions = {
|
||||
checksEnabled: boolean;
|
||||
isDev: boolean;
|
||||
inspectEnabled: boolean;
|
||||
inspectBrkEnabled: boolean;
|
||||
require?: string;
|
||||
webpack?: typeof import('webpack');
|
||||
};
|
||||
@@ -1,128 +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 { ConfigSources, loadConfigSchema } from '@backstage/config-loader';
|
||||
import { AppConfig, ConfigReader } from '@backstage/config';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { getPackages } from '@manypkg/get-packages';
|
||||
import { PackageGraph } from '@backstage/cli-node';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
|
||||
type Options = {
|
||||
args: string[];
|
||||
targetDir?: string;
|
||||
fromPackage?: string;
|
||||
withFilteredKeys?: boolean;
|
||||
watch?: (newFrontendAppConfigs: AppConfig[]) => void;
|
||||
};
|
||||
|
||||
export async function loadCliConfig(options: Options) {
|
||||
const targetDir = options.targetDir ?? targetPaths.dir;
|
||||
|
||||
const { packages } = await getPackages(targetDir);
|
||||
|
||||
let localPackageNames;
|
||||
if (options.fromPackage) {
|
||||
if (packages.length) {
|
||||
const graph = PackageGraph.fromPackages(packages);
|
||||
localPackageNames = Array.from(
|
||||
graph.collectPackageNames([options.fromPackage], node => {
|
||||
// Workaround for Backstage main repo only, since the CLI has some artificial devDependencies
|
||||
if (node.name === '@backstage/cli') {
|
||||
return undefined;
|
||||
}
|
||||
return node.localDependencies.keys();
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
localPackageNames = [options.fromPackage];
|
||||
}
|
||||
} else {
|
||||
localPackageNames = packages.map(p => p.packageJson.name);
|
||||
}
|
||||
|
||||
const schema = await loadConfigSchema({
|
||||
dependencies: localPackageNames,
|
||||
packagePaths: [targetPaths.resolveRoot('package.json')],
|
||||
});
|
||||
|
||||
const source = ConfigSources.default({
|
||||
allowMissingDefaultConfig: true,
|
||||
watch: Boolean(options.watch),
|
||||
rootDir: targetPaths.rootDir,
|
||||
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
|
||||
});
|
||||
|
||||
const appConfigs = await new Promise<AppConfig[]>((resolve, reject) => {
|
||||
async function loadConfigReaderLoop() {
|
||||
let loaded = false;
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
for await (const { configs } of source.readConfigData({
|
||||
signal: abortController.signal,
|
||||
})) {
|
||||
if (loaded) {
|
||||
const newFrontendAppConfigs = schema.process(configs, {
|
||||
visibility: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
ignoreSchemaErrors: true,
|
||||
});
|
||||
options.watch?.(newFrontendAppConfigs);
|
||||
} else {
|
||||
resolve(configs);
|
||||
loaded = true;
|
||||
|
||||
if (!options.watch) {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (loaded) {
|
||||
console.error(`Failed to reload configuration, ${error}`);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
loadConfigReaderLoop();
|
||||
});
|
||||
|
||||
const configurationLoadedMessage = appConfigs.length
|
||||
? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`
|
||||
: `No configuration files found, running without config`;
|
||||
|
||||
process.stderr.write(`${configurationLoadedMessage}\n`);
|
||||
|
||||
const frontendAppConfigs = schema.process(appConfigs, {
|
||||
visibility: ['frontend'],
|
||||
withFilteredKeys: options.withFilteredKeys,
|
||||
ignoreSchemaErrors: true,
|
||||
});
|
||||
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
|
||||
|
||||
const fullConfig = ConfigReader.fromConfigs(appConfigs);
|
||||
|
||||
return {
|
||||
schema,
|
||||
appConfigs,
|
||||
frontendConfig,
|
||||
frontendAppConfigs,
|
||||
fullConfig,
|
||||
};
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 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 { extname } from 'node:path';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
|
||||
export interface EntryPoint {
|
||||
mount: string;
|
||||
path: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
// Unless explicitly specified in exports, the index entrypoint is always
|
||||
// assumed to be at src/index.ts for backwards compatibility.
|
||||
const defaultIndex = {
|
||||
mount: '.',
|
||||
path: 'src/index.ts',
|
||||
name: 'index',
|
||||
ext: '.ts',
|
||||
};
|
||||
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
||||
|
||||
function parseEntryPoint(mount: string, path: string): EntryPoint {
|
||||
const ext = extname(path);
|
||||
|
||||
let name = mount;
|
||||
if (name === '.') {
|
||||
name = 'index';
|
||||
} else if (name.startsWith('./')) {
|
||||
name = name.slice(2);
|
||||
}
|
||||
|
||||
// Script entry points can't have slashes because we create backward-compat
|
||||
// directories for them. Non-script files (like CSS) can have nested paths.
|
||||
if (name.includes('/') && SCRIPT_EXTS.includes(ext)) {
|
||||
throw new Error(`Mount point '${mount}' may not contain multiple slashes`);
|
||||
}
|
||||
|
||||
return { mount, path, name, ext };
|
||||
}
|
||||
|
||||
export function readEntryPoints(pkg: BackstagePackageJson): Array<EntryPoint> {
|
||||
const exp = pkg.exports;
|
||||
if (typeof exp === 'string') {
|
||||
return [defaultIndex];
|
||||
} else if (exp && typeof exp === 'object' && !Array.isArray(exp)) {
|
||||
const entryPoints = new Array<{
|
||||
mount: string;
|
||||
path: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
}>();
|
||||
|
||||
for (const mount of Object.keys(exp)) {
|
||||
const path = exp[mount];
|
||||
if (typeof path !== 'string') {
|
||||
throw new Error(
|
||||
`Exports field value must be a string, got '${JSON.stringify(path)}'`,
|
||||
);
|
||||
}
|
||||
|
||||
// Setting the EXPERIMENTAL_TRIM_NEXT_ENTRY flag will remove any `./next` entry points
|
||||
if (process.env.EXPERIMENTAL_TRIM_NEXT_ENTRY && mount === './next') {
|
||||
continue;
|
||||
}
|
||||
|
||||
entryPoints.push(parseEntryPoint(mount, path));
|
||||
}
|
||||
|
||||
return entryPoints;
|
||||
}
|
||||
|
||||
return [defaultIndex];
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 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 { serializeError } from '@backstage/errors';
|
||||
import { ChildProcess } from 'node:child_process';
|
||||
|
||||
interface RequestMeta {
|
||||
generation: number;
|
||||
}
|
||||
|
||||
type MethodHandler<TRequest, TResponse> = (
|
||||
req: TRequest,
|
||||
meta: RequestMeta,
|
||||
) => Promise<TResponse>;
|
||||
|
||||
interface Request {
|
||||
id: number;
|
||||
method: string;
|
||||
body: unknown;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const requestType = '@backstage/cli/channel/request';
|
||||
const responseType = '@backstage/cli/channel/response';
|
||||
|
||||
export class IpcServer {
|
||||
#generation = 1;
|
||||
#methods = new Map<string, MethodHandler<any, any>>();
|
||||
|
||||
addChild(child: ChildProcess) {
|
||||
const generation = this.#generation++;
|
||||
const sendMessage = child.send?.bind(child);
|
||||
if (!sendMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageListener = (request: Request) => {
|
||||
if (request.type !== requestType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = this.#methods.get(request.method);
|
||||
if (!handler) {
|
||||
sendMessage({
|
||||
type: responseType,
|
||||
id: request.id,
|
||||
error: {
|
||||
name: 'NotFoundError',
|
||||
message: `No handler registered for method ${request.method}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => handler(request.body, { generation }))
|
||||
.then(response =>
|
||||
sendMessage({
|
||||
type: responseType,
|
||||
id: request.id,
|
||||
body: response,
|
||||
}),
|
||||
)
|
||||
.catch(error =>
|
||||
sendMessage({
|
||||
type: responseType,
|
||||
id: request.id,
|
||||
error: serializeError(error),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
child.addListener('message', messageListener as (req: unknown) => void);
|
||||
|
||||
child.addListener('exit', () => {
|
||||
child.removeListener('message', messageListener);
|
||||
});
|
||||
}
|
||||
|
||||
registerMethod<TRequest, TResponse>(
|
||||
method: string,
|
||||
handler: MethodHandler<TRequest, TResponse>,
|
||||
) {
|
||||
if (this.#methods.has(method)) {
|
||||
throw new Error(`A handler is already registered for method ${method}`);
|
||||
}
|
||||
this.#methods.set(method, handler);
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 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 { IpcServer } from './IpcServer';
|
||||
|
||||
interface StorageItem {
|
||||
generation: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface SaveRequest {
|
||||
key: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface SaveResponse {
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
interface LoadRequest {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface LoadResponse {
|
||||
loaded: boolean;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export class ServerDataStore {
|
||||
static bind(server: IpcServer): void {
|
||||
const store = new Map<string, StorageItem>();
|
||||
|
||||
server.registerMethod<SaveRequest, SaveResponse>(
|
||||
'DevDataStore.save',
|
||||
async (request, { generation }) => {
|
||||
const { key, data } = request;
|
||||
if (!key) {
|
||||
throw new Error('Key is required in DevDataStore.save');
|
||||
}
|
||||
|
||||
const item = store.get(key);
|
||||
|
||||
if (!item) {
|
||||
store.set(key, { generation, data });
|
||||
return { saved: true };
|
||||
}
|
||||
|
||||
if (item.generation > generation) {
|
||||
return { saved: false };
|
||||
}
|
||||
|
||||
store.set(key, { generation, data });
|
||||
return { saved: true };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerMethod<LoadRequest, LoadResponse>(
|
||||
'DevDataStore.load',
|
||||
async request => {
|
||||
const item = store.get(request.key);
|
||||
return { loaded: Boolean(item), data: item?.data };
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
* 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 { IpcServer } from './IpcServer';
|
||||
export { ServerDataStore } from './ServerDataStore';
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* 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 { parseArgs, type ParseArgsConfig } from 'node:util';
|
||||
import { parse as parseShellArgs } from 'shell-quote';
|
||||
|
||||
export function createScriptOptionsParser(
|
||||
commandPath: string[],
|
||||
options: ParseArgsConfig['options'],
|
||||
) {
|
||||
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
|
||||
|
||||
return (scriptStr?: string) => {
|
||||
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const argsStr = scriptStr.slice(expectedScript.length).trim();
|
||||
const args = argsStr
|
||||
? parseShellArgs(argsStr).filter(
|
||||
(e): e is string => typeof e === 'string',
|
||||
)
|
||||
: [];
|
||||
|
||||
const { values } = parseArgs({ args, strict: false, options });
|
||||
return values;
|
||||
};
|
||||
}
|
||||
@@ -1,375 +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 chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import {
|
||||
join as joinPath,
|
||||
resolve as resolvePath,
|
||||
relative as relativePath,
|
||||
} from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as tar from 'tar';
|
||||
import partition from 'lodash/partition';
|
||||
|
||||
import { run, targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import {
|
||||
dependencies as cliDependencies,
|
||||
devDependencies as cliDevDependencies,
|
||||
} from '../../../../../package.json';
|
||||
import {
|
||||
BuildOptions,
|
||||
buildPackages,
|
||||
getOutputsForRole,
|
||||
Output,
|
||||
} from '../builder';
|
||||
import { productionPack } from './productionPack';
|
||||
import {
|
||||
PackageRoles,
|
||||
PackageGraph,
|
||||
PackageGraphNode,
|
||||
runConcurrentTasks,
|
||||
} from '@backstage/cli-node';
|
||||
import { createTypeDistProject } from '../typeDistProject';
|
||||
|
||||
// These packages aren't safe to pack in parallel since the CLI depends on them
|
||||
const UNSAFE_PACKAGES = [
|
||||
...Object.keys(cliDependencies),
|
||||
...Object.keys(cliDevDependencies),
|
||||
];
|
||||
|
||||
type FileEntry =
|
||||
| string
|
||||
| {
|
||||
src: string;
|
||||
dest: string;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Target directory for the dist workspace, defaults to a temporary directory
|
||||
*/
|
||||
targetDir?: string;
|
||||
|
||||
/**
|
||||
* Configuration files to load during packaging.
|
||||
*/
|
||||
configPaths?: string[];
|
||||
|
||||
/**
|
||||
* Files to copy into the target workspace.
|
||||
*
|
||||
* Defaults to ['yarn.lock', 'package.json'].
|
||||
*/
|
||||
files?: FileEntry[];
|
||||
|
||||
/**
|
||||
* If set to true, the target packages are built before they are packaged into the workspace.
|
||||
*/
|
||||
buildDependencies?: boolean;
|
||||
|
||||
/**
|
||||
* When `buildDependencies` is set, this list of packages will not be built even if they are dependencies.
|
||||
*/
|
||||
buildExcludes?: string[];
|
||||
|
||||
/**
|
||||
* If set, creates a skeleton tarball that contains all package.json files
|
||||
* with the same structure as the workspace dir.
|
||||
*/
|
||||
skeleton?: 'skeleton.tar' | 'skeleton.tar.gz';
|
||||
|
||||
/**
|
||||
* If set to true, `yarn pack` is always preferred when creating the dist
|
||||
* workspace. This ensures correct workspace output at significant cost to
|
||||
* command performance.
|
||||
*/
|
||||
alwaysPack?: boolean;
|
||||
|
||||
/**
|
||||
* If set to true, the TypeScript feature detection will be enabled, which
|
||||
* annotates the package exports field with the `backstage` export type.
|
||||
*/
|
||||
enableFeatureDetection?: boolean;
|
||||
|
||||
/**
|
||||
* If set to true, the generated code will be minified.
|
||||
*/
|
||||
minify?: boolean;
|
||||
};
|
||||
|
||||
function prefixLogFunc(prefix: string, out: 'stdout' | 'stderr') {
|
||||
return (data: Buffer) => {
|
||||
for (const line of data.toString('utf8').split(/\r?\n/)) {
|
||||
process[out].write(`${prefix} ${line}\n`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `yarn pack` to package local packages and unpacks them into a dist workspace.
|
||||
* The target workspace will end up containing dist version of each package and
|
||||
* will be suitable for packaging e.g. into a docker image.
|
||||
*
|
||||
* This creates a structure that is functionally similar to if the packages were
|
||||
* installed from npm, but uses Yarn workspaces to link to them at runtime.
|
||||
*/
|
||||
export async function createDistWorkspace(
|
||||
packageNames: string[],
|
||||
options: Options = {},
|
||||
) {
|
||||
const targetDir =
|
||||
options.targetDir ??
|
||||
(await fs.mkdtemp(resolvePath(tmpdir(), 'dist-workspace')));
|
||||
|
||||
const packages = await PackageGraph.listTargetPackages();
|
||||
const packageGraph = PackageGraph.fromPackages(packages);
|
||||
const targetNames = packageGraph.collectPackageNames(packageNames, node => {
|
||||
// Don't include dependencies of packages that are marked as bundled
|
||||
if (node.packageJson.bundled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return node.publishedLocalDependencies.keys();
|
||||
});
|
||||
const targets = Array.from(targetNames).map(name => packageGraph.get(name)!);
|
||||
|
||||
if (options.buildDependencies) {
|
||||
const exclude = options.buildExcludes ?? [];
|
||||
const configPaths = options.configPaths ?? [];
|
||||
|
||||
const toBuild = new Set(
|
||||
targets.map(_ => _.name).filter(name => !exclude.includes(name)),
|
||||
);
|
||||
|
||||
const standardBuilds = new Array<BuildOptions>();
|
||||
const customBuild = new Array<{
|
||||
dir: string;
|
||||
name: string;
|
||||
args?: string[];
|
||||
}>();
|
||||
|
||||
for (const pkg of packages) {
|
||||
if (!toBuild.has(pkg.packageJson.name)) {
|
||||
continue;
|
||||
}
|
||||
const role = pkg.packageJson.backstage?.role;
|
||||
if (!role) {
|
||||
console.warn(
|
||||
`Building ${pkg.packageJson.name} separately because it has no role`,
|
||||
);
|
||||
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name });
|
||||
continue;
|
||||
}
|
||||
|
||||
const buildScript = pkg.packageJson.scripts?.build;
|
||||
if (!buildScript) {
|
||||
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!buildScript.startsWith('backstage-cli package build')) {
|
||||
console.warn(
|
||||
`Building ${pkg.packageJson.name} separately because it has a custom build script, '${buildScript}'`,
|
||||
);
|
||||
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PackageRoles.getRoleInfo(role).output.includes('bundle')) {
|
||||
console.warn(
|
||||
`Building ${pkg.packageJson.name} separately because it is a bundled package`,
|
||||
);
|
||||
const args = buildScript.includes('--config')
|
||||
? []
|
||||
: configPaths.map(p => ['--config', p]).flat();
|
||||
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name, args });
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputs = getOutputsForRole(role);
|
||||
|
||||
// No need to build and include types in the production runtime
|
||||
outputs.delete(Output.types);
|
||||
|
||||
if (outputs.size > 0) {
|
||||
standardBuilds.push({
|
||||
targetDir: pkg.dir,
|
||||
packageJson: pkg.packageJson,
|
||||
outputs: outputs,
|
||||
logPrefix: `${chalk.cyan(
|
||||
relativePath(targetPaths.rootDir, pkg.dir),
|
||||
)}: `,
|
||||
minify: options.minify,
|
||||
workspacePackages: packages,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await buildPackages(standardBuilds);
|
||||
|
||||
if (customBuild.length > 0) {
|
||||
await runConcurrentTasks({
|
||||
items: customBuild,
|
||||
worker: async ({ name, dir, args }) => {
|
||||
await run(['yarn', 'run', 'build', ...(args || [])], {
|
||||
cwd: dir,
|
||||
onStdout: prefixLogFunc(`${name}: `, 'stdout'),
|
||||
onStderr: prefixLogFunc(`${name}: `, 'stderr'),
|
||||
}).waitForExit();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await moveToDistWorkspace(
|
||||
targetDir,
|
||||
targets,
|
||||
Boolean(options.alwaysPack),
|
||||
Boolean(options.enableFeatureDetection),
|
||||
);
|
||||
|
||||
const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json'];
|
||||
|
||||
for (const file of files) {
|
||||
const src = typeof file === 'string' ? file : file.src;
|
||||
const dest = typeof file === 'string' ? file : file.dest;
|
||||
await fs.copy(targetPaths.resolveRoot(src), resolvePath(targetDir, dest));
|
||||
}
|
||||
|
||||
if (options.skeleton) {
|
||||
const skeletonFiles = targets
|
||||
.map(target => {
|
||||
const dir = relativePath(targetPaths.rootDir, target.dir);
|
||||
return joinPath(dir, 'package.json');
|
||||
})
|
||||
.sort();
|
||||
|
||||
await tar.create(
|
||||
{
|
||||
file: resolvePath(targetDir, options.skeleton),
|
||||
cwd: targetDir,
|
||||
portable: true,
|
||||
noMtime: true,
|
||||
gzip: options.skeleton.endsWith('.gz'),
|
||||
},
|
||||
skeletonFiles,
|
||||
);
|
||||
}
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
const FAST_PACK_SCRIPTS = [
|
||||
undefined,
|
||||
'backstage-cli prepack',
|
||||
'backstage-cli package prepack',
|
||||
];
|
||||
|
||||
async function moveToDistWorkspace(
|
||||
workspaceDir: string,
|
||||
localPackages: PackageGraphNode[],
|
||||
alwaysPack: boolean,
|
||||
enableFeatureDetection: boolean,
|
||||
): Promise<void> {
|
||||
const [fastPackPackages, slowPackPackages] = partition(
|
||||
localPackages,
|
||||
pkg =>
|
||||
!alwaysPack &&
|
||||
FAST_PACK_SCRIPTS.includes(pkg.packageJson.scripts?.prepack),
|
||||
);
|
||||
|
||||
const featureDetectionProject =
|
||||
fastPackPackages.length > 0 && enableFeatureDetection
|
||||
? await createTypeDistProject()
|
||||
: undefined;
|
||||
|
||||
// New an improved flow where we avoid calling `yarn pack`
|
||||
await Promise.all(
|
||||
fastPackPackages.map(async target => {
|
||||
console.log(`Moving ${target.name} into dist workspace`);
|
||||
|
||||
const outputDir = relativePath(targetPaths.rootDir, target.dir);
|
||||
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
|
||||
await productionPack({
|
||||
packageDir: target.dir,
|
||||
targetDir: absoluteOutputPath,
|
||||
featureDetectionProject,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Old flow is below, which calls `yarn pack` and extracts the tarball
|
||||
|
||||
async function pack(target: PackageGraphNode, archive: string) {
|
||||
console.log(`Repacking ${target.name} into dist workspace`);
|
||||
const archivePath = resolvePath(workspaceDir, archive);
|
||||
|
||||
await run(['yarn', 'pack', '--filename', archivePath], {
|
||||
cwd: target.dir,
|
||||
}).waitForExit();
|
||||
|
||||
const outputDir = relativePath(targetPaths.rootDir, target.dir);
|
||||
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
|
||||
await fs.ensureDir(absoluteOutputPath);
|
||||
|
||||
await tar.extract({
|
||||
file: archivePath,
|
||||
cwd: absoluteOutputPath,
|
||||
strip: 1,
|
||||
});
|
||||
await fs.remove(archivePath);
|
||||
|
||||
// We remove the dependencies from package.json of packages that are marked
|
||||
// as bundled, so that yarn doesn't try to install them.
|
||||
if (target.packageJson.bundled) {
|
||||
const pkgJson = await fs.readJson(
|
||||
resolvePath(absoluteOutputPath, 'package.json'),
|
||||
);
|
||||
delete pkgJson.dependencies;
|
||||
delete pkgJson.devDependencies;
|
||||
delete pkgJson.peerDependencies;
|
||||
delete pkgJson.optionalDependencies;
|
||||
|
||||
await fs.writeJson(
|
||||
resolvePath(absoluteOutputPath, 'package.json'),
|
||||
pkgJson,
|
||||
{
|
||||
spaces: 2,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [unsafePackages, safePackages] = partition(slowPackPackages, p =>
|
||||
UNSAFE_PACKAGES.includes(p.name),
|
||||
);
|
||||
|
||||
// The unsafe package are packed first one by one in order to avoid race conditions
|
||||
// where the CLI is being executed with broken dependencies.
|
||||
for (const target of unsafePackages) {
|
||||
await pack(target, `temp-package.tgz`);
|
||||
}
|
||||
|
||||
// Repacking in parallel is much faster and safe for all packages outside of the Backstage repo
|
||||
await runConcurrentTasks({
|
||||
items: safePackages.map((target, index) => ({ target, index })),
|
||||
worker: async ({ target, index }) => {
|
||||
await pack(target, `temp-package-${index}.tgz`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { createDistWorkspace } from './createDistWorkspace';
|
||||
@@ -1,239 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 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 fs from 'fs-extra';
|
||||
import npmPackList from 'npm-packlist';
|
||||
import { resolve as resolvePath, posix as posixPath } from 'node:path';
|
||||
import { BackstagePackageJson } from '@backstage/cli-node';
|
||||
import { readEntryPoints } from '../entryPoints';
|
||||
import { getEntryPointDefaultFeatureType } from '../typeDistProject';
|
||||
import { Project } from 'ts-morph';
|
||||
|
||||
const PKG_PATH = 'package.json';
|
||||
const PKG_BACKUP_PATH = 'package.json-prepack';
|
||||
|
||||
const SKIPPED_KEYS = ['access', 'registry', 'tag'];
|
||||
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
|
||||
|
||||
interface ProductionPackOptions {
|
||||
packageDir: string;
|
||||
targetDir?: string;
|
||||
/**
|
||||
* Enables package feature detection using this TS-morph project.
|
||||
*/
|
||||
featureDetectionProject?: Project;
|
||||
}
|
||||
|
||||
export async function productionPack(options: ProductionPackOptions) {
|
||||
const { packageDir, targetDir } = options;
|
||||
const pkgPath = resolvePath(packageDir, PKG_PATH);
|
||||
const pkgContent = await fs.readFile(pkgPath, 'utf8');
|
||||
const pkg = JSON.parse(pkgContent) as BackstagePackageJson;
|
||||
|
||||
// If we're making the update in-line, back up the package.json
|
||||
if (!targetDir) {
|
||||
await fs.writeFile(PKG_BACKUP_PATH, pkgContent);
|
||||
}
|
||||
|
||||
// This mutates pkg to fill in index exports, so call it before applying publishConfig
|
||||
await rewriteEntryPoints(pkg, packageDir, options.featureDetectionProject);
|
||||
|
||||
// TODO(Rugvip): Once exports are rolled out more broadly we should deprecate and remove this behavior
|
||||
const publishConfig = pkg.publishConfig ?? {};
|
||||
for (const key of Object.keys(publishConfig)) {
|
||||
if (!SKIPPED_KEYS.includes(key)) {
|
||||
(pkg as any)[key] = publishConfig[key as keyof typeof publishConfig];
|
||||
}
|
||||
}
|
||||
|
||||
// We remove the dependencies from package.json of packages that are marked
|
||||
// as bundled, so that yarn doesn't try to install them.
|
||||
if (pkg.bundled) {
|
||||
delete pkg.dependencies;
|
||||
delete pkg.devDependencies;
|
||||
delete pkg.peerDependencies;
|
||||
delete pkg.optionalDependencies;
|
||||
}
|
||||
|
||||
if (targetDir) {
|
||||
// Lists all dist files, respecting .npmignore, files field in package.json, etc.
|
||||
const filePaths = await npmPackList({
|
||||
path: packageDir,
|
||||
// This makes sure we use the updated package.json when listing files
|
||||
packageJsonCache: new Map([
|
||||
[resolvePath(packageDir, PKG_PATH), pkg],
|
||||
]) as any, // Seems like this parameter type is wrong,
|
||||
});
|
||||
|
||||
await fs.ensureDir(targetDir);
|
||||
for (const filePath of filePaths.sort()) {
|
||||
const target = resolvePath(targetDir, filePath);
|
||||
if (filePath === PKG_PATH) {
|
||||
await fs.writeJson(target, pkg, { encoding: 'utf8', spaces: 2 });
|
||||
} else {
|
||||
await fs.copy(resolvePath(packageDir, filePath), target);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
// Reverts the changes made by productionPack when called without a targetDir.
|
||||
export async function revertProductionPack(packageDir: string) {
|
||||
// postpack isn't called by yarn right now, so it needs to be called manually
|
||||
try {
|
||||
await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true });
|
||||
|
||||
// Check if we're shipping types for other release stages, clean up in that case
|
||||
const pkg = await fs.readJson(PKG_PATH);
|
||||
|
||||
// Remove any extra entrypoint backwards compatibility directories
|
||||
const entryPoints = readEntryPoints(pkg);
|
||||
for (const entryPoint of entryPoints) {
|
||||
if (entryPoint.mount !== '.' && SCRIPT_EXTS.includes(entryPoint.ext)) {
|
||||
await fs.remove(resolvePath(packageDir, entryPoint.name));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to restore package.json, ${error}. ` +
|
||||
'Your package will be fine but you may have ended up with some garbage in the repo.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const EXPORT_MAP = {
|
||||
import: '.esm.js',
|
||||
require: '.cjs.js',
|
||||
types: '.d.ts',
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrites the exports field in package.json to point to dist files, as
|
||||
* well as returning a function that creates backwards compatibility
|
||||
* entry points for importers that don't support exports.
|
||||
*/
|
||||
async function rewriteEntryPoints(
|
||||
pkg: BackstagePackageJson,
|
||||
packageDir: string,
|
||||
featureDetectionProject?: Project,
|
||||
) {
|
||||
const distPath = resolvePath(packageDir, 'dist');
|
||||
if (!(await fs.pathExists(distPath))) {
|
||||
return undefined;
|
||||
}
|
||||
const distFiles = await fs.readdir(distPath);
|
||||
const outputExports = {} as Record<string, string | Record<string, string>>;
|
||||
|
||||
const entryPoints = readEntryPoints(pkg);
|
||||
|
||||
// Clear to ensure a clean slate before adding entries back in further down
|
||||
if (pkg.typesVersions) {
|
||||
pkg.typesVersions = undefined;
|
||||
}
|
||||
|
||||
for (const entryPoint of entryPoints) {
|
||||
if (!SCRIPT_EXTS.includes(entryPoint.ext)) {
|
||||
// Non-script files (like CSS) get their paths rewritten from src/ to dist/
|
||||
outputExports[entryPoint.mount] = entryPoint.path.replace(
|
||||
/^(\.\/)?src\//,
|
||||
'./dist/',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let exp = {} as Record<string, string>;
|
||||
|
||||
for (const [key, ext] of Object.entries(EXPORT_MAP)) {
|
||||
const name = `${entryPoint.name}${ext}`;
|
||||
if (distFiles.includes(name)) {
|
||||
exp[key] = `./${posixPath.join(`dist`, name)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Our current tooling relies on the typesVersions field rather than export.*.types
|
||||
if (exp.types) {
|
||||
if (!pkg.typesVersions) {
|
||||
pkg.typesVersions = { '*': {} };
|
||||
}
|
||||
if (entryPoint.name !== 'index') {
|
||||
pkg.typesVersions['*'][entryPoint.name] = [
|
||||
`dist/${entryPoint.name}.d.ts`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
exp.default = exp.require ?? exp.import;
|
||||
|
||||
// Find the default export type for the entry point, if feature detection is active
|
||||
if (exp.types && featureDetectionProject) {
|
||||
const defaultFeatureType =
|
||||
pkg.backstage?.role &&
|
||||
getEntryPointDefaultFeatureType(
|
||||
pkg.backstage?.role,
|
||||
packageDir,
|
||||
featureDetectionProject,
|
||||
exp.types,
|
||||
);
|
||||
|
||||
if (defaultFeatureType) {
|
||||
// This ensures that the `backstage` field is at the top of the
|
||||
// `exports` field in the package.json because order is important.
|
||||
// https://nodejs.org/docs/latest-v20.x/api/packages.html#conditional-exports
|
||||
//
|
||||
// Adding this to the `exports` field in the package.json is to temporarily
|
||||
// support any existing behavior that relies on this, however not all packages
|
||||
// have exports field in their package.json.
|
||||
exp = { backstage: defaultFeatureType, ...exp };
|
||||
|
||||
// Add the default feature type to the backstage metadata in the package.json
|
||||
pkg.backstage = pkg.backstage ?? {};
|
||||
pkg.backstage.features = pkg.backstage.features ?? {};
|
||||
pkg.backstage.features[entryPoint.mount] = defaultFeatureType;
|
||||
}
|
||||
}
|
||||
|
||||
if (entryPoint.mount === '.') {
|
||||
if (exp.default) {
|
||||
pkg.main = exp.default;
|
||||
}
|
||||
if (exp.import) {
|
||||
pkg.module = exp.import;
|
||||
}
|
||||
if (exp.types) {
|
||||
pkg.types = exp.types;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(exp).length > 0) {
|
||||
outputExports[entryPoint.mount] = exp;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure package.json is also available in typesVersions if present
|
||||
if (pkg.typesVersions?.['*']) {
|
||||
pkg.typesVersions['*']['package.json'] = ['package.json'];
|
||||
}
|
||||
|
||||
if (pkg.exports) {
|
||||
pkg.exports = outputExports;
|
||||
// We treat package.json as a fixed export that is always available in the published package
|
||||
pkg.exports['./package.json'] = './package.json';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* 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 { BackstagePackage } from '@backstage/cli-node';
|
||||
|
||||
/**
|
||||
* A basic check that throws if a packages doesn't contain required backstage metadata for publishing
|
||||
*/
|
||||
export function publishPreflightCheck(pkg: BackstagePackage): void {
|
||||
const { name, backstage } = pkg.packageJson;
|
||||
if (!backstage || !name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { role } = backstage;
|
||||
|
||||
if (
|
||||
role === 'backend-plugin' ||
|
||||
role === 'backend-plugin-module' ||
|
||||
role === 'frontend-plugin'
|
||||
// TODO(Rugvip): We currently support plugin-less frontend modules for the new frontend system, but it needs a different solution
|
||||
// || role === 'frontend-plugin-module'
|
||||
) {
|
||||
if (!backstage.pluginId) {
|
||||
throw new Error(
|
||||
`Plugin package ${name} is missing a backstage.pluginId, please run 'backstage-cli repo fix --publish'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'backend-plugin' || role === 'frontend-plugin') {
|
||||
if (!backstage.pluginPackages) {
|
||||
throw new Error(
|
||||
`Plugin package ${name} is missing a backstage.pluginPackages, please run 'backstage-cli repo fix --publish'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
backstage.pluginId &&
|
||||
(role === 'common-library' ||
|
||||
role === 'node-library' ||
|
||||
role === 'web-library')
|
||||
) {
|
||||
if (!backstage.pluginPackages) {
|
||||
throw new Error(
|
||||
`Plugin library package ${name} is missing a backstage.pluginPackages, please run 'backstage-cli repo fix --publish'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (role === 'backend-plugin-module' || role === 'frontend-plugin-module') {
|
||||
// TODO(Rugvip): Remove this .pluginId check once frontend modules are required to have a plugin ID
|
||||
if (backstage.pluginId && !backstage.pluginPackage) {
|
||||
throw new Error(
|
||||
`Plugin module package ${name} is missing a backstage.pluginPackage, please run 'backstage-cli repo fix --publish'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Copyright 2022 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 { createMockDirectory } from '@backstage/backend-test-utils';
|
||||
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
|
||||
import { findRoleFromCommand } from './role';
|
||||
|
||||
const mockDir = createMockDirectory();
|
||||
overrideTargetPaths(mockDir.path);
|
||||
|
||||
describe('findRoleFromCommand', () => {
|
||||
beforeEach(() => {
|
||||
mockDir.setContent({
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test',
|
||||
backstage: {
|
||||
role: 'web-library',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('provides role info by role', async () => {
|
||||
await expect(findRoleFromCommand({})).resolves.toEqual('web-library');
|
||||
|
||||
await expect(
|
||||
findRoleFromCommand({ role: 'node-library' }),
|
||||
).resolves.toEqual('node-library');
|
||||
|
||||
await expect(findRoleFromCommand({ role: 'invalid' })).rejects.toThrow(
|
||||
`Unknown package role 'invalid'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Copyright 2023 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 fs from 'fs-extra';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import { PackageRoles, PackageRole } from '@backstage/cli-node';
|
||||
|
||||
export async function findRoleFromCommand(opts: {
|
||||
role?: string;
|
||||
}): Promise<PackageRole> {
|
||||
if (opts.role) {
|
||||
return PackageRoles.getRoleInfo(opts.role).role;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* 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 { runBackend } from './runBackend';
|
||||
@@ -1,178 +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 { runBackend } from './runBackend';
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('chokidar', () => ({
|
||||
watch: jest.fn(() => ({
|
||||
on: jest.fn().mockReturnThis(),
|
||||
add: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('cross-spawn', () =>
|
||||
jest.fn(() => ({
|
||||
on: jest.fn().mockReturnThis(),
|
||||
once: jest.fn().mockReturnThis(),
|
||||
kill: jest.fn(),
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
pid: 12345,
|
||||
})),
|
||||
);
|
||||
|
||||
jest.mock('../ipc', () => ({
|
||||
IpcServer: jest.fn().mockImplementation(() => ({
|
||||
addChild: jest.fn(),
|
||||
})),
|
||||
ServerDataStore: {
|
||||
bind: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('ctrlc-windows', () => ({
|
||||
ctrlc: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('runBackend', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let originalPlatform: string;
|
||||
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use fake timers to control debounce
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Save original environment
|
||||
originalEnv = { ...process.env };
|
||||
process.env = { NODE_ENV: 'test' };
|
||||
originalPlatform = process.platform;
|
||||
|
||||
// Mock process.stdin.on to prevent actual stdin reading
|
||||
jest.spyOn(process.stdin, 'on').mockReturnValue(process.stdin);
|
||||
|
||||
// Mock process.once to prevent actual signal handling
|
||||
jest.spyOn(process, 'once').mockReturnValue(process);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
|
||||
jest.clearAllMocks();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('--no-node-snapshot argument handling', () => {
|
||||
it('should pass --no-node-snapshot when NODE_OPTIONS is not set', () => {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
});
|
||||
|
||||
// Fast-forward past the debounce delay (100ms)
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--no-node-snapshot');
|
||||
});
|
||||
|
||||
it('should pass --no-node-snapshot when NODE_OPTIONS exists without --node-snapshot', () => {
|
||||
process.env.NODE_OPTIONS = '--max-old-space-size=4096';
|
||||
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
});
|
||||
|
||||
// Fast-forward past the debounce delay (100ms)
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--no-node-snapshot');
|
||||
});
|
||||
|
||||
it('should not pass --no-node-snapshot when --node-snapshot already exists in NODE_OPTIONS', () => {
|
||||
process.env.NODE_OPTIONS = '--node-snapshot --max-old-space-size=4096';
|
||||
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
});
|
||||
|
||||
// Fast-forward past the debounce delay (100ms)
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).not.toContain('--no-node-snapshot');
|
||||
});
|
||||
|
||||
it('should not pass --no-node-snapshot when --node-snapshot exists in the middle of NODE_OPTIONS', () => {
|
||||
process.env.NODE_OPTIONS =
|
||||
'--max-old-space-size=4096 --node-snapshot --inspect';
|
||||
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
});
|
||||
|
||||
// Fast-forward past the debounce delay (100ms)
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).not.toContain('--no-node-snapshot');
|
||||
});
|
||||
|
||||
it('should pass --no-node-snapshot even with trailing spaces in NODE_OPTIONS', () => {
|
||||
process.env.NODE_OPTIONS = '--max-old-space-size=4096 ';
|
||||
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
});
|
||||
|
||||
// Fast-forward past the debounce delay (100ms)
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--no-node-snapshot');
|
||||
});
|
||||
|
||||
it('should pass --no-node-snapshot alongside other option args like --inspect', () => {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
|
||||
runBackend({
|
||||
entry: 'src/index',
|
||||
inspectEnabled: true,
|
||||
});
|
||||
|
||||
// Fast-forward past the debounce delay (100ms)
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalled();
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
|
||||
expect(spawnArgs).toContain('--no-node-snapshot');
|
||||
expect(spawnArgs).toContain('--inspect');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +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 { FSWatcher, watch } from 'chokidar';
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { ctrlc } from 'ctrlc-windows';
|
||||
import { IpcServer, ServerDataStore } from '../ipc';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute as isAbsolutePath } from 'node:path';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
const loaderArgs = [
|
||||
'--enable-source-maps',
|
||||
'--require',
|
||||
require.resolve('@backstage/cli/config/nodeTransform.cjs'),
|
||||
// TODO: Support modules, although there's currently no way to load them since import() is transpiled tp require()
|
||||
];
|
||||
|
||||
export type RunBackendOptions = {
|
||||
/** The directory to run the backend process in, defaults to cwd */
|
||||
targetDir?: string;
|
||||
/** relative entry point path without extension, e.g. 'src/index' */
|
||||
entry: string;
|
||||
/** Whether to forward the --inspect flag to the node process */
|
||||
inspectEnabled?: boolean | string;
|
||||
/** Whether to forward the --inspect-brk flag to the node process */
|
||||
inspectBrkEnabled?: boolean | string;
|
||||
/** Additional module to require via the --require flag to the node process */
|
||||
require?: string | string[];
|
||||
/** An external linked workspace to override module resolution towards */
|
||||
linkedWorkspace?: string;
|
||||
};
|
||||
|
||||
export async function runBackend(options: RunBackendOptions) {
|
||||
const envEnv = process.env as { NODE_ENV: string; NODE_OPTIONS?: string };
|
||||
if (!envEnv.NODE_ENV) {
|
||||
envEnv.NODE_ENV = 'development';
|
||||
}
|
||||
|
||||
// Set up the parent IPC server and bind the available services
|
||||
const server = new IpcServer();
|
||||
ServerDataStore.bind(server);
|
||||
|
||||
let exiting = false;
|
||||
let firstStart = true;
|
||||
let child: ChildProcess | undefined;
|
||||
let watcher: FSWatcher | undefined = undefined;
|
||||
let shutdownPromise: Promise<void> | undefined = undefined;
|
||||
|
||||
const watchedPaths = new Set<string>();
|
||||
|
||||
const restart = debounce(async () => {
|
||||
if (firstStart) {
|
||||
firstStart = false;
|
||||
} else {
|
||||
console.log();
|
||||
console.log('Change detected, restarting the development server...');
|
||||
console.log();
|
||||
}
|
||||
// If a re-trigger happens during an existing shutdown, we just ignore it
|
||||
if (shutdownPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (child && !child.killed && child.exitCode === null) {
|
||||
// We always wait for the existing process to exit, to make sure we don't get IPC conflicts
|
||||
shutdownPromise = new Promise(resolve => child!.once('exit', resolve));
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
ctrlc(child.pid);
|
||||
} else {
|
||||
child.kill();
|
||||
}
|
||||
await shutdownPromise;
|
||||
shutdownPromise = undefined;
|
||||
}
|
||||
|
||||
// We've received a shutdown signal
|
||||
if (exiting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionArgs = new Array<string>();
|
||||
if (options.inspectEnabled) {
|
||||
const inspect =
|
||||
typeof options.inspectEnabled === 'string'
|
||||
? `--inspect=${options.inspectEnabled}`
|
||||
: '--inspect';
|
||||
optionArgs.push(inspect);
|
||||
} else if (options.inspectBrkEnabled) {
|
||||
const inspect =
|
||||
typeof options.inspectBrkEnabled === 'string'
|
||||
? `--inspect-brk=${options.inspectBrkEnabled}`
|
||||
: '--inspect-brk';
|
||||
optionArgs.push(inspect);
|
||||
}
|
||||
if (options.require) {
|
||||
const requires = [options.require].flat();
|
||||
for (const r of requires) {
|
||||
optionArgs.push(`--require=${r}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unless the user explicitly toggles node-snapshot, default to provide --no-node-snapshot to reduce number of steps to run scaffolder
|
||||
// on Node LTS.
|
||||
if (!envEnv.NODE_OPTIONS?.includes('--node-snapshot')) {
|
||||
optionArgs.push('--no-node-snapshot');
|
||||
}
|
||||
|
||||
const userArgs = process.argv
|
||||
.slice(['node', 'backstage-cli', 'package', 'start'].length)
|
||||
.filter(arg => !optionArgs.includes(arg));
|
||||
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
[...loaderArgs, ...optionArgs, options.entry, ...userArgs],
|
||||
{
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
cwd: options.targetDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BACKSTAGE_CLI_LINKED_WORKSPACE: options.linkedWorkspace,
|
||||
BACKSTAGE_CLI_CHANNEL: '1',
|
||||
ESBK_TSCONFIG_PATH: targetPaths.resolveRoot('tsconfig.json'),
|
||||
},
|
||||
serialization: 'advanced',
|
||||
},
|
||||
);
|
||||
|
||||
server.addChild(child);
|
||||
|
||||
// This captures messages sent by @esbuild-kit/cjs-loader
|
||||
child.on('message', (data: { type?: string } | null) => {
|
||||
if (!watcher) {
|
||||
return;
|
||||
}
|
||||
if (typeof data === 'object' && data?.type === 'watch') {
|
||||
let path = (data as { path: string }).path;
|
||||
if (path.startsWith('file:')) {
|
||||
path = fileURLToPath(path);
|
||||
}
|
||||
|
||||
if (isAbsolutePath(path) && !watchedPaths.has(path)) {
|
||||
watchedPaths.add(path);
|
||||
watcher.add(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
|
||||
restart();
|
||||
|
||||
watcher = watch(['./package.json'], {
|
||||
cwd: process.cwd(),
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
}).on('all', restart);
|
||||
|
||||
// Trigger restart on hitting enter in the terminal
|
||||
process.stdin.on('data', restart);
|
||||
|
||||
const exitPromise = new Promise<void>(resolveExitPromise => {
|
||||
async function handleSignal(signal: NodeJS.Signals) {
|
||||
exiting = true;
|
||||
|
||||
// Forward signals to child and wait for it to exit if still running
|
||||
if (child && child.exitCode === null) {
|
||||
await new Promise(resolve => {
|
||||
child!.on('close', resolve);
|
||||
child!.kill(signal);
|
||||
});
|
||||
}
|
||||
|
||||
resolveExitPromise();
|
||||
}
|
||||
|
||||
process.once('SIGINT', handleSignal);
|
||||
process.once('SIGTERM', handleSignal);
|
||||
});
|
||||
|
||||
return () => exitPromise;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* 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 { PackageRole, BackstagePackageFeatureType } from '@backstage/cli-node';
|
||||
import createFeatureEnvironment from './__testUtils__/createFeatureEnvironment';
|
||||
import { getEntryPointDefaultFeatureType } from './typeDistProject';
|
||||
|
||||
describe('typeDistProject', () => {
|
||||
describe('for package role', () => {
|
||||
// This Record makes sure we're checking all package roles
|
||||
const packageRoles: Record<PackageRole, boolean> = {
|
||||
// Allowed
|
||||
'backend-plugin': true,
|
||||
'backend-plugin-module': true,
|
||||
'frontend-plugin': true,
|
||||
'frontend-plugin-module': true,
|
||||
'web-library': true,
|
||||
'node-library': true,
|
||||
// Disallowed
|
||||
frontend: false,
|
||||
backend: false,
|
||||
cli: false,
|
||||
'cli-module': false,
|
||||
'common-library': false,
|
||||
};
|
||||
|
||||
const allowedPackageRoles = Object.keys(packageRoles).filter(
|
||||
role => packageRoles[role as PackageRole],
|
||||
);
|
||||
|
||||
const disallowedPackageRoles = Object.keys(packageRoles).filter(
|
||||
role => !packageRoles[role as PackageRole],
|
||||
);
|
||||
|
||||
it.each(allowedPackageRoles)(`returns features for %s`, r => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
role: r as PackageRole,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual('@backstage/BackendFeature');
|
||||
});
|
||||
|
||||
it.each(disallowedPackageRoles)(`does not return features for %s`, r => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
role: r as PackageRole,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('for feature $$type', () => {
|
||||
// This Record makes sure we're checking all feature types
|
||||
const featureTypes: Record<BackstagePackageFeatureType | string, boolean> =
|
||||
{
|
||||
// Allowed
|
||||
'@backstage/BackendFeature': true,
|
||||
'@backstage/BackstagePlugin': true,
|
||||
'@backstage/FrontendPlugin': true,
|
||||
'@backstage/FrontendModule': true,
|
||||
// Disallowed
|
||||
'@backstage/Extension': false,
|
||||
'@backstage/RouteRef': false,
|
||||
};
|
||||
|
||||
const allowedFeatureTypes = Object.keys(featureTypes).filter(
|
||||
$$type => featureTypes[$$type as BackstagePackageFeatureType],
|
||||
);
|
||||
|
||||
const disallowedFeatureTypes = Object.keys(featureTypes).filter(
|
||||
$$type => !featureTypes[$$type as BackstagePackageFeatureType],
|
||||
);
|
||||
|
||||
it.each(allowedFeatureTypes)(`returns features for "%s" $$type`, $$type => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
$$type: $$type as BackstagePackageFeatureType,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual($$type);
|
||||
});
|
||||
|
||||
it.each(disallowedFeatureTypes)(
|
||||
`does not return features for "%s" $$type`,
|
||||
$$type => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
$$type: $$type as BackstagePackageFeatureType,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'DefaultExportAssignment',
|
||||
'DefaultExportFromFile',
|
||||
'DefaultExportFromFileAsDefault',
|
||||
'DefaultExportFromFileWithSibling',
|
||||
] as const)('returns features for format "%s"', format => {
|
||||
const { project, role, dir, entryPoint } = createFeatureEnvironment({
|
||||
format,
|
||||
});
|
||||
|
||||
expect(
|
||||
getEntryPointDefaultFeatureType(role, dir, project, entryPoint),
|
||||
).toEqual('@backstage/BackendFeature');
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
BackstagePackageFeatureType,
|
||||
packageFeatureType,
|
||||
PackageRole,
|
||||
} from '@backstage/cli-node';
|
||||
import { resolve as resolvePath } from 'node:path';
|
||||
import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph';
|
||||
import { targetPaths } from '@backstage/cli-common';
|
||||
|
||||
export const createTypeDistProject = async () => {
|
||||
return new Project({
|
||||
tsConfigFilePath: targetPaths.resolveRoot('tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
};
|
||||
|
||||
// A list of the package roles we want to extract features for
|
||||
const targetPackageRoles: PackageRole[] = [
|
||||
'backend-plugin',
|
||||
'backend-plugin-module',
|
||||
'frontend-plugin',
|
||||
'frontend-plugin-module',
|
||||
'web-library',
|
||||
'node-library',
|
||||
];
|
||||
|
||||
export const getEntryPointDefaultFeatureType = (
|
||||
role: PackageRole,
|
||||
packageDir: string,
|
||||
project: Project,
|
||||
entryPoint: string,
|
||||
): BackstagePackageFeatureType | null => {
|
||||
if (isTargetPackageRole(role)) {
|
||||
const distPath = resolvePath(packageDir, entryPoint);
|
||||
|
||||
try {
|
||||
const defaultFeatureType = getSourceFileDefaultFeatureType(
|
||||
project.addSourceFileAtPath(distPath),
|
||||
);
|
||||
|
||||
if (defaultFeatureType) {
|
||||
return defaultFeatureType;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to extract default feature type from ${distPath}, ${error}. ` +
|
||||
'Your package will publish fine but it may be missing metadata about its default feature.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Returns all exports (default and named) from an entry point
|
||||
// that are valid Backstage package features
|
||||
function getSourceFileDefaultFeatureType(
|
||||
sourceFile: SourceFile,
|
||||
): BackstagePackageFeatureType | null {
|
||||
for (const exportSymbol of sourceFile.getExportSymbols()) {
|
||||
const declaration = exportSymbol.getDeclarations()[0];
|
||||
const exportName = declaration.getSymbol()?.getName();
|
||||
|
||||
if (exportName !== 'default') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let exportType: Type<ts.Type> | undefined;
|
||||
|
||||
if (declaration) {
|
||||
if (declaration.isKind(SyntaxKind.ExportAssignment)) {
|
||||
exportType = declaration.getExpression().getType();
|
||||
} else if (declaration.isKind(SyntaxKind.ExportSpecifier)) {
|
||||
if (!declaration.isTypeOnly()) {
|
||||
exportType = declaration.getType();
|
||||
}
|
||||
} else if (declaration.isKind(SyntaxKind.VariableDeclaration)) {
|
||||
exportType = declaration.getType();
|
||||
}
|
||||
}
|
||||
|
||||
if (exportName && exportType) {
|
||||
const $$type = getBackstagePackageFeature$$TypeFromType(exportType);
|
||||
|
||||
if ($$type) {
|
||||
return $$type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Given a TS type, returns the Backstage package feature $$type value
|
||||
function getBackstagePackageFeature$$TypeFromType(
|
||||
type: Type,
|
||||
): BackstagePackageFeatureType | null {
|
||||
// Returns the concrete type of a generic type
|
||||
const exportType = type.getTargetType() ?? type;
|
||||
|
||||
for (const property of exportType.getProperties()) {
|
||||
if (property.getName() === '$$type') {
|
||||
const $$type = property
|
||||
.getValueDeclaration()
|
||||
?.getText()
|
||||
.match(/(\$\$type: '(?<type>.+)')/)?.groups?.type;
|
||||
|
||||
if ($$type && isTargetFeatureType($$type)) {
|
||||
return $$type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Condition for a package role matches a target package role
|
||||
function isTargetPackageRole(role: PackageRole): boolean {
|
||||
return !!role && targetPackageRoles.includes(role);
|
||||
}
|
||||
|
||||
// Returns whether an export is a valid Backstage package feature type
|
||||
function isTargetFeatureType(
|
||||
type: string | BackstagePackageFeatureType,
|
||||
): type is BackstagePackageFeatureType {
|
||||
return (
|
||||
!!type && packageFeatureType.includes(type as BackstagePackageFeatureType)
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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 { isValidUrl } from './urls';
|
||||
|
||||
describe('isValidUrl', () => {
|
||||
it('should return true for url', () => {
|
||||
const validUrl = isValidUrl('http://some.valid.url');
|
||||
expect(validUrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for absolute path', () => {
|
||||
const validUrl = isValidUrl('/some/absolute/path');
|
||||
expect(validUrl).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for relative path', () => {
|
||||
const validUrl = isValidUrl('../some/relative/path');
|
||||
expect(validUrl).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* Copyright 2021 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 function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
!node_modules
|
||||
dist
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
module.exports = 'a'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
exports.value = 'a'
|
||||
packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/b-default.mjs
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
export default 'b'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
export const value = 'b'
|
||||
packages/cli/src/modules/build/tests/transforms/__fixtures__/node_modules/dep-commonjs/c-default.cjs
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
module.exports = 'c'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
exports.value = 'c'
|
||||
Generated
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
export const namedA: string
|
||||
export const namedB: string
|
||||
export const namedC: string
|
||||
export const defaultA: string
|
||||
export const defaultB: string
|
||||
export const defaultC: string
|
||||
|
||||
export namespace dyn {
|
||||
export const namedA: Promise<string>
|
||||
export const namedB: Promise<string>
|
||||
export const namedC: Promise<string>
|
||||
export const defaultA: Promise<string>
|
||||
export const defaultB: Promise<string>
|
||||
export const defaultC: Promise<string>
|
||||
}
|
||||
Generated
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
exports.namedA = require('./a-named').value;
|
||||
// exports.namedB = require('./b-named.mjs').value;
|
||||
exports.namedC = require('./c-named.cjs').value;
|
||||
exports.defaultA = require('./a-default');
|
||||
// exports.defaultB = require('./b-default.mjs').default;
|
||||
exports.defaultC = require('./c-default.cjs');
|
||||
exports.dyn = {
|
||||
namedA: import('./a-named').then(m => m.value),
|
||||
namedB: import('./b-named.mjs').then(m => m.value),
|
||||
namedC: import('./c-named.cjs').then(m => m.value),
|
||||
defaultA: import('./a-default').then(m => m.default),
|
||||
defaultB: import('./b-default.mjs').then(m => m.default),
|
||||
defaultC: import('./c-default.cjs').then(m => m.default),
|
||||
}
|
||||
Generated
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "dep-commonjs",
|
||||
"type": "commonjs",
|
||||
"exports": {
|
||||
".": "./main.js"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"main.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
module.exports = 'a'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
exports.value = 'a'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
export default 'b'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
export const value = 'b'
|
||||
Generated
Vendored
-1
@@ -1 +0,0 @@
|
||||
module.exports = 'c'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user