Compare commits

..

5 Commits

Author SHA1 Message Date
Gregory Schier
0702864a11 Add contribution policy docs and PR checklist template 2026-02-20 14:08:34 -08:00
Gregory Schier
487e66faa4 Use workspace dependency for schemars 2026-02-20 09:01:36 -08:00
Gregory Schier
f71a3ea8fe Remove completed CLI plan doc 2026-02-20 08:57:11 -08:00
Gregory Schier
39fc9e81cd Refine shared plugin event routing API 2026-02-20 08:30:41 -08:00
Gregory Schier
a4f96fca11 Implement CLI send flows and refactor plugin event handling 2026-02-20 08:06:48 -08:00
53 changed files with 229 additions and 4101 deletions

View File

@@ -1,59 +0,0 @@
name: Release API to NPM
on:
push:
tags: [yaak-api-*]
workflow_dispatch:
inputs:
version:
description: API version to publish (for example 0.9.0 or v0.9.0)
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
name: Publish @yaakapp/api
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Install dependencies
run: npm ci
- name: Set @yaakapp/api version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-api-}"
fi
VERSION="${VERSION#v}"
echo "Preparing @yaakapp/api version: $VERSION"
cd packages/plugin-runtime-types
npm version "$VERSION" --no-git-tag-version --allow-same-version
- name: Build @yaakapp/api
working-directory: packages/plugin-runtime-types
run: npm run build
- name: Publish @yaakapp/api
working-directory: packages/plugin-runtime-types
run: npm publish --provenance --access public

View File

@@ -1,218 +0,0 @@
name: Release CLI to NPM
on:
push:
tags: [yaak-cli-*]
workflow_dispatch:
inputs:
version:
description: CLI version to publish (for example 0.4.0 or v0.4.0)
required: true
type: string
permissions:
contents: read
jobs:
prepare-vendored-assets:
name: Prepare vendored plugin assets
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies
run: npm ci
- name: Build plugin assets
env:
SKIP_WASM_BUILD: "1"
run: |
npm run build
npm run vendor:vendor-plugins
- name: Upload vendored assets
uses: actions/upload-artifact@v4
with:
name: vendored-assets
path: |
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
crates-tauri/yaak-app/vendored/plugins
if-no-files-found: error
build-binaries:
name: Build ${{ matrix.pkg }}
needs: prepare-vendored-assets
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- pkg: cli-darwin-arm64
runner: macos-latest
target: aarch64-apple-darwin
binary: yaak
- pkg: cli-darwin-x64
runner: macos-latest
target: x86_64-apple-darwin
binary: yaak
- pkg: cli-linux-arm64
runner: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
binary: yaak
- pkg: cli-linux-x64
runner: ubuntu-22.04
target: x86_64-unknown-linux-gnu
binary: yaak
- pkg: cli-win32-arm64
runner: windows-latest
target: aarch64-pc-windows-msvc
binary: yaak.exe
- pkg: cli-win32-x64
runner: windows-latest
target: x86_64-pc-windows-msvc
binary: yaak.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: release-cli-npm
cache-on-failure: true
- name: Install Linux build dependencies
if: startsWith(matrix.runner, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev
- name: Download vendored assets
uses: actions/download-artifact@v4
with:
name: vendored-assets
path: crates-tauri/yaak-app/vendored
- name: Set CLI build version
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi
VERSION="${VERSION#v}"
echo "Building yaak version: $VERSION"
echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Build yaak
run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
- name: Stage binary artifact
shell: bash
run: |
set -euo pipefail
mkdir -p "npm/dist/${{ matrix.pkg }}"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.pkg }}
path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
if-no-files-found: error
publish-npm:
name: Publish @yaakapp/cli packages
needs: build-binaries
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
registry-url: https://registry.npmjs.org
- name: Download binary artifacts
uses: actions/download-artifact@v4
with:
pattern: cli-*
path: npm/dist
merge-multiple: false
- name: Prepare npm packages
shell: bash
env:
WORKFLOW_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="$WORKFLOW_VERSION"
else
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
fi
VERSION="${VERSION#v}"
if [[ "$VERSION" == *-* ]]; then
PRERELEASE="${VERSION#*-}"
NPM_TAG="${PRERELEASE%%.*}"
else
NPM_TAG="latest"
fi
echo "Preparing CLI npm packages for version: $VERSION"
echo "Publishing with npm dist-tag: $NPM_TAG"
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
- name: Publish @yaakapp/cli-darwin-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-darwin-arm64
- name: Publish @yaakapp/cli-darwin-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-darwin-x64
- name: Publish @yaakapp/cli-linux-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-linux-arm64
- name: Publish @yaakapp/cli-linux-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-linux-x64
- name: Publish @yaakapp/cli-win32-arm64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-win32-arm64
- name: Publish @yaakapp/cli-win32-x64
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli-win32-x64
- name: Publish @yaakapp/cli
run: npm publish --provenance --access public --tag "$NPM_TAG"
working-directory: npm/cli

View File

@@ -1,4 +1,4 @@
name: Release App Artifacts
name: Generate Artifacts
on:
push:
tags: [v*]

1986
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,32 +5,19 @@ edition = "2024"
publish = false
[[bin]]
name = "yaak"
name = "yaakcli"
path = "src/main.rs"
[dependencies]
base64 = "0.22"
clap = { version = "4", features = ["derive"] }
console = "0.15"
dirs = "6"
env_logger = "0.11"
futures = "0.3"
hex = { workspace = true }
include_dir = "0.7"
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
log = { workspace = true }
rand = "0.8"
reqwest = { workspace = true }
rolldown = "0.1.0"
oxc_resolver = "=11.10.0"
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "net", "signal", "time"] }
walkdir = "2"
webbrowser = "1"
zip = "4"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
yaak = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }

View File

