diff --git a/.changeset/auth-module-exports.md b/.changeset/auth-module-exports.md index 0b6dc46ac3..358f12cf61 100644 --- a/.changeset/auth-module-exports.md +++ b/.changeset/auth-module-exports.md @@ -2,4 +2,4 @@ '@backstage/cli-module-auth': patch --- -Export auth helper utilities for use by other CLI modules. Added per-instance config storage with `getInstanceConfig` and `updateInstanceConfig`. +Export auth helper utilities for use by other CLI modules. diff --git a/.changeset/cli-node-auth-api.md b/.changeset/cli-node-auth-api.md index c28a2e29c8..b4b943af5a 100644 --- a/.changeset/cli-node-auth-api.md +++ b/.changeset/cli-node-auth-api.md @@ -1,5 +1,5 @@ --- -'@backstage/cli-node': minor +'@backstage/cli-node': patch --- Added `CliAuth` class for managing CLI authentication state. This provides a class-based API with a static `create` method that resolves the currently selected (or explicitly named) auth instance, transparently refreshes expired access tokens, and exposes helpers for other CLI modules to authenticate with a Backstage backend. diff --git a/packages/cli-module-actions/package.json b/packages/cli-module-actions/package.json index c177e91fc4..86c9997322 100644 --- a/packages/cli-module-actions/package.json +++ b/packages/cli-module-actions/package.json @@ -33,10 +33,10 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/cli-module-auth": "workspace:^", "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", - "cleye": "^2.3.0" + "cleye": "^2.3.0", + "zod": "^3.25.76 || ^4.0.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts index 0fc4addd5c..fee3007637 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -16,7 +16,9 @@ import { cli } from 'cleye'; import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; -import { updateInstanceConfig } from '@backstage/cli-module-auth'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -31,7 +33,9 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; const auth = await CliAuth.create(); - const existing = (await auth.getConfig('pluginSources')) ?? []; + const existing = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (existing.includes(pluginId)) { process.stderr.write( @@ -40,10 +44,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - await updateInstanceConfig(auth.getInstanceName(), 'pluginSources', [ - ...existing, - pluginId, - ]); + await auth.setMetadata('pluginSources', [...existing, pluginId]); process.stdout.write(`Added plugin source "${pluginId}".\n`); }; diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts index 23a6a6449f..7557922c06 100644 --- a/packages/cli-module-actions/src/commands/sourcesList.ts +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -16,12 +16,17 @@ import { cli } from 'cleye'; import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { cli({ help: info }, undefined, args); const auth = await CliAuth.create(); - const sources = (await auth.getConfig('pluginSources')) ?? []; + const sources = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (!sources.length) { process.stderr.write('No plugin sources configured.\n'); diff --git a/packages/cli-module-actions/src/commands/sourcesRemove.ts b/packages/cli-module-actions/src/commands/sourcesRemove.ts index b680c39684..9225228503 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -16,7 +16,9 @@ import { cli } from 'cleye'; import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; -import { updateInstanceConfig } from '@backstage/cli-module-auth'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -31,15 +33,16 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; const auth = await CliAuth.create(); - const existing = (await auth.getConfig('pluginSources')) ?? []; + const existing = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); if (!existing.includes(pluginId)) { process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); return; } - await updateInstanceConfig( - auth.getInstanceName(), + await auth.setMetadata( 'pluginSources', existing.filter(s => s !== pluginId), ); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts index 21f494c54a..2aabefe9ea 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.test.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -37,7 +37,7 @@ describe('resolveAuth', () => { getInstanceName: jest.fn().mockReturnValue('production'), getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), - getConfig: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), + getMetadata: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), } as unknown as CliAuth); const result = await resolveAuth(); @@ -56,7 +56,7 @@ describe('resolveAuth', () => { getInstanceName: jest.fn().mockReturnValue('staging'), getBaseUrl: jest.fn().mockReturnValue('https://staging.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), - getConfig: jest.fn().mockResolvedValue([]), + getMetadata: jest.fn().mockResolvedValue([]), } as unknown as CliAuth); await resolveAuth('staging'); @@ -73,7 +73,7 @@ describe('resolveAuth', () => { .mockRejectedValue( new Error('No access token found. Run "auth login" to authenticate.'), ), - getConfig: jest.fn().mockResolvedValue([]), + getMetadata: jest.fn().mockResolvedValue([]), } as unknown as CliAuth); await expect(resolveAuth()).rejects.toThrow( @@ -86,7 +86,7 @@ describe('resolveAuth', () => { getInstanceName: jest.fn().mockReturnValue('production'), getBaseUrl: jest.fn().mockReturnValue('https://backstage.example.com'), getAccessToken: jest.fn().mockResolvedValue('test-access-token'), - getConfig: jest.fn().mockResolvedValue(undefined), + getMetadata: jest.fn().mockResolvedValue(undefined), } as unknown as CliAuth); const result = await resolveAuth(); diff --git a/packages/cli-module-actions/src/lib/resolveAuth.ts b/packages/cli-module-actions/src/lib/resolveAuth.ts index 4d2afe6695..568bd4cdcd 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -15,6 +15,9 @@ */ import { CliAuth } from '@backstage/cli-node'; +import { z } from 'zod/v3'; + +const pluginSourcesSchema = z.array(z.string()).default([]); export async function resolveAuth(instanceFlag?: string): Promise<{ baseUrl: string; @@ -24,7 +27,9 @@ export async function resolveAuth(instanceFlag?: string): Promise<{ }> { const auth = await CliAuth.create({ instanceName: instanceFlag }); const accessToken = await auth.getAccessToken(); - const pluginSources = (await auth.getConfig('pluginSources')) ?? []; + const pluginSources = pluginSourcesSchema.parse( + await auth.getMetadata('pluginSources'), + ); return { baseUrl: auth.getBaseUrl(), diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index 67fb636baa..510bcaabe7 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -9,12 +9,5 @@ import { CliModule } from '@backstage/cli-node'; const _default: CliModule; export default _default; -// @public (undocumented) -export function updateInstanceConfig( - instanceName: string, - key: string, - value: unknown, -): Promise; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index 6fef1aea2b..fef74ec8a5 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -52,5 +52,3 @@ export default createCliModule({ }); }, }); - -export { updateInstanceConfig } from './lib/storage'; diff --git a/packages/cli-module-auth/src/lib/storage.test.ts b/packages/cli-module-auth/src/lib/storage.test.ts index 9264c6bb89..042b0eb72a 100644 --- a/packages/cli-module-auth/src/lib/storage.test.ts +++ b/packages/cli-module-auth/src/lib/storage.test.ts @@ -22,8 +22,8 @@ import { getAllInstances, getSelectedInstance, getInstanceByName, - getInstanceConfig, - updateInstanceConfig, + getInstanceMetadata, + updateInstanceMetadata, upsertInstance, removeInstance, setSelectedInstance, @@ -359,65 +359,65 @@ describe('storage', () => { }); }); - describe('getInstanceConfig', () => { - it('should return undefined when no config set', async () => { + describe('getInstanceMetadata', () => { + it('should return undefined when no metadata set', async () => { await upsertInstance(mockInstance1); - const result = await getInstanceConfig('production', 'someKey'); + const result = await getInstanceMetadata('production', 'someKey'); expect(result).toBeUndefined(); }); - it('should return config value for a key', async () => { + it('should return metadata value for a key', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'myKey', 'myValue'); + await updateInstanceMetadata('production', 'myKey', 'myValue'); - const result = await getInstanceConfig('production', 'myKey'); + const result = await getInstanceMetadata('production', 'myKey'); expect(result).toBe('myValue'); }); it('should throw NotFoundError for unknown instance', async () => { - await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow( + await expect(getInstanceMetadata('nonexistent', 'key')).rejects.toThrow( NotFoundError, ); }); }); - describe('updateInstanceConfig', () => { - it('should set a config value', async () => { + describe('updateInstanceMetadata', () => { + it('should set a metadata value', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'key1', 'value1'); + await updateInstanceMetadata('production', 'key1', 'value1'); - const result = await getInstanceConfig('production', 'key1'); + const result = await getInstanceMetadata('production', 'key1'); expect(result).toBe('value1'); }); - it('should preserve existing config keys', async () => { + it('should preserve existing metadata keys', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'key1', 'value1'); - await updateInstanceConfig('production', 'key2', 'value2'); + await updateInstanceMetadata('production', 'key1', 'value1'); + await updateInstanceMetadata('production', 'key2', 'value2'); - const result1 = await getInstanceConfig('production', 'key1'); - const result2 = await getInstanceConfig('production', 'key2'); + const result1 = await getInstanceMetadata('production', 'key1'); + const result2 = await getInstanceMetadata('production', 'key2'); expect(result1).toBe('value1'); expect(result2).toBe('value2'); }); it('should throw NotFoundError for unknown instance', async () => { await expect( - updateInstanceConfig('nonexistent', 'key', 'value'), + updateInstanceMetadata('nonexistent', 'key', 'value'), ).rejects.toThrow(NotFoundError); }); - it('should remove instance along with its config', async () => { + it('should remove instance along with its metadata', async () => { await upsertInstance(mockInstance1); - await updateInstanceConfig('production', 'key1', 'value1'); + await updateInstanceMetadata('production', 'key1', 'value1'); await removeInstance('production'); const { instances } = await getAllInstances(); expect(instances.find(i => i.name === 'production')).toBeUndefined(); await upsertInstance(mockInstance1); - const result = await getInstanceConfig('production', 'key1'); + const result = await getInstanceMetadata('production', 'key1'); expect(result).toBeUndefined(); }); }); diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index 32c6178696..07e48271a8 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -29,7 +29,7 @@ export type StoredInstance = { issuedAt: number; accessTokenExpiresAt: number; selected?: boolean; - config?: Record; + metadata?: Record; }; const METADATA_FILE = 'auth-instances.yaml'; @@ -46,7 +46,7 @@ const storedInstanceSchema = z.object({ issuedAt: z.number().int().nonnegative(), accessTokenExpiresAt: z.number().int().nonnegative(), selected: z.boolean().optional(), - config: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }); const authYamlSchema = z.object({ @@ -168,16 +168,15 @@ export async function setSelectedInstance(name: string): Promise { }); } -export async function getInstanceConfig( +export async function getInstanceMetadata( instanceName: string, key: string, -): Promise { +): Promise { const instance = await getInstanceByName(instanceName); - return instance.config?.[key] as T | undefined; + return instance.metadata?.[key]; } -/** @public */ -export async function updateInstanceConfig( +export async function updateInstanceMetadata( instanceName: string, key: string, value: unknown, @@ -190,7 +189,7 @@ export async function updateInstanceConfig( } data.instances[idx] = { ...data.instances[idx], - config: { ...data.instances[idx].config, [key]: value }, + metadata: { ...data.instances[idx].metadata, [key]: value }, }; await writeAll(data); }); diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index 4aeab07e7b..9c997c02aa 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -42,6 +42,7 @@ "commander": "^12.0.0", "fs-extra": "^11.2.0", "pirates": "^4.0.6", + "proper-lockfile": "^4.1.2", "semver": "^7.5.3", "yaml": "^2.0.0", "zod": "^3.25.76 || ^4.0.0" @@ -50,6 +51,7 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", + "@types/proper-lockfile": "^4", "@types/yarnpkg__lockfile": "^1.1.4" }, "optionalDependencies": { diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index ca10eafff2..9f04b80ddf 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -91,8 +91,9 @@ export class CliAuth { static create(options?: CliAuthCreateOptions): Promise; getAccessToken(): Promise; getBaseUrl(): string; - getConfig(key: string): Promise; getInstanceName(): string; + getMetadata(key: string): Promise; + setMetadata(key: string, value: unknown): Promise; } // @public diff --git a/packages/cli-node/src/auth/CliAuth.test.ts b/packages/cli-node/src/auth/CliAuth.test.ts index 3d932e5e34..65aa340d33 100644 --- a/packages/cli-node/src/auth/CliAuth.test.ts +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -180,30 +180,43 @@ describe('CliAuth', () => { }); }); - describe('getConfig', () => { - it('returns a config value from the instance', async () => { - mockStorage.getInstanceConfig.mockResolvedValue([ + describe('getMetadata / setMetadata', () => { + it('returns a metadata value from the instance', async () => { + mockStorage.getInstanceMetadata.mockResolvedValue([ 'catalog', 'scaffolder', ]); const auth = await CliAuth.create(); - const sources = await auth.getConfig('pluginSources'); + const sources = await auth.getMetadata('pluginSources'); expect(sources).toEqual(['catalog', 'scaffolder']); - expect(mockStorage.getInstanceConfig).toHaveBeenCalledWith( + expect(mockStorage.getInstanceMetadata).toHaveBeenCalledWith( 'production', 'pluginSources', ); }); - it('returns undefined for missing config keys', async () => { - mockStorage.getInstanceConfig.mockResolvedValue(undefined); + it('returns undefined for missing metadata keys', async () => { + mockStorage.getInstanceMetadata.mockResolvedValue(undefined); const auth = await CliAuth.create(); - const value = await auth.getConfig('nonexistent'); + const value = await auth.getMetadata('nonexistent'); expect(value).toBeUndefined(); }); + + it('writes a metadata value to the instance store', async () => { + mockStorage.updateInstanceMetadata.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + await auth.setMetadata('pluginSources', ['catalog']); + + expect(mockStorage.updateInstanceMetadata).toHaveBeenCalledWith( + 'production', + 'pluginSources', + ['catalog'], + ); + }); }); }); diff --git a/packages/cli-node/src/auth/CliAuth.ts b/packages/cli-node/src/auth/CliAuth.ts index d7eaad6d40..d65fb9bbfa 100644 --- a/packages/cli-node/src/auth/CliAuth.ts +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -17,13 +17,14 @@ import { type StoredInstance, getSelectedInstance, - getInstanceConfig, + getInstanceMetadata, + updateInstanceMetadata, accessTokenNeedsRefresh, } from './storage'; import { getSecretStore, type SecretStore } from './secretStore'; import { getAuthInstanceService } from './authIdentifiers'; import { httpJson } from './httpJson'; -import { z } from 'zod'; +import { z } from 'zod/v3'; const TokenResponseSchema = z.object({ access_token: z.string().min(1), @@ -104,11 +105,18 @@ export class CliAuth { } /** - * Reads a per-instance configuration value previously stored by the + * Reads a per-instance metadata value previously stored by the * auth module (e.g. `pluginSources`). */ - async getConfig(key: string): Promise { - return getInstanceConfig(this.#instance.name, key); + async getMetadata(key: string): Promise { + return getInstanceMetadata(this.#instance.name, key); + } + + /** + * Writes a per-instance metadata value to the on-disk instance store. + */ + async setMetadata(key: string, value: unknown): Promise { + return updateInstanceMetadata(this.#instance.name, key, value); } async #refreshAccessToken(): Promise { diff --git a/packages/cli-node/src/auth/storage.ts b/packages/cli-node/src/auth/storage.ts index e223cac45d..13a866d058 100644 --- a/packages/cli-node/src/auth/storage.ts +++ b/packages/cli-node/src/auth/storage.ts @@ -15,11 +15,12 @@ */ import { NotFoundError } from '@backstage/errors'; -import fs from 'fs-extra'; +import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import lockfile from 'proper-lockfile'; import YAML from 'yaml'; -import { z } from 'zod'; +import { z } from 'zod/v3'; const METADATA_FILE = 'auth-instances.yaml'; @@ -35,10 +36,13 @@ const storedInstanceSchema = z.object({ issuedAt: z.number().int().nonnegative(), accessTokenExpiresAt: z.number().int().nonnegative(), selected: z.boolean().optional(), - config: z.record(z.string(), z.unknown()).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +const authYamlSchema = z.object({ + instances: z.array(storedInstanceSchema).default([]), }); -/** @public */ export type StoredInstance = { name: string; baseUrl: string; @@ -46,12 +50,17 @@ export type StoredInstance = { issuedAt: number; accessTokenExpiresAt: number; selected?: boolean; - config?: Record; + metadata?: Record; }; -const authYamlSchema = z.object({ - instances: z.array(storedInstanceSchema).default([]), -}); +async function pathExists(p: string): Promise { + try { + await fs.stat(p); + return true; + } catch { + return false; + } +} /** @internal */ export function getMetadataFilePath(): string { @@ -67,7 +76,7 @@ export function getMetadataFilePath(): string { /** @internal */ export async function readAll(): Promise<{ instances: StoredInstance[] }> { const file = getMetadataFilePath(); - if (!(await fs.pathExists(file))) { + if (!(await pathExists(file))) { return { instances: [] }; } const text = await fs.readFile(file, 'utf8'); @@ -86,6 +95,29 @@ export async function readAll(): Promise<{ instances: StoredInstance[] }> { } } +async function writeAll(data: { instances: StoredInstance[] }): Promise { + const file = getMetadataFilePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + const yaml = YAML.stringify(authYamlSchema.parse(data), { indentSeq: false }); + await fs.writeFile(file, yaml, { encoding: 'utf8', mode: 0o600 }); +} + +async function withMetadataLock(fn: () => Promise): Promise { + const file = getMetadataFilePath(); + await fs.mkdir(path.dirname(file), { recursive: true }); + if (!(await 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(); + } +} + /** @internal */ export async function getAllInstances(): Promise<{ instances: StoredInstance[]; @@ -129,12 +161,32 @@ export async function getInstanceByName(name: string): Promise { } /** @internal */ -export async function getInstanceConfig( +export async function getInstanceMetadata( instanceName: string, key: string, -): Promise { +): Promise { const instance = await getInstanceByName(instanceName); - return instance.config?.[key] as T | undefined; + return instance.metadata?.[key]; +} + +/** @internal */ +export async function updateInstanceMetadata( + instanceName: string, + key: string, + value: unknown, +): Promise { + return withMetadataLock(async () => { + const data = await readAll(); + const idx = data.instances.findIndex(i => i.name === instanceName); + if (idx === -1) { + throw new NotFoundError(`Instance '${instanceName}' not found`); + } + data.instances[idx] = { + ...data.instances[idx], + metadata: { ...data.instances[idx].metadata, [key]: value }, + }; + await writeAll(data); + }); } /** @internal */ diff --git a/yarn.lock b/yarn.lock index 3996199a39..299ee9995b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2828,10 +2828,10 @@ __metadata: dependencies: "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" - "@backstage/cli-module-auth": "workspace:^" "@backstage/cli-node": "workspace:^" "@backstage/errors": "workspace:^" cleye: "npm:^2.3.0" + zod: "npm:^3.25.76 || ^4.0.0" bin: cli-module-actions: bin/backstage-cli-module-actions languageName: unknown @@ -3154,6 +3154,7 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": "npm:^1.1.3" + "@types/proper-lockfile": "npm:^4" "@types/yarnpkg__lockfile": "npm:^1.1.4" "@yarnpkg/lockfile": "npm:^1.1.0" "@yarnpkg/parsers": "npm:^3.0.0" @@ -3162,6 +3163,7 @@ __metadata: fs-extra: "npm:^11.2.0" keytar: "npm:^7.9.0" pirates: "npm:^4.0.6" + proper-lockfile: "npm:^4.1.2" semver: "npm:^7.5.3" yaml: "npm:^2.0.0" zod: "npm:^3.25.76 || ^4.0.0"