Compare commits

..

2 Commits

Author SHA1 Message Date
Gregory Schier
986143c4ae Add yaak-actions-builtin crate and integrate with CLI 2026-02-01 09:01:37 -08:00
Gregory Schier
50b0e23d53 Add yaak-actions crate for centralized action system
Implements a unified action system that serves as a single source of truth
for all operations in Yaak (Tauri app, CLI, plugins, deep links, MCP server).

Key features:
- ActionExecutor: Combined registry and execution engine with async RwLock
- ActionHandler: Trait-based handlers using async closures
- Context system: RequiredContext and CurrentContext for action availability
- Action groups: Organize related actions
- TypeScript bindings: Auto-generated via ts-rs for frontend use

Design highlights:
- Handlers are closures (no dependencies on other yaak crates)
- Registration requires both metadata and handler (prevents orphan actions)
- Flexible return values via serde_json::Value
- All methods are async using tokio

All 33 tests passing. Ready for integration with yaak-core and yaak-app.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 09:00:31 -08:00
899 changed files with 7902 additions and 22744 deletions

View File

@@ -6,14 +6,14 @@ Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core R
## Project Structure
```
crates/ # Core crates - should NOT depend on Tauri
crates-tauri/ # Tauri-specific crates (yaak-app-client, yaak-tauri-utils, etc.)
crates-tauri/ # Tauri-specific crates (yaak-app, yaak-tauri-utils, etc.)
crates-cli/ # CLI crate (yaak-cli)
```
## Completed Work
### 1. Folder Restructure
- Moved Tauri-dependent app code to `crates-tauri/yaak-app-client/`
- Moved Tauri-dependent app code to `crates-tauri/yaak-app/`
- Created `crates-tauri/yaak-tauri-utils/` for shared Tauri utilities (window traits, api_client, error handling)
- Created `crates-cli/yaak-cli/` for the standalone CLI
@@ -43,13 +43,13 @@ crates-cli/ # CLI crate (yaak-cli)
3. Move extension traits (e.g., `SomethingManagerExt`) to yaak-app or yaak-tauri-utils
4. Initialize managers in yaak-app's `.setup()` block
5. Remove `tauri` from Cargo.toml dependencies
6. Update `crates-tauri/yaak-app-client/capabilities/default.json` to remove the plugin permission
6. Update `crates-tauri/yaak-app/capabilities/default.json` to remove the plugin permission
7. Replace `tauri::async_runtime::block_on` with `tokio::runtime::Handle::current().block_on()`
## Key Files
- `crates-tauri/yaak-app-client/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app-client/src/commands.rs` - Migrated Tauri commands
- `crates-tauri/yaak-app-client/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-app/src/lib.rs` - Main Tauri app, setup block initializes managers
- `crates-tauri/yaak-app/src/commands.rs` - Migrated Tauri commands
- `crates-tauri/yaak-app/src/models_ext.rs` - Database plugin and extension traits
- `crates-tauri/yaak-tauri-utils/src/window.rs` - WorkspaceWindowTrait for window state
- `crates/yaak-models/src/lib.rs` - Contains `init_standalone()` for CLI usage
@@ -68,5 +68,5 @@ e718a5f1 Refactor models_ext to use init_standalone from yaak-models
## Testing
- Run `cargo check -p <crate>` to verify a crate builds without Tauri
- Run `npm run client:dev` to test the Tauri app still works
- Run `npm run app-dev` to test the Tauri app still works
- Run `cargo run -p yaak-cli -- --help` to test the CLI

View File