@@ -2,9 +2,8 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "yaak")]
#[command(name = "yaakcli")]
#[command(about = "Yaak CLI - API client from the command line")]
#[command(version = crate::version::cli_version())]
pub struct Cli {
/// Use a custom data directory
#[arg(long, global = true)]
@@ -24,18 +23,6 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
/// Authentication commands
Auth(AuthArgs),
/// Plugin development and publishing commands
Plugin(PluginArgs),
#[command(hide = true)]
Build(PluginPathArg),
#[command(hide = true)]
Dev(PluginPathArg),
/// Send a request, folder, or workspace by ID
Send(SendArgs),
@@ -154,10 +141,6 @@ pub enum RequestCommands {
Schema {
#[arg(value_enum)]
request_type: RequestSchemaType,
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Create a new HTTP request
@@ -322,59 +305,3 @@ pub enum EnvironmentCommands {
yes: bool,
},
}
#[derive(Args)]
pub struct AuthArgs {
#[command(subcommand)]
pub command: AuthCommands,
}
#[derive(Subcommand)]
pub enum AuthCommands {
/// Login to Yaak via web browser
Login,
/// Sign out of the Yaak CLI
Logout,
/// Print the current logged-in user's info
Whoami,
}
#[derive(Args)]
pub struct PluginArgs {
#[command(subcommand)]
pub command: PluginCommands,
}
#[derive(Subcommand)]
pub enum PluginCommands {
/// Transpile code into a runnable plugin bundle
Build(PluginPathArg),
/// Build plugin bundle continuously when the filesystem changes
Dev(PluginPathArg),
/// Generate a "Hello World" Yaak plugin
Generate(GenerateArgs),
/// Publish a Yaak plugin version to the plugin registry
Publish(PluginPathArg),
}
#[derive(Args, Clone)]
pub struct PluginPathArg {
/// Path to plugin directory (defaults to current working directory)
pub path: Option<PathBuf>,
}
#[derive(Args, Clone)]
pub struct GenerateArgs {
/// Plugin name (defaults to a generated name in interactive mode)
#[arg(long)]
pub name: Option<String>,
/// Output directory for the generated plugin (defaults to ./<name> in interactive mode)
#[arg(long)]
pub dir: Option<PathBuf>,
}

View File

@@ -1,528 +0,0 @@
use crate::cli::{AuthArgs, AuthCommands};
use crate::ui;
use crate::utils::http;
use base64::Engine as _;
use keyring::Entry;
use rand::RngCore;
use rand::rngs::OsRng;
use reqwest::Url;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::io::{self, IsTerminal, Write};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
const OAUTH_CLIENT_ID: &str = "a1fe44800c2d7e803cad1b4bf07a291c";
const KEYRING_USER: &str = "yaak";
const AUTH_TIMEOUT: Duration = Duration::from_secs(300);
const MAX_REQUEST_BYTES: usize = 16 * 1024;
type CommandResult<T = ()> = std::result::Result<T, String>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment {
Production,
Staging,
Development,
}
impl Environment {
fn app_base_url(self) -> &'static str {
match self {
Environment::Production => "https://yaak.app",
Environment::Staging => "https://todo.yaak.app",
Environment::Development => "http://localhost:9444",
}
}
fn api_base_url(self) -> &'static str {
match self {
Environment::Production => "https://api.yaak.app",
Environment::Staging => "https://todo.yaak.app",
Environment::Development => "http://localhost:9444",
}
}
fn keyring_service(self) -> &'static str {
match self {
Environment::Production => "app.yaak.cli.Token",
Environment::Staging => "app.yaak.cli.staging.Token",
Environment::Development => "app.yaak.cli.dev.Token",
}
}
}
struct OAuthFlow {
app_base_url: String,
auth_url: Url,
token_url: String,
redirect_url: String,
state: String,
code_verifier: String,
}
pub async fn run(args: AuthArgs) -> i32 {
let result = match args.command {
AuthCommands::Login => login().await,
AuthCommands::Logout => logout(),
AuthCommands::Whoami => whoami().await,
};
match result {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
async fn login() -> CommandResult {
let environment = current_environment();
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Failed to start OAuth callback server: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("Failed to determine callback server port: {e}"))?
.port();
let oauth = build_oauth_flow(environment, port)?;
ui::info(&format!("Initiating login to {}", oauth.auth_url));
if !confirm_open_browser()? {
ui::info("Login canceled");
return Ok(());
}
if let Err(err) = webbrowser::open(oauth.auth_url.as_ref()) {
ui::warning(&format!("Failed to open browser: {err}"));
ui::info(&format!("Open this URL manually:\n{}", oauth.auth_url));
}
ui::info("Waiting for authentication...");
let code = tokio::select! {
result = receive_oauth_code(listener, &oauth.state, &oauth.app_base_url) => result?,
_ = tokio::signal::ctrl_c() => {
return Err("Interrupted by user".to_string());
}
_ = tokio::time::sleep(AUTH_TIMEOUT) => {
return Err("Timeout waiting for authentication".to_string());
}
};
let token = exchange_access_token(&oauth, &code).await?;
store_auth_token(environment, &token)?;
ui::success("Authentication successful!");
Ok(())
}
fn logout() -> CommandResult {
delete_auth_token(current_environment())?;
ui::success("Signed out of Yaak");
Ok(())
}
async fn whoami() -> CommandResult {
let environment = current_environment();
let token = match get_auth_token(environment)? {
Some(token) => token,
None => {
ui::warning("Not logged in");
ui::info("Please run `yaak auth login`");
return Ok(());
}
};
let url = format!("{}/api/v1/whoami", environment.api_base_url());
let response = http::build_client(Some(&token))?
.get(url)
.send()
.await
.map_err(|e| format!("Failed to call whoami endpoint: {e}"))?;
let status = response.status();
let body =
response.text().await.map_err(|e| format!("Failed to read whoami response body: {e}"))?;
if !status.is_success() {
if status.as_u16() == 401 {
let _ = delete_auth_token(environment);
return Err(
"Unauthorized to access CLI. Run `yaak auth login` to refresh credentials."
.to_string(),
);
}
return Err(http::parse_api_error(status.as_u16(), &body));
}
println!("{body}");
Ok(())
}
fn current_environment() -> Environment {
let value = std::env::var("ENVIRONMENT").ok();
parse_environment(value.as_deref())
}
fn parse_environment(value: Option<&str>) -> Environment {
match value {
Some("staging") => Environment::Staging,
Some("development") => Environment::Development,
_ => Environment::Production,
}
}
fn build_oauth_flow(environment: Environment, callback_port: u16) -> CommandResult<OAuthFlow> {
let code_verifier = random_hex(32);
let state = random_hex(24);
let redirect_url = format!("http://127.0.0.1:{callback_port}/oauth/callback");
let code_challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(Sha256::digest(code_verifier.as_bytes()));
let mut auth_url = Url::parse(&format!("{}/login/oauth/authorize", environment.app_base_url()))
.map_err(|e| format!("Failed to build OAuth authorize URL: {e}"))?;
auth_url
.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", OAUTH_CLIENT_ID)
.append_pair("redirect_uri", &redirect_url)
.append_pair("state", &state)
.append_pair("code_challenge_method", "S256")
.append_pair("code_challenge", &code_challenge);
Ok(OAuthFlow {
app_base_url: environment.app_base_url().to_string(),
auth_url,
token_url: format!("{}/login/oauth/access_token", environment.app_base_url()),
redirect_url,
state,
code_verifier,
})
}
async fn receive_oauth_code(
listener: TcpListener,
expected_state: &str,
app_base_url: &str,
) -> CommandResult<String> {
loop {
let (mut stream, _) = listener
.accept()
.await
.map_err(|e| format!("OAuth callback server accept error: {e}"))?;
match parse_callback_request(&mut stream).await {
Ok((state, code)) => {
if state != expected_state {
let _ = write_bad_request(&mut stream, "Invalid OAuth state").await;
continue;
}
let success_redirect = format!("{app_base_url}/login/oauth/success");
write_redirect(&mut stream, &success_redirect)
.await
.map_err(|e| format!("Failed responding to OAuth callback: {e}"))?;
return Ok(code);
}
Err(error) => {
let _ = write_bad_request(&mut stream, &error).await;
if error.starts_with("OAuth provider returned error:") {
return Err(error);
}
}
}
}
}
async fn parse_callback_request(stream: &mut TcpStream) -> CommandResult<(String, String)> {
let target = read_http_target(stream).await?;
if !target.starts_with("/oauth/callback") {
return Err("Expected /oauth/callback path".to_string());
}
let url = Url::parse(&format!("http://127.0.0.1{target}"))
.map_err(|e| format!("Failed to parse callback URL: {e}"))?;
let mut state: Option<String> = None;
let mut code: Option<String> = None;
let mut oauth_error: Option<String> = None;
let mut oauth_error_description: Option<String> = None;
for (k, v) in url.query_pairs() {
if k == "state" {
state = Some(v.into_owned());
} else if k == "code" {
code = Some(v.into_owned());
} else if k == "error" {
oauth_error = Some(v.into_owned());
} else if k == "error_description" {
oauth_error_description = Some(v.into_owned());
}
}
if let Some(error) = oauth_error {
let mut message = format!("OAuth provider returned error: {error}");
if let Some(description) = oauth_error_description.filter(|d| !d.is_empty()) {
message.push_str(&format!(" ({description})"));
}
return Err(message);
}
let state = state.ok_or_else(|| "Missing 'state' query parameter".to_string())?;
let code = code.ok_or_else(|| "Missing 'code' query parameter".to_string())?;
if code.is_empty() {
return Err("Missing 'code' query parameter".to_string());
}
Ok((state, code))
}
async fn read_http_target(stream: &mut TcpStream) -> CommandResult<String> {
let mut buf = vec![0_u8; MAX_REQUEST_BYTES];
let mut total_read = 0_usize;
loop {
let n = stream
.read(&mut buf[total_read..])
.await
.map_err(|e| format!("Failed reading callback request: {e}"))?;
if n == 0 {
break;
}
total_read += n;
if buf[..total_read].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
if total_read == MAX_REQUEST_BYTES {
return Err("OAuth callback request too large".to_string());
}
}
let req = String::from_utf8_lossy(&buf[..total_read]);
let request_line =
req.lines().next().ok_or_else(|| "Invalid callback request line".to_string())?;
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default();
let target = parts.next().unwrap_or_default();
if method != "GET" {
return Err(format!("Expected GET callback request, got '{method}'"));
}
if target.is_empty() {
return Err("Missing callback request target".to_string());
}
Ok(target.to_string())
}
async fn write_bad_request(stream: &mut TcpStream, message: &str) -> std::io::Result<()> {
let body = format!("Failed to authenticate: {message}");
let response = format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await
}
async fn write_redirect(stream: &mut TcpStream, location: &str) -> std::io::Result<()> {
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await
}
async fn exchange_access_token(oauth: &OAuthFlow, code: &str) -> CommandResult<String> {
let response = http::build_client(None)?
.post(&oauth.token_url)
.form(&[
("grant_type", "authorization_code"),
("client_id", OAUTH_CLIENT_ID),
("code", code),
("redirect_uri", oauth.redirect_url.as_str()),
("code_verifier", oauth.code_verifier.as_str()),
])
.send()
.await
.map_err(|e| format!("Failed to exchange OAuth code for access token: {e}"))?;
let status = response.status();
let body =
response.text().await.map_err(|e| format!("Failed to read token response body: {e}"))?;
if !status.is_success() {
return Err(format!(
"Failed to fetch access token: status={} body={}",
status.as_u16(),
body
));
}
let parsed: Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid token response JSON: {e}"))?;
let token = parsed
.get("access_token")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| format!("Token response missing access_token: {body}"))?;
Ok(token.to_string())
}
fn keyring_entry(environment: Environment) -> CommandResult<Entry> {
Entry::new(environment.keyring_service(), KEYRING_USER)
.map_err(|e| format!("Failed to initialize auth keyring entry: {e}"))
}
fn get_auth_token(environment: Environment) -> CommandResult<Option<String>> {
let entry = keyring_entry(environment)?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read auth token: {err}")),
}
}
fn store_auth_token(environment: Environment, token: &str) -> CommandResult {
let entry = keyring_entry(environment)?;
entry.set_password(token).map_err(|e| format!("Failed to store auth token: {e}"))
}
fn delete_auth_token(environment: Environment) -> CommandResult {
let entry = keyring_entry(environment)?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(format!("Failed to delete auth token: {err}")),
}
}
fn random_hex(bytes: usize) -> String {
let mut data = vec![0_u8; bytes];
OsRng.fill_bytes(&mut data);
hex::encode(data)
}
fn confirm_open_browser() -> CommandResult<bool> {
if !io::stdin().is_terminal() {
return Ok(true);
}
loop {
print!("Open default browser? [Y/n]: ");
io::stdout().flush().map_err(|e| format!("Failed to flush stdout: {e}"))?;
let mut input = String::new();
io::stdin().read_line(&mut input).map_err(|e| format!("Failed to read input: {e}"))?;
match input.trim().to_ascii_lowercase().as_str() {
"" | "y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => ui::warning("Please answer y or n"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_mapping() {
assert_eq!(parse_environment(Some("staging")), Environment::Staging);
assert_eq!(parse_environment(Some("development")), Environment::Development);
assert_eq!(parse_environment(Some("production")), Environment::Production);
assert_eq!(parse_environment(None), Environment::Production);
}
#[tokio::test]
async fn parses_callback_request() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
parse_callback_request(&mut stream).await
});
let mut client = TcpStream::connect(addr).await.expect("connect");
client
.write_all(
b"GET /oauth/callback?code=abc123&state=xyz HTTP/1.1\r\nHost: localhost\r\n\r\n",
)
.await
.expect("write");
let parsed = server.await.expect("join").expect("parse");
assert_eq!(parsed.0, "xyz");
assert_eq!(parsed.1, "abc123");
}
#[tokio::test]
async fn parse_callback_request_oauth_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
parse_callback_request(&mut stream).await
});
let mut client = TcpStream::connect(addr).await.expect("connect");
client
.write_all(
b"GET /oauth/callback?error=access_denied&error_description=User%20denied&state=xyz HTTP/1.1\r\nHost: localhost\r\n\r\n",
)
.await
.expect("write");
let err = server.await.expect("join").expect_err("should fail");
assert!(err.contains("OAuth provider returned error: access_denied"));
assert!(err.contains("User denied"));
}
#[tokio::test]
async fn receive_oauth_code_fails_fast_on_provider_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local addr");
let server = tokio::spawn(async move {
receive_oauth_code(listener, "expected-state", "http://localhost:9444").await
});
let mut client = TcpStream::connect(addr).await.expect("connect");
client
.write_all(
b"GET /oauth/callback?error=access_denied&state=expected-state HTTP/1.1\r\nHost: localhost\r\n\r\n",
)
.await
.expect("write");
let result = tokio::time::timeout(std::time::Duration::from_secs(2), server)
.await
.expect("should not timeout")
.expect("join");
let err = result.expect_err("should return oauth error");
assert!(err.contains("OAuth provider returned error: access_denied"));
}
#[test]
fn builds_oauth_flow_with_pkce() {
let flow = build_oauth_flow(Environment::Development, 8080).expect("flow");
assert!(flow.auth_url.as_str().contains("code_challenge_method=S256"));
assert!(
flow.auth_url
.as_str()
.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Foauth%2Fcallback")
);
assert_eq!(flow.redirect_url, "http://127.0.0.1:8080/oauth/callback");
assert_eq!(flow.token_url, "http://localhost:9444/login/oauth/access_token");
}
}

