feat(api): add unraid-api --delete command (#1289)

- Added a `command:raw` package.json script to let devs run `dist/cli.js` without rebuilding, enabling experimentation in the transpiled script.
- `unraid-api --delete` allows users to select and remove keys with confirmation
prompts.
This commit is contained in:
Pujit Mehrotra
2025-03-28 10:17:22 -04:00
committed by GitHub
parent fa6a5c56b6
commit 2f09445f2e
5 changed files with 119 additions and 3 deletions

View File

@@ -17,6 +17,7 @@
"start": "node dist/main.js",
"dev": "vite",
"command": "pnpm run build && clear && ./dist/cli.js",
"command:raw": "./dist/cli.js",
"// Build and Deploy": "",
"build": "vite build --mode=production",
"postbuild": "chmod +x dist/main.js && chmod +x dist/cli.js",

View File

@@ -1,6 +1,6 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import crypto from 'crypto';
import { readdir, readFile, writeFile } from 'fs/promises';
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import { watch } from 'chokidar';
@@ -23,6 +23,7 @@ import {
import { getters, store } from '@app/store/index.js';
import { setLocalApiKey } from '@app/store/modules/config.js';
import { FileLoadStatus } from '@app/store/types.js';
import { batchProcess } from '@app/utils.js';
@Injectable()
export class ApiKeyService implements OnModuleInit {
@@ -312,4 +313,36 @@ export class ApiKeyService implements OnModuleInit {
basePath: this.basePath,
};
}
/**
* Deletes API keys from the disk and updates the in-memory store.
*
* This method first verifies that all the provided API key IDs exist in the in-memory store.
* If any keys are missing, it throws an Error detailing the missing keys.
* It then deletes the corresponding JSON files concurrently using batch processing.
* If any errors occur during the file deletion process, an array of errors is thrown.
*
* @param ids An array of API key identifiers to delete.
* @throws Error if one or more API keys are not found.
* @throws Array<Error> if errors occur during the file deletion.
*/
public async deleteApiKeys(ids: string[]): Promise<void> {
// First verify all keys exist
const missingKeys = ids.filter((id) => !this.findByField('id', id));
if (missingKeys.length > 0) {
throw new Error(`API keys not found: ${missingKeys.join(', ')}`);
}
// Delete all files in parallel
const { errors, data: deletedIds } = await batchProcess(ids, async (id) => {
await unlink(join(this.basePath, `${id}.json`));
return id;
});
const deletedSet = new Set(deletedIds);
this.memoryApiKeys = this.memoryApiKeys.filter((key) => !deletedSet.has(key.id));
if (errors.length > 0) {
throw errors;
}
}
}

View File

@@ -2,14 +2,17 @@ import { AuthActionVerb } from 'nest-authz';
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
import type { Permission } from '@app/graphql/generated/api/types.js';
import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
import { Resource, Role } from '@app/graphql/generated/api/types.js';
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 { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
interface KeyOptions {
name: string;
create: boolean;
delete?: boolean;
description?: string;
roles?: Role[];
permissions?: Permission[];
@@ -17,7 +20,7 @@ interface KeyOptions {
@Command({
name: 'apikey',
description: `Create / Fetch Connect API Keys - use --create with no arguments for a creation wizard`,
description: `Create / Fetch / Delete Connect API Keys - use --create with no arguments for a creation wizard, or --delete to remove keys`,
})
export class ApiKeyCommand extends CommandRunner {
constructor(
@@ -88,8 +91,50 @@ ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
return description;
}
async run(_: string[], options: KeyOptions = { create: false, name: '' }): Promise<void> {
@Option({
flags: '--delete',
description: 'Delete selected API keys',
})
parseDelete(): boolean {
return true;
}
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
private async deleteKeys() {
const allKeys = this.apiKeyService.findAll();
if (allKeys.length === 0) {
this.logger.log('No API keys found to delete');
return;
}
const answers = await this.inquirerService.prompt<DeleteApiKeyAnswers>(
DeleteApiKeyQuestionSet.name,
{}
);
if (!answers.selectedKeys || answers.selectedKeys.length === 0) {
this.logger.log('No keys selected for deletion');
return;
}
try {
await this.apiKeyService.deleteApiKeys(answers.selectedKeys);
this.logger.log(`Successfully deleted ${answers.selectedKeys.length} API keys`);
} catch (error) {
this.logger.error(error as any);
process.exit(1);
}
}
async run(
_: string[],
options: KeyOptions = { create: false, name: '', delete: false }
): Promise<void> {
try {
if (options.delete) {
await this.deleteKeys();
return;
}
const key = this.apiKeyService.findByField('name', options.name);
if (key) {
this.logger.log(key.key);

View File

@@ -0,0 +1,35 @@
import { ChoicesFor, Question, QuestionSet } from 'nest-commander';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
export interface DeleteApiKeyAnswers {
selectedKeys: string[];
}
@QuestionSet({ name: 'delete-api-key' })
export class DeleteApiKeyQuestionSet {
constructor(
private readonly apiKeyService: ApiKeyService,
private readonly logger: LogService
) {}
static name = 'delete-api-key';
@Question({
name: 'selectedKeys',
type: 'checkbox',
message: 'Select API keys to delete:',
})
parseSelectedKeys(keyIds: string[]): string[] {
return keyIds;
}
@ChoicesFor({ name: 'selectedKeys' })
async getKeys() {
return this.apiKeyService.findAll().map((key) => ({
name: `${key.name} (${key.description ?? ''}) [${key.id}]`,
value: key.id,
}));
}
}

View File

@@ -5,6 +5,7 @@ import { CommandRunner } from 'nest-commander';
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 { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
import { ConfigCommand } from '@app/unraid-api/cli/config.command.js';
import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command.js';
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
@@ -50,6 +51,7 @@ const DEFAULT_COMMANDS = [
const DEFAULT_PROVIDERS = [
AddApiKeyQuestionSet,
DeleteApiKeyQuestionSet,
AddSSOUserQuestionSet,
RemoveSSOUserQuestionSet,
DeveloperQuestions,