mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-23 08:04:52 +01:00
Compare commits
19 Commits
cli-improv
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e7e1232da | ||
|
|
c31d477a90 | ||
|
|
443e1b8262 | ||
|
|
c6b7cb2e32 | ||
|
|
4aef826a80 | ||
|
|
50c7992b42 | ||
|
|
5e9aebda6f | ||
|
|
a1e84c7785 | ||
|
|
fea4411afa | ||
|
|
8315e4afad | ||
|
|
a19ee9b502 | ||
|
|
0130bdee6f | ||
|
|
71ae9f41ed | ||
|
|
d06b6ce636 | ||
|
|
f5727b28c4 | ||
|
|
c62db7be06 | ||
|
|
4e56daa555 | ||
|
|
746bedf885 | ||
|
|
949c4a445a |
59
.github/workflows/release-api-npm.yml
vendored
Normal file
59
.github/workflows/release-api-npm.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Generate Artifacts
|
name: Release App Artifacts
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: [v*]
|
tags: [v*]
|
||||||
218
.github/workflows/release-cli-npm.yml
vendored
Normal file
218
.github/workflows/release-cli-npm.yml
vendored
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
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
|
||||||
1988
Cargo.lock
generated
1988
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,32 @@ edition = "2024"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "yaakcli"
|
name = "yaak"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
console = "0.15"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
hex = { workspace = true }
|
||||||
|
include_dir = "0.7"
|
||||||
|
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
rand = "0.8"
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
rolldown = "0.1.0"
|
||||||
|
oxc_resolver = "=11.10.0"
|
||||||
schemars = { workspace = true }
|
schemars = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
sha2 = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "net", "signal", "time"] }
|
||||||
|
walkdir = "2"
|
||||||
|
webbrowser = "1"
|
||||||
|
zip = "4"
|
||||||
yaak = { workspace = true }
|
yaak = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "yaakcli")]
|
#[command(name = "yaak")]
|
||||||
#[command(about = "Yaak CLI - API client from the command line")]
|
#[command(about = "Yaak CLI - API client from the command line")]
|
||||||
|
#[command(version = crate::version::cli_version())]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Use a custom data directory
|
/// Use a custom data directory
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
@@ -23,6 +24,18 @@ pub struct Cli {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Commands {
|
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 a request, folder, or workspace by ID
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
|
|
||||||
@@ -141,6 +154,10 @@ pub enum RequestCommands {
|
|||||||
Schema {
|
Schema {
|
||||||
#[arg(value_enum)]
|
#[arg(value_enum)]
|
||||||
request_type: RequestSchemaType,
|
request_type: RequestSchemaType,
|
||||||
|
|
||||||
|
/// Pretty-print schema JSON output
|
||||||
|
#[arg(long)]
|
||||||
|
pretty: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Create a new HTTP request
|
/// Create a new HTTP request
|
||||||
@@ -305,3 +322,59 @@ pub enum EnvironmentCommands {
|
|||||||
yes: bool,
|
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>,
|
||||||
|
}
|
||||||
|
|||||||
528
crates-cli/yaak-cli/src/commands/auth.rs
Normal file
528
crates-cli/yaak-cli/src/commands/auth.rs
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
|
pub mod plugin;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
525
crates-cli/yaak-cli/src/commands/plugin.rs
Normal file
525
crates-cli/yaak-cli/src/commands/plugin.rs
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,8 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
RequestCommands::Schema { request_type } => {
|
RequestCommands::Schema { request_type, pretty } => {
|
||||||
return match schema(ctx, request_type).await {
|
return match schema(ctx, request_type, pretty).await {
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("Error: {error}");
|
eprintln!("Error: {error}");
|
||||||
@@ -75,7 +75,7 @@ fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn schema(ctx: &CliContext, request_type: RequestSchemaType) -> CommandResult {
|
async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) -> CommandResult {
|
||||||
let mut schema = match request_type {
|
let mut schema = match request_type {
|
||||||
RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))
|
RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))
|
||||||
.map_err(|e| format!("Failed to serialize HTTP request schema: {e}"))?,
|
.map_err(|e| format!("Failed to serialize HTTP request schema: {e}"))?,
|
||||||
@@ -85,16 +85,53 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType) -> CommandRes
|
|||||||
.map_err(|e| format!("Failed to serialize WebSocket request schema: {e}"))?,
|
.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 {
|
if let Err(error) = merge_auth_schema_from_plugins(ctx, &mut schema).await {
|
||||||
eprintln!("Warning: Failed to enrich authentication schema from plugins: {error}");
|
eprintln!("Warning: Failed to enrich authentication schema from plugins: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = serde_json::to_string_pretty(&schema)
|
let output = if pretty {
|
||||||
|
serde_json::to_string_pretty(&schema)
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&schema)
|
||||||
|
}
|
||||||
.map_err(|e| format!("Failed to format schema JSON: {e}"))?;
|
.map_err(|e| format!("Failed to format schema JSON: {e}"))?;
|
||||||
println!("{output}");
|
println!("{output}");
|
||||||
Ok(())
|
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(
|
async fn merge_auth_schema_from_plugins(
|
||||||
ctx: &CliContext,
|
ctx: &CliContext,
|
||||||
schema: &mut Value,
|
schema: &mut Value,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::plugin_events::CliPluginEventBridge;
|
use crate::plugin_events::CliPluginEventBridge;
|
||||||
|
use include_dir::{Dir, include_dir};
|
||||||
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -9,6 +11,13 @@ use yaak_models::query_manager::QueryManager;
|
|||||||
use yaak_plugins::events::PluginContext;
|
use yaak_plugins::events::PluginContext;
|
||||||
use yaak_plugins::manager::PluginManager;
|
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 {
|
pub struct CliContext {
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
query_manager: QueryManager,
|
query_manager: QueryManager,
|
||||||
@@ -33,37 +42,32 @@ impl CliContext {
|
|||||||
let installed_plugin_dir = data_dir.join("installed-plugins");
|
let installed_plugin_dir = data_dir.join("installed-plugins");
|
||||||
let node_bin_path = PathBuf::from("node");
|
let node_bin_path = PathBuf::from("node");
|
||||||
|
|
||||||
|
prepare_embedded_vendored_plugins(&vendored_plugin_dir)
|
||||||
|
.expect("Failed to prepare bundled plugins");
|
||||||
|
|
||||||
let plugin_runtime_main =
|
let plugin_runtime_main =
|
||||||
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
prepare_embedded_plugin_runtime(&data_dir)
|
||||||
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
|
.expect("Failed to prepare embedded plugin runtime")
|
||||||
});
|
});
|
||||||
|
|
||||||
let plugin_manager = Arc::new(
|
match PluginManager::new(
|
||||||
PluginManager::new(
|
vendored_plugin_dir,
|
||||||
vendored_plugin_dir,
|
installed_plugin_dir,
|
||||||
installed_plugin_dir,
|
node_bin_path,
|
||||||
node_bin_path,
|
plugin_runtime_main,
|
||||||
plugin_runtime_main,
|
&query_manager,
|
||||||
false,
|
&PluginContext::new_empty(),
|
||||||
)
|
false,
|
||||||
.await,
|
)
|
||||||
);
|
.await
|
||||||
|
{
|
||||||
let plugins = query_manager.connect().list_plugins().unwrap_or_default();
|
Ok(plugin_manager) => Some(Arc::new(plugin_manager)),
|
||||||
if !plugins.is_empty() {
|
Err(err) => {
|
||||||
let errors = plugin_manager
|
eprintln!("Warning: Failed to initialize plugins: {err}");
|
||||||
.initialize_all_plugins(plugins, &PluginContext::new_empty())
|
None
|
||||||
.await;
|
|
||||||
for (plugin_dir, error_msg) in errors {
|
|
||||||
eprintln!(
|
|
||||||
"Warning: Failed to initialize plugin '{}': {}",
|
|
||||||
plugin_dir, error_msg
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(plugin_manager)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -113,3 +117,17 @@ 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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ mod cli;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod context;
|
mod context;
|
||||||
mod plugin_events;
|
mod plugin_events;
|
||||||
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
mod version;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::{Cli, Commands, RequestCommands};
|
use cli::{Cli, Commands, RequestCommands};
|
||||||
@@ -22,6 +24,15 @@ async fn main() {
|
|||||||
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
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!(
|
let needs_plugins = matches!(
|
||||||
&command,
|
&command,
|
||||||
Commands::Send(_)
|
Commands::Send(_)
|
||||||
@@ -30,21 +41,51 @@ async fn main() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
let context = CliContext::initialize(data_dir, app_id, needs_plugins).await;
|
let context = if needs_context {
|
||||||
|
Some(CliContext::initialize(data_dir, app_id, needs_plugins).await)
|
||||||
let exit_code = match command {
|
} else {
|
||||||
Commands::Send(args) => {
|
None
|
||||||
commands::send::run(&context, args, environment.as_deref(), verbose).await
|
|
||||||
}
|
|
||||||
Commands::Workspace(args) => commands::workspace::run(&context, args),
|
|
||||||
Commands::Request(args) => {
|
|
||||||
commands::request::run(&context, args, environment.as_deref(), verbose).await
|
|
||||||
}
|
|
||||||
Commands::Folder(args) => commands::folder::run(&context, args),
|
|
||||||
Commands::Environment(args) => commands::environment::run(&context, args),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
context.shutdown().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::Workspace(args) => commands::workspace::run(
|
||||||
|
context.as_ref().expect("context initialized for workspace"),
|
||||||
|
args,
|
||||||
|
),
|
||||||
|
Commands::Request(args) => {
|
||||||
|
commands::request::run(
|
||||||
|
context.as_ref().expect("context initialized for request"),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(context) = &context {
|
||||||
|
context.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
if exit_code != 0 {
|
if exit_code != 0 {
|
||||||
std::process::exit(exit_code);
|
std::process::exit(exit_code);
|
||||||
|
|||||||
34
crates-cli/yaak-cli/src/ui.rs
Normal file
34
crates-cli/yaak-cli/src/ui.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates-cli/yaak-cli/src/utils/http.rs
Normal file
47
crates-cli/yaak-cli/src/utils/http.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod confirm;
|
pub mod confirm;
|
||||||
|
pub mod http;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
|
|||||||
3
crates-cli/yaak-cli/src/version.rs
Normal file
3
crates-cli/yaak-cli/src/version.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub fn cli_version() -> &'static str {
|
||||||
|
option_env!("YAAK_CLI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use yaak_models::query_manager::QueryManager;
|
|||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
pub fn cli_cmd(data_dir: &Path) -> Command {
|
pub fn cli_cmd(data_dir: &Path) -> Command {
|
||||||
let mut cmd = cargo_bin_cmd!("yaakcli");
|
let mut cmd = cargo_bin_cmd!("yaak");
|
||||||
cmd.arg("--data-dir").arg(data_dir);
|
cmd.arg("--data-dir").arg(data_dir);
|
||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,21 @@ fn request_schema_http_outputs_json_schema() {
|
|||||||
.args(["request", "schema", "http"])
|
.args(["request", "schema", "http"])
|
||||||
.assert()
|
.assert()
|
||||||
.success()
|
.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("\"type\": \"object\""))
|
||||||
.stdout(contains("\"authentication\""));
|
.stdout(contains("\"authentication\""));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ use tokio::sync::Mutex;
|
|||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_api::yaak_api_client;
|
use yaak_api::yaak_api_client;
|
||||||
use yaak_models::models::Plugin;
|
use yaak_models::models::Plugin;
|
||||||
use yaak_models::util::UpdateSource;
|
|
||||||
use yaak_plugins::api::{
|
use yaak_plugins::api::{
|
||||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||||
search_plugins,
|
search_plugins,
|
||||||
};
|
};
|
||||||
use yaak_plugins::events::{Color, Icon, PluginContext, ShowToastRequest};
|
use yaak_plugins::events::PluginContext;
|
||||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||||
@@ -268,6 +267,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.join("index.cjs");
|
.join("index.cjs");
|
||||||
|
|
||||||
let dev_mode = is_dev();
|
let dev_mode = is_dev();
|
||||||
|
let query_manager =
|
||||||
|
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||||
|
|
||||||
// Create plugin manager asynchronously
|
// Create plugin manager asynchronously
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
@@ -277,53 +278,12 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
installed_plugin_dir,
|
installed_plugin_dir,
|
||||||
node_bin_path,
|
node_bin_path,
|
||||||
plugin_runtime_main,
|
plugin_runtime_main,
|
||||||
|
&query_manager,
|
||||||
|
&PluginContext::new_empty(),
|
||||||
dev_mode,
|
dev_mode,
|
||||||
)
|
)
|
||||||
.await;
|
.await
|
||||||
|
.expect("Failed to initialize plugins");
|
||||||
// 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);
|
app_handle_clone.manage(manager);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
super::collect_any_types(json, &mut out);
|
super::collect_any_types(json, &mut out);
|
||||||
|
out.sort();
|
||||||
assert_eq!(out, vec!["foo.bar", "mount_source.MountSourceRBDVolume"]);
|
assert_eq!(out, vec!["foo.bar", "mount_source.MountSourceRBDVolume"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,6 +611,8 @@ pub struct Environment {
|
|||||||
pub base: bool,
|
pub base: bool,
|
||||||
pub parent_model: String,
|
pub parent_model: String,
|
||||||
pub parent_id: Option<String>,
|
pub parent_id: Option<String>,
|
||||||
|
/// Variables defined in this environment scope.
|
||||||
|
/// Child environments override parent variables by name.
|
||||||
pub variables: Vec<EnvironmentVariable>,
|
pub variables: Vec<EnvironmentVariable>,
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
@@ -845,6 +847,8 @@ pub struct HttpUrlParameter {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
#[ts(optional, as = "Option<bool>")]
|
#[ts(optional, as = "Option<bool>")]
|
||||||
pub enabled: 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 name: String,
|
||||||
pub value: String,
|
pub value: String,
|
||||||
#[ts(optional, as = "Option<String>")]
|
#[ts(optional, as = "Option<String>")]
|
||||||
@@ -877,6 +881,7 @@ pub struct HttpRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
pub url_parameters: Vec<HttpUrlParameter>,
|
pub url_parameters: Vec<HttpUrlParameter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,6 +1123,7 @@ pub struct WebsocketRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||||
pub url_parameters: Vec<HttpUrlParameter>,
|
pub url_parameters: Vec<HttpUrlParameter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1728,6 +1734,7 @@ pub struct GrpcRequest {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub service: Option<String>,
|
pub service: Option<String>,
|
||||||
pub sort_priority: f64,
|
pub sort_priority: f64,
|
||||||
|
/// Server URL (http for plaintext or https for secure)
|
||||||
pub url: String,
|
pub url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ use tokio::sync::mpsc::error::TrySendError;
|
|||||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
use tokio::sync::{Mutex, mpsc, oneshot};
|
||||||
use tokio::time::{Instant, timeout};
|
use tokio::time::{Instant, timeout};
|
||||||
use yaak_models::models::Plugin;
|
use yaak_models::models::Plugin;
|
||||||
use yaak_models::util::generate_id;
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::util::{UpdateSource, generate_id};
|
||||||
use yaak_templates::error::Error::RenderError;
|
use yaak_templates::error::Error::RenderError;
|
||||||
use yaak_templates::error::Result as TemplateResult;
|
use yaak_templates::error::Result as TemplateResult;
|
||||||
|
|
||||||
@@ -61,14 +62,18 @@ impl PluginManager {
|
|||||||
/// * `installed_plugin_dir` - Path to installed plugins directory
|
/// * `installed_plugin_dir` - Path to installed plugins directory
|
||||||
/// * `node_bin_path` - Path to the yaaknode binary
|
/// * `node_bin_path` - Path to the yaaknode binary
|
||||||
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
|
/// * `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)
|
/// * `dev_mode` - Whether the app is in dev mode (affects plugin loading)
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
vendored_plugin_dir: PathBuf,
|
vendored_plugin_dir: PathBuf,
|
||||||
installed_plugin_dir: PathBuf,
|
installed_plugin_dir: PathBuf,
|
||||||
node_bin_path: PathBuf,
|
node_bin_path: PathBuf,
|
||||||
plugin_runtime_main: PathBuf,
|
plugin_runtime_main: PathBuf,
|
||||||
|
query_manager: &QueryManager,
|
||||||
|
plugin_context: &PluginContext,
|
||||||
dev_mode: bool,
|
dev_mode: bool,
|
||||||
) -> PluginManager {
|
) -> Result<PluginManager> {
|
||||||
let (events_tx, mut events_rx) = mpsc::channel(2048);
|
let (events_tx, mut events_rx) = mpsc::channel(2048);
|
||||||
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
|
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
|
||||||
let (killed_tx, killed_rx) = oneshot::channel();
|
let (killed_tx, killed_rx) = oneshot::channel();
|
||||||
@@ -151,12 +156,40 @@ impl PluginManager {
|
|||||||
&kill_server_rx,
|
&kill_server_rx,
|
||||||
killed_tx,
|
killed_tx,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
.unwrap();
|
|
||||||
info!("Waiting for plugins to initialize");
|
info!("Waiting for plugins to initialize");
|
||||||
init_plugins_task.await.unwrap();
|
init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?;
|
||||||
|
|
||||||
plugin_manager
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the vendored plugin directory path (resolves dev mode path if applicable)
|
/// Get the vendored plugin directory path (resolves dev mode path if applicable)
|
||||||
|
|||||||
7
npm/README.md
Normal file
7
npm/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# 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.
|
||||||
0
npm/cli-darwin-arm64/bin/.gitkeep
Normal file
0
npm/cli-darwin-arm64/bin/.gitkeep
Normal file
10
npm/cli-darwin-arm64/package.json
Normal file
10
npm/cli-darwin-arm64/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
0
npm/cli-darwin-x64/bin/.gitkeep
Normal file
0
npm/cli-darwin-x64/bin/.gitkeep
Normal file
10
npm/cli-darwin-x64/package.json
Normal file
10
npm/cli-darwin-x64/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
0
npm/cli-linux-arm64/bin/.gitkeep
Normal file
0
npm/cli-linux-arm64/bin/.gitkeep
Normal file
10
npm/cli-linux-arm64/package.json
Normal file
10
npm/cli-linux-arm64/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
0
npm/cli-linux-x64/bin/.gitkeep
Normal file
0
npm/cli-linux-x64/bin/.gitkeep
Normal file
10
npm/cli-linux-x64/package.json
Normal file
10
npm/cli-linux-x64/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
0
npm/cli-win32-arm64/bin/.gitkeep
Normal file
0
npm/cli-win32-arm64/bin/.gitkeep
Normal file
10
npm/cli-win32-arm64/package.json
Normal file
10
npm/cli-win32-arm64/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
0
npm/cli-win32-x64/bin/.gitkeep
Normal file
0
npm/cli-win32-x64/bin/.gitkeep
Normal file
10
npm/cli-win32-x64/package.json
Normal file
10
npm/cli-win32-x64/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
2
npm/cli/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
yaak
|
||||||
|
yaak.exe
|
||||||
30
npm/cli/bin/cli.js
Executable file
30
npm/cli/bin/cli.js
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#!/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);
|
||||||
20
npm/cli/common.js
Normal file
20
npm/cli/common.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
20
npm/cli/index.js
Normal file
20
npm/cli/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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"
|
||||||
|
});
|
||||||
|
};
|
||||||
97
npm/cli/install.js
Normal file
97
npm/cli/install.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
25
npm/cli/package.json
Normal file
25
npm/cli/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
npm/cli/prepublish.js
Normal file
5
npm/cli/prepublish.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
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"));
|
||||||
77
npm/prepare-publish.js
Normal file
77
npm/prepare-publish.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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
66
package-lock.json
generated
@@ -37,7 +37,6 @@
|
|||||||
"plugins/template-function-cookie",
|
"plugins/template-function-cookie",
|
||||||
"plugins/template-function-ctx",
|
"plugins/template-function-ctx",
|
||||||
"plugins/template-function-encode",
|
"plugins/template-function-encode",
|
||||||
"plugins/template-function-faker",
|
|
||||||
"plugins/template-function-fs",
|
"plugins/template-function-fs",
|
||||||
"plugins/template-function-hash",
|
"plugins/template-function-hash",
|
||||||
"plugins/template-function-json",
|
"plugins/template-function-json",
|
||||||
@@ -74,7 +73,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@yaakapp/cli": "^0.3.4",
|
"@yaakapp/cli": "^0.4.0-beta.2",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nodejs-file-downloader": "^4.13.0",
|
"nodejs-file-downloader": "^4.13.0",
|
||||||
@@ -4327,27 +4326,28 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli": {
|
"node_modules/@yaakapp/cli": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli/-/cli-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli/-/cli-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-bSSL3noEfyoPC0M+bj34jbBZbB+gwYLCHL9cf6BYHgkRQKlHFpvN6z8M2jQZljb+CTQdHK0NzosmwHLpjMmAVA==",
|
"integrity": "sha512-UXPxTS9oWVCIr4rShC7HjcAX+gSmw/BQ5F1Xp3Rub3vY/G7+513JJsc1HhLGVZqFfOVRSMEKRxtF9/9okSyiHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
|
"yaak": "bin/cli.js",
|
||||||
"yaakcli": "bin/cli.js"
|
"yaakcli": "bin/cli.js"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@yaakapp/cli-darwin-arm64": "0.3.4",
|
"@yaakapp/cli-darwin-arm64": "0.4.0-beta.2",
|
||||||
"@yaakapp/cli-darwin-x64": "0.3.4",
|
"@yaakapp/cli-darwin-x64": "0.4.0-beta.2",
|
||||||
"@yaakapp/cli-linux-arm64": "0.3.4",
|
"@yaakapp/cli-linux-arm64": "0.4.0-beta.2",
|
||||||
"@yaakapp/cli-linux-x64": "0.3.4",
|
"@yaakapp/cli-linux-x64": "0.4.0-beta.2",
|
||||||
"@yaakapp/cli-win32-arm64": "0.3.4",
|
"@yaakapp/cli-win32-arm64": "0.4.0-beta.2",
|
||||||
"@yaakapp/cli-win32-x64": "0.3.4"
|
"@yaakapp/cli-win32-x64": "0.4.0-beta.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli-darwin-arm64": {
|
"node_modules/@yaakapp/cli-darwin-arm64": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-arm64/-/cli-darwin-arm64-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-arm64/-/cli-darwin-arm64-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-iTohEO7XSVZwSvTgEQE9my3wGyWtTl1q8yfol7hHwVFTX7G8Geh8X2j2vVokHhj7J9OZL9jtYQWIsM1ekOHSEQ==",
|
"integrity": "sha512-mqkyH5tIPRLs9JumP9ZmzjB5gIwmOL1yCDoJ1qVU8DIJ7mwlcQaPGYTK98pVdBcKOjofVakBTcpol9P8rBv4qw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4358,9 +4358,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli-darwin-x64": {
|
"node_modules/@yaakapp/cli-darwin-x64": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-x64/-/cli-darwin-x64-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-x64/-/cli-darwin-x64-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-gz7IcjFGKA0cCAum1Aq8kmVg7erYYSrZ9pliDw0NZyObjrBysJcsDXLodEU437u0pihtdCfoLsq3rsYYs8uwCA==",
|
"integrity": "sha512-QI/H2yUF8CkJq+cnRthoUWWTEJPH4QPA78FYcGjFRhvBaj1m2G/GlCA5NkTXm/fvIjNkQEODSihXrhU+zoSSCw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4371,9 +4371,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli-linux-arm64": {
|
"node_modules/@yaakapp/cli-linux-arm64": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-arm64/-/cli-linux-arm64-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-arm64/-/cli-linux-arm64-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-Yiwz8PBkXngmr0lTMW1pgy+F/kUISkzvqofdoBseXTrS/GDxoW3ILnG3If30LuIyWWPgqpuU+qKMtbVDzuncPQ==",
|
"integrity": "sha512-nvAp97LkgRpqVHyMwDdpkzlKOWG2kJXezCLRZaRWaEpbnNuviSF+0yzCuFGZRHEEspj7B0TiM+sKGkpvjNlweA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4384,9 +4384,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli-linux-x64": {
|
"node_modules/@yaakapp/cli-linux-x64": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-x64/-/cli-linux-x64-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-x64/-/cli-linux-x64-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-j7/r18UYNlFChDVU5N5ye3mmL+OR9Uu3LY72JxW+s/SyV69Bo8Griii75Wt19z/jj2ES8pxD+4IJq56VF3wJ7w==",
|
"integrity": "sha512-9/qAMNrtE9glxih3XWGfFssIJpQ4mHNUTuWYKroc0aZZUrunnCw3tX1tQtFDxy0QRIZcGlBeBRtgxuuBd2fYbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -4397,9 +4397,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli-win32-arm64": {
|
"node_modules/@yaakapp/cli-win32-arm64": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-arm64/-/cli-win32-arm64-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-arm64/-/cli-win32-arm64-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-OUSKOKrSnzrTAGW0c+2ZCwA4yhgw/bA+gyeTvpf7cELVuB0qooGkEcJ3lM7fPMKmUbFU0r+K/Ggq1QMUr7cJLQ==",
|
"integrity": "sha512-eM1zL+hl0y3NBLxWO90y9VyaFsAf0HAsECBWvhKhvEdd6KG4K1XzpXrC30cHQBGePIrCa/az8eSuvTde0Z2C/g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -4410,9 +4410,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@yaakapp/cli-win32-x64": {
|
"node_modules/@yaakapp/cli-win32-x64": {
|
||||||
"version": "0.3.4",
|
"version": "0.4.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-x64/-/cli-win32-x64-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-x64/-/cli-win32-x64-0.4.0-beta.2.tgz",
|
||||||
"integrity": "sha512-sVYnW1rROLbzFUCyeZ++ibN+8gJS7FdPnBRHIE0KORfeI4e7Gw/aMUji2qpSZ1gt3DrAU95DDNjBkDvGBAgqag==",
|
"integrity": "sha512-ySdiK0h216EqURkM5KZoqbPTgbIX4eNK/IgrKwSazxRb369HOZYQ8X68as+VRxEL4NCMmWlQNdbBDuf+apg/mg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -7985,9 +7985,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.11.7",
|
"version": "4.11.10",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz",
|
||||||
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
|
"integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -16020,7 +16020,7 @@
|
|||||||
"@hono/mcp": "^0.2.3",
|
"@hono/mcp": "^0.2.3",
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.10",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -70,7 +70,6 @@
|
|||||||
"app-dev": "node scripts/run-dev.mjs",
|
"app-dev": "node scripts/run-dev.mjs",
|
||||||
"migration": "node scripts/create-migration.cjs",
|
"migration": "node scripts/create-migration.cjs",
|
||||||
"build": "npm run --workspaces --if-present build",
|
"build": "npm run --workspaces --if-present build",
|
||||||
"build-plugins": "npm run --workspaces --if-present build",
|
|
||||||
"test": "npm run --workspaces --if-present test",
|
"test": "npm run --workspaces --if-present test",
|
||||||
"icons": "run-p icons:*",
|
"icons": "run-p icons:*",
|
||||||
"icons:dev": "tauri icon crates-tauri/yaak-app/icons/icon-dev.png --output crates-tauri/yaak-app/icons/dev",
|
"icons:dev": "tauri icon crates-tauri/yaak-app/icons/icon-dev.png --output crates-tauri/yaak-app/icons/dev",
|
||||||
@@ -98,7 +97,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@yaakapp/cli": "^0.3.4",
|
"@yaakapp/cli": "^0.4.0-beta.2",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nodejs-file-downloader": "^4.13.0",
|
"nodejs-file-downloader": "^4.13.0",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"build:copy-types": "run-p build:copy-types:*",
|
"build:copy-types": "run-p build:copy-types:*",
|
||||||
"build:copy-types:root": "cpy --flat ../../crates/yaak-plugins/bindings/*.ts ./src/bindings",
|
"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",
|
"build:copy-types:next": "cpy --flat ../../crates/yaak-plugins/bindings/serde_json/*.ts ./src/bindings/serde_json",
|
||||||
"publish": "npm publish",
|
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const modules = [
|
|||||||
|
|
||||||
function normalizeResult(result: unknown): string {
|
function normalizeResult(result: unknown): string {
|
||||||
if (typeof result === 'string') return result;
|
if (typeof result === 'string') return result;
|
||||||
|
if (result instanceof Date) return result.toISOString();
|
||||||
return JSON.stringify(result);
|
return JSON.stringify(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,18 @@ describe('template-function-faker', () => {
|
|||||||
// accidental additions, removals, or renames across faker upgrades.
|
// accidental additions, removals, or renames across faker upgrades.
|
||||||
expect(names).toMatchSnapshot();
|
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$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@hono/mcp": "^0.2.3",
|
"@hono/mcp": "^0.2.3",
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.7",
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.10",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yaakcli build",
|
"build": "yaakcli build",
|
||||||
"dev": "yaakcli dev"
|
"dev": "yaakcli dev",
|
||||||
|
"test": "vitest --run tests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"httpntlm": "^1.8.13"
|
"httpntlm": "^1.8.13"
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import type { PluginDefinition } from '@yaakapp/api';
|
|||||||
|
|
||||||
import { ntlm } from 'httpntlm';
|
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 = {
|
export const plugin: PluginDefinition = {
|
||||||
authentication: {
|
authentication: {
|
||||||
name: 'windows',
|
name: 'windows',
|
||||||
@@ -68,15 +78,12 @@ export const plugin: PluginDefinition = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const wwwAuthenticateHeader = negotiateResponse.headers.find(
|
const ntlmChallenge = extractNtlmChallenge(negotiateResponse.headers);
|
||||||
(h) => h.name.toLowerCase() === 'www-authenticate',
|
if (ntlmChallenge == null) {
|
||||||
);
|
throw new Error('Unable to find NTLM challenge in WWW-Authenticate response headers');
|
||||||
|
|
||||||
if (!wwwAuthenticateHeader?.value) {
|
|
||||||
throw new Error('Unable to find www-authenticate response header for NTLM');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const type2 = ntlm.parseType2Message(wwwAuthenticateHeader.value, (err: Error | null) => {
|
const type2 = ntlm.parseType2Message(ntlmChallenge, (err: Error | null) => {
|
||||||
if (err != null) throw err;
|
if (err != null) throw err;
|
||||||
});
|
});
|
||||||
const type3 = ntlm.createType3Message(type2, options);
|
const type3 = ntlm.createType3Message(type2, options);
|
||||||
|
|||||||
84
plugins/auth-ntlm/tests/index.test.ts
Normal file
84
plugins/auth-ntlm/tests/index.test.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { readdirSync, cpSync, existsSync } = require('node:fs');
|
const { readdirSync, cpSync, existsSync, mkdirSync } = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
|
||||||
const pluginsDir = path.join(__dirname, '..', 'plugins');
|
const pluginsDir = path.join(__dirname, '..', 'plugins');
|
||||||
@@ -24,6 +24,7 @@ for (const name of readdirSync(pluginsDir)) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const destDir = path.join(__dirname, '../crates-tauri/yaak-app/vendored/plugins/', name);
|
const destDir = path.join(__dirname, '../crates-tauri/yaak-app/vendored/plugins/', name);
|
||||||
|
mkdirSync(destDir, { recursive: true });
|
||||||
console.log(`Copying ${name} to ${destDir}`);
|
console.log(`Copying ${name} to ${destDir}`);
|
||||||
cpSync(path.join(dir, 'package.json'), path.join(destDir, 'package.json'));
|
cpSync(path.join(dir, 'package.json'), path.join(destDir, 'package.json'));
|
||||||
cpSync(path.join(dir, 'build'), path.join(destDir, 'build'), { recursive: true });
|
cpSync(path.join(dir, 'build'), path.join(destDir, 'build'), { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user