View File

@@ -1,7 +1,5 @@
pub mod auth;
pub mod environment;
pub mod folder;
pub mod plugin;
pub mod request;
pub mod send;
pub mod workspace;

View File

@@ -1,525 +0,0 @@
use crate::cli::{GenerateArgs, PluginArgs, PluginCommands, PluginPathArg};
use crate::ui;
use crate::utils::http;
use keyring::Entry;
use rand::Rng;
use rolldown::{
Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat, Platform,
WatchOption, Watcher,
};
use serde::Deserialize;
use std::collections::HashSet;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use walkdir::WalkDir;
use zip::CompressionMethod;
use zip::write::SimpleFileOptions;
type CommandResult<T = ()> = std::result::Result<T, String>;
const KEYRING_USER: &str = "yaak";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Environment {
Production,
Staging,
Development,
}
impl Environment {
fn api_base_url(self) -> &'static str {
match self {
Environment::Production => "https://api.yaak.app",
Environment::Staging => "https://todo.yaak.app",
Environment::Development => "http://localhost:9444",
}
}
fn keyring_service(self) -> &'static str {
match self {
Environment::Production => "app.yaak.cli.Token",
Environment::Staging => "app.yaak.cli.staging.Token",
Environment::Development => "app.yaak.cli.dev.Token",
}
}
}
pub async fn run_build(args: PluginPathArg) -> i32 {
match build(args).await {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
pub async fn run(args: PluginArgs) -> i32 {
match args.command {
PluginCommands::Build(args) => run_build(args).await,
PluginCommands::Dev(args) => run_dev(args).await,
PluginCommands::Generate(args) => run_generate(args).await,
PluginCommands::Publish(args) => run_publish(args).await,
}
}
pub async fn run_dev(args: PluginPathArg) -> i32 {
match dev(args).await {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
pub async fn run_generate(args: GenerateArgs) -> i32 {
match generate(args) {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
pub async fn run_publish(args: PluginPathArg) -> i32 {
match publish(args).await {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
async fn build(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?;
ui::info(&format!("Building plugin {}...", plugin_dir.display()));
let warnings = build_plugin_bundle(&plugin_dir).await?;
for warning in warnings {
ui::warning(&warning);
}
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
Ok(())
}
async fn dev(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?;
ui::info(&format!("Watching plugin {}...", plugin_dir.display()));
ui::info("Press Ctrl-C to stop");
let bundler = Bundler::new(bundler_options(&plugin_dir, true))
.map_err(|err| format!("Failed to initialize Rolldown watcher: {err}"))?;
let watcher = Watcher::new(vec![Arc::new(Mutex::new(bundler))], None)
.map_err(|err| format!("Failed to start Rolldown watcher: {err}"))?;
watcher.start().await;
Ok(())
}
fn generate(args: GenerateArgs) -> CommandResult {
let default_name = random_name();
let name = match args.name {
Some(name) => name,
None => prompt_with_default("Plugin name", &default_name)?,
};
let default_dir = format!("./{name}");
let output_dir = match args.dir {
Some(dir) => dir,
None => PathBuf::from(prompt_with_default("Plugin dir", &default_dir)?),
};
if output_dir.exists() {
return Err(format!("Plugin directory already exists: {}", output_dir.display()));
}
ui::info(&format!("Generating plugin in {}", output_dir.display()));
fs::create_dir_all(output_dir.join("src"))
.map_err(|e| format!("Failed creating plugin directory {}: {e}", output_dir.display()))?;
write_file(&output_dir.join(".gitignore"), TEMPLATE_GITIGNORE)?;
write_file(
&output_dir.join("package.json"),
&TEMPLATE_PACKAGE_JSON.replace("yaak-plugin-name", &name),
)?;
write_file(&output_dir.join("tsconfig.json"), TEMPLATE_TSCONFIG)?;
write_file(&output_dir.join("README.md"), &TEMPLATE_README.replace("yaak-plugin-name", &name))?;
write_file(
&output_dir.join("src/index.ts"),
&TEMPLATE_INDEX_TS.replace("yaak-plugin-name", &name),
)?;
write_file(&output_dir.join("src/index.test.ts"), TEMPLATE_INDEX_TEST_TS)?;
ui::success("Plugin scaffold generated");
ui::info("Next steps:");
println!(" 1. cd {}", output_dir.display());
println!(" 2. npm install");
println!(" 3. yaak plugin build");
Ok(())
}
async fn publish(args: PluginPathArg) -> CommandResult {
let plugin_dir = resolve_plugin_dir(args.path)?;
ensure_plugin_build_inputs(&plugin_dir)?;
let environment = current_environment();
let token = get_auth_token(environment)?
.ok_or_else(|| "Not logged in. Run `yaak auth login`.".to_string())?;
ui::info(&format!("Building plugin {}...", plugin_dir.display()));
let warnings = build_plugin_bundle(&plugin_dir).await?;
for warning in warnings {
ui::warning(&warning);
}
ui::info("Archiving plugin");
let archive = create_publish_archive(&plugin_dir)?;
ui::info("Uploading plugin");
let url = format!("{}/api/v1/plugins/publish", environment.api_base_url());
let response = http::build_client(Some(&token))?
.post(url)
.header(reqwest::header::CONTENT_TYPE, "application/zip")
.body(archive)
.send()
.await
.map_err(|e| format!("Failed to upload plugin: {e}"))?;
let status = response.status();
let body =
response.text().await.map_err(|e| format!("Failed reading publish response body: {e}"))?;
if !status.is_success() {
return Err(http::parse_api_error(status.as_u16(), &body));
}
let published: PublishResponse = serde_json::from_str(&body)
.map_err(|e| format!("Failed parsing publish response JSON: {e}\nResponse: {body}"))?;
ui::success(&format!("Plugin published {}", published.version));
println!(" -> {}", published.url);
Ok(())
}
#[derive(Deserialize)]
struct PublishResponse {
version: String,
url: String,
}
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
prepare_build_output_dir(plugin_dir)?;
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
}
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
let build_dir = plugin_dir.join("build");
if build_dir.exists() {
fs::remove_dir_all(&build_dir)
.map_err(|e| format!("Failed to clean build directory {}: {e}", build_dir.display()))?;
}
fs::create_dir_all(&build_dir)
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
}
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
BundlerOptions {
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
cwd: Some(plugin_dir.to_path_buf()),
file: Some("build/index.js".to_string()),
format: Some(OutputFormat::Cjs),
platform: Some(Platform::Node),
log_level: Some(LogLevel::Info),
experimental: watch
.then_some(ExperimentalOptions { incremental_build: Some(true), ..Default::default() }),
watch: watch.then_some(WatchOption::default()),
..Default::default()
}
}
fn resolve_plugin_dir(path: Option<PathBuf>) -> CommandResult<PathBuf> {
let cwd =
std::env::current_dir().map_err(|e| format!("Failed to read current directory: {e}"))?;
let candidate = match path {
Some(path) if path.is_absolute() => path,
Some(path) => cwd.join(path),
None => cwd,
};
if !candidate.exists() {
return Err(format!("Plugin directory does not exist: {}", candidate.display()));
}
if !candidate.is_dir() {
return Err(format!("Plugin path is not a directory: {}", candidate.display()));
}
candidate
.canonicalize()
.map_err(|e| format!("Failed to resolve plugin directory {}: {e}", candidate.display()))
}
fn ensure_plugin_build_inputs(plugin_dir: &Path) -> CommandResult {
let package_json = plugin_dir.join("package.json");
if !package_json.is_file() {
return Err(format!(
"{} does not exist. Ensure that you are in a plugin directory.",
package_json.display()
));
}
let entry = plugin_dir.join("src/index.ts");
if !entry.is_file() {
return Err(format!("Required entrypoint missing: {}", entry.display()));
}
Ok(())
}
fn create_publish_archive(plugin_dir: &Path) -> CommandResult<Vec<u8>> {
let required_files = [
"README.md",
"package.json",
"build/index.js",
"src/index.ts",
];
let optional_files = ["package-lock.json"];
let mut selected = HashSet::new();
for required in required_files {
let required_path = plugin_dir.join(required);
if !required_path.is_file() {
return Err(format!("Missing required file: {required}"));
}
selected.insert(required.to_string());
}
for optional in optional_files {
selected.insert(optional.to_string());
}
let cursor = std::io::Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for entry in WalkDir::new(plugin_dir) {
let entry = entry.map_err(|e| format!("Failed walking plugin directory: {e}"))?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let rel = path
.strip_prefix(plugin_dir)
.map_err(|e| format!("Failed deriving relative path for {}: {e}", path.display()))?;
let rel = rel.to_string_lossy().replace('\\', "/");
let keep = rel.starts_with("src/") || rel.starts_with("build/") || selected.contains(&rel);
if !keep {
continue;
}
zip.start_file(rel, options).map_err(|e| format!("Failed adding file to archive: {e}"))?;
let mut file = fs::File::open(path)
.map_err(|e| format!("Failed opening file {}: {e}", path.display()))?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)
.map_err(|e| format!("Failed reading file {}: {e}", path.display()))?;
zip.write_all(&contents).map_err(|e| format!("Failed writing archive contents: {e}"))?;
}
let cursor = zip.finish().map_err(|e| format!("Failed finalizing plugin archive: {e}"))?;
Ok(cursor.into_inner())
}
fn write_file(path: &Path, contents: &str) -> CommandResult {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed creating directory {}: {e}", parent.display()))?;
}
fs::write(path, contents).map_err(|e| format!("Failed writing file {}: {e}", path.display()))
}
fn prompt_with_default(label: &str, default: &str) -> CommandResult<String> {
if !io::stdin().is_terminal() {
return Ok(default.to_string());
}
print!("{label} [{default}]: ");
io::stdout().flush().map_err(|e| format!("Failed to flush stdout: {e}"))?;
let mut input = String::new();
io::stdin().read_line(&mut input).map_err(|e| format!("Failed to read input: {e}"))?;
let trimmed = input.trim();
if trimmed.is_empty() { Ok(default.to_string()) } else { Ok(trimmed.to_string()) }
}
fn current_environment() -> Environment {
match std::env::var("ENVIRONMENT").as_deref() {
Ok("staging") => Environment::Staging,
Ok("development") => Environment::Development,
_ => Environment::Production,
}
}
fn keyring_entry(environment: Environment) -> CommandResult<Entry> {
Entry::new(environment.keyring_service(), KEYRING_USER)
.map_err(|e| format!("Failed to initialize auth keyring entry: {e}"))
}
fn get_auth_token(environment: Environment) -> CommandResult<Option<String>> {
let entry = keyring_entry(environment)?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read auth token: {err}")),
}
}
fn random_name() -> String {
const ADJECTIVES: &[&str] = &[
"young", "youthful", "yellow", "yielding", "yappy", "yawning", "yummy", "yucky", "yearly",
"yester", "yeasty", "yelling",
];
const NOUNS: &[&str] = &[
"yak", "yarn", "year", "yell", "yoke", "yoga", "yam", "yacht", "yodel",
];
let mut rng = rand::thread_rng();
let adjective = ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())];
let noun = NOUNS[rng.gen_range(0..NOUNS.len())];
format!("{adjective}-{noun}")
}
const TEMPLATE_GITIGNORE: &str = "node_modules\n";
const TEMPLATE_PACKAGE_JSON: &str = r#"{
"name": "yaak-plugin-name",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaak plugin build",
"dev": "yaak plugin dev"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3",
"vitest": "^4.0.14"
},
"dependencies": {
"@yaakapp/api": "^0.7.0"
}
}
"#;
const TEMPLATE_TSCONFIG: &str = r#"{
"compilerOptions": {
"target": "es2021",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"useDefineForClassFields": true,
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
"#;
const TEMPLATE_README: &str = r#"# yaak-plugin-name
Describe what your plugin does.
"#;
const TEMPLATE_INDEX_TS: &str = r#"import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: "Hello, From Plugin",
icon: "info",
async onSelect(ctx, args) {
await ctx.toast.show({
color: "success",
message: `You clicked the request ${args.httpRequest.id}`,
});
},
},
],
};
"#;
const TEMPLATE_INDEX_TEST_TS: &str = r#"import { describe, expect, test } from "vitest";
import { plugin } from "./index";
describe("Example Plugin", () => {
test("Exports plugin object", () => {
expect(plugin).toBeTypeOf("object");
});
});
"#;
#[cfg(test)]
mod tests {
use super::create_publish_archive;
use std::collections::HashSet;
use std::fs;
use std::io::Cursor;
use tempfile::TempDir;
use zip::ZipArchive;
#[test]
fn publish_archive_includes_required_and_optional_files() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("src")).expect("create src");
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("ignored")).expect("create ignored");
fs::write(root.join("README.md"), "# Demo\n").expect("write README");
fs::write(root.join("package.json"), "{}").expect("write package.json");
fs::write(root.join("package-lock.json"), "{}").expect("write package-lock.json");
fs::write(root.join("src/index.ts"), "export const plugin = {};\n")
.expect("write src/index.ts");
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
.expect("write build/index.js");
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
let archive = create_publish_archive(root).expect("create archive");
let mut zip = ZipArchive::new(Cursor::new(archive)).expect("open zip");
let mut names = HashSet::new();
for i in 0..zip.len() {
let file = zip.by_index(i).expect("zip entry");
names.insert(file.name().to_string());
}
assert!(names.contains("README.md"));
assert!(names.contains("package.json"));
assert!(names.contains("package-lock.json"));
assert!(names.contains("src/index.ts"));
assert!(names.contains("build/index.js"));
assert!(!names.contains("ignored/secret.txt"));
}
}

