Merge branch 'master' into range-slider-component
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+94
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AddLocationRequest = {
|
||||
type?: string;
|
||||
target: string;
|
||||
dryRun?: boolean;
|
||||
onConflict?: 'refresh' | 'reject';
|
||||
};
|
||||
|
||||
// @public
|
||||
|
||||
@@ -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. \'reject\' (default) returns a 409 error, \'refresh\' 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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface BackstageCommand {
|
||||
path: string[];
|
||||
description: string;
|
||||
deprecated?: boolean;
|
||||
experimental?: boolean;
|
||||
execute:
|
||||
| CommandExecuteFn
|
||||
| {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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}',
|
||||
),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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.';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+6
-7
@@ -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" />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user