mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-13 05:45:17 +01:00
Compare commits
2 Commits
yaak-cli-0
...
v2025.9.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6523f477 | ||
|
|
0c8d180928 |
@@ -1,72 +0,0 @@
|
||||
# Claude Context: Detaching Tauri from Yaak
|
||||
|
||||
## Goal
|
||||
Make Yaak runnable as a standalone CLI without Tauri as a dependency. The core Rust crates in `crates/` should be usable independently, while Tauri-specific code lives in `crates-tauri/`.
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
crates/ # Core crates - should NOT depend on Tauri
|
||||
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/`
|
||||
- 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
|
||||
|
||||
### 2. Decoupled Crates (no longer depend on Tauri)
|
||||
- **yaak-models**: Uses `init_standalone()` pattern for CLI database access
|
||||
- **yaak-http**: Removed Tauri plugin, HttpConnectionManager initialized in yaak-app setup
|
||||
- **yaak-common**: Only contains Tauri-free utilities (serde, platform)
|
||||
- **yaak-crypto**: Removed Tauri plugin, EncryptionManager initialized in yaak-app setup, commands moved to yaak-app
|
||||
- **yaak-grpc**: Replaced AppHandle with GrpcConfig struct, uses tokio::process::Command instead of Tauri sidecar
|
||||
|
||||
### 3. CLI Implementation
|
||||
- Basic CLI at `crates-cli/yaak-cli/src/main.rs`
|
||||
- Commands: workspaces, requests, send (by ID), get (ad-hoc URL), create
|
||||
- Uses same database as Tauri app via `yaak_models::init_standalone()`
|
||||
|
||||
## Remaining Work
|
||||
|
||||
### Crates Still Depending on Tauri (in `crates/`)
|
||||
1. **yaak-git** (3 files) - Moderate complexity
|
||||
2. **yaak-plugins** (13 files) - **Hardest** - deeply integrated with Tauri for plugin-to-window communication
|
||||
3. **yaak-sync** (4 files) - Moderate complexity
|
||||
4. **yaak-ws** (5 files) - Moderate complexity
|
||||
|
||||
### Pattern for Decoupling
|
||||
1. Remove Tauri plugin `init()` function from the crate
|
||||
2. Move commands to `yaak-app/src/commands.rs` or keep inline in `lib.rs`
|
||||
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/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/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
|
||||
|
||||
## Git Branch
|
||||
Working on `detach-tauri` branch.
|
||||
|
||||
## Recent Commits
|
||||
```
|
||||
c40cff40 Remove Tauri dependencies from yaak-crypto and yaak-grpc
|
||||
df495f1d Move Tauri utilities from yaak-common to yaak-tauri-utils
|
||||
481e0273 Remove Tauri dependencies from yaak-http and yaak-common
|
||||
10568ac3 Add HTTP request sending to yaak-cli
|
||||
bcb7d600 Add yaak-cli stub with basic database access
|
||||
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 app-dev` to test the Tauri app still works
|
||||
- Run `cargo run -p yaak-cli -- --help` to test the CLI
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
description: Generate formatted release notes for Yaak releases
|
||||
allowed-tools: Bash(git tag:*)
|
||||
---
|
||||
|
||||
Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions.
|
||||
|
||||
## What to do
|
||||
|
||||
1. Identifies the version tag and previous version
|
||||
2. Retrieves all commits between versions
|
||||
- If the version is a beta version, it retrieves commits between the beta version and previous beta version
|
||||
- If the version is a stable version, it retrieves commits between the stable version and the previous stable version
|
||||
3. Fetches PR descriptions for linked issues to find:
|
||||
- Feedback URLs (feedback.yaak.app)
|
||||
- Additional context and descriptions
|
||||
- Installation links for plugins
|
||||
4. Formats the release notes using the standard Yaak format:
|
||||
- Changelog badge at the top
|
||||
- Bulleted list of changes with PR links
|
||||
- Feedback links where available
|
||||
- Full changelog comparison link at the bottom
|
||||
|
||||
## Output Format
|
||||
|
||||
The skill generates markdown-formatted release notes following this structure:
|
||||
|
||||
```markdown
|
||||
[](https://yaak.app/changelog/VERSION)
|
||||
|
||||
- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123)
|
||||
- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456)
|
||||
- A simple item that doesn't have a feedback or PR link
|
||||
|
||||
**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT
|
||||
```
|
||||
|
||||
**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last
|
||||
**IMPORTANT**: PRs by `@gschier` should not mention the @username
|
||||
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
||||
|
||||
## After Generating Release Notes
|
||||
|
||||
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>'
|
||||
```
|
||||
|
||||
**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".
|
||||
@@ -1,27 +0,0 @@
|
||||
# Project Rules
|
||||
|
||||
## General Development
|
||||
|
||||
- **NEVER** commit or push without explicit confirmation
|
||||
|
||||
## Build and Lint
|
||||
|
||||
- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
|
||||
- Run `npm run bootstrap` after changing plugin runtime or MCP server code
|
||||
|
||||
## Plugin System
|
||||
|
||||
### Backend Constraints
|
||||
|
||||
- Always use `UpdateSource::Plugin` when calling database methods from plugin events
|
||||
- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
|
||||
- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
|
||||
|
||||
### MCP Server
|
||||
|
||||
- MCP server has **no active window context** - cannot call `window.workspaceId()`
|
||||
- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
|
||||
|
||||
## Rust Type Generation
|
||||
|
||||
- Run `cargo test --package yaak-plugins` (and for other crates) to regenerate TypeScript bindings after modifying Rust event types
|
||||
@@ -1,49 +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`.
|
||||
- These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
||||
|
||||
## 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`.
|
||||
9
.gitattributes
vendored
9
.gitattributes
vendored
@@ -1,7 +1,2 @@
|
||||
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
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
src-tauri/vendored/**/* linguist-generated=true
|
||||
src-tauri/gen/schemas/**/* linguist-generated=true
|
||||
|
||||
18
.github/pull_request_template.md
vendored
18
.github/pull_request_template.md
vendored
@@ -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. -->
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -18,13 +18,14 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'src-tauri'
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run bootstrap
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all
|
||||
working-directory: src-tauri
|
||||
|
||||
50
.github/workflows/claude.yml
vendored
50
.github/workflows/claude.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
52
.github/workflows/flathub.yml
vendored
52
.github/workflows/flathub.yml
vendored
@@ -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
|
||||
59
.github/workflows/release-api-npm.yml
vendored
59
.github/workflows/release-api-npm.yml
vendored
@@ -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
|
||||
179
.github/workflows/release-app.yml
vendored
179
.github/workflows/release-app.yml
vendored
@@ -1,179 +0,0 @@
|
||||
name: Release App Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: "macos-latest" # for Arm-based Macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
yaak_arch: "arm64"
|
||||
os: "macos"
|
||||
targets: "aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel-based Macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
yaak_arch: "x64"
|
||||
os: "macos"
|
||||
targets: "x86_64-apple-darwin"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: ""
|
||||
yaak_arch: "x64"
|
||||
os: "ubuntu"
|
||||
targets: ""
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: ""
|
||||
yaak_arch: "arm64"
|
||||
os: "ubuntu"
|
||||
targets: ""
|
||||
- platform: "windows-latest"
|
||||
args: ""
|
||||
yaak_arch: "x64"
|
||||
os: "windows"
|
||||
targets: ""
|
||||
# Windows ARM64
|
||||
- platform: "windows-latest"
|
||||
args: "--target aarch64-pc-windows-msvc"
|
||||
yaak_arch: "arm64"
|
||||
os: "windows"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.targets }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- name: install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install trusted-signing-cli (Windows only)
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dir = "$env:USERPROFILE\trusted-signing"
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
|
||||
$exe = Join-Path $dir "trusted-signing-cli.exe"
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
echo $dir >> $env:GITHUB_PATH
|
||||
& $exe --version
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run bootstrap
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --exclude yaak-cli
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version
|
||||
env:
|
||||
YAAK_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- name: Sign vendored binaries (macOS only)
|
||||
if: matrix.os == 'macos'
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Import certificate
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
|
||||
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/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:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
# Apple signing stuff
|
||||
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Windows signing stuff
|
||||
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
releaseBody: "[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)"
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
args: "${{ matrix.args }} --config ./crates-tauri/yaak-app/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/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
|
||||
218
.github/workflows/release-cli-npm.yml
vendored
218
.github/workflows/release-cli-npm.yml
vendored
@@ -1,218 +0,0 @@
|
||||
name: Release CLI to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [yaak-cli-*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: CLI version to publish (for example 0.4.0 or v0.4.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
prepare-vendored-assets:
|
||||
name: Prepare vendored plugin assets
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin assets
|
||||
env:
|
||||
SKIP_WASM_BUILD: "1"
|
||||
run: |
|
||||
npm run build
|
||||
npm run vendor:vendor-plugins
|
||||
|
||||
- name: Upload vendored assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vendored-assets
|
||||
path: |
|
||||
crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs
|
||||
crates-tauri/yaak-app/vendored/plugins
|
||||
if-no-files-found: error
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.pkg }}
|
||||
needs: prepare-vendored-assets
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- pkg: cli-darwin-arm64
|
||||
runner: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary: yaak
|
||||
- pkg: cli-darwin-x64
|
||||
runner: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
binary: yaak
|
||||
- pkg: cli-linux-arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: yaak
|
||||
- pkg: cli-linux-x64
|
||||
runner: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: yaak
|
||||
- pkg: cli-win32-arm64
|
||||
runner: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
binary: yaak.exe
|
||||
- pkg: cli-win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: yaak.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Restore Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release-cli-npm
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: startsWith(matrix.runner, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libdbus-1-dev
|
||||
|
||||
- name: Download vendored assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vendored-assets
|
||||
path: crates-tauri/yaak-app/vendored
|
||||
|
||||
- name: Set CLI build version
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
echo "Building yaak version: $VERSION"
|
||||
echo "YAAK_CLI_VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build yaak
|
||||
run: cargo build --locked --release -p yaak-cli --bin yaak --target ${{ matrix.target }}
|
||||
|
||||
- name: Stage binary artifact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "npm/dist/${{ matrix.pkg }}"
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}"
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.pkg }}
|
||||
path: npm/dist/${{ matrix.pkg }}/${{ matrix.binary }}
|
||||
if-no-files-found: error
|
||||
|
||||
publish-npm:
|
||||
name: Publish @yaakapp/cli packages
|
||||
needs: build-binaries
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: cli-*
|
||||
path: npm/dist
|
||||
merge-multiple: false
|
||||
|
||||
- name: Prepare npm packages
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="$WORKFLOW_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#yaak-cli-}"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
PRERELEASE="${VERSION#*-}"
|
||||
NPM_TAG="${PRERELEASE%%.*}"
|
||||
else
|
||||
NPM_TAG="latest"
|
||||
fi
|
||||
echo "Preparing CLI npm packages for version: $VERSION"
|
||||
echo "Publishing with npm dist-tag: $NPM_TAG"
|
||||
echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV"
|
||||
YAAK_CLI_VERSION="$VERSION" node npm/prepare-publish.js
|
||||
|
||||
- name: Publish @yaakapp/cli-darwin-arm64
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli-darwin-arm64
|
||||
|
||||
- name: Publish @yaakapp/cli-darwin-x64
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli-darwin-x64
|
||||
|
||||
- name: Publish @yaakapp/cli-linux-arm64
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli-linux-arm64
|
||||
|
||||
- name: Publish @yaakapp/cli-linux-x64
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli-linux-x64
|
||||
|
||||
- name: Publish @yaakapp/cli-win32-arm64
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli-win32-arm64
|
||||
|
||||
- name: Publish @yaakapp/cli-win32-x64
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli-win32-x64
|
||||
|
||||
- name: Publish @yaakapp/cli
|
||||
run: npm publish --provenance --access public --tag "$NPM_TAG"
|
||||
working-directory: npm/cli
|
||||
131
.github/workflows/release.yml
vendored
Normal file
131
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
name: Generate Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [ v* ]
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
|
||||
args: '--target aarch64-apple-darwin'
|
||||
yaak_arch: 'arm64'
|
||||
os: 'macos'
|
||||
targets: 'aarch64-apple-darwin'
|
||||
- platform: 'macos-latest' # for Intel-based Macs.
|
||||
args: '--target x86_64-apple-darwin'
|
||||
yaak_arch: 'x64'
|
||||
os: 'macos'
|
||||
targets: 'x86_64-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
yaak_arch: 'x64'
|
||||
os: 'ubuntu'
|
||||
targets: ''
|
||||
- platform: 'ubuntu-22.04-arm'
|
||||
args: ''
|
||||
yaak_arch: 'arm64'
|
||||
os: 'ubuntu'
|
||||
targets: ''
|
||||
- platform: 'windows-latest'
|
||||
args: ''
|
||||
yaak_arch: 'x64'
|
||||
os: 'windows'
|
||||
targets: ''
|
||||
# Windows ARM64
|
||||
- platform: 'windows-latest'
|
||||
args: '--target aarch64-pc-windows-msvc'
|
||||
yaak_arch: 'arm64'
|
||||
os: 'windows'
|
||||
targets: 'aarch64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.targets }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: 'src-tauri'
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
|
||||
- name: install dependencies (Linux only)
|
||||
if: matrix.os == 'ubuntu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install trusted-signing-cli (Windows only)
|
||||
if: matrix.os == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dir = "$env:USERPROFILE\trusted-signing"
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
|
||||
$exe = Join-Path $dir "trusted-signing-cli.exe"
|
||||
Invoke-WebRequest -Uri $url -OutFile $exe
|
||||
echo $dir >> $env:GITHUB_PATH
|
||||
& $exe --version
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- name: Run JS Tests
|
||||
run: npm test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version
|
||||
env:
|
||||
YAAK_VERSION: ${{ github.ref_name }}
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
# Apple signing stuff
|
||||
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Windows signing stuff
|
||||
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
tagName: 'v__VERSION__'
|
||||
releaseName: 'Release __VERSION__'
|
||||
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
args: '${{ matrix.args }} --config ./src-tauri/tauri.release.conf.json'
|
||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -25,7 +25,6 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.eslintcache
|
||||
out
|
||||
|
||||
*.sqlite
|
||||
*.sqlite-*
|
||||
@@ -34,26 +33,3 @@ out
|
||||
|
||||
.tmp
|
||||
tmp
|
||||
.zed
|
||||
codebook.toml
|
||||
target
|
||||
|
||||
# Per-worktree Tauri config (generated by post-checkout hook)
|
||||
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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node scripts/git-hooks/post-checkout.mjs "$@"
|
||||
@@ -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
|
||||
@@ -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).
|
||||
74
Cargo.toml
74
Cargo.toml
@@ -1,74 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
# Shared crates (no Tauri dependency)
|
||||
"crates/yaak-core",
|
||||
"crates/yaak-common",
|
||||
"crates/yaak-crypto",
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-api",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
"crates-tauri/yaak-app",
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
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"
|
||||
tauri = "2.9.5"
|
||||
tauri-plugin = "2.5.2"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-shell = "2.3.3"
|
||||
thiserror = "2.0.17"
|
||||
tokio = "1.48.0"
|
||||
ts-rs = "11.1.0"
|
||||
|
||||
# Internal crates - shared
|
||||
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" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
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" }
|
||||
|
||||
# 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" }
|
||||
|
||||
[profile.release]
|
||||
strip = false
|
||||
14
README.md
14
README.md
@@ -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/icons/icon.png">
|
||||
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
|
||||
|
||||
<p align="center">
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <!-- sponsors-base -->
|
||||
</p>
|
||||
|
||||