View File

@@ -35,8 +35,8 @@ pub async fn run(
}
};
}
RequestCommands::Schema { request_type, pretty } => {
return match schema(ctx, request_type, pretty).await {
RequestCommands::Schema { request_type } => {
return match schema(ctx, request_type).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
@@ -75,7 +75,7 @@ fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
Ok(())
}
async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) -> CommandResult {
async fn schema(ctx: &CliContext, request_type: RequestSchemaType) -> CommandResult {
let mut schema = match request_type {
RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))
.map_err(|e| format!("Failed to serialize HTTP request schema: {e}"))?,
@@ -85,53 +85,16 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool)
.map_err(|e| format!("Failed to serialize WebSocket request schema: {e}"))?,
};
enrich_schema_guidance(&mut schema, request_type);
if let Err(error) = merge_auth_schema_from_plugins(ctx, &mut schema).await {
eprintln!("Warning: Failed to enrich authentication schema from plugins: {error}");
}
let output = if pretty {
serde_json::to_string_pretty(&schema)
} else {
serde_json::to_string(&schema)
}
let output = serde_json::to_string_pretty(&schema)
.map_err(|e| format!("Failed to format schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if !matches!(request_type, RequestSchemaType::Http) {
return;
}
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) {
append_description(
url_schema,
"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).",
);
}
}
fn append_description(schema: &mut Map<String, Value>, extra: &str) {
match schema.get_mut("description") {
Some(Value::String(existing)) if !existing.trim().is_empty() => {
if !existing.ends_with(' ') {
existing.push(' ');
}
existing.push_str(extra);
}
_ => {
schema.insert("description".to_string(), Value::String(extra.to_string()));
}
}
}
async fn merge_auth_schema_from_plugins(
ctx: &CliContext,
schema: &mut Value,

View File

@@ -1,6 +1,4 @@
use crate::plugin_events::CliPluginEventBridge;
use include_dir::{Dir, include_dir};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
@@ -11,13 +9,6 @@ use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs"
));
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app/vendored/plugins");
pub struct CliContext {
data_dir: PathBuf,
query_manager: QueryManager,
@@ -42,32 +33,37 @@ impl CliContext {
let installed_plugin_dir = data_dir.join("installed-plugins");
let node_bin_path = PathBuf::from("node");
prepare_embedded_vendored_plugins(&vendored_plugin_dir)
.expect("Failed to prepare bundled plugins");
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
prepare_embedded_plugin_runtime(&data_dir)
.expect("Failed to prepare embedded plugin runtime")
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
});
match PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
false,
)
.await
{
Ok(plugin_manager) => Some(Arc::new(plugin_manager)),
Err(err) => {
eprintln!("Warning: Failed to initialize plugins: {err}");
None
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
);
}
}
Some(plugin_manager)
} else {
None
};
@@ -117,17 +113,3 @@ impl CliContext {
}
}
}
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
fs::create_dir_all(&runtime_dir)?;
let runtime_main = runtime_dir.join("index.cjs");
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
Ok(runtime_main)
}
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(vendored_plugin_dir)?;
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
Ok(())
}

