mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-08 13:55:14 +02:00
refactor yaak-cli phase 1 command architecture
This commit is contained in:
@@ -5,6 +5,31 @@
|
|||||||
Redesign the yaak-cli command structure to use a resource-oriented `<resource> <action>`
|
Redesign the yaak-cli command structure to use a resource-oriented `<resource> <action>`
|
||||||
pattern that scales well, is discoverable, and supports both human and LLM workflows.
|
pattern that scales well, is discoverable, and supports both human and LLM workflows.
|
||||||
|
|
||||||
|
## Status Snapshot
|
||||||
|
|
||||||
|
Current branch state:
|
||||||
|
|
||||||
|
- Modular CLI structure with command modules and shared `CliContext`
|
||||||
|
- Resource/action hierarchy in place for:
|
||||||
|
- `workspace list`
|
||||||
|
- `request list`
|
||||||
|
- `request send`
|
||||||
|
- `request create`
|
||||||
|
- Top-level `send` exists as a request-send shortcut (not yet flexible request/folder/workspace resolution)
|
||||||
|
- Legacy `get` command removed
|
||||||
|
- No folder/workspace/environment CRUD surface yet
|
||||||
|
- No JSON merge/update flow yet
|
||||||
|
- No `request schema` command yet
|
||||||
|
|
||||||
|
Progress checklist:
|
||||||
|
|
||||||
|
- [x] Phase 1 complete
|
||||||
|
- [ ] Phase 2 complete
|
||||||
|
- [ ] Phase 3 complete
|
||||||
|
- [ ] Phase 4 complete
|
||||||
|
- [ ] Phase 5 complete
|
||||||
|
- [ ] Phase 6 complete
|
||||||
|
|
||||||
## Command Architecture
|
## Command Architecture
|
||||||
|
|
||||||
### Design Principles
|
### Design Principles
|
||||||
@@ -191,6 +216,122 @@ Implementation:
|
|||||||
4. For workspace: list all requests in workspace, send each
|
4. For workspace: list all requests in workspace, send each
|
||||||
5. Add execution options: `--sequential` (default), `--parallel`, `--fail-fast`
|
5. Add execution options: `--sequential` (default), `--parallel`, `--fail-fast`
|
||||||
|
|
||||||
|
## Execution Plan (PR Slices)
|
||||||
|
|
||||||
|
### PR 1: Command tree refactor + compatibility aliases
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
1. Introduce `commands/` modules and a `CliContext` for shared setup
|
||||||
|
2. Add new clap hierarchy (`workspace`, `request`, `folder`, `environment`)
|
||||||
|
3. Route existing behavior into:
|
||||||
|
- `workspace list`
|
||||||
|
- `request list <workspace_id>`
|
||||||
|
- `request send <id>`
|
||||||
|
- `request create <workspace_id> ...`
|
||||||
|
4. Keep compatibility aliases temporarily:
|
||||||
|
- `workspaces` -> `workspace list`
|
||||||
|
- `requests <workspace_id>` -> `request list <workspace_id>`
|
||||||
|
- `create ...` -> `request create ...`
|
||||||
|
5. Remove `get` and update help text
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `yaakcli --help` shows noun/verb structure
|
||||||
|
- Existing list/send/create workflows still work
|
||||||
|
- No behavior change in HTTP send output format
|
||||||
|
|
||||||
|
### PR 2: CRUD surface area
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
1. Implement `show/create/update/delete` for `workspace`, `request`, `folder`, `environment`
|
||||||
|
2. Ensure delete commands require confirmation by default (`--yes` bypass)
|
||||||
|
3. Normalize output format for list/show/create/update/delete responses
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Every command listed in the "Commands" section parses and executes
|
||||||
|
- Delete commands are safe by default in interactive terminals
|
||||||
|
- `--yes` supports non-interactive scripts
|
||||||
|
|
||||||
|
### PR 3: JSON input + merge patch semantics
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
1. Add shared parser for `--json` and positional JSON shorthand
|
||||||
|
2. Add `create --json` and `update --json` for all mutable resources
|
||||||
|
3. Implement server-side RFC 7386 merge patch behavior
|
||||||
|
4. Add guardrails:
|
||||||
|
- `create --json`: reject non-empty `id`
|
||||||
|
- `update --json`: require `id`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Partial `update --json` only modifies provided keys
|
||||||
|
- `null` clears optional values
|
||||||
|
- Invalid JSON and missing required fields return actionable errors
|
||||||
|
|
||||||
|
### PR 4: `request schema` and plugin auth integration
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
1. Add `schemars` to `yaak-models` and derive `JsonSchema` for request models
|
||||||
|
2. Implement `request schema <http|grpc|websocket>`
|
||||||
|
3. Merge plugin auth form inputs into `authentication` schema at runtime
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Command prints valid JSON schema
|
||||||
|
- Schema reflects installed auth providers at runtime
|
||||||
|
- No panic when plugins fail to initialize (degrade gracefully)
|
||||||
|
|
||||||
|
### PR 5: Polymorphic request send
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
1. Replace request resolution in `request send` with `get_any_request`
|
||||||
|
2. Dispatch by request type
|
||||||
|
3. Keep HTTP fully functional
|
||||||
|
4. Return explicit NYI errors for gRPC/WebSocket until implemented
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- HTTP behavior remains unchanged
|
||||||
|
- gRPC/WebSocket IDs are recognized and return explicit status
|
||||||
|
|
||||||
|
### PR 6: Top-level `send` + bulk execution
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
|
||||||
|
1. Add top-level `send <id>` for request/folder/workspace IDs
|
||||||
|
2. Implement folder/workspace fan-out execution
|
||||||
|
3. Add execution controls: `--sequential`, `--parallel`, `--fail-fast`
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Correct ID dispatch order: request -> folder -> workspace
|
||||||
|
- Deterministic summary output (success/failure counts)
|
||||||
|
- Non-zero exit code when any request fails (unless explicitly configured otherwise)
|
||||||
|
|
||||||
|
## Validation Matrix
|
||||||
|
|
||||||
|
1. CLI parsing tests for every command path (including aliases while retained)
|
||||||
|
2. Integration tests against temp SQLite DB for CRUD flows
|
||||||
|
3. Snapshot tests for output text where scripting compatibility matters
|
||||||
|
4. Manual smoke tests:
|
||||||
|
- Send HTTP request with template/rendered vars
|
||||||
|
- JSON create/update for each resource
|
||||||
|
- Delete confirmation and `--yes`
|
||||||
|
- Top-level `send` on request/folder/workspace
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Should compatibility aliases (`workspaces`, `requests`, `create`) be removed immediately or after one release cycle?
|
||||||
|
2. For bulk `send`, should default behavior stop on first failure or continue and summarize?
|
||||||
|
3. Should command output default to human-readable text with an optional `--format json`, or return JSON by default for `show`/`list`?
|
||||||
|
4. For `request schema`, should plugin-derived auth fields be namespaced by plugin ID to avoid collisions?
|
||||||
|
|
||||||
## Crate Changes
|
## Crate Changes
|
||||||
|
|
||||||
- **yaak-cli**: restructure into modules, new clap hierarchy
|
- **yaak-cli**: restructure into modules, new clap hierarchy
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "yaakcli")]
|
||||||
|
#[command(about = "Yaak CLI - API client from the command line")]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Use a custom data directory
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
pub data_dir: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Environment ID to use for variable substitution
|
||||||
|
#[arg(long, short, global = true)]
|
||||||
|
pub environment: Option<String>,
|
||||||
|
|
||||||
|
/// Enable verbose logging
|
||||||
|
#[arg(long, short, global = true)]
|
||||||
|
pub verbose: bool,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Send an HTTP request by ID
|
||||||
|
Send(SendArgs),
|
||||||
|
|
||||||
|
/// Workspace commands
|
||||||
|
Workspace(WorkspaceArgs),
|
||||||
|
|
||||||
|
/// Request commands
|
||||||
|
Request(RequestArgs),
|
||||||
|
|
||||||
|
/// Folder commands (coming soon)
|
||||||
|
#[command(hide = true)]
|
||||||
|
Folder(FolderArgs),
|
||||||
|
|
||||||
|
/// Environment commands (coming soon)
|
||||||
|
#[command(hide = true)]
|
||||||
|
Environment(EnvironmentArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct SendArgs {
|
||||||
|
/// Request ID
|
||||||
|
pub request_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct WorkspaceArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: WorkspaceCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum WorkspaceCommands {
|
||||||
|
/// List all workspaces
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct RequestArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: RequestCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum RequestCommands {
|
||||||
|
/// List requests in a workspace
|
||||||
|
List {
|
||||||
|
/// Workspace ID
|
||||||
|
workspace_id: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Send an HTTP request by ID
|
||||||
|
Send {
|
||||||
|
/// Request ID
|
||||||
|
request_id: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Create a new HTTP request
|
||||||
|
Create {
|
||||||
|
/// Workspace ID
|
||||||
|
workspace_id: String,
|
||||||
|
|
||||||
|
/// Request name
|
||||||
|
#[arg(short, long)]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
/// HTTP method
|
||||||
|
#[arg(short, long, default_value = "GET")]
|
||||||
|
method: String,
|
||||||
|
|
||||||
|
/// URL
|
||||||
|
#[arg(short, long)]
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct FolderArgs {}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct EnvironmentArgs {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod request;
|
||||||
|
pub mod send;
|
||||||
|
pub mod workspace;
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
use crate::cli::{RequestArgs, RequestCommands};
|
||||||
|
use crate::context::CliContext;
|
||||||
|
use log::info;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||||
|
use yaak_http::sender::{HttpSender, ReqwestSender};
|
||||||
|
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
|
||||||
|
use yaak_models::models::{Environment, HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
||||||
|
use yaak_models::render::make_vars_hashmap;
|
||||||
|
use yaak_models::util::UpdateSource;
|
||||||
|
use yaak_plugins::events::{PluginContext, RenderPurpose};
|
||||||
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
|
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
|
||||||
|
|
||||||
|
pub async fn run(ctx: &CliContext, args: RequestArgs, environment: Option<&str>, verbose: bool) {
|
||||||
|
match args.command {
|
||||||
|
RequestCommands::List { workspace_id } => list(ctx, &workspace_id),
|
||||||
|
RequestCommands::Send { request_id } => {
|
||||||
|
send_request_by_id(ctx, &request_id, environment, verbose).await;
|
||||||
|
}
|
||||||
|
RequestCommands::Create { workspace_id, name, method, url } => {
|
||||||
|
create(ctx, workspace_id, name, method, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(ctx: &CliContext, workspace_id: &str) {
|
||||||
|
let requests = ctx
|
||||||
|
.db()
|
||||||
|
.list_http_requests(workspace_id)
|
||||||
|
.expect("Failed to list requests");
|
||||||
|
if requests.is_empty() {
|
||||||
|
println!("No requests found in workspace {}", workspace_id);
|
||||||
|
} else {
|
||||||
|
for request in requests {
|
||||||
|
println!("{} - {} {}", request.id, request.method, request.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(ctx: &CliContext, workspace_id: String, name: String, method: String, url: String) {
|
||||||
|
let request = HttpRequest {
|
||||||
|
workspace_id,
|
||||||
|
name,
|
||||||
|
method: method.to_uppercase(),
|
||||||
|
url,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = ctx
|
||||||
|
.db()
|
||||||
|
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||||
|
.expect("Failed to create request");
|
||||||
|
|
||||||
|
println!("Created request: {}", created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a request by ID and print response in the same format as legacy `send`.
|
||||||
|
pub async fn send_request_by_id(
|
||||||
|
ctx: &CliContext,
|
||||||
|
request_id: &str,
|
||||||
|
environment: Option<&str>,
|
||||||
|
verbose: bool,
|
||||||
|
) {
|
||||||
|
let request = ctx
|
||||||
|
.db()
|
||||||
|
.get_http_request(request_id)
|
||||||
|
.expect("Failed to get request");
|
||||||
|
|
||||||
|
let environment_chain = ctx
|
||||||
|
.db()
|
||||||
|
.resolve_environments(
|
||||||
|
&request.workspace_id,
|
||||||
|
request.folder_id.as_deref(),
|
||||||
|
environment,
|
||||||
|
)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
|
||||||
|
let template_callback = PluginTemplateCallback::new(
|
||||||
|
ctx.plugin_manager.clone(),
|
||||||
|
ctx.encryption_manager.clone(),
|
||||||
|
&plugin_context,
|
||||||
|
RenderPurpose::Send,
|
||||||
|
);
|
||||||
|
|
||||||
|
let rendered_request = render_http_request(
|
||||||
|
&request,
|
||||||
|
environment_chain,
|
||||||
|
&template_callback,
|
||||||
|
&RenderOptions::throw(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to render request templates");
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
println!("> {} {}", rendered_request.method, rendered_request.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendable = SendableHttpRequest::from_http_request(
|
||||||
|
&rendered_request,
|
||||||
|
SendableHttpRequestOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("Failed to build request");
|
||||||
|
|
||||||
|
let (event_tx, mut event_rx) = mpsc::channel(100);
|
||||||
|
|
||||||
|
let verbose_handle = if verbose {
|
||||||
|
Some(tokio::spawn(async move {
|
||||||
|
while let Some(event) = event_rx.recv().await {
|
||||||
|
println!("{}", event);
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
||||||
|
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
|
||||||
|
|
||||||
|
if let Some(handle) = verbose_handle {
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"HTTP {} {}",
|
||||||
|
response.status,
|
||||||
|
response.status_reason.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
for (name, value) in &response.headers {
|
||||||
|
println!("{}: {}", name, value);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (body, _stats) = response.text().await.expect("Failed to read response body");
|
||||||
|
println!("{}", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an HTTP request with template variables and plugin functions.
|
||||||
|
async fn render_http_request(
|
||||||
|
request: &HttpRequest,
|
||||||
|
environment_chain: Vec<Environment>,
|
||||||
|
callback: &PluginTemplateCallback,
|
||||||
|
options: &RenderOptions,
|
||||||
|
) -> yaak_templates::error::Result<HttpRequest> {
|
||||||
|
let vars = &make_vars_hashmap(environment_chain);
|
||||||
|
|
||||||
|
let mut url_parameters = Vec::new();
|
||||||
|
for parameter in request.url_parameters.clone() {
|
||||||
|
if !parameter.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
url_parameters.push(HttpUrlParameter {
|
||||||
|
enabled: parameter.enabled,
|
||||||
|
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
|
||||||
|
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
|
||||||
|
id: parameter.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = Vec::new();
|
||||||
|
for header in request.headers.clone() {
|
||||||
|
if !header.enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.push(HttpRequestHeader {
|
||||||
|
enabled: header.enabled,
|
||||||
|
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
|
||||||
|
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
|
||||||
|
id: header.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = BTreeMap::new();
|
||||||
|
for (key, value) in request.body.clone() {
|
||||||
|
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
let authentication = {
|
||||||
|
let mut disabled = false;
|
||||||
|
let mut auth = BTreeMap::new();
|
||||||
|
|
||||||
|
match request.authentication.get("disabled") {
|
||||||
|
Some(Value::Bool(true)) => {
|
||||||
|
disabled = true;
|
||||||
|
}
|
||||||
|
Some(Value::String(template)) => {
|
||||||
|
disabled = parse_and_render(template.as_str(), vars, callback, options)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty();
|
||||||
|
info!(
|
||||||
|
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||||
|
} else {
|
||||||
|
for (key, value) in request.authentication.clone() {
|
||||||
|
if key == "disabled" {
|
||||||
|
auth.insert(key, Value::Bool(false));
|
||||||
|
} else {
|
||||||
|
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
|
||||||
|
|
||||||
|
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
||||||
|
|
||||||
|
Ok(HttpRequest {
|
||||||
|
url,
|
||||||
|
url_parameters,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
authentication,
|
||||||
|
..request.to_owned()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use crate::cli::SendArgs;
|
||||||
|
use crate::commands::request;
|
||||||
|
use crate::context::CliContext;
|
||||||
|
|
||||||
|
pub async fn run(ctx: &CliContext, args: SendArgs, environment: Option<&str>, verbose: bool) {
|
||||||
|
request::send_request_by_id(ctx, &args.request_id, environment, verbose).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use crate::cli::{WorkspaceArgs, WorkspaceCommands};
|
||||||
|
use crate::context::CliContext;
|
||||||
|
|
||||||
|
pub fn run(ctx: &CliContext, args: WorkspaceArgs) {
|
||||||
|
match args.command {
|
||||||
|
WorkspaceCommands::List => list(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(ctx: &CliContext) {
|
||||||
|
let workspaces = ctx.db().list_workspaces().expect("Failed to list workspaces");
|
||||||
|
if workspaces.is_empty() {
|
||||||
|
println!("No workspaces found");
|
||||||
|
} else {
|
||||||
|
for workspace in workspaces {
|
||||||
|
println!("{} - {}", workspace.id, workspace.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
|
use yaak_models::db_context::DbContext;
|
||||||
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_plugins::events::PluginContext;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
|
pub struct CliContext {
|
||||||
|
query_manager: QueryManager,
|
||||||
|
pub encryption_manager: Arc<EncryptionManager>,
|
||||||
|
pub plugin_manager: Arc<PluginManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CliContext {
|
||||||
|
pub async fn initialize(data_dir: PathBuf, app_id: &str) -> Self {
|
||||||
|
let db_path = data_dir.join("db.sqlite");
|
||||||
|
let blob_path = data_dir.join("blobs.sqlite");
|
||||||
|
|
||||||
|
let (query_manager, _blob_manager, _rx) =
|
||||||
|
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize database");
|
||||||
|
|
||||||
|
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||||
|
|
||||||
|
let vendored_plugin_dir = data_dir.join("vendored-plugins");
|
||||||
|
let installed_plugin_dir = data_dir.join("installed-plugins");
|
||||||
|
let node_bin_path = PathBuf::from("node");
|
||||||
|
|
||||||
|
let plugin_runtime_main =
|
||||||
|
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
|
||||||
|
});
|
||||||
|
|
||||||
|
let plugin_manager = Arc::new(
|
||||||
|
PluginManager::new(
|
||||||
|
vendored_plugin_dir,
|
||||||
|
installed_plugin_dir,
|
||||||
|
node_bin_path,
|
||||||
|
plugin_runtime_main,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
|
let plugins = query_manager.connect().list_plugins().unwrap_or_default();
|
||||||
|
if !plugins.is_empty() {
|
||||||
|
let errors =
|
||||||
|
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
|
||||||
|
for (plugin_dir, error_msg) in errors {
|
||||||
|
eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { query_manager, encryption_manager, plugin_manager }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db(&self) -> DbContext<'_> {
|
||||||
|
self.query_manager.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shutdown(&self) {
|
||||||
|
self.plugin_manager.terminate().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-394
@@ -1,409 +1,52 @@
|
|||||||
use clap::{Parser, Subcommand};
|
mod cli;
|
||||||
use log::info;
|
mod commands;
|
||||||
use serde_json::Value;
|
mod context;
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
|
||||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
|
||||||
use yaak_http::sender::{HttpSender, ReqwestSender};
|
|
||||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
|
|
||||||
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
|
|
||||||
use yaak_models::render::make_vars_hashmap;
|
|
||||||
use yaak_models::util::UpdateSource;
|
|
||||||
use yaak_plugins::events::{PluginContext, RenderPurpose};
|
|
||||||
use yaak_plugins::manager::PluginManager;
|
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
|
||||||
use yaak_templates::{RenderOptions, parse_and_render, render_json_value_raw};
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
use clap::Parser;
|
||||||
#[command(name = "yaakcli")]
|
use cli::{Cli, Commands};
|
||||||
#[command(about = "Yaak CLI - API client from the command line")]
|
use context::CliContext;
|
||||||
struct Cli {
|
|
||||||
/// Use a custom data directory
|
|
||||||
#[arg(long, global = true)]
|
|
||||||
data_dir: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Environment ID to use for variable substitution
|
|
||||||
#[arg(long, short, global = true)]
|
|
||||||
environment: Option<String>,
|
|
||||||
|
|
||||||
/// Enable verbose logging
|
|
||||||
#[arg(long, short, global = true)]
|
|
||||||
verbose: bool,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
|
||||||
command: Commands,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Commands {
|
|
||||||
/// List all workspaces
|
|
||||||
Workspaces,
|
|
||||||
/// List requests in a workspace
|
|
||||||
Requests {
|
|
||||||
/// Workspace ID
|
|
||||||
workspace_id: String,
|
|
||||||
},
|
|
||||||
/// Send an HTTP request by ID
|
|
||||||
Send {
|
|
||||||
/// Request ID
|
|
||||||
request_id: String,
|
|
||||||
},
|
|
||||||
/// Send a GET request to a URL
|
|
||||||
Get {
|
|
||||||
/// URL to request
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
/// Create a new HTTP request
|
|
||||||
Create {
|
|
||||||
/// Workspace ID
|
|
||||||
workspace_id: String,
|
|
||||||
/// Request name
|
|
||||||
#[arg(short, long)]
|
|
||||||
name: String,
|
|
||||||
/// HTTP method
|
|
||||||
#[arg(short, long, default_value = "GET")]
|
|
||||||
method: String,
|
|
||||||
/// URL
|
|
||||||
#[arg(short, long)]
|
|
||||||
url: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render an HTTP request with template variables and plugin functions
|
|
||||||
async fn render_http_request(
|
|
||||||
r: &HttpRequest,
|
|
||||||
environment_chain: Vec<yaak_models::models::Environment>,
|
|
||||||
cb: &PluginTemplateCallback,
|
|
||||||
opt: &RenderOptions,
|
|
||||||
) -> yaak_templates::error::Result<HttpRequest> {
|
|
||||||
let vars = &make_vars_hashmap(environment_chain);
|
|
||||||
|
|
||||||
let mut url_parameters = Vec::new();
|
|
||||||
for p in r.url_parameters.clone() {
|
|
||||||
if !p.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
url_parameters.push(HttpUrlParameter {
|
|
||||||
enabled: p.enabled,
|
|
||||||
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
|
|
||||||
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
|
|
||||||
id: p.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut headers = Vec::new();
|
|
||||||
for p in r.headers.clone() {
|
|
||||||
if !p.enabled {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
headers.push(HttpRequestHeader {
|
|
||||||
enabled: p.enabled,
|
|
||||||
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
|
|
||||||
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
|
|
||||||
id: p.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut body = BTreeMap::new();
|
|
||||||
for (k, v) in r.body.clone() {
|
|
||||||
body.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
|
|
||||||
}
|
|
||||||
|
|
||||||
let authentication = {
|
|
||||||
let mut disabled = false;
|
|
||||||
let mut auth = BTreeMap::new();
|
|
||||||
match r.authentication.get("disabled") {
|
|
||||||
Some(Value::Bool(true)) => {
|
|
||||||
disabled = true;
|
|
||||||
}
|
|
||||||
Some(Value::String(tmpl)) => {
|
|
||||||
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.is_empty();
|
|
||||||
info!(
|
|
||||||
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
if disabled {
|
|
||||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
|
||||||
} else {
|
|
||||||
for (k, v) in r.authentication.clone() {
|
|
||||||
if k == "disabled" {
|
|
||||||
auth.insert(k, Value::Bool(false));
|
|
||||||
} else {
|
|
||||||
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
auth
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = parse_and_render(r.url.clone().as_str(), vars, cb, opt).await?;
|
|
||||||
|
|
||||||
// Apply path placeholders (e.g., /users/:id -> /users/123)
|
|
||||||
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
|
||||||
|
|
||||||
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..r.to_owned() })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let cli = Cli::parse();
|
let Cli { data_dir, environment, verbose, command } = Cli::parse();
|
||||||
|
|
||||||
// Initialize logging
|
if verbose {
|
||||||
if cli.verbose {
|
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the same app_id for both data directory and keyring
|
|
||||||
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
|
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
|
||||||
|
|
||||||
let data_dir = cli.data_dir.unwrap_or_else(|| {
|
let data_dir =
|
||||||
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
data_dir.unwrap_or_else(|| dirs::data_dir().expect("Could not determine data directory").join(app_id));
|
||||||
});
|
|
||||||
|
|
||||||
let db_path = data_dir.join("db.sqlite");
|
let context = CliContext::initialize(data_dir, app_id).await;
|
||||||
let blob_path = data_dir.join("blobs.sqlite");
|
|
||||||
|
|
||||||
let (query_manager, _blob_manager, _rx) =
|
let exit_code = match command {
|
||||||
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize database");
|
Commands::Send(args) => {
|
||||||
|
commands::send::run(&context, args, environment.as_deref(), verbose).await;
|
||||||
let db = query_manager.connect();
|
0
|
||||||
|
|
||||||
// Initialize encryption manager for secure() template function
|
|
||||||
// Use the same app_id as the Tauri app for keyring access
|
|
||||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
|
||||||
|
|
||||||
// Initialize plugin manager for template functions
|
|
||||||
let vendored_plugin_dir = data_dir.join("vendored-plugins");
|
|
||||||
let installed_plugin_dir = data_dir.join("installed-plugins");
|
|
||||||
|
|
||||||
// Use system node for CLI (must be in PATH)
|
|
||||||
let node_bin_path = PathBuf::from("node");
|
|
||||||
|
|
||||||
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
|
|
||||||
let plugin_runtime_main =
|
|
||||||
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
|
||||||
// Development fallback: look relative to crate root
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create plugin manager (plugins may not be available in CLI context)
|
|
||||||
let plugin_manager = Arc::new(
|
|
||||||
PluginManager::new(
|
|
||||||
vendored_plugin_dir,
|
|
||||||
installed_plugin_dir,
|
|
||||||
node_bin_path,
|
|
||||||
plugin_runtime_main,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initialize plugins from database
|
|
||||||
let plugins = db.list_plugins().unwrap_or_default();
|
|
||||||
if !plugins.is_empty() {
|
|
||||||
let errors =
|
|
||||||
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
|
|
||||||
for (plugin_dir, error_msg) in errors {
|
|
||||||
eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg);
|
|
||||||
}
|
}
|
||||||
|
Commands::Workspace(args) => {
|
||||||
|
commands::workspace::run(&context, args);
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Commands::Request(args) => {
|
||||||
|
commands::request::run(&context, args, environment.as_deref(), verbose).await;
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Commands::Folder(_) => {
|
||||||
|
eprintln!("Folder commands are not implemented yet");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
Commands::Environment(_) => {
|
||||||
|
eprintln!("Environment commands are not implemented yet");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
context.shutdown().await;
|
||||||
|
|
||||||
|
if exit_code != 0 {
|
||||||
|
std::process::exit(exit_code);
|
||||||
}
|
}
|
||||||
|
|
||||||
match cli.command {
|
|
||||||
Commands::Workspaces => {
|
|
||||||
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
|
|
||||||
if workspaces.is_empty() {
|
|
||||||
println!("No workspaces found");
|
|
||||||
} else {
|
|
||||||
for ws in workspaces {
|
|
||||||
println!("{} - {}", ws.id, ws.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Commands::Requests { workspace_id } => {
|
|
||||||
let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests");
|
|
||||||
if requests.is_empty() {
|
|
||||||
println!("No requests found in workspace {}", workspace_id);
|
|
||||||
} else {
|
|
||||||
for req in requests {
|
|
||||||
println!("{} - {} {}", req.id, req.method, req.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Commands::Send { request_id } => {
|
|
||||||
let request = db.get_http_request(&request_id).expect("Failed to get request");
|
|
||||||
|
|
||||||
// Resolve environment chain for variable substitution
|
|
||||||
let environment_chain = db
|
|
||||||
.resolve_environments(
|
|
||||||
&request.workspace_id,
|
|
||||||
request.folder_id.as_deref(),
|
|
||||||
cli.environment.as_deref(),
|
|
||||||
)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Create template callback with plugin support
|
|
||||||
let plugin_context = PluginContext::new(None, Some(request.workspace_id.clone()));
|
|
||||||
let template_callback = PluginTemplateCallback::new(
|
|
||||||
plugin_manager.clone(),
|
|
||||||
encryption_manager.clone(),
|
|
||||||
&plugin_context,
|
|
||||||
RenderPurpose::Send,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render templates in the request
|
|
||||||
let rendered_request = render_http_request(
|
|
||||||
&request,
|
|
||||||
environment_chain,
|
|
||||||
&template_callback,
|
|
||||||
&RenderOptions::throw(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("Failed to render request templates");
|
|
||||||
|
|
||||||
if cli.verbose {
|
|
||||||
println!("> {} {}", rendered_request.method, rendered_request.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to sendable request
|
|
||||||
let sendable = SendableHttpRequest::from_http_request(
|
|
||||||
&rendered_request,
|
|
||||||
SendableHttpRequestOptions::default(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("Failed to build request");
|
|
||||||
|
|
||||||
// Create event channel for progress
|
|
||||||
let (event_tx, mut event_rx) = mpsc::channel(100);
|
|
||||||
|
|
||||||
// Spawn task to print events if verbose
|
|
||||||
let verbose = cli.verbose;
|
|
||||||
let verbose_handle = if verbose {
|
|
||||||
Some(tokio::spawn(async move {
|
|
||||||
while let Some(event) = event_rx.recv().await {
|
|
||||||
println!("{}", event);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
// Drain events silently
|
|
||||||
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send the request
|
|
||||||
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
|
||||||
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
|
|
||||||
|
|
||||||
// Wait for event handler to finish
|
|
||||||
if let Some(handle) = verbose_handle {
|
|
||||||
let _ = handle.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print response
|
|
||||||
if verbose {
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"HTTP {} {}",
|
|
||||||
response.status,
|
|
||||||
response.status_reason.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
for (name, value) in &response.headers {
|
|
||||||
println!("{}: {}", name, value);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print body
|
|
||||||
let (body, _stats) = response.text().await.expect("Failed to read response body");
|
|
||||||
println!("{}", body);
|
|
||||||
}
|
|
||||||
Commands::Get { url } => {
|
|
||||||
if cli.verbose {
|
|
||||||
println!("> GET {}", url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a simple GET request
|
|
||||||
let sendable = SendableHttpRequest {
|
|
||||||
url: url.clone(),
|
|
||||||
method: "GET".to_string(),
|
|
||||||
headers: vec![],
|
|
||||||
body: None,
|
|
||||||
options: SendableHttpRequestOptions::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create event channel for progress
|
|
||||||
let (event_tx, mut event_rx) = mpsc::channel(100);
|
|
||||||
|
|
||||||
// Spawn task to print events if verbose
|
|
||||||
let verbose = cli.verbose;
|
|
||||||
let verbose_handle = if verbose {
|
|
||||||
Some(tokio::spawn(async move {
|
|
||||||
while let Some(event) = event_rx.recv().await {
|
|
||||||
println!("{}", event);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
} else {
|
|
||||||
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send the request
|
|
||||||
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
|
|
||||||
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
|
|
||||||
|
|
||||||
if let Some(handle) = verbose_handle {
|
|
||||||
let _ = handle.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print response
|
|
||||||
if verbose {
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"HTTP {} {}",
|
|
||||||
response.status,
|
|
||||||
response.status_reason.as_deref().unwrap_or("")
|
|
||||||
);
|
|
||||||
|
|
||||||
if verbose {
|
|
||||||
for (name, value) in &response.headers {
|
|
||||||
println!("{}: {}", name, value);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print body
|
|
||||||
let (body, _stats) = response.text().await.expect("Failed to read response body");
|
|
||||||
println!("{}", body);
|
|
||||||
}
|
|
||||||
Commands::Create { workspace_id, name, method, url } => {
|
|
||||||
let request = HttpRequest {
|
|
||||||
workspace_id,
|
|
||||||
name,
|
|
||||||
method: method.to_uppercase(),
|
|
||||||
url,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let created = db
|
|
||||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
|
||||||
.expect("Failed to create request");
|
|
||||||
|
|
||||||
println!("Created request: {}", created.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminate plugin manager gracefully
|
|
||||||
plugin_manager.terminate().await;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user