|
||||
@@ -58,15 +58,13 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s 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
|
||||
|
||||
- [Feedback and Bug Reports](https://feedback.yaak.app)
|
||||
- [Documentation](https://yaak.app/docs)
|
||||
- [Documentation](https://feedback.yaak.app/help)
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
14
biome.json
14
biome.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.7/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
@@ -38,18 +38,14 @@
|
||||
"!**/node_modules",
|
||||
"!**/dist",
|
||||
"!**/build",
|
||||
"!target",
|
||||
"!scripts",
|
||||
"!crates",
|
||||
"!crates-tauri",
|
||||
"!packages/plugin-runtime",
|
||||
"!packages/plugin-runtime-types",
|
||||
"!src-tauri",
|
||||
"!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",
|
||||
"!npm"
|
||||
"!src-web/routeTree.gen.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "yaak"
|
||||
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 }
|
||||
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"
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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::db_context::DbContext;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs"
|
||||
));
|
||||
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
||||
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app/vendored/plugins");
|
||||
|
||||
#[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) -> DbContext<'_> {
|
||||
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(())
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod context;
|
||||
mod plugin_events;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod version;
|
||||
mod version_check;
|
||||
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands, PluginCommands, RequestCommands};
|
||||
use context::{CliContext, CliExecutionContext};
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let Cli { data_dir, environment, cookie_jar, verbose, log, command } = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
|
||||
|
||||
let data_dir = data_dir.unwrap_or_else(|| resolve_data_dir(app_id));
|
||||
|
||||
version_check::maybe_check_for_updates().await;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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::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()),
|
||||
};
|
||||
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;
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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::Environment(args) => {
|
||||
let context = CliContext::new(data_dir.clone(), app_id);
|
||||
let exit_code = commands::environment::run(&context, args);
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn resolve_data_dir(app_id: &str) -> PathBuf {
|
||||
if let Some(dir) = wsl_data_dir(app_id) {
|
||||
return dir;
|
||||
}
|
||||
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
||||
}
|
||||
|
||||
/// Detect WSL and resolve the Windows AppData\Roaming path for the Yaak data directory.
|
||||
fn wsl_data_dir(app_id: &str) -> Option<PathBuf> {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let proc_version = std::fs::read_to_string("/proc/version").ok()?;
|
||||
let is_wsl = proc_version.to_lowercase().contains("microsoft");
|
||||
if !is_wsl {
|
||||
return None;
|
||||
}
|
||||
|
||||
// We're in WSL, so try to resolve the Yaak app's data directory in Windows
|
||||
|
||||
// Get the Windows %APPDATA% path via cmd.exe
|
||||
let appdata_output =
|
||||
std::process::Command::new("cmd.exe").args(["/C", "echo", "%APPDATA%"]).output().ok()?;
|
||||
|
||||
let win_path = String::from_utf8(appdata_output.stdout).ok()?.trim().to_string();
|
||||
if win_path.is_empty() || win_path == "%APPDATA%" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Convert Windows path to WSL path using wslpath (handles custom mount points)
|
||||
let wslpath_output = std::process::Command::new("wslpath").arg(&win_path).output().ok()?;
|
||||
|
||||
let wsl_appdata = String::from_utf8(wslpath_output.stdout).ok()?.trim().to_string();
|
||||
if wsl_appdata.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let wsl_path = PathBuf::from(wsl_appdata).join(app_id);
|
||||
|
||||
if wsl_path.exists() { Some(wsl_path) } else { None }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
pub mod confirm;
|
||||
pub mod http;
|
||||
pub mod json;
|
||||
pub mod schema;
|
||||
pub mod workspace;
|
||||
@@ -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') ]}",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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.")),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub fn cli_version() -> &'static str {
|
||||
option_env!("YAAK_CLI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
@@ -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",
|
||||
));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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\""));
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
@@ -1,5 +0,0 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
|
||||
|
||||
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Enable for NodeJS/V8 JIT compiler -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
|
||||
<!-- Allow loading plugins signed with different Team IDs (e.g., 1Password) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,100 +0,0 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Manager, Runtime, State, WebviewWindow, command};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::models::HttpRequestHeader;
|
||||
use yaak_models::queries::workspaces::default_headers;
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::{
|
||||
decrypt_secure_template_function, encrypt_secure_template_function,
|
||||
};
|
||||
|
||||
/// Extension trait for accessing the EncryptionManager from Tauri Manager types.
|
||||
pub trait EncryptionManagerExt<'a, R> {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> EncryptionManagerExt<'a, R> for M {
|
||||
fn crypto(&'a self) -> State<'a, EncryptionManager> {
|
||||
self.state::<EncryptionManager>()
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_decrypt_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let encryption_manager = window.app_handle().state::<EncryptionManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(decrypt_secure_template_function(&encryption_manager, &plugin_context, template)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_secure_template<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
template: &str,
|
||||
) -> Result<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&plugin_context,
|
||||
template,
|
||||
)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_enable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().ensure_workspace_key(workspace_id)?;
|
||||
window.crypto().reveal_workspace_key(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_reveal_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<String> {
|
||||
Ok(window.crypto().reveal_workspace_key(workspace_id)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_set_workspace_key<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().set_human_key(workspace_id, key)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn cmd_disable_encryption<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: &str,
|
||||
) -> Result<()> {
|
||||
window.crypto().disable_encryption(workspace_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) fn cmd_default_headers() -> Vec<HttpRequestHeader> {
|
||||
default_headers()
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
//! Tauri-specific extensions for yaak-git.
|
||||
//!
|
||||
//! This module provides the Tauri commands for git functionality.
|
||||
|
||||
use crate::error::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::command;
|
||||
use yaak_git::{
|
||||
BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult,
|
||||
PushResult, git_add, git_add_credential, git_add_remote, git_checkout_branch, git_clone,
|
||||
git_commit, git_create_branch, git_delete_branch, git_delete_remote_branch, git_fetch_all,
|
||||
git_init, git_log, git_merge_branch, git_pull, git_pull_force_reset, git_pull_merge, git_push,
|
||||
git_remotes, git_rename_branch, git_reset_changes, git_rm_remote, git_status, git_unstage,
|
||||
};
|
||||
|
||||
// NOTE: All of these commands are async to prevent blocking work from locking up the UI
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_checkout(dir: &Path, branch: &str, force: bool) -> Result<String> {
|
||||
Ok(git_checkout_branch(dir, branch, force).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_branch(dir: &Path, branch: &str, base: Option<&str>) -> Result<()> {
|
||||
Ok(git_create_branch(dir, branch, base).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_delete_branch(
|
||||
dir: &Path,
|
||||
branch: &str,
|
||||
force: Option<bool>,
|
||||
) -> Result<BranchDeleteResult> {
|
||||
Ok(git_delete_branch(dir, branch, force.unwrap_or(false)).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_delete_remote_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_delete_remote_branch(dir, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_merge_branch(dir: &Path, branch: &str) -> Result<()> {
|
||||
Ok(git_merge_branch(dir, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
Ok(git_rename_branch(dir, old_name, new_name).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_status(dir: &Path) -> Result<GitStatusSummary> {
|
||||
Ok(git_status(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_log(dir: &Path) -> Result<Vec<GitCommit>> {
|
||||
Ok(git_log(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_initialize(dir: &Path) -> Result<()> {
|
||||
Ok(git_init(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||
Ok(git_clone(url, dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_commit(dir: &Path, message: &str) -> Result<()> {
|
||||
Ok(git_commit(dir, message).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_fetch_all(dir: &Path) -> Result<()> {
|
||||
Ok(git_fetch_all(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_push(dir: &Path) -> Result<PushResult> {
|
||||
Ok(git_push(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull(dir: &Path) -> Result<PullResult> {
|
||||
Ok(git_pull(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull_force_reset(
|
||||
dir: &Path,
|
||||
remote: &str,
|
||||
branch: &str,
|
||||
) -> Result<PullResult> {
|
||||
Ok(git_pull_force_reset(dir, remote, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
Ok(git_pull_merge(dir, remote, branch).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||
for path in rela_paths {
|
||||
git_add(dir, &path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_unstage(dir: &Path, rela_paths: Vec<PathBuf>) -> Result<()> {
|
||||
for path in rela_paths {
|
||||
git_unstage(dir, &path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_reset_changes(dir: &Path) -> Result<()> {
|
||||
Ok(git_reset_changes(dir).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_credential(
|
||||
remote_url: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
Ok(git_add_credential(remote_url, username, password).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_remotes(dir: &Path) -> Result<Vec<GitRemote>> {
|
||||
Ok(git_remotes(dir)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_add_remote(dir: &Path, name: &str, url: &str) -> Result<GitRemote> {
|
||||
Ok(git_add_remote(dir, name, url)?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_git_rm_remote(dir: &Path, name: &str) -> Result<()> {
|
||||
Ok(git_rm_remote(dir, name)?)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
//! Tauri-specific extensions for yaak-models.
|
||||
//!
|
||||
//! This module provides the Tauri plugin initialization and extension traits
|
||||
//! that allow accessing QueryManager and BlobManager from Tauri's Manager types.
|
||||
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::time::Duration;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Emitter, Manager, Runtime, State};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::db_context::DbContext;
|
||||
use yaak_models::error::Result;
|
||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||
|
||||
struct ModelChangeCursor {
|
||||
created_at: String,
|
||||
id: i64,
|
||||
}
|
||||
|
||||
impl ModelChangeCursor {
|
||||
fn from_launch_time() -> Self {
|
||||
Self {
|
||||
created_at: Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
|
||||
id: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_model_changes_batch<R: Runtime>(
|
||||
query_manager: &QueryManager,
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
cursor: &mut ModelChangeCursor,
|
||||
) -> bool {
|
||||
let changes = match query_manager.connect().list_model_changes_since(
|
||||
&cursor.created_at,
|
||||
cursor.id,
|
||||
MODEL_CHANGES_POLL_BATCH_SIZE,
|
||||
) {
|
||||
Ok(changes) => changes,
|
||||
Err(err) => {
|
||||
error!("Failed to poll model_changes rows: {err:?}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if changes.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let fetched_count = changes.len();
|
||||
for change in changes {
|
||||
cursor.created_at = change.created_at;
|
||||
cursor.id = change.id;
|
||||
|
||||
// Local window-originated writes are forwarded immediately from the
|
||||
// in-memory model event channel.
|
||||
if matches!(change.payload.update_source, UpdateSource::Window { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = app_handle.emit("model_write", change.payload) {
|
||||
error!("Failed to emit model_write event: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fetched_count == MODEL_CHANGES_POLL_BATCH_SIZE
|
||||
}
|
||||
|
||||
async fn run_model_change_poller<R: Runtime>(
|
||||
query_manager: QueryManager,
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
mut cursor: ModelChangeCursor,
|
||||
) {
|
||||
loop {
|
||||
while drain_model_changes_batch(&query_manager, &app_handle, &mut cursor) {}
|
||||
tokio::time::sleep(Duration::from_millis(MODEL_CHANGES_POLL_INTERVAL_MS)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for accessing the QueryManager from Tauri Manager types.
|
||||
pub trait QueryManagerExt<'a, R> {
|
||||
fn db_manager(&'a self) -> State<'a, QueryManager>;
|
||||
fn db(&'a self) -> DbContext<'a>;
|
||||
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&DbContext) -> Result<T>;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> QueryManagerExt<'a, R> for M {
|
||||
fn db_manager(&'a self) -> State<'a, QueryManager> {
|
||||
self.state::<QueryManager>()
|
||||
}
|
||||
|
||||
fn db(&'a self) -> DbContext<'a> {
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().connect()
|
||||
}
|
||||
|
||||
fn with_tx<F, T>(&'a self, func: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&DbContext) -> Result<T>,
|
||||
{
|
||||
let qm = self.state::<QueryManager>();
|
||||
qm.inner().with_tx(func)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension trait for accessing the BlobManager from Tauri Manager types.
|
||||
pub trait BlobManagerExt<'a, R> {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager>;
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext;
|
||||
}
|
||||
|
||||
impl<'a, R: Runtime, M: Manager<R>> BlobManagerExt<'a, R> for M {
|
||||
fn blob_manager(&'a self) -> State<'a, BlobManager> {
|
||||
self.state::<BlobManager>()
|
||||
}
|
||||
|
||||
fn blobs(&'a self) -> yaak_models::blob_manager::BlobContext {
|
||||
let manager = self.state::<BlobManager>();
|
||||
manager.inner().connect()
|
||||
}
|
||||
}
|
||||
|
||||
// Commands for yaak-models
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_upsert<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let db = window.db();
|
||||
let blobs = window.blob_manager();
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => db.upsert_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => db.upsert_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => db.upsert_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => db.upsert_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => db.upsert_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => db.upsert_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::KeyValue(m) => db.upsert_key_value(&m, source)?.id,
|
||||
AnyModel::Plugin(m) => db.upsert_plugin(&m, source)?.id,
|
||||
AnyModel::Settings(m) => db.upsert_settings(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => db.upsert_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => db.upsert_workspace(&m, source)?.id,
|
||||
AnyModel::WorkspaceMeta(m) => db.upsert_workspace_meta(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot upsert AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_delete<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
let blobs = window.blob_manager();
|
||||
// Use transaction for deletions because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::CookieJar(m) => tx.delete_cookie_jar(&m, source)?.id,
|
||||
AnyModel::Environment(m) => tx.delete_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.delete_folder(&m, source)?.id,
|
||||
AnyModel::GrpcConnection(m) => tx.delete_grpc_connection(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.delete_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.delete_http_request(&m, source)?.id,
|
||||
AnyModel::HttpResponse(m) => tx.delete_http_response(&m, source, &blobs)?.id,
|
||||
AnyModel::Plugin(m) => tx.delete_plugin(&m, source)?.id,
|
||||
AnyModel::WebsocketConnection(m) => tx.delete_websocket_connection(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.delete_websocket_request(&m, source)?.id,
|
||||
AnyModel::Workspace(m) => tx.delete_workspace(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot delete AnyModel {a:?})"))),
|
||||
};
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_duplicate<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_websocket_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<WebsocketEvent>> {
|
||||
Ok(app_handle.db().list_websocket_events(connection_id)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_grpc_events<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
connection_id: &str,
|
||||
) -> Result<Vec<GrpcEvent>> {
|
||||
Ok(app_handle.db().list_grpc_events(connection_id)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_get_settings<R: Runtime>(app_handle: tauri::AppHandle<R>) -> Result<Settings> {
|
||||
Ok(app_handle.db().get_settings())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_get_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
) -> Result<Option<GraphQlIntrospection>> {
|
||||
Ok(app_handle.db().get_graphql_introspection(request_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
||||
app_handle: tauri::AppHandle<R>,
|
||||
request_id: &str,
|
||||
workspace_id: &str,
|
||||
content: Option<String>,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<GraphQlIntrospection> {
|
||||
let source = UpdateSource::from_window_label(window.label());
|
||||
Ok(app_handle.db().upsert_graphql_introspection(workspace_id, request_id, content, &source)?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn models_workspace_models<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
workspace_id: Option<&str>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<String> {
|
||||
let mut l: Vec<AnyModel> = Vec::new();
|
||||
|
||||
// Add the global models
|
||||
{
|
||||
let db = window.db();
|
||||
l.push(db.get_settings().into());
|
||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let plugins = {
|
||||
let db = window.db();
|
||||
db.list_plugins()?
|
||||
};
|
||||
|
||||
let plugins = plugin_manager.resolve_plugins_for_runtime_from_db(plugins).await;
|
||||
l.append(&mut plugins.into_iter().map(Into::into).collect());
|
||||
|
||||
// Add the workspace children
|
||||
if let Some(wid) = workspace_id {
|
||||
let db = window.db();
|
||||
l.append(&mut db.list_cookie_jars(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_grpc_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_http_responses(wid, None)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_connections(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_websocket_requests(wid)?.into_iter().map(Into::into).collect());
|
||||
l.append(&mut db.list_workspace_metas(wid)?.into_iter().map(Into::into).collect());
|
||||
}
|
||||
|
||||
let j = serde_json::to_string(&l)?;
|
||||
|
||||
Ok(escape_str_for_webview(&j))
|
||||
}
|
||||
|
||||
fn escape_str_for_webview(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let code = c as u32;
|
||||
// ASCII
|
||||
if code <= 0x7F {
|
||||
c.to_string()
|
||||
// BMP characters encoded normally
|
||||
} else if code < 0xFFFF {
|
||||
format!("\\u{:04X}", code)
|
||||
// Beyond BMP encoded a surrogate pairs
|
||||
} else {
|
||||
let high = ((code - 0x10000) >> 10) + 0xD800;
|
||||
let low = ((code - 0x10000) & 0x3FF) + 0xDC00;
|
||||
format!("\\u{:04X}\\u{:04X}", high, low)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Initialize database managers as a plugin (for initialization order).
|
||||
/// Commands are in the main invoke_handler.
|
||||
/// This must be registered before other plugins that depend on the database.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("yaak-models-db")
|
||||
.setup(|app_handle, _api| {
|
||||
let app_path = app_handle.path().app_data_dir().unwrap();
|
||||
let db_path = app_path.join("db.sqlite");
|
||||
let blob_path = app_path.join("blobs.sqlite");
|
||||
|
||||
let (query_manager, blob_manager, rx) =
|
||||
match yaak_models::init_standalone(&db_path, &blob_path) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
app_handle
|
||||
.dialog()
|
||||
.message(e.to_string())
|
||||
.kind(MessageDialogKind::Error)
|
||||
.blocking_show();
|
||||
return Err(Box::from(e.to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
let db = query_manager.connect();
|
||||
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||
{
|
||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||
}
|
||||
// Only stream writes that happen after this app launch.
|
||||
let cursor = ModelChangeCursor::from_launch_time();
|
||||
|
||||
let poll_query_manager = query_manager.clone();
|
||||
|
||||
app_handle.manage(query_manager);
|
||||
app_handle.manage(blob_manager);
|
||||
|
||||
// Poll model_changes so all writers (including external CLI processes) update the UI.
|
||||
let app_handle_poll = app_handle.clone();
|
||||
let query_manager = poll_query_manager;
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_model_change_poller(query_manager, app_handle_poll, cursor).await;
|
||||
});
|
||||
|
||||
// Fast path for local app writes initiated by frontend windows. This keeps the
|
||||
// current sync-model UX snappy, while DB polling handles external writers (CLI).
|
||||
let app_handle_local = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for payload in rx {
|
||||
if !matches!(payload.update_source, UpdateSource::Window { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = app_handle_local.emit("model_write", payload) {
|
||||
error!("Failed to emit local model_write event: {err:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
//! Tauri-specific plugin management code.
|
||||
//!
|
||||
//! This module contains all Tauri integration for the plugin system:
|
||||
//! - Plugin initialization and lifecycle management
|
||||
//! - Tauri commands for plugin search/install/uninstall
|
||||
//! - Plugin update checking
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use log::{error, info, warn};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::path::BaseDirectory;
|
||||
use tauri::plugin::{Builder, TauriPlugin};
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, RunEvent, Runtime, State, WebviewWindow, WindowEvent, command,
|
||||
is_dev,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use ts_rs::TS;
|
||||
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||
use yaak_models::models::{Plugin, PluginSource};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::api::{
|
||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||
search_plugins,
|
||||
};
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::install::{delete_and_uninstall, download_and_install};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_meta::get_plugin_meta;
|
||||
|
||||
static EXITING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// ============================================================================
|
||||
// Plugin Updater
|
||||
// ============================================================================
|
||||
|
||||
const MAX_UPDATE_CHECK_HOURS: u64 = 12;
|
||||
|
||||
pub struct PluginUpdater {
|
||||
last_check: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct PluginUpdateNotification {
|
||||
pub update_count: usize,
|
||||
pub plugins: Vec<PluginUpdateInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "index.ts")]
|
||||
pub struct PluginUpdateInfo {
|
||||
pub name: String,
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
}
|
||||
|
||||
impl PluginUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self { last_check: None }
|
||||
}
|
||||
|
||||
pub async fn check_now<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
|
||||
self.last_check = Some(Instant::now());
|
||||
|
||||
info!("Checking for plugin updates");
|
||||
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = window.app_handle().db().list_plugins()?;
|
||||
let updates = check_plugin_updates(&http_client, plugins.clone()).await?;
|
||||
|
||||
if updates.plugins.is_empty() {
|
||||
info!("No plugin updates available");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Get current plugin versions to build notification
|
||||
let mut update_infos = Vec::new();
|
||||
|
||||
for update in &updates.plugins {
|
||||
if let Some(plugin) = plugins.iter().find(|p| {
|
||||
if let Ok(meta) = get_plugin_meta(&std::path::Path::new(&p.directory)) {
|
||||
meta.name == update.name
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
if let Ok(meta) = get_plugin_meta(&std::path::Path::new(&plugin.directory)) {
|
||||
update_infos.push(PluginUpdateInfo {
|
||||
name: update.name.clone(),
|
||||
current_version: meta.version,
|
||||
latest_version: update.version.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let notification =
|
||||
PluginUpdateNotification { update_count: update_infos.len(), plugins: update_infos };
|
||||
|
||||
info!("Found {} plugin update(s)", notification.update_count);
|
||||
|
||||
if let Err(e) = window.emit_to(window.label(), "plugin_updates_available", ¬ification) {
|
||||
error!("Failed to emit plugin_updates_available event: {}", e);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn maybe_check<R: Runtime>(&mut self, window: &WebviewWindow<R>) -> Result<bool> {
|
||||
let update_period_seconds = MAX_UPDATE_CHECK_HOURS * 60 * 60;
|
||||
|
||||
if let Some(i) = self.last_check
|
||||
&& i.elapsed().as_secs() < update_period_seconds
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.check_now(window).await
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri Commands
|
||||
// ============================================================================
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_search<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
query: &str,
|
||||
) -> Result<PluginSearchResponse> {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
Ok(search_plugins(&http_client, query).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_install<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
name: &str,
|
||||
version: Option<String>,
|
||||
) -> Result<()> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
download_and_install(
|
||||
plugin_manager,
|
||||
&query_manager,
|
||||
&http_client,
|
||||
&plugin_context,
|
||||
name,
|
||||
version,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_install_from_directory<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
directory: &str,
|
||||
) -> Result<Plugin> {
|
||||
let plugin = window.db().upsert_plugin(
|
||||
&Plugin {
|
||||
directory: directory.into(),
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::from_window_label(window.label()),
|
||||
)?;
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
plugin_manager.add_plugin(&window.plugin_context(), &plugin).await?;
|
||||
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||
plugin_id: &str,
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<Plugin> {
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_updates<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) -> Result<PluginUpdatesResponse> {
|
||||
let app_version = app_handle.package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
Ok(check_plugin_updates(&http_client, plugins).await?)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn cmd_plugins_update_all<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
) -> Result<Vec<PluginNameVersion>> {
|
||||
let app_version = window.app_handle().package_info().version.to_string();
|
||||
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
|
||||
let plugins = window.db().list_plugins()?;
|
||||
|
||||
// Get list of available updates (already filtered to only registry plugins)
|
||||
let updates = check_plugin_updates(&http_client, plugins).await?;
|
||||
|
||||
if updates.plugins.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||
let query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||
let plugin_context = window.plugin_context();
|
||||
|
||||
let mut updated = Vec::new();
|
||||
|
||||
for update in updates.plugins {
|
||||
info!("Updating plugin: {} to version {}", update.name, update.version);
|
||||
match download_and_install(
|
||||
plugin_manager.clone(),
|
||||
&query_manager,
|
||||
&http_client,
|
||||
&plugin_context,
|
||||
&update.name,
|
||||
Some(update.version.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
info!("Successfully updated plugin: {}", update.name);
|
||||
updated.push(update.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update plugin {}: {:?}", update.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tauri Plugin Initialization
|
||||
// ============================================================================
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("yaak-plugins")
|
||||
.setup(|app_handle, _| {
|
||||
// Resolve paths for plugin manager
|
||||
let vendored_plugin_dir = app_handle
|
||||
.path()
|
||||
.resolve("vendored/plugins", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin directory resource");
|
||||
|
||||
let installed_plugin_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("failed to get app data dir")
|
||||
.join("installed-plugins");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let node_bin_name = "yaaknode.exe";
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let node_bin_name = "yaaknode";
|
||||
|
||||
let node_bin_path = app_handle
|
||||
.path()
|
||||
.resolve(format!("vendored/node/{}", node_bin_name), BaseDirectory::Resource)
|
||||
.expect("failed to resolve yaaknode binary");
|
||||
|
||||
let plugin_runtime_main = app_handle
|
||||
.path()
|
||||
.resolve("vendored/plugin-runtime", BaseDirectory::Resource)
|
||||
.expect("failed to resolve plugin runtime")
|
||||
.join("index.cjs");
|
||||
|
||||
let dev_mode = is_dev();
|
||||
let query_manager =
|
||||
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||
|
||||
// Create plugin manager asynchronously
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let manager = PluginManager::new(
|
||||
vendored_plugin_dir,
|
||||
installed_plugin_dir,
|
||||
node_bin_path,
|
||||
plugin_runtime_main,
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
dev_mode,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to initialize plugins");
|
||||
|
||||
app_handle_clone.manage(manager);
|
||||
});
|
||||
|
||||
let plugin_updater = PluginUpdater::new();
|
||||
app_handle.manage(Mutex::new(plugin_updater));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_event(|app, e| match e {
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
if EXITING.swap(true, Ordering::SeqCst) {
|
||||
return; // Only exit once to prevent infinite recursion
|
||||
}
|
||||
api.prevent_exit();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
info!("Exiting plugin runtime due to app exit");
|
||||
let manager: State<PluginManager> = app.state();
|
||||
manager.terminate().await;
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
// Check for plugin updates on window focus
|
||||
let w = app.get_webview_window(&label).unwrap();
|
||||
let h = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let val: State<'_, Mutex<PluginUpdater>> = h.state();
|
||||
if let Err(e) = val.lock().await.maybe_check(&w).await {
|
||||
warn!("Failed to check for plugin updates {e:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use serde_json::Value;
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::models::Environment;
|
||||
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
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-tauri-utils"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
tauri = { workspace = true }
|
||||
regex = "1.11.0"
|
||||
@@ -1 +0,0 @@
|
||||
pub mod window;
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["gzip"] }
|
||||
sysproxy = "0.3"
|
||||
thiserror = { workspace = true }
|
||||
yaak-common = { workspace = true }
|
||||
@@ -1,9 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
ReqwestError(#[from] reqwest::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -1,80 +0,0 @@
|
||||
mod error;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
|
||||
use log::{debug, warn};
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use std::time::Duration;
|
||||
use yaak_common::platform::{get_ua_arch, get_ua_platform};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ApiClientKind {
|
||||
App,
|
||||
Cli,
|
||||
}
|
||||
|
||||
/// Build a reqwest Client configured for Yaak's own API calls.
|
||||
///
|
||||
/// Includes a custom User-Agent, JSON accept header, 20s timeout, gzip,
|
||||
/// and automatic OS-level proxy detection via sysproxy.
|
||||
pub fn yaak_api_client(kind: ApiClientKind, version: &str) -> Result<Client> {
|
||||
let platform = get_ua_platform();
|
||||
let arch = get_ua_arch();
|
||||
let product = match kind {
|
||||
ApiClientKind::App => "Yaak",
|
||||
ApiClientKind::Cli => "YaakCli",
|
||||
};
|
||||
let ua = format!("{product}/{version} ({platform}; {arch})");
|
||||
|
||||
let mut default_headers = HeaderMap::new();
|
||||
default_headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());
|
||||
|
||||
let mut builder = reqwest::ClientBuilder::new()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.default_headers(default_headers)
|
||||
.gzip(true)
|
||||
.user_agent(ua);
|
||||
|
||||
if let Some(sys) = get_enabled_system_proxy() {
|
||||
let proxy_url = format!("http://{}:{}", sys.host, sys.port);
|
||||
match reqwest::Proxy::all(&proxy_url) {
|
||||
Ok(p) => {
|
||||
let p = if !sys.bypass.is_empty() {
|
||||
p.no_proxy(reqwest::NoProxy::from_string(&sys.bypass))
|
||||
} else {
|
||||
p
|
||||
};
|
||||
builder = builder.proxy(p);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to configure system proxy: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
/// Returns the system proxy URL if one is enabled, e.g. `http://host:port`.
|
||||
pub fn get_system_proxy_url() -> Option<String> {
|
||||
let sys = get_enabled_system_proxy()?;
|
||||
Some(format!("http://{}:{}", sys.host, sys.port))
|
||||
}
|
||||
|
||||
fn get_enabled_system_proxy() -> Option<sysproxy::Sysproxy> {
|
||||
match sysproxy::Sysproxy::get_system_proxy() {
|
||||
Ok(sys) if sys.enable => {
|
||||
debug!("Detected system proxy: http://{}:{}", sys.host, sys.port);
|
||||
Some(sys)
|
||||
}
|
||||
Ok(_) => {
|
||||
debug!("System proxy detected but not enabled");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Could not detect system proxy: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process"] }
|
||||
@@ -1,42 +0,0 @@
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::process::Stdio;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Creates a new `tokio::process::Command` that won't spawn a console window on Windows.
|
||||
pub fn new_xplatform_command<S: AsRef<OsStr>>(program: S) -> tokio::process::Command {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = tokio::process::Command::new(program);
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Creates a command only if the binary exists and can be invoked with the given probe argument.
|
||||
pub async fn new_checked_command<S: AsRef<OsStr>>(
|
||||
program: S,
|
||||
probe_arg: &str,
|
||||
) -> io::Result<tokio::process::Command> {
|
||||
let program: OsString = program.as_ref().to_os_string();
|
||||
|
||||
let mut probe = new_xplatform_command(&program);
|
||||
probe.arg(probe_arg).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||
|
||||
let status = probe.status().await?;
|
||||
if !status.success() {
|
||||
return Err(io::Error::new(
|
||||
ErrorKind::NotFound,
|
||||
format!(
|
||||
"'{}' is not available on PATH or failed to execute",
|
||||
program.to_string_lossy()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(new_xplatform_command(&program))
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
pub mod command;
|
||||
pub mod platform;
|
||||
pub mod serde;
|
||||
@@ -1,30 +0,0 @@
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn get_bool(v: &Value, key: &str, fallback: bool) -> bool {
|
||||
match v.get(key) {
|
||||
None => fallback,
|
||||
Some(v) => v.as_bool().unwrap_or(fallback),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_str<'a>(v: &'a Value, key: &str) -> &'a str {
|
||||
match v.get(key) {
|
||||
None => "",
|
||||
Some(v) => v.as_str().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_str_map<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> &'a str {
|
||||
match v.get(key) {
|
||||
None => "",
|
||||
Some(v) => v.as_str().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_bool_map(v: &BTreeMap<String, Value>, key: &str, fallback: bool) -> bool {
|
||||
match v.get(key) {
|
||||
None => fallback,
|
||||
Some(v) => v.as_bool().unwrap_or(fallback),
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-core"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
@@ -1,56 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Context for a workspace operation.
|
||||
///
|
||||
/// In Tauri, this is extracted from the WebviewWindow URL.
|
||||
/// In CLI, this is constructed from command arguments or config.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorkspaceContext {
|
||||
pub workspace_id: Option<String>,
|
||||
pub environment_id: Option<String>,
|
||||
pub cookie_jar_id: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceContext {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_workspace(mut self, workspace_id: impl Into<String>) -> Self {
|
||||
self.workspace_id = Some(workspace_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_environment(mut self, environment_id: impl Into<String>) -> Self {
|
||||
self.environment_id = Some(environment_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cookie_jar(mut self, cookie_jar_id: impl Into<String>) -> Self {
|
||||
self.cookie_jar_id = Some(cookie_jar_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_request(mut self, request_id: impl Into<String>) -> Self {
|
||||
self.request_id = Some(request_id.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Application context trait for accessing app-level resources.
|
||||
///
|
||||
/// This abstracts over Tauri's `AppHandle` for path resolution and app identity.
|
||||
/// Implemented by Tauri's AppHandle and by CLI's own context struct.
|
||||
pub trait AppContext: Send + Sync + Clone {
|
||||
/// Returns the path to the application data directory.
|
||||
/// This is where the database and other persistent data are stored.
|
||||
fn app_data_dir(&self) -> PathBuf;
|
||||
|
||||
/// Returns the application identifier (e.g., "app.yaak.desktop").
|
||||
/// Used for keyring access and other platform-specific features.
|
||||
fn app_identifier(&self) -> &str;
|
||||
|
||||
/// Returns true if running in development mode.
|
||||
fn is_dev(&self) -> bool;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Missing required context: {0}")]
|
||||
MissingContext(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! Core abstractions for Yaak that work without Tauri.
|
||||
//!
|
||||
//! This crate provides foundational types and traits that allow Yaak's
|
||||
//! business logic to run in both Tauri (desktop app) and CLI contexts.
|
||||
|
||||
mod context;
|
||||
mod error;
|
||||
|
||||
pub use context::{AppContext, WorkspaceContext};
|
||||
pub use error::{Error, Result};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export function enableEncryption(workspaceId: string) {
|
||||
return invoke<void>('cmd_enable_encryption', { workspaceId });
|
||||
}
|
||||
|
||||
export function revealWorkspaceKey(workspaceId: string) {
|
||||
return invoke<string>('cmd_reveal_workspace_key', { workspaceId });
|
||||
}
|
||||
|
||||
export function setWorkspaceKey(args: { workspaceId: string; key: string }) {
|
||||
return invoke<void>('cmd_set_workspace_key', args);
|
||||
}
|
||||
|
||||
export function disableEncryption(workspaceId: string) {
|
||||
return invoke<void>('cmd_disable_encryption', { workspaceId });
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
extern crate core;
|
||||
|
||||
pub mod encryption;
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
mod master_key;
|
||||
mod workspace_key;
|
||||
@@ -1,261 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { createFastMutation } from '@yaakapp/app/hooks/useFastMutation';
|
||||
import { queryClient } from '@yaakapp/app/lib/queryClient';
|
||||
import { useMemo } from 'react';
|
||||
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
|
||||
import { showToast } from '@yaakapp/app/lib/toast';
|
||||
|
||||
export * from './bindings/gen_git';
|
||||
export * from './bindings/gen_models';
|
||||
|
||||
export interface GitCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type DivergedStrategy = 'force_reset' | 'merge' | 'cancel';
|
||||
|
||||
export type UncommittedChangesStrategy = 'reset' | 'cancel';
|
||||
|
||||
export interface GitCallbacks {
|
||||
addRemote: () => Promise<GitRemote | null>;
|
||||
promptCredentials: (
|
||||
result: Extract<PushResult, { type: 'needs_credentials' }>,
|
||||
) => Promise<GitCredentials | null>;
|
||||
promptDiverged: (
|
||||
result: Extract<PullResult, { type: 'diverged' }>,
|
||||
) => Promise<DivergedStrategy>;
|
||||
promptUncommittedChanges: () => Promise<UncommittedChangesStrategy>;
|
||||
forceSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
const onSuccess = () => queryClient.invalidateQueries({ queryKey: ['git'] });
|
||||
|
||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||
const mutations = useMemo(() => gitMutations(dir, callbacks), [dir, callbacks]);
|
||||
const fetchAll = useQuery<void, string>({
|
||||
queryKey: ['git', 'fetch_all', dir, refreshKey],
|
||||
queryFn: () => invoke('cmd_git_fetch_all', { dir }),
|
||||
refetchInterval: 10 * 60_000,
|
||||
});
|
||||
return [
|
||||
{
|
||||
remotes: useQuery<GitRemote[], string>({
|
||||
queryKey: ['git', 'remotes', dir, refreshKey],
|
||||
queryFn: () => getRemotes(dir),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
log: useQuery<GitCommit[], string>({
|
||||
queryKey: ['git', 'log', dir, refreshKey],
|
||||
queryFn: () => invoke('cmd_git_log', { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
status: useQuery<GitStatusSummary, string>({
|
||||
refetchOnMount: true,
|
||||
queryKey: ['git', 'status', dir, refreshKey, fetchAll.dataUpdatedAt],
|
||||
queryFn: () => invoke('cmd_git_status', { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
},
|
||||
mutations,
|
||||
] as const;
|
||||
}
|
||||
|
||||
export const gitMutations = (dir: string, callbacks: GitCallbacks) => {
|
||||
const push = async () => {
|
||||
const remotes = await getRemotes(dir);
|
||||
if (remotes.length === 0) {
|
||||
const remote = await callbacks.addRemote();
|
||||
if (remote == null) throw new Error('No remote found');
|
||||
}
|
||||
|
||||
const result = await invoke<PushResult>('cmd_git_push', { dir });
|
||||
if (result.type !== 'needs_credentials') return result;
|
||||
|
||||
// Needs credentials, prompt for them
|
||||
const creds = await callbacks.promptCredentials(result);
|
||||
if (creds == null) throw new Error('Canceled');
|
||||
|
||||
await invoke('cmd_git_add_credential', {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
// Push again
|
||||
return invoke<PushResult>('cmd_git_push', { dir });
|
||||
};
|
||||
|
||||
const handleError = (err: unknown) => {
|
||||
showToast({
|
||||
id: `${err}`,
|
||||
message: `${err}`,
|
||||
color: 'danger',
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
init: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'init'],
|
||||
mutationFn: () => invoke('cmd_git_initialize', { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
add: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||
mutationKey: ['git', 'add', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_add', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
addRemote: createFastMutation<GitRemote, string, GitRemote>({
|
||||
mutationKey: ['git', 'add-remote'],
|
||||
mutationFn: (args) => invoke('cmd_git_add_remote', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
rmRemote: createFastMutation<void, string, { name: string }>({
|
||||
mutationKey: ['git', 'rm-remote', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_rm_remote', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
createBranch: createFastMutation<void, string, { branch: string; base?: string }>({
|
||||
mutationKey: ['git', 'branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
mergeBranch: createFastMutation<void, string, { branch: string }>({
|
||||
mutationKey: ['git', 'merge', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_merge_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
deleteBranch: createFastMutation<BranchDeleteResult, string, { branch: string, force?: boolean }>({
|
||||
mutationKey: ['git', 'delete-branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_delete_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
deleteRemoteBranch: createFastMutation<void, string, { branch: string }>({
|
||||
mutationKey: ['git', 'delete-remote-branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_delete_remote_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
renameBranch: createFastMutation<void, string, { oldName: string, newName: string }>({
|
||||
mutationKey: ['git', 'rename-branch', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_rename_branch', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
checkout: createFastMutation<string, string, { branch: string; force: boolean }>({
|
||||
mutationKey: ['git', 'checkout', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_checkout', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
commit: createFastMutation<void, string, { message: string }>({
|
||||
mutationKey: ['git', 'commit', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_commit', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
commitAndPush: createFastMutation<PushResult, string, { message: string }>({
|
||||
mutationKey: ['git', 'commit_push', dir],
|
||||
mutationFn: async (args) => {
|
||||
await invoke('cmd_git_commit', { dir, ...args });
|
||||
return push();
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
|
||||
push: createFastMutation<PushResult, string, void>({
|
||||
mutationKey: ['git', 'push', dir],
|
||||
mutationFn: push,
|
||||
onSuccess,
|
||||
}),
|
||||
pull: createFastMutation<PullResult, string, void>({
|
||||
mutationKey: ['git', 'pull', dir],
|
||||
async mutationFn() {
|
||||
const result = await invoke<PullResult>('cmd_git_pull', { dir });
|
||||
|
||||
if (result.type === 'needs_credentials') {
|
||||
const creds = await callbacks.promptCredentials(result);
|
||||
if (creds == null) throw new Error('Canceled');
|
||||
|
||||
await invoke('cmd_git_add_credential', {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
// Pull again after credentials
|
||||
return invoke<PullResult>('cmd_git_pull', { dir });
|
||||
}
|
||||
|
||||
if (result.type === 'uncommitted_changes') {
|
||||
callbacks.promptUncommittedChanges().then(async (strategy) => {
|
||||
if (strategy === 'cancel') return;
|
||||
|
||||
await invoke('cmd_git_reset_changes', { dir });
|
||||
return invoke<PullResult>('cmd_git_pull', { dir });
|
||||
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
|
||||
}
|
||||
|
||||
if (result.type === 'diverged') {
|
||||
callbacks.promptDiverged(result).then((strategy) => {
|
||||
if (strategy === 'cancel') return;
|
||||
|
||||
if (strategy === 'force_reset') {
|
||||
return invoke<PullResult>('cmd_git_pull_force_reset', {
|
||||
dir,
|
||||
remote: result.remote,
|
||||
branch: result.branch,
|
||||
});
|
||||
}
|
||||
|
||||
return invoke<PullResult>('cmd_git_pull_merge', {
|
||||
dir,
|
||||
remote: result.remote,
|
||||
branch: result.branch,
|
||||
});
|
||||
}).then(async () => { onSuccess(); await callbacks.forceSync(); }, handleError);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
onSuccess,
|
||||
}),
|
||||
unstage: createFastMutation<void, string, { relaPaths: string[] }>({
|
||||
mutationKey: ['git', 'unstage', dir],
|
||||
mutationFn: (args) => invoke('cmd_git_unstage', { dir, ...args }),
|
||||
onSuccess,
|
||||
}),
|
||||
resetChanges: createFastMutation<void, string, void>({
|
||||
mutationKey: ['git', 'reset-changes', dir],
|
||||
mutationFn: () => invoke('cmd_git_reset_changes', { dir }),
|
||||
onSuccess,
|
||||
}),
|
||||
} as const;
|
||||
};
|
||||
|
||||
async function getRemotes(dir: string) {
|
||||
return invoke<GitRemote[]>('cmd_git_remotes', { dir });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a git repository, prompting for credentials if needed.
|
||||
*/
|
||||
export async function gitClone(
|
||||
url: string,
|
||||
dir: string,
|
||||
promptCredentials: (args: { url: string; error: string | null }) => Promise<GitCredentials | null>,
|
||||
): Promise<CloneResult> {
|
||||
const result = await invoke<CloneResult>('cmd_git_clone', { url, dir });
|
||||
if (result.type !== 'needs_credentials') return result;
|
||||
|
||||
// Prompt for credentials
|
||||
const creds = await promptCredentials({ url: result.url, error: result.error });
|
||||
if (creds == null) return {type: 'cancelled'};
|
||||
|
||||
// Store credentials and retry
|
||||
await invoke('cmd_git_add_credential', {
|
||||
remoteUrl: result.url,
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
});
|
||||
|
||||
return invoke<CloneResult>('cmd_git_clone', { url, dir });
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use crate::error::Error::GitNotFound;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
use yaak_common::command::new_checked_command;
|
||||
|
||||
/// Create a git command that runs in the specified directory
|
||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
||||
let mut cmd = new_binary_command_global().await?;
|
||||
cmd.arg("-C").arg(dir);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
/// Create a git command without a specific directory (for global operations)
|
||||
pub(crate) async fn new_binary_command_global() -> Result<Command> {
|
||||
new_checked_command("git", "--version").await.map_err(|_| GitNotFound)
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum BranchDeleteResult {
|
||||
Success { message: String },
|
||||
NotFullyMerged,
|
||||
}
|
||||
|
||||
pub async fn git_checkout_branch(dir: &Path, branch_name: &str, force: bool) -> Result<String> {
|
||||
let branch_name = branch_name.trim_start_matches("origin/");
|
||||
|
||||
let mut args = vec!["checkout"];
|
||||
if force {
|
||||
args.push("--force");
|
||||
}
|
||||
args.push(branch_name);
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git checkout: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to checkout: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(branch_name.to_string())
|
||||
}
|
||||
|
||||
pub async fn git_create_branch(dir: &Path, name: &str, base: Option<&str>) -> Result<()> {
|
||||
let mut cmd = new_binary_command(dir).await?;
|
||||
cmd.arg("branch").arg(name);
|
||||
if let Some(base_branch) = base {
|
||||
cmd.arg(base_branch);
|
||||
}
|
||||
|
||||
let out =
|
||||
cmd.output().await.map_err(|e| GenericError(format!("failed to run git branch: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to create branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_delete_branch(dir: &Path, name: &str, force: bool) -> Result<BranchDeleteResult> {
|
||||
let mut cmd = new_binary_command(dir).await?;
|
||||
|
||||
let out =
|
||||
if force { cmd.args(["branch", "-D", name]) } else { cmd.args(["branch", "-d", name]) }
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git branch -d: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() && stderr.to_lowercase().contains("not fully merged") {
|
||||
return Ok(BranchDeleteResult::NotFullyMerged);
|
||||
}
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to delete branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(BranchDeleteResult::Success { message: combined })
|
||||
}
|
||||
|
||||
pub async fn git_merge_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["merge", name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
// Check for merge conflicts
|
||||
if combined.to_lowercase().contains("conflict") {
|
||||
return Err(GenericError(
|
||||
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_delete_remote_branch(dir: &Path, name: &str) -> Result<()> {
|
||||
// Remote branch names come in as "origin/branch-name", extract the branch name
|
||||
let branch_name = name.trim_start_matches("origin/");
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["push", "origin", "--delete", branch_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git push --delete: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to delete remote branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_rename_branch(dir: &Path, old_name: &str, new_name: &str) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["branch", "-m", old_name, new_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git branch -m: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
if !out.status.success() {
|
||||
return Err(GenericError(format!("Failed to rename branch: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum CloneResult {
|
||||
Success,
|
||||
Cancelled,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
}
|
||||
|
||||
pub async fn git_clone(url: &str, dir: &Path) -> Result<CloneResult> {
|
||||
let parent = dir.parent().ok_or_else(|| GenericError("Invalid clone directory".to_string()))?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| GenericError(format!("Failed to create directory: {e}")))?;
|
||||
let mut cmd = new_binary_command(parent).await?;
|
||||
cmd.args(["clone", url]).arg(dir).env("GIT_TERMINAL_PROMPT", "0");
|
||||
|
||||
let out =
|
||||
cmd.output().await.map_err(|e| GenericError(format!("failed to run git clone: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
let combined_lower = combined.to_lowercase();
|
||||
|
||||
info!("Cloned status={}: {combined}", out.status);
|
||||
|
||||
if !out.status.success() {
|
||||
// Check for credentials error
|
||||
if combined_lower.contains("could not read") {
|
||||
return Ok(CloneResult::NeedsCredentials { url: url.to_string(), error: None });
|
||||
}
|
||||
if combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
{
|
||||
return Ok(CloneResult::NeedsCredentials {
|
||||
url: url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to clone: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(CloneResult::Success)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use crate::binary::new_binary_command_global;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use url::Url;
|
||||
|
||||
pub async fn git_add_credential(remote_url: &str, username: &str, password: &str) -> Result<()> {
|
||||
let url = Url::parse(remote_url)
|
||||
.map_err(|e| GenericError(format!("Failed to parse remote url {remote_url}: {e:?}")))?;
|
||||
let protocol = url.scheme();
|
||||
let host = url.host_str().unwrap();
|
||||
let path = Some(url.path());
|
||||
|
||||
let mut child = new_binary_command_global()
|
||||
.await?
|
||||
.args(["credential", "approve"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
stdin.write_all(format!("protocol={}\n", protocol).as_bytes()).await?;
|
||||
stdin.write_all(format!("host={}\n", host).as_bytes()).await?;
|
||||
if let Some(path) = path {
|
||||
if !path.is_empty() {
|
||||
stdin
|
||||
.write_all(format!("path={}\n", path.trim_start_matches('/')).as_bytes())
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
stdin.write_all(format!("username={}\n", username).as_bytes()).await?;
|
||||
stdin.write_all(format!("password={}\n", password).as_bytes()).await?;
|
||||
stdin.write_all(b"\n").await?; // blank line terminator
|
||||
}
|
||||
|
||||
let status = child.wait().await?;
|
||||
if !status.success() {
|
||||
return Err(GenericError("Failed to approve git credential".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
mod add;
|
||||
mod binary;
|
||||
mod branch;
|
||||
mod clone;
|
||||
mod commit;
|
||||
mod credential;
|
||||
pub mod error;
|
||||
mod fetch;
|
||||
mod init;
|
||||
mod log;
|
||||
|
||||
mod pull;
|
||||
mod push;
|
||||
mod remotes;
|
||||
mod repository;
|
||||
mod reset;
|
||||
mod status;
|
||||
mod unstage;
|
||||
mod util;
|
||||
|
||||
// Re-export all git functions for external use
|
||||
pub use add::git_add;
|
||||
pub use branch::{
|
||||
BranchDeleteResult, git_checkout_branch, git_create_branch, git_delete_branch,
|
||||
git_delete_remote_branch, git_merge_branch, git_rename_branch,
|
||||
};
|
||||
pub use clone::{CloneResult, git_clone};
|
||||
pub use commit::git_commit;
|
||||
pub use credential::git_add_credential;
|
||||
pub use fetch::git_fetch_all;
|
||||
pub use init::git_init;
|
||||
pub use log::{GitCommit, git_log};
|
||||
pub use pull::{PullResult, git_pull, git_pull_force_reset, git_pull_merge};
|
||||
pub use push::{PushResult, git_push};
|
||||
pub use remotes::{GitRemote, git_add_remote, git_remotes, git_rm_remote};
|
||||
pub use reset::git_reset_changes;
|
||||
pub use status::{GitStatusSummary, git_status};
|
||||
pub use unstage::git_unstage;
|
||||
@@ -1,205 +0,0 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use crate::util::{get_current_branch_name, get_default_remote_in_repo};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum PullResult {
|
||||
Success { message: String },
|
||||
UpToDate,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
Diverged { remote: String, branch: String },
|
||||
UncommittedChanges,
|
||||
}
|
||||
|
||||
fn has_uncommitted_changes(dir: &Path) -> Result<bool> {
|
||||
let repo = open_repo(dir)?;
|
||||
let mut opts = git2::StatusOptions::new();
|
||||
opts.include_ignored(false).include_untracked(false);
|
||||
let statuses = repo.statuses(Some(&mut opts))?;
|
||||
Ok(statuses.iter().any(|e| e.status() != git2::Status::CURRENT))
|
||||
}
|
||||
|
||||
pub async fn git_pull(dir: &Path) -> Result<PullResult> {
|
||||
if has_uncommitted_changes(dir)? {
|
||||
return Ok(PullResult::UncommittedChanges);
|
||||
}
|
||||
|
||||
// Extract all git2 data before any await points (git2 types are not Send)
|
||||
let (branch_name, remote_name, remote_url) = {
|
||||
let repo = open_repo(dir)?;
|
||||
let branch_name = get_current_branch_name(&repo)?;
|
||||
let remote = get_default_remote_in_repo(&repo)?;
|
||||
let remote_name =
|
||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
||||
let remote_url =
|
||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
||||
(branch_name, remote_name, remote_url)
|
||||
};
|
||||
|
||||
// Step 1: fetch the specific branch
|
||||
// NOTE: We use fetch + merge instead of `git pull` to avoid conflicts with
|
||||
// global git config (e.g. pull.ff=only) and the background fetch --all.
|
||||
let fetch_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["fetch", &remote_name, &branch_name])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
|
||||
|
||||
let fetch_stdout = String::from_utf8_lossy(&fetch_out.stdout);
|
||||
let fetch_stderr = String::from_utf8_lossy(&fetch_out.stderr);
|
||||
let fetch_combined = format!("{fetch_stdout}{fetch_stderr}");
|
||||
|
||||
info!("Fetched status={} {fetch_combined}", fetch_out.status);
|
||||
|
||||
if fetch_combined.to_lowercase().contains("could not read") {
|
||||
return Ok(PullResult::NeedsCredentials { url: remote_url.to_string(), error: None });
|
||||
}
|
||||
|
||||
if fetch_combined.to_lowercase().contains("unable to access") {
|
||||
return Ok(PullResult::NeedsCredentials {
|
||||
url: remote_url.to_string(),
|
||||
error: Some(fetch_combined.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
if !fetch_out.status.success() {
|
||||
return Err(GenericError(format!("Failed to fetch: {fetch_combined}")));
|
||||
}
|
||||
|
||||
// Step 2: merge the fetched branch
|
||||
let ref_name = format!("{}/{}", remote_name, branch_name);
|
||||
let merge_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["merge", "--ff-only", &ref_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git merge: {e}")))?;
|
||||
|
||||
let merge_stdout = String::from_utf8_lossy(&merge_out.stdout);
|
||||
let merge_stderr = String::from_utf8_lossy(&merge_out.stderr);
|
||||
let merge_combined = format!("{merge_stdout}{merge_stderr}");
|
||||
|
||||
info!("Merged status={} {merge_combined}", merge_out.status);
|
||||
|
||||
if !merge_out.status.success() {
|
||||
let merge_lower = merge_combined.to_lowercase();
|
||||
if merge_lower.contains("cannot fast-forward")
|
||||
|| merge_lower.contains("not possible to fast-forward")
|
||||
|| merge_lower.contains("diverged")
|
||||
{
|
||||
return Ok(PullResult::Diverged { remote: remote_name, branch: branch_name });
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge: {merge_combined}")));
|
||||
}
|
||||
|
||||
if merge_combined.to_lowercase().contains("up to date") {
|
||||
return Ok(PullResult::UpToDate);
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Pulled from {}/{}", remote_name, branch_name) })
|
||||
}
|
||||
|
||||
pub async fn git_pull_force_reset(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
// Step 1: fetch the remote
|
||||
let fetch_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["fetch", remote])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git fetch: {e}")))?;
|
||||
|
||||
if !fetch_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&fetch_out.stderr);
|
||||
return Err(GenericError(format!("Failed to fetch: {stderr}")));
|
||||
}
|
||||
|
||||
// Step 2: reset --hard to remote/branch
|
||||
let ref_name = format!("{}/{}", remote, branch);
|
||||
let reset_out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["reset", "--hard", &ref_name])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
|
||||
|
||||
if !reset_out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&reset_out.stderr);
|
||||
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Reset to {}/{}", remote, branch) })
|
||||
}
|
||||
|
||||
pub async fn git_pull_merge(dir: &Path, remote: &str, branch: &str) -> Result<PullResult> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["pull", "--no-rebase", remote, branch])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git pull --no-rebase: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
info!("Pull merge status={} {combined}", out.status);
|
||||
|
||||
if !out.status.success() {
|
||||
if combined.to_lowercase().contains("conflict") {
|
||||
return Err(GenericError(
|
||||
"Merge conflicts detected. Please resolve them manually.".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(GenericError(format!("Failed to merge pull: {}", combined.trim())));
|
||||
}
|
||||
|
||||
Ok(PullResult::Success { message: format!("Merged from {}/{}", remote, branch) })
|
||||
}
|
||||
|
||||
// pub(crate) fn git_pull_old(dir: &Path) -> Result<PullResult> {
|
||||
// let repo = open_repo(dir)?;
|
||||
//
|
||||
// let branch = get_current_branch(&repo)?.ok_or(NoActiveBranch)?;
|
||||
// let branch_ref = branch.get();
|
||||
// let branch_ref = bytes_to_string(branch_ref.name_bytes())?;
|
||||
//
|
||||
// let remote_name = repo.branch_upstream_remote(&branch_ref)?;
|
||||
// let remote_name = bytes_to_string(&remote_name)?;
|
||||
// debug!("Pulling from {remote_name}");
|
||||
//
|
||||
// let mut remote = repo.find_remote(&remote_name)?;
|
||||
//
|
||||
// let mut options = FetchOptions::new();
|
||||
// let callbacks = default_callbacks();
|
||||
// options.remote_callbacks(callbacks);
|
||||
//
|
||||
// let mut proxy = ProxyOptions::new();
|
||||
// proxy.auto();
|
||||
// options.proxy_options(proxy);
|
||||
//
|
||||
// remote.fetch(&[&branch_ref], Some(&mut options), None)?;
|
||||
//
|
||||
// let stats = remote.stats();
|
||||
//
|
||||
// let fetch_head = repo.find_reference("FETCH_HEAD")?;
|
||||
// let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
|
||||
// do_merge(&repo, &branch, &fetch_commit)?;
|
||||
//
|
||||
// Ok(PullResult::Success {
|
||||
// message: "Hello".to_string(),
|
||||
// // received_bytes: stats.received_bytes(),
|
||||
// // received_objects: stats.received_objects(),
|
||||
// })
|
||||
// }
|
||||
@@ -1,89 +0,0 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::repository::open_repo;
|
||||
use crate::util::{get_current_branch_name, get_default_remote_for_push_in_repo};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case", tag = "type")]
|
||||
#[ts(export, export_to = "gen_git.ts")]
|
||||
pub enum PushResult {
|
||||
Success { message: String },
|
||||
UpToDate,
|
||||
NeedsCredentials { url: String, error: Option<String> },
|
||||
}
|
||||
|
||||
pub async fn git_push(dir: &Path) -> Result<PushResult> {
|
||||
// Extract all git2 data before any await points (git2 types are not Send)
|
||||
let (branch_name, remote_name, remote_url) = {
|
||||
let repo = open_repo(dir)?;
|
||||
let branch_name = get_current_branch_name(&repo)?;
|
||||
let remote = get_default_remote_for_push_in_repo(&repo)?;
|
||||
let remote_name =
|
||||
remote.name().ok_or(GenericError("Failed to get remote name".to_string()))?.to_string();
|
||||
let remote_url =
|
||||
remote.url().ok_or(GenericError("Failed to get remote url".to_string()))?.to_string();
|
||||
(branch_name, remote_name, remote_url)
|
||||
};
|
||||
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["push", &remote_name, &branch_name])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git push: {e}")))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let combined = stdout + stderr;
|
||||
let combined_lower = combined.to_lowercase();
|
||||
|
||||
info!("Pushed to repo status={} {combined}", out.status);
|
||||
|
||||
// Helper to check if this is a credentials error
|
||||
let is_credentials_error = || {
|
||||
combined_lower.contains("could not read")
|
||||
|| combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
};
|
||||
|
||||
// Check for explicit rejection indicators first (e.g., protected branch rejections)
|
||||
// These can occur even if some git servers don't properly set exit codes
|
||||
if combined_lower.contains("rejected") || combined_lower.contains("failed to push") {
|
||||
if is_credentials_error() {
|
||||
return Ok(PushResult::NeedsCredentials {
|
||||
url: remote_url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to push: {combined}")));
|
||||
}
|
||||
|
||||
// Check exit status for any other failures
|
||||
if !out.status.success() {
|
||||
if combined_lower.contains("could not read") {
|
||||
return Ok(PushResult::NeedsCredentials { url: remote_url.to_string(), error: None });
|
||||
}
|
||||
if combined_lower.contains("unable to access")
|
||||
|| combined_lower.contains("authentication failed")
|
||||
{
|
||||
return Ok(PushResult::NeedsCredentials {
|
||||
url: remote_url.to_string(),
|
||||
error: Some(combined.to_string()),
|
||||
});
|
||||
}
|
||||
return Err(GenericError(format!("Failed to push: {combined}")));
|
||||
}
|
||||
|
||||
// Success cases (exit code 0 and no rejection indicators)
|
||||
if combined_lower.contains("up-to-date") {
|
||||
return Ok(PushResult::UpToDate);
|
||||
}
|
||||
|
||||
Ok(PushResult::Success { message: format!("Pushed to {}/{}", remote_name, branch_name) })
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use crate::binary::new_binary_command;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn git_reset_changes(dir: &Path) -> Result<()> {
|
||||
let out = new_binary_command(dir)
|
||||
.await?
|
||||
.args(["reset", "--hard", "HEAD"])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| GenericError(format!("failed to run git reset: {e}")))?;
|
||||
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
return Err(GenericError(format!("Failed to reset: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use crate::manager::GrpcStreamError;
|
||||
use prost::DecodeError;
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::Error as SerdeJsonError;
|
||||
use std::io;
|
||||
use thiserror::Error;
|
||||
use tonic::Status;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
TlsError(#[from] yaak_tls::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
TonicError(#[from] Status),
|
||||
|
||||
#[error("Prost reflect error: {0:?}")]
|
||||
ProstReflectError(#[from] prost_reflect::DescriptorError),
|
||||
|
||||
#[error(transparent)]
|
||||
DeserializerError(#[from] SerdeJsonError),
|
||||
|
||||
#[error(transparent)]
|
||||
GrpcStreamError(#[from] GrpcStreamError),
|
||||
|
||||
#[error(transparent)]
|
||||
GrpcDecodeError(#[from] DecodeError),
|
||||
|
||||
#[error(transparent)]
|
||||
GrpcInvalidMetadataKeyError(#[from] tonic::metadata::errors::InvalidMetadataKey),
|
||||
|
||||
#[error(transparent)]
|
||||
GrpcInvalidMetadataValueError(#[from] tonic::metadata::errors::InvalidMetadataValue),
|
||||
|
||||
#[error(transparent)]
|
||||
IOError(#[from] io::Error),
|
||||
|
||||
#[error("GRPC error: {0}")]
|
||||
GenericError(String),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -1,40 +0,0 @@
|
||||
use crate::error::Result;
|
||||
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
|
||||
use hyper_util::client::legacy::Client;
|
||||
use hyper_util::client::legacy::connect::HttpConnector;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
use log::info;
|
||||
use tonic::body::BoxBody;
|
||||
use yaak_tls::{ClientCertificateConfig, get_tls_config};
|
||||
|
||||
// I think ALPN breaks this because we're specifying http2_only
|
||||
const WITH_ALPN: bool = false;
|
||||
|
||||
pub(crate) fn get_transport(
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Client<HttpsConnector<HttpConnector>, BoxBody>> {
|
||||
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
|
||||
|
||||
let mut http = HttpConnector::new();
|
||||
http.enforce_http(false);
|
||||
|
||||
let connector = HttpsConnectorBuilder::new()
|
||||
.with_tls_config(tls_config)
|
||||
.https_or_http()
|
||||
.enable_http2()
|
||||
.build();
|
||||
|
||||
let client = Client::builder(TokioExecutor::new())
|
||||
.pool_max_idle_per_host(0)
|
||||
.http2_only(true)
|
||||
.build(connector);
|
||||
|
||||
info!(
|
||||
"Created gRPC client validate_certs={} client_cert={}",
|
||||
validate_certificates,
|
||||
client_cert.is_some()
|
||||
);
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
[package]
|
||||
name = "yaak-http"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
async-compression = { version = "0.4", features = ["tokio", "gzip", "deflate", "brotli", "zstd"] }
|
||||
async-trait = "0.1"
|
||||
brotli = "7"
|
||||
bytes = "1.11.1"
|
||||
cookie = "0.18.1"
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
http-body = "1"
|
||||
url = "2"
|
||||
zstd = "0.13"
|
||||
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }
|
||||
log = { workspace = true }
|
||||
mime_guess = "2.0.5"
|
||||
regex = "1.11.1"
|
||||
reqwest = { workspace = true, features = ["rustls-tls-manual-roots-no-provider", "socks", "http2", "stream"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "fs", "io-util"] }
|
||||
tokio-util = { version = "0.7", features = ["codec", "io", "io-util"] }
|
||||
tower-service = "0.3.3"
|
||||
urlencoding = "2.1.3"
|
||||
yaak-common = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
@@ -1,78 +0,0 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
|
||||
/// A stream that chains multiple AsyncRead sources together
|
||||
pub(crate) struct ChainedReader {
|
||||
readers: Vec<ReaderType>,
|
||||
current_index: usize,
|
||||
current_reader: Option<Box<dyn AsyncRead + Send + Unpin + 'static>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum ReaderType {
|
||||
Bytes(Vec<u8>),
|
||||
FilePath(String),
|
||||
}
|
||||
|
||||
impl ChainedReader {
|
||||
pub(crate) fn new(readers: Vec<ReaderType>) -> Self {
|
||||
Self { readers, current_index: 0, current_reader: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncRead for ChainedReader {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
loop {
|
||||
// Try to read from current reader if we have one
|
||||
if let Some(ref mut reader) = self.current_reader {
|
||||
let before_len = buf.filled().len();
|
||||
return match Pin::new(reader).poll_read(cx, buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
if buf.filled().len() == before_len && buf.remaining() > 0 {
|
||||
// Current reader is exhausted, move to next
|
||||
self.current_reader = None;
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
};
|
||||
}
|
||||
|
||||
// We need to get the next reader
|
||||
if self.current_index >= self.readers.len() {
|
||||
// No more readers
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
|
||||
// Get the next reader
|
||||
let reader_type = self.readers[self.current_index].clone();
|
||||
self.current_index += 1;
|
||||
|
||||
match reader_type {
|
||||
ReaderType::Bytes(bytes) => {
|
||||
self.current_reader = Some(Box::new(io::Cursor::new(bytes)));
|
||||
}
|
||||
ReaderType::FilePath(path) => {
|
||||
// We need to handle file opening synchronously in poll_read
|
||||
// This is a limitation - we'll use blocking file open
|
||||
match std::fs::File::open(&path) {
|
||||
Ok(file) => {
|
||||
// Convert std File to tokio File
|
||||
let tokio_file = tokio::fs::File::from_std(file);
|
||||
self.current_reader = Some(Box::new(tokio_file));
|
||||
}
|
||||
Err(e) => return Poll::Ready(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
//! Custom cookie handling for HTTP requests
|
||||
//!
|
||||
//! This module provides cookie storage and matching functionality that was previously
|
||||
//! delegated to reqwest. It implements RFC 6265 cookie domain and path matching.
|
||||
|
||||
use log::debug;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
use yaak_models::models::{Cookie, CookieDomain, CookieExpires};
|
||||
|
||||
/// A thread-safe cookie store that can be shared across requests
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CookieStore {
|
||||
cookies: Arc<Mutex<Vec<Cookie>>>,
|
||||
}
|
||||
|
||||
impl Default for CookieStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CookieStore {
|
||||
/// Create a new empty cookie store
|
||||
pub fn new() -> Self {
|
||||
Self { cookies: Arc::new(Mutex::new(Vec::new())) }
|
||||
}
|
||||
|
||||
/// Create a cookie store from existing cookies
|
||||
pub fn from_cookies(cookies: Vec<Cookie>) -> Self {
|
||||
Self { cookies: Arc::new(Mutex::new(cookies)) }
|
||||
}
|
||||
|
||||
/// Get all cookies (for persistence)
|
||||
pub fn get_all_cookies(&self) -> Vec<Cookie> {
|
||||
self.cookies.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Get the Cookie header value for the given URL
|
||||
pub fn get_cookie_header(&self, url: &Url) -> Option<String> {
|
||||
let cookies = self.cookies.lock().unwrap();
|
||||
let now = SystemTime::now();
|
||||
|
||||
let matching_cookies: Vec<_> = cookies
|
||||
.iter()
|
||||
.filter(|cookie| self.cookie_matches(cookie, url, &now))
|
||||
.filter_map(|cookie| {
|
||||
// Parse the raw cookie to get name=value
|
||||
parse_cookie_name_value(&cookie.raw_cookie)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if matching_cookies.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
matching_cookies
|
||||
.into_iter()
|
||||
.map(|(name, value)| format!("{}={}", name, value))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Set-Cookie headers and add cookies to the store
|
||||
pub fn store_cookies_from_response(&self, url: &Url, set_cookie_headers: &[String]) {
|
||||
let mut cookies = self.cookies.lock().unwrap();
|
||||
|
||||
for header_value in set_cookie_headers {
|
||||
if let Some(cookie) = parse_set_cookie(header_value, url) {
|
||||
// Remove any existing cookie with the same name and domain
|
||||
cookies.retain(|existing| !cookies_match(existing, &cookie));
|
||||
debug!(
|
||||
"Storing cookie: {} for domain {:?}",
|
||||
parse_cookie_name_value(&cookie.raw_cookie)
|
||||
.map(|(n, _)| n)
|
||||
.unwrap_or_else(|| "unknown".to_string()),
|
||||
cookie.domain
|
||||
);
|
||||
cookies.push(cookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a cookie matches the given URL
|
||||
fn cookie_matches(&self, cookie: &Cookie, url: &Url, now: &SystemTime) -> bool {
|
||||
// Check expiration
|
||||
if let CookieExpires::AtUtc(expiry_str) = &cookie.expires {
|
||||
if let Ok(expiry) = parse_cookie_date(expiry_str) {
|
||||
if expiry < *now {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check domain
|
||||
let url_host = match url.host_str() {
|
||||
Some(h) => h.to_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let domain_matches = match &cookie.domain {
|
||||
CookieDomain::HostOnly(domain) => url_host == domain.to_lowercase(),
|
||||
CookieDomain::Suffix(domain) => {
|
||||
let domain_lower = domain.to_lowercase();
|
||||
url_host == domain_lower || url_host.ends_with(&format!(".{}", domain_lower))
|
||||
}
|
||||
// NotPresent and Empty should never occur in practice since we always set domain
|
||||
// when parsing Set-Cookie headers. Treat as non-matching to be safe.
|
||||
CookieDomain::NotPresent | CookieDomain::Empty => false,
|
||||
};
|
||||
|
||||
if !domain_matches {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check path
|
||||
let (cookie_path, _) = &cookie.path;
|
||||
let url_path = url.path();
|
||||
|
||||
path_matches(url_path, cookie_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse name=value from a cookie string (raw_cookie format)
|
||||
fn parse_cookie_name_value(raw_cookie: &str) -> Option<(String, String)> {
|
||||
// The raw_cookie typically looks like "name=value" or "name=value; attr1; attr2=..."
|
||||
let first_part = raw_cookie.split(';').next()?;
|
||||
let mut parts = first_part.splitn(2, '=');
|
||||
let name = parts.next()?.trim().to_string();
|
||||
let value = parts.next().unwrap_or("").trim().to_string();
|
||||
|
||||
if name.is_empty() { None } else { Some((name, value)) }
|
||||
}
|
||||
|
||||
/// Parse a Set-Cookie header into a Cookie
|
||||
fn parse_set_cookie(header_value: &str, request_url: &Url) -> Option<Cookie> {
|
||||
let parsed = cookie::Cookie::parse(header_value).ok()?;
|
||||
|
||||
let raw_cookie = format!("{}={}", parsed.name(), parsed.value());
|
||||
|
||||
// Determine domain
|
||||
let domain = if let Some(domain_attr) = parsed.domain() {
|
||||
// Domain attribute present - this is a suffix match
|
||||
let domain = domain_attr.trim_start_matches('.').to_lowercase();
|
||||
|
||||
// Reject single-component domains (TLDs) except localhost
|
||||
if is_single_component_domain(&domain) && !is_localhost(&domain) {
|
||||
debug!("Rejecting cookie with single-component domain: {}", domain);
|
||||
return None;
|
||||
}
|
||||
|
||||
CookieDomain::Suffix(domain)
|
||||
} else {
|
||||
// No domain attribute - host-only cookie
|
||||
CookieDomain::HostOnly(request_url.host_str().unwrap_or("").to_lowercase())
|
||||
};
|
||||
|
||||
// Determine expiration
|
||||
let expires = if let Some(max_age) = parsed.max_age() {
|
||||
let duration = Duration::from_secs(max_age.whole_seconds().max(0) as u64);
|
||||
let expiry = SystemTime::now() + duration;
|
||||
let expiry_secs = expiry.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
CookieExpires::AtUtc(format!("{}", expiry_secs))
|
||||
} else if let Some(expires_time) = parsed.expires() {
|
||||
match expires_time {
|
||||
cookie::Expiration::DateTime(dt) => {
|
||||
let timestamp = dt.unix_timestamp();
|
||||
CookieExpires::AtUtc(format!("{}", timestamp))
|
||||
}
|
||||
cookie::Expiration::Session => CookieExpires::SessionEnd,
|
||||
}
|
||||
} else {
|
||||
CookieExpires::SessionEnd
|
||||
};
|
||||
|
||||
// Determine path
|
||||
let path = if let Some(path_attr) = parsed.path() {
|
||||
(path_attr.to_string(), true)
|
||||
} else {
|
||||
// Default path is the directory of the request URI
|
||||
let default_path = default_cookie_path(request_url.path());
|
||||
(default_path, false)
|
||||
};
|
||||
|
||||
Some(Cookie { raw_cookie, domain, expires, path })
|
||||
}
|
||||
|
||||
/// Get the default cookie path from a request path (RFC 6265 Section 5.1.4)
|
||||
fn default_cookie_path(request_path: &str) -> String {
|
||||
if request_path.is_empty() || !request_path.starts_with('/') {
|
||||
return "/".to_string();
|
||||
}
|
||||
|
||||
// Find the last slash
|
||||
if let Some(last_slash) = request_path.rfind('/') {
|
||||
if last_slash == 0 { "/".to_string() } else { request_path[..last_slash].to_string() }
|
||||
} else {
|
||||
"/".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a request path matches a cookie path (RFC 6265 Section 5.1.4)
|
||||
fn path_matches(request_path: &str, cookie_path: &str) -> bool {
|
||||
if request_path == cookie_path {
|
||||
return true;
|
||||
}
|
||||
|
||||
if request_path.starts_with(cookie_path) {
|
||||
// Cookie path must end with / or the char after cookie_path in request_path must be /
|
||||
if cookie_path.ends_with('/') {
|
||||
return true;
|
||||
}
|
||||
if request_path.chars().nth(cookie_path.len()) == Some('/') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if two cookies match (same name and domain)
|
||||
fn cookies_match(a: &Cookie, b: &Cookie) -> bool {
|
||||
let name_a = parse_cookie_name_value(&a.raw_cookie).map(|(n, _)| n);
|
||||
let name_b = parse_cookie_name_value(&b.raw_cookie).map(|(n, _)| n);
|
||||
|
||||
if name_a != name_b {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check domain match
|
||||
match (&a.domain, &b.domain) {
|
||||
(CookieDomain::HostOnly(d1), CookieDomain::HostOnly(d2)) => {
|
||||
d1.to_lowercase() == d2.to_lowercase()
|
||||
}
|
||||
(CookieDomain::Suffix(d1), CookieDomain::Suffix(d2)) => {
|
||||
d1.to_lowercase() == d2.to_lowercase()
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a cookie date string (Unix timestamp in our format)
|
||||
fn parse_cookie_date(date_str: &str) -> Result<SystemTime, ()> {
|
||||
let timestamp: i64 = date_str.parse().map_err(|_| ())?;
|
||||
let duration = Duration::from_secs(timestamp.max(0) as u64);
|
||||
Ok(UNIX_EPOCH + duration)
|
||||
}
|
||||
|
||||
/// Check if a domain is a single-component domain (TLD)
|
||||
/// e.g., "com", "org", "net" - domains without any dots
|
||||
fn is_single_component_domain(domain: &str) -> bool {
|
||||
// Empty or only dots
|
||||
let trimmed = domain.trim_matches('.');
|
||||
if trimmed.is_empty() {
|
||||
return true;
|
||||
}
|
||||
// IPv6 addresses use colons, not dots - don't consider them single-component
|
||||
if domain.contains(':') {
|
||||
return false;
|
||||
}
|
||||
!trimmed.contains('.')
|
||||
}
|
||||
|
||||
/// Check if a domain is localhost or a localhost variant
|
||||
fn is_localhost(domain: &str) -> bool {
|
||||
let lower = domain.to_lowercase();
|
||||
lower == "localhost"
|
||||
|| lower.ends_with(".localhost")
|
||||
|| lower == "127.0.0.1"
|
||||
|| lower == "::1"
|
||||
|| lower == "[::1]"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_cookie_name_value() {
|
||||
assert_eq!(
|
||||
parse_cookie_name_value("session=abc123"),
|
||||
Some(("session".to_string(), "abc123".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_cookie_name_value("name=value; Path=/; HttpOnly"),
|
||||
Some(("name".to_string(), "value".to_string()))
|
||||
);
|
||||
assert_eq!(parse_cookie_name_value("empty="), Some(("empty".to_string(), "".to_string())));
|
||||
assert_eq!(parse_cookie_name_value(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_matches() {
|
||||
assert!(path_matches("/", "/"));
|
||||
assert!(path_matches("/foo", "/"));
|
||||
assert!(path_matches("/foo/bar", "/foo"));
|
||||
assert!(path_matches("/foo/bar", "/foo/"));
|
||||
assert!(!path_matches("/foobar", "/foo"));
|
||||
assert!(!path_matches("/foo", "/foo/bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_cookie_path() {
|
||||
assert_eq!(default_cookie_path("/"), "/");
|
||||
assert_eq!(default_cookie_path("/foo"), "/");
|
||||
assert_eq!(default_cookie_path("/foo/bar"), "/foo");
|
||||
assert_eq!(default_cookie_path("/foo/bar/baz"), "/foo/bar");
|
||||
assert_eq!(default_cookie_path(""), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_store_basic() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("https://example.com/path").unwrap();
|
||||
|
||||
// Initially empty
|
||||
assert!(store.get_cookie_header(&url).is_none());
|
||||
|
||||
// Add a cookie
|
||||
store.store_cookies_from_response(&url, &["session=abc123".to_string()]);
|
||||
|
||||
// Should now have the cookie
|
||||
let header = store.get_cookie_header(&url);
|
||||
assert_eq!(header, Some("session=abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_domain_matching() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
|
||||
// Cookie with domain attribute (suffix match)
|
||||
store.store_cookies_from_response(
|
||||
&url,
|
||||
&["domain_cookie=value; Domain=example.com".to_string()],
|
||||
);
|
||||
|
||||
// Should match example.com
|
||||
assert!(store.get_cookie_header(&url).is_some());
|
||||
|
||||
// Should match subdomain
|
||||
let subdomain_url = Url::parse("https://sub.example.com/").unwrap();
|
||||
assert!(store.get_cookie_header(&subdomain_url).is_some());
|
||||
|
||||
// Should not match different domain
|
||||
let other_url = Url::parse("https://other.com/").unwrap();
|
||||
assert!(store.get_cookie_header(&other_url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_path_matching() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("https://example.com/api/v1").unwrap();
|
||||
|
||||
// Cookie with path
|
||||
store.store_cookies_from_response(&url, &["api_cookie=value; Path=/api".to_string()]);
|
||||
|
||||
// Should match /api/v1
|
||||
assert!(store.get_cookie_header(&url).is_some());
|
||||
|
||||
// Should match /api
|
||||
let api_url = Url::parse("https://example.com/api").unwrap();
|
||||
assert!(store.get_cookie_header(&api_url).is_some());
|
||||
|
||||
// Should not match /other
|
||||
let other_url = Url::parse("https://example.com/other").unwrap();
|
||||
assert!(store.get_cookie_header(&other_url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cookie_replacement() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
|
||||
// Add a cookie
|
||||
store.store_cookies_from_response(&url, &["session=old".to_string()]);
|
||||
assert_eq!(store.get_cookie_header(&url), Some("session=old".to_string()));
|
||||
|
||||
// Replace with new value
|
||||
store.store_cookies_from_response(&url, &["session=new".to_string()]);
|
||||
assert_eq!(store.get_cookie_header(&url), Some("session=new".to_string()));
|
||||
|
||||
// Should only have one cookie
|
||||
assert_eq!(store.get_all_cookies().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_single_component_domain() {
|
||||
// Single-component domains (TLDs)
|
||||
assert!(is_single_component_domain("com"));
|
||||
assert!(is_single_component_domain("org"));
|
||||
assert!(is_single_component_domain("net"));
|
||||
assert!(is_single_component_domain("localhost")); // Still single-component, but allowed separately
|
||||
|
||||
// Multi-component domains
|
||||
assert!(!is_single_component_domain("example.com"));
|
||||
assert!(!is_single_component_domain("sub.example.com"));
|
||||
assert!(!is_single_component_domain("co.uk"));
|
||||
|
||||
// Edge cases
|
||||
assert!(is_single_component_domain("")); // Empty is treated as single-component
|
||||
assert!(is_single_component_domain(".")); // Only dots
|
||||
assert!(is_single_component_domain("..")); // Only dots
|
||||
|
||||
// IPv6 addresses (have colons, not dots)
|
||||
assert!(!is_single_component_domain("::1")); // IPv6 localhost
|
||||
assert!(!is_single_component_domain("[::1]")); // Bracketed IPv6
|
||||
assert!(!is_single_component_domain("2001:db8::1")); // IPv6 address
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_localhost() {
|
||||
// Localhost variants
|
||||
assert!(is_localhost("localhost"));
|
||||
assert!(is_localhost("LOCALHOST")); // Case-insensitive
|
||||
assert!(is_localhost("sub.localhost"));
|
||||
assert!(is_localhost("app.sub.localhost"));
|
||||
|
||||
// IP localhost
|
||||
assert!(is_localhost("127.0.0.1"));
|
||||
assert!(is_localhost("::1"));
|
||||
assert!(is_localhost("[::1]"));
|
||||
|
||||
// Not localhost
|
||||
assert!(!is_localhost("example.com"));
|
||||
assert!(!is_localhost("localhost.com")); // .com domain, not localhost
|
||||
assert!(!is_localhost("notlocalhost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_tld_cookies() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
|
||||
// Try to set a cookie with Domain=com (TLD)
|
||||
store.store_cookies_from_response(&url, &["bad=cookie; Domain=com".to_string()]);
|
||||
|
||||
// Should be rejected - no cookies stored
|
||||
assert_eq!(store.get_all_cookies().len(), 0);
|
||||
assert!(store.get_cookie_header(&url).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_localhost_cookies() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("http://localhost:3000/").unwrap();
|
||||
|
||||
// Cookie with Domain=localhost should be allowed
|
||||
store.store_cookies_from_response(&url, &["session=abc; Domain=localhost".to_string()]);
|
||||
|
||||
// Should be accepted
|
||||
assert_eq!(store.get_all_cookies().len(), 1);
|
||||
assert!(store.get_cookie_header(&url).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_127_0_0_1_cookies() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("http://127.0.0.1:8080/").unwrap();
|
||||
|
||||
// Cookie without Domain attribute (host-only) should work
|
||||
store.store_cookies_from_response(&url, &["session=xyz".to_string()]);
|
||||
|
||||
// Should be accepted
|
||||
assert_eq!(store.get_all_cookies().len(), 1);
|
||||
assert!(store.get_cookie_header(&url).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_allow_normal_domain_cookies() {
|
||||
let store = CookieStore::new();
|
||||
let url = Url::parse("https://example.com/").unwrap();
|
||||
|
||||
// Cookie with valid domain should be allowed
|
||||
store.store_cookies_from_response(&url, &["session=abc; Domain=example.com".to_string()]);
|
||||
|
||||
// Should be accepted
|
||||
assert_eq!(store.get_all_cookies().len(), 1);
|
||||
assert!(store.get_cookie_header(&url).is_some());
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
use crate::error::{Error, Result};
|
||||
use async_compression::tokio::bufread::{
|
||||
BrotliDecoder, DeflateDecoder as AsyncDeflateDecoder, GzipDecoder,
|
||||
ZstdDecoder as AsyncZstdDecoder,
|
||||
};
|
||||
use flate2::read::{DeflateDecoder, GzDecoder};
|
||||
use std::io::Read;
|
||||
use tokio::io::{AsyncBufRead, AsyncRead};
|
||||
|
||||
/// Supported compression encodings
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContentEncoding {
|
||||
Gzip,
|
||||
Deflate,
|
||||
Brotli,
|
||||
Zstd,
|
||||
Identity,
|
||||
}
|
||||
|
||||
impl ContentEncoding {
|
||||
/// Parse a Content-Encoding header value into an encoding type.
|
||||
/// Returns Identity for unknown or missing encodings.
|
||||
pub fn from_header(value: Option<&str>) -> Self {
|
||||
match value.map(|s| s.trim().to_lowercase()).as_deref() {
|
||||
Some("gzip") | Some("x-gzip") => ContentEncoding::Gzip,
|
||||
Some("deflate") => ContentEncoding::Deflate,
|
||||
Some("br") => ContentEncoding::Brotli,
|
||||
Some("zstd") => ContentEncoding::Zstd,
|
||||
_ => ContentEncoding::Identity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of decompression, containing both the decompressed data and size info
|
||||
#[derive(Debug)]
|
||||
pub struct DecompressResult {
|
||||
pub data: Vec<u8>,
|
||||
pub compressed_size: u64,
|
||||
pub decompressed_size: u64,
|
||||
}
|
||||
|
||||
/// Decompress data based on the Content-Encoding.
|
||||
/// Returns the original data unchanged if encoding is Identity or unknown.
|
||||
pub fn decompress(data: Vec<u8>, encoding: ContentEncoding) -> Result<DecompressResult> {
|
||||
let compressed_size = data.len() as u64;
|
||||
|
||||
let decompressed = match encoding {
|
||||
ContentEncoding::Identity => data,
|
||||
ContentEncoding::Gzip => decompress_gzip(&data)?,
|
||||
ContentEncoding::Deflate => decompress_deflate(&data)?,
|
||||
ContentEncoding::Brotli => decompress_brotli(&data)?,
|
||||
ContentEncoding::Zstd => decompress_zstd(&data)?,
|
||||
};
|
||||
|
||||
let decompressed_size = decompressed.len() as u64;
|
||||
|
||||
Ok(DecompressResult { data: decompressed, compressed_size, decompressed_size })
|
||||
}
|
||||
|
||||
fn decompress_gzip(data: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut decoder = GzDecoder::new(data);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.map_err(|e| Error::DecompressionError(format!("gzip decompression failed: {}", e)))?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
fn decompress_deflate(data: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut decoder = DeflateDecoder::new(data);
|
||||
let mut decompressed = Vec::new();
|
||||
decoder
|
||||
.read_to_end(&mut decompressed)
|
||||
.map_err(|e| Error::DecompressionError(format!("deflate decompression failed: {}", e)))?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
fn decompress_brotli(data: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut decompressed = Vec::new();
|
||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(data), &mut decompressed)
|
||||
.map_err(|e| Error::DecompressionError(format!("brotli decompression failed: {}", e)))?;
|
||||
Ok(decompressed)
|
||||
}
|
||||
|
||||
fn decompress_zstd(data: &[u8]) -> Result<Vec<u8>> {
|
||||
zstd::stream::decode_all(std::io::Cursor::new(data))
|
||||
.map_err(|e| Error::DecompressionError(format!("zstd decompression failed: {}", e)))
|
||||
}
|
||||
|
||||
/// Create a streaming decompressor that wraps an async reader.
|
||||
/// Returns an AsyncRead that decompresses data on-the-fly.
|
||||
pub fn streaming_decoder<R: AsyncBufRead + Unpin + Send + 'static>(
|
||||
reader: R,
|
||||
encoding: ContentEncoding,
|
||||
) -> Box<dyn AsyncRead + Unpin + Send> {
|
||||
match encoding {
|
||||
ContentEncoding::Identity => Box::new(reader),
|
||||
ContentEncoding::Gzip => Box::new(GzipDecoder::new(reader)),
|
||||
ContentEncoding::Deflate => Box::new(AsyncDeflateDecoder::new(reader)),
|
||||
ContentEncoding::Brotli => Box::new(BrotliDecoder::new(reader)),
|
||||
ContentEncoding::Zstd => Box::new(AsyncZstdDecoder::new(reader)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use flate2::Compression;
|
||||
use flate2::write::GzEncoder;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_content_encoding_from_header() {
|
||||
assert_eq!(ContentEncoding::from_header(Some("gzip")), ContentEncoding::Gzip);
|
||||
assert_eq!(ContentEncoding::from_header(Some("x-gzip")), ContentEncoding::Gzip);
|
||||
assert_eq!(ContentEncoding::from_header(Some("GZIP")), ContentEncoding::Gzip);
|
||||
assert_eq!(ContentEncoding::from_header(Some("deflate")), ContentEncoding::Deflate);
|
||||
assert_eq!(ContentEncoding::from_header(Some("br")), ContentEncoding::Brotli);
|
||||
assert_eq!(ContentEncoding::from_header(Some("zstd")), ContentEncoding::Zstd);
|
||||
assert_eq!(ContentEncoding::from_header(Some("identity")), ContentEncoding::Identity);
|
||||
assert_eq!(ContentEncoding::from_header(Some("unknown")), ContentEncoding::Identity);
|
||||
assert_eq!(ContentEncoding::from_header(None), ContentEncoding::Identity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_identity() {
|
||||
let data = b"hello world".to_vec();
|
||||
let result = decompress(data.clone(), ContentEncoding::Identity).unwrap();
|
||||
assert_eq!(result.data, data);
|
||||
assert_eq!(result.compressed_size, 11);
|
||||
assert_eq!(result.decompressed_size, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_gzip() {
|
||||
// Compress some data with gzip
|
||||
let original = b"hello world, this is a test of gzip compression";
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(original).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let result = decompress(compressed.clone(), ContentEncoding::Gzip).unwrap();
|
||||
assert_eq!(result.data, original);
|
||||
assert_eq!(result.compressed_size, compressed.len() as u64);
|
||||
assert_eq!(result.decompressed_size, original.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_deflate() {
|
||||
// Compress some data with deflate
|
||||
let original = b"hello world, this is a test of deflate compression";
|
||||
let mut encoder = flate2::write::DeflateEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(original).unwrap();
|
||||
let compressed = encoder.finish().unwrap();
|
||||
|
||||
let result = decompress(compressed.clone(), ContentEncoding::Deflate).unwrap();
|
||||
assert_eq!(result.data, original);
|
||||
assert_eq!(result.compressed_size, compressed.len() as u64);
|
||||
assert_eq!(result.decompressed_size, original.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_brotli() {
|
||||
// Compress some data with brotli
|
||||
let original = b"hello world, this is a test of brotli compression";
|
||||
let mut compressed = Vec::new();
|
||||
let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 4, 22);
|
||||
writer.write_all(original).unwrap();
|
||||
drop(writer);
|
||||
|
||||
let result = decompress(compressed.clone(), ContentEncoding::Brotli).unwrap();
|
||||
assert_eq!(result.data, original);
|
||||
assert_eq!(result.compressed_size, compressed.len() as u64);
|
||||
assert_eq!(result.decompressed_size, original.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decompress_zstd() {
|
||||
// Compress some data with zstd
|
||||
let original = b"hello world, this is a test of zstd compression";
|
||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(original), 3).unwrap();
|
||||
|
||||
let result = decompress(compressed.clone(), ContentEncoding::Zstd).unwrap();
|
||||
assert_eq!(result.data, original);
|
||||
assert_eq!(result.compressed_size, compressed.len() as u64);
|
||||
assert_eq!(result.decompressed_size, original.len() as u64);
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
use crate::sender::HttpResponseEvent;
|
||||
use hyper_util::client::legacy::connect::dns::{
|
||||
GaiResolver as HyperGaiResolver, Name as HyperName,
|
||||
};
|
||||
use log::info;
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{RwLock, mpsc};
|
||||
use tower_service::Service;
|
||||
use yaak_models::models::DnsOverride;
|
||||
|
||||
/// Stores resolved addresses for a hostname override
|
||||
#[derive(Clone)]
|
||||
pub struct ResolvedOverride {
|
||||
pub ipv4: Vec<Ipv4Addr>,
|
||||
pub ipv6: Vec<Ipv6Addr>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalhostResolver {
|
||||
fallback: HyperGaiResolver,
|
||||
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
|
||||
overrides: Arc<HashMap<String, ResolvedOverride>>,
|
||||
}
|
||||
|
||||
impl LocalhostResolver {
|
||||
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
|
||||
let resolver = HyperGaiResolver::new();
|
||||
|
||||
// Pre-parse DNS overrides into a lookup map
|
||||
let mut overrides = HashMap::new();
|
||||
for o in dns_overrides {
|
||||
if !o.enabled {
|
||||
continue;
|
||||
}
|
||||
let hostname = o.hostname.to_lowercase();
|
||||
|
||||
let ipv4: Vec<Ipv4Addr> =
|
||||
o.ipv4.iter().filter_map(|s| s.parse::<Ipv4Addr>().ok()).collect();
|
||||
|
||||
let ipv6: Vec<Ipv6Addr> =
|
||||
o.ipv6.iter().filter_map(|s| s.parse::<Ipv6Addr>().ok()).collect();
|
||||
|
||||
// Only add if at least one address is valid
|
||||
if !ipv4.is_empty() || !ipv6.is_empty() {
|
||||
overrides.insert(hostname, ResolvedOverride { ipv4, ipv6 });
|
||||
}
|
||||
}
|
||||
|
||||
Arc::new(Self {
|
||||
fallback: resolver,
|
||||
event_tx: Arc::new(RwLock::new(None)),
|
||||
overrides: Arc::new(overrides),
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the event sender for the current request.
|
||||
/// This should be called before each request to direct DNS events
|
||||
/// to the appropriate channel.
|
||||
pub async fn set_event_sender(&self, tx: Option<mpsc::Sender<HttpResponseEvent>>) {
|
||||
let mut guard = self.event_tx.write().await;
|
||||
*guard = tx;
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for LocalhostResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let host = name.as_str().to_lowercase();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let overrides = self.overrides.clone();
|
||||
|
||||
info!("DNS resolve called for: {}", host);
|
||||
|
||||
// Check for DNS override first
|
||||
if let Some(resolved) = overrides.get(&host) {
|
||||
log::debug!("DNS override found for: {}", host);
|
||||
let hostname = host.clone();
|
||||
let mut addrs: Vec<SocketAddr> = Vec::new();
|
||||
|
||||
// Add IPv4 addresses
|
||||
for ip in &resolved.ipv4 {
|
||||
addrs.push(SocketAddr::new(IpAddr::V4(*ip), 0));
|
||||
}
|
||||
|
||||
// Add IPv6 addresses
|
||||
for ip in &resolved.ipv6 {
|
||||
addrs.push(SocketAddr::new(IpAddr::V6(*ip), 0));
|
||||
}
|
||||
|
||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
return Box::pin(async move {
|
||||
// Emit DNS event for override
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
let _ = tx
|
||||
.send(HttpResponseEvent::DnsResolved {
|
||||
hostname,
|
||||
addresses,
|
||||
duration: 0,
|
||||
overridden: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
|
||||
});
|
||||
}
|
||||
|
||||
// Check for .localhost suffix
|
||||
let is_localhost = host.ends_with(".localhost");
|
||||
if is_localhost {
|
||||
let hostname = host.clone();
|
||||
// Port 0 is fine; reqwest replaces it with the URL's explicit
|
||||
// port or the scheme's default (80/443, etc.).
|
||||
let addrs: Vec<SocketAddr> = vec![
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||
];
|
||||
|
||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
return Box::pin(async move {
|
||||
// Emit DNS event for localhost resolution
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
let _ = tx
|
||||
.send(HttpResponseEvent::DnsResolved {
|
||||
hostname,
|
||||
addresses,
|
||||
duration: 0,
|
||||
overridden: false,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok::<Addrs, Box<dyn std::error::Error + Send + Sync>>(Box::new(addrs.into_iter()))
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to system DNS
|
||||
let mut fallback = self.fallback.clone();
|
||||
let name_str = name.as_str().to_string();
|
||||
let hostname = host.clone();
|
||||
|
||||
Box::pin(async move {
|
||||
let start = Instant::now();
|
||||
|
||||
let result = match HyperName::from_str(&name_str) {
|
||||
Ok(n) => fallback.call(n).await,
|
||||
Err(e) => return Err(Box::new(e) as Box<dyn std::error::Error + Send + Sync>),
|
||||
};
|
||||
|
||||
let duration = start.elapsed().as_millis() as u64;
|
||||
|
||||
match result {
|
||||
Ok(addrs) => {
|
||||
// Collect addresses for event emission
|
||||
let addr_vec: Vec<SocketAddr> = addrs.collect();
|
||||
let addresses: Vec<String> =
|
||||
addr_vec.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
// Emit DNS event
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
let _ = tx
|
||||
.send(HttpResponseEvent::DnsResolved {
|
||||
hostname,
|
||||
addresses,
|
||||
duration,
|
||||
overridden: false,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Box::new(addr_vec.into_iter()) as Addrs)
|
||||
}
|
||||
Err(err) => Err(Box::new(err) as Box<dyn std::error::Error + Send + Sync>),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use serde::{Serialize, Serializer};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("Client error: {0:?}")]
|
||||
Client(#[from] reqwest::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
TlsError(#[from] yaak_tls::error::Error),
|
||||
|
||||
#[error("Request failed with {0:?}")]
|
||||
RequestError(String),
|
||||
|
||||
#[error("Request canceled")]
|
||||
RequestCanceledError,
|
||||
|
||||
#[error("Timeout of {0:?} reached")]
|
||||
RequestTimeout(std::time::Duration),
|
||||
|
||||
#[error("Decompression error: {0}")]
|
||||
DecompressionError(String),
|
||||
|
||||
#[error("Failed to read response body: {0}")]
|
||||
BodyReadError(String),
|
||||
}
|
||||
|
||||
impl Serialize for Error {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.to_string().as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -1,13 +0,0 @@
|
||||
mod chained_reader;
|
||||
pub mod client;
|
||||
pub mod cookies;
|
||||
pub mod decompress;
|
||||
pub mod dns;
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
pub mod path_placeholders;
|
||||
mod proto;
|
||||
pub mod sender;
|
||||
pub mod tee_reader;
|
||||
pub mod transaction;
|
||||
pub mod types;
|
||||
@@ -1,51 +0,0 @@
|
||||
use crate::client::HttpConnectionOptions;
|
||||
use crate::dns::LocalhostResolver;
|
||||
use crate::error::Result;
|
||||
use reqwest::Client;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// A cached HTTP client along with its DNS resolver.
|
||||
/// The resolver is needed to set the event sender per-request.
|
||||
pub struct CachedClient {
|
||||
pub client: Client,
|
||||
pub resolver: Arc<LocalhostResolver>,
|
||||
}
|
||||
|
||||
pub struct HttpConnectionManager {
|
||||
connections: Arc<RwLock<BTreeMap<String, (CachedClient, Instant)>>>,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl HttpConnectionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
ttl: Duration::from_secs(10 * 60),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
|
||||
let mut connections = self.connections.write().await;
|
||||
let id = opt.id.clone();
|
||||
|
||||
// Clean old connections
|
||||
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
|
||||
|
||||
if let Some((cached, last_used)) = connections.get_mut(&id) {
|
||||
*last_used = Instant::now();
|
||||
return Ok(CachedClient {
|
||||
client: cached.client.clone(),
|
||||
resolver: cached.resolver.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let (client, resolver) = opt.build_client()?;
|
||||
let cached = CachedClient { client: client.clone(), resolver: resolver.clone() };
|
||||
connections.insert(id.into(), (cached, Instant::now()));
|
||||
|
||||
Ok(CachedClient { client, resolver })
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use reqwest::Url;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub(crate) fn ensure_proto(url_str: &str) -> String {
|
||||
if url_str.is_empty() {
|
||||
return "".to_string();
|
||||
}
|
||||
|
||||
if url_str.starts_with("http://") || url_str.starts_with("https://") {
|
||||
return url_str.to_string();
|
||||
}
|
||||
|
||||
// Url::from_str will fail without a proto, so add one
|
||||
let parseable_url = format!("http://{}", url_str);
|
||||
if let Ok(u) = Url::from_str(parseable_url.as_str()) {
|
||||
match u.host() {
|
||||
Some(host) => {
|
||||
let h = host.to_string();
|
||||
// These TLDs force HTTPS
|
||||
if h.ends_with(".app") || h.ends_with(".dev") || h.ends_with(".page") {
|
||||
return format!("https://{url_str}");
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
format!("http://{url_str}")
|
||||
}
|
||||
@@ -1,640 +0,0 @@
|
||||
use crate::decompress::{ContentEncoding, streaming_decoder};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::types::{SendableBody, SendableHttpRequest};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures_util::StreamExt;
|
||||
use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
use reqwest::{Client, Method, Version};
|
||||
use std::fmt::Display;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, BufReader, ReadBuf};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RedirectBehavior {
|
||||
/// 307/308: Method and body are preserved
|
||||
Preserve,
|
||||
/// 303 or 301/302 with POST: Method changed to GET, body dropped
|
||||
DropBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HttpResponseEvent {
|
||||
Setting(String, String),
|
||||
Info(String),
|
||||
Redirect {
|
||||
url: String,
|
||||
status: u16,
|
||||
behavior: RedirectBehavior,
|
||||
dropped_body: bool,
|
||||
dropped_headers: Vec<String>,
|
||||
},
|
||||
SendUrl {
|
||||
method: String,
|
||||
scheme: String,
|
||||
username: String,
|
||||
password: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
path: String,
|
||||
query: String,
|
||||
fragment: String,
|
||||
},
|
||||
ReceiveUrl {
|
||||
version: Version,
|
||||
status: String,
|
||||
},
|
||||
HeaderUp(String, String),
|
||||
HeaderDown(String, String),
|
||||
ChunkSent {
|
||||
bytes: usize,
|
||||
},
|
||||
ChunkReceived {
|
||||
bytes: usize,
|
||||
},
|
||||
DnsResolved {
|
||||
hostname: String,
|
||||
addresses: Vec<String>,
|
||||
duration: u64,
|
||||
overridden: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Display for HttpResponseEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
||||
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
||||
HttpResponseEvent::Redirect {
|
||||
url,
|
||||
status,
|
||||
behavior,
|
||||
dropped_body,
|
||||
dropped_headers,
|
||||
} => {
|
||||
let behavior_str = match behavior {
|
||||
RedirectBehavior::Preserve => "preserve",
|
||||
RedirectBehavior::DropBody => "drop body",
|
||||
};
|
||||
let body_str = if *dropped_body { ", body dropped" } else { "" };
|
||||
let headers_str = if dropped_headers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", headers dropped: {}", dropped_headers.join(", "))
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"* Redirect {} -> {} ({}{}{})",
|
||||
status, url, behavior_str, body_str, headers_str
|
||||
)
|
||||
}
|
||||
HttpResponseEvent::SendUrl {
|
||||
method,
|
||||
scheme,
|
||||
username,
|
||||
password,
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
query,
|
||||
fragment,
|
||||
} => {
|
||||
let auth_str = if username.is_empty() && password.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{}:{}@", username, password)
|
||||
};
|
||||
let query_str =
|
||||
if query.is_empty() { String::new() } else { format!("?{}", query) };
|
||||
let fragment_str =
|
||||
if fragment.is_empty() { String::new() } else { format!("#{}", fragment) };
|
||||
write!(
|
||||
f,
|
||||
"> {} {}://{}{}:{}{}{}{}",
|
||||
method, scheme, auth_str, host, port, path, query_str, fragment_str
|
||||
)
|
||||
}
|
||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||
write!(f, "< {} {}", version_to_str(version), status)
|
||||
}
|
||||
HttpResponseEvent::HeaderUp(name, value) => write!(f, "> {}: {}", name, value),
|
||||
HttpResponseEvent::HeaderDown(name, value) => write!(f, "< {}: {}", name, value),
|
||||
HttpResponseEvent::ChunkSent { bytes } => write!(f, "> [{} bytes sent]", bytes),
|
||||
HttpResponseEvent::ChunkReceived { bytes } => write!(f, "< [{} bytes received]", bytes),
|
||||
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
|
||||
if *overridden {
|
||||
write!(f, "* DNS override {} -> {}", hostname, addresses.join(", "))
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"* DNS resolved {} to {} ({}ms)",
|
||||
hostname,
|
||||
addresses.join(", "),
|
||||
duration
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
||||
fn from(event: HttpResponseEvent) -> Self {
|
||||
use yaak_models::models::HttpResponseEventData as D;
|
||||
match event {
|
||||
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
||||
HttpResponseEvent::Info(message) => D::Info { message },
|
||||
HttpResponseEvent::Redirect {
|
||||
url,
|
||||
status,
|
||||
behavior,
|
||||
dropped_body,
|
||||
dropped_headers,
|
||||
} => D::Redirect {
|
||||
url,
|
||||
status,
|
||||
behavior: match behavior {
|
||||
RedirectBehavior::Preserve => "preserve".to_string(),
|
||||
RedirectBehavior::DropBody => "drop_body".to_string(),
|
||||
},
|
||||
dropped_body,
|
||||
dropped_headers,
|
||||
},
|
||||
HttpResponseEvent::SendUrl {
|
||||
method,
|
||||
scheme,
|
||||
username,
|
||||
password,
|
||||
host,
|
||||
port,
|
||||
path,
|
||||
query,
|
||||
fragment,
|
||||
} => {
|
||||
D::SendUrl { method, scheme, username, password, host, port, path, query, fragment }
|
||||
}
|
||||
HttpResponseEvent::ReceiveUrl { version, status } => {
|
||||
D::ReceiveUrl { version: format!("{:?}", version), status }
|
||||
}
|
||||
HttpResponseEvent::HeaderUp(name, value) => D::HeaderUp { name, value },
|
||||
HttpResponseEvent::HeaderDown(name, value) => D::HeaderDown { name, value },
|
||||
HttpResponseEvent::ChunkSent { bytes } => D::ChunkSent { bytes },
|
||||
HttpResponseEvent::ChunkReceived { bytes } => D::ChunkReceived { bytes },
|
||||
HttpResponseEvent::DnsResolved { hostname, addresses, duration, overridden } => {
|
||||
D::DnsResolved { hostname, addresses, duration, overridden }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics about the body after consumption
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BodyStats {
|
||||
/// Size of the body as received over the wire (before decompression)
|
||||
pub size_compressed: u64,
|
||||
/// Size of the body after decompression
|
||||
pub size_decompressed: u64,
|
||||
}
|
||||
|
||||
/// An AsyncRead wrapper that sends chunk events as data is read
|
||||
pub struct TrackingRead<R> {
|
||||
inner: R,
|
||||
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
ended: bool,
|
||||
}
|
||||
|
||||
impl<R> TrackingRead<R> {
|
||||
pub fn new(inner: R, event_tx: mpsc::Sender<HttpResponseEvent>) -> Self {
|
||||
Self { inner, event_tx, ended: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for TrackingRead<R> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<std::io::Result<()>> {
|
||||
let before = buf.filled().len();
|
||||
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let bytes_read = buf.filled().len() - before;
|
||||
if bytes_read > 0 {
|
||||
// Ignore send errors - receiver may have been dropped or channel is full
|
||||
let _ =
|
||||
self.event_tx.try_send(HttpResponseEvent::ChunkReceived { bytes: bytes_read });
|
||||
} else if !self.ended {
|
||||
self.ended = true;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Type alias for the body stream
|
||||
type BodyStream = Pin<Box<dyn AsyncRead + Send>>;
|
||||
|
||||
/// HTTP response with deferred body consumption.
|
||||
/// Headers are available immediately after send(), body can be consumed in different ways.
|
||||
/// Note: Debug is manually implemented since BodyStream doesn't implement Debug.
|
||||
pub struct HttpResponse {
|
||||
/// HTTP status code
|
||||
pub status: u16,
|
||||
/// HTTP status reason phrase (e.g., "OK", "Not Found")
|
||||
pub status_reason: Option<String>,
|
||||
/// Response headers (Vec to support multiple headers with same name, e.g., Set-Cookie)
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// Request headers (Vec to support multiple headers with same name)
|
||||
pub request_headers: Vec<(String, String)>,
|
||||
/// Content-Length from headers (may differ from actual body size)
|
||||
pub content_length: Option<u64>,
|
||||
/// Final URL (after redirects)
|
||||
pub url: String,
|
||||
/// Remote address of the server
|
||||
pub remote_addr: Option<String>,
|
||||
/// HTTP version (e.g., "HTTP/1.1", "HTTP/2")
|
||||
pub version: Option<String>,
|
||||
|
||||
/// The body stream (consumed when calling bytes(), text(), write_to_file(), or drain())
|
||||
body_stream: Option<BodyStream>,
|
||||
/// Content-Encoding for decompression
|
||||
encoding: ContentEncoding,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HttpResponse {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HttpResponse")
|
||||
.field("status", &self.status)
|
||||
.field("status_reason", &self.status_reason)
|
||||
.field("headers", &self.headers)
|
||||
.field("content_length", &self.content_length)
|
||||
.field("url", &self.url)
|
||||
.field("remote_addr", &self.remote_addr)
|
||||
.field("version", &self.version)
|
||||
.field("body_stream", &"<stream>")
|
||||
.field("encoding", &self.encoding)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpResponse {
|
||||
/// Create a new HttpResponse with an unconsumed body stream
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
status: u16,
|
||||
status_reason: Option<String>,
|
||||
headers: Vec<(String, String)>,
|
||||
request_headers: Vec<(String, String)>,
|
||||
content_length: Option<u64>,
|
||||
url: String,
|
||||
remote_addr: Option<String>,
|
||||
version: Option<String>,
|
||||
body_stream: BodyStream,
|
||||
encoding: ContentEncoding,
|
||||
) -> Self {
|
||||
Self {
|
||||
status,
|
||||
status_reason,
|
||||
headers,
|
||||
request_headers,
|
||||
content_length,
|
||||
url,
|
||||
remote_addr,
|
||||
version,
|
||||
body_stream: Some(body_stream),
|
||||
encoding,
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the body and return it as bytes (loads entire body into memory).
|
||||
/// Also decompresses the body if Content-Encoding is set.
|
||||
pub async fn bytes(mut self) -> Result<(Vec<u8>, BodyStats)> {
|
||||
let stream = self.body_stream.take().ok_or_else(|| {
|
||||
Error::RequestError("Response body has already been consumed".to_string())
|
||||
})?;
|
||||
|
||||
let buf_reader = BufReader::new(stream);
|
||||
let mut decoder = streaming_decoder(buf_reader, self.encoding);
|
||||
|
||||
let mut decompressed = Vec::new();
|
||||
let mut bytes_read = 0u64;
|
||||
|
||||
// Read through the decoder in chunks to track compressed size
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match decoder.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
decompressed.extend_from_slice(&buf[..n]);
|
||||
bytes_read += n as u64;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(Error::BodyReadError(e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stats = BodyStats {
|
||||
// For now, we can't easily track compressed size when streaming through decoder
|
||||
// Use content_length as an approximation, or decompressed size if identity encoding
|
||||
size_compressed: self.content_length.unwrap_or(bytes_read),
|
||||
size_decompressed: decompressed.len() as u64,
|
||||
};
|
||||
|
||||
Ok((decompressed, stats))
|
||||
}
|
||||
|
||||
/// Consume the body and return it as a UTF-8 string.
|
||||
pub async fn text(self) -> Result<(String, BodyStats)> {
|
||||
let (bytes, stats) = self.bytes().await?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|e| Error::RequestError(format!("Response is not valid UTF-8: {}", e)))?;
|
||||
Ok((text, stats))
|
||||
}
|
||||
|
||||
/// Take the body stream for manual consumption.
|
||||
/// Returns an AsyncRead that decompresses on-the-fly if Content-Encoding is set.
|
||||
/// The caller is responsible for reading and processing the stream.
|
||||
pub fn into_body_stream(&mut self) -> Result<Box<dyn AsyncRead + Unpin + Send>> {
|
||||
let stream = self.body_stream.take().ok_or_else(|| {
|
||||
Error::RequestError("Response body has already been consumed".to_string())
|
||||
})?;
|
||||
|
||||
let buf_reader = BufReader::new(stream);
|
||||
let decoder = streaming_decoder(buf_reader, self.encoding);
|
||||
|
||||
Ok(decoder)
|
||||
}
|
||||
|
||||
/// Discard the body without reading it (useful for redirects).
|
||||
pub async fn drain(mut self) -> Result<()> {
|
||||
let stream = self.body_stream.take().ok_or_else(|| {
|
||||
Error::RequestError("Response body has already been consumed".to_string())
|
||||
})?;
|
||||
|
||||
// Just read and discard all bytes
|
||||
let mut reader = stream;
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(_) => continue,
|
||||
Err(e) => {
|
||||
return Err(Error::RequestError(format!(
|
||||
"Failed to drain response body: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for sending HTTP requests
|
||||
#[async_trait]
|
||||
pub trait HttpSender: Send + Sync {
|
||||
/// Send an HTTP request and return the response with headers.
|
||||
/// The body is not consumed until you call bytes(), text(), write_to_file(), or drain().
|
||||
/// Events are sent through the provided channel.
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse>;
|
||||
}
|
||||
|
||||
/// Reqwest-based implementation of HttpSender
|
||||
pub struct ReqwestSender {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl ReqwestSender {
|
||||
/// Create a new ReqwestSender with a default client
|
||||
pub fn new() -> Result<Self> {
|
||||
let client = Client::builder().build().map_err(Error::Client)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Create a new ReqwestSender with a custom client
|
||||
pub fn with_client(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpSender for ReqwestSender {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
// Helper to send events (ignores errors if receiver is dropped or channel is full)
|
||||
let send_event = |event: HttpResponseEvent| {
|
||||
let _ = event_tx.try_send(event);
|
||||
};
|
||||
|
||||
// Parse the HTTP method
|
||||
let method = Method::from_bytes(request.method.as_bytes())
|
||||
.map_err(|e| Error::RequestError(format!("Invalid HTTP method: {}", e)))?;
|
||||
|
||||
// Build the request
|
||||
let mut req_builder = self.client.request(method, &request.url);
|
||||
|
||||
// Add headers
|
||||
for header in request.headers {
|
||||
if header.0.is_empty() {
|
||||
continue;
|
||||
}
|
||||
req_builder = req_builder.header(&header.0, &header.1);
|
||||
}
|
||||
|
||||
// Configure timeout
|
||||
if let Some(d) = request.options.timeout
|
||||
&& !d.is_zero()
|
||||
{
|
||||
req_builder = req_builder.timeout(d);
|
||||
}
|
||||
|
||||
// Add body
|
||||
match request.body {
|
||||
None => {}
|
||||
Some(SendableBody::Bytes(bytes)) => {
|
||||
req_builder = req_builder.body(bytes);
|
||||
}
|
||||
Some(SendableBody::Stream { data, content_length }) => {
|
||||
// Convert AsyncRead stream to reqwest Body. If content length is
|
||||
// known, wrap with a SizedBody so hyper can set Content-Length
|
||||
// automatically (for both HTTP/1.1 and HTTP/2).
|
||||
let stream = tokio_util::io::ReaderStream::new(data);
|
||||
let body = if let Some(len) = content_length {
|
||||
reqwest::Body::wrap(SizedBody::new(stream, len))
|
||||
} else {
|
||||
reqwest::Body::wrap_stream(stream)
|
||||
};
|
||||
req_builder = req_builder.body(body);
|
||||
}
|
||||
}
|
||||
|
||||
// Send the request
|
||||
let sendable_req = req_builder.build()?;
|
||||
send_event(HttpResponseEvent::Setting(
|
||||
"timeout".to_string(),
|
||||
if request.options.timeout.unwrap_or_default().is_zero() {
|
||||
"Infinity".to_string()
|
||||
} else {
|
||||
format!("{:?}", request.options.timeout)
|
||||
},
|
||||
));
|
||||
|
||||
send_event(HttpResponseEvent::SendUrl {
|
||||
method: sendable_req.method().to_string(),
|
||||
scheme: sendable_req.url().scheme().to_string(),
|
||||
username: sendable_req.url().username().to_string(),
|
||||
password: sendable_req.url().password().unwrap_or_default().to_string(),
|
||||
host: sendable_req.url().host_str().unwrap_or_default().to_string(),
|
||||
port: sendable_req.url().port_or_known_default().unwrap_or(0),
|
||||
path: sendable_req.url().path().to_string(),
|
||||
query: sendable_req.url().query().unwrap_or_default().to_string(),
|
||||
fragment: sendable_req.url().fragment().unwrap_or_default().to_string(),
|
||||
});
|
||||
|
||||
let mut request_headers = Vec::new();
|
||||
for (name, value) in sendable_req.headers() {
|
||||
let v = value.to_str().unwrap_or_default().to_string();
|
||||
request_headers.push((name.to_string(), v.clone()));
|
||||
send_event(HttpResponseEvent::HeaderUp(name.to_string(), v));
|
||||
}
|
||||
send_event(HttpResponseEvent::Info("Sending request to server".to_string()));
|
||||
|
||||
// Map some errors to our own, so they look nicer
|
||||
let response = self.client.execute(sendable_req).await.map_err(|e| {
|
||||
if reqwest::Error::is_timeout(&e) {
|
||||
Error::RequestTimeout(
|
||||
request.options.timeout.unwrap_or(Duration::from_secs(0)).clone(),
|
||||
)
|
||||
} else {
|
||||
Error::Client(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let status_reason = response.status().canonical_reason().map(|s| s.to_string());
|
||||
let url = response.url().to_string();
|
||||
let remote_addr = response.remote_addr().map(|a| a.to_string());
|
||||
let version = Some(version_to_str(&response.version()));
|
||||
let content_length = response.content_length();
|
||||
|
||||
send_event(HttpResponseEvent::ReceiveUrl {
|
||||
version: response.version(),
|
||||
status: response.status().to_string(),
|
||||
});
|
||||
|
||||
// Extract headers (use Vec to preserve duplicates like Set-Cookie)
|
||||
let mut headers = Vec::new();
|
||||
for (key, value) in response.headers() {
|
||||
if let Ok(v) = value.to_str() {
|
||||
send_event(HttpResponseEvent::HeaderDown(key.to_string(), v.to_string()));
|
||||
headers.push((key.to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Determine content encoding for decompression
|
||||
// HTTP headers are case-insensitive, so we need to search for any casing
|
||||
let encoding = ContentEncoding::from_header(
|
||||
headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-encoding"))
|
||||
.map(|(_, v)| v.as_str()),
|
||||
);
|
||||
|
||||
// Get the byte stream instead of loading into memory
|
||||
let byte_stream = response.bytes_stream();
|
||||
|
||||
// Convert the stream to an AsyncRead
|
||||
let stream_reader = StreamReader::new(
|
||||
byte_stream.map(|result| result.map_err(|e| std::io::Error::other(e))),
|
||||
);
|
||||
|
||||
// Wrap the stream with tracking to emit chunk received events via the same channel
|
||||
let tracking_reader = TrackingRead::new(stream_reader, event_tx);
|
||||
let body_stream: BodyStream = Box::pin(tracking_reader);
|
||||
|
||||
Ok(HttpResponse::new(
|
||||
status,
|
||||
status_reason,
|
||||
headers,
|
||||
request_headers,
|
||||
content_length,
|
||||
url,
|
||||
remote_addr,
|
||||
version,
|
||||
body_stream,
|
||||
encoding,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around a byte stream that reports a known content length via
|
||||
/// `size_hint()`. This lets hyper set the `Content-Length` header
|
||||
/// automatically based on the body size, without us having to add it as an
|
||||
/// explicit header — which can cause duplicate `Content-Length` headers and
|
||||
/// break HTTP/2.
|
||||
struct SizedBody<S> {
|
||||
stream: std::sync::Mutex<S>,
|
||||
remaining: u64,
|
||||
}
|
||||
|
||||
impl<S> SizedBody<S> {
|
||||
fn new(stream: S, content_length: u64) -> Self {
|
||||
Self { stream: std::sync::Mutex::new(stream), remaining: content_length }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> HttpBody for SizedBody<S>
|
||||
where
|
||||
S: futures_util::Stream<Item = std::result::Result<Bytes, std::io::Error>>
|
||||
+ Send
|
||||
+ Unpin
|
||||
+ 'static,
|
||||
{
|
||||
type Data = Bytes;
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn poll_frame(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<std::result::Result<Frame<Self::Data>, Self::Error>>> {
|
||||
let this = self.get_mut();
|
||||
let mut stream = this.stream.lock().unwrap();
|
||||
match stream.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
this.remaining = this.remaining.saturating_sub(chunk.len() as u64);
|
||||
Poll::Ready(Some(Ok(Frame::data(chunk))))
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
|
||||
Poll::Ready(None) => Poll::Ready(None),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
SizeHint::with_exact(self.remaining)
|
||||
}
|
||||
}
|
||||
|
||||
fn version_to_str(version: &Version) -> String {
|
||||
match *version {
|
||||
Version::HTTP_09 => "HTTP/0.9".to_string(),
|
||||
Version::HTTP_10 => "HTTP/1.0".to_string(),
|
||||
Version::HTTP_11 => "HTTP/1.1".to_string(),
|
||||
Version::HTTP_2 => "HTTP/2".to_string(),
|
||||
Version::HTTP_3 => "HTTP/3".to_string(),
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::io::{AsyncRead, ReadBuf};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// A reader that forwards all read data to a channel while also returning it to the caller.
|
||||
/// This allows capturing request body data as it's being sent.
|
||||
/// Uses an unbounded channel to ensure all data is captured without blocking the request.
|
||||
pub struct TeeReader<R> {
|
||||
inner: R,
|
||||
tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<R> TeeReader<R> {
|
||||
pub fn new(inner: R, tx: mpsc::UnboundedSender<Vec<u8>>) -> Self {
|
||||
Self { inner, tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for TeeReader<R> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let before_len = buf.filled().len();
|
||||
|
||||
match Pin::new(&mut self.inner).poll_read(cx, buf) {
|
||||
Poll::Ready(Ok(())) => {
|
||||
let after_len = buf.filled().len();
|
||||
if after_len > before_len {
|
||||
// Data was read, send a copy to the channel
|
||||
let data = buf.filled()[before_len..after_len].to_vec();
|
||||
// Send to unbounded channel - this never blocks
|
||||
// Ignore error if receiver is closed
|
||||
let _ = self.tx.send(data);
|
||||
}
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
Poll::Ready(Err(e)) => Poll::Ready(Err(e)),
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tee_reader_captures_all_data() {
|
||||
let data = b"Hello, World!";
|
||||
let cursor = Cursor::new(data.to_vec());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut tee = TeeReader::new(cursor, tx);
|
||||
let mut output = Vec::new();
|
||||
tee.read_to_end(&mut output).await.unwrap();
|
||||
|
||||
// Verify the reader returns the correct data
|
||||
assert_eq!(output, data);
|
||||
|
||||
// Verify the channel received the data
|
||||
let mut captured = Vec::new();
|
||||
while let Ok(chunk) = rx.try_recv() {
|
||||
captured.extend(chunk);
|
||||
}
|
||||
assert_eq!(captured, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tee_reader_with_chunked_reads() {
|
||||
let data = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
let cursor = Cursor::new(data.to_vec());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut tee = TeeReader::new(cursor, tx);
|
||||
|
||||
// Read in small chunks
|
||||
let mut buf = [0u8; 5];
|
||||
let mut output = Vec::new();
|
||||
loop {
|
||||
let n = tee.read(&mut buf).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
output.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
|
||||
// Verify the reader returns the correct data
|
||||
assert_eq!(output, data);
|
||||
|
||||
// Verify the channel received all chunks
|
||||
let mut captured = Vec::new();
|
||||
while let Ok(chunk) = rx.try_recv() {
|
||||
captured.extend(chunk);
|
||||
}
|
||||
assert_eq!(captured, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tee_reader_empty_data() {
|
||||
let data: Vec<u8> = vec![];
|
||||
let cursor = Cursor::new(data.clone());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut tee = TeeReader::new(cursor, tx);
|
||||
let mut output = Vec::new();
|
||||
tee.read_to_end(&mut output).await.unwrap();
|
||||
|
||||
// Verify empty output
|
||||
assert!(output.is_empty());
|
||||
|
||||
// Verify no data was sent to channel
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tee_reader_works_when_receiver_dropped() {
|
||||
let data = b"Hello, World!";
|
||||
let cursor = Cursor::new(data.to_vec());
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
// Drop the receiver before reading
|
||||
drop(rx);
|
||||
|
||||
let mut tee = TeeReader::new(cursor, tx);
|
||||
let mut output = Vec::new();
|
||||
|
||||
// Should still work even though receiver is dropped
|
||||
tee.read_to_end(&mut output).await.unwrap();
|
||||
assert_eq!(output, data);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tee_reader_large_data() {
|
||||
// Test with 1MB of data
|
||||
let data: Vec<u8> = (0..1024 * 1024).map(|i| (i % 256) as u8).collect();
|
||||
let cursor = Cursor::new(data.clone());
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut tee = TeeReader::new(cursor, tx);
|
||||
let mut output = Vec::new();
|
||||
tee.read_to_end(&mut output).await.unwrap();
|
||||
|
||||
// Verify the reader returns the correct data
|
||||
assert_eq!(output, data);
|
||||
|
||||
// Verify the channel received all data
|
||||
let mut captured = Vec::new();
|
||||
while let Ok(chunk) = rx.try_recv() {
|
||||
captured.extend(chunk);
|
||||
}
|
||||
assert_eq!(captured, data);
|
||||
}
|
||||
}
|
||||
@@ -1,931 +0,0 @@
|
||||
use crate::cookies::CookieStore;
|
||||
use crate::error::Result;
|
||||
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
|
||||
use crate::types::{SendableBody, SendableHttpRequest};
|
||||
use log::debug;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::watch::Receiver;
|
||||
use url::Url;
|
||||
|
||||
/// HTTP Transaction that manages the lifecycle of a request, including redirect handling
|
||||
pub struct HttpTransaction<S: HttpSender> {
|
||||
sender: S,
|
||||
max_redirects: usize,
|
||||
cookie_store: Option<CookieStore>,
|
||||
}
|
||||
|
||||
impl<S: HttpSender> HttpTransaction<S> {
|
||||
/// Create a new transaction with default settings
|
||||
pub fn new(sender: S) -> Self {
|
||||
Self { sender, max_redirects: 10, cookie_store: None }
|
||||
}
|
||||
|
||||
/// Create a new transaction with custom max redirects
|
||||
pub fn with_max_redirects(sender: S, max_redirects: usize) -> Self {
|
||||
Self { sender, max_redirects, cookie_store: None }
|
||||
}
|
||||
|
||||
/// Create a new transaction with a cookie store
|
||||
pub fn with_cookie_store(sender: S, cookie_store: CookieStore) -> Self {
|
||||
Self { sender, max_redirects: 10, cookie_store: Some(cookie_store) }
|
||||
}
|
||||
|
||||
/// Create a new transaction with custom max redirects and a cookie store
|
||||
pub fn with_options(
|
||||
sender: S,
|
||||
max_redirects: usize,
|
||||
cookie_store: Option<CookieStore>,
|
||||
) -> Self {
|
||||
Self { sender, max_redirects, cookie_store }
|
||||
}
|
||||
|
||||
/// Execute the request with cancellation support.
|
||||
/// Returns an HttpResponse with unconsumed body - caller decides how to consume it.
|
||||
/// Events are sent through the provided channel.
|
||||
pub async fn execute_with_cancellation(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
mut cancelled_rx: Receiver<bool>,
|
||||
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
let mut redirect_count = 0;
|
||||
let mut current_url = request.url;
|
||||
let mut current_method = request.method;
|
||||
let mut current_headers = request.headers;
|
||||
let mut current_body = request.body;
|
||||
|
||||
// Helper to send events (ignores errors if receiver is dropped or channel is full)
|
||||
let send_event = |event: HttpResponseEvent| {
|
||||
let _ = event_tx.try_send(event);
|
||||
};
|
||||
|
||||
loop {
|
||||
// Check for cancellation before each request
|
||||
if *cancelled_rx.borrow() {
|
||||
return Err(crate::error::Error::RequestCanceledError);
|
||||
}
|
||||
|
||||
// Inject cookies into headers if we have a cookie store
|
||||
let headers_with_cookies = if let Some(cookie_store) = &self.cookie_store {
|
||||
let mut headers = current_headers.clone();
|
||||
if let Ok(url) = Url::parse(¤t_url) {
|
||||
if let Some(cookie_header) = cookie_store.get_cookie_header(&url) {
|
||||
debug!("Injecting Cookie header: {}", cookie_header);
|
||||
// Check if there's already a Cookie header and merge if so
|
||||
if let Some(existing) =
|
||||
headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie"))
|
||||
{
|
||||
existing.1 = format!("{}; {}", existing.1, cookie_header);
|
||||
} else {
|
||||
headers.push(("Cookie".to_string(), cookie_header));
|
||||
}
|
||||
}
|
||||
}
|
||||
headers
|
||||
} else {
|
||||
current_headers.clone()
|
||||
};
|
||||
|
||||
// Build request for this iteration
|
||||
let preserved_body = match ¤t_body {
|
||||
Some(SendableBody::Bytes(b)) => Some(SendableBody::Bytes(b.clone())),
|
||||
_ => None,
|
||||
};
|
||||
let request_had_body = current_body.is_some();
|
||||
let req = SendableHttpRequest {
|
||||
url: current_url.clone(),
|
||||
method: current_method.clone(),
|
||||
headers: headers_with_cookies,
|
||||
body: current_body,
|
||||
options: request.options.clone(),
|
||||
};
|
||||
|
||||
// Send the request
|
||||
send_event(HttpResponseEvent::Setting(
|
||||
"redirects".to_string(),
|
||||
request.options.follow_redirects.to_string(),
|
||||
));
|
||||
|
||||
// Execute with cancellation support
|
||||
let response = tokio::select! {
|
||||
result = self.sender.send(req, event_tx.clone()) => result?,
|
||||
_ = cancelled_rx.changed() => {
|
||||
return Err(crate::error::Error::RequestCanceledError);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse Set-Cookie headers and store cookies
|
||||
if let Some(cookie_store) = &self.cookie_store {
|
||||
if let Ok(url) = Url::parse(¤t_url) {
|
||||
let set_cookie_headers: Vec<String> = response
|
||||
.headers
|
||||
.iter()
|
||||
.filter(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
|
||||
.map(|(_, v)| v.clone())
|
||||
.collect();
|
||||
|
||||
if !set_cookie_headers.is_empty() {
|
||||
debug!("Storing {} cookies from response", set_cookie_headers.len());
|
||||
cookie_store.store_cookies_from_response(&url, &set_cookie_headers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !Self::is_redirect(response.status) {
|
||||
// Not a redirect - return the response for caller to consume body
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
if !request.options.follow_redirects {
|
||||
// Redirects disabled - return the redirect response as-is
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// Check if we've exceeded max redirects
|
||||
if redirect_count >= self.max_redirects {
|
||||
// Drain the response before returning error
|
||||
let _ = response.drain().await;
|
||||
return Err(crate::error::Error::RequestError(format!(
|
||||
"Maximum redirect limit ({}) exceeded",
|
||||
self.max_redirects
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract Location header before draining (headers are available immediately)
|
||||
// HTTP headers are case-insensitive, so we need to search for any casing
|
||||
let location = response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("location"))
|
||||
.map(|(_, v)| v.clone())
|
||||
.ok_or_else(|| {
|
||||
crate::error::Error::RequestError(
|
||||
"Redirect response missing Location header".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Also get status before draining
|
||||
let status = response.status;
|
||||
|
||||
send_event(HttpResponseEvent::Info("Ignoring the response body".to_string()));
|
||||
|
||||
// Drain the redirect response body before following
|
||||
response.drain().await?;
|
||||
|
||||
// Update the request URL
|
||||
let previous_url = current_url.clone();
|
||||
current_url = if location.starts_with("http://") || location.starts_with("https://") {
|
||||
// Absolute URL
|
||||
location
|
||||
} else if location.starts_with('/') {
|
||||
// Absolute path - need to extract base URL from current request
|
||||
let base_url = Self::extract_base_url(¤t_url)?;
|
||||
format!("{}{}", base_url, location)
|
||||
} else {
|
||||
// Relative path - need to resolve relative to current path
|
||||
let base_path = Self::extract_base_path(¤t_url)?;
|
||||
format!("{}/{}", base_path, location)
|
||||
};
|
||||
|
||||
// Determine redirect behavior based on status code and method
|
||||
let behavior = if status == 303 {
|
||||
// 303 See Other always changes to GET
|
||||
RedirectBehavior::DropBody
|
||||
} else if (status == 301 || status == 302) && current_method == "POST" {
|
||||
// For 301/302, change POST to GET (common browser behavior)
|
||||
RedirectBehavior::DropBody
|
||||
} else {
|
||||
// For 307 and 308, the method and body are preserved
|
||||
// Also for 301/302 with non-POST methods
|
||||
RedirectBehavior::Preserve
|
||||
};
|
||||
|
||||
let mut dropped_headers =
|
||||
Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url);
|
||||
|
||||
// Handle method changes for certain redirect codes
|
||||
if matches!(behavior, RedirectBehavior::DropBody) {
|
||||
if current_method != "GET" {
|
||||
current_method = "GET".to_string();
|
||||
}
|
||||
// Remove content-related headers
|
||||
current_headers.retain(|h| {
|
||||
let name_lower = h.0.to_lowercase();
|
||||
let should_drop =
|
||||
name_lower.starts_with("content-") || name_lower == "transfer-encoding";
|
||||
if should_drop {
|
||||
Self::push_header_if_missing(&mut dropped_headers, &h.0);
|
||||
}
|
||||
!should_drop
|
||||
});
|
||||
}
|
||||
|
||||
// Restore body for Preserve redirects (307/308), drop for others.
|
||||
// Stream bodies can't be replayed (same limitation as reqwest).
|
||||
current_body = if matches!(behavior, RedirectBehavior::Preserve) {
|
||||
if request_had_body && preserved_body.is_none() {
|
||||
// Stream body was consumed and can't be replayed (same as reqwest)
|
||||
return Err(crate::error::Error::RequestError(
|
||||
"Cannot follow redirect: request body was a stream and cannot be resent"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
preserved_body
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Body was dropped if the request had one but we can't resend it
|
||||
let dropped_body = request_had_body && current_body.is_none();
|
||||
|
||||
send_event(HttpResponseEvent::Redirect {
|
||||
url: current_url.clone(),
|
||||
status,
|
||||
behavior: behavior.clone(),
|
||||
dropped_body,
|
||||
dropped_headers,
|
||||
});
|
||||
|
||||
redirect_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove sensitive headers when redirecting to a different host.
|
||||
/// This matches reqwest's `remove_sensitive_headers()` behavior and prevents
|
||||
/// credentials from being forwarded to third-party servers (e.g., an
|
||||
/// Authorization header sent from an API redirect to an S3 bucket).
|
||||
fn remove_sensitive_headers(
|
||||
headers: &mut Vec<(String, String)>,
|
||||
previous_url: &str,
|
||||
next_url: &str,
|
||||
) -> Vec<String> {
|
||||
let mut dropped_headers = Vec::new();
|
||||
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
|
||||
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
|
||||
});
|
||||
let next_host = Url::parse(next_url).ok().and_then(|u| {
|
||||
u.host_str().map(|h| format!("{}:{}", h, u.port_or_known_default().unwrap_or(0)))
|
||||
});
|
||||
if previous_host != next_host {
|
||||
headers.retain(|h| {
|
||||
let name_lower = h.0.to_lowercase();
|
||||
let should_drop = name_lower == "authorization"
|
||||
|| name_lower == "cookie"
|
||||
|| name_lower == "cookie2"
|
||||
|| name_lower == "proxy-authorization"
|
||||
|| name_lower == "www-authenticate";
|
||||
if should_drop {
|
||||
Self::push_header_if_missing(&mut dropped_headers, &h.0);
|
||||
}
|
||||
!should_drop
|
||||
});
|
||||
}
|
||||
dropped_headers
|
||||
}
|
||||
|
||||
fn push_header_if_missing(headers: &mut Vec<String>, name: &str) {
|
||||
if !headers.iter().any(|h| h.eq_ignore_ascii_case(name)) {
|
||||
headers.push(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a status code indicates a redirect
|
||||
fn is_redirect(status: u16) -> bool {
|
||||
matches!(status, 301 | 302 | 303 | 307 | 308)
|
||||
}
|
||||
|
||||
/// Extract the base URL (scheme + host) from a full URL
|
||||
fn extract_base_url(url: &str) -> Result<String> {
|
||||
// Find the position after "://"
|
||||
let scheme_end = url.find("://").ok_or_else(|| {
|
||||
crate::error::Error::RequestError(format!("Invalid URL format: {}", url))
|
||||
})?;
|
||||
|
||||
// Find the first '/' after the scheme
|
||||
let path_start = url[scheme_end + 3..].find('/');
|
||||
|
||||
if let Some(idx) = path_start {
|
||||
Ok(url[..scheme_end + 3 + idx].to_string())
|
||||
} else {
|
||||
// No path, return entire URL
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the base path (everything except the last segment) from a URL
|
||||
fn extract_base_path(url: &str) -> Result<String> {
|
||||
if let Some(last_slash) = url.rfind('/') {
|
||||
// Don't include the trailing slash if it's part of the host
|
||||
if url[..last_slash].ends_with("://") || url[..last_slash].ends_with(':') {
|
||||
Ok(url.to_string())
|
||||
} else {
|
||||
Ok(url[..last_slash].to_string())
|
||||
}
|
||||
} else {
|
||||
Ok(url.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::decompress::ContentEncoding;
|
||||
use crate::sender::{HttpResponseEvent, HttpSender};
|
||||
use async_trait::async_trait;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncRead;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Captured request metadata for test assertions
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct CapturedRequest {
|
||||
url: String,
|
||||
method: String,
|
||||
headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Mock sender for testing
|
||||
struct MockSender {
|
||||
responses: Arc<Mutex<Vec<MockResponse>>>,
|
||||
/// Captured requests for assertions
|
||||
captured_requests: Arc<Mutex<Vec<CapturedRequest>>>,
|
||||
}
|
||||
|
||||
struct MockResponse {
|
||||
status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl MockSender {
|
||||
fn new(responses: Vec<MockResponse>) -> Self {
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(responses)),
|
||||
captured_requests: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpSender for MockSender {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
// Capture the request metadata for later assertions
|
||||
self.captured_requests.lock().await.push(CapturedRequest {
|
||||
url: request.url.clone(),
|
||||
method: request.method.clone(),
|
||||
headers: request.headers.clone(),
|
||||
});
|
||||
|
||||
let mut responses = self.responses.lock().await;
|
||||
if responses.is_empty() {
|
||||
Err(crate::error::Error::RequestError("No more mock responses".to_string()))
|
||||
} else {
|
||||
let mock = responses.remove(0);
|
||||
// Create a simple in-memory stream from the body
|
||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||
Box::pin(std::io::Cursor::new(mock.body));
|
||||
Ok(HttpResponse::new(
|
||||
mock.status,
|
||||
None, // status_reason
|
||||
mock.headers,
|
||||
Vec::new(),
|
||||
None, // content_length
|
||||
"https://example.com".to_string(), // url
|
||||
None, // remote_addr
|
||||
Some("HTTP/1.1".to_string()), // version
|
||||
body_stream,
|
||||
ContentEncoding::Identity,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transaction_no_redirect() {
|
||||
let response = MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() };
|
||||
let sender = MockSender::new(vec![response]);
|
||||
let transaction = HttpTransaction::new(sender);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
// Consume the body to verify it
|
||||
let (body, _) = result.bytes().await.unwrap();
|
||||
assert_eq!(body, b"OK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transaction_single_redirect() {
|
||||
let redirect_headers =
|
||||
vec![("Location".to_string(), "https://example.com/new".to_string())];
|
||||
|
||||
let responses = vec![
|
||||
MockResponse { status: 302, headers: redirect_headers, body: vec![] },
|
||||
MockResponse { status: 200, headers: Vec::new(), body: b"Final".to_vec() },
|
||||
];
|
||||
|
||||
let sender = MockSender::new(responses);
|
||||
let transaction = HttpTransaction::new(sender);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com/old".to_string(),
|
||||
method: "GET".to_string(),
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
let (body, _) = result.bytes().await.unwrap();
|
||||
assert_eq!(body, b"Final");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_transaction_max_redirects_exceeded() {
|
||||
let redirect_headers =
|
||||
vec![("Location".to_string(), "https://example.com/loop".to_string())];
|
||||
|
||||
// Create more redirects than allowed
|
||||
let responses: Vec<MockResponse> = (0..12)
|
||||
.map(|_| MockResponse { status: 302, headers: redirect_headers.clone(), body: vec![] })
|
||||
.collect();
|
||||
|
||||
let sender = MockSender::new(responses);
|
||||
let transaction = HttpTransaction::with_max_redirects(sender, 10);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com/start".to_string(),
|
||||
method: "GET".to_string(),
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||
if let Err(crate::error::Error::RequestError(msg)) = result {
|
||||
assert!(msg.contains("Maximum redirect limit"));
|
||||
} else {
|
||||
panic!("Expected RequestError with max redirect message. Got {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_redirect() {
|
||||
assert!(HttpTransaction::<MockSender>::is_redirect(301));
|
||||
assert!(HttpTransaction::<MockSender>::is_redirect(302));
|
||||
assert!(HttpTransaction::<MockSender>::is_redirect(303));
|
||||
assert!(HttpTransaction::<MockSender>::is_redirect(307));
|
||||
assert!(HttpTransaction::<MockSender>::is_redirect(308));
|
||||
assert!(!HttpTransaction::<MockSender>::is_redirect(200));
|
||||
assert!(!HttpTransaction::<MockSender>::is_redirect(404));
|
||||
assert!(!HttpTransaction::<MockSender>::is_redirect(500));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_url() {
|
||||
let result =
|
||||
HttpTransaction::<MockSender>::extract_base_url("https://example.com/path/to/resource");
|
||||
assert_eq!(result.unwrap(), "https://example.com");
|
||||
|
||||
let result = HttpTransaction::<MockSender>::extract_base_url("http://localhost:8080/api");
|
||||
assert_eq!(result.unwrap(), "http://localhost:8080");
|
||||
|
||||
let result = HttpTransaction::<MockSender>::extract_base_url("invalid-url");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_base_path() {
|
||||
let result = HttpTransaction::<MockSender>::extract_base_path(
|
||||
"https://example.com/path/to/resource",
|
||||
);
|
||||
assert_eq!(result.unwrap(), "https://example.com/path/to");
|
||||
|
||||
let result = HttpTransaction::<MockSender>::extract_base_path("https://example.com/single");
|
||||
assert_eq!(result.unwrap(), "https://example.com");
|
||||
|
||||
let result = HttpTransaction::<MockSender>::extract_base_path("https://example.com/");
|
||||
assert_eq!(result.unwrap(), "https://example.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cookie_injection() {
|
||||
// Create a mock sender that verifies the Cookie header was injected
|
||||
struct CookieVerifyingSender {
|
||||
expected_cookie: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpSender for CookieVerifyingSender {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
// Verify the Cookie header was injected
|
||||
let cookie_header =
|
||||
request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("cookie"));
|
||||
|
||||
assert!(cookie_header.is_some(), "Cookie header should be present");
|
||||
assert!(
|
||||
cookie_header.unwrap().1.contains(&self.expected_cookie),
|
||||
"Cookie header should contain expected value"
|
||||
);
|
||||
|
||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||
Box::pin(std::io::Cursor::new(vec![]));
|
||||
Ok(HttpResponse::new(
|
||||
200,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
None,
|
||||
"https://example.com".to_string(),
|
||||
None,
|
||||
Some("HTTP/1.1".to_string()),
|
||||
body_stream,
|
||||
ContentEncoding::Identity,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
use yaak_models::models::{Cookie, CookieDomain, CookieExpires};
|
||||
|
||||
// Create a cookie store with a test cookie
|
||||
let cookie = Cookie {
|
||||
raw_cookie: "session=abc123".to_string(),
|
||||
domain: CookieDomain::HostOnly("example.com".to_string()),
|
||||
expires: CookieExpires::SessionEnd,
|
||||
path: ("/".to_string(), false),
|
||||
};
|
||||
let cookie_store = CookieStore::from_cookies(vec![cookie]);
|
||||
|
||||
let sender = CookieVerifyingSender { expected_cookie: "session=abc123".to_string() };
|
||||
let transaction = HttpTransaction::with_cookie_store(sender, cookie_store);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com/api".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_cookie_parsing() {
|
||||
// Create a cookie store
|
||||
let cookie_store = CookieStore::new();
|
||||
|
||||
// Mock sender that returns a Set-Cookie header
|
||||
struct SetCookieSender;
|
||||
|
||||
#[async_trait]
|
||||
impl HttpSender for SetCookieSender {
|
||||
async fn send(
|
||||
&self,
|
||||
_request: SendableHttpRequest,
|
||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
let headers =
|
||||
vec![("set-cookie".to_string(), "session=xyz789; Path=/".to_string())];
|
||||
|
||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||
Box::pin(std::io::Cursor::new(vec![]));
|
||||
Ok(HttpResponse::new(
|
||||
200,
|
||||
None,
|
||||
headers,
|
||||
Vec::new(),
|
||||
None,
|
||||
"https://example.com".to_string(),
|
||||
None,
|
||||
Some("HTTP/1.1".to_string()),
|
||||
body_stream,
|
||||
ContentEncoding::Identity,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let sender = SetCookieSender;
|
||||
let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone());
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com/login".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify the cookie was stored
|
||||
let cookies = cookie_store.get_all_cookies();
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(cookies[0].raw_cookie.contains("session=xyz789"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_multiple_set_cookie_headers() {
|
||||
// Create a cookie store
|
||||
let cookie_store = CookieStore::new();
|
||||
|
||||
// Mock sender that returns multiple Set-Cookie headers
|
||||
struct MultiSetCookieSender;
|
||||
|
||||
#[async_trait]
|
||||
impl HttpSender for MultiSetCookieSender {
|
||||
async fn send(
|
||||
&self,
|
||||
_request: SendableHttpRequest,
|
||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
// Multiple Set-Cookie headers (this is standard HTTP behavior)
|
||||
let headers = vec![
|
||||
("set-cookie".to_string(), "session=abc123; Path=/".to_string()),
|
||||
("set-cookie".to_string(), "user_id=42; Path=/".to_string()),
|
||||
(
|
||||
"set-cookie".to_string(),
|
||||
"preferences=dark; Path=/; Max-Age=86400".to_string(),
|
||||
),
|
||||
];
|
||||
|
||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||
Box::pin(std::io::Cursor::new(vec![]));
|
||||
Ok(HttpResponse::new(
|
||||
200,
|
||||
None,
|
||||
headers,
|
||||
Vec::new(),
|
||||
None,
|
||||
"https://example.com".to_string(),
|
||||
None,
|
||||
Some("HTTP/1.1".to_string()),
|
||||
body_stream,
|
||||
ContentEncoding::Identity,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let sender = MultiSetCookieSender;
|
||||
let transaction = HttpTransaction::with_cookie_store(sender, cookie_store.clone());
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com/login".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: vec![],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify all three cookies were stored
|
||||
let cookies = cookie_store.get_all_cookies();
|
||||
assert_eq!(cookies.len(), 3, "All three Set-Cookie headers should be parsed and stored");
|
||||
|
||||
let cookie_values: Vec<&str> = cookies.iter().map(|c| c.raw_cookie.as_str()).collect();
|
||||
assert!(
|
||||
cookie_values.iter().any(|c| c.contains("session=abc123")),
|
||||
"session cookie should be stored"
|
||||
);
|
||||
assert!(
|
||||
cookie_values.iter().any(|c| c.contains("user_id=42")),
|
||||
"user_id cookie should be stored"
|
||||
);
|
||||
assert!(
|
||||
cookie_values.iter().any(|c| c.contains("preferences=dark")),
|
||||
"preferences cookie should be stored"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cookies_across_redirects() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
// Create a cookie store
|
||||
let cookie_store = CookieStore::new();
|
||||
|
||||
// Track request count
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let request_count_clone = request_count.clone();
|
||||
|
||||
struct RedirectWithCookiesSender {
|
||||
request_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpSender for RedirectWithCookiesSender {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
_event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> Result<HttpResponse> {
|
||||
let count = self.request_count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
let (status, headers) = if count == 0 {
|
||||
// First request: return redirect with Set-Cookie
|
||||
let h = vec![
|
||||
("location".to_string(), "https://example.com/final".to_string()),
|
||||
("set-cookie".to_string(), "redirect_cookie=value1".to_string()),
|
||||
];
|
||||
(302, h)
|
||||
} else {
|
||||
// Second request: verify cookie was sent
|
||||
let cookie_header =
|
||||
request.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case("cookie"));
|
||||
|
||||
assert!(cookie_header.is_some(), "Cookie header should be present on redirect");
|
||||
assert!(
|
||||
cookie_header.unwrap().1.contains("redirect_cookie=value1"),
|
||||
"Redirect cookie should be included"
|
||||
);
|
||||
|
||||
(200, Vec::new())
|
||||
};
|
||||
|
||||
let body_stream: Pin<Box<dyn AsyncRead + Send>> =
|
||||
Box::pin(std::io::Cursor::new(vec![]));
|
||||
Ok(HttpResponse::new(
|
||||
status,
|
||||
None,
|
||||
headers,
|
||||
Vec::new(),
|
||||
None,
|
||||
"https://example.com".to_string(),
|
||||
None,
|
||||
Some("HTTP/1.1".to_string()),
|
||||
body_stream,
|
||||
ContentEncoding::Identity,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let sender = RedirectWithCookiesSender { request_count: request_count_clone };
|
||||
let transaction = HttpTransaction::with_cookie_store(sender, cookie_store);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://example.com/start".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![],
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(request_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cross_origin_redirect_strips_auth_headers() {
|
||||
// Redirect from api.example.com -> s3.amazonaws.com should strip Authorization
|
||||
let responses = vec![
|
||||
MockResponse {
|
||||
status: 302,
|
||||
headers: vec![(
|
||||
"Location".to_string(),
|
||||
"https://s3.amazonaws.com/bucket/file.pdf".to_string(),
|
||||
)],
|
||||
body: vec![],
|
||||
},
|
||||
MockResponse { status: 200, headers: Vec::new(), body: b"PDF content".to_vec() },
|
||||
];
|
||||
|
||||
let sender = MockSender::new(responses);
|
||||
let captured = sender.captured_requests.clone();
|
||||
let transaction = HttpTransaction::new(sender);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://api.example.com/download".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![
|
||||
("Authorization".to_string(), "Basic dXNlcjpwYXNz".to_string()),
|
||||
("Accept".to_string(), "application/pdf".to_string()),
|
||||
],
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
let requests = captured.lock().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
// First request should have the Authorization header
|
||||
assert!(
|
||||
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"First request should have Authorization header"
|
||||
);
|
||||
|
||||
// Second request (to different host) should NOT have the Authorization header
|
||||
assert!(
|
||||
!requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"Redirected request to different host should NOT have Authorization header"
|
||||
);
|
||||
|
||||
// Non-sensitive headers should still be present
|
||||
assert!(
|
||||
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("accept")),
|
||||
"Non-sensitive headers should be preserved across cross-origin redirects"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_same_origin_redirect_preserves_auth_headers() {
|
||||
// Redirect within the same host should keep Authorization
|
||||
let responses = vec![
|
||||
MockResponse {
|
||||
status: 302,
|
||||
headers: vec![(
|
||||
"Location".to_string(),
|
||||
"https://api.example.com/v2/download".to_string(),
|
||||
)],
|
||||
body: vec![],
|
||||
},
|
||||
MockResponse { status: 200, headers: Vec::new(), body: b"OK".to_vec() },
|
||||
];
|
||||
|
||||
let sender = MockSender::new(responses);
|
||||
let captured = sender.captured_requests.clone();
|
||||
let transaction = HttpTransaction::new(sender);
|
||||
|
||||
let request = SendableHttpRequest {
|
||||
url: "https://api.example.com/v1/download".to_string(),
|
||||
method: "GET".to_string(),
|
||||
headers: vec![
|
||||
("Authorization".to_string(), "Bearer token123".to_string()),
|
||||
("Accept".to_string(), "application/json".to_string()),
|
||||
],
|
||||
options: crate::types::SendableHttpRequestOptions {
|
||||
follow_redirects: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (_tx, rx) = tokio::sync::watch::channel(false);
|
||||
let (event_tx, _event_rx) = mpsc::channel(100);
|
||||
let result = transaction.execute_with_cancellation(request, rx, event_tx).await.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
|
||||
let requests = captured.lock().await;
|
||||
assert_eq!(requests.len(), 2);
|
||||
|
||||
// Both requests should have the Authorization header (same host)
|
||||
assert!(
|
||||
requests[0].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"First request should have Authorization header"
|
||||
);
|
||||
assert!(
|
||||
requests[1].headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
|
||||
"Redirected request to same host should preserve Authorization header"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user