diff --git a/.changeset/cli-module-actions-use-cli-auth.md b/.changeset/cli-module-actions-use-cli-auth.md new file mode 100644 index 0000000000..705ebac220 --- /dev/null +++ b/.changeset/cli-module-actions-use-cli-auth.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-actions': patch +--- + +Migrated to use `CliAuth` from `@backstage/cli-node` for authentication instead of importing individual functions from `@backstage/cli-module-auth`. diff --git a/.changeset/cli-module-auth-deprecate-exports.md b/.changeset/cli-module-auth-deprecate-exports.md new file mode 100644 index 0000000000..5a8c79feef --- /dev/null +++ b/.changeset/cli-module-auth-deprecate-exports.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-module-auth': patch +--- + +Deprecated `getSelectedInstance`, `getInstanceConfig`, `accessTokenNeedsRefresh`, `refreshAccessToken`, `getSecretStore`, and `httpJson` exports in favor of the new `CliAuth` class and shared utilities from `@backstage/cli-node`. diff --git a/.changeset/cli-node-auth-api.md b/.changeset/cli-node-auth-api.md new file mode 100644 index 0000000000..bed9681178 --- /dev/null +++ b/.changeset/cli-node-auth-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': minor +--- + +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. Also added `httpJson`, `getSecretStore`, `SecretStore`, `StoredInstance`, and `HttpInit` exports. diff --git a/packages/cli-module-actions/src/commands/sourcesAdd.ts b/packages/cli-module-actions/src/commands/sourcesAdd.ts index 2a8135b63f..6d9ea2b524 100644 --- a/packages/cli-module-actions/src/commands/sourcesAdd.ts +++ b/packages/cli-module-actions/src/commands/sourcesAdd.ts @@ -15,12 +15,8 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { - getSelectedInstance, - getInstanceConfig, - updateInstanceConfig, -} from '@backstage/cli-module-auth'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { updateInstanceConfig } from '@backstage/cli-module-auth'; export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -34,9 +30,8 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; - const instance = await getSelectedInstance(); - const existing = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + const auth = await CliAuth.create(); + const existing = (await auth.getConfig('pluginSources')) ?? []; if (existing.includes(pluginId)) { process.stderr.write( @@ -45,7 +40,7 @@ export default async ({ args, info }: CliCommandContext) => { return; } - await updateInstanceConfig(instance.name, 'pluginSources', [ + await updateInstanceConfig(auth.instanceName, 'pluginSources', [ ...existing, pluginId, ]); diff --git a/packages/cli-module-actions/src/commands/sourcesList.ts b/packages/cli-module-actions/src/commands/sourcesList.ts index 68b368efd5..23a6a6449f 100644 --- a/packages/cli-module-actions/src/commands/sourcesList.ts +++ b/packages/cli-module-actions/src/commands/sourcesList.ts @@ -15,18 +15,13 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { - getSelectedInstance, - getInstanceConfig, -} from '@backstage/cli-module-auth'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; export default async ({ args, info }: CliCommandContext) => { cli({ help: info }, undefined, args); - const instance = await getSelectedInstance(); - const sources = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + const auth = await CliAuth.create(); + const sources = (await auth.getConfig('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 731abe790d..81a2d5abf3 100644 --- a/packages/cli-module-actions/src/commands/sourcesRemove.ts +++ b/packages/cli-module-actions/src/commands/sourcesRemove.ts @@ -15,12 +15,8 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { - getSelectedInstance, - getInstanceConfig, - updateInstanceConfig, -} from '@backstage/cli-module-auth'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; +import { updateInstanceConfig } from '@backstage/cli-module-auth'; export default async ({ args, info }: CliCommandContext) => { const parsed = cli( @@ -34,9 +30,8 @@ export default async ({ args, info }: CliCommandContext) => { const pluginId = parsed._[0]; - const instance = await getSelectedInstance(); - const existing = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; + const auth = await CliAuth.create(); + const existing = (await auth.getConfig('pluginSources')) ?? []; if (!existing.includes(pluginId)) { process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`); @@ -44,7 +39,7 @@ export default async ({ args, info }: CliCommandContext) => { } await updateInstanceConfig( - instance.name, + auth.instanceName, 'pluginSources', existing.filter(s => s !== pluginId), ); diff --git a/packages/cli-module-actions/src/lib/ActionsClient.test.ts b/packages/cli-module-actions/src/lib/ActionsClient.test.ts index 61526873c9..ecf7861f26 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.test.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.test.ts @@ -15,11 +15,15 @@ */ import { ActionsClient } from './ActionsClient'; -import { httpJson } from '@backstage/cli-module-auth'; +import { httpJson } from '@backstage/cli-node'; -jest.mock('@backstage/cli-module-auth', () => ({ - httpJson: jest.fn(), -})); +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); + return { + ...actual, + httpJson: jest.fn(), + }; +}); const mockHttpJson = httpJson as jest.MockedFunction; diff --git a/packages/cli-module-actions/src/lib/ActionsClient.ts b/packages/cli-module-actions/src/lib/ActionsClient.ts index aa04902b2f..f7d284ad1a 100644 --- a/packages/cli-module-actions/src/lib/ActionsClient.ts +++ b/packages/cli-module-actions/src/lib/ActionsClient.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { httpJson } from '@backstage/cli-module-auth'; +import { httpJson } from '@backstage/cli-node'; export type ActionDef = { id: string; diff --git a/packages/cli-module-actions/src/lib/resolveAuth.test.ts b/packages/cli-module-actions/src/lib/resolveAuth.test.ts index f2702cf757..21cf6ebc38 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.test.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.test.ts @@ -15,39 +15,17 @@ */ import { resolveAuth } from './resolveAuth'; -import { - getSelectedInstance, - getInstanceConfig, - accessTokenNeedsRefresh, - refreshAccessToken, - getSecretStore, - type StoredInstance, -} from '@backstage/cli-module-auth'; +import { CliAuth, type StoredInstance } from '@backstage/cli-node'; -jest.mock('@backstage/cli-module-auth', () => ({ - getSelectedInstance: jest.fn(), - getInstanceConfig: jest.fn(), - accessTokenNeedsRefresh: jest.fn(), - refreshAccessToken: jest.fn(), - getSecretStore: jest.fn(), -})); +jest.mock('@backstage/cli-node', () => { + const actual = jest.requireActual('@backstage/cli-node'); + return { + ...actual, + CliAuth: { create: jest.fn() }, + }; +}); -const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction< - typeof getSelectedInstance ->; -const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction< - typeof getInstanceConfig ->; -const mockAccessTokenNeedsRefresh = - accessTokenNeedsRefresh as jest.MockedFunction< - typeof accessTokenNeedsRefresh - >; -const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction< - typeof refreshAccessToken ->; -const mockGetSecretStore = getSecretStore as jest.MockedFunction< - typeof getSecretStore ->; +const mockCreate = CliAuth.create as jest.MockedFunction; describe('resolveAuth', () => { const mockInstance: StoredInstance = { @@ -58,27 +36,22 @@ describe('resolveAuth', () => { accessTokenExpiresAt: Date.now() + 3600_000, }; - const mockSecretStore = { - get: jest.fn(), - set: jest.fn(), - delete: jest.fn(), - }; - beforeEach(() => { jest.clearAllMocks(); - mockGetSelectedInstance.mockResolvedValue(mockInstance); - mockAccessTokenNeedsRefresh.mockReturnValue(false); - mockGetSecretStore.mockResolvedValue(mockSecretStore); - mockSecretStore.get.mockResolvedValue('test-access-token'); - mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']); }); it('resolves auth with the selected instance and stored token', async () => { + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getConfig: jest.fn().mockResolvedValue(['catalog', 'scaffolder']), + } as unknown as CliAuth); + const result = await resolveAuth(); - expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined); - expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance); - expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + expect(mockCreate).toHaveBeenCalledWith({ instanceName: undefined }); expect(result).toEqual({ instance: mockInstance, accessToken: 'test-access-token', @@ -86,28 +59,32 @@ describe('resolveAuth', () => { }); }); - it('passes instance name flag to getSelectedInstance', async () => { + it('passes instance name flag to CliAuth.create', async () => { + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getConfig: jest.fn().mockResolvedValue([]), + } as unknown as CliAuth); + await resolveAuth('staging'); - expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging'); + expect(mockCreate).toHaveBeenCalledWith({ instanceName: 'staging' }); }); - it('refreshes the access token when it is about to expire', async () => { - const refreshedInstance = { - ...mockInstance, - accessTokenExpiresAt: Date.now() + 7200_000, - }; - mockAccessTokenNeedsRefresh.mockReturnValue(true); - mockRefreshAccessToken.mockResolvedValue(refreshedInstance); - - const result = await resolveAuth(); - - expect(mockRefreshAccessToken).toHaveBeenCalledWith('production'); - expect(result.instance).toBe(refreshedInstance); - }); - - it('throws when no access token is stored', async () => { - mockSecretStore.get.mockResolvedValue(undefined); + it('throws when getAccessToken fails', async () => { + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest + .fn() + .mockRejectedValue( + new Error('No access token found. Run "auth login" to authenticate.'), + ), + getConfig: jest.fn().mockResolvedValue([]), + } as unknown as CliAuth); await expect(resolveAuth()).rejects.toThrow( 'No access token found. Run "auth login" to authenticate.', @@ -115,7 +92,13 @@ describe('resolveAuth', () => { }); it('returns empty plugin sources when none are configured', async () => { - mockGetInstanceConfig.mockResolvedValue(undefined); + mockCreate.mockResolvedValue({ + instance: mockInstance, + instanceName: mockInstance.name, + baseUrl: mockInstance.baseUrl, + getAccessToken: jest.fn().mockResolvedValue('test-access-token'), + getConfig: 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 11fb646545..18664b1b55 100644 --- a/packages/cli-module-actions/src/lib/resolveAuth.ts +++ b/packages/cli-module-actions/src/lib/resolveAuth.ts @@ -14,35 +14,16 @@ * limitations under the License. */ -import { - getSelectedInstance, - getInstanceConfig, - accessTokenNeedsRefresh, - refreshAccessToken, - getSecretStore, - type StoredInstance, -} from '@backstage/cli-module-auth'; +import { CliAuth, type StoredInstance } from '@backstage/cli-node'; export async function resolveAuth(instanceFlag?: string): Promise<{ instance: StoredInstance; accessToken: string; pluginSources: string[]; }> { - let instance = await getSelectedInstance(instanceFlag); + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); + const pluginSources = (await auth.getConfig('pluginSources')) ?? []; - 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.'); - } - - const pluginSources = - (await getInstanceConfig(instance.name, 'pluginSources')) ?? []; - - return { instance, accessToken, pluginSources }; + return { instance: auth.instance, accessToken, pluginSources }; } diff --git a/packages/cli-module-auth/report.api.md b/packages/cli-module-auth/report.api.md index a3b98e2f02..39011f0bac 100644 --- a/packages/cli-module-auth/report.api.md +++ b/packages/cli-module-auth/report.api.md @@ -4,61 +4,44 @@ ```ts import { CliModule } from '@backstage/cli-node'; +import { getSecretStore } from '@backstage/cli-node'; +import { HttpInit } from '@backstage/cli-node'; +import { httpJson } from '@backstage/cli-node'; +import { SecretStore } from '@backstage/cli-node'; +import { StoredInstance } from '@backstage/cli-node'; -// @public (undocumented) +// @public @deprecated (undocumented) export function accessTokenNeedsRefresh(instance: StoredInstance): boolean; // @public (undocumented) const _default: CliModule; export default _default; -// @public (undocumented) +// @public @deprecated (undocumented) export function getInstanceConfig( instanceName: string, key: string, ): Promise; -// @public (undocumented) -export function getSecretStore(): Promise; +export { getSecretStore }; -// @public (undocumented) +// @public @deprecated (undocumented) export function getSelectedInstance( instanceName?: string, ): Promise; -// @public (undocumented) -export type HttpInit = { - headers?: Record; - method?: string; - body?: any; - signal?: AbortSignal; -}; +export { HttpInit }; -// @public (undocumented) -export function httpJson(url: string, init?: HttpInit): Promise; +export { httpJson }; -// @public (undocumented) +// @public @deprecated (undocumented) export function refreshAccessToken( instanceName: string, ): Promise; -// @public (undocumented) -export type SecretStore = { - get(service: string, account: string): Promise; - set(service: string, account: string, secret: string): Promise; - delete(service: string, account: string): Promise; -}; +export { SecretStore }; -// @public (undocumented) -export type StoredInstance = { - name: string; - baseUrl: string; - clientId: string; - issuedAt: number; - accessTokenExpiresAt: number; - selected?: boolean; - config?: Record; -}; +export { StoredInstance }; // @public (undocumented) export function updateInstanceConfig( diff --git a/packages/cli-module-auth/src/commands/printToken.ts b/packages/cli-module-auth/src/commands/printToken.ts index 39a78c1832..45b4cfd9f6 100644 --- a/packages/cli-module-auth/src/commands/printToken.ts +++ b/packages/cli-module-auth/src/commands/printToken.ts @@ -15,10 +15,7 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; -import { getSelectedInstance } from '../lib/storage'; -import { getSecretStore } from '../lib/secretStore'; +import { CliAuth, type CliCommandContext } from '@backstage/cli-node'; export default async ({ args, info }: CliCommandContext) => { const { @@ -37,18 +34,8 @@ export default async ({ args, info }: CliCommandContext) => { 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.'); - } + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); process.stdout.write(`${accessToken}\n`); }; diff --git a/packages/cli-module-auth/src/commands/show.ts b/packages/cli-module-auth/src/commands/show.ts index e1b7c62f72..f9a3f4eb90 100644 --- a/packages/cli-module-auth/src/commands/show.ts +++ b/packages/cli-module-auth/src/commands/show.ts @@ -15,11 +15,7 @@ */ import { cli } from 'cleye'; -import type { CliCommandContext } from '@backstage/cli-node'; -import { httpJson } from '../lib/http'; -import { getSelectedInstance } from '../lib/storage'; -import { accessTokenNeedsRefresh, refreshAccessToken } from '../lib/auth'; -import { getSecretStore } from '../lib/secretStore'; +import { CliAuth, httpJson, type CliCommandContext } from '@backstage/cli-node'; export default async ({ args, info }: CliCommandContext) => { const { @@ -38,23 +34,13 @@ export default async ({ args, info }: CliCommandContext) => { args, ); - let instance = await getSelectedInstance(instanceFlag); + const auth = await CliAuth.create({ instanceName: instanceFlag }); + const accessToken = await auth.getAccessToken(); - if (accessTokenNeedsRefresh(instance)) { - process.stdout.write('Refreshing access token...\n'); - instance = await refreshAccessToken(instance.name); - } - const authBase = new URL('/api/auth', instance.baseUrl) + const authBase = new URL('/api/auth', auth.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`, { diff --git a/packages/cli-module-auth/src/index.ts b/packages/cli-module-auth/src/index.ts index 38567e2174..a8079932d4 100644 --- a/packages/cli-module-auth/src/index.ts +++ b/packages/cli-module-auth/src/index.ts @@ -53,16 +53,16 @@ export default createCliModule({ }, }); -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export { getSelectedInstance, getInstanceConfig, updateInstanceConfig, type StoredInstance, } from './lib/storage'; -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth'; -/** @public */ +/** @public @deprecated Import from {@link @backstage/cli-node} instead. */ export { getSecretStore, type SecretStore } from './lib/secretStore'; -/** @public */ +/** @public @deprecated Import from {@link @backstage/cli-node} instead. */ export { httpJson, type HttpInit } from './lib/http'; diff --git a/packages/cli-module-auth/src/lib/auth.ts b/packages/cli-module-auth/src/lib/auth.ts index 36bdf3dfbc..d53aad6cb2 100644 --- a/packages/cli-module-auth/src/lib/auth.ts +++ b/packages/cli-module-auth/src/lib/auth.ts @@ -15,12 +15,8 @@ */ import { z } from 'zod/v3'; -import { - StoredInstance, - upsertInstance, - withMetadataLock, - getInstanceByName, -} from './storage'; +import type { StoredInstance } from '@backstage/cli-node'; +import { upsertInstance, withMetadataLock, getInstanceByName } from './storage'; import { getSecretStore } from './secretStore'; import { httpJson } from './http'; @@ -31,12 +27,13 @@ const TokenResponseSchema = z.object({ refresh_token: z.string().min(1).optional(), }); -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { - return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration + // 2 minutes before expiration + return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; } -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export async function refreshAccessToken( instanceName: string, ): Promise { diff --git a/packages/cli-module-auth/src/lib/http.ts b/packages/cli-module-auth/src/lib/http.ts index 4861a07722..df0704fcf4 100644 --- a/packages/cli-module-auth/src/lib/http.ts +++ b/packages/cli-module-auth/src/lib/http.ts @@ -14,28 +14,4 @@ * limitations under the License. */ -import { ResponseError } from '@backstage/errors'; - -/** @public */ -export type HttpInit = { - headers?: Record; - method?: string; - body?: any; - signal?: AbortSignal; -}; - -/** @public */ -export async function httpJson(url: string, init?: HttpInit): Promise { - 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; -} +export { httpJson, type HttpInit } from '@backstage/cli-node'; diff --git a/packages/cli-module-auth/src/lib/secretStore.ts b/packages/cli-module-auth/src/lib/secretStore.ts index a0f3c81d51..f7aa5256ae 100644 --- a/packages/cli-module-auth/src/lib/secretStore.ts +++ b/packages/cli-module-auth/src/lib/secretStore.ts @@ -18,12 +18,8 @@ import fs from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; -/** @public */ -export type SecretStore = { - get(service: string, account: string): Promise; - set(service: string, account: string, secret: string): Promise; - delete(service: string, account: string): Promise; -}; +export type { SecretStore } from '@backstage/cli-node'; +import type { SecretStore } from '@backstage/cli-node'; async function loadKeytar(): Promise { try { diff --git a/packages/cli-module-auth/src/lib/storage.ts b/packages/cli-module-auth/src/lib/storage.ts index 0e7668a0ea..b50b472157 100644 --- a/packages/cli-module-auth/src/lib/storage.ts +++ b/packages/cli-module-auth/src/lib/storage.ts @@ -22,6 +22,9 @@ import lockfile from 'proper-lockfile'; import YAML from 'yaml'; import { z } from 'zod/v3'; +export type { StoredInstance } from '@backstage/cli-node'; +import type { StoredInstance } from '@backstage/cli-node'; + const METADATA_FILE = 'auth-instances.yaml'; const INSTANCE_NAME_PATTERN = /^[a-zA-Z0-9._:@-]+$/; @@ -39,17 +42,6 @@ const storedInstanceSchema = z.object({ config: z.record(z.string(), z.unknown()).optional(), }); -/** @public */ -export type StoredInstance = { - name: string; - baseUrl: string; - clientId: string; - issuedAt: number; - accessTokenExpiresAt: number; - selected?: boolean; - config?: Record; -}; - const authYamlSchema = z.object({ instances: z.array(storedInstanceSchema).default([]), }); @@ -99,7 +91,6 @@ export async function getAllInstances(): Promise<{ 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, @@ -108,7 +99,7 @@ export async function getAllInstances(): Promise<{ }; } -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth} instead. */ export async function getSelectedInstance( instanceName?: string, ): Promise { @@ -171,7 +162,7 @@ export async function setSelectedInstance(name: string): Promise { }); } -/** @public */ +/** @public @deprecated Use {@link @backstage/cli-node#CliAuth.getConfig} instead. */ export async function getInstanceConfig( instanceName: string, key: string, diff --git a/packages/cli-node/package.json b/packages/cli-node/package.json index fbefa34fcc..4aeab07e7b 100644 --- a/packages/cli-node/package.json +++ b/packages/cli-node/package.json @@ -52,6 +52,9 @@ "@backstage/test-utils": "workspace:^", "@types/yarnpkg__lockfile": "^1.1.4" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "peerDependencies": { "@swc/core": "^1.15.6" }, diff --git a/packages/cli-node/report.api.md b/packages/cli-node/report.api.md index 2fac82080b..5af6665789 100644 --- a/packages/cli-node/report.api.md +++ b/packages/cli-node/report.api.md @@ -86,6 +86,21 @@ export interface BackstagePackageJson { version: string; } +// @public +export class CliAuth { + get baseUrl(): string; + static create(options?: CliAuthCreateOptions): Promise; + getAccessToken(): Promise; + getConfig(key: string): Promise; + get instance(): StoredInstance; + get instanceName(): string; +} + +// @public +export interface CliAuthCreateOptions { + instanceName?: string; +} + // @public export interface CliCommand { deprecated?: boolean; @@ -133,6 +148,9 @@ export function createCliModule(options: { }) => Promise; }): CliModule; +// @public (undocumented) +export function getSecretStore(): Promise; + // @public export class GitUtils { static listChangedFiles(ref: string): Promise; @@ -142,6 +160,17 @@ export class GitUtils { // @public export function hasBackstageYarnPlugin(workspaceDir?: string): Promise; +// @public (undocumented) +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +// @public (undocumented) +export function httpJson(url: string, init?: HttpInit): Promise; + // @public export function isMonoRepo(): Promise; @@ -272,6 +301,24 @@ export function runWorkerQueueThreads( results: TResult[]; }>; +// @public (undocumented) +export type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +// @public (undocumented) +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + // @public export class SuccessCache { // (undocumented) diff --git a/packages/cli-node/src/auth/CliAuth.test.ts b/packages/cli-node/src/auth/CliAuth.test.ts new file mode 100644 index 0000000000..16a9b10dde --- /dev/null +++ b/packages/cli-node/src/auth/CliAuth.test.ts @@ -0,0 +1,210 @@ +/* + * 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 { CliAuth } from './CliAuth'; +import * as storage from './storage'; +import * as secretStoreModule from './secretStore'; +import * as httpModule from './httpJson'; + +jest.mock('./storage'); +jest.mock('./secretStore'); +jest.mock('./httpJson'); + +const mockStorage = storage as jest.Mocked; +const mockSecretStoreModule = secretStoreModule as jest.Mocked< + typeof secretStoreModule +>; +const mockHttp = httpModule as jest.Mocked; + +describe('CliAuth', () => { + const now = Date.now(); + const mockInstance = { + name: 'production', + baseUrl: 'https://backstage.example.com', + clientId: 'prod-client', + issuedAt: now, + accessTokenExpiresAt: now + 3600_000, + }; + + const mockSecretStore = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockStorage.getSelectedInstance.mockResolvedValue(mockInstance); + mockSecretStoreModule.getSecretStore.mockResolvedValue(mockSecretStore); + mockStorage.accessTokenNeedsRefresh.mockReturnValue(false); + mockSecretStore.get.mockResolvedValue('test-access-token'); + }); + + describe('create', () => { + it('resolves the currently selected instance by default', async () => { + const auth = await CliAuth.create(); + + expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith(undefined); + expect(auth.instance).toEqual(mockInstance); + expect(auth.instanceName).toBe('production'); + expect(auth.baseUrl).toBe('https://backstage.example.com'); + }); + + it('resolves a named instance when specified', async () => { + await CliAuth.create({ instanceName: 'staging' }); + + expect(mockStorage.getSelectedInstance).toHaveBeenCalledWith('staging'); + }); + + it('throws when no instance can be found', async () => { + mockStorage.getSelectedInstance.mockRejectedValue( + new Error( + 'No instances found. Run "auth login" to authenticate first.', + ), + ); + + await expect(CliAuth.create()).rejects.toThrow( + 'No instances found. Run "auth login" to authenticate first.', + ); + }); + }); + + describe('getAccessToken', () => { + it('returns a stored access token when it is still valid', async () => { + const auth = await CliAuth.create(); + const token = await auth.getAccessToken(); + + expect(token).toBe('test-access-token'); + expect(mockSecretStore.get).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:production', + 'accessToken', + ); + expect(mockHttp.httpJson).not.toHaveBeenCalled(); + }); + + it('throws when no access token is stored', async () => { + mockSecretStore.get.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'No access token found. Run "auth login" to authenticate.', + ); + }); + + it('refreshes the token when it is about to expire', async () => { + mockStorage.accessTokenNeedsRefresh.mockReturnValue(true); + mockSecretStore.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'refreshToken') return 'old-refresh-token'; + if (account === 'accessToken') return 'new-access-token'; + return undefined; + }, + ); + + mockHttp.httpJson.mockResolvedValue({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token', + }); + + const auth = await CliAuth.create(); + const token = await auth.getAccessToken(); + + expect(token).toBe('new-access-token'); + expect(mockHttp.httpJson).toHaveBeenCalledWith( + 'https://backstage.example.com/api/auth/v1/token', + expect.objectContaining({ + method: 'POST', + body: { + grant_type: 'refresh_token', + refresh_token: 'old-refresh-token', + }, + }), + ); + expect(mockSecretStore.set).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:production', + 'accessToken', + 'new-access-token', + ); + expect(mockSecretStore.set).toHaveBeenCalledWith( + 'backstage-cli:auth-instance:production', + 'refreshToken', + 'new-refresh-token', + ); + }); + + it('throws when refresh token is missing and access token has expired', async () => { + mockStorage.accessTokenNeedsRefresh.mockReturnValue(true); + mockSecretStore.get.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'Access token is expired and no refresh token is available', + ); + }); + + it('throws when the token response is malformed', async () => { + mockStorage.accessTokenNeedsRefresh.mockReturnValue(true); + mockSecretStore.get.mockImplementation( + async (_service: string, account: string) => { + if (account === 'refreshToken') return 'refresh-token'; + return undefined; + }, + ); + + mockHttp.httpJson.mockResolvedValue({ + token_type: 'Bearer', + expires_in: 3600, + }); + + const auth = await CliAuth.create(); + + await expect(auth.getAccessToken()).rejects.toThrow( + 'Invalid token response', + ); + }); + }); + + describe('getConfig', () => { + it('returns a config value from the instance', async () => { + mockStorage.getInstanceConfig.mockResolvedValue([ + 'catalog', + 'scaffolder', + ]); + + const auth = await CliAuth.create(); + const sources = await auth.getConfig('pluginSources'); + + expect(sources).toEqual(['catalog', 'scaffolder']); + expect(mockStorage.getInstanceConfig).toHaveBeenCalledWith( + 'production', + 'pluginSources', + ); + }); + + it('returns undefined for missing config keys', async () => { + mockStorage.getInstanceConfig.mockResolvedValue(undefined); + + const auth = await CliAuth.create(); + const value = await auth.getConfig('nonexistent'); + + expect(value).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli-node/src/auth/CliAuth.ts b/packages/cli-node/src/auth/CliAuth.ts new file mode 100644 index 0000000000..8934e9bd7f --- /dev/null +++ b/packages/cli-node/src/auth/CliAuth.ts @@ -0,0 +1,156 @@ +/* + * 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 { + type StoredInstance, + getSelectedInstance, + getInstanceConfig, + accessTokenNeedsRefresh, +} from './storage'; +import { getSecretStore, type SecretStore } from './secretStore'; +import { httpJson } from './httpJson'; +import { z } from 'zod'; + +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(), +}); + +/** + * Options for creating a {@link CliAuth} instance. + * + * @public + */ +export interface CliAuthCreateOptions { + /** + * An explicit instance name to resolve. When omitted the currently + * selected instance is used. + */ + instanceName?: string; +} + +/** + * Manages authentication state for Backstage CLI commands. + * + * Reads the currently selected (or explicitly named) auth instance from + * the on-disk instance store, transparently refreshes expired access + * tokens, and exposes helpers that other CLI modules need to talk to a + * Backstage backend. + * + * @public + */ +export class CliAuth { + readonly #secretStore: SecretStore; + #instance: StoredInstance; + + /** + * Resolve the current auth instance and return a ready-to-use + * {@link CliAuth} object. Throws when no instance can be found. + */ + static async create(options?: CliAuthCreateOptions): Promise { + const instance = await getSelectedInstance(options?.instanceName); + const secretStore = await getSecretStore(); + return new CliAuth(instance, secretStore); + } + + private constructor(instance: StoredInstance, secretStore: SecretStore) { + this.#instance = instance; + this.#secretStore = secretStore; + } + + /** The resolved instance metadata. */ + get instance(): StoredInstance { + return this.#instance; + } + + /** Shorthand for `instance.name`. */ + get instanceName(): string { + return this.#instance.name; + } + + /** Shorthand for `instance.baseUrl`. */ + get baseUrl(): string { + return this.#instance.baseUrl; + } + + /** + * Returns a valid access token, refreshing it first if the current + * token is expired or about to expire. + */ + async getAccessToken(): Promise { + if (accessTokenNeedsRefresh(this.#instance)) { + await this.#refreshAccessToken(); + } + + const service = `backstage-cli:auth-instance:${this.#instance.name}`; + const token = await this.#secretStore.get(service, 'accessToken'); + if (!token) { + throw new Error( + 'No access token found. Run "auth login" to authenticate.', + ); + } + return token; + } + + /** + * Reads a per-instance configuration value previously stored by the + * auth module (e.g. `pluginSources`). + */ + async getConfig(key: string): Promise { + return getInstanceConfig(this.#instance.name, key); + } + + async #refreshAccessToken(): Promise { + const service = `backstage-cli:auth-instance:${this.#instance.name}`; + const refreshToken = + (await this.#secretStore.get(service, 'refreshToken')) ?? ''; + if (!refreshToken) { + throw new Error( + 'Access token is expired and no refresh token is available', + ); + } + + const response = await httpJson( + `${this.#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 this.#secretStore.set(service, 'accessToken', token.access_token); + if (token.refresh_token) { + await this.#secretStore.set(service, 'refreshToken', token.refresh_token); + } + this.#instance = { + ...this.#instance, + issuedAt: Date.now(), + accessTokenExpiresAt: Date.now() + token.expires_in * 1000, + }; + } +} diff --git a/packages/cli-node/src/auth/httpJson.ts b/packages/cli-node/src/auth/httpJson.ts new file mode 100644 index 0000000000..4861a07722 --- /dev/null +++ b/packages/cli-node/src/auth/httpJson.ts @@ -0,0 +1,41 @@ +/* + * 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 { ResponseError } from '@backstage/errors'; + +/** @public */ +export type HttpInit = { + headers?: Record; + method?: string; + body?: any; + signal?: AbortSignal; +}; + +/** @public */ +export async function httpJson(url: string, init?: HttpInit): Promise { + 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; +} diff --git a/packages/cli-node/src/auth/index.ts b/packages/cli-node/src/auth/index.ts new file mode 100644 index 0000000000..8037f25714 --- /dev/null +++ b/packages/cli-node/src/auth/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { CliAuth, type CliAuthCreateOptions } from './CliAuth'; +export { type StoredInstance } from './storage'; +export { httpJson, type HttpInit } from './httpJson'; +export { getSecretStore, type SecretStore } from './secretStore'; diff --git a/packages/cli-node/src/auth/secretStore.ts b/packages/cli-node/src/auth/secretStore.ts new file mode 100644 index 0000000000..a0f3c81d51 --- /dev/null +++ b/packages/cli-node/src/auth/secretStore.ts @@ -0,0 +1,112 @@ +/* + * 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'; + +/** @public */ +export type SecretStore = { + get(service: string, account: string): Promise; + set(service: string, account: string, secret: string): Promise; + delete(service: string, account: string): Promise; +}; + +async function loadKeytar(): Promise { + 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 { + const result = await this.keytar.getPassword(service, account); + return result ?? undefined; + } + async set(service: string, account: string, secret: string): Promise { + await this.keytar.setPassword(service, account, secret); + } + async delete(service: string, account: string): Promise { + 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 { + 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 { + 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 { + const file = this.filePath(service, account); + await fs.remove(file); + } +} + +let singleton: SecretStore | undefined; + +/** @public */ +export async function getSecretStore(): Promise { + 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; +} diff --git a/packages/cli-node/src/auth/storage.ts b/packages/cli-node/src/auth/storage.ts new file mode 100644 index 0000000000..e223cac45d --- /dev/null +++ b/packages/cli-node/src/auth/storage.ts @@ -0,0 +1,144 @@ +/* + * 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 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(), + config: z.record(z.string(), z.unknown()).optional(), +}); + +/** @public */ +export type StoredInstance = { + name: string; + baseUrl: string; + clientId: string; + issuedAt: number; + accessTokenExpiresAt: number; + selected?: boolean; + config?: Record; +}; + +const authYamlSchema = z.object({ + instances: z.array(storedInstanceSchema).default([]), +}); + +/** @internal */ +export 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); +} + +/** @internal */ +export 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: [] }; + } +} + +/** @internal */ +export async function getAllInstances(): Promise<{ + instances: StoredInstance[]; + selected: StoredInstance | undefined; +}> { + const { instances } = await readAll(); + const selected = instances.find(i => i.selected) ?? instances[0]; + return { + instances: instances.map(i => ({ + ...i, + selected: i.name === selected.name, + })), + selected, + }; +} + +/** @internal */ +export async function getSelectedInstance( + instanceName?: string, +): Promise { + 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; +} + +/** @internal */ +export async function getInstanceByName(name: string): Promise { + const { instances } = await readAll(); + const instance = instances.find(i => i.name === name); + if (!instance) { + throw new NotFoundError(`Instance '${name}' not found`); + } + return instance; +} + +/** @internal */ +export async function getInstanceConfig( + instanceName: string, + key: string, +): Promise { + const instance = await getInstanceByName(instanceName); + return instance.config?.[key] as T | undefined; +} + +/** @internal */ +export function accessTokenNeedsRefresh(instance: StoredInstance): boolean { + // 2 minutes before expiration + return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; +} diff --git a/packages/cli-node/src/index.ts b/packages/cli-node/src/index.ts index 8831753a82..a824673c1e 100644 --- a/packages/cli-node/src/index.ts +++ b/packages/cli-node/src/index.ts @@ -20,6 +20,7 @@ * @packageDocumentation */ +export * from './auth'; export * from './cache'; export * from './cli-module'; export * from './concurrency'; diff --git a/yarn.lock b/yarn.lock index 4063ff72e8..2a8c8e7f02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3159,12 +3159,16 @@ __metadata: chalk: "npm:^4.0.0" commander: "npm:^12.0.0" fs-extra: "npm:^11.2.0" + keytar: "npm:^7.9.0" pirates: "npm:^4.0.6" semver: "npm:^7.5.3" yaml: "npm:^2.0.0" zod: "npm:^3.25.76 || ^4.0.0" peerDependencies: "@swc/core": ^1.15.6 + dependenciesMeta: + keytar: + optional: true peerDependenciesMeta: "@swc/core": optional: true