Address second round of PR review feedback

- Replace getConfig<T> with getMetadata/setMetadata on CliAuth, removing
  the unsafe type parameter in favor of returning unknown
- Move updateInstanceConfig from cli-module-auth public API to
  CliAuth.setMetadata, removing the cross-package dependency
- Rename 'config' to 'metadata' in StoredInstance and storage schemas
- Add zod validation at consumer sites (cli-module-actions) for
  type-safe metadata access
- Fix zod imports to use zod/v3 for compatibility with zod v4
- Add proper-lockfile to cli-node for metadata write locking
- Refactor cli-node storage from fs-extra to node:fs
- Remove @backstage/cli-module-auth dependency from cli-module-actions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 19:24:26 +01:00
parent 2b90358730
commit 4f6e7de133
18 changed files with 168 additions and 86 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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:^",
@@ -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<string[]>('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`);
};
@@ -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<string[]>('pluginSources')) ?? [];
const sources = pluginSourcesSchema.parse(
await auth.getMetadata('pluginSources'),
);
if (!sources.length) {
process.stderr.write('No plugin sources configured.\n');
@@ -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<string[]>('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),
);
@@ -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();
@@ -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<string[]>('pluginSources')) ?? [];
const pluginSources = pluginSourcesSchema.parse(
await auth.getMetadata('pluginSources'),
);
return {
baseUrl: auth.getBaseUrl(),
-7
View File
@@ -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<void>;
// (No @packageDocumentation comment for this package)
```
-2
View File
@@ -52,5 +52,3 @@ export default createCliModule({
});
},
});
export { updateInstanceConfig } from './lib/storage';
@@ -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();
});
});
+7 -8
View File
@@ -29,7 +29,7 @@ export type StoredInstance = {
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
metadata?: Record<string, unknown>;
};
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<void> {
});
}
export async function getInstanceConfig<T = unknown>(
export async function getInstanceMetadata(
instanceName: string,
key: string,
): Promise<T | undefined> {
): Promise<unknown> {
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);
});
+2
View File
@@ -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": {
+2 -1
View File
@@ -91,8 +91,9 @@ export class CliAuth {
static create(options?: CliAuthCreateOptions): Promise<CliAuth>;
getAccessToken(): Promise<string>;
getBaseUrl(): string;
getConfig<T = unknown>(key: string): Promise<T | undefined>;
getInstanceName(): string;
getMetadata(key: string): Promise<unknown>;
setMetadata(key: string, value: unknown): Promise<void>;
}
// @public
+21 -8
View File
@@ -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<string[]>('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'],
);
});
});
});
+13 -5
View File
@@ -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<T = unknown>(key: string): Promise<T | undefined> {
return getInstanceConfig<T>(this.#instance.name, key);
async getMetadata(key: string): Promise<unknown> {
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<void> {
return updateInstanceMetadata(this.#instance.name, key, value);
}
async #refreshAccessToken(): Promise<void> {
+64 -12
View File
@@ -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<string, unknown>;
metadata?: Record<string, unknown>;
};
const authYamlSchema = z.object({
instances: z.array(storedInstanceSchema).default([]),
});
async function pathExists(p: string): Promise<boolean> {
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<void> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<StoredInstance> {
}
/** @internal */
export async function getInstanceConfig<T = unknown>(
export async function getInstanceMetadata(
instanceName: string,
key: string,
): Promise<T | undefined> {
): Promise<unknown> {
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<void> {
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 */
+3 -1
View File
@@ -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"