diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 62622dd83b..6b4bf24fda 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.15.1", + "version": "4.17.0", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/src/unraid-api/auth/api-key.service.spec.ts b/api/src/unraid-api/auth/api-key.service.spec.ts index aa5b5b738b..4bdf8feff8 100644 --- a/api/src/unraid-api/auth/api-key.service.spec.ts +++ b/api/src/unraid-api/auth/api-key.service.spec.ts @@ -681,4 +681,104 @@ describe('ApiKeyService', () => { ]); }); }); + + describe('convertRolesStringArrayToRoles', () => { + beforeEach(async () => { + vi.mocked(getters.paths).mockReturnValue({ + 'auth-keys': mockBasePath, + } as ReturnType); + + // Create a fresh mock logger for each test + mockLogger = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + verbose: vi.fn(), + }; + + apiKeyService = new ApiKeyService(); + // Replace the logger with our mock + (apiKeyService as any).logger = mockLogger; + }); + + it('should convert uppercase role strings to Role enum values', () => { + const roles = ['ADMIN', 'CONNECT', 'VIEWER']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]); + }); + + it('should convert lowercase role strings to Role enum values', () => { + const roles = ['admin', 'connect', 'guest']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.GUEST]); + }); + + it('should convert mixed case role strings to Role enum values', () => { + const roles = ['Admin', 'CoNnEcT', 'ViEwEr']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]); + }); + + it('should handle roles with whitespace', () => { + const roles = [' ADMIN ', ' CONNECT ', 'VIEWER ']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]); + }); + + it('should filter out invalid roles and warn', () => { + const roles = ['ADMIN', 'INVALID_ROLE', 'VIEWER', 'ANOTHER_INVALID']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([Role.ADMIN, Role.VIEWER]); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Ignoring invalid roles: INVALID_ROLE, ANOTHER_INVALID' + ); + }); + + it('should return empty array when all roles are invalid', () => { + const roles = ['INVALID1', 'INVALID2', 'INVALID3']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([]); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Ignoring invalid roles: INVALID1, INVALID2, INVALID3' + ); + }); + + it('should return empty array for empty input', () => { + const result = apiKeyService.convertRolesStringArrayToRoles([]); + + expect(result).toEqual([]); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should handle all valid Role enum values', () => { + const roles = Object.values(Role); + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual(Object.values(Role)); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should deduplicate roles', () => { + const roles = ['ADMIN', 'admin', 'ADMIN', 'VIEWER', 'viewer']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + // Note: Current implementation doesn't deduplicate, but this test documents the behavior + expect(result).toEqual([Role.ADMIN, Role.ADMIN, Role.ADMIN, Role.VIEWER, Role.VIEWER]); + }); + + it('should handle mixed valid and invalid roles correctly', () => { + const roles = ['ADMIN', 'invalid', 'CONNECT', 'bad_role', 'GUEST', 'VIEWER']; + const result = apiKeyService.convertRolesStringArrayToRoles(roles); + + expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.GUEST, Role.VIEWER]); + expect(mockLogger.warn).toHaveBeenCalledWith('Ignoring invalid roles: invalid, bad_role'); + }); + }); }); diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index 63eb90d17b..7c0a90e543 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -110,9 +110,25 @@ export class ApiKeyService implements OnModuleInit { } public convertRolesStringArrayToRoles(roles: string[]): Role[] { - return roles - .map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role]) - .filter(Boolean); + const validRoles: Role[] = []; + const invalidRoles: string[] = []; + + for (const roleStr of roles) { + const upperRole = roleStr.trim().toUpperCase(); + const role = Role[upperRole as keyof typeof Role]; + + if (role && ApiKeyService.validRoles.has(role)) { + validRoles.push(role); + } else { + invalidRoles.push(roleStr); + } + } + + if (invalidRoles.length > 0) { + this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`); + } + + return validRoles; } async create({ diff --git a/api/src/unraid-api/cli/__test__/api-key.command.test.ts b/api/src/unraid-api/cli/__test__/api-key.command.test.ts new file mode 100644 index 0000000000..be122970ec --- /dev/null +++ b/api/src/unraid-api/cli/__test__/api-key.command.test.ts @@ -0,0 +1,192 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { InquirerService } from 'nest-commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; +import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js'; +import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + +describe('ApiKeyCommand', () => { + let command: ApiKeyCommand; + let apiKeyService: ApiKeyService; + let logService: LogService; + let inquirerService: InquirerService; + let questionSet: AddApiKeyQuestionSet; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyCommand, + AddApiKeyQuestionSet, + { + provide: ApiKeyService, + useValue: { + findByField: vi.fn(), + create: vi.fn(), + findAll: vi.fn(), + deleteApiKeys: vi.fn(), + convertRolesStringArrayToRoles: vi.fn((roles) => roles), + convertPermissionsStringArrayToPermissions: vi.fn((perms) => perms), + getAllValidPermissions: vi.fn(() => []), + }, + }, + { + provide: LogService, + useValue: { + log: vi.fn(), + error: vi.fn(), + }, + }, + { + provide: InquirerService, + useValue: { + prompt: vi.fn(), + }, + }, + ], + }).compile(); + + command = module.get(ApiKeyCommand); + apiKeyService = module.get(ApiKeyService); + logService = module.get(LogService); + inquirerService = module.get(InquirerService); + questionSet = module.get(AddApiKeyQuestionSet); + }); + + describe('AddApiKeyQuestionSet', () => { + describe('shouldAskOverwrite', () => { + it('should return true when an API key with the given name exists', () => { + vi.mocked(apiKeyService.findByField).mockReturnValue({ + key: 'existing-key', + name: 'test-key', + description: 'Test key', + roles: [], + permissions: [], + } as any); + + const result = questionSet.shouldAskOverwrite({ name: 'test-key' }); + + expect(result).toBe(true); + expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'test-key'); + }); + + it('should return false when no API key with the given name exists', () => { + vi.mocked(apiKeyService.findByField).mockReturnValue(null); + + const result = questionSet.shouldAskOverwrite({ name: 'non-existent-key' }); + + expect(result).toBe(false); + expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'non-existent-key'); + }); + }); + }); + + describe('run', () => { + it('should find and return existing key when not creating', async () => { + const mockKey = { key: 'test-api-key-123', name: 'test-key' }; + vi.mocked(apiKeyService.findByField).mockReturnValue(mockKey as any); + + await command.run([], { name: 'test-key', create: false }); + + expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'test-key'); + expect(logService.log).toHaveBeenCalledWith('test-api-key-123'); + }); + + it('should create new key when key does not exist and create flag is set', async () => { + vi.mocked(apiKeyService.findByField).mockReturnValue(null); + vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'new-api-key-456' } as any); + + await command.run([], { + name: 'new-key', + create: true, + roles: ['ADMIN'] as any, + description: 'Test description', + }); + + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: 'new-key', + description: 'Test description', + roles: ['ADMIN'], + permissions: undefined, + overwrite: false, + }); + expect(logService.log).toHaveBeenCalledWith('new-api-key-456'); + }); + + it('should error when key exists and overwrite is not set in non-interactive mode', async () => { + const mockKey = { key: 'existing-key', name: 'test-key' }; + vi.mocked(apiKeyService.findByField) + .mockReturnValueOnce(null) // First call in line 131 + .mockReturnValueOnce(mockKey as any); // Second call in non-interactive check + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + + await expect( + command.run([], { + name: 'test-key', + create: true, + roles: ['ADMIN'] as any, + }) + ).rejects.toThrow(); + + expect(logService.error).toHaveBeenCalledWith( + "API key with name 'test-key' already exists. Use --overwrite to replace it." + ); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + it('should create key with overwrite when key exists and overwrite is set', async () => { + const mockKey = { key: 'existing-key', name: 'test-key' }; + vi.mocked(apiKeyService.findByField) + .mockReturnValueOnce(null) // First call in line 131 + .mockReturnValueOnce(mockKey as any); // Second call in non-interactive check + vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'overwritten-key' } as any); + + await command.run([], { + name: 'test-key', + create: true, + roles: ['ADMIN'] as any, + overwrite: true, + }); + + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: 'test-key', + description: 'CLI generated key: test-key', + roles: ['ADMIN'], + permissions: undefined, + overwrite: true, + }); + expect(logService.log).toHaveBeenCalledWith('overwritten-key'); + }); + + it('should prompt for missing fields when creating without sufficient info', async () => { + vi.mocked(apiKeyService.findByField).mockReturnValue(null); + vi.mocked(inquirerService.prompt).mockResolvedValue({ + name: 'prompted-key', + roles: ['USER'], + permissions: [], + description: 'Prompted description', + overwrite: false, + } as any); + vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'prompted-api-key' } as any); + + await command.run([], { name: '', create: true }); + + expect(inquirerService.prompt).toHaveBeenCalledWith('add-api-key', { + name: '', + create: true, + }); + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: 'prompted-key', + description: 'Prompted description', + roles: ['USER'], + permissions: [], + overwrite: false, + }); + }); + }); +}); diff --git a/api/src/unraid-api/cli/apikey/add-api-key.questions.ts b/api/src/unraid-api/cli/apikey/add-api-key.questions.ts index bae17e2486..bd2e9453c1 100644 --- a/api/src/unraid-api/cli/apikey/add-api-key.questions.ts +++ b/api/src/unraid-api/cli/apikey/add-api-key.questions.ts @@ -39,6 +39,12 @@ export class AddApiKeyQuestionSet { return this.apiKeyService.convertRolesStringArrayToRoles(val); } + @WhenFor({ name: 'roles' }) + shouldAskRoles(options: { roles?: Role[]; permissions?: Permission[] }): boolean { + // Ask for roles if they weren't provided or are empty + return !options.roles || options.roles.length === 0; + } + @ChoicesFor({ name: 'roles' }) async getRoles() { return Object.values(Role); @@ -53,6 +59,12 @@ export class AddApiKeyQuestionSet { return this.apiKeyService.convertPermissionsStringArrayToPermissions(val); } + @WhenFor({ name: 'permissions' }) + shouldAskPermissions(options: { roles?: Role[]; permissions?: Permission[] }): boolean { + // Ask for permissions if they weren't provided or are empty + return !options.permissions || options.permissions.length === 0; + } + @ChoicesFor({ name: 'permissions' }) async getPermissions() { return this.apiKeyService @@ -72,6 +84,6 @@ export class AddApiKeyQuestionSet { @WhenFor({ name: 'overwrite' }) shouldAskOverwrite(options: { name: string }): boolean { - return Boolean(this.apiKeyService.findByKey(options.name)); + return Boolean(this.apiKeyService.findByField('name', options.name)); } } diff --git a/api/src/unraid-api/cli/apikey/api-key.command.spec.ts b/api/src/unraid-api/cli/apikey/api-key.command.spec.ts new file mode 100644 index 0000000000..c24d989002 --- /dev/null +++ b/api/src/unraid-api/cli/apikey/api-key.command.spec.ts @@ -0,0 +1,285 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js'; +import { InquirerService } from 'nest-commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js'; +import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js'; +import { LogService } from '@app/unraid-api/cli/log.service.js'; + +describe('ApiKeyCommand', () => { + let command: ApiKeyCommand; + let apiKeyService: ApiKeyService; + let logService: LogService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApiKeyCommand, + { + provide: ApiKeyService, + useValue: { + findByField: vi.fn(), + create: vi.fn(), + convertRolesStringArrayToRoles: vi.fn(), + convertPermissionsStringArrayToPermissions: vi.fn(), + findAll: vi.fn(), + deleteApiKeys: vi.fn(), + }, + }, + { + provide: LogService, + useValue: { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }, + { + provide: InquirerService, + useValue: { + prompt: vi.fn(), + }, + }, + ], + }).compile(); + + command = module.get(ApiKeyCommand); + apiKeyService = module.get(ApiKeyService); + logService = module.get(LogService); + }); + + describe('parseRoles', () => { + it('should parse valid roles correctly', () => { + const mockConvert = vi + .spyOn(apiKeyService, 'convertRolesStringArrayToRoles') + .mockReturnValue([Role.ADMIN, Role.CONNECT]); + + const result = command.parseRoles('ADMIN,CONNECT'); + + expect(mockConvert).toHaveBeenCalledWith(['ADMIN', 'CONNECT']); + expect(result).toEqual([Role.ADMIN, Role.CONNECT]); + }); + + it('should return GUEST role when no roles provided', () => { + const result = command.parseRoles(''); + + expect(result).toEqual([Role.GUEST]); + }); + + it('should handle roles with spaces', () => { + const mockConvert = vi + .spyOn(apiKeyService, 'convertRolesStringArrayToRoles') + .mockReturnValue([Role.ADMIN, Role.VIEWER]); + + const result = command.parseRoles('ADMIN, VIEWER'); + + expect(mockConvert).toHaveBeenCalledWith(['ADMIN', ' VIEWER']); + expect(result).toEqual([Role.ADMIN, Role.VIEWER]); + }); + + it('should throw error when no valid roles found', () => { + vi.spyOn(apiKeyService, 'convertRolesStringArrayToRoles').mockReturnValue([]); + + expect(() => command.parseRoles('INVALID_ROLE')).toThrow( + `Invalid roles. Valid options are: ${Object.values(Role).join(', ')}` + ); + }); + + it('should handle mixed valid and invalid roles with warning', () => { + const mockConvert = vi + .spyOn(apiKeyService, 'convertRolesStringArrayToRoles') + .mockImplementation((roles) => { + const validRoles: Role[] = []; + const invalidRoles: string[] = []; + + for (const roleStr of roles) { + const upperRole = roleStr.trim().toUpperCase(); + const role = Role[upperRole as keyof typeof Role]; + + if (role) { + validRoles.push(role); + } else { + invalidRoles.push(roleStr); + } + } + + if (invalidRoles.length > 0) { + logService.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`); + } + + return validRoles; + }); + + const result = command.parseRoles('ADMIN,INVALID,VIEWER'); + + expect(mockConvert).toHaveBeenCalledWith(['ADMIN', 'INVALID', 'VIEWER']); + expect(logService.warn).toHaveBeenCalledWith('Ignoring invalid roles: INVALID'); + expect(result).toEqual([Role.ADMIN, Role.VIEWER]); + }); + }); + + describe('run', () => { + it('should create API key with roles without prompting', async () => { + const mockKey = { + id: 'test-id', + key: 'test-key-123', + name: 'TEST', + roles: [Role.ADMIN], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey); + + await command.run([], { + name: 'TEST', + create: true, + roles: [Role.ADMIN], + permissions: undefined, + description: 'Test description', + }); + + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: 'TEST', + description: 'Test description', + roles: [Role.ADMIN], + permissions: undefined, + overwrite: false, + }); + expect(logService.log).toHaveBeenCalledWith('test-key-123'); + }); + + it('should create API key with permissions only without prompting', async () => { + const mockKey = { + id: 'test-id', + key: 'test-key-456', + name: 'TEST_PERMS', + roles: [], + createdAt: new Date().toISOString(), + permissions: [], + }; + const mockPermissions = [ + { + resource: Resource.DOCKER, + actions: [AuthAction.READ_ANY], + }, + ]; + + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey); + + await command.run([], { + name: 'TEST_PERMS', + create: true, + roles: undefined, + permissions: mockPermissions, + description: 'Test with permissions', + }); + + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: 'TEST_PERMS', + description: 'Test with permissions', + roles: undefined, + permissions: mockPermissions, + overwrite: false, + }); + expect(logService.log).toHaveBeenCalledWith('test-key-456'); + }); + + it('should use default description when not provided', async () => { + const mockKey = { + id: 'test-id', + key: 'test-key-789', + name: 'NO_DESC', + roles: [Role.VIEWER], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null); + vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey); + + await command.run([], { + name: 'NO_DESC', + create: true, + roles: [Role.VIEWER], + permissions: undefined, + }); + + expect(apiKeyService.create).toHaveBeenCalledWith({ + name: 'NO_DESC', + description: 'CLI generated key: NO_DESC', + roles: [Role.VIEWER], + permissions: undefined, + overwrite: false, + }); + }); + + it('should return existing key when found', async () => { + const existingKey = { + id: 'existing-id', + key: 'existing-key-123', + name: 'EXISTING', + roles: [Role.ADMIN], + createdAt: new Date().toISOString(), + permissions: [], + }; + vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey); + + await command.run([], { + name: 'EXISTING', + create: false, + }); + + expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'EXISTING'); + expect(logService.log).toHaveBeenCalledWith('existing-key-123'); + expect(apiKeyService.create).not.toHaveBeenCalled(); + }); + + it('should handle uppercase role conversion', () => { + const mockConvert = vi + .spyOn(apiKeyService, 'convertRolesStringArrayToRoles') + .mockImplementation((roles) => { + return roles + .map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role]) + .filter(Boolean); + }); + + const result = command.parseRoles('admin,connect'); + + expect(mockConvert).toHaveBeenCalledWith(['admin', 'connect']); + expect(result).toEqual([Role.ADMIN, Role.CONNECT]); + }); + + it('should handle lowercase role conversion', () => { + const mockConvert = vi + .spyOn(apiKeyService, 'convertRolesStringArrayToRoles') + .mockImplementation((roles) => { + return roles + .map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role]) + .filter(Boolean); + }); + + const result = command.parseRoles('viewer'); + + expect(mockConvert).toHaveBeenCalledWith(['viewer']); + expect(result).toEqual([Role.VIEWER]); + }); + + it('should handle mixed case role conversion', () => { + const mockConvert = vi + .spyOn(apiKeyService, 'convertRolesStringArrayToRoles') + .mockImplementation((roles) => { + return roles + .map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role]) + .filter(Boolean); + }); + + const result = command.parseRoles('Admin,CoNnEcT'); + + expect(mockConvert).toHaveBeenCalledWith(['Admin', 'CoNnEcT']); + expect(result).toEqual([Role.ADMIN, Role.CONNECT]); + }); + }); +}); diff --git a/api/src/unraid-api/cli/apikey/api-key.command.ts b/api/src/unraid-api/cli/apikey/api-key.command.ts index 2fcdf0b71e..c40d632007 100644 --- a/api/src/unraid-api/cli/apikey/api-key.command.ts +++ b/api/src/unraid-api/cli/apikey/api-key.command.ts @@ -15,6 +15,7 @@ interface KeyOptions { description?: string; roles?: Role[]; permissions?: Permission[]; + overwrite?: boolean; } @Command({ @@ -52,22 +53,15 @@ export class ApiKeyCommand extends CommandRunner { }) parseRoles(roles: string): Role[] { if (!roles) return [Role.GUEST]; - const validRoles: Set = new Set(Object.values(Role)); - const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role); - const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role)); + const roleArray = roles.split(',').filter(Boolean); + const validRoles = this.apiKeyService.convertRolesStringArrayToRoles(roleArray); - if (validRequestedRoles.length === 0) { - throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`); + if (validRoles.length === 0) { + throw new Error(`Invalid roles. Valid options are: ${Object.values(Role).join(', ')}`); } - const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role)); - - if (invalidRoles.length > 0) { - this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`); - } - - return validRequestedRoles; + return validRoles; } @Option({ @@ -98,6 +92,14 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, return true; } + @Option({ + flags: '--overwrite', + description: 'Overwrite existing API key if it exists', + }) + parseOverwrite(): boolean { + return true; + } + /** Prompt the user to select API keys to delete. Then, delete the selected keys. */ private async deleteKeys() { const allKeys = await this.apiKeyService.findAll(); @@ -138,8 +140,27 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, if (key) { this.logger.log(key.key); } else if (options.create) { - options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options); - this.logger.log('Creating API Key...' + JSON.stringify(options)); + // Check if we have minimum required info from flags (name + at least one role or permission) + const hasMinimumInfo = + options.name && + ((options.roles && options.roles.length > 0) || + (options.permissions && options.permissions.length > 0)); + + if (!hasMinimumInfo) { + // Interactive mode - prompt for missing fields + options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options); + } else { + // Non-interactive mode - check if key exists and handle overwrite + const existingKey = this.apiKeyService.findByField('name', options.name); + if (existingKey && !options.overwrite) { + this.logger.error( + `API key with name '${options.name}' already exists. Use --overwrite to replace it.` + ); + process.exit(1); + } + } + + this.logger.log('Creating API Key...'); if (!options.roles && !options.permissions) { this.logger.error('Please add at least one role or permission to the key.'); @@ -154,7 +175,7 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`, description: options.description || `CLI generated key: ${options.name}`, roles: options.roles, permissions: options.permissions, - overwrite: true, + overwrite: options.overwrite ?? false, }); this.logger.log(key.key);