View File

@@ -2,9 +2,7 @@ mod cli;
mod commands;
mod context;
mod plugin_events;
mod ui;
mod utils;
mod version;
use clap::Parser;
use cli::{Cli, Commands, RequestCommands};
@@ -24,15 +22,6 @@ async fn main() {
dirs::data_dir().expect("Could not determine data directory").join(app_id)
});
let needs_context = matches!(
&command,
Commands::Send(_)
| Commands::Workspace(_)
| Commands::Request(_)
| Commands::Folder(_)
| Commands::Environment(_)
);
let needs_plugins = matches!(
&command,
Commands::Send(_)
@@ -41,51 +30,21 @@ async fn main() {
})
);
let context = if needs_context {
Some(CliContext::initialize(data_dir, app_id, needs_plugins).await)
} else {
None
};
let context = CliContext::initialize(data_dir, app_id, needs_plugins).await;
let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Plugin(args) => commands::plugin::run(args).await,
Commands::Build(args) => commands::plugin::run_build(args).await,
Commands::Dev(args) => commands::plugin::run_dev(args).await,
Commands::Send(args) => {
commands::send::run(
context.as_ref().expect("context initialized for send"),
args,
environment.as_deref(),
verbose,
)
.await
commands::send::run(&context, args, environment.as_deref(), verbose).await
}
Commands::Workspace(args) => commands::workspace::run(
context.as_ref().expect("context initialized for workspace"),
args,
),
Commands::Workspace(args) => commands::workspace::run(&context, args),
Commands::Request(args) => {
commands::request::run(
context.as_ref().expect("context initialized for request"),
args,
environment.as_deref(),
verbose,
)
.await
commands::request::run(&context, args, environment.as_deref(), verbose).await
}
Commands::Folder(args) => {
commands::folder::run(context.as_ref().expect("context initialized for folder"), args)
}
Commands::Environment(args) => commands::environment::run(
context.as_ref().expect("context initialized for environment"),
args,
),
Commands::Folder(args) => commands::folder::run(&context, args),
Commands::Environment(args) => commands::environment::run(&context, args),
};
if let Some(context) = &context {
context.shutdown().await;
}
context.shutdown().await;
if exit_code != 0 {
std::process::exit(exit_code);

View File

@@ -1,34 +0,0 @@
use console::style;
use std::io::{self, IsTerminal};
pub fn info(message: &str) {
if io::stdout().is_terminal() {
println!("{:<8} {}", style("INFO").cyan().bold(), style(message).cyan());
} else {
println!("INFO {message}");
}
}
pub fn warning(message: &str) {
if io::stdout().is_terminal() {
println!("{:<8} {}", style("WARNING").yellow().bold(), style(message).yellow());
} else {
println!("WARNING {message}");
}
}
pub fn success(message: &str) {
if io::stdout().is_terminal() {
println!("{:<8} {}", style("SUCCESS").green().bold(), style(message).green());
} else {
println!("SUCCESS {message}");
}
}
pub fn error(message: &str) {
if io::stderr().is_terminal() {
eprintln!("{:<8} {}", style("ERROR").red().bold(), style(message).red());
} else {
eprintln!("Error: {message}");
}
}

View File

@@ -1,47 +0,0 @@
use reqwest::Client;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, USER_AGENT};
use serde_json::Value;
pub fn build_client(session_token: Option<&str>) -> Result<Client, String> {
let mut headers = HeaderMap::new();
let user_agent = HeaderValue::from_str(&user_agent())
.map_err(|e| format!("Failed to build user-agent header: {e}"))?;
headers.insert(USER_AGENT, user_agent);
if let Some(token) = session_token {
let token_value = HeaderValue::from_str(token)
.map_err(|e| format!("Failed to build session header: {e}"))?;
headers.insert(HeaderName::from_static("x-yaak-session"), token_value);
}
Client::builder()
.default_headers(headers)
.build()
.map_err(|e| format!("Failed to initialize HTTP client: {e}"))
}
pub fn parse_api_error(status: u16, body: &str) -> String {
if let Ok(value) = serde_json::from_str::<Value>(body) {
if let Some(message) = value.get("message").and_then(Value::as_str) {
return message.to_string();
}
if let Some(error) = value.get("error").and_then(Value::as_str) {
return error.to_string();
}
}
format!("API error {status}: {body}")
}
fn user_agent() -> String {
format!("YaakCli/{} ({})", crate::version::cli_version(), ua_platform())
}
fn ua_platform() -> &'static str {
match std::env::consts::OS {
"windows" => "Win",
"darwin" => "Mac",
"linux" => "Linux",
_ => "Unknown",
}
}

View File

@@ -1,3 +1,2 @@
pub mod confirm;
pub mod http;
pub mod json;

View File

@@ -1,3 +0,0 @@
pub fn cli_version() -> &'static str {
option_env!("YAAK_CLI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
}

View File

@@ -10,7 +10,7 @@ use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
pub fn cli_cmd(data_dir: &Path) -> Command {
let mut cmd = cargo_bin_cmd!("yaak");
let mut cmd = cargo_bin_cmd!("yaakcli");
cmd.arg("--data-dir").arg(data_dir);
cmd
}

View File

@@ -189,21 +189,6 @@ fn request_schema_http_outputs_json_schema() {
.args(["request", "schema", "http"])
.assert()
.success()
.stdout(contains("\"type\":\"object\""))
.stdout(contains("\"authentication\":"))
.stdout(contains("/foo/:id/comments/:commentId"))
.stdout(contains("put concrete values in `urlParameters`"));
}
#[test]
fn request_schema_http_pretty_prints_with_flag() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["request", "schema", "http", "--pretty"])
.assert()
.success()
.stdout(contains("\"type\": \"object\""))
.stdout(contains("\"authentication\""));
}

View File

@@ -23,11 +23,12 @@ use tokio::sync::Mutex;
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_models::models::Plugin;
use yaak_models::util::UpdateSource;
use yaak_plugins::api::{
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
search_plugins,
};
use yaak_plugins::events::PluginContext;
use yaak_plugins::events::{Color, Icon, PluginContext, ShowToastRequest};
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::plugin_meta::get_plugin_meta;
@@ -267,8 +268,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.join("index.cjs");
let dev_mode = is_dev();
let query_manager =
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
// Create plugin manager asynchronously
let app_handle_clone = app_handle.clone();
@@ -278,12 +277,53 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
dev_mode,
)
.await
.expect("Failed to initialize plugins");
.await;
// Initialize all plugins after manager is created
let bundled_dirs = manager
.list_bundled_plugin_dirs()
.await
.expect("Failed to list bundled plugins");
// Ensure all bundled plugins make it into the database
let db = app_handle_clone.db();
for dir in &bundled_dirs {
if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
..Default::default()
},
&UpdateSource::Background,
)
.expect("Failed to upsert bundled plugin");
}
}
// Get all plugins from database and initialize
let plugins = db.list_plugins().expect("Failed to list plugins from database");
drop(db); // Explicitly drop the connection before await
let errors =
manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
// Show toast for any failed plugins
for (plugin_dir, error_msg) in errors {
let plugin_name = plugin_dir.split('/').last().unwrap_or(&plugin_dir);
let toast = ShowToastRequest {
message: format!("Failed to start plugin '{}': {}", plugin_name, error_msg),
color: Some(Color::Danger),
icon: Some(Icon::AlertTriangle),
timeout: Some(10000),
};
if let Err(emit_err) = app_handle_clone.emit("show_toast", toast) {
error!("Failed to emit toast for plugin error: {emit_err:?}");
}
}
app_handle_clone.manage(manager);
});