@@ -0,0 +1,51 @@
---
description: Review a PR in a new worktree
allowed-tools: Bash(git worktree:*), Bash(gh pr:*)
---
Review a GitHub pull request in a new git worktree.
## Usage
```
/review-pr <PR_NUMBER>
```
## What to do
1. List all open pull requests and ask the user to select one
2. Get PR information using `gh pr view <PR_NUMBER> --json number,headRefName`
3. Extract the branch name from the PR
4. Create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>` using `git worktree add` with a timeout of at least 300000ms (5 minutes) since the post-checkout hook runs a bootstrap script
5. Checkout the PR branch in the new worktree using `gh pr checkout <PR_NUMBER>`
6. The post-checkout hook will automatically:
- Create `.env.local` with unique ports
- Copy editor config folders
- Run `npm install && npm run bootstrap`
7. Inform the user:
- Where the worktree was created
- What ports were assigned
- How to access it (cd command)
- How to run the dev server
- How to remove the worktree when done
## Example Output
```
Created worktree for PR #123 at ../yaak-worktrees/pr-123
Branch: feature-auth
Ports: Vite (1421), MCP (64344)
To start working:
cd ../yaak-worktrees/pr-123
npm run app-dev
To remove when done:
git worktree remove ../yaak-worktrees/pr-123
```
## Error Handling
- If the PR doesn't exist, show a helpful error
- If the worktree already exists, inform the user and ask if they want to remove and recreate it
- If `gh` CLI is not available, inform the user to install it

View File

@@ -43,7 +43,5 @@ The skill generates markdown-formatted release notes following this structure:
After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using:
```bash
gh release create <tag> --draft --prerelease --title "Release <version>" --notes '<release notes>'
gh release create <tag> --draft --prerelease --title "<tag>" --notes '<release notes>'
```
**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1".

View File

@@ -0,0 +1,35 @@
# Worktree Management Skill
## Creating Worktrees
When creating git worktrees for this project, ALWAYS use the path format:
```
../yaak-worktrees/<NAME>
```
For example:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## What Happens Automatically
The post-checkout hook will automatically:
1. Create `.env.local` with unique ports (YAAK_DEV_PORT and YAAK_PLUGIN_MCP_SERVER_PORT)
2. Copy gitignored editor config folders (.zed, .idea, etc.)
3. Run `npm install && npm run bootstrap`
## Deleting Worktrees
```bash
git worktree remove ../yaak-worktrees/<NAME>
```
## Port Assignments
- Main worktree: 1420 (Vite), 64343 (MCP)
- First worktree: 1421, 64344
- Second worktree: 1422, 64345
- etc.
Each worktree can run `npm run app-dev` simultaneously without conflicts.

View File

@@ -1,48 +0,0 @@
---
name: release-generate-release-notes
description: Generate Yaak release notes from git history and PR metadata, including feedback links and full changelog compare links. Use when asked to run or replace the old Claude generate-release-notes command.
---
# Generate Release Notes
Generate formatted markdown release notes for a Yaak tag.
## Workflow
1. Determine target tag.
2. Determine previous comparable tag:
- Beta tag: compare against previous beta (if the root version is the same) or stable tag.
- Stable tag: compare against previous stable tag.
3. Collect commits in range:
- `git log --oneline <prev_tag>..<target_tag>`
4. For linked PRs, fetch metadata:
- `gh pr view <PR_NUMBER> --json number,title,body,author,url`
5. Extract useful details:
- Feedback URLs (`feedback.yaak.app`)
- Plugin install links or other notable context
6. Format notes using Yaak style:
- Changelog badge at top
- Bulleted items with PR links where available
- Feedback links where available
- Full changelog compare link at bottom
## Formatting Rules
- Wrap final notes in a markdown code fence.
- Keep a blank line before and after the code fence.
- Output the markdown code block last.
- Do not append `by @gschier` for PRs authored by `@gschier`.
## Release Creation Prompt
After producing notes, ask whether to create a draft GitHub release.
If confirmed and release does not yet exist, run:
`gh release create <tag> --draft --prerelease --title "Release <version_without_v>" --notes '<release notes>'`
If a draft release for the tag already exists, update it instead:
`gh release edit <tag> --title "Release <version_without_v>" --notes-file <path_to_notes>`
Use title format `Release <version_without_v>`, e.g. `v2026.2.1-beta.1` -> `Release 2026.2.1-beta.1`.

4
.gitattributes vendored
View File

@@ -1,5 +1,5 @@
crates-tauri/yaak-app-client/vendored/**/* linguist-generated=true
crates-tauri/yaak-app-client/gen/schemas/**/* linguist-generated=true
crates-tauri/yaak-app/vendored/**/* linguist-generated=true
crates-tauri/yaak-app/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true
crates/yaak-templates/pkg/* linguist-generated=true

View File

@@ -1,18 +0,0 @@
## Summary
<!-- Describe the bug and the fix in 1-3 sentences. -->
## Submission
- [ ] This PR is a bug fix or small-scope improvement.
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
- [ ] I tested this change locally.
- [ ] I added or updated tests when reasonable.
Approved feedback item (required if not a bug fix or small-scope improvement):
<!-- https://yaak.app/feedback/... -->
## Related
<!-- Link related issues, discussions, or feedback items. -->

View File

@@ -1,52 +0,0 @@
name: Update Flathub
on:
release:
types: [published]
permissions:
contents: read
jobs:
update-flathub:
name: Update Flathub manifest
runs-on: ubuntu-latest
# Only run for stable releases (skip betas/pre-releases)
if: ${{ !github.event.release.prerelease }}
steps:
- name: Checkout app repo
uses: actions/checkout@v4
- name: Checkout Flathub repo
uses: actions/checkout@v4
with:
repository: flathub/app.yaak.Yaak
token: ${{ secrets.FLATHUB_TOKEN }}
path: flathub-repo
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install source generators
run: |
pip install flatpak-node-generator tomlkit aiohttp
git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools flatpak/flatpak-builder-tools
- name: Run update-manifest.sh
run: bash flatpak/update-manifest.sh "${{ github.event.release.tag_name }}" flathub-repo
- name: Commit and push to Flathub
working-directory: flathub-repo
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet && echo "No changes to commit" && exit 0
git commit -m "Update to ${{ github.event.release.tag_name }}"
git push

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
name: Release App Artifacts
name: Generate Artifacts
on:
push:
tags: [v*]
@@ -122,8 +122,8 @@ jobs:
security list-keychain -d user -s $KEYCHAIN_PATH
# Sign vendored binaries with hardened runtime and their specific entitlements
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0
env:
@@ -152,28 +152,4 @@ jobs:
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
releaseDraft: true
prerelease: true
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app-client/tauri.release.conf.json"
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
if: matrix.os == 'windows'
shell: pwsh
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
npx tauri bundle ${{ matrix.args }} --bundles nsis --config ./crates-tauri/yaak-app-client/tauri.release.conf.json --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
$dest = $setup.FullName -replace '-setup\.exe$', '-setup-machine.exe'
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/tauri.release.conf.json"

16
.gitignore vendored
View File

@@ -39,22 +39,8 @@ codebook.toml
target
# Per-worktree Tauri config (generated by post-checkout hook)
crates-tauri/yaak-app-client/tauri.worktree.conf.json
crates-tauri/yaak-app-proxy/tauri.worktree.conf.json
crates-tauri/yaak-app/tauri.worktree.conf.json
# Tauri auto-generated permission files
**/permissions/autogenerated
**/permissions/schemas
# Flatpak build artifacts
flatpak-repo/
.flatpak-builder/
flatpak/flatpak-builder-tools/
flatpak/cargo-sources.json
flatpak/node-sources.json
# Local Codex desktop env state
.codex/environments/environment.toml
# Claude Code local settings
.claude/settings.local.json

View File

@@ -1,2 +0,0 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging.
- Do not commit, push, or tag without explicit approval

View File

@@ -1,16 +0,0 @@
# Contributing to Yaak
Yaak accepts community pull requests for:
- Bug fixes
- Small-scope improvements directly tied to existing behavior
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
## Approval for Non-Bugfix Changes
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
## Development Setup
For local setup and development workflows, see [`DEVELOPMENT.md`](DEVELOPMENT.md).

2749
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,9 @@
[workspace]
resolver = "2"
members = [
"crates/yaak",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
# Shared crates (no Tauri dependency)
"crates/yaak-actions",
"crates/yaak-actions-builtin",
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
@@ -19,20 +17,14 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
"crates-tauri/yaak-app",
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
]
[workspace.dependencies]
@@ -43,7 +35,6 @@ log = "0.4.29"
reqwest = "0.12.20"
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2"
schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
sha2 = "0.10.9"
@@ -55,13 +46,10 @@ thiserror = "2.0.17"
tokio = "1.48.0"
ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
# Internal crates - shared
yaak-actions = { path = "crates/yaak-actions" }
yaak-actions-builtin = { path = "crates/yaak-actions-builtin" }
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
@@ -74,18 +62,12 @@ yaak-sync = { path = "crates/yaak-sync" }
yaak-templates = { path = "crates/yaak-templates" }
yaak-tls = { path = "crates/yaak-tls" }
yaak-ws = { path = "crates/yaak-ws" }
yaak-api = { path = "crates/yaak-api" }
yaak-proxy = { path = "crates/yaak-proxy" }
# Internal crates - proxy
yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
# Internal crates - Tauri-specific
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
[profile.release]
strip = false

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app-client/icons/icon.png">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/crates-tauri/yaak-app/icons/icon.png">
</a>
</p>
@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
@@ -58,10 +58,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight
## Contribution Policy
> [!IMPORTANT]
> Community PRs are currently limited to bug fixes and small-scope improvements.
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
Yaak is open source but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
## Useful Resources

View File

@@ -1,210 +0,0 @@
import {
createWorkspaceModel,
foldersAtom,
patchModel,
} from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import { Fragment, useMemo } from 'react';
import { useAuthTab } from '../hooks/useAuthTab';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useHeadersTab } from '../hooks/useHeadersTab';
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
import { useModelAncestors } from '../hooks/useModelAncestors';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { hideDialog } from '../lib/dialog';
import { CopyIconButton } from './CopyIconButton';
import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge';
import { Icon } from '@yaakapp-internal/ui';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { Link } from './core/Link';
import { HStack, VStack } from './core/Stacks';
import type { TabItem } from './core/Tabs/Tabs';
import { TabContent, Tabs } from './core/Tabs/Tabs';
import { EmptyStateText } from './EmptyStateText';
import { EnvironmentEditor } from './EnvironmentEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { MarkdownEditor } from './MarkdownEditor';
interface Props {
folderId: string | null;
tab?: FolderSettingsTab;
}
const TAB_AUTH = 'auth';
const TAB_HEADERS = 'headers';
const TAB_VARIABLES = 'variables';
const TAB_GENERAL = 'general';
export type FolderSettingsTab =
| typeof TAB_AUTH
| typeof TAB_HEADERS
| typeof TAB_GENERAL
| typeof TAB_VARIABLES;
export function FolderSettingsDialog({ folderId, tab }: Props) {
const folders = useAtomValue(foldersAtom);
const folder = folders.find((f) => f.id === folderId) ?? null;
const ancestors = useModelAncestors(folder);
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
const authTab = useAuthTab(TAB_AUTH, folder);
const headersTab = useHeadersTab(TAB_HEADERS, folder);
const inheritedHeaders = useInheritedHeaders(folder);
const environments = useEnvironmentsBreakdown();
const folderEnvironment = environments.allEnvironments.find(
(e) => e.parentModel === 'folder' && e.parentId === folderId,
);
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
const tabs = useMemo<TabItem[]>(() => {
if (folder == null) return [];
return [
{
value: TAB_GENERAL,
label: 'General',
},
...headersTab,
...authTab,
{
value: TAB_VARIABLES,
label: 'Variables',
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
},
];
}, [authTab, folder, headersTab, numVars]);
if (folder == null) return null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
{breadcrumbs.map((item, index) => (
<Fragment key={item.id}>
{index > 0 && (
<Icon
icon="chevron_right"
size="lg"
className="opacity-50 flex-shrink-0"
/>
)}
<span className="text-text-subtle truncate min-w-0" title={item.name}>
{item.name}
</span>
</Fragment>
))}
{breadcrumbs.length > 0 && (
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
)}
<span
className="whitespace-nowrap"
title={folder.name}
>
{folder.name}
</span>
</div>
</div>
<Tabs
defaultValue={tab ?? TAB_GENERAL}
label="Folder Settings"
className="pt-2 pb-2 pl-3 pr-1 flex-1"
layout="horizontal"
addBorders
tabs={tabs}
>
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
<HttpAuthenticationEditor model={folder} />
</TabContent>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
<Input
label="Folder Name"
defaultValue={folder.name}
onChange={(name) => patchModel(folder, { name })}
stateKey={`name.${folder.id}`}
/>
<MarkdownEditor
name="folder-description"
placeholder="Folder description"
className="border border-border px-2"
defaultValue={folder.description}
stateKey={`description.${folder.id}`}
onChange={(description) => patchModel(folder, { description })}
/>
<HStack alignItems="center" justifyContent="between" className="w-full">
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(folder);
if (didDelete) {
hideDialog('folder-settings');
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Folder
</Button>
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
{folder.id}
<CopyIconButton
className="opacity-70 !text-primary"
size="2xs"
iconSize="sm"
title="Copy folder ID"
text={folder.id}
/>
</InlineCode>
</HStack>
</div>
</TabContent>
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={folder.id}
headers={folder.headers}
onChange={(headers) => patchModel(folder, { headers })}
stateKey={`headers.${folder.id}`}
/>
</TabContent>
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
{folderEnvironment == null ? (
<EmptyStateText>
<VStack alignItems="center" space={1.5}>
<p>
Override{' '}
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
Variables
</Link>{' '}
for requests within this folder.
</p>
<Button
variant="border"
size="sm"
onClick={async () => {
await createWorkspaceModel({
workspaceId: folder.workspaceId,
parentModel: 'folder',
parentId: folder.id,
model: 'environment',
name: 'Folder Environment',
});
}}
>
Create Folder Environment
</Button>
</VStack>
</EmptyStateText>
) : (
<EnvironmentEditor hideName environment={folderEnvironment} />
)}
</TabContent>
</Tabs>
</div>
);
}

View File

@@ -1,116 +0,0 @@
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
*/
export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
constructor(private view: EditorView) {
this.updateCount();
}
update(update: ViewUpdate) {
// Recompute when doc changes, search state changes, or selection moves
const query = getSearchQuery(update.state);
const prevQuery = getSearchQuery(update.startState);
const open = searchPanelOpen(update.state);
const prevOpen = searchPanelOpen(update.startState);
if (update.docChanged || update.selectionSet || !query.eq(prevQuery) || open !== prevOpen) {
this.updateCount();
}
}
private updateCount() {
const state = this.view.state;
const open = searchPanelOpen(state);
const query = getSearchQuery(state);
if (!open) {
this.removeCountEl();
return;
}
this.ensureCountEl();
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = '0/0';
}
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = '0/0';
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
}
}
private ensureCountEl() {
// Find the search panel in the editor DOM
const panel = this.view.dom.querySelector('.cm-search');
if (!panel) {
this.countEl = null;
return;
}
if (this.countEl && this.countEl.parentElement === panel) {
return; // Already attached
}
this.countEl = document.createElement('span');
this.countEl.className = 'cm-search-match-count';
// Reorder: insert prev button, then next button, then count after the search input
const searchInput = panel.querySelector('input');
const prevBtn = panel.querySelector('button[name="prev"]');
const nextBtn = panel.querySelector('button[name="next"]');
if (searchInput && searchInput.parentElement === panel) {
searchInput.after(this.countEl);
if (prevBtn) this.countEl.after(prevBtn);
if (nextBtn && prevBtn) prevBtn.after(nextBtn);
} else {
panel.prepend(this.countEl);
}
}
private removeCountEl() {
if (this.countEl) {
this.countEl.remove();
this.countEl = null;
}
}
destroy() {
this.removeCountEl();
}
},
);
}

View File

@@ -1,66 +0,0 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
export interface RadioCardOption<T extends string> {
value: T;
label: ReactNode;
description?: ReactNode;
}
export interface RadioCardsProps<T extends string> {
value: T | null;
onChange: (value: T) => void;
options: RadioCardOption<T>[];
name: string;
}
export function RadioCards<T extends string>({
value,
onChange,
options,
name,
}: RadioCardsProps<T>) {
return (
<div className="flex flex-col gap-2">
{options.map((option) => {
const selected = value === option.value;
return (
<label
key={option.value}
className={classNames(
'flex items-start gap-3 p-3 rounded-lg border cursor-pointer',
'transition-colors',
selected
? 'border-border-focus'
: 'border-border-subtle hocus:border-text-subtlest',
)}
>
<input
type="radio"
name={name}
value={option.value}
checked={selected}
onChange={() => onChange(option.value)}
className="sr-only"
/>
<div
className={classNames(
'mt-1 w-4 h-4 flex-shrink-0 rounded-full border',
'flex items-center justify-center',
selected ? 'border-focus' : 'border-border',
)}
>
{selected && <div className="w-2 h-2 rounded-full bg-text" />}
</div>
<div className="flex flex-col gap-0.5">
<span className="font-semibold text-text">{option.label}</span>
{option.description && (
<span className="text-sm text-text-subtle">{option.description}</span>
)}
</div>
</label>
);
})}
</div>
);
}

View File

@@ -1,102 +0,0 @@
import type { DivergedStrategy } from '@yaakapp-internal/git';
import { useState } from 'react';
import { showDialog } from '../../lib/dialog';
import { Button } from '../core/Button';
import { InlineCode } from '../core/InlineCode';
import { RadioCards } from '../core/RadioCards';
import { HStack } from '../core/Stacks';
type Resolution = 'force_reset' | 'merge';
const resolutionLabel: Record<Resolution, string> = {
force_reset: 'Force Pull',
merge: 'Merge',
};
interface DivergedDialogProps {
remote: string;
branch: string;
onResult: (strategy: DivergedStrategy) => void;
onHide: () => void;
}
function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProps) {
const [selected, setSelected] = useState<Resolution | null>(null);
const handleSubmit = () => {
if (selected == null) return;
onResult(selected);
onHide();
};
const handleCancel = () => {
onResult('cancel');
onHide();
};
return (
<div className="flex flex-col gap-4 mb-4">
<p className="text-text-subtle">
Your local branch has diverged from{' '}
<InlineCode>
{remote}/{branch}
</InlineCode>. How would you like to resolve this?
</p>
<RadioCards
name="diverged-strategy"
value={selected}
onChange={setSelected}
options={[
{
value: 'merge',
label: 'Merge Commit',
description: 'Combining local and remote changes into a single merge commit',
},
{
value: 'force_reset',
label: 'Force Pull',
description: 'Discard local commits and reset to match the remote branch',
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === 'force_reset' ? 'danger' : 'primary'}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : 'Select an option'}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
</div>
);
}
export async function promptDivergedStrategy({
remote,
branch,
}: {
remote: string;
branch: string;
}): Promise<DivergedStrategy> {
return new Promise((resolve) => {
showDialog({
id: 'git-diverged',
title: 'Branches Diverged',
hideX: true,
size: 'sm',
disableBackdropClose: true,
onClose: () => resolve('cancel'),
render: ({ hide }) =>
DivergedDialog({
remote,
branch,
onHide: hide,
onResult: resolve,
}),
});
});
}

View File

@@ -1,13 +0,0 @@
import type { UncommittedChangesStrategy } from '@yaakapp-internal/git';
import { showConfirm } from '../../lib/confirm';
export async function promptUncommittedChangesStrategy(): Promise<UncommittedChangesStrategy> {
const confirmed = await showConfirm({
id: 'git-uncommitted-changes',
title: 'Uncommitted Changes',
description: 'You have uncommitted changes. Commit or reset your changes before pulling.',
confirmText: 'Reset and Pull',
color: 'danger',
});
return confirmed ? 'reset' : 'cancel';
}

View File

@@ -1,25 +0,0 @@
import deepEqual from "@gilbarbara/deep-equal";
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
import type { Atom } from "jotai";
import { atom } from "jotai";
import { selectAtom } from "jotai/utils";
import type { SplitLayoutLayout } from "../components/core/SplitLayout";
import { atomWithKVStorage } from "./atoms/atomWithKVStorage";
export function deepEqualAtom<T>(a: Atom<T>) {
return selectAtom(
a,
(v) => v,
(a, b) => deepEqual(a, b),
);
}
export const workspaceLayoutAtom = atomWithKVStorage<SplitLayoutLayout>(
"workspace_layout",
"horizontal",
);
export const updateAvailableAtom = atom<Omit<
UpdateInfo,
"replyEventId"
> | null>(null);

View File

@@ -1,322 +0,0 @@
import { emit } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { debounce } from "@yaakapp-internal/lib";
import type {
FormInput,
InternalEvent,
JsonPrimitive,
ShowToastRequest,
} from "@yaakapp-internal/plugins";
import { updateAllPlugins } from "@yaakapp-internal/plugins";
import type {
PluginUpdateNotification,
UpdateInfo,
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
import { Icon } from "@yaakapp-internal/ui";
import { HStack, VStack } from "../components/core/Stacks";
// Listen for toasts
import { listenToTauriEvent } from "../hooks/useListenToTauriEvent";
import { updateAvailableAtom } from "./atoms";
import { stringToColor } from "./color";
import { generateId } from "./generateId";
import { jotaiStore } from "./jotai";
import { showPrompt } from "./prompt";
import { showPromptForm } from "./prompt-form";
import { invokeCmd } from "./tauri";
import { showToast } from "./toast";
export function initGlobalListeners() {
listenToTauriEvent<ShowToastRequest>("show_toast", (event) => {
showToast({ ...event.payload });
});
listenToTauriEvent("settings", () => openSettings.mutate(null));
// Track active dynamic form dialogs so follow-up input updates can reach them
const activeForms = new Map<string, (inputs: FormInput[]) => void>();
// Listen for plugin events
listenToTauriEvent<InternalEvent>(
"plugin_event",
async ({ payload: event }) => {
if (event.payload.type === "prompt_text_request") {
const value = await showPrompt(event.payload);
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: "prompt_text_response",
value,
},
};
await emit(event.id, result);
} else if (event.payload.type === "prompt_form_request") {
if (event.replyId != null) {
// Follow-up update from plugin runtime — update the active dialog's inputs
const updateInputs = activeForms.get(event.replyId);
if (updateInputs) {
updateInputs(event.payload.inputs);
}
return;
}
// Initial request — show the dialog with bidirectional support
const emitFormResponse = (
values: Record<string, JsonPrimitive> | null,
done: boolean,
) => {
const result: InternalEvent = {
id: generateId(),
replyId: event.id,
pluginName: event.pluginName,
pluginRefId: event.pluginRefId,
context: event.context,
payload: {
type: "prompt_form_response",
values,
done,
},
};
emit(event.id, result);
};
const values = await showPromptForm({
id: event.payload.id,
title: event.payload.title,
description: event.payload.description,
size: event.payload.size,
inputs: event.payload.inputs,
confirmText: event.payload.confirmText,
cancelText: event.payload.cancelText,
onValuesChange: debounce(
(values) => emitFormResponse(values, false),
150,
),
onInputsUpdated: (cb) => activeForms.set(event.id, cb),
});
// Clean up and send final response
activeForms.delete(event.id);
emitFormResponse(values, true);
}
},
);
listenToTauriEvent<string>(
"update_installed",
async ({ payload: version }) => {
console.log("Got update installed event", version);
showUpdateInstalledToast(version);
},
);
// Listen for update events
listenToTauriEvent<UpdateInfo>("update_available", async ({ payload }) => {
console.log("Got update available", payload);
showUpdateAvailableToast(payload);
});
listenToTauriEvent<YaakNotification>("notification", ({ payload }) => {
console.log("Got notification event", payload);
showNotificationToast(payload);
});
// Listen for plugin update events
listenToTauriEvent<PluginUpdateNotification>(
"plugin_updates_available",
({ payload }) => {
console.log("Got plugin updates event", payload);
showPluginUpdatesToast(payload);
},
);
}
function showUpdateInstalledToast(version: string) {
const UPDATE_TOAST_ID = "update-info";
showToast({
id: UPDATE_TOAST_ID,
color: "primary",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} was installed</h2>
<p className="text-text-subtle text-sm">
Start using the new version now?
</p>
</VStack>
),
action: ({ hide }) => (
<ButtonInfiniteLoading
size="xs"
className="mr-auto min-w-[5rem]"
color="primary"
loadingChildren="Restarting..."
onClick={() => {
hide();
setTimeout(() => invokeCmd("cmd_restart", {}), 200);
}}
>
Relaunch Yaak
</ButtonInfiniteLoading>
),
});
}
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await emit<UpdateResponse>(replyEventId, { type: "ack" });
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{downloaded ? "Do you want to install" : "Download and install"} the
update?
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[10rem]"
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
onClick={async () => {
await emit<UpdateResponse>(replyEventId, {
type: "action",
action: "install",
});
}}
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
}
function showPluginUpdatesToast(updateInfo: PluginUpdateNotification) {
const PLUGIN_UPDATE_TOAST_ID = "plugin-updates";
const count = updateInfo.updateCount;
const pluginNames = updateInfo.plugins.map((p: { name: string }) => p.name);
showToast({
id: PLUGIN_UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">
{count === 1 ? "1 plugin update" : `${count} plugin updates`}{" "}
available
</h2>
<p className="text-text-subtle text-sm">
{count === 1
? pluginNames[0]
: `${pluginNames.slice(0, 2).join(", ")}${count > 2 ? `, and ${count - 2} more` : ""}`}
</p>
</VStack>
),
action: ({ hide }) => (
<HStack space={1.5}>
<ButtonInfiniteLoading
size="xs"
color="info"
className="min-w-[5rem]"
loadingChildren="Updating..."
onClick={async () => {
const updated = await updateAllPlugins();
hide();
if (updated.length > 0) {
showToast({
color: "success",
message: `Successfully updated ${updated.length} plugin${updated.length === 1 ? "" : "s"}`,
});
}
}}
>
Update All
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
onClick={() => {
hide();
openSettings.mutate("plugins:installed");
}}
>
View Updates
</Button>
</HStack>
),
});
}
function showNotificationToast(n: YaakNotification) {
const actionUrl = n.action?.url;
const actionLabel = n.action?.label;
showToast({
id: n.id,
timeout: n.timeout ?? null,
color: stringToColor(n.color) ?? undefined,
message: (
<VStack>
{n.title && <h2 className="font-semibold">{n.title}</h2>}
<p className="text-text-subtle text-sm">{n.message}</p>
</VStack>
),
onClose: () => {
invokeCmd("cmd_dismiss_notification", { notificationId: n.id }).catch(
console.error,
);
},
action: ({ hide }) => {
return actionLabel && actionUrl ? (
<Button
size="xs"
color={stringToColor(n.color) ?? undefined}
className="mr-auto min-w-[5rem]"
rightSlot={<Icon icon="external_link" />}
onClick={() => {
hide();
return openUrl(actionUrl);
}}
>
{actionLabel}
</Button>
) : null;
},
});
}

View File

@@ -1,8 +0,0 @@
export type { Appearance } from "@yaakapp-internal/theme";
export {
getCSSAppearance,
getWindowAppearance,
resolveAppearance,
subscribeToPreferredAppearance,
subscribeToWindowAppearanceChange,
} from "@yaakapp-internal/theme";

View File

@@ -1,45 +0,0 @@
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
import { invokeCmd } from "../tauri";
import type { Appearance } from "./appearance";
import { resolveAppearance } from "./appearance";
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
export async function getThemes() {
const themes = (
await invokeCmd<GetThemesResponse[]>("cmd_get_themes")
).flatMap((t) => t.themes);
themes.sort((a, b) => a.label.localeCompare(b.label));
// Remove duplicates, in case multiple plugins provide the same theme
const uniqueThemes = Array.from(
new Map(themes.map((t) => [t.id, t])).values(),
);
return { themes: [defaultDarkTheme, defaultLightTheme, ...uniqueThemes] };
}
export async function getResolvedTheme(
preferredAppearance: Appearance,
appearanceSetting: string,
themeLight: string,
themeDark: string,
) {
const appearance = resolveAppearance(preferredAppearance, appearanceSetting);
const { themes } = await getThemes();
const darkThemes = themes.filter((t) => t.dark);
const lightThemes = themes.filter((t) => !t.dark);
const dark =
darkThemes.find((t) => t.id === themeDark) ??
darkThemes[0] ??
defaultDarkTheme;
const light =
lightThemes.find((t) => t.id === themeLight) ??
lightThemes[0] ??
defaultLightTheme;
const active = appearance === "dark" ? dark : light;
return { dark, light, active };
}

View File

@@ -1,13 +0,0 @@
export type {
YaakColorKey,
YaakColors,
YaakTheme,
} from "@yaakapp-internal/theme";
export {
addThemeStylesToDocument,
applyThemeToDocument,
completeTheme,
getThemeCSS,
indent,
setThemeOnDocument,
} from "@yaakapp-internal/theme";

View File

@@ -1 +0,0 @@
export { YaakColor } from "@yaakapp-internal/theme";

View File

@@ -1,47 +0,0 @@
import "./main.css";
import { RouterProvider } from "@tanstack/react-router";
import { type } from "@tauri-apps/plugin-os";
import {
changeModelStoreWorkspace,
initModelStore,
} from "@yaakapp-internal/models";
import { setPlatformOnDocument } from "@yaakapp-internal/theme";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router";
const osType = type();
setPlatformOnDocument(osType);
window.addEventListener("keydown", (e) => {
const rx = /input|select|textarea/i;
const target = e.target;
if (e.key !== "Backspace") return;
if (!(target instanceof Element)) return;
if (target.getAttribute("contenteditable") !== null) return;
if (
!rx.test(target.tagName) ||
("disabled" in target && target.disabled) ||
("readOnly" in target && target.readOnly)
) {
e.preventDefault();
}
});
// Initialize a bunch of watchers
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root");
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -1,16 +0,0 @@
const sharedConfig = require("@yaakapp-internal/tailwind-config");
/** @type {import('tailwindcss').Config} */
module.exports = {
...sharedConfig,
content: [
"./*.{html,ts,tsx}",
"./commands/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./hooks/**/*.{ts,tsx}",
"./init/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
"./routes/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}",
],
};

View File

@@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es2021",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"useDefineForClassFields": true,
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@yaakapp-internal/theme": ["../../packages/theme/src/index.ts"],
"@yaakapp-internal/theme/*": ["../../packages/theme/src/*"],
"@yaakapp-internal/ui": ["../../packages/ui/src/index.ts"],
"@yaakapp-internal/ui/*": ["../../packages/ui/src/*"],
},
},
"include": ["."],
"exclude": ["vite.config.ts"],
"references": [{ "path": "./tsconfig.node.json" }],
}

View File

@@ -1,67 +0,0 @@
// @ts-ignore
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig, normalizePath } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import topLevelAwait from "vite-plugin-top-level-await";
import wasm from "vite-plugin-wasm";
const require = createRequire(import.meta.url);
const cMapsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "cmaps"),
);
const standardFontsDir = normalizePath(
path.join(
path.dirname(require.resolve("pdfjs-dist/package.json")),
"standard_fonts",
),
);
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
plugins: [
wasm(),
tanstackRouter({
target: "react",
routesDirectory: "./routes",
generatedRouteTree: "./routeTree.gen.ts",
autoCodeSplitting: true,
}),
svgr(),
react(),
topLevelAwait(),
viteStaticCopy({
targets: [
{ src: cMapsDir, dest: "" },
{ src: standardFontsDir, dest: "" },
],
}),
],
build: {
sourcemap: true,
outDir: "../../dist/apps/yaak-client",
emptyOutDir: true,
rollupOptions: {
output: {
// Make chunk names readable
chunkFileNames: "assets/chunk-[name]-[hash].js",
entryFileNames: "assets/entry-[name]-[hash].js",
assetFileNames: "assets/asset-[name]-[hash][extname]",
},
},
},
clearScreen: false,
server: {
port: parseInt(
process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420",
10,
),
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
};
});

View File

@@ -1,31 +0,0 @@
import { Button, type ButtonProps } from "@yaakapp-internal/ui";
import { useCallback, useState } from "react";
import type { ActionInvocation } from "@yaakapp-internal/proxy-lib";
import { useActionMetadata } from "./hooks";
import { rpc } from "./rpc";
type ActionButtonProps = Omit<ButtonProps, "onClick" | "children"> & {
action: ActionInvocation;
/** Override the label from metadata */
children?: React.ReactNode;
};
export function ActionButton({ action, children, ...props }: ActionButtonProps) {
const meta = useActionMetadata(action);
const [busy, setBusy] = useState(false);
const onClick = useCallback(async () => {
setBusy(true);
try {
await rpc("execute_action", action);
} finally {
setBusy(false);
}
}, [action]);
return (
<Button {...props} disabled={props.disabled || busy} isLoading={busy} onClick={onClick}>
{children ?? meta?.label ?? "…"}
</Button>
);
}

View File

@@ -1,35 +0,0 @@
import { useEffect, useState } from "react";
import type {
ActionInvocation,
ActionMetadata,
} from "@yaakapp-internal/proxy-lib";
import { rpc } from "./rpc";
let cachedActions: [ActionInvocation, ActionMetadata][] | null = null;
/** Fetch and cache all action metadata. */
async function getActions(): Promise<[ActionInvocation, ActionMetadata][]> {
if (!cachedActions) {
const { actions } = await rpc("list_actions", {});
cachedActions = actions;
}
return cachedActions;
}
/** Look up metadata for a specific action invocation. */
export function useActionMetadata(
action: ActionInvocation,
): ActionMetadata | null {
const [meta, setMeta] = useState<ActionMetadata | null>(null);
useEffect(() => {
getActions().then((actions) => {
const match = actions.find(
([inv]) => inv.scope === action.scope && inv.action === action.action,
);
setMeta(match?.[1] ?? null);
});
}, [action]);
return meta;
}

View File

@@ -1,64 +0,0 @@
import type {
ActionInvocation,
ActionMetadata,
} from "@yaakapp-internal/proxy-lib";
import { rpc } from "./rpc";
type ActionBinding = {
invocation: ActionInvocation;
meta: ActionMetadata;
keys: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean };
};
/** Parse a hotkey string like "Ctrl+Shift+P" into its parts. */
function parseHotkey(hotkey: string): ActionBinding["keys"] {
const parts = hotkey.split("+").map((p) => p.trim().toLowerCase());
return {
ctrl: parts.includes("ctrl") || parts.includes("control"),
shift: parts.includes("shift"),
alt: parts.includes("alt"),
meta: parts.includes("meta") || parts.includes("cmd") || parts.includes("command"),
key: parts.filter(
(p) => !["ctrl", "control", "shift", "alt", "meta", "cmd", "command"].includes(p),
)[0] ?? "",
};
}
function matchesEvent(binding: ActionBinding["keys"], e: KeyboardEvent): boolean {
return (
e.ctrlKey === binding.ctrl &&
e.shiftKey === binding.shift &&
e.altKey === binding.alt &&
e.metaKey === binding.meta &&
e.key.toLowerCase() === binding.key
);
}
/** Fetch all actions from Rust and register a global keydown listener. */
export async function initHotkeys(): Promise<() => void> {
const { actions } = await rpc("list_actions", {});
const bindings: ActionBinding[] = actions
.filter(
(entry): entry is [ActionInvocation, ActionMetadata & { defaultHotkey: string }] =>
entry[1].defaultHotkey != null,
)
.map(([invocation, meta]) => ({
invocation,
meta,
keys: parseHotkey(meta.defaultHotkey),
}));
function onKeyDown(e: KeyboardEvent) {
for (const binding of bindings) {
if (matchesEvent(binding.keys, e)) {
e.preventDefault();
rpc("execute_action", binding.invocation);
return;
}
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}

View File

@@ -1,27 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak Proxy</title>
<style>
html,
body {
background-color: white;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #1b1a29;
}
}
</style>
</head>
<body class="text-base">
<div id="root"></div>
<script type="module" src="/theme.ts"></script>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -1,92 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
body,
#root {
@apply w-full h-full overflow-hidden text-text bg-surface;
}
:root {
--font-family-interface: "";
--font-family-editor: "";
}
:root {
font-variant-ligatures: none;
}
html[data-platform="linux"] {
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
::selection {
@apply bg-selection;
}
:not(a),
:not(input):not(textarea),
:not(input):not(textarea)::after,
:not(input):not(textarea)::before {
@apply select-none cursor-default;
}
input,
textarea {
&::placeholder {
@apply text-placeholder;
}
}
a,
a[href] * {
@apply cursor-pointer !important;
}
table th {
@apply text-left;
}
:not(iframe) {
&::-webkit-scrollbar,
&::-webkit-scrollbar-corner {
@apply w-[8px] h-[8px] bg-transparent;
}
&::-webkit-scrollbar-track {
@apply bg-transparent;
}
&::-webkit-scrollbar-thumb {
@apply bg-text-subtlest rounded-[4px] opacity-20;
}
&::-webkit-scrollbar-thumb:hover {
@apply opacity-40 !important;
}
}
.hide-scrollbars {
&::-webkit-scrollbar-corner,
&::-webkit-scrollbar {
@apply hidden !important;
}
}
.rtl {
direction: rtl;
}
:root {
color-scheme: light dark;
--transition-duration: 100ms ease-in-out;
--color-white: 255 100% 100%;
--color-black: 255 0% 0%;
}
}

View File

@@ -1,111 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type } from "@tauri-apps/plugin-os";
import { HeaderSize } from "@yaakapp-internal/ui";
import { ActionButton } from "./ActionButton";
import classNames from "classnames";
import { createStore, Provider, useAtomValue } from "jotai";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./main.css";
import { initHotkeys } from "./hotkeys";
import { listen, rpc } from "./rpc";
import { applyChange, dataAtom, httpExchangesAtom, replaceAll } from "./store";
const queryClient = new QueryClient();
const jotaiStore = createStore();
// Load initial models from the database
rpc("list_models", {}).then((res) => {
jotaiStore.set(dataAtom, (prev) =>
replaceAll(prev, "http_exchange", res.httpExchanges),
);
});
// Register hotkeys from action metadata
initHotkeys();
// Subscribe to model change events from the backend
listen("model_write", (payload) => {
jotaiStore.set(dataAtom, (prev) =>
applyChange(prev, "http_exchange", payload.model, payload.change),
);
});
function App() {
const osType = type();
const exchanges = useAtomValue(httpExchangesAtom);
return (
<div
className={classNames(
"h-full w-full grid grid-rows-[auto_1fr]",
osType === "linux" && "border border-border-subtle",
)}
>
<HeaderSize
size="lg"
osType={osType}
hideWindowControls={false}
useNativeTitlebar={false}
interfaceScale={1}
className="x-theme-appHeader bg-surface"
>
<div
data-tauri-drag-region
className="flex items-center px-2 text-sm font-semibold text-text-subtle"
>
Yaak Proxy
</div>
</HeaderSize>
<main className="overflow-auto p-4">
<div className="flex items-center gap-3 mb-4">
<ActionButton
action={{ scope: "global", action: "proxy_start" }}
size="sm"
tone="primary"
/>
<ActionButton
action={{ scope: "global", action: "proxy_stop" }}
size="sm"
variant="border"
/>
</div>
<div className="text-xs font-mono">
{exchanges.length === 0 ? (
<p className="text-text-subtlest">No traffic yet</p>
) : (
<table className="w-full text-left">
<thead>
<tr className="text-text-subtlest border-b border-border-subtle">
<th className="py-1 pr-3 font-medium">Method</th>
<th className="py-1 pr-3 font-medium">URL</th>
<th className="py-1 pr-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{exchanges.map((ex) => (
<tr key={ex.id} className="border-b border-border-subtle">
<td className="py-1 pr-3">{ex.method}</td>
<td className="py-1 pr-3 truncate max-w-md">{ex.url}</td>
<td className="py-1 pr-3">{ex.resStatus ?? "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</main>
</div>
);
}
createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Provider store={jotaiStore}>
<App />
</Provider>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -1,31 +0,0 @@
{
"name": "@yaakapp/yaak-proxy",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev --force",
"build": "vite build",
"lint": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/model-store": "^1.0.0",
"@yaakapp-internal/proxy-lib": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.8"
}
}

View File

@@ -1,7 +0,0 @@
module.exports = {
plugins: [
require("@tailwindcss/nesting")(require("postcss-nesting")),
require("tailwindcss"),
require("autoprefixer"),
],
};

View File

@@ -1,30 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { listen as tauriListen } from "@tauri-apps/api/event";
import type {
RpcEventSchema,
RpcSchema,
} from "@yaakapp-internal/proxy-lib";
type Req<K extends keyof RpcSchema> = RpcSchema[K][0];
type Res<K extends keyof RpcSchema> = RpcSchema[K][1];
export async function rpc<K extends keyof RpcSchema>(
cmd: K,
payload: Req<K>,
): Promise<Res<K>> {
return invoke("rpc", { cmd, payload }) as Promise<Res<K>>;
}
/** Subscribe to a backend event. Returns an unsubscribe function. */
export function listen<K extends keyof RpcEventSchema>(
event: K & string,
callback: (payload: RpcEventSchema[K]) => void,
): () => void {
let unsub: (() => void) | null = null;
tauriListen<RpcEventSchema[K]>(event, (e) => callback(e.payload))
.then((fn) => {
unsub = fn;
})
.catch(console.error);
return () => unsub?.();
}

View File

@@ -1,15 +0,0 @@
import { createModelStore } from "@yaakapp-internal/model-store";
import type { HttpExchange } from "@yaakapp-internal/proxy-lib";
type ProxyModels = {
http_exchange: HttpExchange;
};
export const { dataAtom, applyChange, replaceAll, listAtom, orderedListAtom } =
createModelStore<ProxyModels>(["http_exchange"]);
export const httpExchangesAtom = orderedListAtom(
"http_exchange",
"createdAt",
"desc",
);

View File

@@ -1,7 +0,0 @@
const sharedConfig = require("@yaakapp-internal/tailwind-config");
/** @type {import('tailwindcss').Config} */
module.exports = {
...sharedConfig,
content: ["./*.{html,ts,tsx}", "../../packages/ui/src/**/*.{ts,tsx}"],
};

View File

@@ -1,9 +0,0 @@
import {
applyThemeToDocument,
defaultDarkTheme,
platformFromUserAgent,
setPlatformOnDocument,
} from "@yaakapp-internal/theme";
setPlatformOnDocument(platformFromUserAgent(navigator.userAgent));
applyThemeToDocument(defaultDarkTheme);

View File

@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"noUncheckedIndexedAccess": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,16 +0,0 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
build: {
outDir: "../../dist/apps/yaak-proxy",
emptyOutDir: true,
},
clearScreen: false,
server: {
port: parseInt(process.env.YAAK_PROXY_DEV_PORT ?? "2420", 10),
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_"],
});

View File

@@ -42,13 +42,12 @@
"!scripts",
"!crates",
"!crates-tauri",
"!apps/yaak-client/tailwind.config.cjs",
"!apps/yaak-client/postcss.config.cjs",
"!apps/yaak-client/vite.config.ts",
"!apps/yaak-client/routeTree.gen.ts",
"!src-web/tailwind.config.cjs",
"!src-web/postcss.config.cjs",
"!src-web/vite.config.ts",
"!src-web/routeTree.gen.ts",
"!packages/plugin-runtime-types/lib",
"!**/bindings",
"!flatpak"
"!**/bindings"
]
}
}

View File

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

View File

@@ -1,66 +0,0 @@
# Yaak CLI
The `yaak` CLI for publishing plugins and creating/updating/sending requests.
## Installation
```sh
npm install @yaakapp/cli
```
## Agentic Workflows
The `yaak` CLI is primarily meant to be used by AI agents, and has the following features:
- `schema` subcommands to get the JSON Schema for any model (eg. `yaak request schema http`)
- `--json '{...}'` input format to create and update data
- `--verbose` mode for extracting debug info while sending requests
- The ability to send entire workspaces and folders (Supports `--parallel` and `--fail-fast`)
### Example Prompts
Use the `yaak` CLI with agents like Claude or Codex to do useful things for you.
Here are some example prompts:
```text
Scan my API routes and create a workspace (using yaak cli) with
all the requests needed for me to do manual testing?
```
```text
Send all the GraphQL requests in my workspace
```
## Description
Here's the current print of `yaak --help`
```text
Yaak CLI - API client from the command line
Usage: yaak [OPTIONS] <COMMAND>
Commands:
auth Authentication commands
plugin Plugin development and publishing commands
send Send a request, folder, or workspace by ID
workspace Workspace commands
request Request commands
folder Folder commands
environment Environment commands
Options:
--data-dir <DATA_DIR> Use a custom data directory
-e, --environment <ENVIRONMENT> Environment ID to use for variable substitution
-v, --verbose Enable verbose send output (events and streamed response body)
--log [<LEVEL>] Enable CLI logging; optionally set level (error|warn|info|debug|trace) [possible values: error, warn, info, debug, trace]
-h, --help Print help
-V, --version Print version
Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)
```

View File

@@ -1,475 +0,0 @@
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "yaak")]
#[command(about = "Yaak CLI - API client from the command line")]
#[command(version = crate::version::cli_version())]
#[command(disable_help_subcommand = true)]
#[command(after_help = r#"Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)
"#)]
pub struct Cli {
/// Use a custom data directory
#[arg(long, global = true)]
pub data_dir: Option<PathBuf>,
/// Environment ID to use for variable substitution
#[arg(long, short, global = true)]
pub environment: Option<String>,
/// Cookie jar ID to use when sending requests
#[arg(long = "cookie-jar", global = true, value_name = "COOKIE_JAR_ID")]
pub cookie_jar: Option<String>,
/// Enable verbose send output (events and streamed response body)
#[arg(long, short, global = true)]
pub verbose: bool,
/// Enable CLI logging; optionally set level (error|warn|info|debug|trace)
#[arg(long, global = true, value_name = "LEVEL", num_args = 0..=1, ignore_case = true)]
pub log: Option<Option<LogLevel>>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Authentication commands
Auth(AuthArgs),
/// Plugin development and publishing commands
Plugin(PluginArgs),
#[command(hide = true)]
Build(PluginPathArg),
#[command(hide = true)]
Dev(PluginPathArg),
/// Backward-compatible alias for `plugin generate`
#[command(hide = true)]
Generate(GenerateArgs),
/// Backward-compatible alias for `plugin publish`
#[command(hide = true)]
Publish(PluginPathArg),
/// Send a request, folder, or workspace by ID
Send(SendArgs),
/// Cookie jar commands
CookieJar(CookieJarArgs),
/// Workspace commands
Workspace(WorkspaceArgs),
/// Request commands
Request(RequestArgs),
/// Folder commands
Folder(FolderArgs),
/// Environment commands
Environment(EnvironmentArgs),
}
#[derive(Args)]
pub struct SendArgs {
/// Request, folder, or workspace ID
pub id: String,
/// Execute requests in parallel
#[arg(long)]
pub parallel: bool,
/// Stop on first request failure when sending folders/workspaces
#[arg(long, conflicts_with = "parallel")]
pub fail_fast: bool,
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct CookieJarArgs {
#[command(subcommand)]
pub command: CookieJarCommands,
}
#[derive(Subcommand)]
pub enum CookieJarCommands {
/// List cookie jars in a workspace
List {
/// Workspace ID (optional when exactly one workspace exists)
workspace_id: Option<String>,
},
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct WorkspaceArgs {
#[command(subcommand)]
pub command: WorkspaceCommands,
}
#[derive(Subcommand)]
pub enum WorkspaceCommands {
/// List all workspaces
List,
/// Output JSON schema for workspace create/update payloads
Schema {
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Show a workspace as JSON
Show {
/// Workspace ID
workspace_id: String,
},
/// Create a workspace
Create {
/// Workspace name
#[arg(short, long)]
name: Option<String>,
/// JSON payload
#[arg(long, conflicts_with = "json_input")]
json: Option<String>,
/// JSON payload shorthand
#[arg(value_name = "JSON", conflicts_with = "json")]
json_input: Option<String>,
},
/// Update a workspace
Update {
/// JSON payload
#[arg(long, conflicts_with = "json_input")]
json: Option<String>,
/// JSON payload shorthand
#[arg(value_name = "JSON", conflicts_with = "json")]
json_input: Option<String>,
},
/// Delete a workspace
Delete {
/// Workspace ID
workspace_id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct RequestArgs {
#[command(subcommand)]
pub command: RequestCommands,
}
#[derive(Subcommand)]
pub enum RequestCommands {
/// List requests in a workspace
List {
/// Workspace ID (optional when exactly one workspace exists)
workspace_id: Option<String>,
},
/// Show a request as JSON
Show {
/// Request ID
request_id: String,
},
/// Send a request by ID
Send {
/// Request ID
request_id: String,
},
/// Output JSON schema for request create/update payloads
Schema {
#[arg(value_enum)]
request_type: RequestSchemaType,
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Create a new HTTP request
Create {
/// Workspace ID (or positional JSON payload shorthand)
workspace_id: Option<String>,
/// Request name
#[arg(short, long)]
name: Option<String>,
/// HTTP method
#[arg(short, long)]
method: Option<String>,
/// URL
#[arg(short, long)]
url: Option<String>,
/// JSON payload
#[arg(long)]
json: Option<String>,
},
/// Update an HTTP request
Update {
/// JSON payload
#[arg(long, conflicts_with = "json_input")]
json: Option<String>,
/// JSON payload shorthand
#[arg(value_name = "JSON", conflicts_with = "json")]
json_input: Option<String>,
},
/// Delete a request
Delete {
/// Request ID
request_id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum RequestSchemaType {
Http,
Grpc,
Websocket,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum LogLevel {
Error,
Warn,
Info,
Debug,
Trace,
}
impl LogLevel {
pub fn as_filter(self) -> log::LevelFilter {
match self {
LogLevel::Error => log::LevelFilter::Error,
LogLevel::Warn => log::LevelFilter::Warn,
LogLevel::Info => log::LevelFilter::Info,
LogLevel::Debug => log::LevelFilter::Debug,
LogLevel::Trace => log::LevelFilter::Trace,
}
}
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct FolderArgs {
#[command(subcommand)]
pub command: FolderCommands,
}
#[derive(Subcommand)]
pub enum FolderCommands {
/// List folders in a workspace
List {
/// Workspace ID (optional when exactly one workspace exists)
workspace_id: Option<String>,
},
/// Show a folder as JSON
Show {
/// Folder ID
folder_id: String,
},
/// Create a folder
Create {
/// Workspace ID (or positional JSON payload shorthand)
workspace_id: Option<String>,
/// Folder name
#[arg(short, long)]
name: Option<String>,
/// JSON payload
#[arg(long)]
json: Option<String>,
},
/// Update a folder
Update {
/// JSON payload
#[arg(long, conflicts_with = "json_input")]
json: Option<String>,
/// JSON payload shorthand
#[arg(value_name = "JSON", conflicts_with = "json")]
json_input: Option<String>,
},
/// Delete a folder
Delete {
/// Folder ID
folder_id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct EnvironmentArgs {
#[command(subcommand)]
pub command: EnvironmentCommands,
}
#[derive(Subcommand)]
pub enum EnvironmentCommands {
/// List environments in a workspace
List {
/// Workspace ID (optional when exactly one workspace exists)
workspace_id: Option<String>,
},
/// Output JSON schema for environment create/update payloads
Schema {
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Show an environment as JSON
Show {
/// Environment ID
environment_id: String,
},
/// Create an environment
#[command(after_help = r#"Modes (choose one):
1) yaak environment create <workspace_id> --name <name>
2) yaak environment create --json '{"workspaceId":"wk_abc","name":"Production"}'
3) yaak environment create '{"workspaceId":"wk_abc","name":"Production"}'
4) yaak environment create <workspace_id> --json '{"name":"Production"}'
"#)]
Create {
/// Workspace ID for flag-based mode, or positional JSON payload shorthand
#[arg(value_name = "WORKSPACE_ID_OR_JSON")]
workspace_id: Option<String>,
/// Environment name
#[arg(short, long)]
name: Option<String>,
/// JSON payload (use instead of WORKSPACE_ID/--name)
#[arg(long)]
json: Option<String>,
},
/// Update an environment
Update {
/// JSON payload
#[arg(long, conflicts_with = "json_input")]
json: Option<String>,
/// JSON payload shorthand
#[arg(value_name = "JSON", conflicts_with = "json")]
json_input: Option<String>,
},
/// Delete an environment
Delete {
/// Environment ID
environment_id: String,
/// Skip confirmation prompt
#[arg(short, long)]
yes: bool,
},
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
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)]
#[command(disable_help_subcommand = true)]
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),
/// Install a plugin from a local directory or from the registry
Install(InstallPluginArgs),
/// 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>,
}
#[derive(Args, Clone)]
pub struct InstallPluginArgs {
/// Local plugin directory path, or registry plugin spec (@org/plugin[@version])
pub source: String,
}

View File

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

View File

@@ -1,42 +0,0 @@
use crate::cli::{CookieJarArgs, CookieJarCommands};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: CookieJarArgs) -> i32 {
let result = match args.command {
CookieJarCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "cookie-jar list")?;
let cookie_jars = ctx
.db()
.list_cookie_jars(&workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?;
if cookie_jars.is_empty() {
println!("No cookie jars found in workspace {}", workspace_id);
} else {
for cookie_jar in cookie_jars {
println!(
"{} - {} ({} cookies)",
cookie_jar.id,
cookie_jar.name,
cookie_jar.cookies.len()
);
}
}
Ok(())
}

View File

@@ -1,176 +0,0 @@
use crate::cli::{EnvironmentArgs, EnvironmentCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
parse_required_json, require_id, validate_create_id,
};
use crate::utils::schema::append_agent_hints;
use crate::utils::workspace::resolve_workspace_id;
use schemars::schema_for;
use yaak_models::models::Environment;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 {
let result = match args.command {
EnvironmentCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),
EnvironmentCommands::Schema { pretty } => schema(pretty),
EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id),
EnvironmentCommands::Create { workspace_id, name, json } => {
create(ctx, workspace_id, name, json)
}
EnvironmentCommands::Update { json, json_input } => update(ctx, json, json_input),
EnvironmentCommands::Delete { environment_id, yes } => delete(ctx, &environment_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn schema(pretty: bool) -> CommandResult {
let mut schema = serde_json::to_value(schema_for!(Environment))
.map_err(|e| format!("Failed to serialize environment schema: {e}"))?;
append_agent_hints(&mut schema);
let output =
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
.map_err(|e| format!("Failed to format environment schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?;
let environments = ctx
.db()
.list_environments_ensure_base(&workspace_id)
.map_err(|e| format!("Failed to list environments: {e}"))?;
if environments.is_empty() {
println!("No environments found in workspace {}", workspace_id);
} else {
for environment in environments {
println!("{} - {} ({})", environment.id, environment.name, environment.parent_model);
}
}
Ok(())
}
fn show(ctx: &CliContext, environment_id: &str) -> CommandResult {
let environment = ctx
.db()
.get_environment(environment_id)
.map_err(|e| format!("Failed to get environment: {e}"))?;
let output = serde_json::to_string_pretty(&environment)
.map_err(|e| format!("Failed to serialize environment: {e}"))?;
println!("{output}");
Ok(())
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
name: Option<String>,
json: Option<String>,
) -> CommandResult {
let json_shorthand =
workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned);
let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v));
let payload = parse_optional_json(json, json_shorthand, "environment create")?;
if let Some(payload) = payload {
if name.is_some() {
return Err("environment create cannot combine --name with JSON payload".to_string());
}
validate_create_id(&payload, "environment")?;
let mut environment: Environment = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse environment create JSON: {e}"))?;
let fallback_workspace_id =
if workspace_id_arg.is_none() && environment.workspace_id.is_empty() {
Some(resolve_workspace_id(ctx, None, "environment create")?)
} else {
None
};
merge_workspace_id_arg(
workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),
&mut environment.workspace_id,
"environment create",
)?;
if environment.parent_model.is_empty() {
environment.parent_model = "environment".to_string();
}
let created = ctx
.db()
.upsert_environment(&environment, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create environment: {e}"))?;
println!("Created environment: {}", created.id);
return Ok(());
}
let workspace_id =
resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "environment create")?;
let name = name.ok_or_else(|| {
"environment create requires --name unless JSON payload is provided".to_string()
})?;
let environment = Environment {
workspace_id,
name,
parent_model: "environment".to_string(),
..Default::default()
};
let created = ctx
.db()
.upsert_environment(&environment, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create environment: {e}"))?;
println!("Created environment: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "environment update")?;
let id = require_id(&patch, "environment update")?;
let existing = ctx
.db()
.get_environment(&id)
.map_err(|e| format!("Failed to get environment for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?;
let saved = ctx
.db()
.upsert_environment(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update environment: {e}"))?;
println!("Updated environment: {}", saved.id);
Ok(())
}
fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("environment", environment_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_environment_by_id(environment_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete environment: {e}"))?;
println!("Deleted environment: {}", deleted.id);
Ok(())
}

View File

@@ -1,144 +0,0 @@
use crate::cli::{FolderArgs, FolderCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
parse_required_json, require_id, validate_create_id,
};
use crate::utils::workspace::resolve_workspace_id;
use yaak_models::models::Folder;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
let result = match args.command {
FolderCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),
FolderCommands::Show { folder_id } => show(ctx, &folder_id),
FolderCommands::Create { workspace_id, name, json } => {
create(ctx, workspace_id, name, json)
}
FolderCommands::Update { json, json_input } => update(ctx, json, json_input),
FolderCommands::Delete { folder_id, yes } => delete(ctx, &folder_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "folder list")?;
let folders =
ctx.db().list_folders(&workspace_id).map_err(|e| format!("Failed to list folders: {e}"))?;
if folders.is_empty() {
println!("No folders found in workspace {}", workspace_id);
} else {
for folder in folders {
println!("{} - {}", folder.id, folder.name);
}
}
Ok(())
}
fn show(ctx: &CliContext, folder_id: &str) -> CommandResult {
let folder =
ctx.db().get_folder(folder_id).map_err(|e| format!("Failed to get folder: {e}"))?;
let output = serde_json::to_string_pretty(&folder)
.map_err(|e| format!("Failed to serialize folder: {e}"))?;
println!("{output}");
Ok(())
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
name: Option<String>,
json: Option<String>,
) -> CommandResult {
let json_shorthand =
workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned);
let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v));
let payload = parse_optional_json(json, json_shorthand, "folder create")?;
if let Some(payload) = payload {
if name.is_some() {
return Err("folder create cannot combine --name with JSON payload".to_string());
}
validate_create_id(&payload, "folder")?;
let mut folder: Folder = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse folder create JSON: {e}"))?;
let fallback_workspace_id = if workspace_id_arg.is_none() && folder.workspace_id.is_empty()
{
Some(resolve_workspace_id(ctx, None, "folder create")?)
} else {
None
};
merge_workspace_id_arg(
workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),
&mut folder.workspace_id,
"folder create",
)?;
let created = ctx
.db()
.upsert_folder(&folder, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create folder: {e}"))?;
println!("Created folder: {}", created.id);
return Ok(());
}
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "folder create")?;
let name = name.ok_or_else(|| {
"folder create requires --name unless JSON payload is provided".to_string()
})?;
let folder = Folder { workspace_id, name, ..Default::default() };
let created = ctx
.db()
.upsert_folder(&folder, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create folder: {e}"))?;
println!("Created folder: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "folder update")?;
let id = require_id(&patch, "folder update")?;
let existing =
ctx.db().get_folder(&id).map_err(|e| format!("Failed to get folder for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?;
let saved = ctx
.db()
.upsert_folder(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update folder: {e}"))?;
println!("Updated folder: {}", saved.id);
Ok(())
}
fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("folder", folder_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_folder_by_id(folder_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete folder: {e}"))?;
println!("Deleted folder: {}", deleted.id);
Ok(())
}

View File

@@ -1,8 +0,0 @@
pub mod auth;
pub mod cookie_jar;
pub mod environment;
pub mod folder;
pub mod plugin;
pub mod request;
pub mod send;
pub mod workspace;

View File

@@ -1,680 +0,0 @@
use crate::cli::{GenerateArgs, InstallPluginArgs, PluginPathArg};
use crate::context::CliContext;
use crate::ui;
use crate::utils::http;
use keyring::Entry;
use rand::Rng;
use rolldown::{
BundleEvent, Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat,
Platform, WatchOption, Watcher, WatcherEvent,
};
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 yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::models::{Plugin, PluginSource};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::install::download_and_install;
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_install(context: &CliContext, args: InstallPluginArgs) -> i32 {
match install(context, args).await {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
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()));
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}"))?;
let emitter = watcher.emitter();
let watch_root = plugin_dir.clone();
let _event_logger = tokio::spawn(async move {
loop {
let event = {
let rx = emitter.rx.lock().await;
rx.recv()
};
let Ok(event) = event else {
break;
};
match event {
WatcherEvent::Change(change) => {
let changed_path = Path::new(change.path.as_str());
let display_path = changed_path
.strip_prefix(&watch_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| {
changed_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string())
});
ui::info(&format!("Rebuilding plugin {display_path}"));
}
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}
WatcherEvent::Event(BundleEvent::Error(event)) => {
if event.error.diagnostics.is_empty() {
ui::error("Plugin build failed");
} else {
for diagnostic in event.error.diagnostics {
ui::error(&diagnostic.to_string());
}
}
}
WatcherEvent::Close => break,
_ => {}
}
}
});
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(())
}
async fn install(context: &CliContext, args: InstallPluginArgs) -> CommandResult {
if args.source.starts_with('@') {
let (name, version) =
parse_registry_install_spec(args.source.as_str()).ok_or_else(|| {
"Invalid registry plugin spec. Expected format: @org/plugin or @org/plugin@version"
.to_string()
})?;
return install_from_registry(context, name, version).await;
}
install_from_directory(context, args.source.as_str()).await
}
async fn install_from_registry(
context: &CliContext,
name: String,
version: Option<String>,
) -> CommandResult {
let current_version = crate::version::cli_version();
let http_client = yaak_api_client(ApiClientKind::Cli, current_version)
.map_err(|err| format!("Failed to initialize API client: {err}"))?;
let installing_version = version.clone().unwrap_or_else(|| "latest".to_string());
ui::info(&format!("Installing registry plugin {name}@{installing_version}"));
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
let installed = download_and_install(
context.plugin_manager(),
context.query_manager(),
&http_client,
&plugin_context,
name.as_str(),
version,
)
.await
.map_err(|err| format!("Failed to install plugin: {err}"))?;
ui::success(&format!("Installed plugin {}@{}", installed.name, installed.version));
Ok(())
}
async fn install_from_directory(context: &CliContext, source: &str) -> CommandResult {
let plugin_dir = resolve_plugin_dir(Some(PathBuf::from(source)))?;
let plugin_dir_str = plugin_dir
.to_str()
.ok_or_else(|| {
format!("Plugin directory path is not valid UTF-8: {}", plugin_dir.display())
})?
.to_string();
ui::info(&format!("Installing plugin from directory {}", plugin_dir.display()));
let plugin = context
.db()
.upsert_plugin(
&Plugin {
directory: plugin_dir_str,
url: None,
enabled: true,
source: PluginSource::Filesystem,
..Default::default()
},
&UpdateSource::Background,
)
.map_err(|err| format!("Failed to save plugin in database: {err}"))?;
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
context
.plugin_manager()
.add_plugin(&plugin_context, &plugin)
.await
.map_err(|err| format!("Failed to load plugin runtime: {err}"))?;
ui::success(&format!("Installed plugin from {}", plugin.directory));
Ok(())
}
fn parse_registry_install_spec(source: &str) -> Option<(String, Option<String>)> {
if !source.starts_with('@') || !source.contains('/') {
return None;
}
let rest = source.get(1..)?;
let version_split = rest.rfind('@').map(|idx| idx + 1);
let (name, version) = match version_split {
Some(at_idx) => {
let (name, version) = source.split_at(at_idx);
let version = version.strip_prefix('@').unwrap_or_default();
if version.is_empty() {
return None;
}
(name.to_string(), Some(version.to_string()))
}
None => (source.to_string(), None),
};
if !name.starts_with('@') {
return None;
}
let without_scope = name.get(1..)?;
let (scope, plugin_name) = without_scope.split_once('/')?;
if scope.is_empty() || plugin_name.is_empty() {
return None;
}
Some((name, version))
}
#[derive(Deserialize)]
struct PublishResponse {
version: String,
url: String,
}
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
prepare_build_output_dir(plugin_dir)?;
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
}
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
let build_dir = plugin_dir.join("build");
if build_dir.exists() {
fs::remove_dir_all(&build_dir)
.map_err(|e| format!("Failed to clean build directory {}: {e}", build_dir.display()))?;
}
fs::create_dir_all(&build_dir)
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
}
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
BundlerOptions {
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
cwd: Some(plugin_dir.to_path_buf()),
file: Some("build/index.js".to_string()),
format: Some(OutputFormat::Cjs),
platform: Some(Platform::Node),
log_level: Some(LogLevel::Info),
experimental: watch
.then_some(ExperimentalOptions { incremental_build: Some(true), ..Default::default() }),
watch: watch.then_some(WatchOption::default()),
..Default::default()
}
}
fn resolve_plugin_dir(path: Option<PathBuf>) -> CommandResult<PathBuf> {
let cwd =
std::env::current_dir().map_err(|e| format!("Failed to read current directory: {e}"))?;
let candidate = match path {
Some(path) if path.is_absolute() => path,
Some(path) => cwd.join(path),
None => cwd,
};
if !candidate.exists() {
return Err(format!("Plugin directory does not exist: {}", candidate.display()));
}
if !candidate.is_dir() {
return Err(format!("Plugin path is not a directory: {}", candidate.display()));
}
candidate
.canonicalize()
.map_err(|e| format!("Failed to resolve plugin directory {}: {e}", candidate.display()))
}
fn ensure_plugin_build_inputs(plugin_dir: &Path) -> CommandResult {
let package_json = plugin_dir.join("package.json");
if !package_json.is_file() {
return Err(format!(
"{} does not exist. Ensure that you are in a plugin directory.",
package_json.display()
));
}
let entry = plugin_dir.join("src/index.ts");
if !entry.is_file() {
return Err(format!("Required entrypoint missing: {}", entry.display()));
}
Ok(())
}
fn create_publish_archive(plugin_dir: &Path) -> CommandResult<Vec<u8>> {
let required_files = [
"README.md",
"package.json",
"build/index.js",
"src/index.ts",
];
let optional_files = ["package-lock.json"];
let mut selected = HashSet::new();
for required in required_files {
let required_path = plugin_dir.join(required);
if !required_path.is_file() {
return Err(format!("Missing required file: {required}"));
}
selected.insert(required.to_string());
}
for optional in optional_files {
selected.insert(optional.to_string());
}
let cursor = std::io::Cursor::new(Vec::new());
let mut zip = zip::ZipWriter::new(cursor);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for entry in WalkDir::new(plugin_dir) {
let entry = entry.map_err(|e| format!("Failed walking plugin directory: {e}"))?;
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let rel = path
.strip_prefix(plugin_dir)
.map_err(|e| format!("Failed deriving relative path for {}: {e}", path.display()))?;
let rel = rel.to_string_lossy().replace('\\', "/");
let keep = rel.starts_with("src/") || rel.starts_with("build/") || selected.contains(&rel);
if !keep {
continue;
}
zip.start_file(rel, options).map_err(|e| format!("Failed adding file to archive: {e}"))?;
let mut file = fs::File::open(path)
.map_err(|e| format!("Failed opening file {}: {e}", path.display()))?;
let mut contents = Vec::new();
file.read_to_end(&mut contents)
.map_err(|e| format!("Failed reading file {}: {e}", path.display()))?;
zip.write_all(&contents).map_err(|e| format!("Failed writing archive contents: {e}"))?;
}
let cursor = zip.finish().map_err(|e| format!("Failed finalizing plugin archive: {e}"))?;
Ok(cursor.into_inner())
}
fn write_file(path: &Path, contents: &str) -> CommandResult {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed creating directory {}: {e}", parent.display()))?;
}
fs::write(path, contents).map_err(|e| format!("Failed writing file {}: {e}", path.display()))
}
fn prompt_with_default(label: &str, default: &str) -> CommandResult<String> {
if !io::stdin().is_terminal() {
return Ok(default.to_string());
}
print!("{label} [{default}]: ");
io::stdout().flush().map_err(|e| format!("Failed to flush stdout: {e}"))?;
let mut input = String::new();
io::stdin().read_line(&mut input).map_err(|e| format!("Failed to read input: {e}"))?;
let trimmed = input.trim();
if trimmed.is_empty() { Ok(default.to_string()) } else { Ok(trimmed.to_string()) }
}
fn current_environment() -> Environment {
match std::env::var("ENVIRONMENT").as_deref() {
Ok("staging") => Environment::Staging,
Ok("development") => Environment::Development,
_ => Environment::Production,
}
}
fn keyring_entry(environment: Environment) -> CommandResult<Entry> {
Entry::new(environment.keyring_service(), KEYRING_USER)
.map_err(|e| format!("Failed to initialize auth keyring entry: {e}"))
}
fn get_auth_token(environment: Environment) -> CommandResult<Option<String>> {
let entry = keyring_entry(environment)?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(format!("Failed to read auth token: {err}")),
}
}
fn random_name() -> String {
const ADJECTIVES: &[&str] = &[
"young", "youthful", "yellow", "yielding", "yappy", "yawning", "yummy", "yucky", "yearly",
"yester", "yeasty", "yelling",
];
const NOUNS: &[&str] = &[
"yak", "yarn", "year", "yell", "yoke", "yoga", "yam", "yacht", "yodel",
];
let mut rng = rand::thread_rng();
let adjective = ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())];
let noun = NOUNS[rng.gen_range(0..NOUNS.len())];
format!("{adjective}-{noun}")
}
const TEMPLATE_GITIGNORE: &str = "node_modules\n";
const TEMPLATE_PACKAGE_JSON: &str = r#"{
"name": "yaak-plugin-name",
"private": true,
"version": "0.0.1",
"scripts": {
"build": "yaak plugin build",
"dev": "yaak plugin dev"
},
"devDependencies": {
"@types/node": "^24.10.1",
"typescript": "^5.9.3",
"vitest": "^4.0.14"
},
"dependencies": {
"@yaakapp/api": "^0.7.0"
}
}
"#;
const TEMPLATE_TSCONFIG: &str = r#"{
"compilerOptions": {
"target": "es2021",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"useDefineForClassFields": true,
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
"#;
const TEMPLATE_README: &str = r#"# yaak-plugin-name
Describe what your plugin does.
"#;
const TEMPLATE_INDEX_TS: &str = r#"import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: "Hello, From Plugin",
icon: "info",
async onSelect(ctx, args) {
await ctx.toast.show({
color: "success",
message: `You clicked the request ${args.httpRequest.id}`,
});
},
},
],
};
"#;
const TEMPLATE_INDEX_TEST_TS: &str = r#"import { describe, expect, test } from "vitest";
import { plugin } from "./index";
describe("Example Plugin", () => {
test("Exports plugin object", () => {
expect(plugin).toBeTypeOf("object");
});
});
"#;
#[cfg(test)]
mod tests {
use super::create_publish_archive;
use std::collections::HashSet;
use std::fs;
use std::io::Cursor;
use tempfile::TempDir;
use zip::ZipArchive;
#[test]
fn publish_archive_includes_required_and_optional_files() {
let dir = TempDir::new().expect("temp dir");
let root = dir.path();
fs::create_dir_all(root.join("src")).expect("create src");
fs::create_dir_all(root.join("build")).expect("create build");
fs::create_dir_all(root.join("ignored")).expect("create ignored");
fs::write(root.join("README.md"), "# Demo\n").expect("write README");
fs::write(root.join("package.json"), "{}").expect("write package.json");
fs::write(root.join("package-lock.json"), "{}").expect("write package-lock.json");
fs::write(root.join("src/index.ts"), "export const plugin = {};\n")
.expect("write src/index.ts");
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
.expect("write build/index.js");
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
let archive = create_publish_archive(root).expect("create archive");
let mut zip = ZipArchive::new(Cursor::new(archive)).expect("open zip");
let mut names = HashSet::new();
for i in 0..zip.len() {
let file = zip.by_index(i).expect("zip entry");
names.insert(file.name().to_string());
}
assert!(names.contains("README.md"));
assert!(names.contains("package.json"));
assert!(names.contains("package-lock.json"));
assert!(names.contains("src/index.ts"));
assert!(names.contains("build/index.js"));
assert!(!names.contains("ignored/secret.txt"));
}
}

View File

@@ -1,548 +0,0 @@
use crate::cli::{RequestArgs, RequestCommands, RequestSchemaType};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
parse_required_json, require_id, validate_create_id,
};
use crate::utils::schema::append_agent_hints;
use crate::utils::workspace::resolve_workspace_id;
use schemars::schema_for;
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::io::Write;
use tokio::sync::mpsc;
use yaak::send::{SendHttpRequestByIdWithPluginsParams, send_http_request_by_id_with_plugins};
use yaak_http::sender::HttpResponseEvent as SenderHttpResponseEvent;
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{FormInput, FormInputBase, JsonPrimitive, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
pub async fn run(
ctx: &CliContext,
args: RequestArgs,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> i32 {
let result = match args.command {
RequestCommands::List { workspace_id } => list(ctx, workspace_id.as_deref()),
RequestCommands::Show { request_id } => show(ctx, &request_id),
RequestCommands::Send { request_id } => {
return match send_request_by_id(ctx, &request_id, environment, cookie_jar_id, verbose)
.await
{
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
};
}
RequestCommands::Schema { request_type, pretty } => {
return match schema(ctx, request_type, pretty).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
};
}
RequestCommands::Create { workspace_id, name, method, url, json } => {
create(ctx, workspace_id, name, method, url, json)
}
RequestCommands::Update { json, json_input } => update(ctx, json, json_input),
RequestCommands::Delete { request_id, yes } => delete(ctx, &request_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
let workspace_id = resolve_workspace_id(ctx, workspace_id, "request list")?;
let requests = ctx
.db()
.list_http_requests(&workspace_id)
.map_err(|e| format!("Failed to list requests: {e}"))?;
if requests.is_empty() {
println!("No requests found in workspace {}", workspace_id);
} else {
for request in requests {
println!("{} - {} {}", request.id, request.method, request.name);
}
}
Ok(())
}
async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) -> CommandResult {
let mut schema = match request_type {
RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))
.map_err(|e| format!("Failed to serialize HTTP request schema: {e}"))?,
RequestSchemaType::Grpc => serde_json::to_value(schema_for!(GrpcRequest))
.map_err(|e| format!("Failed to serialize gRPC request schema: {e}"))?,
RequestSchemaType::Websocket => serde_json::to_value(schema_for!(WebsocketRequest))
.map_err(|e| format!("Failed to serialize WebSocket request schema: {e}"))?,
};
enrich_schema_guidance(&mut schema, request_type);
append_agent_hints(&mut schema);
if let Err(error) = merge_auth_schema_from_plugins(ctx, &mut schema).await {
eprintln!("Warning: Failed to enrich authentication schema from plugins: {error}");
}
let output =
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
.map_err(|e| format!("Failed to format schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
fn enrich_schema_guidance(schema: &mut Value, request_type: RequestSchemaType) {
if !matches!(request_type, RequestSchemaType::Http) {
return;
}
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return;
};
if let Some(url_schema) = properties.get_mut("url").and_then(Value::as_object_mut) {
append_description(
url_schema,
"For path segments like `/foo/:id/comments/:commentId`, put concrete values in `urlParameters` using names without `:` (for example `id`, `commentId`).",
);
}
}
fn append_description(schema: &mut Map<String, Value>, extra: &str) {
match schema.get_mut("description") {
Some(Value::String(existing)) if !existing.trim().is_empty() => {
if !existing.ends_with(' ') {
existing.push(' ');
}
existing.push_str(extra);
}
_ => {
schema.insert("description".to_string(), Value::String(extra.to_string()));
}
}
}
async fn merge_auth_schema_from_plugins(
ctx: &CliContext,
schema: &mut Value,
) -> Result<(), String> {
let plugin_context = PluginContext::new_empty();
let plugin_manager = ctx.plugin_manager();
let summaries = plugin_manager
.get_http_authentication_summaries(&plugin_context)
.await
.map_err(|e| e.to_string())?;
let mut auth_variants = Vec::new();
for (_, summary) in summaries {
let config = match plugin_manager
.get_http_authentication_config(
&plugin_context,
&summary.name,
HashMap::<String, JsonPrimitive>::new(),
"yaakcli_request_schema",
)
.await
{
Ok(config) => config,
Err(error) => {
eprintln!(
"Warning: Failed to load auth config for strategy '{}': {}",
summary.name, error
);
continue;
}
};
auth_variants.push(auth_variant_schema(&summary.name, &summary.label, &config.args));
}
let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
return Ok(());
};
let Some(auth_schema) = properties.get_mut("authentication") else {
return Ok(());
};
if !auth_variants.is_empty() {
let mut one_of = vec![auth_schema.clone()];
one_of.extend(auth_variants);
*auth_schema = json!({ "oneOf": one_of });
}
Ok(())
}
fn auth_variant_schema(auth_name: &str, auth_label: &str, args: &[FormInput]) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for input in args {
add_input_schema(input, &mut properties, &mut required);
}
let mut schema = json!({
"title": auth_label,
"description": format!("Authentication values for strategy '{}'", auth_name),
"type": "object",
"properties": properties,
"additionalProperties": true
});
if !required.is_empty() {
schema["required"] = json!(required);
}
schema
}
fn add_input_schema(
input: &FormInput,
properties: &mut Map<String, Value>,
required: &mut Vec<String>,
) {
match input {
FormInput::Text(v) => add_base_schema(
&v.base,
json!({
"type": "string",
"writeOnly": v.password.unwrap_or(false),
}),
properties,
required,
),
FormInput::Editor(v) => add_base_schema(
&v.base,
json!({
"type": "string",
"x-editorLanguage": v.language.clone(),
}),
properties,
required,
),
FormInput::Select(v) => {
let options: Vec<Value> =
v.options.iter().map(|o| Value::String(o.value.clone())).collect();
add_base_schema(
&v.base,
json!({
"type": "string",
"enum": options,
}),
properties,
required,
);
}
FormInput::Checkbox(v) => {
add_base_schema(&v.base, json!({ "type": "boolean" }), properties, required);
}
FormInput::File(v) => {
if v.multiple.unwrap_or(false) {
add_base_schema(
&v.base,
json!({
"type": "array",
"items": { "type": "string" },
}),
properties,
required,
);
} else {
add_base_schema(&v.base, json!({ "type": "string" }), properties, required);
}
}
FormInput::HttpRequest(v) => {
add_base_schema(&v.base, json!({ "type": "string" }), properties, required);
}
FormInput::KeyValue(v) => {
add_base_schema(
&v.base,
json!({
"type": "object",
"additionalProperties": true,
}),
properties,
required,
);
}
FormInput::Accordion(v) => {
if let Some(children) = &v.inputs {
for child in children {
add_input_schema(child, properties, required);
}
}
}
FormInput::HStack(v) => {
if let Some(children) = &v.inputs {
for child in children {
add_input_schema(child, properties, required);
}
}
}
FormInput::Banner(v) => {
if let Some(children) = &v.inputs {
for child in children {
add_input_schema(child, properties, required);
}
}
}
FormInput::Markdown(_) => {}
}
}
fn add_base_schema(
base: &FormInputBase,
mut schema: Value,
properties: &mut Map<String, Value>,
required: &mut Vec<String>,
) {
if base.hidden.unwrap_or(false) || base.name.trim().is_empty() {
return;
}
if let Some(description) = &base.description {
schema["description"] = Value::String(description.clone());
}
if let Some(label) = &base.label {
schema["title"] = Value::String(label.clone());
}
if let Some(default_value) = &base.default_value {
schema["default"] = Value::String(default_value.clone());
}
let name = base.name.clone();
properties.insert(name.clone(), schema);
if !base.optional.unwrap_or(false) {
required.push(name);
}
}
fn create(
ctx: &CliContext,
workspace_id: Option<String>,
name: Option<String>,
method: Option<String>,
url: Option<String>,
json: Option<String>,
) -> CommandResult {
let json_shorthand =
workspace_id.as_deref().filter(|v| is_json_shorthand(v)).map(str::to_owned);
let workspace_id_arg = workspace_id.filter(|v| !is_json_shorthand(v));
let payload = parse_optional_json(json, json_shorthand, "request create")?;
if let Some(payload) = payload {
if name.is_some() || method.is_some() || url.is_some() {
return Err("request create cannot combine simple flags with JSON payload".to_string());
}
validate_create_id(&payload, "request")?;
let mut request: HttpRequest = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
{
Some(resolve_workspace_id(ctx, None, "request create")?)
} else {
None
};
merge_workspace_id_arg(
workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),
&mut request.workspace_id,
"request create",
)?;
let created = ctx
.db()
.upsert_http_request(&request, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create request: {e}"))?;
println!("Created request: {}", created.id);
return Ok(());
}
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
let name = name.unwrap_or_default();
let url = url.unwrap_or_default();
let method = method.unwrap_or_else(|| "GET".to_string());
let request = HttpRequest {
workspace_id,
name,
method: method.to_uppercase(),
url,
..Default::default()
};
let created = ctx
.db()
.upsert_http_request(&request, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create request: {e}"))?;
println!("Created request: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "request update")?;
let id = require_id(&patch, "request update")?;
let existing = ctx
.db()
.get_http_request(&id)
.map_err(|e| format!("Failed to get request for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "request update")?;
let saved = ctx
.db()
.upsert_http_request(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update request: {e}"))?;
println!("Updated request: {}", saved.id);
Ok(())
}
fn show(ctx: &CliContext, request_id: &str) -> CommandResult {
let request =
ctx.db().get_http_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
let output = serde_json::to_string_pretty(&request)
.map_err(|e| format!("Failed to serialize request: {e}"))?;
println!("{output}");
Ok(())
}
fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("request", request_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_http_request_by_id(request_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete request: {e}"))?;
println!("Deleted request: {}", deleted.id);
Ok(())
}
/// Send a request by ID and print response in the same format as legacy `send`.
pub async fn send_request_by_id(
ctx: &CliContext,
request_id: &str,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let request =
ctx.db().get_any_request(request_id).map_err(|e| format!("Failed to get request: {e}"))?;
match request {
AnyRequest::HttpRequest(http_request) => {
send_http_request_by_id(
ctx,
&http_request.id,
&http_request.workspace_id,
environment,
cookie_jar_id,
verbose,
)
.await
}
AnyRequest::GrpcRequest(_) => {
Err("gRPC request send is not implemented yet in yaak-cli".to_string())
}
AnyRequest::WebsocketRequest(_) => {
Err("WebSocket request send is not implemented yet in yaak-cli".to_string())
}
}
}
async fn send_http_request_by_id(
ctx: &CliContext,
request_id: &str,
workspace_id: &str,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let cookie_jar_id = resolve_cookie_jar_id(ctx, workspace_id, cookie_jar_id)?;
let plugin_context =
PluginContext::new(Some("cli".to_string()), Some(workspace_id.to_string()));
let (event_tx, mut event_rx) = mpsc::channel::<SenderHttpResponseEvent>(100);
let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let event_handle = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if verbose && !matches!(event, SenderHttpResponseEvent::ChunkReceived { .. }) {
println!("{}", event);
}
}
});
let body_handle = tokio::task::spawn_blocking(move || {
let mut stdout = std::io::stdout();
while let Some(chunk) = body_chunk_rx.blocking_recv() {
if stdout.write_all(&chunk).is_err() {
break;
}
let _ = stdout.flush();
}
});
let response_dir = ctx.data_dir().join("responses");
let result = send_http_request_by_id_with_plugins(SendHttpRequestByIdWithPluginsParams {
query_manager: ctx.query_manager(),
blob_manager: ctx.blob_manager(),
request_id,
environment_id: environment,
update_source: UpdateSource::Sync,
cookie_jar_id,
response_dir: &response_dir,
emit_events_to: Some(event_tx),
emit_response_body_chunks_to: Some(body_chunk_tx),
plugin_manager: ctx.plugin_manager(),
encryption_manager: ctx.encryption_manager.clone(),
plugin_context: &plugin_context,
cancelled_rx: None,
connection_manager: None,
})
.await;
let _ = event_handle.await;
let _ = body_handle.await;
result.map_err(|e| e.to_string())?;
Ok(())
}
pub(crate) fn resolve_cookie_jar_id(
ctx: &CliContext,
workspace_id: &str,
explicit_cookie_jar_id: Option<&str>,
) -> Result<Option<String>, String> {
if let Some(cookie_jar_id) = explicit_cookie_jar_id {
return Ok(Some(cookie_jar_id.to_string()));
}
let default_cookie_jar = ctx
.db()
.list_cookie_jars(workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?
.into_iter()
.min_by_key(|jar| jar.created_at)
.map(|jar| jar.id);
Ok(default_cookie_jar)
}

View File

@@ -1,242 +0,0 @@
use crate::cli::SendArgs;
use crate::commands::request;
use crate::context::CliContext;
use futures::future::join_all;
use yaak_models::queries::any_request::AnyRequest;
enum ExecutionMode {
Sequential,
Parallel,
}
pub async fn run(
ctx: &CliContext,
args: SendArgs,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> i32 {
match send_target(ctx, args, environment, cookie_jar_id, verbose).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
async fn send_target(
ctx: &CliContext,
args: SendArgs,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential };
if let Ok(request) = ctx.db().get_any_request(&args.id) {
let workspace_id = match &request {
AnyRequest::HttpRequest(r) => r.workspace_id.clone(),
AnyRequest::GrpcRequest(r) => r.workspace_id.clone(),
AnyRequest::WebsocketRequest(r) => r.workspace_id.clone(),
};
let resolved_cookie_jar_id =
request::resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar_id)?;
return request::send_request_by_id(
ctx,
&args.id,
environment,
resolved_cookie_jar_id.as_deref(),
verbose,
)
.await;
}
if let Ok(folder) = ctx.db().get_folder(&args.id) {
let resolved_cookie_jar_id =
request::resolve_cookie_jar_id(ctx, &folder.workspace_id, cookie_jar_id)?;
let request_ids = collect_folder_request_ids(ctx, &args.id)?;
if request_ids.is_empty() {
println!("No requests found in folder {}", args.id);
return Ok(());
}
return send_many(
ctx,
request_ids,
mode,
args.fail_fast,
environment,
resolved_cookie_jar_id.as_deref(),
verbose,
)
.await;
}
if let Ok(workspace) = ctx.db().get_workspace(&args.id) {
let resolved_cookie_jar_id =
request::resolve_cookie_jar_id(ctx, &workspace.id, cookie_jar_id)?;
let request_ids = collect_workspace_request_ids(ctx, &args.id)?;
if request_ids.is_empty() {
println!("No requests found in workspace {}", args.id);
return Ok(());
}
return send_many(
ctx,
request_ids,
mode,
args.fail_fast,
environment,
resolved_cookie_jar_id.as_deref(),
verbose,
)
.await;
}
Err(format!("Could not resolve ID '{}' as request, folder, or workspace", args.id))
}
fn collect_folder_request_ids(ctx: &CliContext, folder_id: &str) -> Result<Vec<String>, String> {
let mut ids = Vec::new();
let mut http_ids = ctx
.db()
.list_http_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list HTTP requests in folder: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut http_ids);
let mut grpc_ids = ctx
.db()
.list_grpc_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list gRPC requests in folder: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut grpc_ids);
let mut websocket_ids = ctx
.db()
.list_websocket_requests_for_folder_recursive(folder_id)
.map_err(|e| format!("Failed to list WebSocket requests in folder: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut websocket_ids);
Ok(ids)
}
fn collect_workspace_request_ids(
ctx: &CliContext,
workspace_id: &str,
) -> Result<Vec<String>, String> {
let mut ids = Vec::new();
let mut http_ids = ctx
.db()
.list_http_requests(workspace_id)
.map_err(|e| format!("Failed to list HTTP requests in workspace: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut http_ids);
let mut grpc_ids = ctx
.db()
.list_grpc_requests(workspace_id)
.map_err(|e| format!("Failed to list gRPC requests in workspace: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut grpc_ids);
let mut websocket_ids = ctx
.db()
.list_websocket_requests(workspace_id)
.map_err(|e| format!("Failed to list WebSocket requests in workspace: {e}"))?
.into_iter()
.map(|r| r.id)
.collect::<Vec<_>>();
ids.append(&mut websocket_ids);
Ok(ids)
}
async fn send_many(
ctx: &CliContext,
request_ids: Vec<String>,
mode: ExecutionMode,
fail_fast: bool,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let mut success_count = 0usize;
let mut failures: Vec<(String, String)> = Vec::new();
match mode {
ExecutionMode::Sequential => {
for request_id in request_ids {
match request::send_request_by_id(
ctx,
&request_id,
environment,
cookie_jar_id,
verbose,
)
.await
{
Ok(()) => success_count += 1,
Err(error) => {
failures.push((request_id, error));
if fail_fast {
break;
}
}
}
}
}
ExecutionMode::Parallel => {
let tasks = request_ids
.iter()
.map(|request_id| async move {
(
request_id.clone(),
request::send_request_by_id(
ctx,
request_id,
environment,
cookie_jar_id,
verbose,
)
.await,
)
})
.collect::<Vec<_>>();
for (request_id, result) in join_all(tasks).await {
match result {
Ok(()) => success_count += 1,
Err(error) => failures.push((request_id, error)),
}
}
}
}
let failure_count = failures.len();
println!("Send summary: {success_count} succeeded, {failure_count} failed");
if failure_count == 0 {
return Ok(());
}
for (request_id, error) in failures {
eprintln!(" {}: {}", request_id, error);
}
Err("One or more requests failed".to_string())
}

View File

@@ -1,138 +0,0 @@
use crate::cli::{WorkspaceArgs, WorkspaceCommands};
use crate::context::CliContext;
use crate::utils::confirm::confirm_delete;
use crate::utils::json::{
apply_merge_patch, parse_optional_json, parse_required_json, require_id, validate_create_id,
};
use crate::utils::schema::append_agent_hints;
use schemars::schema_for;
use yaak_models::models::Workspace;
use yaak_models::util::UpdateSource;
type CommandResult<T = ()> = std::result::Result<T, String>;
pub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {
let result = match args.command {
WorkspaceCommands::List => list(ctx),
WorkspaceCommands::Schema { pretty } => schema(pretty),
WorkspaceCommands::Show { workspace_id } => show(ctx, &workspace_id),
WorkspaceCommands::Create { name, json, json_input } => create(ctx, name, json, json_input),
WorkspaceCommands::Update { json, json_input } => update(ctx, json, json_input),
WorkspaceCommands::Delete { workspace_id, yes } => delete(ctx, &workspace_id, yes),
};
match result {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
1
}
}
}
fn schema(pretty: bool) -> CommandResult {
let mut schema = serde_json::to_value(schema_for!(Workspace))
.map_err(|e| format!("Failed to serialize workspace schema: {e}"))?;
append_agent_hints(&mut schema);
let output =
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
.map_err(|e| format!("Failed to format workspace schema JSON: {e}"))?;
println!("{output}");
Ok(())
}
fn list(ctx: &CliContext) -> CommandResult {
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
if workspaces.is_empty() {
println!("No workspaces found");
} else {
for workspace in workspaces {
println!("{} - {}", workspace.id, workspace.name);
}
}
Ok(())
}
fn show(ctx: &CliContext, workspace_id: &str) -> CommandResult {
let workspace = ctx
.db()
.get_workspace(workspace_id)
.map_err(|e| format!("Failed to get workspace: {e}"))?;
let output = serde_json::to_string_pretty(&workspace)
.map_err(|e| format!("Failed to serialize workspace: {e}"))?;
println!("{output}");
Ok(())
}
fn create(
ctx: &CliContext,
name: Option<String>,
json: Option<String>,
json_input: Option<String>,
) -> CommandResult {
let payload = parse_optional_json(json, json_input, "workspace create")?;
if let Some(payload) = payload {
if name.is_some() {
return Err("workspace create cannot combine --name with JSON payload".to_string());
}
validate_create_id(&payload, "workspace")?;
let workspace: Workspace = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse workspace create JSON: {e}"))?;
let created = ctx
.db()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
return Ok(());
}
let name = name.ok_or_else(|| {
"workspace create requires --name unless JSON payload is provided".to_string()
})?;
let workspace = Workspace { name, ..Default::default() };
let created = ctx
.db()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.map_err(|e| format!("Failed to create workspace: {e}"))?;
println!("Created workspace: {}", created.id);
Ok(())
}
fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) -> CommandResult {
let patch = parse_required_json(json, json_input, "workspace update")?;
let id = require_id(&patch, "workspace update")?;
let existing = ctx
.db()
.get_workspace(&id)
.map_err(|e| format!("Failed to get workspace for update: {e}"))?;
let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?;
let saved = ctx
.db()
.upsert_workspace(&updated, &UpdateSource::Sync)
.map_err(|e| format!("Failed to update workspace: {e}"))?;
println!("Updated workspace: {}", saved.id);
Ok(())
}
fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
if !yes && !confirm_delete("workspace", workspace_id) {
println!("Aborted");
return Ok(());
}
let deleted = ctx
.db()
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync)
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
println!("Deleted workspace: {}", deleted.id);
Ok(())
}

View File

@@ -1,149 +0,0 @@
use crate::plugin_events::CliPluginEventBridge;
use include_dir::{Dir, include_dir};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use yaak_crypto::manager::EncryptionManager;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::query_manager::QueryManager;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
));
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app-client/vendored/plugins");
#[derive(Clone, Debug, Default)]
pub struct CliExecutionContext {
pub request_id: Option<String>,
pub workspace_id: Option<String>,
pub environment_id: Option<String>,
pub cookie_jar_id: Option<String>,
}
pub struct CliContext {
data_dir: PathBuf,
query_manager: QueryManager,
blob_manager: BlobManager,
pub encryption_manager: Arc<EncryptionManager>,
plugin_manager: Option<Arc<PluginManager>>,
plugin_event_bridge: Mutex<Option<CliPluginEventBridge>>,
}
impl CliContext {
pub fn new(data_dir: PathBuf, app_id: &str) -> Self {
let db_path = data_dir.join("db.sqlite");
let blob_path = data_dir.join("blobs.sqlite");
let (query_manager, blob_manager, _rx) =
match yaak_models::init_standalone(&db_path, &blob_path) {
Ok(v) => v,
Err(err) => {
eprintln!("Error: Failed to initialize database: {err}");
std::process::exit(1);
}
};
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
data_dir,
query_manager,
blob_manager,
encryption_manager,
plugin_manager: None,
plugin_event_bridge: Mutex::new(None),
}
}
pub async fn init_plugins(&mut self, execution_context: CliExecutionContext) {
let vendored_plugin_dir = self.data_dir.join("vendored-plugins");
let installed_plugin_dir = self.data_dir.join("installed-plugins");
let node_bin_path = PathBuf::from("node");
prepare_embedded_vendored_plugins(&vendored_plugin_dir)
.expect("Failed to prepare bundled plugins");
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
prepare_embedded_plugin_runtime(&self.data_dir)
.expect("Failed to prepare embedded plugin runtime")
});
match PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&self.query_manager,
&PluginContext::new_empty(),
false,
)
.await
{
Ok(plugin_manager) => {
let plugin_manager = Arc::new(plugin_manager);
let plugin_event_bridge = CliPluginEventBridge::start(
plugin_manager.clone(),
self.query_manager.clone(),
self.blob_manager.clone(),
self.encryption_manager.clone(),
self.data_dir.clone(),
execution_context,
)
.await;
self.plugin_manager = Some(plugin_manager);
*self.plugin_event_bridge.lock().await = Some(plugin_event_bridge);
}
Err(err) => {
eprintln!("Warning: Failed to initialize plugins: {err}");
}
}
}
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
pub fn db(&self) -> ClientDb<'_> {
self.query_manager.connect()
}
pub fn query_manager(&self) -> &QueryManager {
&self.query_manager
}
pub fn blob_manager(&self) -> &BlobManager {
&self.blob_manager
}
pub fn plugin_manager(&self) -> Arc<PluginManager> {
self.plugin_manager.clone().expect("Plugin manager was not initialized for this command")
}
pub async fn shutdown(&self) {
if let Some(plugin_manager) = &self.plugin_manager {
if let Some(plugin_event_bridge) = self.plugin_event_bridge.lock().await.take() {
plugin_event_bridge.shutdown(plugin_manager).await;
}
plugin_manager.terminate().await;
}
}
}
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
fs::create_dir_all(&runtime_dir)?;
let runtime_main = runtime_dir.join("index.cjs");
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
Ok(runtime_main)
}
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(vendored_plugin_dir)?;
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
Ok(())
}

View File

@@ -1,241 +1,290 @@
mod cli;
mod commands;
mod context;
mod plugin_events;
mod ui;
mod utils;
mod version;
mod version_check;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use yaak_http::sender::{HttpSender, ReqwestSender};
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
use clap::Parser;
use cli::{Cli, Commands, PluginCommands, RequestCommands};
use context::{CliContext, CliExecutionContext};
use yaak_models::queries::any_request::AnyRequest;
#[derive(Parser)]
#[command(name = "yaakcli")]
#[command(about = "Yaak CLI - API client from the command line")]
struct Cli {
/// Use a custom data directory
#[arg(long, global = true)]
data_dir: Option<PathBuf>,
/// Environment ID to use for variable substitution
#[arg(long, short, global = true)]
environment: Option<String>,
/// Enable verbose logging
#[arg(long, short, global = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// List all workspaces
Workspaces,
/// List requests in a workspace
Requests {
/// Workspace ID
workspace_id: String,
},
/// Send an HTTP request by ID
Send {
/// Request ID
request_id: String,
},
/// Send a GET request to a URL
Get {
/// URL to request
url: String,
},
/// Create a new HTTP request
Create {
/// Workspace ID
workspace_id: String,
/// Request name
#[arg(short, long)]
name: String,
/// HTTP method
#[arg(short, long, default_value = "GET")]
method: String,
/// URL
#[arg(short, long)]
url: String,
},
}
#[tokio::main]
async fn main() {
let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse();
let cli = Cli::parse();
if let Some(log_level) = log {
match log_level {
Some(level) => {
env_logger::Builder::new().filter_level(level.as_filter()).init();
}
None => {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.init();
}
}
// Initialize logging
if cli.verbose {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
}
// Use the same app_id for both data directory and keyring
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
let data_dir = data_dir.unwrap_or_else(|| {
let data_dir = cli.data_dir.unwrap_or_else(|| {
dirs::data_dir().expect("Could not determine data directory").join(app_id)
});
version_check::maybe_check_for_updates().await;
let db_path = data_dir.join("db.sqlite");
let blob_path = data_dir.join("blobs.sqlite");
let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Plugin(args) => match args.command {
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
PluginCommands::Install(install_args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
context.init_plugins(CliExecutionContext::default()).await;
let exit_code = commands::plugin::run_install(&context, install_args).await;
context.shutdown().await;
exit_code
}
},
Commands::Build(args) => commands::plugin::run_build(args).await,
Commands::Dev(args) => commands::plugin::run_dev(args).await,
Commands::Generate(args) => commands::plugin::run_generate(args).await,
Commands::Publish(args) => commands::plugin::run_publish(args).await,
Commands::Send(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
match resolve_send_execution_context(
&context,
&args.id,
environment.as_deref(),
cookie_jar.as_deref(),
) {
Ok(execution_context) => {
context.init_plugins(execution_context).await;
let exit_code = commands::send::run(
&context,
args,
environment.as_deref(),
cookie_jar.as_deref(),
verbose,
)
.await;
context.shutdown().await;
exit_code
}
Err(error) => {
eprintln!("Error: {error}");
1
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize database");
let db = query_manager.connect();
// Initialize plugin manager for template functions
let vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = data_dir.join("installed-plugins");
// Use system node for CLI (must be in PATH)
let node_bin_path = PathBuf::from("node");
// Find the plugin runtime - check YAAK_PLUGIN_RUNTIME env var, then fallback to development path
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
// Development fallback: look relative to crate root
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
});
// Create plugin manager (plugins may not be available in CLI context)
let plugin_manager = Arc::new(
PluginManager::new(
vendored_plugin_dir.clone(),
installed_plugin_dir.clone(),
node_bin_path.clone(),
plugin_runtime_main,
false,
)
.await,
);
// Initialize plugins from database
let plugins = db.list_plugins().unwrap_or_default();
if !plugins.is_empty() {
let errors =
plugin_manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
for (plugin_dir, error_msg) in errors {
eprintln!("Warning: Failed to initialize plugin '{}': {}", plugin_dir, error_msg);
}
}
match cli.command {
Commands::Workspaces => {
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
if workspaces.is_empty() {
println!("No workspaces found");
} else {
for ws in workspaces {
println!("{} - {}", ws.id, ws.name);
}
}
}
Commands::CookieJar(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::cookie_jar::run(&context, args);
context.shutdown().await;
exit_code
Commands::Requests { workspace_id } => {
let requests = db.list_http_requests(&workspace_id).expect("Failed to list requests");
if requests.is_empty() {
println!("No requests found in workspace {}", workspace_id);
} else {
for req in requests {
println!("{} - {} {}", req.id, req.method, req.name);
}
}
}
Commands::Workspace(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::workspace::run(&context, args);
context.shutdown().await;
exit_code
}
Commands::Request(args) => {
let mut context = CliContext::new(data_dir.clone(), app_id);
let execution_context_result = match &args.command {
RequestCommands::Send { request_id } => resolve_request_execution_context(
&context,
request_id,
environment.as_deref(),
cookie_jar.as_deref(),
),
_ => Ok(CliExecutionContext::default()),
Commands::Send { request_id } => {
use yaak_actions::{
ActionExecutor, ActionId, ActionParams, ActionResult, ActionTarget, CurrentContext,
};
match execution_context_result {
Ok(execution_context) => {
let with_plugins = matches!(
&args.command,
RequestCommands::Send { .. } | RequestCommands::Schema { .. }
);
if with_plugins {
context.init_plugins(execution_context).await;
use yaak_actions_builtin::{BuiltinActionDependencies, register_http_actions};
// Create dependencies
let deps = BuiltinActionDependencies::new_standalone(
&db_path,
&blob_path,
&app_id,
vendored_plugin_dir.clone(),
installed_plugin_dir.clone(),
node_bin_path.clone(),
)
.await
.expect("Failed to initialize dependencies");
// Create executor and register actions
let executor = ActionExecutor::new();
executor.register_builtin_groups().await.expect("Failed to register groups");
register_http_actions(&executor, &deps).await.expect("Failed to register HTTP actions");
// Prepare context
let context = CurrentContext {
target: Some(ActionTarget::HttpRequest { id: request_id.clone() }),
environment_id: cli.environment.clone(),
workspace_id: None,
has_window: false,
can_prompt: false,
};
// Prepare params
let params = ActionParams {
data: serde_json::json!({
"render": true,
"follow_redirects": false,
"timeout_ms": 30000,
}),
};
// Invoke action
let action_id = ActionId::builtin("http", "send-request");
let result = executor.invoke(&action_id, context, params).await.expect("Action failed");
// Handle result
match result {
ActionResult::Success { data, message } => {
if let Some(msg) = message {
println!("{}", msg);
}
if let Some(data) = data {
println!("{}", serde_json::to_string_pretty(&data).unwrap());
}
let exit_code = commands::request::run(
&context,
args,
environment.as_deref(),
cookie_jar.as_deref(),
verbose,
)
.await;
context.shutdown().await;
exit_code
}
Err(error) => {
eprintln!("Error: {error}");
1
ActionResult::RequiresInput { .. } => {
eprintln!("Action requires input (not supported in CLI)");
}
ActionResult::Cancelled => {
eprintln!("Action cancelled");
}
}
}
Commands::Folder(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::folder::run(&context, args);
context.shutdown().await;
exit_code
Commands::Get { url } => {
if cli.verbose {
println!("> GET {}", url);
}
// Build a simple GET request
let sendable = SendableHttpRequest {
url: url.clone(),
method: "GET".to_string(),
headers: vec![],
body: None,
options: SendableHttpRequestOptions::default(),
};
// Create event channel for progress
let (event_tx, mut event_rx) = mpsc::channel(100);
// Spawn task to print events if verbose
let verbose = cli.verbose;
let verbose_handle = if verbose {
Some(tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
println!("{}", event);
}
}))
} else {
tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
None
};
// Send the request
let sender = ReqwestSender::new().expect("Failed to create HTTP client");
let response = sender.send(sendable, event_tx).await.expect("Failed to send request");
if let Some(handle) = verbose_handle {
let _ = handle.await;
}
// Print response
if verbose {
println!();
}
println!(
"HTTP {} {}",
response.status,
response.status_reason.as_deref().unwrap_or("")
);
if verbose {
for (name, value) in &response.headers {
println!("{}: {}", name, value);
}
println!();
}
// Print body
let (body, _stats) = response.text().await.expect("Failed to read response body");
println!("{}", body);
}
Commands::Environment(args) => {
let context = CliContext::new(data_dir.clone(), app_id);
let exit_code = commands::environment::run(&context, args);
context.shutdown().await;
exit_code
Commands::Create { workspace_id, name, method, url } => {
let request = HttpRequest {
workspace_id,
name,
method: method.to_uppercase(),
url,
..Default::default()
};
let created = db
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to create request");
println!("Created request: {}", created.id);
}
};
if exit_code != 0 {
std::process::exit(exit_code);
}
}
fn resolve_send_execution_context(
context: &CliContext,
id: &str,
environment: Option<&str>,
explicit_cookie_jar_id: Option<&str>,
) -> Result<CliExecutionContext, String> {
if let Ok(request) = context.db().get_any_request(id) {
let (request_id, workspace_id) = match request {
AnyRequest::HttpRequest(r) => (Some(r.id), r.workspace_id),
AnyRequest::GrpcRequest(r) => (Some(r.id), r.workspace_id),
AnyRequest::WebsocketRequest(r) => (Some(r.id), r.workspace_id),
};
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
request_id,
workspace_id: Some(workspace_id),
environment_id: environment.map(str::to_string),
cookie_jar_id,
});
}
if let Ok(folder) = context.db().get_folder(id) {
let cookie_jar_id =
resolve_cookie_jar_id(context, &folder.workspace_id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
request_id: None,
workspace_id: Some(folder.workspace_id),
environment_id: environment.map(str::to_string),
cookie_jar_id,
});
}
if let Ok(workspace) = context.db().get_workspace(id) {
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace.id, explicit_cookie_jar_id)?;
return Ok(CliExecutionContext {
request_id: None,
workspace_id: Some(workspace.id),
environment_id: environment.map(str::to_string),
cookie_jar_id,
});
}
Err(format!("Could not resolve ID '{}' as request, folder, or workspace", id))
}
fn resolve_request_execution_context(
context: &CliContext,
request_id: &str,
environment: Option<&str>,
explicit_cookie_jar_id: Option<&str>,
) -> Result<CliExecutionContext, String> {
let request = context
.db()
.get_any_request(request_id)
.map_err(|e| format!("Failed to get request: {e}"))?;
let workspace_id = match request {
AnyRequest::HttpRequest(r) => r.workspace_id,
AnyRequest::GrpcRequest(r) => r.workspace_id,
AnyRequest::WebsocketRequest(r) => r.workspace_id,
};
let cookie_jar_id = resolve_cookie_jar_id(context, &workspace_id, explicit_cookie_jar_id)?;
Ok(CliExecutionContext {
request_id: Some(request_id.to_string()),
workspace_id: Some(workspace_id),
environment_id: environment.map(str::to_string),
cookie_jar_id,
})
}
fn resolve_cookie_jar_id(
context: &CliContext,
workspace_id: &str,
explicit_cookie_jar_id: Option<&str>,
) -> Result<Option<String>, String> {
if let Some(cookie_jar_id) = explicit_cookie_jar_id {
return Ok(Some(cookie_jar_id.to_string()));
}
let default_cookie_jar = context
.db()
.list_cookie_jars(workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?
.into_iter()
.min_by_key(|jar| jar.created_at)
.map(|jar| jar.id);
Ok(default_cookie_jar)
// Terminate plugin manager gracefully
plugin_manager.terminate().await;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,16 +0,0 @@
use std::io::{self, IsTerminal, Write};
pub fn confirm_delete(resource_name: &str, resource_id: &str) -> bool {
if !io::stdin().is_terminal() {
eprintln!("Refusing to delete in non-interactive mode without --yes");
std::process::exit(1);
}
print!("Delete {resource_name} {resource_id}? [y/N]: ");
io::stdout().flush().expect("Failed to flush stdout");
let mut input = String::new();
io::stdin().read_line(&mut input).expect("Failed to read confirmation");
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}

View File

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

View File

@@ -1,131 +0,0 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
type JsonResult<T> = std::result::Result<T, String>;
pub fn is_json_shorthand(input: &str) -> bool {
input.trim_start().starts_with('{')
}
pub fn parse_json_object(raw: &str, context: &str) -> JsonResult<Value> {
let value: Value = serde_json::from_str(raw)
.map_err(|error| format!("Invalid JSON for {context}: {error}"))?;
if !value.is_object() {
return Err(format!("JSON payload for {context} must be an object"));
}
Ok(value)
}
pub fn parse_optional_json(
json_flag: Option<String>,
json_shorthand: Option<String>,
context: &str,
) -> JsonResult<Option<Value>> {
match (json_flag, json_shorthand) {
(Some(_), Some(_)) => {
Err(format!("Cannot provide both --json and positional JSON for {context}"))
}
(Some(raw), None) => parse_json_object(&raw, context).map(Some),
(None, Some(raw)) => parse_json_object(&raw, context).map(Some),
(None, None) => Ok(None),
}
}
pub fn parse_required_json(
json_flag: Option<String>,
json_shorthand: Option<String>,
context: &str,
) -> JsonResult<Value> {
parse_optional_json(json_flag, json_shorthand, context)?
.ok_or_else(|| format!("Missing JSON payload for {context}. Use --json or positional JSON"))
}
pub fn require_id(payload: &Value, context: &str) -> JsonResult<String> {
payload
.get("id")
.and_then(|value| value.as_str())
.filter(|value| !value.is_empty())
.map(|value| value.to_string())
.ok_or_else(|| format!("{context} requires a non-empty \"id\" field"))
}
pub fn validate_create_id(payload: &Value, context: &str) -> JsonResult<()> {
let Some(id_value) = payload.get("id") else {
return Ok(());
};
match id_value {
Value::String(id) if id.is_empty() => Ok(()),
_ => Err(format!("{context} create JSON must omit \"id\" or set it to an empty string")),
}
}
pub fn merge_workspace_id_arg(
workspace_id_from_arg: Option<&str>,
payload_workspace_id: &mut String,
context: &str,
) -> JsonResult<()> {
if let Some(workspace_id_arg) = workspace_id_from_arg {
if payload_workspace_id.is_empty() {
*payload_workspace_id = workspace_id_arg.to_string();
} else if payload_workspace_id != workspace_id_arg {
return Err(format!(
"{context} got conflicting workspace_id values between positional arg and JSON payload"
));
}
}
if payload_workspace_id.is_empty() {
return Err(format!(
"{context} requires non-empty \"workspaceId\" in JSON payload or positional workspace_id"
));
}
Ok(())
}
pub fn apply_merge_patch<T>(existing: &T, patch: &Value, id: &str, context: &str) -> JsonResult<T>
where
T: Serialize + DeserializeOwned,
{
let mut base = serde_json::to_value(existing)
.map_err(|error| format!("Failed to serialize existing model for {context}: {error}"))?;
merge_patch(&mut base, patch);
let Some(base_object) = base.as_object_mut() else {
return Err(format!("Merged payload for {context} must be an object"));
};
base_object.insert("id".to_string(), Value::String(id.to_string()));
serde_json::from_value(base)
.map_err(|error| format!("Failed to deserialize merged payload for {context}: {error}"))
}
fn merge_patch(target: &mut Value, patch: &Value) {
match patch {
Value::Object(patch_map) => {
if !target.is_object() {
*target = Value::Object(Map::new());
}
let target_map =
target.as_object_mut().expect("merge_patch target expected to be object");
for (key, patch_value) in patch_map {
if patch_value.is_null() {
target_map.remove(key);
continue;
}
let target_entry = target_map.entry(key.clone()).or_insert(Value::Null);
merge_patch(target_entry, patch_value);
}
}
_ => {
*target = patch.clone();
}
}
}

View File

@@ -1,5 +0,0 @@
pub mod confirm;
pub mod http;
pub mod json;
pub mod schema;
pub mod workspace;

View File

@@ -1,15 +0,0 @@
use serde_json::{Value, json};
pub fn append_agent_hints(schema: &mut Value) {
let Some(schema_obj) = schema.as_object_mut() else {
return;
};
schema_obj.insert(
"x-yaak-agent-hints".to_string(),
json!({
"templateVariableSyntax": "${[ my_var ]}",
"templateFunctionSyntax": "${[ namespace.my_func(a='aaa',b='bbb') ]}",
}),
);
}

View File

@@ -1,19 +0,0 @@
use crate::context::CliContext;
pub fn resolve_workspace_id(
ctx: &CliContext,
workspace_id: Option<&str>,
command_name: &str,
) -> Result<String, String> {
if let Some(workspace_id) = workspace_id {
return Ok(workspace_id.to_string());
}
let workspaces =
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
match workspaces.as_slice() {
[] => Err(format!("No workspaces found. {command_name} requires a workspace ID.")),
[workspace] => Ok(workspace.id.clone()),
_ => Err(format!("Multiple workspaces found. {command_name} requires a workspace ID.")),
}
}

View File

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

View File

@@ -1,226 +0,0 @@
use crate::ui;
use crate::version;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use yaak_api::{ApiClientKind, yaak_api_client};
const CACHE_FILE_NAME: &str = "cli-version-check.json";
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
const REQUEST_TIMEOUT: Duration = Duration::from_millis(800);
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
struct VersionCheckResponse {
outdated: bool,
latest_version: Option<String>,
upgrade_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
struct CacheRecord {
checked_at_epoch_secs: u64,
response: VersionCheckResponse,
last_warned_at_epoch_secs: Option<u64>,
last_warned_version: Option<String>,
}
impl Default for CacheRecord {
fn default() -> Self {
Self {
checked_at_epoch_secs: 0,
response: VersionCheckResponse::default(),
last_warned_at_epoch_secs: None,
last_warned_version: None,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VersionCheckRequest<'a> {
current_version: &'a str,
channel: String,
install_source: String,
platform: &'a str,
arch: &'a str,
}
pub async fn maybe_check_for_updates() {
if should_skip_check() {
return;
}
let now = unix_epoch_secs();
let cache_path = cache_path();
let cached = read_cache(&cache_path);
if let Some(cache) = cached.as_ref().filter(|c| !is_expired(c.checked_at_epoch_secs, now)) {
let mut record = cache.clone();
maybe_warn_outdated(&mut record, now);
write_cache(&cache_path, &record);
return;
}
let fresh = fetch_version_check().await;
match fresh {
Some(response) => {
let mut record = CacheRecord {
checked_at_epoch_secs: now,
response: response.clone(),
last_warned_at_epoch_secs: cached
.as_ref()
.and_then(|c| c.last_warned_at_epoch_secs),
last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),
};
maybe_warn_outdated(&mut record, now);
write_cache(&cache_path, &record);
}
None => {
let fallback = cached.as_ref().map(|cache| cache.response.clone()).unwrap_or_default();
let mut record = CacheRecord {
checked_at_epoch_secs: now,
response: fallback,
last_warned_at_epoch_secs: cached
.as_ref()
.and_then(|c| c.last_warned_at_epoch_secs),
last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),
};
maybe_warn_outdated(&mut record, now);
write_cache(&cache_path, &record);
}
}
}
fn should_skip_check() -> bool {
if std::env::var("YAAK_CLI_NO_UPDATE_CHECK")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
{
return true;
}
if std::env::var("CI").is_ok() {
return true;
}
!std::io::stdout().is_terminal()
}
async fn fetch_version_check() -> Option<VersionCheckResponse> {
let api_url = format!("{}/cli/check", update_base_url());
let current_version = version::cli_version();
let payload = VersionCheckRequest {
current_version,
channel: release_channel(current_version),
install_source: install_source(),
platform: std::env::consts::OS,
arch: std::env::consts::ARCH,
};
let client = yaak_api_client(ApiClientKind::Cli, current_version).ok()?;
let request = client.post(api_url).json(&payload);
let response = tokio::time::timeout(REQUEST_TIMEOUT, request.send()).await.ok()?.ok()?;
if !response.status().is_success() {
return None;
}
tokio::time::timeout(REQUEST_TIMEOUT, response.json::<VersionCheckResponse>()).await.ok()?.ok()
}
fn release_channel(version: &str) -> String {
version
.split_once('-')
.and_then(|(_, suffix)| suffix.split('.').next())
.unwrap_or("stable")
.to_string()
}
fn install_source() -> String {
std::env::var("YAAK_CLI_INSTALL_SOURCE")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "source".to_string())
}
fn update_base_url() -> &'static str {
match std::env::var("ENVIRONMENT").ok().as_deref() {
Some("development") => "http://localhost:9444",
_ => "https://update.yaak.app",
}
}
fn maybe_warn_outdated(record: &mut CacheRecord, now: u64) {
if !record.response.outdated {
return;
}
let latest =
record.response.latest_version.clone().unwrap_or_else(|| "a newer release".to_string());
let warn_suppressed = record.last_warned_version.as_deref() == Some(latest.as_str())
&& record.last_warned_at_epoch_secs.is_some_and(|t| !is_expired(t, now));
if warn_suppressed {
return;
}
let hint = record.response.upgrade_hint.clone().unwrap_or_else(default_upgrade_hint);
ui::warning_stderr(&format!("A newer Yaak CLI version is available ({latest}). {hint}"));
record.last_warned_version = Some(latest);
record.last_warned_at_epoch_secs = Some(now);
}
fn default_upgrade_hint() -> String {
if install_source() == "npm" {
let channel = release_channel(version::cli_version());
if channel == "stable" {
return "Run `npm install -g @yaakapp/cli@latest` to update.".to_string();
}
return format!("Run `npm install -g @yaakapp/cli@{channel}` to update.");
}
"Update your Yaak CLI installation to the latest release.".to_string()
}
fn cache_path() -> PathBuf {
std::env::temp_dir().join("yaak-cli").join(format!("{}-{CACHE_FILE_NAME}", environment_name()))
}
fn environment_name() -> &'static str {
match std::env::var("ENVIRONMENT").ok().as_deref() {
Some("staging") => "staging",
Some("development") => "development",
_ => "production",
}
}
fn read_cache(path: &Path) -> Option<CacheRecord> {
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str::<CacheRecord>(&contents).ok()
}
fn write_cache(path: &Path, record: &CacheRecord) {
let Some(parent) = path.parent() else {
return;
};
if fs::create_dir_all(parent).is_err() {
return;
}
let Ok(json) = serde_json::to_string(record) else {
return;
};
let _ = fs::write(path, json);
}
fn is_expired(checked_at_epoch_secs: u64, now: u64) -> bool {
now.saturating_sub(checked_at_epoch_secs) >= CHECK_INTERVAL_SECS
}
fn unix_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs()
}

View File

@@ -1,64 +0,0 @@
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
pub struct TestHttpServer {
pub url: String,
addr: SocketAddr,
shutdown: Arc<AtomicBool>,
handle: Option<thread::JoinHandle<()>>,
}
impl TestHttpServer {
pub fn spawn_ok(body: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind test HTTP server");
let addr = listener.local_addr().expect("Failed to get local addr");
let url = format!("http://{addr}/test");
listener.set_nonblocking(true).expect("Failed to set test server listener nonblocking");
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_signal = Arc::clone(&shutdown);
let body_bytes = body.as_bytes().to_vec();
let handle = thread::spawn(move || {
while !shutdown_signal.load(Ordering::Relaxed) {
match listener.accept() {
Ok((mut stream, _)) => {
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
let mut request_buf = [0u8; 4096];
let _ = stream.read(&mut request_buf);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body_bytes.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(&body_bytes);
let _ = stream.flush();
break;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
}
Err(_) => break,
}
}
});
Self { url, addr, shutdown, handle: Some(handle) }
}
}
impl Drop for TestHttpServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
let _ = TcpStream::connect(self.addr);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}

View File

@@ -1,106 +0,0 @@
#![allow(dead_code)]
pub mod http_server;
use assert_cmd::Command;
use assert_cmd::cargo::cargo_bin_cmd;
use std::path::Path;
use yaak_models::models::{Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
pub fn cli_cmd(data_dir: &Path) -> Command {
let mut cmd = cargo_bin_cmd!("yaak");
cmd.arg("--data-dir").arg(data_dir);
cmd
}
pub fn parse_created_id(stdout: &[u8], label: &str) -> String {
String::from_utf8_lossy(stdout)
.trim()
.split_once(": ")
.map(|(_, id)| id.to_string())
.unwrap_or_else(|| panic!("Expected id in '{label}' output"))
}
pub fn query_manager(data_dir: &Path) -> QueryManager {
let db_path = data_dir.join("db.sqlite");
let blob_path = data_dir.join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("Failed to initialize DB");
query_manager
}
pub fn seed_workspace(data_dir: &Path, workspace_id: &str) {
let workspace = Workspace {
id: workspace_id.to_string(),
name: "Seed Workspace".to_string(),
description: "Seeded for integration tests".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_workspace(&workspace, &UpdateSource::Sync)
.expect("Failed to seed workspace");
}
pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
let request = HttpRequest {
id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seeded Request".to_string(),
method: "GET".to_string(),
url: "https://example.com".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed request");
}
pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {
let folder = Folder {
id: folder_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seed Folder".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_folder(&folder, &UpdateSource::Sync)
.expect("Failed to seed folder");
}
pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
let request = GrpcRequest {
id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seeded gRPC Request".to_string(),
url: "https://example.com".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_grpc_request(&request, &UpdateSource::Sync)
.expect("Failed to seed gRPC request");
}
pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
let request = WebsocketRequest {
id: request_id.to_string(),
workspace_id: workspace_id.to_string(),
name: "Seeded WebSocket Request".to_string(),
url: "wss://example.com/socket".to_string(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_websocket_request(&request, &UpdateSource::Sync)
.expect("Failed to seed WebSocket request");
}

View File

@@ -1,146 +0,0 @@
mod common;
use common::{cli_cmd, parse_created_id, query_manager, seed_workspace};
use predicates::str::contains;
use tempfile::TempDir;
#[test]
fn create_list_show_delete_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
cli_cmd(data_dir)
.args(["environment", "list", "wk_test"])
.assert()
.success()
.stdout(contains("Global Variables"));
let create_assert = cli_cmd(data_dir)
.args(["environment", "create", "wk_test", "--name", "Production"])
.assert()
.success();
let environment_id = parse_created_id(&create_assert.get_output().stdout, "environment create");
cli_cmd(data_dir)
.args(["environment", "list", "wk_test"])
.assert()
.success()
.stdout(contains(&environment_id))
.stdout(contains("Production"));
cli_cmd(data_dir)
.args(["environment", "show", &environment_id])
.assert()
.success()
.stdout(contains(format!("\"id\": \"{environment_id}\"")))
.stdout(contains("\"parentModel\": \"environment\""));
cli_cmd(data_dir)
.args(["environment", "delete", &environment_id, "--yes"])
.assert()
.success()
.stdout(contains(format!("Deleted environment: {environment_id}")));
assert!(query_manager(data_dir).connect().get_environment(&environment_id).is_err());
}
#[test]
fn json_create_and_update_merge_patch_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"environment",
"create",
r#"{"workspaceId":"wk_test","name":"Json Environment"}"#,
])
.assert()
.success();
let environment_id = parse_created_id(&create_assert.get_output().stdout, "environment create");
cli_cmd(data_dir)
.args([
"environment",
"update",
&format!(r##"{{"id":"{}","color":"#00ff00"}}"##, environment_id),
])
.assert()
.success()
.stdout(contains(format!("Updated environment: {environment_id}")));
cli_cmd(data_dir)
.args(["environment", "show", &environment_id])
.assert()
.success()
.stdout(contains("\"name\": \"Json Environment\""))
.stdout(contains("\"color\": \"#00ff00\""));
}
#[test]
fn create_merges_positional_workspace_id_into_json_payload() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"environment",
"create",
"wk_test",
"--json",
r#"{"name":"Merged Environment"}"#,
])
.assert()
.success();
let environment_id = parse_created_id(&create_assert.get_output().stdout, "environment create");
cli_cmd(data_dir)
.args(["environment", "show", &environment_id])
.assert()
.success()
.stdout(contains("\"workspaceId\": \"wk_test\""))
.stdout(contains("\"name\": \"Merged Environment\""));
}
#[test]
fn create_rejects_conflicting_workspace_ids_between_arg_and_json() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_workspace(data_dir, "wk_other");
cli_cmd(data_dir)
.args([
"environment",
"create",
"wk_test",
"--json",
r#"{"workspaceId":"wk_other","name":"Mismatch"}"#,
])
.assert()
.failure()
.stderr(contains(
"environment create got conflicting workspace_id values between positional arg and JSON payload",
));
}
#[test]
fn environment_schema_outputs_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["environment", "schema"])
.assert()
.success()
.stdout(contains("\"type\":\"object\""))
.stdout(contains("\"x-yaak-agent-hints\""))
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
.stdout(contains(
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
))
.stdout(contains("\"workspaceId\""));
}

View File

@@ -1,122 +0,0 @@
mod common;
use common::{cli_cmd, parse_created_id, query_manager, seed_workspace};
use predicates::str::contains;
use tempfile::TempDir;
#[test]
fn create_list_show_delete_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args(["folder", "create", "wk_test", "--name", "Auth"])
.assert()
.success();
let folder_id = parse_created_id(&create_assert.get_output().stdout, "folder create");
cli_cmd(data_dir)
.args(["folder", "list", "wk_test"])
.assert()
.success()
.stdout(contains(&folder_id))
.stdout(contains("Auth"));
cli_cmd(data_dir)
.args(["folder", "show", &folder_id])
.assert()
.success()
.stdout(contains(format!("\"id\": \"{folder_id}\"")))
.stdout(contains("\"workspaceId\": \"wk_test\""));
cli_cmd(data_dir)
.args(["folder", "delete", &folder_id, "--yes"])
.assert()
.success()
.stdout(contains(format!("Deleted folder: {folder_id}")));
assert!(query_manager(data_dir).connect().get_folder(&folder_id).is_err());
}
#[test]
fn json_create_and_update_merge_patch_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"folder",
"create",
r#"{"workspaceId":"wk_test","name":"Json Folder"}"#,
])
.assert()
.success();
let folder_id = parse_created_id(&create_assert.get_output().stdout, "folder create");
cli_cmd(data_dir)
.args([
"folder",
"update",
&format!(r#"{{"id":"{}","description":"Folder Description"}}"#, folder_id),
])
.assert()
.success()
.stdout(contains(format!("Updated folder: {folder_id}")));
cli_cmd(data_dir)
.args(["folder", "show", &folder_id])
.assert()
.success()
.stdout(contains("\"name\": \"Json Folder\""))
.stdout(contains("\"description\": \"Folder Description\""));
}
#[test]
fn create_merges_positional_workspace_id_into_json_payload() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"folder",
"create",
"wk_test",
"--json",
r#"{"name":"Merged Folder"}"#,
])
.assert()
.success();
let folder_id = parse_created_id(&create_assert.get_output().stdout, "folder create");
cli_cmd(data_dir)
.args(["folder", "show", &folder_id])
.assert()
.success()
.stdout(contains("\"workspaceId\": \"wk_test\""))
.stdout(contains("\"name\": \"Merged Folder\""));
}
#[test]
fn create_rejects_conflicting_workspace_ids_between_arg_and_json() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_workspace(data_dir, "wk_other");
cli_cmd(data_dir)
.args([
"folder",
"create",
"wk_test",
"--json",
r#"{"workspaceId":"wk_other","name":"Mismatch"}"#,
])
.assert()
.failure()
.stderr(contains(
"folder create got conflicting workspace_id values between positional arg and JSON payload",
));
}

View File

@@ -1,291 +0,0 @@
mod common;
use common::http_server::TestHttpServer;
use common::{
cli_cmd, parse_created_id, query_manager, seed_grpc_request, seed_request,
seed_websocket_request, seed_workspace,
};
use predicates::str::contains;
use tempfile::TempDir;
use yaak_models::models::HttpResponseState;
#[test]
fn show_and_delete_yes_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"request",
"create",
"wk_test",
"--name",
"Smoke Test",
"--url",
"https://example.com",
])
.assert()
.success();
let request_id = parse_created_id(&create_assert.get_output().stdout, "request create");
cli_cmd(data_dir)
.args(["request", "show", &request_id])
.assert()
.success()
.stdout(contains(format!("\"id\": \"{request_id}\"")))
.stdout(contains("\"workspaceId\": \"wk_test\""));
cli_cmd(data_dir)
.args(["request", "delete", &request_id, "--yes"])
.assert()
.success()
.stdout(contains(format!("Deleted request: {request_id}")));
assert!(query_manager(data_dir).connect().get_http_request(&request_id).is_err());
}
#[test]
fn delete_without_yes_fails_in_non_interactive_mode() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_request(data_dir, "wk_test", "rq_seed_delete_noninteractive");
cli_cmd(data_dir)
.args(["request", "delete", "rq_seed_delete_noninteractive"])
.assert()
.failure()
.code(1)
.stderr(contains("Refusing to delete in non-interactive mode without --yes"));
assert!(
query_manager(data_dir).connect().get_http_request("rq_seed_delete_noninteractive").is_ok()
);
}
#[test]
fn json_create_and_update_merge_patch_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"request",
"create",
r#"{"workspaceId":"wk_test","name":"Json Request","url":"https://example.com"}"#,
])
.assert()
.success();
let request_id = parse_created_id(&create_assert.get_output().stdout, "request create");
cli_cmd(data_dir)
.args([
"request",
"update",
&format!(r#"{{"id":"{}","name":"Renamed Request"}}"#, request_id),
])
.assert()
.success()
.stdout(contains(format!("Updated request: {request_id}")));
cli_cmd(data_dir)
.args(["request", "show", &request_id])
.assert()
.success()
.stdout(contains("\"name\": \"Renamed Request\""))
.stdout(contains("\"url\": \"https://example.com\""));
}
#[test]
fn update_requires_id_in_json_payload() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["request", "update", r#"{"name":"No ID"}"#])
.assert()
.failure()
.stderr(contains("request update requires a non-empty \"id\" field"));
}
#[test]
fn create_allows_workspace_only_with_empty_defaults() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir).args(["request", "create", "wk_test"]).assert().success();
let request_id = parse_created_id(&create_assert.get_output().stdout, "request create");
let request = query_manager(data_dir)
.connect()
.get_http_request(&request_id)
.expect("Failed to load created request");
assert_eq!(request.workspace_id, "wk_test");
assert_eq!(request.method, "GET");
assert_eq!(request.name, "");
assert_eq!(request.url, "");
}
#[test]
fn create_merges_positional_workspace_id_into_json_payload() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"request",
"create",
"wk_test",
"--json",
r#"{"name":"Merged Request","url":"https://example.com"}"#,
])
.assert()
.success();
let request_id = parse_created_id(&create_assert.get_output().stdout, "request create");
cli_cmd(data_dir)
.args(["request", "show", &request_id])
.assert()
.success()
.stdout(contains("\"workspaceId\": \"wk_test\""))
.stdout(contains("\"name\": \"Merged Request\""));
}
#[test]
fn create_rejects_conflicting_workspace_ids_between_arg_and_json() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_workspace(data_dir, "wk_other");
cli_cmd(data_dir)
.args([
"request",
"create",
"wk_test",
"--json",
r#"{"workspaceId":"wk_other","name":"Mismatch"}"#,
])
.assert()
.failure()
.stderr(contains(
"request create got conflicting workspace_id values between positional arg and JSON payload",
));
}
#[test]
fn request_send_persists_response_body_and_events() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let server = TestHttpServer::spawn_ok("hello from integration test");
let create_assert = cli_cmd(data_dir)
.args([
"request",
"create",
"wk_test",
"--name",
"Send Test",
"--url",
&server.url,
])
.assert()
.success();
let request_id = parse_created_id(&create_assert.get_output().stdout, "request create");
cli_cmd(data_dir)
.args(["request", "send", &request_id])
.assert()
.success()
.stdout(contains("hello from integration test"));
let qm = query_manager(data_dir);
let db = qm.connect();
let responses =
db.list_http_responses_for_request(&request_id, None).expect("Failed to load responses");
assert_eq!(responses.len(), 1, "expected exactly one persisted response");
let response = &responses[0];
assert_eq!(response.status, 200);
assert!(matches!(response.state, HttpResponseState::Closed));
assert!(response.error.is_none());
let body_path =
response.body_path.as_ref().expect("expected persisted response body path").to_string();
let body = std::fs::read_to_string(&body_path).expect("Failed to read response body file");
assert_eq!(body, "hello from integration test");
let events =
db.list_http_response_events(&response.id).expect("Failed to load response events");
assert!(!events.is_empty(), "expected at least one persisted response event");
}
#[test]
fn request_schema_http_outputs_json_schema() {
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"])
.assert()
.success()
.stdout(contains("\"type\":\"object\""))
.stdout(contains("\"x-yaak-agent-hints\""))
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
.stdout(contains(
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
))
.stdout(contains("\"authentication\":"))
.stdout(contains("/foo/:id/comments/:commentId"))
.stdout(contains("put concrete values in `urlParameters`"));
}
#[test]
fn request_schema_http_pretty_prints_with_flag() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["request", "schema", "http", "--pretty"])
.assert()
.success()
.stdout(contains("\"type\": \"object\""))
.stdout(contains("\"authentication\""));
}
#[test]
fn request_send_grpc_returns_explicit_nyi_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_grpc_request(data_dir, "wk_test", "gr_seed_nyi");
cli_cmd(data_dir)
.args(["request", "send", "gr_seed_nyi"])
.assert()
.failure()
.code(1)
.stderr(contains("gRPC request send is not implemented yet in yaak-cli"));
}
#[test]
fn request_send_websocket_returns_explicit_nyi_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_websocket_request(data_dir, "wk_test", "wr_seed_nyi");
cli_cmd(data_dir)
.args(["request", "send", "wr_seed_nyi"])
.assert()
.failure()
.code(1)
.stderr(contains("WebSocket request send is not implemented yet in yaak-cli"));
}

View File

@@ -1,79 +0,0 @@
mod common;
use common::http_server::TestHttpServer;
use common::{cli_cmd, query_manager, seed_folder, seed_workspace};
use predicates::str::contains;
use tempfile::TempDir;
use yaak_models::models::HttpRequest;
use yaak_models::util::UpdateSource;
#[test]
fn top_level_send_workspace_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let server = TestHttpServer::spawn_ok("workspace bulk send");
let request = HttpRequest {
id: "rq_workspace_send".to_string(),
workspace_id: "wk_test".to_string(),
name: "Workspace Send".to_string(),
method: "GET".to_string(),
url: server.url.clone(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed workspace request");
cli_cmd(data_dir)
.args(["send", "wk_test"])
.assert()
.success()
.stdout(contains("workspace bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}
#[test]
fn top_level_send_folder_sends_http_requests_and_prints_summary() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_folder(data_dir, "wk_test", "fl_test");
let server = TestHttpServer::spawn_ok("folder bulk send");
let request = HttpRequest {
id: "rq_folder_send".to_string(),
workspace_id: "wk_test".to_string(),
folder_id: Some("fl_test".to_string()),
name: "Folder Send".to_string(),
method: "GET".to_string(),
url: server.url.clone(),
..Default::default()
};
query_manager(data_dir)
.connect()
.upsert_http_request(&request, &UpdateSource::Sync)
.expect("Failed to seed folder request");
cli_cmd(data_dir)
.args(["send", "fl_test"])
.assert()
.success()
.stdout(contains("folder bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}
#[test]
fn top_level_send_unknown_id_fails_with_clear_error() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["send", "does_not_exist"])
.assert()
.failure()
.code(1)
.stderr(contains("Could not resolve ID 'does_not_exist' as request, folder, or workspace"));
}

View File

@@ -1,77 +0,0 @@
mod common;
use common::{cli_cmd, parse_created_id, query_manager};
use predicates::str::contains;
use tempfile::TempDir;
#[test]
fn create_show_delete_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let create_assert =
cli_cmd(data_dir).args(["workspace", "create", "--name", "WS One"]).assert().success();
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
cli_cmd(data_dir)
.args(["workspace", "show", &workspace_id])
.assert()
.success()
.stdout(contains(format!("\"id\": \"{workspace_id}\"")))
.stdout(contains("\"name\": \"WS One\""));
cli_cmd(data_dir)
.args(["workspace", "delete", &workspace_id, "--yes"])
.assert()
.success()
.stdout(contains(format!("Deleted workspace: {workspace_id}")));
assert!(query_manager(data_dir).connect().get_workspace(&workspace_id).is_err());
}
#[test]
fn json_create_and_update_merge_patch_round_trip() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let create_assert = cli_cmd(data_dir)
.args(["workspace", "create", r#"{"name":"Json Workspace"}"#])
.assert()
.success();
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
cli_cmd(data_dir)
.args([
"workspace",
"update",
&format!(r#"{{"id":"{}","description":"Updated via JSON"}}"#, workspace_id),
])
.assert()
.success()
.stdout(contains(format!("Updated workspace: {workspace_id}")));
cli_cmd(data_dir)
.args(["workspace", "show", &workspace_id])
.assert()
.success()
.stdout(contains("\"name\": \"Json Workspace\""))
.stdout(contains("\"description\": \"Updated via JSON\""));
}
#[test]
fn workspace_schema_outputs_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["workspace", "schema"])
.assert()
.success()
.stdout(contains("\"type\":\"object\""))
.stdout(contains("\"x-yaak-agent-hints\""))
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
.stdout(contains(
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
))
.stdout(contains("\"name\""));
}

View File

@@ -1,21 +0,0 @@
[package]
name = "yaak-proxy-lib"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
chrono = { workspace = true, features = ["serde"] }
log = { workspace = true }
include_dir = "0.7"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
rusqlite = { version = "0.32.1", features = ["bundled", "chrono"] }
sea-query = { version = "0.32.1", features = ["with-chrono", "attr"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
ts-rs = { workspace = true, features = ["chrono-impl"] }
yaak-database = { workspace = true }
yaak-proxy = { workspace = true }
yaak-rpc = { workspace = true }

View File

@@ -1,8 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ModelChangeEvent } from "./ModelChangeEvent";
export type HttpExchange = { id: string, createdAt: string, updatedAt: string, url: string, method: string, reqHeaders: Array<ProxyHeader>, reqBody: Array<number> | null, resStatus: number | null, resHeaders: Array<ProxyHeader>, resBody: Array<number> | null, error: string | null, };
export type ModelPayload = { model: HttpExchange, change: ModelChangeEvent, };
export type ProxyHeader = { name: string, value: string, };

View File

@@ -1,20 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { HttpExchange, ModelPayload } from "./gen_models";
export type ActionInvocation = { "scope": "global", action: GlobalAction, };
export type ActionMetadata = { label: string, defaultHotkey: string | null, };
export type GlobalAction = "proxy_start" | "proxy_stop";
export type ListActionsRequest = Record<string, never>;
export type ListActionsResponse = { actions: Array<[ActionInvocation, ActionMetadata]>, };
export type ListModelsRequest = Record<string, never>;
export type ListModelsResponse = { httpExchanges: Array<HttpExchange>, };
export type RpcEventSchema = { model_write: ModelPayload, };
export type RpcSchema = { execute_action: [ActionInvocation, boolean], list_actions: [ListActionsRequest, ListActionsResponse], list_models: [ListModelsRequest, ListModelsResponse], };

View File

@@ -1,2 +0,0 @@
export * from "./gen_rpc";
export * from "./gen_models";

View File

@@ -1,14 +0,0 @@
CREATE TABLE http_exchanges
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
url TEXT NOT NULL DEFAULT '',
method TEXT NOT NULL DEFAULT '',
req_headers TEXT NOT NULL DEFAULT '[]',
req_body BLOB,
res_status INTEGER,
res_headers TEXT NOT NULL DEFAULT '[]',
res_body BLOB,
error TEXT
);

View File

@@ -1,53 +0,0 @@
use serde::{Deserialize, Serialize};
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_rpc.ts")]
pub enum GlobalAction {
ProxyStart,
ProxyStop,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(tag = "scope", rename_all = "snake_case")]
#[ts(export, export_to = "gen_rpc.ts")]
pub enum ActionInvocation {
Global { action: GlobalAction },
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ActionMetadata {
pub label: String,
pub default_hotkey: Option<String>,
}
fn default_hotkey(mac: &str, other: &str) -> Option<String> {
if cfg!(target_os = "macos") {
Some(mac.into())
} else {
Some(other.into())
}
}
/// All global actions with their metadata, used by `list_actions` RPC.
pub fn all_global_actions() -> Vec<(ActionInvocation, ActionMetadata)> {
vec![
(
ActionInvocation::Global { action: GlobalAction::ProxyStart },
ActionMetadata {
label: "Start Proxy".into(),
default_hotkey: default_hotkey("Meta+Shift+P", "Ctrl+Shift+P"),
},
),
(
ActionInvocation::Global { action: GlobalAction::ProxyStop },
ActionMetadata {
label: "Stop Proxy".into(),
default_hotkey: default_hotkey("Meta+Shift+S", "Ctrl+Shift+S"),
},
),
]
}

View File

@@ -1,33 +0,0 @@
use include_dir::{Dir, include_dir};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use std::path::Path;
use yaak_database::{ConnectionOrTx, DbContext, run_migrations};
static MIGRATIONS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/migrations");
#[derive(Clone)]
pub struct ProxyQueryManager {
pool: Pool<SqliteConnectionManager>,
}
impl ProxyQueryManager {
pub fn new(db_path: &Path) -> Self {
let manager = SqliteConnectionManager::file(db_path);
let pool = Pool::builder()
.max_size(5)
.build(manager)
.expect("Failed to create proxy DB pool");
run_migrations(&pool, &MIGRATIONS).expect("Failed to run proxy DB migrations");
Self { pool }
}
pub fn with_conn<F, T>(&self, func: F) -> T
where
F: FnOnce(&DbContext) -> T,
{
let conn = self.pool.get().expect("Failed to get proxy DB connection");
let ctx = DbContext::new(ConnectionOrTx::Connection(conn));
func(&ctx)
}
}

View File

@@ -1,225 +0,0 @@
pub mod actions;
pub mod db;
pub mod models;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use log::warn;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_database::{ModelChangeEvent, UpdateSource};
use yaak_proxy::{CapturedRequest, ProxyEvent, ProxyHandle, RequestState};
use yaak_rpc::{RpcError, RpcEventEmitter, define_rpc};
use crate::actions::{ActionInvocation, ActionMetadata, GlobalAction};
use crate::db::ProxyQueryManager;
use crate::models::{HttpExchange, ModelPayload, ProxyHeader};
// -- Context --
pub struct ProxyCtx {
handle: Mutex<Option<ProxyHandle>>,
pub db: ProxyQueryManager,
pub events: RpcEventEmitter,
}
impl ProxyCtx {
pub fn new(db_path: &Path, events: RpcEventEmitter) -> Self {
Self {
handle: Mutex::new(None),
db: ProxyQueryManager::new(db_path),
events,
}
}
}
// -- Request/response types --
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ListActionsRequest {}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ListActionsResponse {
pub actions: Vec<(ActionInvocation, ActionMetadata)>,
}
#[derive(Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct ListModelsRequest {}
#[derive(Serialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
#[serde(rename_all = "camelCase")]
pub struct ListModelsResponse {
pub http_exchanges: Vec<HttpExchange>,
}
// -- Handlers --
fn execute_action(ctx: &ProxyCtx, invocation: ActionInvocation) -> Result<bool, RpcError> {
match invocation {
ActionInvocation::Global { action } => match action {
GlobalAction::ProxyStart => {
let mut handle = ctx
.handle
.lock()
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
if handle.is_some() {
return Ok(true); // already running
}
let mut proxy_handle = yaak_proxy::start_proxy(9090)
.map_err(|e| RpcError { message: e })?;
if let Some(event_rx) = proxy_handle.take_event_rx() {
let db = ctx.db.clone();
let events = ctx.events.clone();
std::thread::spawn(move || run_event_loop(event_rx, db, events));
}
*handle = Some(proxy_handle);
Ok(true)
}
GlobalAction::ProxyStop => {
let mut handle = ctx
.handle
.lock()
.map_err(|_| RpcError { message: "lock poisoned".into() })?;
handle.take();
Ok(true)
}
},
}
}
fn list_actions(_ctx: &ProxyCtx, _req: ListActionsRequest) -> Result<ListActionsResponse, RpcError> {
Ok(ListActionsResponse {
actions: crate::actions::all_global_actions(),
})
}
fn list_models(ctx: &ProxyCtx, _req: ListModelsRequest) -> Result<ListModelsResponse, RpcError> {
ctx.db.with_conn(|db| {
Ok(ListModelsResponse {
http_exchanges: db.find_all::<HttpExchange>()
.map_err(|e| RpcError { message: e.to_string() })?,
})
})
}
// -- Event loop --
fn run_event_loop(rx: std::sync::mpsc::Receiver<ProxyEvent>, db: ProxyQueryManager, events: RpcEventEmitter) {
let mut in_flight: HashMap<u64, CapturedRequest> = HashMap::new();
while let Ok(event) = rx.recv() {
match event {
ProxyEvent::RequestStart { id, method, url, http_version } => {
in_flight.insert(id, CapturedRequest {
id,
method,
url,
http_version,
status: None,
elapsed_ms: None,
remote_http_version: None,
request_headers: vec![],
request_body: None,
response_headers: vec![],
response_body: None,
response_body_size: 0,
state: RequestState::Sending,
error: None,
});
}
ProxyEvent::RequestHeader { id, name, value } => {
if let Some(r) = in_flight.get_mut(&id) {
r.request_headers.push((name, value));
}
}
ProxyEvent::RequestBody { id, body } => {
if let Some(r) = in_flight.get_mut(&id) {
r.request_body = Some(body);
}
}
ProxyEvent::ResponseStart { id, status, http_version, elapsed_ms } => {
if let Some(r) = in_flight.get_mut(&id) {
r.status = Some(status);
r.remote_http_version = Some(http_version);
r.elapsed_ms = Some(elapsed_ms);
r.state = RequestState::Receiving;
}
}
ProxyEvent::ResponseHeader { id, name, value } => {
if let Some(r) = in_flight.get_mut(&id) {
r.response_headers.push((name, value));
}
}
ProxyEvent::ResponseBodyChunk { .. } => {
// Progress only — no action needed
}
ProxyEvent::ResponseBodyComplete { id, body, size, elapsed_ms } => {
if let Some(mut r) = in_flight.remove(&id) {
r.response_body = body;
r.response_body_size = size;
r.elapsed_ms = r.elapsed_ms.or(Some(elapsed_ms));
r.state = RequestState::Complete;
write_entry(&db, &events, &r);
}
}
ProxyEvent::Error { id, error } => {
if let Some(mut r) = in_flight.remove(&id) {
r.error = Some(error);
r.state = RequestState::Error;
write_entry(&db, &events, &r);
}
}
}
}
}
fn write_entry(db: &ProxyQueryManager, events: &RpcEventEmitter, r: &CapturedRequest) {
let entry = HttpExchange {
url: r.url.clone(),
method: r.method.clone(),
req_headers: r.request_headers.iter()
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
.collect(),
req_body: r.request_body.clone(),
res_status: r.status.map(|s| s as i32),
res_headers: r.response_headers.iter()
.map(|(n, v)| ProxyHeader { name: n.clone(), value: v.clone() })
.collect(),
res_body: r.response_body.clone(),
error: r.error.clone(),
..Default::default()
};
db.with_conn(|ctx| {
match ctx.upsert(&entry, &UpdateSource::Background) {
Ok((saved, created)) => {
events.emit("model_write", &ModelPayload {
model: saved,
change: ModelChangeEvent::Upsert { created },
});
}
Err(e) => warn!("Failed to write proxy entry: {e}"),
}
});
}
// -- Router + Schema --
define_rpc! {
ProxyCtx;
commands {
execute_action(ActionInvocation) -> bool,
list_actions(ListActionsRequest) -> ListActionsResponse,
list_models(ListModelsRequest) -> ListModelsResponse,
}
events {
model_write(ModelPayload),
}
}

View File

@@ -1,116 +0,0 @@
use chrono::NaiveDateTime;
use rusqlite::Row;
use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_def};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_database::{ModelChangeEvent, Result as DbResult, UpdateSource, UpsertModelInfo, generate_prefixed_id, upsert_date};
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ProxyHeader {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "http_exchanges")]
pub struct HttpExchange {
pub id: String,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub url: String,
pub method: String,
pub req_headers: Vec<ProxyHeader>,
pub req_body: Option<Vec<u8>>,
pub res_status: Option<i32>,
pub res_headers: Vec<ProxyHeader>,
pub res_body: Option<Vec<u8>>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ModelPayload {
pub model: HttpExchange,
pub change: ModelChangeEvent,
}
impl UpsertModelInfo for HttpExchange {
fn table_name() -> impl IntoTableRef + IntoIden {
HttpExchangeIden::Table
}
fn id_column() -> impl IntoIden + Eq + Clone {
HttpExchangeIden::Id
}
fn generate_id() -> String {
generate_prefixed_id("he")
}
fn order_by() -> (impl IntoColumnRef, Order) {
(HttpExchangeIden::CreatedAt, Order::Desc)
}
fn get_id(&self) -> String {
self.id.clone()
}
fn insert_values(
self,
source: &UpdateSource,
) -> DbResult<Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>> {
use HttpExchangeIden::*;
Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)),
(Url, self.url.into()),
(Method, self.method.into()),
(ReqHeaders, serde_json::to_string(&self.req_headers)?.into()),
(ReqBody, self.req_body.into()),
(ResStatus, self.res_status.into()),
(ResHeaders, serde_json::to_string(&self.res_headers)?.into()),
(ResBody, self.res_body.into()),
(Error, self.error.into()),
])
}
fn update_columns() -> Vec<impl IntoIden> {
vec![
HttpExchangeIden::UpdatedAt,
HttpExchangeIden::Url,
HttpExchangeIden::Method,
HttpExchangeIden::ReqHeaders,
HttpExchangeIden::ReqBody,
HttpExchangeIden::ResStatus,
HttpExchangeIden::ResHeaders,
HttpExchangeIden::ResBody,
HttpExchangeIden::Error,
]
}
fn from_row(r: &Row) -> rusqlite::Result<Self>
where
Self: Sized,
{
let req_headers: String = r.get("req_headers")?;
let res_headers: String = r.get("res_headers")?;
Ok(Self {
id: r.get("id")?,
created_at: r.get("created_at")?,
updated_at: r.get("updated_at")?,
url: r.get("url")?,
method: r.get("method")?,
req_headers: serde_json::from_str(&req_headers).unwrap_or_default(),
req_body: r.get("req_body")?,
res_status: r.get("res_status")?,
res_headers: serde_json::from_str(&res_headers).unwrap_or_default(),
res_body: r.get("res_body")?,
error: r.get("error")?,
})
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "@yaakapp-internal/tauri-client",
"private": true,
"version": "1.0.0",
"main": "bindings/index.ts"
}

View File

@@ -1,186 +0,0 @@
use crate::PluginContextExt;
use crate::error::Error::GenericError;
use crate::error::Result;
use crate::models_ext::BlobManagerExt;
use crate::models_ext::QueryManagerExt;
use log::warn;
use std::sync::Arc;
use std::time::Instant;
use tauri::{AppHandle, Manager, Runtime, WebviewWindow};
use tokio::sync::watch::Receiver;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
use yaak_http::manager::HttpConnectionManager;
use yaak_models::models::{CookieJar, Environment, HttpRequest, HttpResponse, HttpResponseState};
use yaak_models::util::UpdateSource;
use yaak_plugins::events::PluginContext;
use yaak_plugins::manager::PluginManager;
/// Context for managing response state during HTTP transactions.
/// Handles both persisted responses (stored in DB) and ephemeral responses (in-memory only).
struct ResponseContext<R: Runtime> {
app_handle: AppHandle<R>,
response: HttpResponse,
update_source: UpdateSource,
}
impl<R: Runtime> ResponseContext<R> {
fn new(app_handle: AppHandle<R>, response: HttpResponse, update_source: UpdateSource) -> Self {
Self { app_handle, response, update_source }
}
/// Whether this response is persisted (has a non-empty ID)
fn is_persisted(&self) -> bool {
!self.response.id.is_empty()
}
/// Update the response state. For persisted responses, fetches from DB, applies the
/// closure, and updates the DB. For ephemeral responses, just applies the closure
/// to the in-memory response.
fn update<F>(&mut self, func: F) -> Result<()>
where
F: FnOnce(&mut HttpResponse),
{
if self.is_persisted() {
let r = self.app_handle.with_tx(|tx| {
let mut r = tx.get_http_response(&self.response.id)?;
func(&mut r);
tx.update_http_response_if_id(&r, &self.update_source)?;
Ok(r)
})?;
self.response = r;
Ok(())
} else {
func(&mut self.response);
Ok(())
}
}
/// Get the current response state
fn response(&self) -> &HttpResponse {
&self.response
}
}
pub async fn send_http_request<R: Runtime>(
window: &WebviewWindow<R>,
unrendered_request: &HttpRequest,
og_response: &HttpResponse,
environment: Option<Environment>,
cookie_jar: Option<CookieJar>,
cancelled_rx: &mut Receiver<bool>,
) -> Result<HttpResponse> {
send_http_request_with_context(
window,
unrendered_request,
og_response,
environment,
cookie_jar,
cancelled_rx,
&window.plugin_context(),
)
.await
}
pub async fn send_http_request_with_context<R: Runtime>(
window: &WebviewWindow<R>,
unrendered_request: &HttpRequest,
og_response: &HttpResponse,
environment: Option<Environment>,
cookie_jar: Option<CookieJar>,
cancelled_rx: &Receiver<bool>,
plugin_context: &PluginContext,
) -> Result<HttpResponse> {
let app_handle = window.app_handle().clone();
let update_source = UpdateSource::from_window_label(window.label());
let mut response_ctx =
ResponseContext::new(app_handle.clone(), og_response.clone(), update_source);
// Execute the inner send logic and handle errors consistently
let start = Instant::now();
let result = send_http_request_inner(
window,
unrendered_request,
environment,
cookie_jar,
cancelled_rx,
plugin_context,
&mut response_ctx,
)
.await;
match result {
Ok(response) => Ok(response),
Err(e) => {
let error = e.to_string();
let elapsed = start.elapsed().as_millis() as i32;
warn!("Failed to send request: {error:?}");
let _ = response_ctx.update(|r| {
r.state = HttpResponseState::Closed;
r.elapsed = elapsed;
if r.elapsed_headers == 0 {
r.elapsed_headers = elapsed;
}
r.error = Some(error);
});
Ok(response_ctx.response().clone())
}
}
}
async fn send_http_request_inner<R: Runtime>(
window: &WebviewWindow<R>,
unrendered_request: &HttpRequest,
environment: Option<Environment>,
cookie_jar: Option<CookieJar>,
cancelled_rx: &Receiver<bool>,
plugin_context: &PluginContext,
response_ctx: &mut ResponseContext<R>,
) -> Result<HttpResponse> {
let app_handle = window.app_handle().clone();
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
let connection_manager = app_handle.state::<HttpConnectionManager>();
let environment_id = environment.map(|e| e.id);
let cookie_jar_id = cookie_jar.as_ref().map(|jar| jar.id.clone());
let response_dir = app_handle.path().app_data_dir()?.join("responses");
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
query_manager: app_handle.db_manager().inner(),
blob_manager: app_handle.blob_manager().inner(),
request: unrendered_request.clone(),
environment_id: environment_id.as_deref(),
update_source: response_ctx.update_source.clone(),
cookie_jar_id,
response_dir: &response_dir,
emit_events_to: None,
emit_response_body_chunks_to: None,
existing_response: Some(response_ctx.response().clone()),
plugin_manager,
encryption_manager,
plugin_context,
cancelled_rx: Some(cancelled_rx.clone()),
connection_manager: Some(connection_manager.inner()),
})
.await
.map_err(|e| GenericError(e.to_string()))?;
Ok(result.response)
}
pub fn resolve_http_request<R: Runtime>(
window: &WebviewWindow<R>,
request: &HttpRequest,
) -> Result<(HttpRequest, String)> {
let mut new_request = request.clone();
let (authentication_type, authentication, authentication_context_id) =
window.db().resolve_auth_for_http_request(request)?;
new_request.authentication_type = authentication_type;
new_request.authentication = authentication;
let headers = window.db().resolve_headers_for_http_request(request)?;
new_request.headers = headers;
Ok((new_request, authentication_context_id))
}

View File

@@ -1,5 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_app_client_lib::run();
}

View File

@@ -1,85 +0,0 @@
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
pub use yaak::render::render_http_request;
use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_template<T: TemplateCallback>(
template: &str,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<String> {
let vars = &make_vars_hashmap(environment_chain);
parse_and_render(template, vars, cb, &opt).await
}
pub async fn render_json_value<T: TemplateCallback>(
value: Value,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<Value> {
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, &opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, &opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, &opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, &opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}

View File

@@ -1,8 +0,0 @@
# Generated by Cargo
# will have compiled files and executables
target/
gen/*
**/permissions/autogenerated
**/permissions/schemas

View File

@@ -1,22 +0,0 @@
[package]
name = "yaak-app-proxy"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[lib]
name = "tauri_app_proxy_lib"
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2.5.3", features = [] }
[dependencies]
log = { workspace = true }
serde_json = { workspace = true }
tauri = { workspace = true }
tauri-plugin-os = "2.3.2"
yaak-proxy-lib = { workspace = true }
yaak-rpc = { workspace = true }
yaak-window = { workspace = true }

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}

View File

@@ -1,18 +0,0 @@
{
"identifier": "default",
"description": "Default capabilities for the Yaak Proxy app",
"windows": [
"*"
],
"permissions": [
"core:default",
"os:allow-os-type",
"core:window:allow-close",
"core:window:allow-is-fullscreen",
"core:window:allow-is-maximized",
"core:window:allow-maximize",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-unmaximize"
]
}

View File

@@ -1,6 +0,0 @@
{
"name": "@yaakapp-internal/tauri-proxy",
"private": true,
"version": "1.0.0",
"main": "bindings/index.ts"
}

View File

@@ -1,59 +0,0 @@
use log::error;
use tauri::{Emitter, Manager, RunEvent, State};
use yaak_proxy_lib::ProxyCtx;
use yaak_rpc::{RpcEventEmitter, RpcRouter};
use yaak_window::window::CreateWindowConfig;
#[tauri::command]
fn rpc(
router: State<'_, RpcRouter<ProxyCtx>>,
ctx: State<'_, ProxyCtx>,
cmd: String,
payload: serde_json::Value,
) -> Result<serde_json::Value, String> {
router.dispatch(&cmd, payload, &ctx).map_err(|e| e.message)
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.setup(|app| {
let data_dir = app.path().app_data_dir().expect("no app data dir");
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
let (emitter, event_rx) = RpcEventEmitter::new();
app.manage(ProxyCtx::new(&data_dir.join("proxy.db"), emitter));
app.manage(yaak_proxy_lib::build_router());
// Drain RPC events and forward as Tauri events
let app_handle = app.handle().clone();
std::thread::spawn(move || {
for event in event_rx {
if let Err(e) = app_handle.emit(event.event, event.payload) {
error!("Failed to emit RPC event: {e}");
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![rpc])
.build(tauri::generate_context!())
.expect("error while building yaak proxy tauri application")
.run(|app_handle, event| {
if let RunEvent::Ready = event {
let config = CreateWindowConfig {
url: "/",
label: "main",
title: "Yaak Proxy",
inner_size: Some((1000.0, 700.0)),
visible: true,
hide_titlebar: true,
..Default::default()
};
if let Err(e) = yaak_window::window::create_window(app_handle, config) {
error!("Failed to create proxy window: {e:?}");
}
}
});
}

View File

@@ -1,24 +0,0 @@
{
"productName": "Yaak Proxy",
"version": "0.0.0",
"identifier": "app.yaak.proxy",
"build": {
"beforeBuildCommand": "npm --prefix ../.. run proxy:tauri-before-build",
"beforeDevCommand": "npm --prefix ../.. run proxy:tauri-before-dev",
"devUrl": "http://localhost:2420",
"frontendDist": "../../dist/apps/yaak-proxy"
},
"app": {
"withGlobalTauri": false,
"windows": []
},
"bundle": {
"icon": [
"../yaak-app-client/icons/release/32x32.png",
"../yaak-app-client/icons/release/128x128.png",
"../yaak-app-client/icons/release/128x128@2x.png",
"../yaak-app-client/icons/release/icon.icns",
"../yaak-app-client/icons/release/icon.ico"
]
}
}

Some files were not shown because too many files have changed in this diff Show More