diff --git a/packages/plugin-runtime-types/src/plugins/Context.ts b/packages/plugin-runtime-types/src/plugins/Context.ts index 163a438c..5d8918e8 100644 --- a/packages/plugin-runtime-types/src/plugins/Context.ts +++ b/packages/plugin-runtime-types/src/plugins/Context.ts @@ -57,6 +57,10 @@ export interface Context { send(args: SendHttpRequestRequest): Promise; getById(args: GetHttpRequestByIdRequest): Promise; render(args: RenderHttpRequestRequest): Promise; + list(args: { workspaceId?: string; folderId?: string }): Promise>; + }; + folder: { + list(args: { workspaceId?: string }): Promise>; }; httpResponse: { find(args: FindHttpResponsesRequest): Promise; @@ -64,6 +68,10 @@ export interface Context { templates: { render(args: TemplateRenderRequest & { data: T }): Promise; }; + file: { + writeText(filePath: string, content: string): Promise; + readText(filePath: string): Promise; + }; plugin: { reload(): void; }; diff --git a/packages/plugin-runtime-types/src/plugins/HttpCollectionActionPlugin.ts b/packages/plugin-runtime-types/src/plugins/HttpCollectionActionPlugin.ts new file mode 100644 index 00000000..b734e14f --- /dev/null +++ b/packages/plugin-runtime-types/src/plugins/HttpCollectionActionPlugin.ts @@ -0,0 +1,11 @@ +import type { Context } from './Context'; +import type { Folder, Workspace } from '../bindings/gen_models'; +import type { Icon } from '../bindings/gen_events'; + +export type HttpCollectionAction = { label: string; icon?: Icon }; + +export type CallHttpCollectionActionArgs = { folder?: Folder; workspace?: Workspace }; + +export type HttpCollectionActionPlugin = HttpCollectionAction & { + onSelect(ctx: Context, args: CallHttpCollectionActionArgs): Promise | void; +}; diff --git a/packages/plugin-runtime-types/src/plugins/index.ts b/packages/plugin-runtime-types/src/plugins/index.ts index 4e8fed2f..72ed2e53 100644 --- a/packages/plugin-runtime-types/src/plugins/index.ts +++ b/packages/plugin-runtime-types/src/plugins/index.ts @@ -4,6 +4,7 @@ import type { Context } from './Context'; import type { FilterPlugin } from './FilterPlugin'; import { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin'; import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin'; +import type { HttpCollectionActionPlugin } from './HttpCollectionActionPlugin'; import type { ImporterPlugin } from './ImporterPlugin'; import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin'; import type { ThemePlugin } from './ThemePlugin'; @@ -12,6 +13,7 @@ export type { Context }; export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin'; export type { DynamicAuthenticationArg } from './AuthenticationPlugin'; export type { TemplateFunctionPlugin }; +export type { HttpCollectionActionPlugin } from './HttpCollectionActionPlugin'; /** * The global structure of a Yaak plugin @@ -24,6 +26,7 @@ export type PluginDefinition = { filter?: FilterPlugin; authentication?: AuthenticationPlugin; httpRequestActions?: HttpRequestActionPlugin[]; + httpCollectionActions?: HttpCollectionActionPlugin[]; grpcRequestActions?: GrpcRequestActionPlugin[]; templateFunctions?: TemplateFunctionPlugin[]; }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index ded26509..4bea594e 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -10,6 +10,7 @@ import { GrpcRequestAction, HttpAuthenticationAction, HttpRequestAction, + HttpCollectionAction, InternalEvent, InternalEventPayload, ListCookieNamesResponse, @@ -172,6 +173,24 @@ export class PluginInstance { return; } + if ( + payload.type === 'get_http_collection_actions_request' && + Array.isArray(this.#mod?.httpCollectionActions) + ) { + const reply: HttpRequestAction[] = this.#mod.httpCollectionActions.map((a) => ({ + ...a, + // Add everything except onSelect + onSelect: undefined, + })); + const replyPayload: InternalEventPayload = { + type: 'get_http_collection_actions_response', + pluginRefId: this.#workerData.pluginRefId, + actions: reply, + }; + this.#sendPayload(context, replyPayload, replyId); + return; + } + if (payload.type === 'get_themes_request' && Array.isArray(this.#mod?.themes)) { const replyPayload: InternalEventPayload = { type: 'get_themes_response', @@ -302,6 +321,18 @@ export class PluginInstance { } } + if ( + payload.type === 'call_http_collection_action_request' && + Array.isArray(this.#mod.httpCollectionActions) + ) { + const action = this.#mod.httpCollectionActions[payload.index]; + if (typeof action?.onSelect === 'function') { + await action.onSelect(ctx, payload.args); + this.#sendEmpty(context, replyId); + return; + } + } + if ( payload.type === 'call_grpc_request_action_request' && Array.isArray(this.#mod.grpcRequestActions) @@ -611,6 +642,26 @@ export class PluginInstance { ); return httpRequest; }, + list: async (args: { workspaceId?: string; folderId?: string }) => { + const payload = { + type: 'list_http_requests_request', + // plugin events use camelCase field names in Rust -> snake_case mapping + folderId: args.folderId, + workspaceId: args.workspaceId, + } as any; + const { httpRequests } = await this.#sendForReply(context, payload); + return httpRequests as any[]; + }, + }, + folder: { + list: async (args: { workspaceId?: string }) => { + const payload = { + type: 'list_folders_request', + workspaceId: args.workspaceId, + } as any; + const { folders } = await this.#sendForReply(context, payload); + return folders as any[]; + }, }, cookies: { getValue: async (args: GetCookieValueRequest) => { @@ -638,6 +689,24 @@ export class PluginInstance { return result.data as any; }, }, + file: { + writeText: async (filePath: string, content: string) => { + const payload: InternalEventPayload = { + type: 'write_text_file_request', + filePath, + content, + } as any; + await this.#sendForReply(context, payload); + }, + readText: async (filePath: string) => { + const payload: InternalEventPayload = { + type: 'read_text_file_request', + filePath, + } as any; + const result = await this.#sendForReply(context, payload); + return result.content; + }, + }, store: { get: async (key: string) => { const payload = { type: 'get_key_value_request', key } as const; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 49e5f773..680758b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,7 +43,8 @@ use yaak_plugins::events::{ CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, - GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse, + GetHttpRequestActionsResponse, GetHttpCollectionActionsResponse, + CallHttpCollectionActionArgs, CallHttpCollectionActionRequest, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest, }; @@ -846,6 +847,40 @@ async fn cmd_http_request_actions( Ok(plugin_manager.get_http_request_actions(&window).await?) } +#[tauri::command] +async fn cmd_http_collection_actions( + window: WebviewWindow, + plugin_manager: State<'_, PluginManager>, +) -> YaakResult> { + Ok(plugin_manager.get_http_collection_actions(&window).await?) +} + +#[tauri::command] +async fn cmd_call_http_collection_action( + window: WebviewWindow, + req: CallHttpCollectionActionRequest, + plugin_manager: State<'_, PluginManager>, +) -> YaakResult<()> { + let folder = match &req.args.folder { + Some(f) => Some(window.db().get_folder(&f.id)?), + None => None, + }; + let workspace = match &req.args.workspace { + Some(w) => Some(window.db().get_workspace(&w.id)?), + None => None, + }; + + Ok(plugin_manager + .call_http_collection_action( + &window, + CallHttpCollectionActionRequest { + args: CallHttpCollectionActionArgs { folder, workspace }, + ..req + }, + ) + .await?) +} + #[tauri::command] async fn cmd_grpc_request_actions( window: WebviewWindow, @@ -1448,6 +1483,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ cmd_call_http_authentication_action, cmd_call_http_request_action, + cmd_call_http_collection_action, cmd_call_grpc_request_action, cmd_check_for_updates, cmd_create_grpc_request, @@ -1467,6 +1503,7 @@ pub fn run() { cmd_grpc_reflect, cmd_grpc_request_actions, cmd_http_request_actions, + cmd_http_collection_actions, cmd_import_data, cmd_install_plugin, cmd_metadata, diff --git a/src-tauri/src/plugin_events.rs b/src-tauri/src/plugin_events.rs index b89c187b..9087418c 100644 --- a/src-tauri/src/plugin_events.rs +++ b/src-tauri/src/plugin_events.rs @@ -20,6 +20,7 @@ use yaak_plugins::error::Error::PluginErr; use yaak_plugins::events::{ Color, DeleteKeyValueResponse, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, GetCookieValueResponse, GetHttpRequestByIdResponse, GetKeyValueResponse, Icon, InternalEvent, + ListHttpRequestsResponse, InternalEventPayload, ListCookieNamesResponse, RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent, @@ -60,6 +61,30 @@ pub(crate) async fn handle_plugin_event( http_responses, }))) } + InternalEventPayload::ListHttpRequestsRequest(req) => { + let mut http_requests = Vec::new(); + if let Some(folder_id) = req.folder_id { + http_requests = app_handle + .db() + .list_http_requests_for_folder_recursive(&folder_id)?; + } else if let Some(workspace_id) = req.workspace_id { + http_requests = app_handle.db().list_http_requests(&workspace_id)?; + } + + Ok(Some(InternalEventPayload::ListHttpRequestsResponse(ListHttpRequestsResponse { + http_requests, + }))) + } + InternalEventPayload::ListFoldersRequest(req) => { + let mut folders = Vec::new(); + if let Some(workspace_id) = req.workspace_id { + folders = app_handle.db().list_folders(&workspace_id)?; + } + + Ok(Some(InternalEventPayload::ListFoldersResponse( + yaak_plugins::events::ListFoldersResponse { folders }, + ))) + } InternalEventPayload::GetHttpRequestByIdRequest(req) => { let http_request = app_handle.db().get_http_request(&req.id).ok(); Ok(Some(InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse { @@ -352,6 +377,26 @@ pub(crate) async fn handle_plugin_event( environment_id, }))) } + InternalEventPayload::WriteTextFileRequest(req) => { + use std::fs; + use std::path::Path; + + // Ensure the directory exists + if let Some(parent) = Path::new(&req.file_path).parent() { + fs::create_dir_all(parent)?; + } + + fs::write(&req.file_path, &req.content)?; + Ok(Some(InternalEventPayload::WriteTextFileResponse(EmptyPayload {}))) + } + InternalEventPayload::ReadTextFileRequest(req) => { + use std::fs; + + let content = fs::read_to_string(&req.file_path)?; + Ok(Some(InternalEventPayload::ReadTextFileResponse( + yaak_plugins::events::ReadTextFileResponse { content }, + ))) + } _ => Ok(None), } } diff --git a/src-tauri/yaak-plugins/src/events.rs b/src-tauri/yaak-plugins/src/events.rs index 6d65a223..91ab124b 100644 --- a/src-tauri/yaak-plugins/src/events.rs +++ b/src-tauri/yaak-plugins/src/events.rs @@ -89,6 +89,10 @@ pub enum InternalEventPayload { GetHttpRequestActionsRequest(EmptyPayload), GetHttpRequestActionsResponse(GetHttpRequestActionsResponse), CallHttpRequestActionRequest(CallHttpRequestActionRequest), + // HTTP Collection Actions + GetHttpCollectionActionsRequest(EmptyPayload), + GetHttpCollectionActionsResponse(GetHttpCollectionActionsResponse), + CallHttpCollectionActionRequest(CallHttpCollectionActionRequest), // Grpc Request Actions GetGrpcRequestActionsRequest(EmptyPayload), @@ -151,10 +155,19 @@ pub enum InternalEventPayload { FindHttpResponsesRequest(FindHttpResponsesRequest), FindHttpResponsesResponse(FindHttpResponsesResponse), + ListHttpRequestsRequest(ListHttpRequestsRequest), + ListHttpRequestsResponse(ListHttpRequestsResponse), + ListFoldersRequest(ListFoldersRequest), + ListFoldersResponse(ListFoldersResponse), GetThemesRequest(GetThemesRequest), GetThemesResponse(GetThemesResponse), + WriteTextFileRequest(WriteTextFileRequest), + WriteTextFileResponse(EmptyPayload), + ReadTextFileRequest(ReadTextFileRequest), + ReadTextFileResponse(ReadTextFileResponse), + /// Returned when a plugin doesn't get run, just so the server /// has something to listen for EmptyResponse(EmptyPayload), @@ -1096,6 +1109,14 @@ pub struct GetHttpRequestActionsResponse { pub plugin_ref_id: String, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct GetHttpCollectionActionsResponse { + pub actions: Vec, + pub plugin_ref_id: String, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] @@ -1105,6 +1126,15 @@ pub struct HttpRequestAction { pub icon: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct HttpCollectionAction { + pub label: String, + #[ts(optional)] + pub icon: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] @@ -1114,6 +1144,15 @@ pub struct CallHttpRequestActionRequest { pub args: CallHttpRequestActionArgs, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct CallHttpCollectionActionRequest { + pub index: i32, + pub plugin_ref_id: String, + pub args: CallHttpCollectionActionArgs, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] @@ -1121,6 +1160,16 @@ pub struct CallHttpRequestActionArgs { pub http_request: HttpRequest, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct CallHttpCollectionActionArgs { + #[ts(optional)] + pub folder: Option, + #[ts(optional)] + pub workspace: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] @@ -1185,6 +1234,38 @@ pub struct FindHttpResponsesResponse { pub http_responses: Vec, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct ListHttpRequestsRequest { + #[ts(optional)] + pub folder_id: Option, + #[ts(optional)] + pub workspace_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct ListHttpRequestsResponse { + pub http_requests: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct ListFoldersRequest { + #[ts(optional)] + pub workspace_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct ListFoldersResponse { + pub folders: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] #[serde(default, rename_all = "camelCase")] #[ts(export, export_to = "gen_events.ts")] @@ -1238,3 +1319,25 @@ pub struct DeleteKeyValueRequest { pub struct DeleteKeyValueResponse { pub deleted: bool, } + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct WriteTextFileRequest { + pub file_path: String, + pub content: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct ReadTextFileRequest { + pub file_path: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] +#[serde(default, rename_all = "camelCase")] +#[ts(export, export_to = "gen_events.ts")] +pub struct ReadTextFileResponse { + pub content: String, +} diff --git a/src-tauri/yaak-plugins/src/manager.rs b/src-tauri/yaak-plugins/src/manager.rs index fb0c861c..e0e07e65 100644 --- a/src-tauri/yaak-plugins/src/manager.rs +++ b/src-tauri/yaak-plugins/src/manager.rs @@ -10,6 +10,7 @@ use crate::events::{ FilterRequest, FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigRequest, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, + GetHttpCollectionActionsResponse, CallHttpCollectionActionRequest, GetTemplateFunctionConfigRequest, GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesRequest, GetThemesResponse, ImportRequest, ImportResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginContext, @@ -482,6 +483,27 @@ impl PluginManager { Ok(all_actions) } + pub async fn get_http_collection_actions( + &self, + window: &WebviewWindow, + ) -> Result> { + let reply_events = self + .send_and_wait( + &PluginContext::new(window), + &InternalEventPayload::GetHttpCollectionActionsRequest(EmptyPayload {}), + ) + .await?; + + let mut all_actions = Vec::new(); + for event in reply_events { + if let InternalEventPayload::GetHttpCollectionActionsResponse(resp) = event.payload { + all_actions.push(resp.clone()); + } + } + + Ok(all_actions) + } + pub async fn get_template_function_config( &self, window: &WebviewWindow, @@ -564,6 +586,23 @@ impl PluginManager { Ok(()) } + pub async fn call_http_collection_action( + &self, + window: &WebviewWindow, + req: CallHttpCollectionActionRequest, + ) -> Result<()> { + let ref_id = req.plugin_ref_id.clone(); + let plugin = + self.get_plugin_by_ref_id(ref_id.as_str()).await.ok_or(PluginNotFoundErr(ref_id))?; + let event = plugin.build_event_to_send( + &PluginContext::new(window), + &InternalEventPayload::CallHttpCollectionActionRequest(req), + None, + ); + plugin.send(&event).await?; + Ok(()) + } + pub async fn call_grpc_request_action( &self, window: &WebviewWindow, diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 2c0d5591..f813d89f 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -37,6 +37,7 @@ import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems'; import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions'; import { useHotKey } from '../hooks/useHotKey'; import { getHttpRequestActions } from '../hooks/useHttpRequestActions'; +import { getHttpCollectionActions } from '../hooks/useHttpCollectionActions'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { getModelAncestors } from '../hooks/useModelAncestors'; import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest'; @@ -374,6 +375,17 @@ function Sidebar({ className }: { className?: string }) { if (request != null) await a.call(request); }, })), + ...(items.length === 1 && (child.model === 'folder' || child.model === 'workspace') + ? await getHttpCollectionActions() + : [] + ).map((a) => ({ + label: a.label, + leftSlot: , + onSelect: async () => { + const model = getModel(child.model, child.id); + if (model != null) await a.call(model as any); + }, + })), ]; const modelCreationItems: DropdownItem[] = items.length === 1 && child.model === 'folder' diff --git a/src-web/hooks/useHttpCollectionActions.ts b/src-web/hooks/useHttpCollectionActions.ts new file mode 100644 index 00000000..c384bfca --- /dev/null +++ b/src-web/hooks/useHttpCollectionActions.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import type { Folder, Workspace } from '@yaakapp-internal/models'; +import type { + CallHttpCollectionActionRequest, + GetHttpCollectionActionsResponse, + HttpCollectionAction, +} from '@yaakapp-internal/plugins'; +import { useMemo } from 'react'; +import { invokeCmd } from '../lib/tauri'; +import { usePluginsKey } from './usePlugins'; + +export type CallableHttpCollectionAction = Pick & { + call: (model: Folder | Workspace) => Promise; +}; + +export function useHttpCollectionActions() { + const pluginsKey = usePluginsKey(); + + const actionsResult = useQuery({ + queryKey: ['http_collection_actions', pluginsKey], + queryFn: () => getHttpCollectionActions(), + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: none + const actions = useMemo(() => { + return actionsResult.data ?? []; + }, [JSON.stringify(actionsResult.data)]); + + return actions; +} + +export async function getHttpCollectionActions() { + const responses = await invokeCmd('cmd_http_collection_actions'); + const actions = responses.flatMap((r) => + r.actions.map((a, i) => ({ + label: a.label, + icon: a.icon, + call: async (model: Folder | Workspace) => { + const payload: CallHttpCollectionActionRequest = { + index: i, + pluginRefId: r.pluginRefId, + args: (model as any).model === 'folder' ? { folder: model as Folder } : { workspace: model as Workspace }, + } as any; + await invokeCmd('cmd_call_http_collection_action', { req: payload }); + }, + })), + ); + + return actions; +} diff --git a/src-web/lib/tauri.ts b/src-web/lib/tauri.ts index 124ea691..9f9e48e0 100644 --- a/src-web/lib/tauri.ts +++ b/src-web/lib/tauri.ts @@ -5,6 +5,7 @@ type TauriCmd = | 'cmd_call_grpc_request_action' | 'cmd_call_http_authentication_action' | 'cmd_call_http_request_action' + | 'cmd_call_http_collection_action' | 'cmd_check_for_updates' | 'cmd_create_grpc_request' | 'cmd_curl_to_request' @@ -24,6 +25,7 @@ type TauriCmd = | 'cmd_grpc_reflect' | 'cmd_grpc_request_actions' | 'cmd_http_request_actions' + | 'cmd_http_collection_actions' | 'cmd_http_response_body' | 'cmd_import_data' | 'cmd_install_plugin'