View File

@@ -55,7 +55,6 @@ mod tests {
let mut out = Vec::new();
super::collect_any_types(json, &mut out);
out.sort();
assert_eq!(out, vec!["foo.bar", "mount_source.MountSourceRBDVolume"]);
}
}

View File

@@ -611,8 +611,6 @@ pub struct Environment {
pub base: bool,
pub parent_model: String,
pub parent_id: Option<String>,
/// Variables defined in this environment scope.
/// Child environments override parent variables by name.
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
pub sort_priority: f64,
@@ -847,8 +845,6 @@ pub struct HttpUrlParameter {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
/// Other entries are appended as query parameters
pub name: String,
pub value: String,
#[ts(optional, as = "Option<String>")]
@@ -881,7 +877,6 @@ pub struct HttpRequest {
pub name: String,
pub sort_priority: f64,
pub url: String,
/// URL parameters used for both path placeholders (`:id`) and query string entries.
pub url_parameters: Vec<HttpUrlParameter>,
}
@@ -1123,7 +1118,6 @@ pub struct WebsocketRequest {
pub name: String,
pub sort_priority: f64,
pub url: String,
/// URL parameters used for both path placeholders (`:id`) and query string entries.
pub url_parameters: Vec<HttpUrlParameter>,
}
@@ -1734,7 +1728,6 @@ pub struct GrpcRequest {
pub name: String,
pub service: Option<String>,
pub sort_priority: f64,
/// Server URL (http for plaintext or https for secure)
pub url: String,
}

View File

@@ -34,8 +34,7 @@ use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, timeout};
use yaak_models::models::Plugin;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{UpdateSource, generate_id};
use yaak_models::util::generate_id;
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result as TemplateResult;
@@ -62,18 +61,14 @@ impl PluginManager {
/// * `installed_plugin_dir` - Path to installed plugins directory
/// * `node_bin_path` - Path to the yaaknode binary
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
/// * `query_manager` - Query manager for bundled plugin registration and loading
/// * `plugin_context` - Context to use while initializing plugins
/// * `dev_mode` - Whether the app is in dev mode (affects plugin loading)
pub async fn new(
vendored_plugin_dir: PathBuf,
installed_plugin_dir: PathBuf,
node_bin_path: PathBuf,
plugin_runtime_main: PathBuf,
query_manager: &QueryManager,
plugin_context: &PluginContext,
dev_mode: bool,
) -> Result<PluginManager> {
) -> PluginManager {
let (events_tx, mut events_rx) = mpsc::channel(2048);
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
let (killed_tx, killed_rx) = oneshot::channel();
@@ -156,40 +151,12 @@ impl PluginManager {
&kill_server_rx,
killed_tx,
)
.await?;
.await
.unwrap();
info!("Waiting for plugins to initialize");
init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?;
init_plugins_task.await.unwrap();
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect();
for dir in bundled_dirs {
if db.get_plugin_by_directory(&dir).is_none() {
db.upsert_plugin(
&Plugin {
directory: dir,
enabled: true,
url: None,
..Default::default()
},
&UpdateSource::Background,
)?;
}
}
let plugins = db.list_plugins()?;
drop(db);
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
if !init_errors.is_empty() {
let joined = init_errors
.into_iter()
.map(|(dir, err)| format!("{dir}: {err}"))
.collect::<Vec<_>>()
.join("; ");
return Err(PluginErr(format!("Failed to initialize plugin(s): {joined}")));
}
Ok(plugin_manager)
plugin_manager
}
/// Get the vendored plugin directory path (resolves dev mode path if applicable)

View File

@@ -1,7 +0,0 @@
# Yaak CLI NPM Packages
The Rust `yaak` CLI binary is published to NPM with a meta package (`@yaakapp/cli`) and
platform-specific optional dependency packages. The package exposes both `yaak` and `yaakcli`
commands for compatibility.
This follows the same strategy previously used in the standalone `yaak-cli` repo.

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/cli-darwin-arm64",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"os": ["darwin"],
"cpu": ["arm64"]
}

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/cli-darwin-x64",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"os": ["darwin"],
"cpu": ["x64"]
}

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/cli-linux-arm64",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"os": ["linux"],
"cpu": ["arm64"]
}

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/cli-linux-x64",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"os": ["linux"],
"cpu": ["x64"]
}

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/cli-win32-arm64",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"os": ["win32"],
"cpu": ["arm64"]
}

View File

@@ -1,10 +0,0 @@
{
"name": "@yaakapp/cli-win32-x64",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"os": ["win32"],
"cpu": ["x64"]
}

2
npm/cli/.gitignore vendored
View File

@@ -1,2 +0,0 @@
yaak
yaak.exe

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env node
const path = require("path");
const childProcess = require("child_process");
const { BINARY_NAME, PLATFORM_SPECIFIC_PACKAGE_NAME } = require("../common");
function getBinaryPath() {
try {
if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {
throw new Error("unsupported platform");
}
return require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`);
} catch (_) {
return path.join(__dirname, "..", BINARY_NAME);
}
}
const result = childProcess.spawnSync(getBinaryPath(), process.argv.slice(2), {
stdio: "inherit"
});
if (result.error) {
throw result.error;
}
if (result.signal) {
process.kill(process.pid, result.signal);
}
process.exit(result.status ?? 1);

View File

@@ -1,20 +0,0 @@
const BINARY_DISTRIBUTION_PACKAGES = {
darwin_arm64: "@yaakapp/cli-darwin-arm64",
darwin_x64: "@yaakapp/cli-darwin-x64",
linux_arm64: "@yaakapp/cli-linux-arm64",
linux_x64: "@yaakapp/cli-linux-x64",
win32_x64: "@yaakapp/cli-win32-x64",
win32_arm64: "@yaakapp/cli-win32-arm64"
};
const BINARY_DISTRIBUTION_VERSION = require("./package.json").version;
const BINARY_NAME = process.platform === "win32" ? "yaak.exe" : "yaak";
const PLATFORM_SPECIFIC_PACKAGE_NAME =
BINARY_DISTRIBUTION_PACKAGES[`${process.platform}_${process.arch}`];
module.exports = {
BINARY_DISTRIBUTION_PACKAGES,
BINARY_DISTRIBUTION_VERSION,
BINARY_NAME,
PLATFORM_SPECIFIC_PACKAGE_NAME
};

View File

@@ -1,20 +0,0 @@
const path = require("path");
const childProcess = require("child_process");
const { PLATFORM_SPECIFIC_PACKAGE_NAME, BINARY_NAME } = require("./common");
function getBinaryPath() {
try {
if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {
throw new Error("unsupported platform");
}
return require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`);
} catch (_) {
return path.join(__dirname, BINARY_NAME);
}
}
module.exports.runBinary = function runBinary(...args) {
childProcess.execFileSync(getBinaryPath(), args, {
stdio: "inherit"
});
};

View File

@@ -1,97 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const zlib = require("node:zlib");
const https = require("node:https");
const {
BINARY_DISTRIBUTION_VERSION,
BINARY_NAME,
PLATFORM_SPECIFIC_PACKAGE_NAME
} = require("./common");
const fallbackBinaryPath = path.join(__dirname, BINARY_NAME);
function makeRequest(url) {
return new Promise((resolve, reject) => {
https
.get(url, (response) => {
if (response.statusCode >= 200 && response.statusCode < 300) {
const chunks = [];
response.on("data", (chunk) => chunks.push(chunk));
response.on("end", () => resolve(Buffer.concat(chunks)));
} else if (
response.statusCode >= 300 &&
response.statusCode < 400 &&
response.headers.location
) {
makeRequest(response.headers.location).then(resolve, reject);
} else {
reject(
new Error(
`npm responded with status code ${response.statusCode} when downloading package ${url}`
)
);
}
})
.on("error", (error) => reject(error));
});
}
function extractFileFromTarball(tarballBuffer, filepath) {
let offset = 0;
while (offset < tarballBuffer.length) {
const header = tarballBuffer.subarray(offset, offset + 512);
offset += 512;
const fileName = header.toString("utf-8", 0, 100).replace(/\0.*/g, "");
const fileSize = parseInt(header.toString("utf-8", 124, 136).replace(/\0.*/g, ""), 8);
if (fileName === filepath) {
return tarballBuffer.subarray(offset, offset + fileSize);
}
offset = (offset + fileSize + 511) & ~511;
}
return null;
}
async function downloadBinaryFromNpm() {
if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {
throw new Error(`Unsupported platform: ${process.platform}/${process.arch}`);
}
const packageNameWithoutScope = PLATFORM_SPECIFIC_PACKAGE_NAME.split("/")[1];
const tarballUrl = `https://registry.npmjs.org/${PLATFORM_SPECIFIC_PACKAGE_NAME}/-/${packageNameWithoutScope}-${BINARY_DISTRIBUTION_VERSION}.tgz`;
const tarballDownloadBuffer = await makeRequest(tarballUrl);
const tarballBuffer = zlib.unzipSync(tarballDownloadBuffer);
const binary = extractFileFromTarball(tarballBuffer, `package/bin/${BINARY_NAME}`);
if (!binary) {
throw new Error(`Could not find package/bin/${BINARY_NAME} in tarball`);
}
fs.writeFileSync(fallbackBinaryPath, binary);
fs.chmodSync(fallbackBinaryPath, "755");
}
function isPlatformSpecificPackageInstalled() {
try {
if (!PLATFORM_SPECIFIC_PACKAGE_NAME) {
return false;
}
require.resolve(`${PLATFORM_SPECIFIC_PACKAGE_NAME}/bin/${BINARY_NAME}`);
return true;
} catch (_) {
return false;
}
}
if (!isPlatformSpecificPackageInstalled()) {
console.log("Platform package missing. Downloading Yaak CLI binary from npm...");
downloadBinaryFromNpm().catch((err) => {
console.error("Failed to install Yaak CLI binary:", err);
process.exitCode = 1;
});
} else {
console.log("Platform package present. Using bundled Yaak CLI binary.");
}

