Merge branch 'master' into range-slider-component

This commit is contained in:
root
2026-03-10 20:29:25 +05:30
401 changed files with 12549 additions and 5799 deletions
@@ -87,7 +87,6 @@ const examplePlugin: OverridableFrontendPlugin<
>;
};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
title?: string;
icon?: IconElement;
@@ -170,7 +170,7 @@ const overviewContent = (
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
@@ -299,7 +299,7 @@ const apiPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item xs={12}>
<Grid container>
@@ -333,10 +333,7 @@ const userPage = (
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
<EntityOwnershipCard entityFilterKind={customEntityFilterKind} />
</Grid>
</Grid>
</EntityLayout.Route>
@@ -352,10 +349,7 @@ const groupPage = (
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
<EntityOwnershipCard entityFilterKind={customEntityFilterKind} />
</Grid>
<Grid item xs={12} md={6}>
<EntityMembersListCard />
@@ -377,7 +371,7 @@ const systemPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasComponentsCard variant="gridItem" />
@@ -392,7 +386,6 @@ const systemPage = (
</EntityLayout.Route>
<EntityLayout.Route path="/diagram" title="Diagram">
<EntityCatalogGraphCard
variant="gridItem"
direction={Direction.TOP_BOTTOM}
title="System Diagram"
height={700}
@@ -421,7 +414,7 @@ const domainPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
@@ -440,7 +433,7 @@ const resourcePage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
+1 -1
View File
@@ -164,7 +164,7 @@
"cron": "^3.0.0",
"express": "^4.22.0",
"express-promise-router": "^4.1.0",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^8.2.2",
"fs-extra": "^11.2.0",
"git-url-parse": "^15.0.0",
"helmet": "^6.0.0",
@@ -16,6 +16,7 @@
import { LocalTaskWorker } from './LocalTaskWorker';
import { mockServices } from '@backstage/backend-test-utils';
import { ConflictError } from '@backstage/errors';
import waitFor from 'wait-for-expect';
jest.setTimeout(10_000);
@@ -110,6 +111,54 @@ describe('LocalTaskWorker', () => {
controller.abort();
});
it('can cancel a running task', async () => {
let receivedSignal: AbortSignal | undefined;
const fn = jest.fn(async (signal: AbortSignal) => {
receivedSignal = signal;
await new Promise(r => setTimeout(r, 5000));
});
const controller = new AbortController();
const worker = new LocalTaskWorker('a', fn, logger);
worker.start(
{
version: 2,
cadence: 'PT10S',
timeoutAfterDuration: 'PT10S',
},
{ signal: controller.signal },
);
await waitFor(() => {
expect(fn).toHaveBeenCalledTimes(1);
});
expect(receivedSignal?.aborted).toBe(false);
worker.cancel();
expect(receivedSignal?.aborted).toBe(true);
controller.abort();
});
it('cannot cancel a task that is not running', async () => {
const fn = jest.fn();
const controller = new AbortController();
const worker = new LocalTaskWorker('a', fn, logger);
worker.start(
{
version: 2,
initialDelayDuration: 'PT1000S',
cadence: 'PT10S',
timeoutAfterDuration: 'PT10S',
},
{ signal: controller.signal },
);
expect(() => worker.cancel()).toThrow(ConflictError);
controller.abort();
});
it('goes through the expected states', async () => {
const fn = jest
.fn()
@@ -29,6 +29,7 @@ import { delegateAbortController, serializeError, sleep } from './util';
*/
export class LocalTaskWorker {
private abortWait: AbortController | undefined;
private taskAbortController: AbortController | undefined;
#taskState: Exclude<TaskApiTasksResponse['taskState'], null> = {
status: 'idle',
};
@@ -93,6 +94,13 @@ export class LocalTaskWorker {
this.abortWait.abort();
}
cancel(): void {
if (!this.taskAbortController) {
throw new ConflictError(`Task ${this.taskId} is not running`);
}
this.taskAbortController.abort();
}
taskState(): TaskApiTasksResponse['taskState'] {
return this.#taskState;
}
@@ -134,10 +142,10 @@ export class LocalTaskWorker {
): Promise<void> {
// Abort the task execution either if the worker is stopped, or if the
// task timeout is hit
const taskAbortController = delegateAbortController(signal);
this.taskAbortController = delegateAbortController(signal);
const timeoutDuration = Duration.fromISO(settings.timeoutAfterDuration);
const timeoutHandle = setTimeout(() => {
taskAbortController.abort();
this.taskAbortController?.abort();
}, timeoutDuration.as('milliseconds'));
this.#taskState = {
@@ -152,7 +160,7 @@ export class LocalTaskWorker {
};
try {
await this.fn(taskAbortController.signal);
await this.fn(this.taskAbortController.signal);
this.#taskState.lastRunEndedAt = DateTime.utc().toISO()!;
this.#taskState.lastRunError = undefined;
} catch (e) {
@@ -162,7 +170,8 @@ export class LocalTaskWorker {
// release resources
clearTimeout(timeoutHandle);
taskAbortController.abort();
this.taskAbortController.abort();
this.taskAbortController = undefined;
}
/**
@@ -399,6 +399,100 @@ describe('PluginTaskManagerImpl', () => {
);
});
describe('cancelTask with local scope', () => {
it('can cancel a running task', async () => {
const { manager } = await init('SQLITE_3');
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'local',
});
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
}, 60_000);
it('cannot cancel a task that is not running', async () => {
const { manager } = await init('SQLITE_3');
const fn = jest.fn();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn,
scope: 'local',
});
await expect(manager.cancelTask('task1')).rejects.toThrow(ConflictError);
}, 60_000);
});
describe('cancelTask with global scope', () => {
it.each(databases.eachSupportedId())(
'can cancel a running task, %p',
async databaseId => {
const { manager } = await init(databaseId);
const promise = createDeferred();
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
fn: async () => {
promise.resolve();
await new Promise(r => setTimeout(r, 20000));
},
scope: 'global',
});
await promise;
await expect(manager.cancelTask('task1')).resolves.toBeUndefined();
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a non-existent task, %p',
async databaseId => {
const { manager } = await init(databaseId);
await expect(manager.cancelTask('nonexistent')).rejects.toThrow(
NotFoundError,
);
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a task that is not running, %p',
async databaseId => {
const { manager } = await init(databaseId);
await manager.scheduleTask({
id: 'task1',
timeout: Duration.fromMillis(5000),
frequency: Duration.fromObject({ years: 1 }),
initialDelay: Duration.fromObject({ years: 1 }),
fn: jest.fn(),
scope: 'global',
});
await expect(manager.cancelTask('task1')).rejects.toThrow(
ConflictError,
);
},
);
});
describe('parseDuration', () => {
it('should parse durations', () => {
expect(parseDuration({ milliseconds: 5000 })).toEqual('PT5S');
@@ -107,6 +107,17 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
await TaskWorker.trigger(knex, id);
}
async cancelTask(id: string): Promise<void> {
const localTask = this.localWorkersById.get(id);
if (localTask) {
localTask.cancel();
return;
}
const knex = await this.databaseFactory();
await TaskWorker.cancel(knex, id);
}
async scheduleTask(
task: SchedulerServiceTaskScheduleDefinition &
SchedulerServiceTaskInvocationDefinition,
@@ -206,6 +217,15 @@ export class PluginTaskSchedulerImpl implements SchedulerService {
},
);
router.post(
'/.backstage/scheduler/v1/tasks/:id/cancel',
async (req, res) => {
const { id } = req.params;
await this.cancelTask(id);
res.status(200).end();
},
);
return router;
}
@@ -15,6 +15,7 @@
*/
import { TestDatabases, mockServices } from '@backstage/backend-test-utils';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { DateTime, Duration } from 'luxon';
import waitForExpect from 'wait-for-expect';
import { migrateBackendTasks } from '../database/migrateBackendTasks';
@@ -584,4 +585,79 @@ describe('TaskWorker', () => {
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'can cancel a running task, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
const fn = jest.fn(async () => {});
const settings: TaskSettingsV2 = {
version: 2,
cadence: '* * * * * *',
initialDelayDuration: undefined,
timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO()!,
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
await worker.tryClaimTask('ticket', settings);
// Verify the task is running
let row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row.current_run_ticket).toBe('ticket');
await TaskWorker.cancel(knex, 'task1');
// Verify the task is now idle with a cancellation error recorded
row = (await knex<DbTasksRow>(DB_TASKS_TABLE))[0];
expect(row.current_run_ticket).toBeNull();
expect(row.current_run_started_at).toBeNull();
expect(row.current_run_expires_at).toBeNull();
expect(row.last_run_ended_at).not.toBeNull();
expect(row.last_run_error_json).toContain('Task was cancelled');
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a non-existent task, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
await expect(TaskWorker.cancel(knex, 'nonexistent')).rejects.toThrow(
NotFoundError,
);
await knex.destroy();
},
);
it.each(databases.eachSupportedId())(
'cannot cancel a task that is not running, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateBackendTasks(knex);
const fn = jest.fn(async () => {});
const settings: TaskSettingsV2 = {
version: 2,
cadence: '* * * * * *',
initialDelayDuration: undefined,
timeoutAfterDuration: Duration.fromObject({ minutes: 1 }).toISO()!,
};
const worker = new TaskWorker('task1', fn, knex, logger);
await worker.persistTask(settings);
await expect(TaskWorker.cancel(knex, 'task1')).rejects.toThrow(
ConflictError,
);
await knex.destroy();
},
);
});
@@ -152,6 +152,36 @@ export class TaskWorker {
}
}
static async cancel(knex: Knex, taskId: string): Promise<void> {
const [row] = await knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', taskId)
.select('settings_json', 'current_run_ticket');
if (!row) {
throw new NotFoundError(`Task ${taskId} does not exist`);
}
if (!row.current_run_ticket) {
throw new ConflictError(`Task ${taskId} is not running`);
}
const settings = taskSettingsV2Schema.parse(JSON.parse(row.settings_json));
const nextRun = TaskWorker.computeNextRunStartAt(knex, settings);
const updatedRows = await knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', taskId)
.where('current_run_ticket', '=', row.current_run_ticket)
.update({
next_run_start_at: nextRun,
current_run_ticket: knex.raw('null'),
current_run_started_at: knex.raw('null'),
current_run_expires_at: knex.raw('null'),
last_run_ended_at: knex.fn.now(),
last_run_error_json: serializeError(new Error('Task was cancelled')),
});
if (updatedRows < 1) {
throw new ConflictError(`Task ${taskId} is not running`);
}
}
static async taskStates(
knex: Knex,
): Promise<Map<string, TaskApiTasksResponse['taskState']>> {
@@ -227,11 +257,22 @@ export class TaskWorker {
}
// Abort the task execution either if the worker is stopped, or if the
// task timeout is hit
// task timeout is hit, or if the task ticket was lost (e.g. due to
// cancellation from another host)
const taskAbortController = delegateAbortController(signal);
const timeoutHandle = setTimeout(() => {
taskAbortController.abort();
}, Duration.fromISO(taskSettings.timeoutAfterDuration).as('milliseconds'));
let livenessHandle: ReturnType<typeof setTimeout> | undefined;
const scheduleLivenessCheck = () => {
livenessHandle = setTimeout(async () => {
await this.checkLiveness(ticket, taskAbortController);
if (!taskAbortController.signal.aborted) {
scheduleLivenessCheck();
}
}, this.workCheckFrequency.as('milliseconds'));
};
scheduleLivenessCheck();
try {
this.#workerState = {
@@ -248,6 +289,7 @@ export class TaskWorker {
status: 'idle',
};
clearTimeout(timeoutHandle);
clearTimeout(livenessHandle);
}
await this.tryReleaseTask(ticket, taskSettings);
@@ -283,7 +325,7 @@ export class TaskWorker {
// We make a conversion here to make typescript happy, because the luxon versions of the cron library and here may not be the same
const timeConverted = DateTime.fromJSDate(time.toJSDate());
nextStartAt = this.nextRunAtRaw(timeConverted);
nextStartAt = TaskWorker.nextRunAtRaw(this.knex, timeConverted);
startAt ||= nextStartAt;
} else if (isManual) {
nextStartAt = this.knex.raw('null');
@@ -334,6 +376,33 @@ export class TaskWorker {
);
}
/**
* Checks whether the current task ticket is still valid in the database.
* If the ticket has been cleared (e.g. by cancellation or janitor cleanup),
* aborts the task execution.
*/
private async checkLiveness(
ticket: string,
taskAbortController: AbortController,
): Promise<void> {
try {
const [row] = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
.select('current_run_ticket');
if (!row || row.current_run_ticket !== ticket) {
this.logger.info(
`Task ticket for "${this.taskId}" is no longer valid; aborting execution`,
);
taskAbortController.abort();
}
} catch (e) {
this.logger.warn(
`Failed to check liveness for task "${this.taskId}", ${e}`,
);
}
}
/**
* Check if the task is ready to run
*/
@@ -407,48 +476,49 @@ export class TaskWorker {
return rows === 1;
}
private static computeNextRunStartAt(
knex: Knex,
settings: TaskSettingsV2,
): Knex.Raw {
const isManual = settings?.cadence === 'manual';
const isDuration = settings?.cadence.startsWith('P');
const isCron = !isManual && !isDuration;
if (isCron) {
const time = new CronTime(settings.cadence).sendAt().toUTC();
const timeConverted = DateTime.fromJSDate(time.toJSDate());
return TaskWorker.nextRunAtRaw(knex, timeConverted);
}
if (isManual) {
return knex.raw('null');
}
const dt = Duration.fromISO(settings.cadence).as('seconds');
if (knex.client.config.client.includes('sqlite3')) {
return knex.raw(`max(datetime(next_run_start_at, ?), datetime('now'))`, [
`+${dt} seconds`,
]);
}
if (knex.client.config.client.includes('mysql')) {
return knex.raw(
`greatest(next_run_start_at + interval ${dt} second, now())`,
);
}
return knex.raw(
`greatest(next_run_start_at + interval '${dt} seconds', now())`,
);
}
async tryReleaseTask(
ticket: string,
settings: TaskSettingsV2,
error?: Error,
): Promise<boolean> {
const isManual = settings?.cadence === 'manual';
const isDuration = settings?.cadence.startsWith('P');
const isCron = !isManual && !isDuration;
let nextRun: Knex.Raw;
if (isCron) {
const time = new CronTime(settings.cadence).sendAt().toUTC();
this.logger.debug(`task: ${this.taskId} will next occur around ${time}`);
// We make a conversion here to make typescript happy, because the luxon versions of the cron library and here may not be the same
const timeConverted = DateTime.fromJSDate(time.toJSDate());
nextRun = this.nextRunAtRaw(timeConverted);
} else if (isManual) {
nextRun = this.knex.raw('null');
} else {
const dt = Duration.fromISO(settings.cadence).as('seconds');
this.logger.debug(
`task: ${this.taskId} will next occur around ${DateTime.now().plus({
seconds: dt,
})}`,
);
if (this.knex.client.config.client.includes('sqlite3')) {
nextRun = this.knex.raw(
`max(datetime(next_run_start_at, ?), datetime('now'))`,
[`+${dt} seconds`],
);
} else if (this.knex.client.config.client.includes('mysql')) {
nextRun = this.knex.raw(
`greatest(next_run_start_at + interval ${dt} second, now())`,
);
} else {
nextRun = this.knex.raw(
`greatest(next_run_start_at + interval '${dt} seconds', now())`,
);
}
}
const nextRun = TaskWorker.computeNextRunStartAt(this.knex, settings);
const rows = await this.knex<DbTasksRow>(DB_TASKS_TABLE)
.where('id', '=', this.taskId)
@@ -467,12 +537,13 @@ export class TaskWorker {
return rows === 1;
}
private nextRunAtRaw(time: DateTime): Knex.Raw {
if (this.knex.client.config.client.includes('sqlite3')) {
return this.knex.raw('datetime(?)', [time.toISO()]);
} else if (this.knex.client.config.client.includes('mysql')) {
return this.knex.raw(`?`, [time.toSQL({ includeOffset: false })]);
private static nextRunAtRaw(knex: Knex, time: DateTime): Knex.Raw {
if (knex.client.config.client.includes('sqlite3')) {
return knex.raw('datetime(?)', [time.toISO()]);
}
return this.knex.raw(`?`, [time.toISO()]);
if (knex.client.config.client.includes('mysql')) {
return knex.raw(`?`, [time.toSQL({ includeOffset: false })]);
}
return knex.raw(`?`, [time.toISO()]);
}
}
@@ -643,6 +643,7 @@ export interface RootServiceFactoryOptions<
// @public
export interface SchedulerService {
cancelTask(id: string): Promise<void>;
createScheduledTaskRunner(
schedule: SchedulerServiceTaskScheduleDefinition,
): SchedulerServiceTaskRunner;
@@ -304,6 +304,16 @@ export interface SchedulerService {
*/
triggerTask(id: string): Promise<void>;
/**
* Cancels a currently running task by ID, marking it as idle.
*
* If the task doesn't exist, a NotFoundError is thrown. If the task is
* not currently running, a ConflictError is thrown.
*
* @param id - The task ID
*/
cancelTask(id: string): Promise<void>;
/**
* Schedules a task function for recurring runs.
*
@@ -206,6 +206,68 @@ describe('MockSchedulerService', () => {
await expect(isDone()).resolves.toBe(true);
});
it('should cancel a running task and allow re-triggering with a fresh signal', async () => {
const scheduler = new MockSchedulerService();
const signals: AbortSignal[] = [];
scheduler.scheduleTask({
...baseOpts,
id: 'test',
fn: async signal => {
signals.push(signal);
// Simulate long-running work that respects cancellation
await new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new Error('aborted'));
return;
}
signal.addEventListener('abort', () => reject(new Error('aborted')));
setTimeout(1).then(resolve);
});
},
});
// First run completes normally
await scheduler.triggerTask('test');
expect(signals).toHaveLength(1);
expect(signals[0].aborted).toBe(false);
// Start a task that will block until cancelled
const blockingScheduler = new MockSchedulerService();
let resolveBlock: (() => void) | undefined;
blockingScheduler.scheduleTask({
...baseOpts,
id: 'blocking',
fn: async signal => {
signals.push(signal);
await new Promise<void>((resolve, reject) => {
signal.addEventListener('abort', () => reject(new Error('aborted')));
resolveBlock = resolve;
});
},
});
const triggerPromise = blockingScheduler.triggerTask('blocking');
// Give the task fn time to start
await setTimeout(1);
await blockingScheduler.cancelTask('blocking');
await triggerPromise.catch(() => {});
expect(signals).toHaveLength(2);
expect(signals[1].aborted).toBe(true);
// Re-trigger should get a fresh non-aborted signal
resolveBlock = undefined;
const triggerPromise2 = blockingScheduler.triggerTask('blocking');
await setTimeout(1);
resolveBlock!();
await triggerPromise2;
expect(signals).toHaveLength(3);
expect(signals[2].aborted).toBe(false);
});
it('should abort tasks when shutting down', async () => {
let taskSignal: AbortSignal | undefined;
@@ -23,6 +23,7 @@ import {
SchedulerServiceTaskRunner,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { createDeferred, DeferredPromise } from '@backstage/types';
export class MockSchedulerService implements SchedulerService {
@@ -95,10 +96,22 @@ export class MockSchedulerService implements SchedulerService {
});
}
async cancelTask(id: string): Promise<void> {
const task = this.#tasks.get(id);
if (!task) {
throw new NotFoundError(`Task ${id} not found`);
}
if (!this.#runningTasks.has(id)) {
throw new ConflictError(`Task ${id} is not running`);
}
task.abortControllers.abort();
task.abortControllers = new AbortController();
}
async triggerTask(id: string): Promise<void> {
const task = this.#tasks.get(id);
if (!task) {
throw new Error(`Task ${id} not found`);
throw new NotFoundError(`Task ${id} not found`);
}
if (this.#runningTasks.has(id)) {
return;
@@ -526,6 +526,7 @@ export namespace mockServices {
getScheduledTasks: jest.fn(),
scheduleTask: jest.fn(),
triggerTask: jest.fn(),
cancelTask: jest.fn(),
}));
}
+1 -1
View File
@@ -66,7 +66,7 @@
"@backstage/plugin-search-backend-node": "workspace:^",
"@backstage/plugin-signals-backend": "workspace:^",
"@backstage/plugin-techdocs-backend": "workspace:^",
"@opentelemetry/auto-instrumentations-node": "^0.67.0",
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
"@opentelemetry/exporter-prometheus": "^0.211.0",
"@opentelemetry/sdk-node": "^0.211.0",
"example-app": "link:../app"
+1
View File
@@ -15,6 +15,7 @@ export type AddLocationRequest = {
type?: string;
target: string;
dryRun?: boolean;
onConflict?: 'refresh' | 'reject';
};
// @public
+5 -2
View File
@@ -567,12 +567,15 @@ export class CatalogClient implements CatalogApi {
request: AddLocationRequest,
options?: CatalogRequestOptions,
): Promise<AddLocationResponse> {
const { type = 'url', target, dryRun } = request;
const { type = 'url', target, dryRun, onConflict } = request;
const response = await this.apiClient.createLocation(
{
body: { type, target },
query: { dryRun: dryRun ? 'true' : undefined },
query: {
dryRun: dryRun ? 'true' : undefined,
onConflict,
},
},
options,
);
@@ -178,6 +178,7 @@ export type CreateLocation = {
body: CreateLocationRequest;
query: {
dryRun?: string;
onConflict?: 'refresh' | 'reject';
};
};
/**
@@ -592,6 +593,7 @@ export class DefaultApiClient {
* Create a location for a given target.
* @param createLocationRequest -
* @param dryRun -
* @param onConflict - Behavior when the location already exists. \&#39;reject\&#39; (default) returns a 409 error, \&#39;refresh\&#39; triggers a refresh of the existing location entity and returns 201.
*/
public async createLocation(
// @ts-ignore
@@ -600,7 +602,7 @@ export class DefaultApiClient {
): Promise<TypedResponse<CreateLocation201Response>> {
const baseUrl = await this.discoveryApi.getBaseUrl(pluginId);
const uriTemplate = `/locations{?dryRun}`;
const uriTemplate = `/locations{?dryRun,onConflict}`;
const uri = parser.parse(uriTemplate).expand({
...request.query,
+6
View File
@@ -396,6 +396,12 @@ export type AddLocationRequest = {
* contain the entities that match the given location.
*/
dryRun?: boolean;
/**
* Behavior when the location already exists. If set to `'reject'` (the
* default), a conflict error is returned. If set to `'refresh'`, the
* existing location entity is marked for refresh and a 201 is returned.
*/
onConflict?: 'refresh' | 'reject';
};
/**
+133 -53
View File
@@ -12,6 +12,7 @@ Options:
-h, --help
Commands:
auth [command]
build-workspace
config [command]
config:check
@@ -30,13 +31,92 @@ Commands:
versions:migrate
```
### `backstage-cli auth`
```
Usage: backstage-cli auth [options] [command] [command]
Options:
-h, --help
Commands:
help [command]
list
login
logout
print-token
select
show
```
### `backstage-cli auth list`
```
Usage: backstage-cli auth list
Options:
-h, --help
```
### `backstage-cli auth login`
```
Usage: backstage-cli auth login
Options:
--backend-url <string>
--instance <string>
--no-browser
-h, --help
```
### `backstage-cli auth logout`
```
Usage: backstage-cli auth logout
Options:
--instance <string>
-h, --help
```
### `backstage-cli auth print-token`
```
Usage: backstage-cli auth print-token
Options:
--instance <string>
-h, --help
```
### `backstage-cli auth select`
```
Usage: backstage-cli auth select
Options:
--instance <string>
-h, --help
```
### `backstage-cli auth show`
```
Usage: backstage-cli auth show
Options:
--instance <string>
-h, --help
```
### `backstage-cli build-workspace`
```
Usage: program [options] <workspace-dir> [packages...]
Usage: backstage-cli build-workspace <workspace-dir> [packages...]
Options:
--alwaysPack
--always-pack
-h, --help
```
@@ -131,7 +211,7 @@ Options:
### `backstage-cli create-github-app`
```
Usage: program [options] <github-org>
Usage: backstage-cli create-github-app <github-org>
Options:
-h, --help
@@ -213,16 +293,16 @@ Options:
### `backstage-cli new`
```
Usage: program [options]
Usage: backstage-cli new
Options:
--baseVersion <version>
--license <license>
--no-private
--npm-registry <URL>
--option <name>=<value>
--scope <scope>
--select <name>
--base-version <string>
--license <string>
--npm-registry <string>
--option <string>
--private
--scope <string>
--select <string>
--skip-install
-h, --help
```
@@ -249,13 +329,13 @@ Commands:
### `backstage-cli package build`
```
Usage: program [options]
Usage: backstage-cli package build
Options:
--config <path>
--config <string>
--minify
--module-federation
--role <name>
--role <string>
--skip-build-dependencies
--stats
-h, --help
@@ -273,13 +353,13 @@ Options:
### `backstage-cli package lint`
```
Usage: program [options] [directories...]
Usage: backstage-cli package lint [directories...]
Options:
--fix
--format <format>
--max-warnings <number>
--output-file <path>
--format <string>
--max-warnings <string>
--output-file <string>
-h, --help
```
@@ -304,17 +384,17 @@ Options:
### `backstage-cli package start`
```
Usage: program [options]
Usage: backstage-cli package start
Options:
--check
--config <path>
--entrypoint <path>
--inspect [host]
--inspect-brk [host]
--link <path>
--require <path...>
--role <name>
--config <string>
--entrypoint <string>
--inspect <string>
--inspect-brk <string>
--link <string>
--require <string>
--role <string>
-h, --help
```
@@ -453,12 +533,12 @@ Commands:
### `backstage-cli repo build`
```
Usage: program [options] [command]
Usage: backstage-cli repo build
Options:
--all
--minify
--since <ref>
--since <string>
-h, --help
```
@@ -474,7 +554,7 @@ Options:
### `backstage-cli repo fix`
```
Usage: program [options]
Usage: backstage-cli repo fix
Options:
--check
@@ -485,23 +565,23 @@ Options:
### `backstage-cli repo lint`
```
Usage: program [options] [command]
Usage: backstage-cli repo lint
Options:
--fix
--format <format>
--max-warnings <number>
--output-file <path>
--since <ref>
--successCache
--successCacheDir <path>
--format <string>
--max-warnings <string>
--output-file <string>
--since <string>
--success-cache
--success-cache-dir <string>
-h, --help
```
### `backstage-cli repo list-deprecations`
```
Usage: program [options]
Usage: backstage-cli repo list-deprecations
Options:
--json
@@ -511,28 +591,28 @@ Options:
### `backstage-cli repo start`
```
Usage: program [options] [packageNameOrPath...]
Usage: backstage-cli repo start [packages...]
Options:
--config <path>
--inspect [host]
--inspect-brk [host]
--link <path>
--plugin <pluginId>
--require <path...>
--config <string>
--inspect <string>
--inspect-brk <string>
--link <string>
--plugin <string>
--require <string>
-h, --help
```
### `backstage-cli repo test`
```
Usage: program [options]
Usage: backstage-cli repo test
Options:
--jest-help
--since <ref>
--successCache
--successCacheDir <path>
--since <string>
--success-cache
--success-cache-dir <string>
-h, --help
```
@@ -575,11 +655,11 @@ Options:
### `backstage-cli versions:bump`
```
Usage: program [options]
Usage: backstage-cli versions:bump
Options:
--pattern <glob>
--release <version|next|main>
--pattern <string>
--release <string>
--skip-install
--skip-migrate
-h, --help
@@ -588,10 +668,10 @@ Options:
### `backstage-cli versions:migrate`
```
Usage: program [options]
Usage: backstage-cli versions:migrate
Options:
--pattern <glob>
--pattern <string>
--skip-code-changes
-h, --help
```
+7
View File
@@ -120,6 +120,7 @@
"postcss": "^8.1.0",
"postcss-import": "^16.1.0",
"process": "^0.11.10",
"proper-lockfile": "^4.1.2",
"raw-loader": "^4.0.2",
"react-dev-utils": "^12.0.0-next.60",
"react-refresh": "^0.17.0",
@@ -131,6 +132,7 @@
"rollup-plugin-postcss": "^4.0.0",
"rollup-pluginutils": "^2.8.2",
"semver": "^7.5.3",
"shell-quote": "^1.8.1",
"style-loader": "^3.3.1",
"sucrase": "^3.20.2",
"swc-loader": "^0.2.3",
@@ -174,9 +176,11 @@
"@types/jest": "^30.0.0",
"@types/node": "^22.13.14",
"@types/npm-packlist": "^3.0.0",
"@types/proper-lockfile": "^4",
"@types/recursive-readdir": "^2.2.0",
"@types/rollup-plugin-peer-deps-external": "^2.2.0",
"@types/rollup-plugin-postcss": "^3.1.4",
"@types/shell-quote": "^1.7.5",
"@types/svgo": "^2.6.2",
"@types/tar": "^6.1.1",
"@types/terser-webpack-plugin": "^5.0.4",
@@ -194,6 +198,9 @@
"webpack": "~5.105.0",
"webpack-dev-server": "^5.0.0"
},
"optionalDependencies": {
"keytar": "^7.9.0"
},
"peerDependencies": {
"@jest/environment-jsdom-abstract": "^30.0.0",
"@module-federation/enhanced": "^0.21.6",
+1
View File
@@ -28,5 +28,6 @@ import { CliInitializer } from './wiring/CliInitializer';
initializer.add(import('./modules/new'));
initializer.add(import('./modules/test'));
initializer.add(import('./modules/translations'));
initializer.add(import('./modules/auth'));
await initializer.run();
})();
@@ -0,0 +1,33 @@
/*
* 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 { CommandContext } from '../../../wiring/types';
import { getAllInstances } from '../lib/storage';
export default async ({ args, info }: CommandContext) => {
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`);
}
};
@@ -0,0 +1,383 @@
/*
* 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 { CommandContext } 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 }: CommandContext) => {
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);
}
}
@@ -0,0 +1,77 @@
/*
* 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 { CommandContext } 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 }: CommandContext) => {
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');
};
@@ -0,0 +1,54 @@
/*
* 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 { CommandContext } from '../../../wiring/types';
import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth';
import { getSelectedInstance } from '../lib/storage';
import { getSecretStore } from '../lib/secretStore';
export default async ({ args, info }: CommandContext) => {
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`);
};
@@ -0,0 +1,43 @@
/*
* 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 { CommandContext } from '../../../wiring/types';
import { setSelectedInstance } from '../lib/storage';
import { pickInstance } from '../lib/prompt';
export default async ({ args, info }: CommandContext) => {
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`);
};
@@ -0,0 +1,72 @@
/*
* 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 { CommandContext } 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 }: CommandContext) => {
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`);
}
};
+53
View File
@@ -0,0 +1,53 @@
/*
* 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 { createCliPlugin } from '../../wiring/factory';
export default createCliPlugin({
pluginId: 'auth',
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') },
});
},
});
@@ -0,0 +1,336 @@
/*
* 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');
});
});
});
+84
View File
@@ -0,0 +1,84 @@
/*
* 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;
});
}
@@ -0,0 +1,269 @@
/*
* 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);
});
});
});
+40
View File
@@ -0,0 +1,40 @@
/*
* 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;
}
@@ -0,0 +1,65 @@
/*
* 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();
});
});
@@ -0,0 +1,98 @@
/*
* 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()));
},
};
}
@@ -0,0 +1,178 @@
/*
* 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);
});
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
* 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);
}
@@ -0,0 +1,191 @@
/*
* 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',
}),
]);
});
});
});
@@ -0,0 +1,58 @@
/*
* 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;
}
@@ -0,0 +1,250 @@
/*
* 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);
});
});
});
@@ -0,0 +1,110 @@
/*
* 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;
}
@@ -0,0 +1,420 @@
/*
* 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);
});
});
});
@@ -0,0 +1,177 @@
/*
* 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();
}
}
@@ -15,20 +15,51 @@
*/
import fs from 'fs-extra';
import { cli } from 'cleye';
import { createDistWorkspace } from '../lib/packager';
import type { CommandContext } from '../../../wiring/types';
type Options = {
alwaysPack?: boolean;
};
export default async ({ args, info }: CommandContext) => {
// 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...]` },
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;
export default async (dir: string, packages: string[], options: Options) => {
if (!(await fs.pathExists(dir))) {
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
}
await createDistWorkspace(packages, {
targetDir: dir,
alwaysPack: options.alwaysPack,
alwaysPack,
enableFeatureDetection: true,
});
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import fs from 'fs-extra';
import { buildPackage, Output } from '../../../lib/builder';
import { findRoleFromCommand } from '../../../lib/role';
@@ -29,40 +29,90 @@ import { buildFrontend } from '../../../lib/buildFrontend';
import { buildBackend } from '../../../lib/buildBackend';
import { isValidUrl } from '../../../lib/urls';
import chalk from 'chalk';
import type { CommandContext } from '../../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: {
role,
minify,
skipBuildDependencies,
stats,
config,
moduleFederation,
},
} = cli(
{
help: info,
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,
);
export async function command(opts: OptionValues): Promise<void> {
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
const role = await findRoleFromCommand(opts);
const resolvedRole = await findRoleFromCommand({ role });
if (role === 'frontend' || role === 'backend') {
const configPaths = (opts.config as string[]).map(arg => {
if (resolvedRole === 'frontend' || resolvedRole === 'backend') {
const configPaths = config.map(arg => {
if (isValidUrl(arg)) {
return arg;
}
return targetPaths.resolve(arg);
});
if (role === 'frontend') {
if (resolvedRole === 'frontend') {
return buildFrontend({
targetDir: targetPaths.dir,
configPaths,
writeStats: Boolean(opts.stats),
writeStats: Boolean(stats),
webpack,
});
}
return buildBackend({
targetDir: targetPaths.dir,
configPaths,
skipBuildDependencies: Boolean(opts.skipBuildDependencies),
minify: Boolean(opts.minify),
skipBuildDependencies: Boolean(skipBuildDependencies),
minify: Boolean(minify),
});
}
let isModuleFederationRemote: boolean | undefined = undefined;
if ((role as string) === 'frontend-dynamic-container') {
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.`,
@@ -70,7 +120,7 @@ export async function command(opts: OptionValues): Promise<void> {
);
isModuleFederationRemote = true;
}
if (opts.moduleFederation) {
if (moduleFederation) {
isModuleFederationRemote = true;
}
@@ -79,13 +129,13 @@ export async function command(opts: OptionValues): Promise<void> {
return buildFrontend({
targetDir: targetPaths.dir,
configPaths: [],
writeStats: Boolean(opts.stats),
writeStats: Boolean(stats),
isModuleFederationRemote,
webpack,
});
}
const roleInfo = PackageRoles.getRoleInfo(role);
const roleInfo = PackageRoles.getRoleInfo(resolvedRole);
const outputs = new Set<Output>();
@@ -106,7 +156,7 @@ export async function command(opts: OptionValues): Promise<void> {
return buildPackage({
outputs,
packageJson,
minify: Boolean(opts.minify),
minify: Boolean(minify),
workspacePackages: await PackageGraph.listTargetPackages(),
});
}
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { command } from './command';
export { default } from './command';
@@ -14,22 +14,80 @@
* limitations under the License.
*/
import { OptionValues } from 'commander';
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 { CommandContext } from '../../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: {
config,
role,
check,
require: requirePath,
link,
entrypoint,
inspect,
inspectBrk,
},
} = cli(
{
help: info,
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,
);
export async function command(opts: OptionValues): Promise<void> {
await startPackage({
role: await findRoleFromCommand(opts),
entrypoint: opts.entrypoint,
role: await findRoleFromCommand({ role }),
entrypoint,
targetDir: targetPaths.dir,
configPaths: opts.config as string[],
checksEnabled: Boolean(opts.check),
linkedWorkspace: await resolveLinkedWorkspace(opts.link),
inspectEnabled: opts.inspect,
inspectBrkEnabled: opts.inspectBrk,
require: opts.require,
configPaths: config,
checksEnabled: Boolean(check),
linkedWorkspace: await resolveLinkedWorkspace(link),
inspectEnabled: inspect || (inspect === '' ? true : undefined),
inspectBrkEnabled: inspectBrk || (inspectBrk === '' ? true : undefined),
require: requirePath,
});
}
};
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { command } from './command';
export { default } from './command';
@@ -15,7 +15,7 @@
*/
import chalk from 'chalk';
import { Command, OptionValues } from 'commander';
import { cli } from 'cleye';
import { relative as relativePath } from 'node:path';
import { buildPackages, getOutputsForRole } from '../../lib/builder';
import { targetPaths } from '@backstage/cli-common';
@@ -29,18 +29,46 @@ import {
import { buildFrontend } from '../../lib/buildFrontend';
import { buildBackend } from '../../lib/buildBackend';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { all, since, minify },
} = cli(
{
help: info,
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,
);
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
let packages = await PackageGraph.listTargetPackages();
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
if (opts.since) {
if (since) {
const graph = PackageGraph.fromPackages(packages);
const changedPackages = await graph.listChangedPackages({
ref: opts.since,
ref: since,
analyzeLockfile: true,
});
const withDevDependents = graph.collectPackageNames(
@@ -53,7 +81,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const apps = new Array<BackstagePackage>();
const backends = new Array<BackstagePackage>();
const parseBuildScript = createScriptOptionsParser(cmd, ['package', 'build']);
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 =
@@ -92,14 +127,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
outputs,
logPrefix: `${chalk.cyan(relativePath(targetPaths.rootDir, pkg.dir))}: `,
workspacePackages: packages,
minify: opts.minify ?? buildOptions.minify,
minify: minify ?? Boolean(buildOptions.minify),
};
});
console.log('Building packages');
await buildPackages(options);
if (opts.all) {
if (all) {
console.log('Building apps');
await runConcurrentTasks({
items: apps,
@@ -112,9 +147,12 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
);
return;
}
const configPaths = buildOptions.config;
await buildFrontend({
targetDir: pkg.dir,
configPaths: (buildOptions.config as string[]) ?? [],
configPaths: Array.isArray(configPaths)
? (configPaths as string[])
: [],
writeStats: Boolean(buildOptions.stats),
webpack,
});
@@ -136,9 +174,9 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
await buildBackend({
targetDir: pkg.dir,
skipBuildDependencies: true,
minify: opts.minify ?? buildOptions.minify,
minify: minify ?? Boolean(buildOptions.minify),
});
},
});
}
}
};
@@ -21,10 +21,12 @@ import {
} 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 { CommandContext } from '../../../../wiring/types';
const ACCEPTED_PACKAGE_ROLES: Array<PackageRole | undefined> = [
'frontend',
@@ -33,19 +35,61 @@ const ACCEPTED_PACKAGE_ROLES: Array<PackageRole | undefined> = [
'backend-plugin',
];
type CommandOptions = {
plugin: string[];
config: string[];
inspect?: boolean | string;
inspectBrk?: boolean | string;
require?: string;
link?: string;
};
export default async ({ args, info }: CommandContext) => {
const {
flags: { plugin, config, require: requirePath, link, inspect, inspectBrk },
_: namesOrPaths,
} = cli(
{
help: { ...info, usage: `${info.usage} [packages...]` },
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,
);
export async function command(namesOrPaths: string[], options: CommandOptions) {
const targetPackages = await findTargetPackages(namesOrPaths, options.plugin);
const targetPackages = await findTargetPackages(namesOrPaths, plugin);
const packageOptions = await resolvePackageOptions(targetPackages, options);
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');
@@ -60,7 +104,7 @@ export async function command(namesOrPaths: string[], options: CommandOptions) {
// Each of these block until interrupted by user
await Promise.all(packageOptions.map(entry => startPackage(entry.options)));
}
};
export async function findTargetPackages(
namesOrPaths: string[],
@@ -165,6 +209,15 @@ export async function findTargetPackages(
);
}
type CommandOptions = {
plugin: string[];
config: string[];
inspect?: boolean | string;
inspectBrk?: boolean | string;
require?: string;
link?: string;
};
async function resolvePackageOptions(
targetPackages: BackstagePackage[],
options: CommandOptions,
+5 -182
View File
@@ -14,46 +14,7 @@
* limitations under the License.
*/
import { Command, Option } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
const configOption = [
'--config <path>',
'Config files to load instead of app-config.yaml',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
] as const;
export function registerPackageCommands(command: Command) {
command
.command('build')
.description('Build a package for production deployment or publishing')
.option('--role <name>', 'Run the command with an explicit package role')
.option(
'--minify',
'Minify the generated code. Does not apply to app package (app is minified by default).',
)
.option(
'--skip-build-dependencies',
'Skip the automatic building of local dependencies. Applies to backend packages only.',
)
.option(
'--stats',
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
)
.option(
'--config <path>',
'Config files to load instead of app-config.yaml. Applies to app packages only.',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
)
.option(
'--module-federation',
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
)
.action(lazy(() => import('./commands/package/build'), 'command'));
}
export const buildPlugin = createCliPlugin({
pluginId: 'build',
@@ -61,146 +22,26 @@ export const buildPlugin = createCliPlugin({
reg.addCommand({
path: ['package', 'build'],
description: 'Build a package for production deployment or publishing',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--role <name>',
'Run the command with an explicit package role',
)
.option(
'--minify',
'Minify the generated code. Does not apply to app package (app is minified by default).',
)
.option(
'--skip-build-dependencies',
'Skip the automatic building of local dependencies. Applies to backend packages only.',
)
.option(
'--stats',
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
)
.option(
'--config <path>',
'Config files to load instead of app-config.yaml. Applies to app packages only.',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
)
.option(
'--module-federation',
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
)
.action(lazy(() => import('./commands/package/build'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/build') },
});
reg.addCommand({
path: ['repo', 'build'],
description:
'Build packages in the project, excluding bundled app and backend packages.',
execute: async ({ args }) => {
const command = new Command();
// This command expect `package build` to be registered, as its used to parse
// individual plugins' package build scripts.
registerPackageCommands(command.command('package'));
const defaultCommand = command
.option(
'--all',
'Build all packages, including bundled app and backend packages.',
)
.option(
'--since <ref>',
'Only build packages and their dev dependents that changed since the specified ref',
)
.option(
'--minify',
'Minify the generated code. Does not apply to app package (app is minified by default).',
)
.action(lazy(() => import('./commands/repo/build'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/build') },
});
reg.addCommand({
path: ['package', 'start'],
description: 'Start a package for local development',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(...configOption)
.option(
'--role <name>',
'Run the command with an explicit package role',
)
.option('--check', 'Enable type checking and linting if available')
.option('--inspect [host]', 'Enable debugger in Node.js environments')
.option(
'--inspect-brk [host]',
'Enable debugger in Node.js environments, breaking before code starts',
)
.option(
'--require <path...>',
'Add a --require argument to the node process',
)
.option(
'--link <path>',
'Link an external workspace for module resolution',
)
.option(
'--entrypoint <path>',
'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"',
)
.action(lazy(() => import('./commands/package/start'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/start') },
});
reg.addCommand({
path: ['repo', 'start'],
description: 'Starts packages in the repo for local development',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.argument(
'[packageNameOrPath...]',
'Run the specified package instead of the defaults.',
)
.option(
'--plugin <pluginId>',
'Start the dev entry-point for any matching plugin package in the repo',
(opt: string, opts: string[]) => (opts ? [...opts, opt] : [opt]),
Array<string>(),
)
.option(...configOption)
.option(
'--inspect [host]',
'Enable debugger in Node.js environments. Applies to backend package only',
)
.option(
'--inspect-brk [host]',
'Enable debugger in Node.js environments, breaking before code starts. Applies to backend package only',
)
.option(
'--require <path...>',
'Add a --require argument to the node process. Applies to backend package only',
)
.option(
'--link <path>',
'Link an external workspace for module resolution',
)
.action(
lazy(() => import('../build/commands/repo/start'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/start') },
});
reg.addCommand({
@@ -239,25 +80,7 @@ export const buildPlugin = createCliPlugin({
path: ['build-workspace'],
description:
'Builds a temporary dist workspace from the provided packages',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.arguments('<workspace-dir> [packages...]')
.addOption(
new Option(
'--alwaysYarnPack',
'Alias for --alwaysPack for backwards compatibility.',
)
.implies({ alwaysPack: true })
.hideHelp(true),
)
.option(
'--alwaysPack',
'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)',
)
.action(lazy(() => import('./commands/buildWorkspace'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/buildWorkspace') },
});
},
});
@@ -13,34 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { parseArgs, type ParseArgsConfig } from 'node:util';
import { parse as parseShellArgs } from 'shell-quote';
export function createScriptOptionsParser(
anyCmd: Command,
commandPath: string[],
options: ParseArgsConfig['options'],
) {
// Regardless of what command instance is passed in we want to find
// the root command and resolve the path from there
let rootCmd = anyCmd;
while (rootCmd.parent) {
rootCmd = rootCmd.parent;
}
// Now find the command that was requested
let targetCmd = rootCmd as Command | undefined;
for (const name of commandPath) {
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
| Command
| undefined;
}
if (!targetCmd) {
throw new Error(
`Could not find package command '${commandPath.join(' ')}'`,
);
}
const cmd = targetCmd;
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
@@ -49,22 +28,13 @@ export function createScriptOptionsParser(
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
const args = argsStr
? parseShellArgs(argsStr).filter(
(e): e is string => typeof e === 'string',
)
: [];
// Can't clone or copy or even use commands as prototype, so we mutate
// the necessary members instead, and then reset them once we're done
const currentOpts = (cmd as any)._optionValues;
const currentStore = (cmd as any)._storeOptionsAsProperties;
const result: Record<string, any> = {};
(cmd as any)._storeOptionsAsProperties = false;
(cmd as any)._optionValues = result;
// Triggers the writing of options to the result object
cmd.parseOptions(argsStr.split(' '));
(cmd as any)._optionValues = currentOpts;
(cmd as any)._storeOptionsAsProperties = currentStore;
return result;
const { values } = parseArgs({ args, strict: false, options });
return values;
};
}
@@ -16,20 +16,12 @@
import { createMockDirectory } from '@backstage/backend-test-utils';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import { Command } from 'commander';
import { findRoleFromCommand } from './role';
const mockDir = createMockDirectory();
overrideTargetPaths(mockDir.path);
describe('findRoleFromCommand', () => {
function mkCommand(args?: string) {
const parsed = new Command()
.option('--role <role>', 'test role')
.parse(args?.split(' ') ?? [], { from: 'user' });
return parsed.opts();
}
beforeEach(() => {
mockDir.setContent({
'package.json': JSON.stringify({
@@ -42,16 +34,14 @@ describe('findRoleFromCommand', () => {
});
it('provides role info by role', async () => {
await expect(findRoleFromCommand(mkCommand())).resolves.toEqual(
'web-library',
);
await expect(findRoleFromCommand({})).resolves.toEqual('web-library');
await expect(
findRoleFromCommand(mkCommand('--role node-library')),
findRoleFromCommand({ role: 'node-library' }),
).resolves.toEqual('node-library');
await expect(
findRoleFromCommand(mkCommand('--role invalid')),
).rejects.toThrow(`Unknown package role 'invalid'`);
await expect(findRoleFromCommand({ role: 'invalid' })).rejects.toThrow(
`Unknown package role 'invalid'`,
);
});
});
+4 -5
View File
@@ -15,16 +15,15 @@
*/
import fs from 'fs-extra';
import { OptionValues } from 'commander';
import { targetPaths } from '@backstage/cli-common';
import { PackageRoles, PackageRole } from '@backstage/cli-node';
export async function findRoleFromCommand(
opts: OptionValues,
): Promise<PackageRole> {
export async function findRoleFromCommand(opts: {
role?: string;
}): Promise<PackageRole> {
if (opts.role) {
return PackageRoles.getRoleInfo(opts.role)?.role;
return PackageRoles.getRoleInfo(opts.role).role;
}
const pkg = await fs.readJson(targetPaths.resolve('package.json'));
@@ -19,14 +19,27 @@ import chalk from 'chalk';
import { stringify as stringifyYaml } from 'yaml';
import inquirer, { Question, Answers } from 'inquirer';
import { targetPaths } from '@backstage/cli-common';
import { cli } from 'cleye';
import { GithubCreateAppServer } from './GithubCreateAppServer';
import openBrowser from 'react-dev-utils/openBrowser';
import type { CommandContext } from '../../../../wiring/types';
// This is an experimental command that at this point does not support GitHub Enterprise
// due to lacking support for creating apps from manifests.
// https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app-from-a-manifest
export default async (org: string) => {
export default async ({ args, info }: CommandContext) => {
const { _: positionals } = cli(
{
help: { ...info, usage: `${info.usage} <github-org>` },
parameters: ['<github-org>'],
},
undefined,
args,
);
const org = positionals[0];
const answers: Answers = await inquirer.prompt({
name: 'appType',
type: 'checkbox',
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'new',
@@ -23,16 +21,7 @@ export default createCliPlugin({
reg.addCommand({
path: ['create-github-app'],
description: 'Create new GitHub App in your organization.',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.argument('<github-org>')
.action(
lazy(() => import('./commands/create-github-app'), 'default'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/create-github-app') },
});
},
});
@@ -15,15 +15,47 @@
*/
import fs from 'fs-extra';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import { ESLint } from 'eslint';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { fix, format, outputFile, maxWarnings },
_: directories,
} = cli(
{
help: { ...info, usage: `${info.usage} [directories...]` },
parameters: ['[directories...]'],
flags: {
fix: {
type: Boolean,
description: 'Attempt to automatically fix violations',
},
format: {
type: String,
description: 'Lint report output format',
default: 'eslint-formatter-friendly',
},
outputFile: {
type: String,
description: 'Write the lint report to a file instead of stdout',
},
maxWarnings: {
type: String,
description:
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
},
},
},
undefined,
args,
);
export default async (directories: string[], opts: OptionValues) => {
const eslint = new ESLint({
cwd: targetPaths.dir,
fix: opts.fix,
fix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
@@ -31,31 +63,31 @@ export default async (directories: string[], opts: OptionValues) => {
directories.length ? directories : ['.'],
);
const maxWarnings = opts.maxWarnings ?? -1;
const ignoreWarnings = +maxWarnings === -1;
const maxWarningsNum = maxWarnings ? +maxWarnings : -1;
const ignoreWarnings = maxWarningsNum === -1;
const failed =
results.some(r => r.errorCount > 0) ||
(!ignoreWarnings &&
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarnings);
maxWarningsNum);
if (opts.fix) {
if (fix) {
await ESLint.outputFixes(results);
}
const formatter = await eslint.loadFormatter(opts.format);
const formatter = await eslint.loadFormatter(format);
// This formatter uses the cwd to format file paths, so let's have that happen from the root instead
if (opts.format === 'eslint-formatter-friendly') {
if (format === 'eslint-formatter-friendly') {
process.chdir(targetPaths.rootDir);
}
const resultText = await formatter.format(results);
if (resultText) {
if (opts.outputFile) {
await fs.writeFile(targetPaths.resolve(opts.outputFile), resultText);
if (outputFile) {
await fs.writeFile(targetPaths.resolve(outputFile), resultText);
} else {
console.log(resultText);
}
@@ -16,7 +16,7 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import { Command, OptionValues } from 'commander';
import { cli } from 'cleye';
import { createHash } from 'node:crypto';
import { relative as relativePath } from 'node:path';
import {
@@ -29,6 +29,7 @@ import {
import { targetPaths } from '@backstage/cli-common';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import type { CommandContext } from '../../../../wiring/types';
function depCount(pkg: BackstagePackageJson) {
const deps = pkg.dependencies ? Object.keys(pkg.dependencies).length : 0;
@@ -38,24 +39,90 @@ function depCount(pkg: BackstagePackageJson) {
return deps + devDeps;
}
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
export default async ({ args, info }: CommandContext) => {
for (const flag of [
'outputFile',
'successCache',
'successCacheDir',
'maxWarnings',
]) {
if (args.some(a => a === `--${flag}` || a.startsWith(`--${flag}=`))) {
process.stderr.write(
`DEPRECATION WARNING: --${flag} is deprecated, use the kebab-case form instead\n`,
);
}
}
const {
flags: {
fix,
format,
outputFile,
successCache: useSuccessCache,
successCacheDir,
since,
maxWarnings,
},
} = cli(
{
help: info,
flags: {
fix: {
type: Boolean,
description: 'Attempt to automatically fix violations',
},
format: {
type: String,
description: 'Lint report output format',
default: 'eslint-formatter-friendly',
},
outputFile: {
type: String,
description: 'Write the lint report to a file instead of stdout',
},
successCache: {
type: Boolean,
description:
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
},
successCacheDir: {
type: String,
description:
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
},
since: {
type: String,
description:
'Only lint packages that changed since the specified ref',
},
maxWarnings: {
type: String,
description:
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
},
},
},
undefined,
args,
);
let packages = await PackageGraph.listTargetPackages();
const cache = SuccessCache.create({
name: 'lint',
basePath: opts.successCacheDir,
basePath: successCacheDir,
});
const cacheContext = opts.successCache
const cacheContext = useSuccessCache
? {
entries: await cache.read(),
lockfile: await Lockfile.load(targetPaths.resolveRoot('yarn.lock')),
}
: undefined;
if (opts.since) {
if (since) {
const graph = PackageGraph.fromPackages(packages);
packages = await graph.listChangedPackages({
ref: opts.since,
ref: since,
analyzeLockfile: true,
});
}
@@ -65,7 +132,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
packages.sort((a, b) => depCount(b.packageJson) - depCount(a.packageJson));
// This formatter uses the cwd to format file paths, so let's have that happen from the root instead
if (opts.format === 'eslint-formatter-friendly') {
if (format === 'eslint-formatter-friendly') {
process.chdir(targetPaths.rootDir);
}
@@ -74,7 +141,12 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
process.env.FORCE_COLOR = '1';
}
const parseLintScript = createScriptOptionsParser(cmd, ['package', 'lint']);
const parseLintScript = createScriptOptionsParser(['package', 'lint'], {
fix: { type: 'boolean' },
format: { type: 'string' },
'output-file': { type: 'string' },
'max-warnings': { type: 'string' },
});
const items = await Promise.all(
packages.map(async pkg => {
@@ -112,20 +184,20 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const { results: resultsList } = await runWorkerQueueThreads({
items: items.filter(item => item.lintOptions), // Filter out packages without lint script
context: {
fix: Boolean(opts.fix),
format: opts.format as string | undefined,
fix: Boolean(fix),
format: format as string | undefined,
shouldCache: Boolean(cacheContext),
maxWarnings: opts.maxWarnings ?? -1,
maxWarnings: maxWarnings ?? '-1',
successCache: cacheContext?.entries,
rootDir: targetPaths.rootDir,
},
workerFactory: async ({
fix,
format,
fix: workerFix,
format: workerFormat,
shouldCache,
successCache,
rootDir,
maxWarnings,
maxWarnings: workerMaxWarnings,
}) => {
const { ESLint } = require('eslint') as typeof import('eslint');
const crypto = require('node:crypto') as typeof import('crypto');
@@ -151,7 +223,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const start = Date.now();
const eslint = new ESLint({
cwd: fullDir,
fix,
fix: workerFix,
extensions: ['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'],
});
@@ -192,7 +264,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
}
const formatter = await eslint.loadFormatter(format);
const formatter = await eslint.loadFormatter(workerFormat);
const results = await eslint.lintFiles(['.']);
@@ -200,18 +272,18 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
const time = ((Date.now() - start) / 1000).toFixed(2);
console.log(`Checked ${count} files in ${relativeDir} ${time}s`);
if (fix) {
if (workerFix) {
await ESLint.outputFixes(results);
}
const ignoreWarnings = +maxWarnings === -1;
const ignoreWarnings = +workerMaxWarnings === -1;
const resultText = formatter.format(results) as string;
const failed =
results.some(r => r.errorCount > 0) ||
(!ignoreWarnings &&
results.reduce((current, next) => current + next.warningCount, 0) >
maxWarnings);
+workerMaxWarnings);
return {
relativeDir,
@@ -242,8 +314,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
// When doing repo lint, only list the results if the lint failed to avoid a log
// dump of all warnings that might be irrelevant
if (resultText) {
if (opts.outputFile) {
if (opts.format === 'json') {
if (outputFile) {
if (format === 'json') {
jsonResults.push(resultText);
} else {
errorOutput += `${resultText}\n`;
@@ -258,7 +330,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
}
if (opts.format === 'json') {
if (format === 'json') {
let mergedJsonResults: any[] = [];
for (const jsonResult of jsonResults) {
mergedJsonResults = mergedJsonResults.concat(JSON.parse(jsonResult));
@@ -266,8 +338,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
errorOutput = JSON.stringify(mergedJsonResults, null, 2);
}
if (opts.outputFile && errorOutput) {
await fs.writeFile(targetPaths.resolveRoot(opts.outputFile), errorOutput);
if (outputFile && errorOutput) {
await fs.writeFile(targetPaths.resolveRoot(outputFile), errorOutput);
}
if (cacheContext) {
@@ -277,4 +349,4 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
if (failed) {
process.exit(1);
}
}
};
+2 -64
View File
@@ -14,28 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export function registerPackageLintCommand(command: Command) {
command.arguments('[directories...]');
command.option('--fix', 'Attempt to automatically fix violations');
command.option(
'--format <format>',
'Lint report output format',
'eslint-formatter-friendly',
);
command.option(
'--output-file <path>',
'Write the lint report to a file instead of stdout',
);
command.option(
'--max-warnings <number>',
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
);
command.description('Lint a package');
command.action(lazy(() => import('./commands/package/lint'), 'default'));
}
export default createCliPlugin({
pluginId: 'lint',
@@ -43,53 +21,13 @@ export default createCliPlugin({
reg.addCommand({
path: ['package', 'lint'],
description: 'Lint a package',
execute: async ({ args }) => {
const command = new Command();
registerPackageLintCommand(command);
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/lint') },
});
reg.addCommand({
path: ['repo', 'lint'],
description: 'Lint a repository',
execute: async ({ args }) => {
const command = new Command();
registerPackageLintCommand(command.command('package').command('lint'));
command.option('--fix', 'Attempt to automatically fix violations');
command.option(
'--format <format>',
'Lint report output format',
'eslint-formatter-friendly',
);
command.option(
'--output-file <path>',
'Write the lint report to a file instead of stdout',
);
command.option(
'--successCache',
'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run',
);
command.option(
'--successCacheDir <path>',
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
);
command.option(
'--since <ref>',
'Only lint packages that changed since the specified ref',
);
command.option(
'--max-warnings <number>',
'Fail if more than this number of warnings. -1 allows warnings. (default: -1)',
);
command.description('Lint a repository');
command.action(lazy(() => import('./commands/repo/lint'), 'command'));
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/lint') },
});
},
});
@@ -13,34 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { parseArgs, type ParseArgsConfig } from 'node:util';
import { parse as parseShellArgs } from 'shell-quote';
export function createScriptOptionsParser(
anyCmd: Command,
commandPath: string[],
options: ParseArgsConfig['options'],
) {
// Regardless of what command instance is passed in we want to find
// the root command and resolve the path from there
let rootCmd = anyCmd;
while (rootCmd.parent) {
rootCmd = rootCmd.parent;
}
// Now find the command that was requested
let targetCmd = rootCmd as Command | undefined;
for (const name of commandPath) {
targetCmd = targetCmd?.commands.find(c => c.name() === name) as
| Command
| undefined;
}
if (!targetCmd) {
throw new Error(
`Could not find package command '${commandPath.join(' ')}'`,
);
}
const cmd = targetCmd;
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
@@ -49,22 +28,13 @@ export function createScriptOptionsParser(
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
const args = argsStr
? parseShellArgs(argsStr).filter(
(e): e is string => typeof e === 'string',
)
: [];
// Can't clone or copy or even use commands as prototype, so we mutate
// the necessary members instead, and then reset them once we're done
const currentOpts = (cmd as any)._optionValues;
const currentStore = (cmd as any)._storeOptionsAsProperties;
const result: Record<string, any> = {};
(cmd as any)._storeOptionsAsProperties = false;
(cmd as any)._optionValues = result;
// Triggers the writing of options to the result object
cmd.parseOptions(argsStr.split(' '));
(cmd as any)._optionValues = currentOpts;
(cmd as any)._storeOptionsAsProperties = currentStore;
return result;
const { values } = parseArgs({ args, strict: false, options });
return values;
};
}
@@ -21,7 +21,7 @@ import {
PackageRole,
PackageRoles,
} from '@backstage/cli-node';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import fs from 'fs-extra';
import {
resolve as resolvePath,
@@ -492,14 +492,38 @@ export function fixPeerModules(pkg: FixablePackage) {
type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void;
export async function command(opts: OptionValues): Promise<void> {
export default async ({
args,
info,
}: import('../../../../wiring/types').CommandContext) => {
const {
flags: { publish, check },
} = cli(
{
help: info,
flags: {
publish: {
type: Boolean,
description:
'Enable additional fixes that only apply when publishing packages',
},
check: {
type: Boolean,
description:
'Fail if any packages would have been changed by the command',
},
},
},
undefined,
args,
);
const packages = await readFixablePackages();
const fixRepositoryField = createRepositoryFieldFixer();
const fixers: PackageFixer[] = [fixPackageExports, fixSideEffects];
// Fixers that only apply to repos that publish packages
if (opts.publish) {
if (publish) {
fixers.push(
fixRepositoryField,
fixPluginId,
@@ -514,11 +538,11 @@ export async function command(opts: OptionValues): Promise<void> {
}
}
if (opts.check) {
if (check) {
if (printPackageFixHint(packages)) {
process.exit(1);
}
} else {
await writeFixedPackages(packages);
}
}
};
@@ -16,12 +16,26 @@
import chalk from 'chalk';
import { ESLint } from 'eslint';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { relative as relativePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
export default async ({ args, info }: CommandContext) => {
const {
flags: { json },
} = cli(
{
help: info,
flags: {
json: { type: Boolean, description: 'Output as JSON' },
},
},
undefined,
args,
);
export async function command(opts: OptionValues) {
const packages = await PackageGraph.listTargetPackages();
const eslint = new ESLint({
@@ -74,7 +88,7 @@ export async function command(opts: OptionValues) {
stderr.cursorTo(0);
}
if (opts.json) {
if (json) {
console.log(JSON.stringify(deprecations, null, 2));
} else {
for (const d of deprecations) {
@@ -87,4 +101,4 @@ export async function command(opts: OptionValues) {
if (deprecations.length > 0) {
process.exit(1);
}
}
};
+2 -27
View File
@@ -13,9 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Command } from 'commander';
import { createCliPlugin } from '../../wiring/factory';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'maintenance',
@@ -23,36 +21,13 @@ export default createCliPlugin({
reg.addCommand({
path: ['repo', 'fix'],
description: 'Automatically fix packages in the project',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--publish',
'Enable additional fixes that only apply when publishing packages',
)
.option(
'--check',
'Fail if any packages would have been changed by the command',
)
.action(lazy(() => import('./commands/repo/fix'), 'command'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/fix') },
});
reg.addCommand({
path: ['repo', 'list-deprecations'],
description: 'List deprecations',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option('--json', 'Output as JSON')
.action(
lazy(() => import('./commands/repo/list-deprecations'), 'command'),
);
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/list-deprecations') },
});
},
});
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import fs from 'fs-extra';
import { Command } from 'commander';
import * as runObj from '@backstage/cli-common';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump';
@@ -126,6 +125,8 @@ const expectLogsToMatch = (
expect(receivedLogs.filter(Boolean).sort()).toEqual(expected.sort());
};
const info = { usage: 'backstage-cli versions:bump', description: '' };
describe('bump', () => {
const mockDir = createMockDirectory();
@@ -190,7 +191,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -283,11 +284,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({
pattern: null,
release: 'main',
skipInstall: true,
} as unknown as Command);
await bump({ args: ['--release', 'main', '--skip-install'], info });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -389,7 +386,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -493,7 +490,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -589,7 +586,7 @@ describe('bump', () => {
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await expect(
bump({ pattern: null, release: '999.0.1' } as unknown as Command),
bump({ args: ['--release', '999.0.1'], info }),
).rejects.toThrow('No release found for 999.0.1 version');
});
expect(logs.filter(Boolean)).toEqual([
@@ -694,7 +691,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'next' } as unknown as Command);
await bump({ args: ['--release', 'next'], info });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -774,9 +771,14 @@ describe('bump', () => {
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({
pattern: '@{backstage,backstage-extra}/*',
release: 'main',
} as any);
args: [
'--pattern',
'@{backstage,backstage-extra}/*',
'--release',
'main',
],
info,
});
});
expectLogsToMatch(logs, [
'Using custom pattern glob @{backstage,backstage-extra}/*',
@@ -882,7 +884,7 @@ describe('bump', () => {
),
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
'Using default pattern glob @backstage/*',
@@ -1121,7 +1123,7 @@ describe('environment variables', () => {
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
@@ -1195,7 +1197,7 @@ describe('environment variables', () => {
} as any);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
@@ -1280,7 +1282,7 @@ describe('environment variables', () => {
);
const { log: logs } = await withLogCollector(['log', 'warn'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
await bump({ args: ['--release', 'main'], info });
});
expectLogsToMatch(logs, [
@@ -1329,9 +1331,7 @@ describe('environment variables', () => {
},
});
await expect(
bump({ pattern: null, release: 'main' } as unknown as Command),
).rejects.toThrow();
await expect(bump({ args: ['--release', 'main'], info })).rejects.toThrow();
});
it('should handle network errors when using custom base URL', async () => {
@@ -1359,8 +1359,6 @@ describe('environment variables', () => {
),
);
await expect(
bump({ pattern: null, release: 'main' } as unknown as Command),
).rejects.toThrow();
await expect(bump({ args: ['--release', 'main'], info })).rejects.toThrow();
});
});
@@ -26,7 +26,7 @@ import fs from 'fs-extra';
import chalk from 'chalk';
import { minimatch } from 'minimatch';
import semver from 'semver';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { isError, NotFoundError } from '@backstage/errors';
import { resolve as resolvePath } from 'node:path';
@@ -48,6 +48,7 @@ import {
import { migrateMovedPackages } from './migrate';
import { runYarnInstall } from '../../lib/utils';
import { run } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
const DEP_TYPES = [
'dependencies',
@@ -73,12 +74,41 @@ function extendsDefaultPattern(pattern: string): boolean {
return minimatch('@backstage/', pattern.slice(0, -1));
}
export default async (opts: OptionValues) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { pattern: patternFlag, release, skipInstall, skipMigrate },
} = cli(
{
help: info,
flags: {
pattern: {
type: String,
description: 'Override glob for matching packages to upgrade',
},
release: {
type: String,
description: 'Bump to a specific Backstage release line or version',
default: 'main',
},
skipInstall: {
type: Boolean,
description: 'Skips yarn install step',
},
skipMigrate: {
type: Boolean,
description: 'Skips migration of any moved packages',
},
},
},
undefined,
args,
);
const lockfilePath = targetPaths.resolveRoot('yarn.lock');
const lockfile = await Lockfile.load(lockfilePath);
const yarnPluginEnabled = await hasBackstageYarnPlugin();
let pattern = opts.pattern;
let pattern = patternFlag;
if (!pattern) {
console.log(`Using default pattern glob ${DEFAULT_PATTERN_GLOB}`);
@@ -97,15 +127,15 @@ export default async (opts: OptionValues) => {
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else if (semver.valid(opts.release)) {
} else if (semver.valid(release)) {
// Specific release specified. Be strict when resolving versions
releaseManifest = await getManifestByVersion({ version: opts.release });
releaseManifest = await getManifestByVersion({ version: release! });
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else {
// Release line specified. Be lenient when resolving versions.
if (opts.release === 'next') {
if (release === 'next') {
const next = await getManifestByReleaseLine({
releaseLine: 'next',
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
@@ -120,12 +150,12 @@ export default async (opts: OptionValues) => {
: main;
} else {
releaseManifest = await getManifestByReleaseLine({
releaseLine: opts.release,
releaseLine: release!,
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
}
findTargetVersion = createVersionFinder({
releaseLine: opts.releaseLine,
releaseLine: release,
releaseManifest,
});
}
@@ -264,7 +294,7 @@ export default async (opts: OptionValues) => {
);
}
if (!opts.skipInstall) {
if (!skipInstall) {
await runYarnInstall();
} else {
console.log();
@@ -272,14 +302,14 @@ export default async (opts: OptionValues) => {
console.log(chalk.yellow(`Skipping yarn install`));
}
if (!opts.skipMigrate) {
if (!skipMigrate) {
console.log();
const changed = await migrateMovedPackages({
pattern: opts.pattern,
pattern: patternFlag,
});
if (changed && !opts.skipInstall) {
if (changed && !skipInstall) {
await runYarnInstall();
}
}
@@ -123,7 +123,7 @@ describe('versions:migrate', () => {
});
const { warn, log: logs } = await withLogCollector(async () => {
await migrate({});
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
});
expectLogsToMatch(logs, [
@@ -229,7 +229,7 @@ describe('versions:migrate', () => {
});
await withLogCollector(async () => {
await migrate({});
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
@@ -311,7 +311,7 @@ describe('versions:migrate', () => {
});
await withLogCollector(async () => {
await migrate({});
await migrate({ args: [], info: { usage: 'test', description: 'test' } });
});
expect(runObj.run).toHaveBeenCalledTimes(1);
@@ -16,11 +16,12 @@
import { BackstagePackageJson, PackageGraph } from '@backstage/cli-node';
import chalk from 'chalk';
import { resolve as resolvePath, join as joinPath } from 'node:path';
import { OptionValues } from 'commander';
import { cli } from 'cleye';
import { readJson, writeJson } from 'fs-extra';
import { minimatch } from 'minimatch';
import { runYarnInstall } from '../../lib/utils';
import replace from 'replace-in-file';
import type { CommandContext } from '../../../../wiring/types';
declare module 'replace-in-file' {
export default function (config: {
@@ -38,10 +39,30 @@ declare module 'replace-in-file' {
>;
}
export default async (options: OptionValues) => {
export default async ({ args, info }: CommandContext) => {
const {
flags: { pattern, skipCodeChanges },
} = cli(
{
help: info,
flags: {
pattern: {
type: String,
description: 'Override glob for matching packages to upgrade',
},
skipCodeChanges: {
type: Boolean,
description: 'Skip code changes and only update package.json files',
},
},
},
undefined,
args,
);
const changed = await migrateMovedPackages({
pattern: options.pattern,
skipCodeChanges: options.skipCodeChanges,
pattern,
skipCodeChanges,
});
if (changed) {
+2 -36
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'migrate',
@@ -24,45 +22,13 @@ export default createCliPlugin({
path: ['versions:migrate'],
description:
'Migrate any plugins that have been moved to the @backstage-community namespace automatically',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--pattern <glob>',
'Override glob for matching packages to upgrade',
)
.option(
'--skip-code-changes',
'Skip code changes and only update package.json files',
)
.action(lazy(() => import('./commands/versions/migrate'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/versions/migrate') },
});
reg.addCommand({
path: ['versions:bump'],
description: 'Bump Backstage packages to the latest versions',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.option(
'--pattern <glob>',
'Override glob for matching packages to upgrade',
)
.option(
'--release <version|next|main>',
'Bump to a specific Backstage release line or version',
'main',
)
.option('--skip-install', 'Skips yarn install step')
.option('--skip-migrate', 'Skips migration of any moved packages')
.action(lazy(() => import('./commands/versions/bump'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/versions/bump') },
});
reg.addCommand({
@@ -16,6 +16,7 @@
import { createNewPackage } from '../lib/createNewPackage';
import { default as newCommand } from './new';
import type { CommandContext } from '../../../wiring/types';
jest.mock('../lib/createNewPackage');
@@ -34,13 +35,21 @@ describe.each([
});
it(`should generate naming options for --scope=${scope}`, async () => {
await newCommand({ scope, option: [], skipInstall: false });
const args = ['--skip-install'];
if (scope) {
args.push('--scope', scope);
}
const context: CommandContext = {
args,
info: { usage: 'backstage-cli new', description: 'test' },
};
await newCommand(context);
expect(createNewPackage).toHaveBeenCalledWith(
expect.objectContaining({
configOverrides: {
configOverrides: expect.objectContaining({
packageNamePrefix: prefix,
packageNamePluginInfix: infix,
},
}),
}),
);
});
+72 -25
View File
@@ -14,28 +14,75 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { createNewPackage } from '../lib/createNewPackage';
import type { CommandContext } from '../../../wiring/types';
type ArgOptions = {
option: string[];
select?: string;
skipInstall: boolean;
private?: boolean;
npmRegistry?: string;
scope?: string;
license?: string;
baseVersion?: string;
};
export default async ({ args, info }: CommandContext) => {
for (const flag of ['skipInstall', 'npmRegistry', 'baseVersion']) {
if (args.some(a => a === `--${flag}` || a.startsWith(`--${flag}=`))) {
process.stderr.write(
`DEPRECATION WARNING: --${flag} is deprecated, use the kebab-case form instead\n`,
);
}
}
export default async (opts: ArgOptions) => {
const {
option: rawArgOptions,
select: preselectedTemplateId,
skipInstall,
scope,
private: isPrivate,
...otherGlobals
} = opts;
flags: {
select,
option: rawArgOptions,
skipInstall,
scope,
npmRegistry,
baseVersion,
license,
private: isPrivate,
},
} = cli(
{
help: info,
flags: {
select: {
type: String,
description: 'Select the thing you want to be creating upfront',
},
option: {
type: [String] as const,
description: 'Pre-fill options for the creation process',
default: [] as string[],
},
skipInstall: {
type: Boolean,
description: `Skips running 'yarn install' and 'yarn lint --fix'`,
},
scope: {
type: String,
description: 'The scope to use for new packages',
},
npmRegistry: {
type: String,
description: 'The package registry to use for new packages',
},
baseVersion: {
type: String,
description:
'The version to use for any new packages (default: 0.1.0)',
},
license: {
type: String,
description:
'The license to use for any new packages (default: Apache-2.0)',
},
private: {
type: Boolean,
description: 'Mark new packages as private',
default: true,
},
},
},
undefined,
args,
);
const prefilledParams = parseParams(rawArgOptions);
@@ -50,8 +97,8 @@ export default async (opts: ArgOptions) => {
}
if (
isPrivate === false || // set to false with --no-private flag
Object.values(otherGlobals).filter(Boolean).length !== 0
isPrivate === false ||
[npmRegistry, baseVersion, license].filter(Boolean).length !== 0
) {
console.warn(
`Global template configuration via CLI flags is deprecated, see https://backstage.io/docs/cli/new for information on how to configure package templating`,
@@ -60,16 +107,16 @@ export default async (opts: ArgOptions) => {
await createNewPackage({
prefilledParams,
preselectedTemplateId,
preselectedTemplateId: select,
configOverrides: {
license: otherGlobals.license,
version: otherGlobals.baseVersion,
license,
version: baseVersion,
private: isPrivate,
publishRegistry: otherGlobals.npmRegistry,
publishRegistry: npmRegistry,
packageNamePrefix: packagePrefix,
packageNamePluginInfix: pluginInfix,
},
skipInstall,
skipInstall: Boolean(skipInstall),
});
};
+1 -41
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
import { NotImplementedError } from '@backstage/errors';
export default createCliPlugin({
@@ -25,45 +23,7 @@ export default createCliPlugin({
path: ['new'],
description:
'Open up an interactive guide to creating new things in your app',
execute: async ({ args }) => {
const command = new Command();
const defaultCommand = command
.storeOptionsAsProperties(false)
.description(
'Open up an interactive guide to creating new things in your app',
)
.option(
'--select <name>',
'Select the thing you want to be creating upfront',
)
.option(
'--option <name>=<value>',
'Pre-fill options for the creation process',
(opt, arr: string[]) => [...arr, opt],
[],
)
.option(
'--skip-install',
`Skips running 'yarn install' and 'yarn lint --fix'`,
)
.option('--scope <scope>', 'The scope to use for new packages')
.option(
'--npm-registry <URL>',
'The package registry to use for new packages',
)
.option(
'--baseVersion <version>',
'The version to use for any new packages (default: 0.1.0)',
)
.option(
'--license <license>',
'The license to use for any new packages (default: Apache-2.0)',
)
.option('--no-private', 'Do not mark new packages as private')
.action(lazy(() => import('./commands/new'), 'default'));
await defaultCommand.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/new') },
});
reg.addCommand({
@@ -14,9 +14,8 @@
* limitations under the License.
*/
import { Command, OptionValues } from 'commander';
import { runCheck, findOwnPaths } from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
function includesAnyOf(hayStack: string[], ...needles: string[]) {
for (const needle of needles) {
@@ -27,15 +26,7 @@ function includesAnyOf(hayStack: string[], ...needles: string[]) {
return false;
}
export default async (_opts: OptionValues, cmd: Command) => {
// all args are forwarded to jest
let parent = cmd;
while (parent.parent) {
parent = parent.parent;
}
const allArgs = parent.args as string[];
const args = allArgs.slice(allArgs.indexOf('test') + 1);
export default async ({ args }: CommandContext) => {
// Only include our config if caller isn't passing their own config
if (!includesAnyOf(args, '-c', '--config')) {
/* eslint-disable-next-line no-restricted-syntax */
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { cli } from 'cleye';
import { createFlagFinder } from './test';
describe('createFlagFinder', () => {
@@ -45,3 +46,58 @@ describe('createFlagFinder', () => {
expect(find('--qux')).toBe(true);
});
});
describe('repo test arg forwarding', () => {
// Mirrors the cleye configuration used in the repo test command handler
function parseRepoTestArgs(args: string[]) {
return cli(
{
help: false,
flags: {
since: { type: String },
successCache: { type: Boolean },
successCacheDir: { type: String },
jestHelp: { type: Boolean },
},
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
},
undefined,
args,
);
}
it('strips Backstage flags from args while preserving Jest flags and arguments', () => {
const args = [
'--since',
'main',
'--success-cache',
'--coverage',
'--watch',
'path/to/test',
];
const { flags } = parseRepoTestArgs(args);
expect(flags.since).toBe('main');
expect(flags.successCache).toBe(true);
expect(args).toEqual(['--coverage', '--watch', 'path/to/test']);
});
it('supports legacy camelCase flag names', () => {
const args = ['--successCache', '--successCacheDir', '/tmp/cache'];
const { flags } = parseRepoTestArgs(args);
expect(flags.successCache).toBe(true);
expect(flags.successCacheDir).toBe('/tmp/cache');
expect(args).toEqual([]);
});
it('leaves args untouched when no Backstage flags are present', () => {
const args = ['--coverage', '--verbose', '--bail'];
parseRepoTestArgs(args);
expect(args).toEqual(['--coverage', '--verbose', '--bail']);
});
});
@@ -16,12 +16,12 @@
import os from 'node:os';
import crypto from 'node:crypto';
import { cli } from 'cleye';
import yargs from 'yargs';
// 'jest-cli' is included with jest and should be kept in sync with the installed jest version
// eslint-disable-next-line @backstage/no-undeclared-imports
import { run as runJest, yargsOptions as jestYargsOptions } from 'jest-cli';
import { relative as relativePath } from 'node:path';
import { Command, OptionValues } from 'commander';
import { Lockfile, PackageGraph, SuccessCache } from '@backstage/cli-node';
import {
@@ -31,6 +31,7 @@ import {
findOwnPaths,
isChildPath,
} from '@backstage/cli-common';
import type { CommandContext } from '../../../../wiring/types';
type JestProject = {
displayName: string;
@@ -130,36 +131,49 @@ export function createFlagFinder(args: string[]) {
};
}
function removeOptionArg(args: string[], option: string, size: number = 2) {
let changed = false;
do {
changed = false;
const index = args.indexOf(option);
if (index >= 0) {
changed = true;
args.splice(index, size);
}
const indexEq = args.findIndex(arg => arg.startsWith(`${option}=`));
if (indexEq >= 0) {
changed = true;
args.splice(indexEq, 1);
}
} while (changed);
}
export async function command(opts: OptionValues, cmd: Command): Promise<void> {
export default async ({ args, info }: CommandContext) => {
const testGlobal = global as TestGlobal;
// all args are forwarded to jest
let parent = cmd;
while (parent.parent) {
parent = parent.parent;
for (const flag of ['successCache', 'successCacheDir', 'jestHelp']) {
if (args.some(a => a === `--${flag}` || a.startsWith(`--${flag}=`))) {
process.stderr.write(
`DEPRECATION WARNING: --${flag} is deprecated, use the kebab-case form instead\n`,
);
}
}
const allArgs = parent.args as string[];
const args = allArgs.slice(allArgs.indexOf('test') + 1);
// Parse Backstage-specific flags; unknown flags and arguments are left in
// args so they can be forwarded to Jest.
const { flags: opts } = cli(
{
help: info,
flags: {
since: {
type: String,
description:
'Only include test packages changed since the specified ref',
},
successCache: {
type: Boolean,
description: 'Cache and skip tests for unchanged packages',
},
successCacheDir: {
type: String,
description: 'Directory for the success cache',
},
jestHelp: {
type: Boolean,
description: "Show Jest's own help output",
},
},
ignoreArgv: type => type === 'unknown-flag' || type === 'argument',
},
undefined,
args,
);
const hasFlags = createFlagFinder(args);
const sinceRef = opts.since || undefined;
// Parse the args to ensure that no file filters are provided, in which case we refuse to run
const { _: parsedArgs } = await yargs(args).options(jestYargsOptions).argv;
@@ -177,7 +191,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
// Run in watch mode unless in CI, coverage mode, or running all tests
let isSingleWatchMode = args.includes('--watch');
if (
!opts.since &&
!sinceRef &&
!process.env.CI &&
!hasFlags('--coverage', '--watch', '--watchAll')
) {
@@ -246,10 +260,6 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
args.push('--maxWorkers=2');
}
if (opts.since) {
removeOptionArg(args, '--since');
}
let packageGraph: PackageGraph | undefined;
async function getPackageGraph() {
if (packageGraph) {
@@ -261,10 +271,10 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
let selectedProjects: string[] | undefined = undefined;
if (opts.since && !hasFlags('--selectProjects')) {
if (sinceRef && !hasFlags('--selectProjects')) {
const graph = await getPackageGraph();
const changedPackages = await graph.listChangedPackages({
ref: opts.since,
ref: sinceRef,
analyzeLockfile: true,
});
@@ -304,17 +314,13 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}--no-node-snapshot`;
}
if (args.includes('--jest-help')) {
removeOptionArg(args, '--jest-help');
if (opts.jestHelp) {
args.push('--help');
}
// This code path is enabled by the --successCache flag, which is specific to
// the `repo test` command in the Backstage CLI.
if (opts.successCache) {
removeOptionArg(args, '--successCache', 1);
removeOptionArg(args, '--successCacheDir');
// Refuse to run if file filters are provided
if (parsedArgs.length > 0) {
throw new Error(
@@ -439,4 +445,4 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
}
await runJest(args);
}
};
+2 -33
View File
@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { createCliPlugin } from '../../wiring/factory';
import { Command } from 'commander';
import { lazy } from '../../wiring/lazy';
export default createCliPlugin({
pluginId: 'test',
@@ -24,43 +22,14 @@ export default createCliPlugin({
path: ['repo', 'test'],
description:
'Run tests, forwarding args to Jest, defaulting to watch mode',
execute: async ({ args }) => {
const command = new Command();
command.allowUnknownOption(true);
command.allowExcessArguments(true);
command.option(
'--since <ref>',
'Only test packages that changed since the specified ref',
);
command.option('--successCache', 'Enable success caching');
command.option(
'--successCacheDir <path>',
'Set the success cache location, (default: node_modules/.cache/backstage-cli)',
);
command.option(
'--jest-help',
'Show help for Jest CLI options, which are passed through',
);
command.action(lazy(() => import('./commands/repo/test'), 'command'));
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/repo/test') },
});
reg.addCommand({
path: ['package', 'test'],
description:
'Run tests, forwarding args to Jest, defaulting to watch mode',
execute: async ({ args }) => {
const command = new Command();
command.allowUnknownOption(true);
command.allowExcessArguments(true);
command.helpOption('--backstage-cli-help');
command.action(
lazy(() => import('./commands/package/test'), 'default'),
);
await command.parseAsync(args, { from: 'user' });
},
execute: { loader: () => import('./commands/package/test') },
});
},
});
@@ -52,7 +52,7 @@ describe('extractTranslations', () => {
expect(refs[0].messages).toHaveProperty(['membersListCard.title']);
// Verify interpolation placeholders are preserved
expect(refs[0].messages['membersListCard.subtitle']).toContain(
expect(refs[0].messages['membersListCard.title']).toContain(
'{{groupName}}',
);
});
@@ -92,6 +92,124 @@ describe('CliInitializer', () => {
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should run experimental commands but exclude them from help output', async () => {
expect.assertions(3);
process.argv = ['node', 'cli', 'secret'];
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
init: async reg => {
reg.addCommand({
path: ['visible'],
description: 'A visible command',
execute: () => Promise.resolve(),
});
reg.addCommand({
path: ['secret'],
description: 'An experimental command',
experimental: true,
execute: ({ args }) => {
expect(args).toEqual([]);
return Promise.resolve();
},
});
},
}),
);
await initializer.run();
expect(process.exit).toHaveBeenCalledWith(0);
process.argv = ['node', 'cli', '--help'];
const writeSpy = jest.spyOn(process.stdout, 'write');
const initializer2 = new CliInitializer();
initializer2.add(
createCliPlugin({
pluginId: 'test',
init: async reg => {
reg.addCommand({
path: ['visible'],
description: 'A visible command',
execute: () => Promise.resolve(),
});
reg.addCommand({
path: ['secret'],
description: 'An experimental command',
experimental: true,
execute: () => Promise.resolve(),
});
},
}),
);
await initializer2.run();
const helpOutput = writeSpy.mock.calls.map(c => c[0]).join('');
expect(helpOutput).not.toContain('secret');
writeSpy.mockRestore();
});
it('should hide tree nodes when all children are experimental', async () => {
process.argv = ['node', 'cli', '--help'];
const writeSpy = jest.spyOn(process.stdout, 'write');
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
init: async reg => {
reg.addCommand({
path: ['visible'],
description: 'A visible command',
execute: () => Promise.resolve(),
});
reg.addCommand({
path: ['group', 'alpha'],
description: 'First experimental command',
experimental: true,
execute: () => Promise.resolve(),
});
reg.addCommand({
path: ['group', 'beta'],
description: 'Second experimental command',
experimental: true,
execute: () => Promise.resolve(),
});
},
}),
);
await initializer.run();
const helpOutput = writeSpy.mock.calls.map(c => c[0]).join('');
expect(helpOutput).toContain('visible');
expect(helpOutput).not.toContain('group');
writeSpy.mockRestore();
});
it('should show tree nodes when some children are visible', async () => {
process.argv = ['node', 'cli', '--help'];
const writeSpy = jest.spyOn(process.stdout, 'write');
const initializer = new CliInitializer();
initializer.add(
createCliPlugin({
pluginId: 'test',
init: async reg => {
reg.addCommand({
path: ['group', 'alpha'],
description: 'A visible nested command',
execute: () => Promise.resolve(),
});
reg.addCommand({
path: ['group', 'beta'],
description: 'An experimental nested command',
experimental: true,
execute: () => Promise.resolve(),
});
},
}),
);
await initializer.run();
const helpOutput = writeSpy.mock.calls.map(c => c[0]).join('');
expect(helpOutput).toContain('group');
writeSpy.mockRestore();
});
it('should pass positional args to the subcommand if nested', async () => {
expect.assertions(2);
process.argv = [
+18 -3
View File
@@ -15,7 +15,7 @@
*/
import { CommandGraph } from './CommandGraph';
import { CliFeature, OpaqueCliPlugin } from './types';
import { BackstageCommand, CliFeature, OpaqueCliPlugin } from './types';
import { CommandRegistry } from './CommandRegistry';
import { Command } from 'commander';
import { version } from './version';
@@ -24,6 +24,17 @@ import { exitWithError } from './errors';
import { ForwardedError } from '@backstage/errors';
import { isPromise } from 'node:util/types';
function isNodeHidden(
node:
| { $$type: '@tree/leaf'; command: BackstageCommand }
| { $$type: '@tree/root'; children: unknown[] },
): boolean {
if (node.$$type === '@tree/leaf') {
return !!node.command.deprecated || !!node.command.experimental;
}
return node.children.every(child => isNodeHidden(child as any));
}
type UninitializedFeature = CliFeature | Promise<{ default: CliFeature }>;
export class CliInitializer {
@@ -80,7 +91,9 @@ export class CliInitializer {
const { node, argParser } = queue.shift()!;
if (node.$$type === '@tree/root') {
const treeParser = argParser
.command(`${node.name} [command]`)
.command(`${node.name} [command]`, {
hidden: isNodeHidden(node),
})
.description(node.name);
queue.push(
@@ -91,7 +104,9 @@ export class CliInitializer {
);
} else {
argParser
.command(node.name, { hidden: !!node.command.deprecated })
.command(node.name, {
hidden: !!node.command.deprecated || !!node.command.experimental,
})
.description(node.command.description)
.helpOption(false)
.allowUnknownOption(true)
+1
View File
@@ -35,6 +35,7 @@ export interface BackstageCommand {
path: string[];
description: string;
deprecated?: boolean;
experimental?: boolean;
execute:
| CommandExecuteFn
| {
+1
View File
@@ -49,6 +49,7 @@
"@backstage/config": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/ui": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"@types/prop-types": "^15.7.3",
"history": "^5.0.0",
@@ -114,12 +114,16 @@ describe('ApiProvider', () => {
withLogCollector(['error'], () => {
expect(() => {
render(<MyHookConsumer />);
}).toThrow(/^API context is not available/);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringContaining('Error: API context is not available'),
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('Error: API context is not available'),
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <MyHookConsumer> component',
@@ -130,12 +134,16 @@ describe('ApiProvider', () => {
withLogCollector(['error'], () => {
expect(() => {
render(<MyHocConsumer />);
}).toThrow(/^API context is not available/);
}).toThrow('No implementation available for apiRef{x}');
}).error,
).toEqual([
expect.stringContaining('Error: API context is not available'),
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining('Error: API context is not available'),
expect.stringContaining(
'Error: No implementation available for apiRef{x}',
),
expect.objectContaining({ type: 'unhandled-exception' }),
expect.stringContaining(
'The above error occurred in the <withApis(Component)> component',
+24 -19
View File
@@ -45,7 +45,9 @@ import {
fetchApiRef,
discoveryApiRef,
errorApiRef,
useAnalytics,
} from '@backstage/core-plugin-api';
import { BUIProvider } from '@backstage/ui';
import {
AppLanguageApi,
appLanguageApiRef,
@@ -339,6 +341,7 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
for (const flag of plugin.getFeatureFlags()) {
featureFlagsApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: plugin.getId(),
});
}
@@ -389,26 +392,28 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be
return (
<ApiProvider apis={apis}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
<BUIProvider useAnalytics={useAnalytics}>
<AppContextProvider appContext={appContext}>
<ThemeProvider>
<RoutingProvider
routePaths={routing.paths}
routeParents={routing.parents}
routeObjects={routing.objects}
routeBindings={routeBindings}
basePath={getBasePath(loadedConfig.api)}
>
<Suspense fallback={<Progress />}>{children}</Suspense>
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppContextProvider>
<InternalAppContext.Provider
value={{
routeObjects: routing.objects,
appIdentityProxy: this.appIdentityProxy,
}}
>
<Suspense fallback={<Progress />}>{children}</Suspense>
</InternalAppContext.Provider>
</RoutingProvider>
</ThemeProvider>
</AppContextProvider>
</BUIProvider>
</ApiProvider>
);
};
+1
View File
@@ -32,6 +32,7 @@
},
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
+12
View File
@@ -22,11 +22,13 @@ import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { JSX as JSX_2 } from 'react';
import { JSX as JSX_3 } from 'react/jsx-runtime';
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/core-plugin-api';
import { SubRouteRef as SubRouteRef_2 } from '@backstage/frontend-plugin-api';
import { TypesToApiRefs } from '@backstage/frontend-plugin-api';
// @public
export function compatWrapper(element: ReactNode): JSX_3.Element;
@@ -143,5 +145,15 @@ export type ToNewRouteRef<T extends RouteRef | SubRouteRef | ExternalRouteRef> =
? ExternalRouteRef_2<IParams>
: never;
// @public
export function withApis<T extends {}>(
apis: TypesToApiRefs<T>,
): <TProps extends T>(
WrappedComponent: ComponentType<TProps>,
) => {
(props: PropsWithChildren<Omit<TProps, keyof T>>): JSX_3.Element;
displayName: string;
};
// (No @packageDocumentation comment for this package)
```
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ExtensionAttachToSpec } from '@backstage/frontend-plugin-api';
import { ExtensionAttachTo } from '@backstage/frontend-plugin-api';
import { EntityLayout, EntitySwitch, isKind } from '@backstage/plugin-catalog';
import { JSX } from 'react';
import { collectEntityPageContents } from './collectEntityPageContents';
@@ -73,7 +73,7 @@ const otherTestContent = (
function collect(element: JSX.Element) {
const result = new Array<{
id: string;
attachTo: ExtensionAttachToSpec;
attachTo: ExtensionAttachTo;
}>();
collectEntityPageContents(element, {
+1
View File
@@ -31,3 +31,4 @@ export {
convertLegacyRouteRefs,
type ToNewRouteRef,
} from './convertLegacyRouteRef';
export { withApis } from './withApis';
@@ -0,0 +1,85 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/frontend-plugin-api';
import {
TestApiProvider,
withLogCollector,
} from '@backstage/frontend-test-utils';
import { render, screen } from '@testing-library/react';
import { withApis } from './withApis';
describe('withApis', () => {
type MyApi = () => string;
const myApiRef = createApiRef<MyApi>({ id: 'my-api' });
const MyComponent = withApis({ getMessage: myApiRef })(({ getMessage }) => {
return <p>message: {getMessage()}</p>;
});
it('should inject APIs as props and set display name', () => {
render(
<TestApiProvider apis={[[myApiRef, () => 'hello']]}>
<MyComponent />
</TestApiProvider>,
);
expect(screen.getByText('message: hello')).toBeInTheDocument();
expect(MyComponent.displayName).toBe('withApis(Component)');
});
it('should ignore properties from the prototype', () => {
const otherRef = createApiRef<number>({ id: 'other' });
const proto = { other: otherRef };
const props = { getMessage: { enumerable: true, value: myApiRef } };
const obj = Object.create(proto, props) as {
getMessage: typeof myApiRef;
other: typeof otherRef;
};
const WeirdComponent = withApis(obj)(({ getMessage }) => {
return <p>message: {getMessage()}</p>;
});
render(
<TestApiProvider apis={[[myApiRef, () => 'hello']]}>
<WeirdComponent />
</TestApiProvider>,
);
expect(screen.getByText('message: hello')).toBeInTheDocument();
});
it('should throw NotImplementedError if the API is not available', () => {
expect(
withLogCollector(['error'], () => {
expect(() => {
render(
<TestApiProvider apis={[]}>
<MyComponent />
</TestApiProvider>,
);
}).toThrow('No implementation available for apiRef{my-api}');
}).error,
).toEqual(
expect.arrayContaining([
expect.stringContaining(
'No implementation available for apiRef{my-api}',
),
]),
);
});
});
+59
View File
@@ -0,0 +1,59 @@
/*
* 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 { ComponentType, PropsWithChildren } from 'react';
import { TypesToApiRefs, useApiHolder } from '@backstage/frontend-plugin-api';
import { NotImplementedError } from '@backstage/errors';
/**
* Wrapper for giving component an API context.
*
* @param apis - APIs for the context.
* @public
*/
export function withApis<T extends {}>(apis: TypesToApiRefs<T>) {
return function withApisWrapper<TProps extends T>(
WrappedComponent: ComponentType<TProps>,
) {
const Hoc = (props: PropsWithChildren<Omit<TProps, keyof T>>) => {
const apiHolder = useApiHolder();
const impls = {} as T;
for (const key in apis) {
if (Object.hasOwn(apis, key)) {
const ref = apis[key];
const api = apiHolder.get(ref);
if (!api) {
throw new NotImplementedError(
`No implementation available for ${ref}`,
);
}
impls[key] = api;
}
}
return <WrappedComponent {...(props as TProps)} {...impls} />;
};
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
Hoc.displayName = `withApis(${displayName})`;
return Hoc;
};
}
+3 -3
View File
@@ -21,6 +21,9 @@ export const coreComponentsTranslationRef: TranslationRef<
readonly 'table.pagination.lastTooltip': 'Last Page';
readonly 'table.pagination.nextTooltip': 'Next Page';
readonly 'table.pagination.previousTooltip': 'Previous Page';
readonly 'emptyState.missingAnnotation.title': 'Missing Annotation';
readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:';
readonly 'emptyState.missingAnnotation.readMore': 'Read more';
readonly 'signIn.title': 'Sign In';
readonly 'signIn.loginFailed': 'Login failed';
readonly 'signIn.customProvider.title': 'Custom User';
@@ -44,9 +47,6 @@ export const coreComponentsTranslationRef: TranslationRef<
readonly 'errorPage.goBack': 'Go back';
readonly 'errorPage.showMoreDetails': 'Show more details';
readonly 'errorPage.showLessDetails': 'Show less details';
readonly 'emptyState.missingAnnotation.title': 'Missing Annotation';
readonly 'emptyState.missingAnnotation.actionTitle': 'Add the annotation to your component YAML as shown in the highlighted example below:';
readonly 'emptyState.missingAnnotation.readMore': 'Read more';
readonly 'supportConfig.default.title': 'Support Not Configured';
readonly 'supportConfig.default.linkTitle': 'Add `app.support` config key';
readonly 'errorBoundary.title': 'Please contact {{slackChannel}} for help.';
+1
View File
@@ -500,6 +500,7 @@ export type PluginConfig<
// @public
export type PluginFeatureFlagConfig = {
name: string;
description?: string;
};
export { ProfileInfo };
@@ -73,6 +73,8 @@ export type BackstagePlugin<
export type PluginFeatureFlagConfig = {
/** Feature flag name */
name: string;
/** Feature flag description */
description?: string;
};
/**
@@ -131,7 +131,7 @@ const overviewContent = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
@@ -266,7 +266,7 @@ const apiPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
<EntityLinksCard />
@@ -301,7 +301,7 @@ const userPage = (
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard variant="gridItem" />
<EntityOwnershipCard />
</Grid>
</Grid>
</EntityLayout.Route>
@@ -317,7 +317,7 @@ const groupPage = (
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard variant="gridItem" />
<EntityOwnershipCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityMembersListCard />
@@ -339,7 +339,7 @@ const systemPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
<EntityLinksCard />
@@ -357,7 +357,6 @@ const systemPage = (
</EntityLayout.Route>
<EntityLayout.Route path="/diagram" title="Diagram">
<EntityCatalogGraphCard
variant="gridItem"
direction={Direction.TOP_BOTTOM}
title="System Diagram"
height={700}
@@ -386,7 +385,7 @@ const domainPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
+16 -2
View File
@@ -4,10 +4,13 @@
```ts
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiHolder as ApiHolder_2 } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppTree } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -169,7 +172,6 @@ export type CreateSpecializedAppOptions = {
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
advanced?: {
apis?: ApiHolder;
allowUnknownExtensionConfig?: boolean;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
@@ -177,6 +179,18 @@ export type CreateSpecializedAppOptions = {
};
};
// @public (undocumented)
export type ExtensionFactoryMiddleware = (
originalFactory: (contextOverrides?: {
config?: JsonObject;
}) => ExtensionDataContainer<ExtensionDataRef>,
context: {
node: AppNode;
apis: ApiHolder_2;
config?: JsonObject;
},
) => Iterable<ExtensionDataValue<any, any>>;
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
@@ -19,7 +19,6 @@ import {
Extension,
ExtensionDataRef,
ExtensionDefinition,
ExtensionFactoryMiddleware,
ExtensionInput,
PortableSchema,
ResolvedExtensionInput,
@@ -29,6 +28,7 @@ import {
createExtensionInput,
createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware } from '../wiring/types';
import {
createAppNodeInstance,
instantiateAppNodeTree,
@@ -18,10 +18,10 @@ import {
ApiHolder,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionFactoryMiddleware,
ExtensionInput,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware } from '../wiring/types';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -149,7 +149,7 @@ describe('buildAppTree', () => {
attachTo: [
{ id: 'a', input: 'x' },
{ id: 'b', input: 'x' },
],
] as any,
},
{
...baseSpec,
@@ -157,7 +157,7 @@ describe('buildAppTree', () => {
attachTo: [
{ id: 'b', input: 'x' },
{ id: 'c', input: 'x' },
],
] as any,
},
],
collector,
@@ -103,7 +103,10 @@ describe('createSpecializedApp', () => {
features: [
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'a' }, { name: 'b' }],
featureFlags: [
{ name: 'a' },
{ name: 'b', description: 'Feature B description' },
],
extensions: [
createExtension({
attachTo: { id: 'root', input: 'app' },
@@ -146,6 +149,11 @@ describe('createSpecializedApp', () => {
expect(screen.getByText('flags:test=a,test=b')).toBeInTheDocument();
expect(flags).toEqual([
{ name: 'a', pluginId: 'test' },
{ name: 'b', pluginId: 'test', description: 'Feature B description' },
]);
expect(app.apis).toMatchInlineSnapshot(`
ApiResolver {
"apis": Map {
@@ -29,9 +29,9 @@ import {
createApiFactory,
routeResolutionApiRef,
AppNode,
ExtensionFactoryMiddleware,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware } from './types';
import {
AnyApiFactory,
ApiHolder,
@@ -255,17 +255,6 @@ export type CreateSpecializedAppOptions = {
*/
apis?: ApiHolder;
/**
* If set to true, the system will silently accept and move on if
* encountering config for extensions that do not exist. The default is to
* reject such config to help catch simple mistakes.
*
* This flag can be useful in some scenarios where you have a dynamic set of
* extensions enabled at different times, but also increases the risk of
* accidentally missing e.g. simple typos in your config.
*/
allowUnknownExtensionConfig?: boolean;
/**
* Applies one or more middleware on every extension, as they are added to
* the application.
@@ -357,6 +346,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: feature.id,
}),
);
@@ -365,6 +355,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: feature.pluginId,
}),
);
@@ -20,3 +20,4 @@ export {
} from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export { type AppError, type AppErrorTypes } from './createErrorCollector';
export { type ExtensionFactoryMiddleware } from './types';
@@ -0,0 +1,36 @@
/*
* 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 { JsonObject } from '@backstage/types';
import {
ApiHolder,
AppNode,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionDataValue,
} from '@backstage/frontend-plugin-api';
/** @public */
export type ExtensionFactoryMiddleware = (
originalFactory: (contextOverrides?: {
config?: JsonObject;
}) => ExtensionDataContainer<ExtensionDataRef>,
context: {
node: AppNode;
apis: ApiHolder;
config?: JsonObject;
},
) => Iterable<ExtensionDataValue<any, any>>;

Some files were not shown because too many files have changed in this diff Show More