View File

@@ -1,25 +0,0 @@
{
"name": "@yaakapp/cli",
"version": "0.0.1",
"main": "./index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"scripts": {
"postinstall": "node ./install.js",
"prepublishOnly": "node ./prepublish.js"
},
"bin": {
"yaak": "bin/cli.js",
"yaakcli": "bin/cli.js"
},
"optionalDependencies": {
"@yaakapp/cli-darwin-x64": "0.0.1",
"@yaakapp/cli-darwin-arm64": "0.0.1",
"@yaakapp/cli-linux-arm64": "0.0.1",
"@yaakapp/cli-linux-x64": "0.0.1",
"@yaakapp/cli-win32-x64": "0.0.1",
"@yaakapp/cli-win32-arm64": "0.0.1"
}
}

View File

@@ -1,5 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const readme = path.join(__dirname, "..", "..", "README.md");
fs.copyFileSync(readme, path.join(__dirname, "README.md"));

View File

@@ -1,77 +0,0 @@
const { chmodSync, copyFileSync, existsSync, readFileSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const version = process.env.YAAK_CLI_VERSION?.replace(/^v/, "");
if (!version) {
console.error("YAAK_CLI_VERSION is not set");
process.exit(1);
}
const packages = [
"cli",
"cli-darwin-arm64",
"cli-darwin-x64",
"cli-linux-arm64",
"cli-linux-x64",
"cli-win32-arm64",
"cli-win32-x64"
];
const binaries = [
{
src: join(__dirname, "dist", "cli-darwin-arm64", "yaak"),
dest: join(__dirname, "cli-darwin-arm64", "bin", "yaak")
},
{
src: join(__dirname, "dist", "cli-darwin-x64", "yaak"),
dest: join(__dirname, "cli-darwin-x64", "bin", "yaak")
},
{
src: join(__dirname, "dist", "cli-linux-arm64", "yaak"),
dest: join(__dirname, "cli-linux-arm64", "bin", "yaak")
},
{
src: join(__dirname, "dist", "cli-linux-x64", "yaak"),
dest: join(__dirname, "cli-linux-x64", "bin", "yaak")
},
{
src: join(__dirname, "dist", "cli-win32-arm64", "yaak.exe"),
dest: join(__dirname, "cli-win32-arm64", "bin", "yaak.exe")
},
{
src: join(__dirname, "dist", "cli-win32-x64", "yaak.exe"),
dest: join(__dirname, "cli-win32-x64", "bin", "yaak.exe")
}
];
for (const { src, dest } of binaries) {
if (!existsSync(src)) {
console.error(`Missing binary artifact: ${src}`);
process.exit(1);
}
copyFileSync(src, dest);
if (!dest.endsWith(".exe")) {
chmodSync(dest, 0o755);
}
}
for (const pkg of packages) {
const filepath = join(__dirname, pkg, "package.json");
const json = JSON.parse(readFileSync(filepath, "utf-8"));
json.version = version;
if (json.name === "@yaakapp/cli") {
json.optionalDependencies = {
"@yaakapp/cli-darwin-x64": version,
"@yaakapp/cli-darwin-arm64": version,
"@yaakapp/cli-linux-arm64": version,
"@yaakapp/cli-linux-x64": version,
"@yaakapp/cli-win32-x64": version,
"@yaakapp/cli-win32-arm64": version
};
}
writeFileSync(filepath, `${JSON.stringify(json, null, 2)}\n`);
}
console.log(`Prepared @yaakapp/cli npm packages for ${version}`);

66
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"plugins/template-function-cookie",
"plugins/template-function-ctx",
"plugins/template-function-encode",
"plugins/template-function-faker",
"plugins/template-function-fs",
"plugins/template-function-hash",
"plugins/template-function-json",
@@ -73,7 +74,7 @@
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.4.0-beta.2",
"@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0",
"husky": "^9.1.7",
"nodejs-file-downloader": "^4.13.0",
@@ -4326,28 +4327,27 @@
"link": true
},
"node_modules/@yaakapp/cli": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli/-/cli-0.4.0-beta.2.tgz",
"integrity": "sha512-UXPxTS9oWVCIr4rShC7HjcAX+gSmw/BQ5F1Xp3Rub3vY/G7+513JJsc1HhLGVZqFfOVRSMEKRxtF9/9okSyiHg==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli/-/cli-0.3.4.tgz",
"integrity": "sha512-bSSL3noEfyoPC0M+bj34jbBZbB+gwYLCHL9cf6BYHgkRQKlHFpvN6z8M2jQZljb+CTQdHK0NzosmwHLpjMmAVA==",
"dev": true,
"hasInstallScript": true,
"bin": {
"yaak": "bin/cli.js",
"yaakcli": "bin/cli.js"
},
"optionalDependencies": {
"@yaakapp/cli-darwin-arm64": "0.4.0-beta.2",
"@yaakapp/cli-darwin-x64": "0.4.0-beta.2",
"@yaakapp/cli-linux-arm64": "0.4.0-beta.2",
"@yaakapp/cli-linux-x64": "0.4.0-beta.2",
"@yaakapp/cli-win32-arm64": "0.4.0-beta.2",
"@yaakapp/cli-win32-x64": "0.4.0-beta.2"
"@yaakapp/cli-darwin-arm64": "0.3.4",
"@yaakapp/cli-darwin-x64": "0.3.4",
"@yaakapp/cli-linux-arm64": "0.3.4",
"@yaakapp/cli-linux-x64": "0.3.4",
"@yaakapp/cli-win32-arm64": "0.3.4",
"@yaakapp/cli-win32-x64": "0.3.4"
}
},
"node_modules/@yaakapp/cli-darwin-arm64": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-arm64/-/cli-darwin-arm64-0.4.0-beta.2.tgz",
"integrity": "sha512-mqkyH5tIPRLs9JumP9ZmzjB5gIwmOL1yCDoJ1qVU8DIJ7mwlcQaPGYTK98pVdBcKOjofVakBTcpol9P8rBv4qw==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-arm64/-/cli-darwin-arm64-0.3.4.tgz",
"integrity": "sha512-iTohEO7XSVZwSvTgEQE9my3wGyWtTl1q8yfol7hHwVFTX7G8Geh8X2j2vVokHhj7J9OZL9jtYQWIsM1ekOHSEQ==",
"cpu": [
"arm64"
],
@@ -4358,9 +4358,9 @@
]
},
"node_modules/@yaakapp/cli-darwin-x64": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-x64/-/cli-darwin-x64-0.4.0-beta.2.tgz",
"integrity": "sha512-QI/H2yUF8CkJq+cnRthoUWWTEJPH4QPA78FYcGjFRhvBaj1m2G/GlCA5NkTXm/fvIjNkQEODSihXrhU+zoSSCw==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-x64/-/cli-darwin-x64-0.3.4.tgz",
"integrity": "sha512-gz7IcjFGKA0cCAum1Aq8kmVg7erYYSrZ9pliDw0NZyObjrBysJcsDXLodEU437u0pihtdCfoLsq3rsYYs8uwCA==",
"cpu": [
"x64"
],
@@ -4371,9 +4371,9 @@
]
},
"node_modules/@yaakapp/cli-linux-arm64": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-arm64/-/cli-linux-arm64-0.4.0-beta.2.tgz",
"integrity": "sha512-nvAp97LkgRpqVHyMwDdpkzlKOWG2kJXezCLRZaRWaEpbnNuviSF+0yzCuFGZRHEEspj7B0TiM+sKGkpvjNlweA==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-arm64/-/cli-linux-arm64-0.3.4.tgz",
"integrity": "sha512-Yiwz8PBkXngmr0lTMW1pgy+F/kUISkzvqofdoBseXTrS/GDxoW3ILnG3If30LuIyWWPgqpuU+qKMtbVDzuncPQ==",
"cpu": [
"arm64"
],
@@ -4384,9 +4384,9 @@
]
},
"node_modules/@yaakapp/cli-linux-x64": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-x64/-/cli-linux-x64-0.4.0-beta.2.tgz",
"integrity": "sha512-9/qAMNrtE9glxih3XWGfFssIJpQ4mHNUTuWYKroc0aZZUrunnCw3tX1tQtFDxy0QRIZcGlBeBRtgxuuBd2fYbg==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-x64/-/cli-linux-x64-0.3.4.tgz",
"integrity": "sha512-j7/r18UYNlFChDVU5N5ye3mmL+OR9Uu3LY72JxW+s/SyV69Bo8Griii75Wt19z/jj2ES8pxD+4IJq56VF3wJ7w==",
"cpu": [
"x64"
],
@@ -4397,9 +4397,9 @@
]
},
"node_modules/@yaakapp/cli-win32-arm64": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-arm64/-/cli-win32-arm64-0.4.0-beta.2.tgz",
"integrity": "sha512-eM1zL+hl0y3NBLxWO90y9VyaFsAf0HAsECBWvhKhvEdd6KG4K1XzpXrC30cHQBGePIrCa/az8eSuvTde0Z2C/g==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-arm64/-/cli-win32-arm64-0.3.4.tgz",
"integrity": "sha512-OUSKOKrSnzrTAGW0c+2ZCwA4yhgw/bA+gyeTvpf7cELVuB0qooGkEcJ3lM7fPMKmUbFU0r+K/Ggq1QMUr7cJLQ==",
"cpu": [
"arm64"
],
@@ -4410,9 +4410,9 @@
]
},
"node_modules/@yaakapp/cli-win32-x64": {
"version": "0.4.0-beta.2",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-x64/-/cli-win32-x64-0.4.0-beta.2.tgz",
"integrity": "sha512-ySdiK0h216EqURkM5KZoqbPTgbIX4eNK/IgrKwSazxRb369HOZYQ8X68as+VRxEL4NCMmWlQNdbBDuf+apg/mg==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-x64/-/cli-win32-x64-0.3.4.tgz",
"integrity": "sha512-sVYnW1rROLbzFUCyeZ++ibN+8gJS7FdPnBRHIE0KORfeI4e7Gw/aMUji2qpSZ1gt3DrAU95DDNjBkDvGBAgqag==",
"cpu": [
"x64"
],
@@ -7985,9 +7985,9 @@
}
},
"node_modules/hono": {
"version": "4.11.10",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz",
"integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==",
"version": "4.11.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -16020,7 +16020,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.10",
"hono": "^4.11.7",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@@ -70,6 +70,7 @@
"app-dev": "node scripts/run-dev.mjs",
"migration": "node scripts/create-migration.cjs",
"build": "npm run --workspaces --if-present build",
"build-plugins": "npm run --workspaces --if-present build",
"test": "npm run --workspaces --if-present test",
"icons": "run-p icons:*",
"icons:dev": "tauri icon crates-tauri/yaak-app/icons/icon-dev.png --output crates-tauri/yaak-app/icons/dev",
@@ -97,7 +98,7 @@
"devDependencies": {
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.4.0-beta.2",
"@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0",
"husky": "^9.1.7",
"nodejs-file-downloader": "^4.13.0",

View File

@@ -27,6 +27,7 @@
"build:copy-types": "run-p build:copy-types:*",
"build:copy-types:root": "cpy --flat ../../crates/yaak-plugins/bindings/*.ts ./src/bindings",
"build:copy-types:next": "cpy --flat ../../crates/yaak-plugins/bindings/serde_json/*.ts ./src/bindings/serde_json",
"publish": "npm publish",
"prepublishOnly": "npm run build"
},
"dependencies": {

View File

@@ -29,7 +29,6 @@ const modules = [
function normalizeResult(result: unknown): string {
if (typeof result === 'string') return result;
if (result instanceof Date) return result.toISOString();
return JSON.stringify(result);
}

View File

@@ -9,18 +9,4 @@ describe('template-function-faker', () => {
// accidental additions, removals, or renames across faker upgrades.
expect(names).toMatchSnapshot();
});
it('renders date results as unquoted ISO strings', async () => {
const { plugin } = await import('../src/index');
const fn = plugin.templateFunctions?.find((fn) => fn.name === 'faker.date.future');
expect(fn?.onRender).toBeTypeOf('function');
const result = await fn!.onRender!(
{} as Parameters<NonNullable<typeof fn.onRender>>[0],
{ values: {} } as Parameters<NonNullable<typeof fn.onRender>>[1],
);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
});

View File

@@ -18,7 +18,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.10",
"hono": "^4.11.7",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@@ -11,8 +11,7 @@
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
"dev": "yaakcli dev"
},
"dependencies": {
"httpntlm": "^1.8.13"

View File

@@ -2,16 +2,6 @@ import type { PluginDefinition } from '@yaakapp/api';
import { ntlm } from 'httpntlm';
function extractNtlmChallenge(headers: Array<{ name: string; value: string }>): string | null {
const authValues = headers
.filter((h) => h.name.toLowerCase() === 'www-authenticate')
.flatMap((h) => h.value.split(','))
.map((v) => v.trim())
.filter(Boolean);
return authValues.find((v) => /^NTLM\s+\S+/i.test(v)) ?? null;
}
export const plugin: PluginDefinition = {
authentication: {
name: 'windows',
@@ -78,12 +68,15 @@ export const plugin: PluginDefinition = {
},
});
const ntlmChallenge = extractNtlmChallenge(negotiateResponse.headers);
if (ntlmChallenge == null) {
throw new Error('Unable to find NTLM challenge in WWW-Authenticate response headers');
const wwwAuthenticateHeader = negotiateResponse.headers.find(
(h) => h.name.toLowerCase() === 'www-authenticate',
);
if (!wwwAuthenticateHeader?.value) {
throw new Error('Unable to find www-authenticate response header for NTLM');
}
const type2 = ntlm.parseType2Message(ntlmChallenge, (err: Error | null) => {
const type2 = ntlm.parseType2Message(wwwAuthenticateHeader.value, (err: Error | null) => {
if (err != null) throw err;
});
const type3 = ntlm.createType3Message(type2, options);

View File

@@ -1,84 +0,0 @@
import type { Context } from '@yaakapp/api';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const ntlmMock = vi.hoisted(() => ({
createType1Message: vi.fn(),
parseType2Message: vi.fn(),
createType3Message: vi.fn(),
}));
vi.mock('httpntlm', () => ({ ntlm: ntlmMock }));
import { plugin } from '../src';
describe('auth-ntlm', () => {
beforeEach(() => {
ntlmMock.createType1Message.mockReset();
ntlmMock.parseType2Message.mockReset();
ntlmMock.createType3Message.mockReset();
ntlmMock.createType1Message.mockReturnValue('NTLM TYPE1');
ntlmMock.parseType2Message.mockReturnValue({} as any);
ntlmMock.createType3Message.mockReturnValue('NTLM TYPE3');
});
test('uses NTLM challenge when Negotiate and NTLM headers are separate', async () => {
const send = vi.fn().mockResolvedValue({
headers: [
{ name: 'WWW-Authenticate', value: 'Negotiate' },
{ name: 'WWW-Authenticate', value: 'NTLM TlRMTVNTUAACAAAAAA==' },
],
});
const ctx = { httpRequest: { send } } as unknown as Context;
const result = await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://example.local/resource',
method: 'GET',
contextId: 'ctx',
});
expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(
'NTLM TlRMTVNTUAACAAAAAA==',
expect.any(Function),
);
expect(result).toEqual({ setHeaders: [{ name: 'Authorization', value: 'NTLM TYPE3' }] });
});
test('uses NTLM challenge when auth schemes are comma-separated in one header', async () => {
const send = vi.fn().mockResolvedValue({
headers: [{ name: 'www-authenticate', value: 'Negotiate, NTLM TlRMTVNTUAACAAAAAA==' }],
});
const ctx = { httpRequest: { send } } as unknown as Context;
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://example.local/resource',
method: 'GET',
contextId: 'ctx',
});
expect(ntlmMock.parseType2Message).toHaveBeenCalledWith(
'NTLM TlRMTVNTUAACAAAAAA==',
expect.any(Function),
);
});
test('throws a clear error when NTLM challenge is missing', async () => {
const send = vi.fn().mockResolvedValue({
headers: [{ name: 'WWW-Authenticate', value: 'Negotiate' }],
});
const ctx = { httpRequest: { send } } as unknown as Context;
await expect(
plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://example.local/resource',
method: 'GET',
contextId: 'ctx',
}),
).rejects.toThrow('Unable to find NTLM challenge in WWW-Authenticate response headers');
});
});

View File

@@ -1,4 +1,4 @@
const { readdirSync, cpSync, existsSync, mkdirSync } = require('node:fs');
const { readdirSync, cpSync, existsSync } = require('node:fs');
const path = require('node:path');
const pluginsDir = path.join(__dirname, '..', 'plugins');
@@ -24,7 +24,6 @@ for (const name of readdirSync(pluginsDir)) {
continue;
}
const destDir = path.join(__dirname, '../crates-tauri/yaak-app/vendored/plugins/', name);
mkdirSync(destDir, { recursive: true });
console.log(`Copying ${name} to ${destDir}`);
cpSync(path.join(dir, 'package.json'), path.join(destDir, 'package.json'));
cpSync(path.join(dir, 'build'), path.join(destDir, 'build'), { recursive: true });