Compare commits

..

49 Commits

Author SHA1 Message Date
Gregory Schier
b563319bed Fix biome lint: update schema to 2.3.13, exclude npm dir, fix lint errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:19:05 -08:00
Gregory Schier
3d577dd7d9 Update release skills for CLI 2026-03-05 16:06:40 -08:00
Gregory Schier
591c68c59c Revert macOS CI runners back to macos-latest
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:59:10 -08:00
Gregory Schier
a0cb7f813f Replace format-graphql with pretty_graphql for comment-preserving GraphQL formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:53:13 -08:00
Gregory Schier
cfab62707e Exclude yaak-cli from app release tests and remove stale lint comments
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:36:09 -08:00
Gregory Schier
267508e533 Support comments in JSON body (#419)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:05:09 -08:00
Gregory Schier
242f55b609 Fix macOS Tahoe stoplight positioning and build on macOS 26
On macOS Tahoe (26+), the default title bar is 32px with 14px buttons,
so the old formula (button_height + PAD_Y = 14 + 18 = 32) produced no
change. Add TITLEBAR_EXTRA_HEIGHT to push the title bar taller than
the Tahoe default. Use OnceLock to capture the original default height
so repeated calls don't accumulate extra pixels.

Also update CI runners to macos-26 for Tahoe SDK builds and adjust
frontend padding for larger stoplights.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:56:19 -08:00
dependabot[bot]
67a3dd15ac Bump @hono/node-server from 1.19.9 to 1.19.10 (#417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 13:24:21 -08:00
dependabot[bot]
543325613b Bump hono from 4.11.10 to 4.12.4 (#416)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 13:24:04 -08:00
Gregory Schier
88f5f0e045 Add redirect drop metadata and warning UI (#418) 2026-03-05 06:14:11 -08:00
Gregory Schier
615f3134d2 Fix plugin settings layout 2026-03-04 09:21:15 -08:00
Gregory Schier
0c7051d59c Better external OAuth callback format 2026-03-04 09:10:49 -08:00
Gregory Schier
30f006401a CLI plugin host: handle send/render HTTP requests (#415)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:41:53 -08:00
Gregory Schier
3c12074db6 Mention CLI in main dropdown 2026-03-03 06:50:25 -08:00
Gregory Schier
851f12f149 app: detect CLI availability and add command palette copy action 2026-03-02 14:55:04 -08:00
Gregory Schier
cc0d31fdbb cli: use update.yaak.app /cli/check for version checks 2026-03-02 14:52:23 -08:00
Gregory Schier
bab4fe899b Upgrade Yaak CLI 2026-03-02 08:01:02 -08:00
Gregory Schier
0b250ff5b5 Fix lint 2026-03-02 07:52:08 -08:00
Gregory Schier
fbf0473b20 Fix plugin dev rebuild signaling and reload toast timing 2026-03-02 07:02:28 -08:00
Gregory Schier
876b7ef454 Add top-level generate and publish CLI aliases 2026-03-02 06:28:04 -08:00
Gregory Schier
96e8572758 Add CLI update check and API client kind identity 2026-03-02 06:21:00 -08:00
Gregory Schier
f302dc39a2 Move local plugin install command into plugins_ext 2026-03-01 16:42:13 -08:00
Gregory Schier
2ca51125a4 Improve plugin source modeling and runtime dedup (#414) 2026-03-01 16:30:43 -08:00
Gregory Schier
2d99e26f19 plugin-events: route model/find requests through shared handler (#409) 2026-02-28 14:16:32 -08:00
Gregory Schier
da1e04d99e Fix Copy as gRPCurl with template-tag payloads (#413) 2026-02-28 07:39:44 -08:00
dependabot[bot]
d875eaa5bf Bump minimatch from 3.1.2 to 3.1.5 (#411)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 07:29:04 -08:00
Gregory Schier
5fa2469cd6 Some cleanup 2026-02-28 07:24:30 -08:00
Gregory Schier
49053cb423 Fix [object Object] request descriptions after OpenAPI import (#412) 2026-02-27 15:36:46 -08:00
Gregory Schier
37d0cabb22 fix: preserve drive letter in Windows plugin paths (#410)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:23:24 -08:00
Gregory Schier
435ee54140 docs: clarify app vs cli tag naming 2026-02-26 09:40:58 -08:00
dependabot[bot]
407f2c9921 Bump rollup from 4.55.1 to 4.59.0 (#406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-26 09:31:25 -08:00
Gregory Schier
3a6630a14d tests(cli): prevent request send test teardown hang (#408) 2026-02-26 08:58:13 -08:00
Gregory Schier
ffc80d234c Remove noisy logs 2026-02-26 08:13:18 -08:00
Gregory Schier
644c683714 Update CLI package 2026-02-26 08:13:07 -08:00
Gregory Schier
d2c1bd79ac Fix chaining multiple requests together.
Fixes
https://yaak.app/feedback/posts/request-chaining-issue-on-cold-start
2026-02-26 08:12:54 -08:00
Gregory Schier
020589f2e6 fix: keep Send All response updates window-scoped (#405) 2026-02-25 06:54:59 -08:00
Gregory Schier
b2a70d8938 cli: prep 0.4.0 stable release 2026-02-23 08:55:16 -08:00
Gregory Schier
64c626ed30 Improve CLI streaming output, logging flags, and schema/help ergonomics 2026-02-23 08:06:41 -08:00
Gregory Schier
35d9ed901a Add workspace/environment schemas and shared agent hints 2026-02-23 08:06:41 -08:00
gschier
f04b34be1a Deploying to main from @ mountain-loop/yaak@1e7e1232da 🚀 2026-02-23 15:47:59 +00:00
Gregory Schier
1e7e1232da cli: compact schema output by default and tighten field docs 2026-02-22 16:18:29 -08:00
Gregory Schier
c31d477a90 cli: share HTTP helpers and improve schema guidance 2026-02-22 16:03:05 -08:00
Gregory Schier
443e1b8262 ci(cli): stamp Cargo version from release tag before build 2026-02-22 15:35:42 -08:00
Gregory Schier
c6b7cb2e32 ci(cli): use build script with SKIP_WASM_BUILD in release workflow 2026-02-22 15:26:21 -08:00
Gregory Schier
4aef826a80 Initialize plugins in PluginManager::new and fix CLI release deps 2026-02-22 15:06:55 -08:00
Gregory Schier
50c7992b42 Unify plugin bootstrap and prep vendored assets in CLI release 2026-02-22 15:01:34 -08:00
Gregory Schier
5e9aebda6f Embed CLI plugin assets and share bundled plugin registration 2026-02-22 14:44:40 -08:00
Gregory Schier
a1e84c7785 Bump @yaakapp/cli to 0.4.0-beta.2 2026-02-22 14:28:49 -08:00
Gregory Schier
fea4411afa Remove recursive API npm publish script 2026-02-22 14:21:07 -08:00
140 changed files with 5325 additions and 1944 deletions

View File

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

View File

@@ -37,6 +37,7 @@ The skill generates markdown-formatted release notes following this structure:
**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

View File

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

View File

@@ -1,46 +0,0 @@
---
name: release-check-out-pr
description: Check out a GitHub pull request for review in this repo, either in the current directory or in a new isolated worktree at ../yaak-worktrees/pr-<PR_NUMBER>. Use when asked to run or replace the old Claude check-out-pr command.
---
# Check Out PR
Check out a PR by number and let the user choose between current-directory checkout and isolated worktree checkout.
## Workflow
1. Confirm `gh` CLI is available.
2. If no PR number is provided, list open PRs (`gh pr list`) and ask the user to choose one.
3. Read PR metadata:
- `gh pr view <PR_NUMBER> --json number,headRefName`
4. Ask the user to choose:
- Option A: check out in the current directory
- Option B: create a new worktree at `../yaak-worktrees/pr-<PR_NUMBER>`
## Option A: Current Directory
1. Run:
- `gh pr checkout <PR_NUMBER>`
2. Report the checked-out branch.
## Option B: New Worktree
1. Use path:
- `../yaak-worktrees/pr-<PR_NUMBER>`
2. Create the worktree with a timeout of at least 5 minutes because checkout hooks run bootstrap.
3. In the new worktree, run:
- `gh pr checkout <PR_NUMBER>`
4. Report:
- Worktree path
- Assigned ports from `.env.local` if present
- How to start work:
- `cd ../yaak-worktrees/pr-<PR_NUMBER>`
- `npm run app-dev`
- How to remove when done:
- `git worktree remove ../yaak-worktrees/pr-<PR_NUMBER>`
## Error Handling
- If PR does not exist, show a clear error.
- If worktree already exists, ask whether to reuse it or remove/recreate it.
- If `gh` is missing, instruct the user to install/authenticate it.

View File

@@ -32,6 +32,7 @@ Generate formatted markdown release notes for a Yaak tag.
- 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

View File

@@ -1,37 +0,0 @@
---
name: worktree-management
description: Manage Yaak git worktrees using the standard ../yaak-worktrees/<NAME> layout, including creation, removal, and expected automatic setup behavior and port assignments.
---
# Worktree Management
Use the Yaak-standard worktree path layout and lifecycle commands.
## Path Convention
Always create worktrees under:
`../yaak-worktrees/<NAME>`
Examples:
- `git worktree add ../yaak-worktrees/feature-auth`
- `git worktree add ../yaak-worktrees/bugfix-login`
- `git worktree add ../yaak-worktrees/refactor-api`
## Automatic Setup After Checkout
Project git hooks automatically:
1. Create `.env.local` with unique `YAAK_DEV_PORT` and `YAAK_PLUGIN_MCP_SERVER_PORT`
2. Copy gitignored editor config folders
3. Run `npm install && npm run bootstrap`
## Remove Worktree
`git worktree remove ../yaak-worktrees/<NAME>`
## Port Pattern
- Main worktree: Vite `1420`, MCP `64343`
- First extra worktree: `1421`, `64344`
- Second extra worktree: `1422`, `64345`
- Continue incrementally for additional worktrees

View File

@@ -95,7 +95,7 @@ jobs:
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
run: cargo test --all --exclude yaak-cli
- name: Set version
run: npm run replace-version

View File

@@ -14,8 +14,44 @@ 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
@@ -67,6 +103,27 @@ jobs:
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 }}

3
.gitignore vendored
View File

@@ -54,3 +54,6 @@ flatpak/node-sources.json
# Local Codex desktop env state
.codex/environments/environment.toml
# Claude Code local settings
.claude/settings.local.json

2
AGENTS.md Normal file
View File

@@ -0,0 +1,2 @@
- 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

245
Cargo.lock generated
View File

@@ -173,6 +173,17 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "apollo-parser"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "947e21ff51879f8a40d7519dfe619268de2afba4042a8a43878276de3cb910f0"
dependencies = [
"memchr",
"rowan",
"thiserror 2.0.17",
]
[[package]]
name = "append-only-vec"
version = "0.1.8"
@@ -1200,7 +1211,7 @@ dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"unicode-width 0.2.2",
"windows-sys 0.59.0",
]
@@ -1347,6 +1358,12 @@ dependencies = [
"libc",
]
[[package]]
name = "countme"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cow-utils"
version = "0.1.3"
@@ -1405,6 +1422,31 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
dependencies = [
"bitflags 1.3.2",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"parking_lot",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.3"
@@ -2294,6 +2336,15 @@ dependencies = [
"slab",
]
[[package]]
name = "fuzzy-matcher"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
dependencies = [
"thread_local 1.1.9",
]
[[package]]
name = "fxhash"
version = "0.2.1"
@@ -3164,6 +3215,24 @@ dependencies = [
"generic-array",
]
[[package]]
name = "inquire"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
dependencies = [
"bitflags 2.11.0",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"fxhash",
"newline-converter",
"once_cell",
"tempfile",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "interfaces"
version = "0.0.8"
@@ -3756,6 +3825,18 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log 0.4.29",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.0.4"
@@ -3851,6 +3932,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -3942,7 +4032,7 @@ dependencies = [
"kqueue",
"libc",
"log 0.4.29",
"mio",
"mio 1.0.4",
"notify-types",
"walkdir",
"windows-sys 0.59.0",
@@ -4483,7 +4573,7 @@ checksum = "75b1853bc34cadaa90aa09f95713d8b77ec0c0d3e2d90ccf7a74216f40d20850"
dependencies = [
"flate2",
"postcard",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"thiserror 2.0.17",
@@ -4501,7 +4591,7 @@ dependencies = [
"textwrap",
"thiserror 2.0.17",
"unicode-segmentation",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -4526,7 +4616,7 @@ dependencies = [
"hashbrown 0.16.1",
"oxc_data_structures",
"oxc_estree",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
]
@@ -4582,7 +4672,7 @@ dependencies = [
"oxc_index",
"oxc_syntax",
"petgraph 0.8.3",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -4603,7 +4693,7 @@ dependencies = [
"oxc_sourcemap",
"oxc_span",
"oxc_syntax",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -4615,7 +4705,7 @@ dependencies = [
"cow-utils",
"oxc-browserslist",
"oxc_syntax",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
]
@@ -4690,7 +4780,7 @@ dependencies = [
"oxc_ecmascript",
"oxc_span",
"oxc_syntax",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -4707,7 +4797,7 @@ dependencies = [
"oxc_semantic",
"oxc_span",
"oxc_syntax",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -4732,7 +4822,7 @@ dependencies = [
"oxc_span",
"oxc_syntax",
"oxc_traverse",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -4754,7 +4844,7 @@ dependencies = [
"oxc_regular_expression",
"oxc_span",
"oxc_syntax",
"rustc-hash",
"rustc-hash 2.1.1",
"seq-macro",
]
@@ -4770,7 +4860,7 @@ dependencies = [
"oxc_diagnostics",
"oxc_span",
"phf 0.13.1",
"rustc-hash",
"rustc-hash 2.1.1",
"unicode-id-start",
]
@@ -4787,7 +4877,7 @@ dependencies = [
"once_cell",
"papaya",
"pnp",
"rustc-hash",
"rustc-hash 2.1.1",
"self_cell",
"serde",
"serde_json",
@@ -4817,7 +4907,7 @@ dependencies = [
"oxc_span",
"oxc_syntax",
"phf 0.13.1",
"rustc-hash",
"rustc-hash 2.1.1",
"self_cell",
]
@@ -4829,7 +4919,7 @@ checksum = "c7f89482522f3cd820817d48ee4ade5b10822060d6e5e4d419f05f6d8bd29d70"
dependencies = [
"base64-simd",
"json-escape-simd",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
]
@@ -4893,7 +4983,7 @@ dependencies = [
"oxc_span",
"oxc_syntax",
"oxc_traverse",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"sha1",
@@ -4918,7 +5008,7 @@ dependencies = [
"oxc_syntax",
"oxc_transformer",
"oxc_traverse",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -4936,7 +5026,7 @@ dependencies = [
"oxc_semantic",
"oxc_span",
"oxc_syntax",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -5341,7 +5431,7 @@ dependencies = [
"nodejs-built-in-modules",
"pathdiff",
"radix_trie",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"thiserror 2.0.17",
@@ -5460,6 +5550,18 @@ dependencies = [
"termtree",
]
[[package]]
name = "pretty_graphql"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea8c38ecedb3d28a998ea783469a78587f5f984d61226cf071f6979861e9e6a9"
dependencies = [
"apollo-parser",
"memchr",
"rowan",
"tiny_pretty",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
@@ -5640,7 +5742,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustc-hash 2.1.1",
"rustls",
"socket2 0.5.10",
"thiserror 2.0.17",
@@ -5660,7 +5762,7 @@ dependencies = [
"lru-slab",
"rand 0.9.1",
"ring",
"rustc-hash",
"rustc-hash 2.1.1",
"rustls",
"rustls-pki-types",
"slab",
@@ -6154,7 +6256,7 @@ dependencies = [
"rolldown_tracing",
"rolldown_utils",
"rolldown_watcher",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"string_wizard",
@@ -6171,7 +6273,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967"
dependencies = [
"unicode-width",
"unicode-width 0.2.2",
"yansi",
]
@@ -6196,7 +6298,7 @@ dependencies = [
"kqueue",
"libc",
"log 0.4.29",
"mio",
"mio 1.0.4",
"rolldown-notify-types",
"walkdir",
"windows-sys 0.61.2",
@@ -6244,7 +6346,7 @@ dependencies = [
"rolldown_sourcemap",
"rolldown_std_utils",
"rolldown_utils",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"simdutf8",
@@ -6262,7 +6364,7 @@ dependencies = [
"blake3",
"dashmap",
"rolldown_debug_action",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"tracing",
@@ -6319,7 +6421,7 @@ dependencies = [
"rolldown-ariadne",
"rolldown_utils",
"ropey",
"rustc-hash",
"rustc-hash 2.1.1",
"sugar_path",
]
@@ -6353,7 +6455,7 @@ dependencies = [
"rolldown_resolver",
"rolldown_sourcemap",
"rolldown_utils",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"string_wizard",
@@ -6373,7 +6475,7 @@ dependencies = [
"rolldown_common",
"rolldown_plugin",
"rolldown_utils",
"rustc-hash",
"rustc-hash 2.1.1",
"serde_json",
"xxhash-rust",
]
@@ -6444,7 +6546,7 @@ dependencies = [
"oxc",
"oxc_sourcemap",
"rolldown_utils",
"rustc-hash",
"rustc-hash 2.1.1",
]
[[package]]
@@ -6496,7 +6598,7 @@ dependencies = [
"regex 1.11.1",
"regress",
"rolldown_std_utils",
"rustc-hash",
"rustc-hash 2.1.1",
"serde_json",
"simdutf8",
"sugar_path",
@@ -6526,6 +6628,18 @@ dependencies = [
"str_indices",
]
[[package]]
name = "rowan"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21"
dependencies = [
"countme",
"hashbrown 0.14.5",
"rustc-hash 1.1.0",
"text-size",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -6568,6 +6682,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -7173,6 +7293,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
@@ -7359,7 +7500,7 @@ dependencies = [
"memchr",
"oxc_index",
"oxc_sourcemap",
"rustc-hash",
"rustc-hash 2.1.1",
"serde",
]
@@ -8060,6 +8201,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]]
name = "textwrap"
version = "0.16.2"
@@ -8068,7 +8215,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
"unicode-width 0.2.2",
]
[[package]]
@@ -8182,6 +8329,12 @@ dependencies = [
"crunchy",
]
[[package]]
name = "tiny_pretty"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650d82e943da333637be9f1567d33d605e76810a26464edfd7ae74f7ef181e95"
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -8215,7 +8368,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
dependencies = [
"bytes",
"libc",
"mio",
"mio 1.0.4",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.1",
@@ -8785,6 +8938,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -9562,6 +9721,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -10089,6 +10257,7 @@ dependencies = [
"md5 0.8.0",
"mime_guess",
"openssl-sys",
"pretty_graphql",
"r2d2",
"r2d2_sqlite",
"rand 0.9.1",
@@ -10141,6 +10310,7 @@ dependencies = [
name = "yaak-cli"
version = "0.1.0"
dependencies = [
"arboard",
"assert_cmd",
"base64 0.22.1",
"clap",
@@ -10149,6 +10319,8 @@ dependencies = [
"env_logger",
"futures",
"hex",
"include_dir",
"inquire",
"keyring",
"log 0.4.29",
"oxc_resolver",
@@ -10165,6 +10337,7 @@ dependencies = [
"walkdir",
"webbrowser",
"yaak",
"yaak-api",
"yaak-crypto",
"yaak-http",
"yaak-models",
@@ -10286,6 +10459,7 @@ dependencies = [
"urlencoding",
"yaak-common",
"yaak-models",
"yaak-templates",
"yaak-tls",
"zstd",
]
@@ -10358,7 +10532,6 @@ dependencies = [
"md5 0.7.0",
"path-slash",
"rand 0.9.1",
"regex 1.11.1",
"reqwest",
"serde",
"serde_json",

View File

@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"linter": {
"enabled": true,
"rules": {
@@ -48,7 +48,8 @@
"!src-web/routeTree.gen.ts",
"!packages/plugin-runtime-types/lib",
"!**/bindings",
"!flatpak"
"!flatpak",
"!npm"
]
}
}

View File

@@ -9,13 +9,16 @@ 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"
@@ -31,6 +34,7 @@ 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 }

View File

@@ -1,87 +1,66 @@
# yaak-cli
# Yaak CLI
Command-line interface for Yaak.
The `yaak` CLI for publishing plugins and creating/updating/sending requests.
## Command Overview
## Installation
Current top-level commands:
```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
yaakcli send <request_id>
yaakcli workspace list
yaakcli workspace show <workspace_id>
yaakcli workspace create --name <name>
yaakcli workspace create --json '{"name":"My Workspace"}'
yaakcli workspace create '{"name":"My Workspace"}'
yaakcli workspace update --json '{"id":"wk_abc","description":"Updated"}'
yaakcli workspace delete <workspace_id> [--yes]
yaakcli request list <workspace_id>
yaakcli request show <request_id>
yaakcli request send <request_id>
yaakcli request create <workspace_id> --name <name> --url <url> [--method GET]
yaakcli request create --json '{"workspaceId":"wk_abc","name":"Users","url":"https://api.example.com/users"}'
yaakcli request create '{"workspaceId":"wk_abc","name":"Users","url":"https://api.example.com/users"}'
yaakcli request update --json '{"id":"rq_abc","name":"Users v2"}'
yaakcli request delete <request_id> [--yes]
yaakcli folder list <workspace_id>
yaakcli folder show <folder_id>
yaakcli folder create <workspace_id> --name <name>
yaakcli folder create --json '{"workspaceId":"wk_abc","name":"Auth"}'
yaakcli folder create '{"workspaceId":"wk_abc","name":"Auth"}'
yaakcli folder update --json '{"id":"fl_abc","name":"Auth v2"}'
yaakcli folder delete <folder_id> [--yes]
yaakcli environment list <workspace_id>
yaakcli environment show <environment_id>
yaakcli environment create <workspace_id> --name <name>
yaakcli environment create --json '{"workspaceId":"wk_abc","name":"Production"}'
yaakcli environment create '{"workspaceId":"wk_abc","name":"Production"}'
yaakcli environment update --json '{"id":"ev_abc","color":"#00ff00"}'
yaakcli environment delete <environment_id> [--yes]
Scan my API routes and create a workspace (using yaak cli) with
all the requests needed for me to do manual testing?
```
Global options:
- `--data-dir <path>`: use a custom data directory
- `-e, --environment <id>`: environment to use during request rendering/sending
- `-v, --verbose`: verbose logging and send output
Notes:
- `send` is currently a shortcut for sending an HTTP request ID.
- `delete` commands prompt for confirmation unless `--yes` is provided.
- In non-interactive mode, `delete` commands require `--yes`.
- `create` and `update` commands support `--json` and positional JSON shorthand.
- `update` uses JSON Merge Patch semantics (RFC 7386) for partial updates.
## Examples
```bash
yaakcli workspace list
yaakcli workspace create --name "My Workspace"
yaakcli workspace show wk_abc
yaakcli workspace update --json '{"id":"wk_abc","description":"Team workspace"}'
yaakcli request list wk_abc
yaakcli request show rq_abc
yaakcli request create wk_abc --name "Users" --url "https://api.example.com/users"
yaakcli request update --json '{"id":"rq_abc","name":"Users v2"}'
yaakcli request send rq_abc -e ev_abc
yaakcli request delete rq_abc --yes
yaakcli folder create wk_abc --name "Auth"
yaakcli folder update --json '{"id":"fl_abc","name":"Auth v2"}'
yaakcli environment create wk_abc --name "Production"
yaakcli environment update --json '{"id":"ev_abc","color":"#00ff00"}'
```text
Send all the GraphQL requests in my workspace
```
## Roadmap
## Description
Planned command expansion (request schema and polymorphic send) is tracked in `PLAN.md`.
Here's the current print of `yaak --help`
When command behavior changes, update this README and verify with:
```text
Yaak CLI - API client from the command line
```bash
cargo run -q -p yaak-cli -- --help
cargo run -q -p yaak-cli -- request --help
cargo run -q -p yaak-cli -- workspace --help
cargo run -q -p yaak-cli -- folder --help
cargo run -q -p yaak-cli -- environment --help
Usage: yaak [OPTIONS] <COMMAND>
Commands:
auth Authentication commands
plugin Plugin development and publishing commands
send Send a request, folder, or workspace by ID
workspace Workspace commands
request Request commands
folder Folder commands
environment Environment commands
Options:
--data-dir <DATA_DIR> Use a custom data directory
-e, --environment <ENVIRONMENT> Environment ID to use for variable substitution
-v, --verbose Enable verbose send output (events and streamed response body)
--log [<LEVEL>] Enable CLI logging; optionally set level (error|warn|info|debug|trace) [possible values: error, warn, info, debug, trace]
-h, --help Print help
-V, --version Print version
Agent Hints:
- Template variable syntax is ${[ my_var ]}, not {{ ... }}
- Template function syntax is ${[ namespace.my_func(a='aaa',b='bbb') ]}
- View JSONSchema for models before creating or updating (eg. `yaak request schema http`)
- Deletion requires confirmation (--yes for non-interactive environments)
```

View File

@@ -4,7 +4,14 @@ use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "yaak")]
#[command(about = "Yaak CLI - API client from the command line")]
#[command(version)]
#[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)]
@@ -14,10 +21,18 @@ pub struct Cli {
#[arg(long, short, global = true)]
pub environment: Option<String>,
/// Enable verbose logging
/// 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,
}
@@ -36,9 +51,20 @@ pub enum Commands {
#[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),
@@ -57,12 +83,8 @@ pub struct SendArgs {
/// Request, folder, or workspace ID
pub id: String,
/// Execute requests sequentially (default)
#[arg(long, conflicts_with = "parallel")]
pub sequential: bool,
/// Execute requests in parallel
#[arg(long, conflicts_with = "sequential")]
#[arg(long)]
pub parallel: bool,
/// Stop on first request failure when sending folders/workspaces
@@ -71,6 +93,23 @@ pub struct SendArgs {
}
#[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,
@@ -81,6 +120,13 @@ 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
@@ -125,6 +171,7 @@ pub enum WorkspaceCommands {
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct RequestArgs {
#[command(subcommand)]
pub command: RequestCommands,
@@ -134,8 +181,8 @@ pub struct RequestArgs {
pub enum RequestCommands {
/// List requests in a workspace
List {
/// Workspace ID
workspace_id: String,
/// Workspace ID (optional when exactly one workspace exists)
workspace_id: Option<String>,
},
/// Show a request as JSON
@@ -154,6 +201,10 @@ pub enum RequestCommands {
Schema {
#[arg(value_enum)]
request_type: RequestSchemaType,
/// Pretty-print schema JSON output
#[arg(long)]
pretty: bool,
},
/// Create a new HTTP request
@@ -207,7 +258,29 @@ pub enum RequestSchemaType {
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,
@@ -217,8 +290,8 @@ pub struct FolderArgs {
pub enum FolderCommands {
/// List folders in a workspace
List {
/// Workspace ID
workspace_id: String,
/// Workspace ID (optional when exactly one workspace exists)
workspace_id: Option<String>,
},
/// Show a folder as JSON
@@ -264,6 +337,7 @@ pub enum FolderCommands {
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct EnvironmentArgs {
#[command(subcommand)]
pub command: EnvironmentCommands,
@@ -273,8 +347,15 @@ pub struct EnvironmentArgs {
pub enum EnvironmentCommands {
/// List environments in a workspace
List {
/// Workspace ID
workspace_id: String,
/// 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
@@ -284,15 +365,22 @@ pub enum EnvironmentCommands {
},
/// 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 (or positional JSON payload shorthand)
/// 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
/// JSON payload (use instead of WORKSPACE_ID/--name)
#[arg(long)]
json: Option<String>,
},
@@ -320,6 +408,7 @@ pub enum EnvironmentCommands {
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct AuthArgs {
#[command(subcommand)]
pub command: AuthCommands,
@@ -338,6 +427,7 @@ pub enum AuthCommands {
}
#[derive(Args)]
#[command(disable_help_subcommand = true)]
pub struct PluginArgs {
#[command(subcommand)]
pub command: PluginCommands,
@@ -354,6 +444,9 @@ pub enum PluginCommands {
/// 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),
}
@@ -374,3 +467,9 @@ pub struct GenerateArgs {
#[arg(long)]
pub dir: Option<PathBuf>,
}
#[derive(Args, Clone)]
pub struct InstallPluginArgs {
/// Local plugin directory path, or registry plugin spec (@org/plugin[@version])
pub source: String,
}

View File

@@ -1,5 +1,6 @@
use crate::cli::{AuthArgs, AuthCommands};
use crate::ui;
use crate::utils::http;
use base64::Engine as _;
use keyring::Entry;
use rand::RngCore;
@@ -136,10 +137,8 @@ async fn whoami() -> CommandResult {
};
let url = format!("{}/api/v1/whoami", environment.api_base_url());
let response = reqwest::Client::new()
let response = http::build_client(Some(&token))?
.get(url)
.header("X-Yaak-Session", token)
.header(reqwest::header::USER_AGENT, user_agent())
.send()
.await
.map_err(|e| format!("Failed to call whoami endpoint: {e}"))?;
@@ -156,7 +155,7 @@ async fn whoami() -> CommandResult {
.to_string(),
);
}
return Err(parse_api_error(status.as_u16(), &body));
return Err(http::parse_api_error(status.as_u16(), &body));
}
println!("{body}");
@@ -342,9 +341,8 @@ async fn write_redirect(stream: &mut TcpStream, location: &str) -> std::io::Resu
}
async fn exchange_access_token(oauth: &OAuthFlow, code: &str) -> CommandResult<String> {
let response = reqwest::Client::new()
let response = http::build_client(None)?
.post(&oauth.token_url)
.header(reqwest::header::USER_AGENT, user_agent())
.form(&[
("grant_type", "authorization_code"),
("client_id", OAUTH_CLIENT_ID),
@@ -406,38 +404,12 @@ fn delete_auth_token(environment: Environment) -> CommandResult {
}
}
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 random_hex(bytes: usize) -> String {
let mut data = vec![0_u8; bytes];
OsRng.fill_bytes(&mut data);
hex::encode(data)
}
fn user_agent() -> String {
format!("YaakCli/{} ({})", env!("CARGO_PKG_VERSION"), ua_platform())
}
fn ua_platform() -> &'static str {
match std::env::consts::OS {
"windows" => "Win",
"darwin" => "Mac",
"linux" => "Linux",
_ => "Unknown",
}
}
fn confirm_open_browser() -> CommandResult<bool> {
if !io::stdin().is_terminal() {
return Ok(true);

View File

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

View File

@@ -2,9 +2,12 @@ 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, parse_optional_json, parse_required_json, require_id,
validate_create_id,
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;
@@ -12,7 +15,8 @@ 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),
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)
@@ -30,10 +34,23 @@ pub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 {
}
}
fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
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)
.list_environments_ensure_base(&workspace_id)
.map_err(|e| format!("Failed to list environments: {e}"))?;
if environments.is_empty() {
@@ -63,17 +80,11 @@ fn create(
name: Option<String>,
json: Option<String>,
) -> CommandResult {
if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) {
return Err(
"environment create cannot combine workspace_id with --json payload".to_string()
);
}
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,
workspace_id.clone().filter(|v| is_json_shorthand(v)),
"environment create",
)?;
let payload = parse_optional_json(json, json_shorthand, "environment create")?;
if let Some(payload) = payload {
if name.is_some() {
@@ -83,10 +94,17 @@ fn create(
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}"))?;
if environment.workspace_id.is_empty() {
return Err("environment create JSON requires non-empty \"workspaceId\"".to_string());
}
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();
@@ -101,9 +119,8 @@ fn create(
return Ok(());
}
let workspace_id = workspace_id.ok_or_else(|| {
"environment create requires workspace_id unless JSON payload is provided".to_string()
})?;
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()
})?;

View File

@@ -2,9 +2,10 @@ 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, parse_optional_json, parse_required_json, require_id,
validate_create_id,
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;
@@ -12,7 +13,7 @@ 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),
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)
@@ -30,9 +31,10 @@ pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
}
}
fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
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}"))?;
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 {
@@ -58,15 +60,11 @@ fn create(
name: Option<String>,
json: Option<String>,
) -> CommandResult {
if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) {
return Err("folder create cannot combine workspace_id with --json payload".to_string());
}
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,
workspace_id.clone().filter(|v| is_json_shorthand(v)),
"folder create",
)?;
let payload = parse_optional_json(json, json_shorthand, "folder create")?;
if let Some(payload) = payload {
if name.is_some() {
@@ -74,12 +72,19 @@ fn create(
}
validate_create_id(&payload, "folder")?;
let folder: Folder = serde_json::from_value(payload)
let mut folder: Folder = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse folder create JSON: {e}"))?;
if folder.workspace_id.is_empty() {
return Err("folder create JSON requires non-empty \"workspaceId\"".to_string());
}
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()
@@ -90,9 +95,7 @@ fn create(
return Ok(());
}
let workspace_id = workspace_id.ok_or_else(|| {
"folder create requires workspace_id unless JSON payload is provided".to_string()
})?;
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()
})?;

View File

@@ -1,4 +1,5 @@
pub mod auth;
pub mod cookie_jar;
pub mod environment;
pub mod folder;
pub mod plugin;

View File

@@ -1,13 +1,14 @@
use crate::cli::{GenerateArgs, PluginArgs, PluginCommands, PluginPathArg};
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::{
Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat, Platform,
WatchOption, Watcher,
BundleEvent, Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat,
Platform, WatchOption, Watcher, WatcherEvent,
};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
@@ -15,6 +16,11 @@ 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;
@@ -57,12 +63,13 @@ pub async fn run_build(args: PluginPathArg) -> i32 {
}
}
pub async fn run(args: PluginArgs) -> i32 {
match args.command {
PluginCommands::Build(args) => run_build(args).await,
PluginCommands::Dev(args) => run_dev(args).await,
PluginCommands::Generate(args) => run_generate(args).await,
PluginCommands::Publish(args) => run_publish(args).await,
pub async fn run_install(context: &CliContext, args: InstallPluginArgs) -> i32 {
match install(context, args).await {
Ok(()) => 0,
Err(error) => {
ui::error(&error);
1
}
}
}
@@ -114,12 +121,53 @@ async fn dev(args: PluginPathArg) -> CommandResult {
ensure_plugin_build_inputs(&plugin_dir)?;
ui::info(&format!("Watching plugin {}...", plugin_dir.display()));
ui::info("Press Ctrl-C to stop");
let bundler = Bundler::new(bundler_options(&plugin_dir, true))
.map_err(|err| format!("Failed to initialize Rolldown watcher: {err}"))?;
let watcher = Watcher::new(vec![Arc::new(Mutex::new(bundler))], None)
.map_err(|err| format!("Failed to start Rolldown watcher: {err}"))?;
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(())
@@ -186,10 +234,8 @@ async fn publish(args: PluginPathArg) -> CommandResult {
ui::info("Uploading plugin");
let url = format!("{}/api/v1/plugins/publish", environment.api_base_url());
let response = reqwest::Client::new()
let response = http::build_client(Some(&token))?
.post(url)
.header("X-Yaak-Session", token)
.header(reqwest::header::USER_AGENT, user_agent())
.header(reqwest::header::CONTENT_TYPE, "application/zip")
.body(archive)
.send()
@@ -201,7 +247,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
response.text().await.map_err(|e| format!("Failed reading publish response body: {e}"))?;
if !status.is_success() {
return Err(parse_api_error(status.as_u16(), &body));
return Err(http::parse_api_error(status.as_u16(), &body));
}
let published: PublishResponse = serde_json::from_str(&body)
@@ -211,6 +257,113 @@ async fn publish(args: PluginPathArg) -> CommandResult {
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,
@@ -389,32 +542,6 @@ fn get_auth_token(environment: Environment) -> CommandResult<Option<String>> {
}
}
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/{} ({})", env!("CARGO_PKG_VERSION"), ua_platform())
}
fn ua_platform() -> &'static str {
match std::env::consts::OS {
"windows" => "Win",
"darwin" => "Mac",
"linux" => "Linux",
_ => "Unknown",
}
}
fn random_name() -> String {
const ADJECTIVES: &[&str] = &[
"young", "youthful", "yellow", "yielding", "yappy", "yawning", "yummy", "yucky", "yearly",

View File

@@ -2,14 +2,18 @@ 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, parse_optional_json, parse_required_json, require_id,
validate_create_id,
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;
@@ -21,13 +25,16 @@ 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),
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, verbose).await {
return match send_request_by_id(ctx, &request_id, environment, cookie_jar_id, verbose)
.await
{
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
@@ -35,8 +42,8 @@ pub async fn run(
}
};
}
RequestCommands::Schema { request_type } => {
return match schema(ctx, request_type).await {
RequestCommands::Schema { request_type, pretty } => {
return match schema(ctx, request_type, pretty).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
@@ -60,10 +67,11 @@ pub async fn run(
}
}
fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
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)
.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);
@@ -75,7 +83,7 @@ fn list(ctx: &CliContext, workspace_id: &str) -> CommandResult {
Ok(())
}
async fn schema(ctx: &CliContext, request_type: RequestSchemaType) -> CommandResult {
async fn schema(ctx: &CliContext, request_type: RequestSchemaType, pretty: bool) -> CommandResult {
let mut schema = match request_type {
RequestSchemaType::Http => serde_json::to_value(schema_for!(HttpRequest))
.map_err(|e| format!("Failed to serialize HTTP request schema: {e}"))?,
@@ -85,16 +93,51 @@ async fn schema(ctx: &CliContext, request_type: RequestSchemaType) -> CommandRes
.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 = serde_json::to_string_pretty(&schema)
.map_err(|e| format!("Failed to format schema JSON: {e}"))?;
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,
@@ -298,15 +341,11 @@ fn create(
url: Option<String>,
json: Option<String>,
) -> CommandResult {
if json.is_some() && workspace_id.as_deref().is_some_and(|v| !is_json_shorthand(v)) {
return Err("request create cannot combine workspace_id with --json payload".to_string());
}
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,
workspace_id.clone().filter(|v| is_json_shorthand(v)),
"request create",
)?;
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() {
@@ -314,12 +353,19 @@ fn create(
}
validate_create_id(&payload, "request")?;
let request: HttpRequest = serde_json::from_value(payload)
let mut request: HttpRequest = serde_json::from_value(payload)
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
if request.workspace_id.is_empty() {
return Err("request create JSON requires non-empty \"workspaceId\"".to_string());
}
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()
@@ -330,9 +376,7 @@ fn create(
return Ok(());
}
let workspace_id = workspace_id.ok_or_else(|| {
"request create requires workspace_id unless JSON payload is provided".to_string()
})?;
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());
@@ -401,6 +445,7 @@ 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 =
@@ -412,6 +457,7 @@ pub async fn send_request_by_id(
&http_request.id,
&http_request.workspace_id,
environment,
cookie_jar_id,
verbose,
)
.await
@@ -430,18 +476,32 @@ async fn send_http_request_by_id(
request_id: &str,
workspace_id: &str,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let plugin_context = PluginContext::new(None, Some(workspace_id.to_string()));
let cookie_jar_id = resolve_cookie_jar_id(ctx, workspace_id, cookie_jar_id)?;
let (event_tx, mut event_rx) = mpsc::channel(100);
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 {
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 {
@@ -450,9 +510,10 @@ async fn send_http_request_by_id(
request_id,
environment_id: environment,
update_source: UpdateSource::Sync,
cookie_jar_id: None,
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,
@@ -462,24 +523,26 @@ async fn send_http_request_by_id(
.await;
let _ = event_handle.await;
let result = result.map_err(|e| e.to_string())?;
if verbose {
println!();
}
println!(
"HTTP {} {}",
result.response.status,
result.response.status_reason.as_deref().unwrap_or("")
);
if verbose {
for header in &result.response.headers {
println!("{}: {}", header.name, header.value);
}
println!();
}
let body = String::from_utf8(result.response_body)
.map_err(|e| format!("Failed to read response body: {e}"))?;
println!("{}", body);
let _ = body_handle.await;
result.map_err(|e| e.to_string())?;
Ok(())
}
pub(crate) fn resolve_cookie_jar_id(
ctx: &CliContext,
workspace_id: &str,
explicit_cookie_jar_id: Option<&str>,
) -> Result<Option<String>, String> {
if let Some(cookie_jar_id) = explicit_cookie_jar_id {
return Ok(Some(cookie_jar_id.to_string()));
}
let default_cookie_jar = ctx
.db()
.list_cookie_jars(workspace_id)
.map_err(|e| format!("Failed to list cookie jars: {e}"))?
.into_iter()
.min_by_key(|jar| jar.created_at)
.map(|jar| jar.id);
Ok(default_cookie_jar)
}

View File

@@ -2,6 +2,7 @@ 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,
@@ -12,9 +13,10 @@ 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, verbose).await {
match send_target(ctx, args, environment, cookie_jar_id, verbose).await {
Ok(()) => 0,
Err(error) => {
eprintln!("Error: {error}");
@@ -27,30 +29,70 @@ 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 ctx.db().get_any_request(&args.id).is_ok() {
return request::send_request_by_id(ctx, &args.id, environment, verbose).await;
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 ctx.db().get_folder(&args.id).is_ok() {
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, verbose).await;
return send_many(
ctx,
request_ids,
mode,
args.fail_fast,
environment,
resolved_cookie_jar_id.as_deref(),
verbose,
)
.await;
}
if ctx.db().get_workspace(&args.id).is_ok() {
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, verbose).await;
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))
@@ -131,6 +173,7 @@ async fn send_many(
mode: ExecutionMode,
fail_fast: bool,
environment: Option<&str>,
cookie_jar_id: Option<&str>,
verbose: bool,
) -> Result<(), String> {
let mut success_count = 0usize;
@@ -139,7 +182,15 @@ async fn send_many(
match mode {
ExecutionMode::Sequential => {
for request_id in request_ids {
match request::send_request_by_id(ctx, &request_id, environment, verbose).await {
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));
@@ -156,7 +207,14 @@ async fn send_many(
.map(|request_id| async move {
(
request_id.clone(),
request::send_request_by_id(ctx, request_id, environment, verbose).await,
request::send_request_by_id(
ctx,
request_id,
environment,
cookie_jar_id,
verbose,
)
.await,
)
})
.collect::<Vec<_>>();

View File

@@ -4,6 +4,8 @@ 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;
@@ -12,6 +14,7 @@ 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),
@@ -27,6 +30,18 @@ pub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {
}
}
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}"))?;

View File

@@ -1,4 +1,6 @@
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;
@@ -9,6 +11,21 @@ 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,
@@ -19,68 +36,71 @@ pub struct CliContext {
}
impl CliContext {
pub async fn initialize(data_dir: PathBuf, app_id: &str, with_plugins: bool) -> Self {
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) = yaak_models::init_standalone(&db_path, &blob_path)
.expect("Failed to initialize database");
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
let plugin_manager = if with_plugins {
let vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = data_dir.join("installed-plugins");
let node_bin_path = PathBuf::from("node");
let plugin_runtime_main =
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../crates-tauri/yaak-app/vendored/plugin-runtime/index.cjs")
});
let plugin_manager = Arc::new(
PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
false,
)
.await,
);
let plugins = query_manager.connect().list_plugins().unwrap_or_default();
if !plugins.is_empty() {
let errors = plugin_manager
.initialize_all_plugins(plugins, &PluginContext::new_empty())
.await;
for (plugin_dir, error_msg) in errors {
eprintln!(
"Warning: Failed to initialize plugin '{}': {}",
plugin_dir, error_msg
);
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);
}
}
Some(plugin_manager)
} else {
None
};
let plugin_event_bridge = if let Some(plugin_manager) = &plugin_manager {
Some(CliPluginEventBridge::start(plugin_manager.clone(), query_manager.clone()).await)
} else {
None
};
};
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
data_dir,
query_manager,
blob_manager,
encryption_manager,
plugin_manager,
plugin_event_bridge: Mutex::new(plugin_event_bridge),
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}");
}
}
}
@@ -113,3 +133,17 @@ impl CliContext {
}
}
}
fn prepare_embedded_plugin_runtime(data_dir: &Path) -> std::io::Result<PathBuf> {
let runtime_dir = data_dir.join("vendored").join("plugin-runtime");
fs::create_dir_all(&runtime_dir)?;
let runtime_main = runtime_dir.join("index.cjs");
fs::write(&runtime_main, EMBEDDED_PLUGIN_RUNTIME)?;
Ok(runtime_main)
}
fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(vendored_plugin_dir)?;
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
Ok(())
}

View File

@@ -4,17 +4,28 @@ mod context;
mod plugin_events;
mod ui;
mod utils;
mod version;
mod version_check;
use clap::Parser;
use cli::{Cli, Commands, RequestCommands};
use context::CliContext;
use cli::{Cli, Commands, PluginCommands, RequestCommands};
use context::{CliContext, CliExecutionContext};
use yaak_models::queries::any_request::AnyRequest;
#[tokio::main]
async fn main() {
let Cli { data_dir, environment, verbose, command } = Cli::parse();
let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse();
if verbose {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
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" };
@@ -23,70 +34,208 @@ async fn main() {
dirs::data_dir().expect("Could not determine data directory").join(app_id)
});
let needs_context = matches!(
&command,
Commands::Send(_)
| Commands::Workspace(_)
| Commands::Request(_)
| Commands::Folder(_)
| Commands::Environment(_)
);
let needs_plugins = matches!(
&command,
Commands::Send(_)
| Commands::Request(cli::RequestArgs {
command: RequestCommands::Send { .. } | RequestCommands::Schema { .. },
})
);
let context = if needs_context {
Some(CliContext::initialize(data_dir, app_id, needs_plugins).await)
} else {
None
};
version_check::maybe_check_for_updates().await;
let exit_code = match command {
Commands::Auth(args) => commands::auth::run(args).await,
Commands::Plugin(args) => commands::plugin::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) => {
commands::send::run(
context.as_ref().expect("context initialized for send"),
args,
let mut context = CliContext::new(data_dir.clone(), app_id);
match resolve_send_execution_context(
&context,
&args.id,
environment.as_deref(),
verbose,
)
.await
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::Workspace(args) => commands::workspace::run(
context.as_ref().expect("context initialized for workspace"),
args,
),
Commands::Request(args) => {
commands::request::run(
context.as_ref().expect("context initialized for request"),
args,
environment.as_deref(),
verbose,
)
.await
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) => {
commands::folder::run(context.as_ref().expect("context initialized for 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
}
Commands::Environment(args) => commands::environment::run(
context.as_ref().expect("context initialized for environment"),
args,
),
};
if let Some(context) = &context {
context.shutdown().await;
}
if exit_code != 0 {
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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,14 @@ pub fn warning(message: &str) {
}
}
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());

View File

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

View File

@@ -63,6 +63,30 @@ pub fn validate_create_id(payload: &Value, context: &str) -> JsonResult<()> {
}
}
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,14 @@
use std::io::{Read, Write};
use std::net::TcpListener;
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<()>>,
}
@@ -12,29 +17,46 @@ impl TestHttpServer {
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 || {
if let Ok((mut stream, _)) = listener.accept() {
let mut request_buf = [0u8; 4096];
let _ = stream.read(&mut request_buf);
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();
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, handle: Some(handle) }
Self { url, addr, shutdown, handle: Some(handle) }
}
}
impl Drop for TestHttpServer {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
let _ = TcpStream::connect(self.addr);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}

View File

@@ -78,3 +78,69 @@ fn json_create_and_update_merge_patch_round_trip() {
.stdout(contains("\"name\": \"Json Environment\""))
.stdout(contains("\"color\": \"#00ff00\""));
}
#[test]
fn create_merges_positional_workspace_id_into_json_payload() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"environment",
"create",
"wk_test",
"--json",
r#"{"name":"Merged Environment"}"#,
])
.assert()
.success();
let environment_id = parse_created_id(&create_assert.get_output().stdout, "environment create");
cli_cmd(data_dir)
.args(["environment", "show", &environment_id])
.assert()
.success()
.stdout(contains("\"workspaceId\": \"wk_test\""))
.stdout(contains("\"name\": \"Merged Environment\""));
}
#[test]
fn create_rejects_conflicting_workspace_ids_between_arg_and_json() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_workspace(data_dir, "wk_other");
cli_cmd(data_dir)
.args([
"environment",
"create",
"wk_test",
"--json",
r#"{"workspaceId":"wk_other","name":"Mismatch"}"#,
])
.assert()
.failure()
.stderr(contains(
"environment create got conflicting workspace_id values between positional arg and JSON payload",
));
}
#[test]
fn environment_schema_outputs_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["environment", "schema"])
.assert()
.success()
.stdout(contains("\"type\":\"object\""))
.stdout(contains("\"x-yaak-agent-hints\""))
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
.stdout(contains(
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
))
.stdout(contains("\"workspaceId\""));
}

View File

@@ -72,3 +72,51 @@ fn json_create_and_update_merge_patch_round_trip() {
.stdout(contains("\"name\": \"Json Folder\""))
.stdout(contains("\"description\": \"Folder Description\""));
}
#[test]
fn create_merges_positional_workspace_id_into_json_payload() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
let create_assert = cli_cmd(data_dir)
.args([
"folder",
"create",
"wk_test",
"--json",
r#"{"name":"Merged Folder"}"#,
])
.assert()
.success();
let folder_id = parse_created_id(&create_assert.get_output().stdout, "folder create");
cli_cmd(data_dir)
.args(["folder", "show", &folder_id])
.assert()
.success()
.stdout(contains("\"workspaceId\": \"wk_test\""))
.stdout(contains("\"name\": \"Merged Folder\""));
}
#[test]
fn create_rejects_conflicting_workspace_ids_between_arg_and_json() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
seed_workspace(data_dir, "wk_test");
seed_workspace(data_dir, "wk_other");
cli_cmd(data_dir)
.args([
"folder",
"create",
"wk_test",
"--json",
r#"{"workspaceId":"wk_other","name":"Mismatch"}"#,
])
.assert()
.failure()
.stderr(contains(
"folder create got conflicting workspace_id values between positional arg and JSON payload",
));
}

View File

@@ -130,6 +130,54 @@ fn create_allows_workspace_only_with_empty_defaults() {
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");
@@ -156,7 +204,6 @@ fn request_send_persists_response_body_and_events() {
.args(["request", "send", &request_id])
.assert()
.success()
.stdout(contains("HTTP 200 OK"))
.stdout(contains("hello from integration test"));
let qm = query_manager(data_dir);
@@ -189,6 +236,26 @@ fn request_schema_http_outputs_json_schema() {
.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\""));
}

View File

@@ -31,7 +31,6 @@ fn top_level_send_workspace_sends_http_requests_and_prints_summary() {
.args(["send", "wk_test"])
.assert()
.success()
.stdout(contains("HTTP 200 OK"))
.stdout(contains("workspace bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}
@@ -62,7 +61,6 @@ fn top_level_send_folder_sends_http_requests_and_prints_summary() {
.args(["send", "fl_test"])
.assert()
.success()
.stdout(contains("HTTP 200 OK"))
.stdout(contains("folder bulk send"))
.stdout(contains("Send summary: 1 succeeded, 0 failed"));
}

View File

@@ -57,3 +57,21 @@ fn json_create_and_update_merge_patch_round_trip() {
.stdout(contains("\"name\": \"Json Workspace\""))
.stdout(contains("\"description\": \"Updated via JSON\""));
}
#[test]
fn workspace_schema_outputs_json_schema() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
cli_cmd(data_dir)
.args(["workspace", "schema"])
.assert()
.success()
.stdout(contains("\"type\":\"object\""))
.stdout(contains("\"x-yaak-agent-hints\""))
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
.stdout(contains(
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
))
.stdout(contains("\"name\""));
}

View File

@@ -30,6 +30,7 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
http = { version = "1.2.0", default-features = false }
log = { workspace = true }
md5 = "0.8.0"
pretty_graphql = "0.2"
r2d2 = "0.8.10"
r2d2_sqlite = "0.25.0"
mime_guess = "2.0.5"

View File

@@ -154,6 +154,7 @@ async fn send_http_request_inner<R: Runtime>(
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,

View File

@@ -31,14 +31,16 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
use tokio::sync::Mutex;
use tokio::task::block_in_place;
use tokio::time;
use yaak_common::command::new_checked_command;
use yaak_crypto::manager::EncryptionManager;
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
use yaak_templates::strip_json_comments::strip_json_comments;
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
use yaak_mac_window::AppHandleMacWindowExt;
use yaak_models::models::{
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
Workspace, WorkspaceMeta,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
WorkspaceMeta,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{
@@ -97,6 +99,7 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
struct AppMetaData {
is_dev: bool,
version: String,
cli_version: Option<String>,
name: String,
app_data_dir: String,
app_log_dir: String,
@@ -113,9 +116,11 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
let vendored_plugin_dir =
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
let cli_version = detect_cli_version().await;
Ok(AppMetaData {
is_dev: is_dev(),
version: app_handle.package_info().version.to_string(),
cli_version,
name: app_handle.package_info().name.to_string(),
app_data_dir: app_data_dir.to_string_lossy().to_string(),
app_log_dir: app_log_dir.to_string_lossy().to_string(),
@@ -126,6 +131,24 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
})
}
async fn detect_cli_version() -> Option<String> {
detect_cli_version_for_binary("yaak").await
}
async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
let mut cmd = new_checked_command(program, "--version").await.ok()?;
let out = cmd.arg("--version").output().await.ok()?;
if !out.status.success() {
return None;
}
let line = String::from_utf8(out.stdout).ok()?;
let line = line.lines().find(|l| !l.trim().is_empty())?.trim();
let mut parts = line.split_whitespace();
let _name = parts.next();
Some(parts.next().unwrap_or(line).to_string())
}
#[tauri::command]
async fn cmd_template_tokens_to_string<R: Runtime>(
window: WebviewWindow<R>,
@@ -411,6 +434,7 @@ async fn cmd_grpc_go<R: Runtime>(
result.expect("Failed to render template")
})
});
let msg = strip_json_comments(&msg);
in_msg_tx.try_send(msg.clone()).unwrap();
}
Ok(IncomingMsg::Commit) => {
@@ -446,6 +470,7 @@ async fn cmd_grpc_go<R: Runtime>(
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
)
.await?;
let msg = strip_json_comments(&msg);
app_handle.db().upsert_grpc_event(
&GrpcEvent {
@@ -847,6 +872,14 @@ async fn cmd_format_json(text: &str) -> YaakResult<String> {
Ok(format_json(text, " "))
}
#[tauri::command]
async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
match pretty_graphql::format_text(text, &Default::default()) {
Ok(formatted) => Ok(formatted),
Err(_) => Ok(text.to_string()),
}
}
#[tauri::command]
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
@@ -1345,29 +1378,6 @@ async fn cmd_send_http_request<R: Runtime>(
Ok(r)
}
#[tauri::command]
async fn cmd_install_plugin<R: Runtime>(
directory: &str,
url: Option<String>,
plugin_manager: State<'_, PluginManager>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
) -> YaakResult<Plugin> {
let plugin = app_handle.db().upsert_plugin(
&Plugin { directory: directory.into(), url, enabled: true, ..Default::default() },
&UpdateSource::from_window_label(window.label()),
)?;
plugin_manager
.add_plugin(
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
&plugin,
)
.await?;
Ok(plugin)
}
#[tauri::command]
async fn cmd_reload_plugins<R: Runtime>(
app_handle: AppHandle<R>,
@@ -1639,6 +1649,7 @@ pub fn run() {
cmd_http_request_body,
cmd_http_response_body,
cmd_format_json,
cmd_format_graphql,
cmd_get_http_authentication_summaries,
cmd_get_http_authentication_config,
cmd_get_sse_events,
@@ -1652,7 +1663,6 @@ pub fn run() {
cmd_workspace_actions,
cmd_folder_actions,
cmd_import_data,
cmd_install_plugin,
cmd_metadata,
cmd_new_child_window,
cmd_new_main_window,
@@ -1721,6 +1731,7 @@ pub fn run() {
git_ext::cmd_git_rm_remote,
//
// Plugin commands
plugins_ext::cmd_plugins_install_from_directory,
plugins_ext::cmd_plugins_search,
plugins_ext::cmd_plugins_install,
plugins_ext::cmd_plugins_uninstall,

View File

@@ -15,6 +15,7 @@ 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;
@@ -255,23 +256,32 @@ pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
}
#[tauri::command]
pub(crate) fn models_workspace_models<R: Runtime>(
pub(crate) async fn models_workspace_models<R: Runtime>(
window: WebviewWindow<R>,
workspace_id: Option<&str>,
plugin_manager: State<'_, PluginManager>,
) -> Result<String> {
let db = window.db();
let mut l: Vec<AnyModel> = Vec::new();
// Add the settings
l.push(db.get_settings().into());
// 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());
}
// Add global models
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());
l.append(&mut db.list_plugins()?.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());

View File

@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
use std::time::Instant;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_common::platform::get_os_str;
use yaak_models::util::UpdateSource;
@@ -102,7 +102,7 @@ impl YaakNotifier {
let launch_info = get_or_upsert_launch_info(app_handle);
let app_version = app_handle.package_info().version.to_string();
let req = yaak_api_client(&app_version)?
let req = yaak_api_client(ApiClientKind::App, &app_version)?
.request(Method::GET, "https://notify.yaak.app/notifications")
.query(&[
("version", &launch_info.current_version),

View File

@@ -19,13 +19,13 @@ use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak_crypto::manager::EncryptionManager;
use yaak_models::models::{AnyModel, HttpResponse, Plugin};
use yaak_models::models::{HttpResponse, Plugin};
use yaak_models::queries::any_request::AnyRequest;
use yaak_models::util::UpdateSource;
use yaak_plugins::error::Error::PluginErr;
use yaak_plugins::events::{
Color, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, GetCookieValueResponse, Icon,
InternalEvent, InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
Color, EmptyPayload, ErrorResponse, GetCookieValueResponse, Icon, InternalEvent,
InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
WorkspaceInfo,
@@ -118,7 +118,7 @@ async fn handle_host_plugin_request<R: Runtime>(
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
message: format!("Reloaded plugin {}@{}", info.name, info.version),
icon: Some(Icon::Info),
timeout: Some(3000),
timeout: Some(5000),
..Default::default()
}),
None,
@@ -190,71 +190,6 @@ async fn handle_host_plugin_request<R: Runtime>(
Ok(None)
}
}
HostRequest::FindHttpResponses(req) => {
let http_responses = app_handle
.db()
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
.unwrap_or_default();
Ok(Some(InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
http_responses,
})))
}
HostRequest::UpsertModel(req) => {
use AnyModel::*;
let model = match &req.model {
HttpRequest(m) => {
HttpRequest(app_handle.db().upsert_http_request(m, &UpdateSource::Plugin)?)
}
GrpcRequest(m) => {
GrpcRequest(app_handle.db().upsert_grpc_request(m, &UpdateSource::Plugin)?)
}
WebsocketRequest(m) => WebsocketRequest(
app_handle.db().upsert_websocket_request(m, &UpdateSource::Plugin)?,
),
Folder(m) => Folder(app_handle.db().upsert_folder(m, &UpdateSource::Plugin)?),
Environment(m) => {
Environment(app_handle.db().upsert_environment(m, &UpdateSource::Plugin)?)
}
Workspace(m) => {
Workspace(app_handle.db().upsert_workspace(m, &UpdateSource::Plugin)?)
}
_ => {
return Err(PluginErr("Upsert not supported for this model type".into()).into());
}
};
Ok(Some(InternalEventPayload::UpsertModelResponse(
yaak_plugins::events::UpsertModelResponse { model },
)))
}
HostRequest::DeleteModel(req) => {
let model = match req.model.as_str() {
"http_request" => AnyModel::HttpRequest(
app_handle.db().delete_http_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"grpc_request" => AnyModel::GrpcRequest(
app_handle.db().delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"websocket_request" => AnyModel::WebsocketRequest(
app_handle
.db()
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)?,
),
"folder" => AnyModel::Folder(
app_handle.db().delete_folder_by_id(&req.id, &UpdateSource::Plugin)?,
),
"environment" => AnyModel::Environment(
app_handle.db().delete_environment_by_id(&req.id, &UpdateSource::Plugin)?,
),
_ => {
return Err(PluginErr("Delete not supported for this model type".into()).into());
}
};
Ok(Some(InternalEventPayload::DeleteModelResponse(
yaak_plugins::events::DeleteModelResponse { model },
)))
}
HostRequest::RenderGrpcRequest(req) => {
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
@@ -362,7 +297,7 @@ async fn handle_host_plugin_request<R: Runtime>(
workspace_id: http_request.workspace_id.clone(),
..Default::default()
},
&UpdateSource::Plugin,
&UpdateSource::from_window_label(window.label()),
&blobs,
)?
};

View File

@@ -21,14 +21,14 @@ use tauri::{
};
use tokio::sync::Mutex;
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_models::models::Plugin;
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::{Color, Icon, PluginContext, ShowToastRequest};
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;
@@ -73,7 +73,7 @@ impl PluginUpdater {
info!("Checking for plugin updates");
let app_version = window.app_handle().package_info().version.to_string();
let http_client = yaak_api_client(&app_version)?;
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?;
@@ -138,7 +138,7 @@ pub async fn cmd_plugins_search<R: Runtime>(
query: &str,
) -> Result<PluginSearchResponse> {
let app_version = app_handle.package_info().version.to_string();
let http_client = yaak_api_client(&app_version)?;
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
Ok(search_plugins(&http_client, query).await?)
}
@@ -150,7 +150,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
) -> 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(&app_version)?;
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(
@@ -165,6 +165,28 @@ pub async fn cmd_plugins_install<R: Runtime>(
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,
@@ -181,7 +203,7 @@ 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(&app_version)?;
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?)
}
@@ -191,7 +213,7 @@ 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(&app_version)?;
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)
@@ -268,6 +290,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.join("index.cjs");
let dev_mode = is_dev();
let query_manager =
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
// Create plugin manager asynchronously
let app_handle_clone = app_handle.clone();
@@ -277,53 +301,12 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
dev_mode,
)
.await;
// Initialize all plugins after manager is created
let bundled_dirs = manager
.list_bundled_plugin_dirs()
.await
.expect("Failed to list bundled plugins");
// Ensure all bundled plugins make it into the database
let db = app_handle_clone.db();
for dir in &bundled_dirs {
if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
..Default::default()
},
&UpdateSource::Background,
)
.expect("Failed to upsert bundled plugin");
}
}
// Get all plugins from database and initialize
let plugins = db.list_plugins().expect("Failed to list plugins from database");
drop(db); // Explicitly drop the connection before await
let errors =
manager.initialize_all_plugins(plugins, &PluginContext::new_empty()).await;
// Show toast for any failed plugins
for (plugin_dir, error_msg) in errors {
let plugin_name = plugin_dir.split('/').last().unwrap_or(&plugin_dir);
let toast = ShowToastRequest {
message: format!("Failed to start plugin '{}': {}", plugin_name, error_msg),
color: Some(Color::Danger),
icon: Some(Icon::AlertTriangle),
timeout: Some(10000),
};
if let Err(emit_err) = app_handle_clone.emit("show_toast", toast) {
error!("Failed to emit toast for plugin error: {emit_err:?}");
}
}
.await
.expect("Failed to initialize plugins");
app_handle_clone.manage(manager);
});

View File

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

View File

@@ -8,7 +8,7 @@ use std::fs;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use yaak_api::yaak_api_client;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install;
@@ -47,7 +47,7 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
let query_manager = app_handle.db_manager();
let app_version = app_handle.package_info().version.to_string();
let http_client = yaak_api_client(&app_version)?;
let http_client = yaak_api_client(ApiClientKind::App, &app_version)?;
let plugin_context = window.plugin_context();
let pv = download_and_install(
plugin_manager,
@@ -88,7 +88,8 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
}
let app_version = app_handle.package_info().version.to_string();
let resp = yaak_api_client(&app_version)?.get(file_url).send().await?;
let resp =
yaak_api_client(ApiClientKind::App, &app_version)?.get(file_url).send().await?;
let json = resp.bytes().await?;
let p = app_handle
.path()

View File

@@ -24,6 +24,7 @@ use yaak_models::util::UpdateSource;
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
use yaak_plugins::manager::PluginManager;
use yaak_plugins::template_callback::PluginTemplateCallback;
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
use yaak_templates::{RenderErrorBehavior, RenderOptions};
use yaak_tls::find_client_certificate;
use yaak_ws::{WebsocketManager, render_websocket_request};
@@ -72,8 +73,10 @@ pub async fn cmd_ws_send<R: Runtime>(
)
.await?;
let message = maybe_strip_json_comments(&request.message);
let mut ws_manager = ws_manager.lock().await;
ws_manager.send(&connection.id, Message::Text(request.message.clone().into())).await?;
ws_manager.send(&connection.id, Message::Text(message.clone().into())).await?;
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
@@ -82,7 +85,7 @@ pub async fn cmd_ws_send<R: Runtime>(
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Text,
message: request.message.into(),
message: message.into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),

View File

@@ -7,7 +7,7 @@ use std::ops::Add;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
use ts_rs::TS;
use yaak_api::yaak_api_client;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_common::platform::get_os_str;
use yaak_models::db_context::DbContext;
use yaak_models::query_manager::QueryManager;
@@ -119,7 +119,7 @@ pub async fn activate_license<R: Runtime>(
) -> Result<()> {
info!("Activating license {}", license_key);
let app_version = window.app_handle().package_info().version.to_string();
let client = yaak_api_client(&app_version)?;
let client = yaak_api_client(ApiClientKind::App, &app_version)?;
let payload = ActivateLicenseRequestPayload {
license_key: license_key.to_string(),
app_platform: get_os_str().to_string(),
@@ -157,7 +157,7 @@ pub async fn deactivate_license<R: Runtime>(window: &WebviewWindow<R>) -> Result
let activation_id = get_activation_id(app_handle).await;
let app_version = window.app_handle().package_info().version.to_string();
let client = yaak_api_client(&app_version)?;
let client = yaak_api_client(ApiClientKind::App, &app_version)?;
let path = format!("/licenses/activations/{}/deactivate", activation_id);
let payload =
DeactivateLicenseRequestPayload { app_platform: get_os_str().to_string(), app_version };
@@ -203,7 +203,7 @@ pub async fn check_license<R: Runtime>(window: &WebviewWindow<R>) -> Result<Lice
(true, _) => {
info!("Checking license activation");
// A license has been activated, so let's check the license server
let client = yaak_api_client(&payload.app_version)?;
let client = yaak_api_client(ApiClientKind::App, &payload.app_version)?;
let path = format!("/licenses/activations/{activation_id}/check-v2");
let response = client.post(build_url(&path)).json(&payload).send().await?;

View File

@@ -12,6 +12,11 @@ unsafe impl Sync for UnsafeWindowHandle {}
const WINDOW_CONTROL_PAD_X: f64 = 13.0;
const WINDOW_CONTROL_PAD_Y: f64 = 18.0;
/// Extra pixels to add to the title bar height when the default title bar is
/// already as tall as button_height + PAD_Y (i.e. macOS Tahoe 26+, where the
/// default is 32px and 14 + 18 = 32). On pre-Tahoe this is unused because the
/// default title bar is shorter than button_height + PAD_Y.
const TITLEBAR_EXTRA_HEIGHT: f64 = 4.0;
const MAIN_WINDOW_PREFIX: &str = "main_";
pub(crate) fn update_window_title<R: Runtime>(window: Window<R>, title: String) {
@@ -95,12 +100,29 @@ fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, x: f64, y: f64,
ns_window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom = ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let title_bar_frame_height = button_height + y;
let title_bar_container_view = close.superview().superview();
// Capture the OS default title bar height on the first call, before
// we've modified it. This avoids the height growing on repeated calls.
use std::sync::OnceLock;
static DEFAULT_TITLEBAR_HEIGHT: OnceLock<f64> = OnceLock::new();
let default_height =
*DEFAULT_TITLEBAR_HEIGHT.get_or_init(|| NSView::frame(title_bar_container_view).size.height);
// On pre-Tahoe, button_height + y is larger than the default title bar
// height, so the resize works as before. On Tahoe (26+), the default is
// already 32px and button_height + y = 32, so nothing changes. In that
// case, add TITLEBAR_EXTRA_HEIGHT extra pixels to push the buttons down.
let desired = button_height + y;
let title_bar_frame_height = if desired > default_height {
desired
} else {
default_height + TITLEBAR_EXTRA_HEIGHT
};
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;

View File

@@ -8,14 +8,24 @@ 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(version: &str) -> Result<Client> {
pub fn yaak_api_client(kind: ApiClientKind, version: &str) -> Result<Client> {
let platform = get_ua_platform();
let arch = get_ua_arch();
let ua = format!("Yaak/{version} ({platform}; {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());

View File

@@ -1,4 +1,6 @@
use std::ffi::OsStr;
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;
@@ -14,3 +16,27 @@ pub fn new_xplatform_command<S: AsRef<OsStr>>(program: S) -> tokio::process::Com
}
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))
}

View File

@@ -21,3 +21,10 @@ pub fn get_str_map<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> &'a str {
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),
}
}

View File

@@ -1,9 +1,8 @@
use crate::error::Error::GitNotFound;
use crate::error::Result;
use std::path::Path;
use std::process::Stdio;
use tokio::process::Command;
use yaak_common::command::new_xplatform_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> {
@@ -14,17 +13,5 @@ pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
/// Create a git command without a specific directory (for global operations)
pub(crate) async fn new_binary_command_global() -> Result<Command> {
// 1. Probe that `git` exists and is runnable
let mut probe = new_xplatform_command("git");
probe.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
let status = probe.status().await.map_err(|_| GitNotFound)?;
if !status.success() {
return Err(GitNotFound);
}
// 2. Build the reusable git command
let cmd = new_xplatform_command("git");
Ok(cmd)
new_checked_command("git", "--version").await.map_err(|_| GitNotFound)
}

View File

@@ -29,4 +29,5 @@ 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 }

View File

@@ -1,7 +1,6 @@
use crate::client::HttpConnectionOptions;
use crate::dns::LocalhostResolver;
use crate::error::Result;
use log::info;
use reqwest::Client;
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -36,7 +35,6 @@ impl HttpConnectionManager {
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
if let Some((cached, last_used)) = connections.get_mut(&id) {
info!("Re-using HTTP client {id}");
*last_used = Instant::now();
return Ok(CachedClient {
client: cached.client.clone(),

View File

@@ -30,6 +30,8 @@ pub enum HttpResponseEvent {
url: String,
status: u16,
behavior: RedirectBehavior,
dropped_body: bool,
dropped_headers: Vec<String>,
},
SendUrl {
method: String,
@@ -67,12 +69,28 @@ impl Display for HttpResponseEvent {
match self {
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
HttpResponseEvent::Redirect { url, status, behavior } => {
HttpResponseEvent::Redirect {
url,
status,
behavior,
dropped_body,
dropped_headers,
} => {
let behavior_str = match behavior {
RedirectBehavior::Preserve => "preserve",
RedirectBehavior::DropBody => "drop body",
};
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str)
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,
@@ -130,13 +148,21 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
match event {
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
HttpResponseEvent::Info(message) => D::Info { message },
HttpResponseEvent::Redirect { url, status, behavior } => D::Redirect {
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,

View File

@@ -1,7 +1,7 @@
use crate::cookies::CookieStore;
use crate::error::Result;
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
use crate::types::SendableHttpRequest;
use crate::types::{SendableBody, SendableHttpRequest};
use log::debug;
use tokio::sync::mpsc;
use tokio::sync::watch::Receiver;
@@ -87,6 +87,11 @@ impl<S: HttpSender> HttpTransaction<S> {
};
// Build request for this iteration
let preserved_body = match &current_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(),
@@ -182,8 +187,6 @@ impl<S: HttpSender> HttpTransaction<S> {
format!("{}/{}", base_path, location)
};
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Determine redirect behavior based on status code and method
let behavior = if status == 303 {
// 303 See Other always changes to GET
@@ -197,11 +200,8 @@ impl<S: HttpSender> HttpTransaction<S> {
RedirectBehavior::Preserve
};
send_event(HttpResponseEvent::Redirect {
url: current_url.clone(),
status,
behavior: behavior.clone(),
});
let mut dropped_headers =
Self::remove_sensitive_headers(&mut current_headers, &previous_url, &current_url);
// Handle method changes for certain redirect codes
if matches!(behavior, RedirectBehavior::DropBody) {
@@ -211,13 +211,40 @@ impl<S: HttpSender> HttpTransaction<S> {
// Remove content-related headers
current_headers.retain(|h| {
let name_lower = h.0.to_lowercase();
!name_lower.starts_with("content-") && name_lower != "transfer-encoding"
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
});
}
// Reset body for next iteration (since it was moved in the send call)
// For redirects that change method to GET or for all redirects since body was consumed
current_body = None;
// 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;
}
@@ -231,7 +258,8 @@ impl<S: HttpSender> HttpTransaction<S> {
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)))
});
@@ -241,13 +269,24 @@ impl<S: HttpSender> HttpTransaction<S> {
if previous_host != next_host {
headers.retain(|h| {
let name_lower = h.0.to_lowercase();
name_lower != "authorization"
&& name_lower != "cookie"
&& name_lower != "cookie2"
&& name_lower != "proxy-authorization"
&& name_lower != "www-authenticate"
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

View File

@@ -9,8 +9,9 @@ use std::collections::BTreeMap;
use std::pin::Pin;
use std::time::Duration;
use tokio::io::AsyncRead;
use yaak_common::serde::{get_bool, get_str, get_str_map};
use yaak_common::serde::{get_bool, get_bool_map, get_str, get_str_map};
use yaak_models::models::HttpRequest;
use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments};
pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
@@ -134,16 +135,69 @@ pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String {
result
}
fn strip_query_params(url: &str, names: &[&str]) -> String {
// Split off fragment
let (base_and_query, fragment) = if let Some(hash_pos) = url.find('#') {
(&url[..hash_pos], Some(&url[hash_pos..]))
} else {
(url, None)
};
let result = if let Some(q_pos) = base_and_query.find('?') {
let base = &base_and_query[..q_pos];
let query = &base_and_query[q_pos + 1..];
let filtered: Vec<&str> = query
.split('&')
.filter(|pair| {
let key = pair.split('=').next().unwrap_or("");
let decoded = urlencoding::decode(key).unwrap_or_default();
!names.contains(&decoded.as_ref())
})
.collect();
if filtered.is_empty() {
base.to_string()
} else {
format!("{}?{}", base, filtered.join("&"))
}
} else {
base_and_query.to_string()
};
match fragment {
Some(f) => format!("{}{}", result, f),
None => result,
}
}
fn build_url(r: &HttpRequest) -> String {
let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters);
append_query_params(
let mut url = append_query_params(
&url_string,
params
.iter()
.filter(|p| p.enabled && !p.name.is_empty())
.map(|p| (p.name.clone(), p.value.clone()))
.collect(),
)
);
// GraphQL GET requests encode query/variables as URL query parameters
if r.method.to_lowercase() == "get" && r.body_type.as_deref() == Some("graphql") {
url = append_graphql_query_params(&url, &r.body);
}
url
}
fn append_graphql_query_params(url: &str, body: &BTreeMap<String, serde_json::Value>) -> String {
let query = get_str_map(body, "query").to_string();
let variables = strip_json_comments(&get_str_map(body, "variables"));
let mut params = vec![("query".to_string(), query)];
if !variables.trim().is_empty() {
params.push(("variables".to_string(), variables));
}
// Strip existing query/variables params to avoid duplicates
let url = strip_query_params(url, &["query", "variables"]);
append_query_params(&url, params)
}
fn build_headers(r: &HttpRequest) -> Vec<(String, String)> {
@@ -177,7 +231,7 @@ async fn build_body(
(build_form_body(&body), Some("application/x-www-form-urlencoded".to_string()))
}
"multipart/form-data" => build_multipart_body(&body, &headers).await?,
_ if body.contains_key("text") => (build_text_body(&body), None),
_ if body.contains_key("text") => (build_text_body(&body, body_type), None),
t => {
warn!("Unsupported body type: {}", t);
(None, None)
@@ -252,13 +306,20 @@ async fn build_binary_body(
}))
}
fn build_text_body(body: &BTreeMap<String, serde_json::Value>) -> Option<SendableBodyWithMeta> {
fn build_text_body(body: &BTreeMap<String, serde_json::Value>, body_type: &str) -> Option<SendableBodyWithMeta> {
let text = get_str_map(body, "text");
if text.is_empty() {
None
} else {
Some(SendableBodyWithMeta::Bytes(Bytes::from(text.to_string())))
return None;
}
let send_comments = get_bool_map(body, "sendJsonComments", false);
let text = if !send_comments && body_type == "application/json" {
maybe_strip_json_comments(text)
} else {
text.to_string()
};
Some(SendableBodyWithMeta::Bytes(Bytes::from(text)))
}
fn build_graphql_body(
@@ -266,7 +327,7 @@ fn build_graphql_body(
body: &BTreeMap<String, serde_json::Value>,
) -> Option<SendableBodyWithMeta> {
let query = get_str_map(body, "query");
let variables = get_str_map(body, "variables");
let variables = strip_json_comments(&get_str_map(body, "variables"));
if method.to_lowercase() == "get" {
// GraphQL GET requests use query parameters, not a body
@@ -684,7 +745,7 @@ mod tests {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("Hello, World!"));
let result = build_text_body(&body);
let result = build_text_body(&body, "application/json");
match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => {
assert_eq!(bytes, Bytes::from("Hello, World!"))
@@ -698,7 +759,7 @@ mod tests {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!(""));
let result = build_text_body(&body);
let result = build_text_body(&body, "application/json");
assert!(result.is_none());
}
@@ -706,10 +767,57 @@ mod tests {
async fn test_text_body_missing() {
let body = BTreeMap::new();
let result = build_text_body(&body);
let result = build_text_body(&body, "application/json");
assert!(result.is_none());
}
#[tokio::test]
async fn test_text_body_strips_json_comments_by_default() {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}"));
let result = build_text_body(&body, "application/json");
match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => {
let text = String::from_utf8_lossy(&bytes);
assert!(!text.contains("// comment"));
assert!(text.contains("\"foo\": \"bar\""));
}
_ => panic!("Expected Some(SendableBody::Bytes)"),
}
}
#[tokio::test]
async fn test_text_body_send_json_comments_when_opted_in() {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("{\n // comment\n \"foo\": \"bar\"\n}"));
body.insert("sendJsonComments".to_string(), json!(true));
let result = build_text_body(&body, "application/json");
match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => {
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("// comment"));
}
_ => panic!("Expected Some(SendableBody::Bytes)"),
}
}
#[tokio::test]
async fn test_text_body_no_strip_for_non_json() {
let mut body = BTreeMap::new();
body.insert("text".to_string(), json!("// not json\nsome text"));
let result = build_text_body(&body, "text/plain");
match result {
Some(SendableBodyWithMeta::Bytes(bytes)) => {
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("// not json"));
}
_ => panic!("Expected Some(SendableBody::Bytes)"),
}
}
#[tokio::test]
async fn test_form_urlencoded_body() -> Result<()> {
let mut body = BTreeMap::new();

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
@@ -67,7 +67,9 @@ export type ParentAuthentication = { authentication: Record<string, any>, authen
export type ParentHeaders = { headers: Array<HttpRequestHeader>, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };

View File

@@ -0,0 +1,33 @@
ALTER TABLE plugins
ADD COLUMN source TEXT DEFAULT 'filesystem' NOT NULL;
-- Existing registry installs have a URL; classify them first.
UPDATE plugins
SET source = 'registry'
WHERE url IS NOT NULL;
-- Best-effort bundled backfill for legacy rows.
UPDATE plugins
SET source = 'bundled'
WHERE source = 'filesystem'
AND (
-- Normalize separators so this also works for Windows paths.
replace(directory, '\', '/') LIKE '%/vendored/plugins/%'
OR replace(directory, '\', '/') LIKE '%/vendored-plugins/%'
);
-- Keep one row per exact directory before adding uniqueness.
-- Tie-break by recency.
WITH ranked AS (SELECT id,
ROW_NUMBER() OVER (
PARTITION BY directory
ORDER BY updated_at DESC,
created_at DESC
) AS row_num
FROM plugins)
DELETE
FROM plugins
WHERE id IN (SELECT id FROM ranked WHERE row_num > 1);
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_directory_unique
ON plugins (directory);

View File

@@ -74,7 +74,7 @@ pub struct ClientCertificate {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct DnsOverride {
@@ -293,7 +293,7 @@ impl UpsertModelInfo for Settings {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "workspaces")]
@@ -590,7 +590,7 @@ impl UpsertModelInfo for CookieJar {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "environments")]
@@ -611,6 +611,8 @@ pub struct Environment {
pub base: bool,
pub parent_model: String,
pub parent_id: Option<String>,
/// Variables defined in this environment scope.
/// Child environments override parent variables by name.
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
pub sort_priority: f64,
@@ -698,7 +700,7 @@ impl UpsertModelInfo for Environment {
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct EnvironmentVariable {
@@ -845,6 +847,8 @@ pub struct HttpUrlParameter {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
/// Other entries are appended as query parameters
pub name: String,
pub value: String,
#[ts(optional, as = "Option<String>")]
@@ -877,6 +881,7 @@ pub struct HttpRequest {
pub name: String,
pub sort_priority: f64,
pub url: String,
/// URL parameters used for both path placeholders (`:id`) and query string entries.
pub url_parameters: Vec<HttpUrlParameter>,
}
@@ -1118,6 +1123,7 @@ pub struct WebsocketRequest {
pub name: String,
pub sort_priority: f64,
pub url: String,
/// URL parameters used for both path placeholders (`:id`) and query string entries.
pub url_parameters: Vec<HttpUrlParameter>,
}
@@ -1493,6 +1499,10 @@ pub enum HttpResponseEventData {
url: String,
status: u16,
behavior: String,
#[serde(default)]
dropped_body: bool,
#[serde(default)]
dropped_headers: Vec<String>,
},
SendUrl {
method: String,
@@ -1728,6 +1738,7 @@ pub struct GrpcRequest {
pub name: String,
pub service: Option<String>,
pub sort_priority: f64,
/// Server URL (http for plaintext or https for secure)
pub url: String,
}
@@ -2067,6 +2078,46 @@ pub struct Plugin {
pub directory: String,
pub enabled: bool,
pub url: Option<String>,
pub source: PluginSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
pub enum PluginSource {
Bundled,
Filesystem,
Registry,
}
impl FromStr for PluginSource {
type Err = crate::error::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"bundled" => Ok(Self::Bundled),
"filesystem" => Ok(Self::Filesystem),
"registry" => Ok(Self::Registry),
_ => Ok(Self::default()),
}
}
}
impl Display for PluginSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
PluginSource::Bundled => "bundled".to_string(),
PluginSource::Filesystem => "filesystem".to_string(),
PluginSource::Registry => "registry".to_string(),
};
write!(f, "{}", str)
}
}
impl Default for PluginSource {
fn default() -> Self {
Self::Filesystem
}
}
impl UpsertModelInfo for Plugin {
@@ -2102,6 +2153,7 @@ impl UpsertModelInfo for Plugin {
(Directory, self.directory.into()),
(Url, self.url.into()),
(Enabled, self.enabled.into()),
(Source, self.source.to_string().into()),
])
}
@@ -2112,6 +2164,7 @@ impl UpsertModelInfo for Plugin {
PluginIden::Directory,
PluginIden::Url,
PluginIden::Enabled,
PluginIden::Source,
]
}
@@ -2128,6 +2181,7 @@ impl UpsertModelInfo for Plugin {
url: row.get("url")?,
directory: row.get("directory")?,
enabled: row.get("enabled")?,
source: PluginSource::from_str(row.get::<_, String>("source")?.as_str()).unwrap(),
})
}
}

View File

@@ -26,6 +26,10 @@ impl<'a> DbContext<'a> {
}
pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
self.upsert(plugin, source)
let mut plugin_to_upsert = plugin.clone();
if let Some(existing) = self.get_plugin_by_directory(&plugin.directory) {
plugin_to_upsert.id = existing.id;
}
self.upsert(&plugin_to_upsert, source)
}
}

View File

@@ -15,7 +15,6 @@ log = { workspace = true }
md5 = "0.7.0"
path-slash = "0.2.1"
rand = "0.9.0"
regex = "1.10.6"
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId:
export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };
export type CallHttpAuthenticationResponse = {
export type CallHttpAuthenticationResponse = {
/**
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders?: Array<HttpHeader>,
setHeaders?: Array<HttpHeader>,
/**
* Query parameters to add to the request. Existing params will be replaced, while
* new params will be added.
@@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, };
export type ExportHttpRequestResponse = { content: string, };
export type FileFilter = { name: string,
export type FileFilter = { name: string,
/**
* File extensions to require
*/
@@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hid
export type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };
export type FormInputBase = {
export type FormInputBase = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputCheckbox = {
export type FormInputCheckbox = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputEditor = {
export type FormInputEditor = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Don't show the editor gutter (line numbers, folds, etc.)
*/
hideGutter?: boolean,
hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputFile = {
export type FormInputFile = {
/**
* The title of the file selection window
*/
title: string,
title: string,
/**
* Allow selecting multiple files
*/
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -250,63 +250,63 @@ description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };
export type FormInputHttpRequest = {
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputKeyValue = {
export type FormInputKeyValue = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -314,36 +314,36 @@ description?: string, };
export type FormInputMarkdown = { content: string, hidden?: boolean, };
export type FormInputSelect = {
export type FormInputSelect = {
/**
* The options that will be available in the select input
*/
options: Array<FormInputSelectOption>,
options: Array<FormInputSelectOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -351,44 +351,44 @@ description?: string, };
export type FormInputSelectOption = { label: string, value: string, };
export type FormInputText = {
export type FormInputText = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Placeholder for the text input
*/
password?: boolean,
password?: boolean,
/**
* Whether to allow newlines in the input, like a <textarea/>
*/
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -474,7 +474,7 @@ export type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };
export type OpenExternalUrlRequest = { url: string, };
export type OpenWindowRequest = { url: string,
export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
@@ -486,15 +486,15 @@ export type PromptFormRequest = { id: string, title: string, description?: strin
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**
* Text to add to the confirmation button
*/
confirmText?: string, password?: boolean,
confirmText?: string, password?: boolean,
/**
* Text to add to the cancel button
*/
cancelText?: string,
cancelText?: string,
/**
* Require the user to enter a non-empty value
*/
@@ -524,12 +524,12 @@ export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
/**
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
@@ -546,23 +546,23 @@ export type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, }
export type TemplateRenderResponse = { data: JsonValue, };
export type Theme = {
export type Theme = {
/**
* How the theme is identified. This should never be changed
*/
id: string,
id: string,
/**
* The friendly name of the theme to be displayed to the user
*/
label: string,
label: string,
/**
* Whether the theme will be used for dark or light appearance
*/
dark: boolean,
dark: boolean,
/**
* The default top-level colors for the theme
*/
base: ThemeComponentColors,
base: ThemeComponentColors,
/**
* Optionally override theme for individual UI components for more control
*/

View File

@@ -18,7 +18,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -34,9 +39,17 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -49,17 +62,24 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
@@ -77,7 +97,11 @@ export type WebsocketEvent = { model: "websocket_event", id: string, createdAt:
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };

View File

@@ -24,3 +24,7 @@ export async function checkPluginUpdates() {
export async function updateAllPlugins() {
return invoke<PluginNameVersion[]>('cmd_plugins_update_all', {});
}
export async function installPluginFromDirectory(directory: string) {
return invoke<void>('cmd_plugins_install_from_directory', { directory });
}

View File

@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::str::FromStr;
use ts_rs::TS;
use yaak_models::models::Plugin;
use yaak_models::models::{Plugin, PluginSource};
/// Get plugin info from the registry.
pub async fn get_plugin(
@@ -58,7 +58,7 @@ pub async fn check_plugin_updates(
) -> Result<PluginUpdatesResponse> {
let name_versions: Vec<PluginNameVersion> = plugins
.into_iter()
.filter(|p| p.url.is_some()) // Only check plugins with URLs (from registry)
.filter(|p| matches!(p.source, PluginSource::Registry)) // Only check registry-installed plugins
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
Err(e) => {

View File

@@ -9,7 +9,7 @@ use log::info;
use std::fs::{create_dir_all, remove_dir_all};
use std::io::Cursor;
use std::sync::Arc;
use yaak_models::models::Plugin;
use yaak_models::models::{Plugin, PluginSource};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
@@ -78,6 +78,7 @@ pub async fn download_and_install(
directory: plugin_dir_str.clone(),
enabled: true,
url: Some(plugin_version.url.clone()),
source: PluginSource::Registry,
..Default::default()
},
&UpdateSource::Background,

View File

@@ -21,9 +21,10 @@ use crate::events::{
use crate::native_template_functions::{template_function_keyring, template_function_secure};
use crate::nodejs::start_nodejs_plugin_runtime;
use crate::plugin_handle::PluginHandle;
use crate::plugin_meta::get_plugin_meta;
use crate::server_ws::PluginRuntimeServerWebsocket;
use log::{error, info, warn};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -33,8 +34,9 @@ use tokio::net::TcpListener;
use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::{Instant, timeout};
use yaak_models::models::Plugin;
use yaak_models::util::generate_id;
use yaak_models::models::{Plugin, PluginSource};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{UpdateSource, generate_id};
use yaak_templates::error::Error::RenderError;
use yaak_templates::error::Result as TemplateResult;
@@ -61,14 +63,18 @@ impl PluginManager {
/// * `installed_plugin_dir` - Path to installed plugins directory
/// * `node_bin_path` - Path to the yaaknode binary
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
/// * `query_manager` - Query manager for bundled plugin registration and loading
/// * `plugin_context` - Context to use while initializing plugins
/// * `dev_mode` - Whether the app is in dev mode (affects plugin loading)
pub async fn new(
vendored_plugin_dir: PathBuf,
installed_plugin_dir: PathBuf,
node_bin_path: PathBuf,
plugin_runtime_main: PathBuf,
query_manager: &QueryManager,
plugin_context: &PluginContext,
dev_mode: bool,
) -> PluginManager {
) -> Result<PluginManager> {
let (events_tx, mut events_rx) = mpsc::channel(2048);
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
let (killed_tx, killed_rx) = oneshot::channel();
@@ -151,12 +157,41 @@ impl PluginManager {
&kill_server_rx,
killed_tx,
)
.await
.unwrap();
.await?;
info!("Waiting for plugins to initialize");
init_plugins_task.await.unwrap();
init_plugins_task.await.map_err(|e| PluginErr(e.to_string()))?;
plugin_manager
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
let db = query_manager.connect();
for dir in &bundled_dirs {
if db.get_plugin_by_directory(dir).is_none() {
db.upsert_plugin(
&Plugin {
directory: dir.clone(),
enabled: true,
url: None,
source: PluginSource::Bundled,
..Default::default()
},
&UpdateSource::Background,
)?;
}
}
let plugins = db.list_plugins()?;
drop(db);
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
if !init_errors.is_empty() {
let joined = init_errors
.into_iter()
.map(|(dir, err)| format!("{dir}: {err}"))
.collect::<Vec<_>>()
.join("; ");
return Err(PluginErr(format!("Failed to initialize plugin(s): {joined}")));
}
Ok(plugin_manager)
}
/// Get the vendored plugin directory path (resolves dev mode path if applicable)
@@ -180,6 +215,57 @@ impl PluginManager {
read_plugins_dir(&plugins_dir).await
}
pub async fn resolve_plugins_for_runtime_from_db(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
let bundled_dirs = match self.list_bundled_plugin_dirs().await {
Ok(dirs) => dirs,
Err(err) => {
warn!("Failed to read bundled plugin dirs for resolution: {err:?}");
Vec::new()
}
};
self.resolve_plugins_for_runtime(plugins, bundled_dirs)
}
/// Resolve the plugin set for the current runtime instance.
///
/// Rules:
/// - Drop bundled rows that are not present in this instance's bundled directory list.
/// - Deduplicate by plugin metadata name (fallback to directory key when metadata is unreadable).
/// - Prefer sources in this order: filesystem > registry > bundled.
/// - For same-source conflicts, prefer the most recently installed row (`created_at`).
fn resolve_plugins_for_runtime(
&self,
plugins: Vec<Plugin>,
bundled_dirs: Vec<String>,
) -> Vec<Plugin> {
let bundled_dir_set: HashSet<String> = bundled_dirs.into_iter().collect();
let mut selected: HashMap<String, Plugin> = HashMap::new();
for plugin in plugins {
if matches!(plugin.source, PluginSource::Bundled)
&& !bundled_dir_set.contains(&plugin.directory)
{
continue;
}
let key = match get_plugin_meta(Path::new(&plugin.directory)) {
Ok(meta) => meta.name,
Err(_) => format!("__dir__{}", plugin.directory),
};
match selected.get(&key) {
Some(existing) if !prefer_plugin(&plugin, existing) => {}
_ => {
selected.insert(key, plugin);
}
}
}
let mut resolved = selected.into_values().collect::<Vec<_>>();
resolved.sort_by(|a, b| b.created_at.cmp(&a.created_at));
resolved
}
pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?;
self.remove_plugin(plugin_context, &plugin).await
@@ -254,7 +340,8 @@ impl PluginManager {
Ok(())
}
/// Initialize all plugins from the provided list.
/// Initialize all plugins from the provided DB list.
/// Plugin candidates are resolved for this runtime instance before initialization.
/// Returns a list of (plugin_directory, error_message) for any plugins that failed to initialize.
pub async fn initialize_all_plugins(
&self,
@@ -264,15 +351,18 @@ impl PluginManager {
info!("Initializing all plugins");
let start = Instant::now();
let mut errors = Vec::new();
let plugins = self.resolve_plugins_for_runtime_from_db(plugins).await;
// Rebuild runtime handles from scratch to avoid stale/duplicate handles.
let existing_handles = { self.plugin_handles.lock().await.clone() };
for plugin_handle in existing_handles {
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
error!("Failed to remove plugin {} {e:?}", plugin_handle.dir);
errors.push((plugin_handle.dir.clone(), e.to_string()));
}
}
for plugin in plugins {
// First remove the plugin if it exists and is enabled
if let Some(plugin_handle) = self.get_plugin_by_dir(&plugin.directory).await {
if let Err(e) = self.remove_plugin(plugin_context, &plugin_handle).await {
error!("Failed to remove plugin {} {e:?}", plugin.directory);
continue;
}
}
if let Err(e) = self.add_plugin(plugin_context, &plugin).await {
warn!("Failed to add plugin {} {e:?}", plugin.directory);
errors.push((plugin.directory.clone(), e.to_string()));
@@ -1030,6 +1120,24 @@ impl PluginManager {
}
}
fn source_priority(source: &PluginSource) -> i32 {
match source {
PluginSource::Filesystem => 3,
PluginSource::Registry => 2,
PluginSource::Bundled => 1,
}
}
fn prefer_plugin(candidate: &Plugin, existing: &Plugin) -> bool {
let candidate_priority = source_priority(&candidate.source);
let existing_priority = source_priority(&existing.source);
if candidate_priority != existing_priority {
return candidate_priority > existing_priority;
}
candidate.created_at > existing.created_at
}
async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
let mut result = read_dir(dir).await?;
let mut dirs: Vec<String> = vec![];
@@ -1048,16 +1156,10 @@ async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
fn fix_windows_paths(p: &PathBuf) -> String {
use dunce;
use path_slash::PathBufExt;
use regex::Regex;
// 1. Remove UNC prefix for Windows paths to pass to sidecar
let safe_path = dunce::simplified(p.as_path()).to_string_lossy().to_string();
// 1. Remove UNC prefix for Windows paths
let safe_path = dunce::simplified(p.as_path());
// 2. Remove the drive letter
let safe_path = Regex::new("^[a-zA-Z]:").unwrap().replace(safe_path.as_str(), "");
// 3. Convert backslashes to forward
let safe_path = PathBuf::from(safe_path.to_string()).to_slash_lossy().to_string();
safe_path
// 2. Convert backslashes to forward slashes for Node.js compatibility
PathBuf::from(safe_path).to_slash_lossy().to_string()
}

View File

@@ -1,5 +1,5 @@
/* tslint:disable */
/* eslint-disable */
export function unescape_template(template: string): any;
export function parse_template(template: string): any;
export function escape_template(template: string): any;
export function parse_template(template: string): any;
export function unescape_template(template: string): any;

View File

@@ -165,10 +165,10 @@ function takeFromExternrefTable0(idx) {
* @param {string} template
* @returns {any}
*/
export function unescape_template(template) {
export function escape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.unescape_template(ptr0, len0);
const ret = wasm.escape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
@@ -193,10 +193,10 @@ export function parse_template(template) {
* @param {string} template
* @returns {any}
*/
export function escape_template(template) {
export function unescape_template(template) {
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.escape_template(ptr0, len0);
const ret = wasm.unescape_template(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}

Binary file not shown.

View File

@@ -11,6 +11,7 @@ pub fn format_json(text: &str, tab: &str) -> String {
let mut new_json = "".to_string();
let mut depth = 0;
let mut state = FormatState::None;
let mut saw_newline_in_whitespace = false;
loop {
let rest_of_chars = chars.clone();
@@ -61,6 +62,62 @@ pub fn format_json(text: &str, tab: &str) -> String {
continue;
}
// Handle line comments (//)
if current_char == '/' && chars.peek() == Some(&'/') {
chars.next(); // Skip second /
// Collect the rest of the comment until newline
let mut comment = String::from("//");
loop {
match chars.peek() {
Some(&'\n') | None => break,
Some(_) => comment.push(chars.next().unwrap()),
}
}
// Check if the comma handler already added \n + indent
let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\t');
if trimmed.ends_with(",\n") && !saw_newline_in_whitespace {
// Trailing comment on the same line as comma (e.g. "foo",// comment)
new_json.truncate(trimmed.len() - 1);
new_json.push(' ');
} else if !trimmed.ends_with('\n') && !new_json.is_empty() {
// Trailing comment after a value (no newline before us)
new_json.push(' ');
}
new_json.push_str(&comment);
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
saw_newline_in_whitespace = false;
continue;
}
// Handle block comments (/* ... */)
if current_char == '/' && chars.peek() == Some(&'*') {
chars.next(); // Skip *
let mut comment = String::from("/*");
loop {
match chars.next() {
None => break,
Some('*') if chars.peek() == Some(&'/') => {
chars.next(); // Skip /
comment.push_str("*/");
break;
}
Some(c) => comment.push(c),
}
}
// If we're not already on a fresh line, add newline + indent before comment
let trimmed = new_json.trim_end_matches(|c: char| c == ' ' || c == '\t');
if !trimmed.is_empty() && !trimmed.ends_with('\n') {
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
}
new_json.push_str(&comment);
// After block comment, add newline + indent for the next content
new_json.push('\n');
new_json.push_str(tab.to_string().repeat(depth).as_str());
continue;
}
match current_char {
',' => {
new_json.push(current_char);
@@ -125,20 +182,37 @@ pub fn format_json(text: &str, tab: &str) -> String {
|| current_char == '\t'
|| current_char == '\r'
{
if current_char == '\n' {
saw_newline_in_whitespace = true;
}
// Don't add these
} else {
saw_newline_in_whitespace = false;
new_json.push(current_char);
}
}
}
}
// Replace only lines containing whitespace with nothing
new_json
.lines()
.filter(|line| !line.trim().is_empty()) // Filter out whitespace-only lines
.collect::<Vec<&str>>() // Collect the non-empty lines into a vector
.join("\n") // Join the lines back into a single string
// Filter out whitespace-only lines, but preserve empty lines inside block comments
let mut result_lines: Vec<&str> = Vec::new();
let mut in_block_comment = false;
for line in new_json.lines() {
if in_block_comment {
result_lines.push(line);
if line.contains("*/") {
in_block_comment = false;
}
} else {
if line.contains("/*") && !line.contains("*/") {
in_block_comment = true;
}
if !line.trim().is_empty() {
result_lines.push(line);
}
}
}
result_lines.iter().map(|line| line.trim_end()).collect::<Vec<&str>>().join("\n")
}
#[cfg(test)]
@@ -297,6 +371,161 @@ mod tests {
r#"
{}
}
"#
.trim()
);
}
#[test]
fn test_line_comment_between_keys() {
assert_eq!(
format_json(
r#"{"foo":"bar",// a comment
"baz":"qux"}"#,
" "
),
r#"
{
"foo": "bar", // a comment
"baz": "qux"
}
"#
.trim()
);
}
#[test]
fn test_line_comment_at_end() {
assert_eq!(
format_json(
r#"{"foo":"bar" // trailing
}"#,
" "
),
r#"
{
"foo": "bar" // trailing
}
"#
.trim()
);
}
#[test]
fn test_block_comment() {
assert_eq!(
format_json(r#"{"foo":"bar",/* comment */"baz":"qux"}"#, " "),
r#"
{
"foo": "bar",
/* comment */
"baz": "qux"
}
"#
.trim()
);
}
#[test]
fn test_comment_in_array() {
assert_eq!(
format_json(
r#"[1,// item comment
2,3]"#,
" "
),
r#"
[
1, // item comment
2,
3
]
"#
.trim()
);
}
#[test]
fn test_comment_only_line() {
assert_eq!(
format_json(
r#"{
// this is a standalone comment
"foo": "bar"
}"#,
" "
),
r#"
{
// this is a standalone comment
"foo": "bar"
}
"#
.trim()
);
}
#[test]
fn test_multiline_block_comment() {
assert_eq!(
format_json(
r#"{
"foo": "bar"
/**
Hello World!
Hi there
*/
}"#,
" "
),
r#"
{
"foo": "bar"
/**
Hello World!
Hi there
*/
}
"#
.trim()
);
}
// NOTE: trailing whitespace on output lines is trimmed by the formatter.
// We can't easily add a test for this because raw string literals get
// trailing whitespace stripped by the editor/linter.
#[test]
fn test_comment_inside_string_ignored() {
assert_eq!(
format_json(r#"{"foo":"// not a comment","bar":"/* also not */"}"#, " "),
r#"
{
"foo": "// not a comment",
"bar": "/* also not */"
}
"#
.trim()
);
}
#[test]
fn test_comment_on_line_after_comma() {
assert_eq!(
format_json(
r#"{
"a": "aaa",
// "b": "bbb"
}"#,
" "
),
r#"
{
"a": "aaa",
// "b": "bbb"
}
"#
.trim()
);

View File

@@ -1,6 +1,7 @@
pub mod error;
pub mod escape;
pub mod format_json;
pub mod strip_json_comments;
pub mod parser;
pub mod renderer;
pub mod wasm;

View File

@@ -0,0 +1,318 @@
/// Strips JSON comments only if the result is valid JSON. If stripping comments
/// produces invalid JSON, the original text is returned unchanged.
pub fn maybe_strip_json_comments(text: &str) -> String {
let stripped = strip_json_comments(text);
if serde_json::from_str::<serde_json::Value>(&stripped).is_ok() {
stripped
} else {
text.to_string()
}
}
/// Strips comments from JSONC, preserving the original formatting as much as possible.
///
/// - Trailing comments on a line are removed (along with preceding whitespace)
/// - Whole-line comments are removed, including the line itself
/// - Block comments are removed, including any lines that become empty
/// - Comments inside strings and template tags are left alone
pub fn strip_json_comments(text: &str) -> String {
let mut chars = text.chars().peekable();
let mut result = String::with_capacity(text.len());
let mut in_string = false;
let mut in_template_tag = false;
loop {
let current_char = match chars.next() {
None => break,
Some(c) => c,
};
// Handle JSON strings
if in_string {
result.push(current_char);
match current_char {
'"' => in_string = false,
'\\' => {
if let Some(c) = chars.next() {
result.push(c);
}
}
_ => {}
}
continue;
}
// Handle template tags
if in_template_tag {
result.push(current_char);
if current_char == ']' && chars.peek() == Some(&'}') {
result.push(chars.next().unwrap());
in_template_tag = false;
}
continue;
}
// Check for template tag start
if current_char == '$' && chars.peek() == Some(&'{') {
let mut lookahead = chars.clone();
lookahead.next(); // skip {
if lookahead.peek() == Some(&'[') {
in_template_tag = true;
result.push(current_char);
result.push(chars.next().unwrap()); // {
result.push(chars.next().unwrap()); // [
continue;
}
}
// Check for line comment
if current_char == '/' && chars.peek() == Some(&'/') {
chars.next(); // skip second /
// Consume until newline
loop {
match chars.peek() {
Some(&'\n') | None => break,
Some(_) => {
chars.next();
}
}
}
// Trim trailing whitespace that preceded the comment
let trimmed_len = result.trim_end_matches(|c: char| c == ' ' || c == '\t').len();
result.truncate(trimmed_len);
continue;
}
// Check for block comment
if current_char == '/' && chars.peek() == Some(&'*') {
chars.next(); // skip *
// Consume until */
loop {
match chars.next() {
None => break,
Some('*') if chars.peek() == Some(&'/') => {
chars.next(); // skip /
break;
}
Some(_) => {}
}
}
// Trim trailing whitespace that preceded the comment
let trimmed_len = result.trim_end_matches(|c: char| c == ' ' || c == '\t').len();
result.truncate(trimmed_len);
// Skip whitespace/newline after the block comment if the next line is content
// (this handles the case where the block comment is on its own line)
continue;
}
if current_char == '"' {
in_string = true;
}
result.push(current_char);
}
// Remove lines that are now empty (were comment-only lines)
let result = result
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<&str>>()
.join("\n");
// Remove trailing commas before } or ]
strip_trailing_commas(&result)
}
/// Removes trailing commas before closing braces/brackets, respecting strings.
fn strip_trailing_commas(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
let mut in_string = false;
while i < chars.len() {
let ch = chars[i];
if in_string {
result.push(ch);
match ch {
'"' => in_string = false,
'\\' => {
i += 1;
if i < chars.len() {
result.push(chars[i]);
}
}
_ => {}
}
i += 1;
continue;
}
if ch == '"' {
in_string = true;
result.push(ch);
i += 1;
continue;
}
if ch == ',' {
// Look ahead past whitespace/newlines for } or ]
let mut j = i + 1;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
// Skip the comma
i += 1;
continue;
}
}
result.push(ch);
i += 1;
}
result
}
#[cfg(test)]
mod tests {
use crate::strip_json_comments::strip_json_comments;
#[test]
fn test_no_comments() {
let input = r#"{
"foo": "bar",
"baz": 123
}"#;
assert_eq!(strip_json_comments(input), input);
}
#[test]
fn test_trailing_line_comment() {
assert_eq!(
strip_json_comments(r#"{
"foo": "bar", // this is a comment
"baz": 123
}"#),
r#"{
"foo": "bar",
"baz": 123
}"#
);
}
#[test]
fn test_whole_line_comment() {
assert_eq!(
strip_json_comments(r#"{
// this is a comment
"foo": "bar"
}"#),
r#"{
"foo": "bar"
}"#
);
}
#[test]
fn test_inline_block_comment() {
assert_eq!(
strip_json_comments(r#"{
"foo": /* a comment */ "bar"
}"#),
r#"{
"foo": "bar"
}"#
);
}
#[test]
fn test_whole_line_block_comment() {
assert_eq!(
strip_json_comments(r#"{
/* a comment */
"foo": "bar"
}"#),
r#"{
"foo": "bar"
}"#
);
}
#[test]
fn test_multiline_block_comment() {
assert_eq!(
strip_json_comments(r#"{
/**
* Hello World!
*/
"foo": "bar"
}"#),
r#"{
"foo": "bar"
}"#
);
}
#[test]
fn test_comment_inside_string_preserved() {
let input = r#"{
"foo": "// not a comment",
"bar": "/* also not */"
}"#;
assert_eq!(strip_json_comments(input), input);
}
#[test]
fn test_comment_inside_template_tag_preserved() {
let input = r#"{
"foo": ${[ fn("// hi", "/* hey */") ]}
}"#;
assert_eq!(strip_json_comments(input), input);
}
#[test]
fn test_multiple_comments() {
assert_eq!(
strip_json_comments(r#"{
// first comment
"foo": "bar", // trailing
/* block */
"baz": 123
}"#),
r#"{
"foo": "bar",
"baz": 123
}"#
);
}
#[test]
fn test_trailing_comma_after_comment_removed() {
assert_eq!(
strip_json_comments(r#"{
"a": "aaa",
// "b": "bbb"
}"#),
r#"{
"a": "aaa"
}"#
);
}
#[test]
fn test_trailing_comma_in_array() {
assert_eq!(
strip_json_comments(r#"[1, 2, /* 3 */]"#),
r#"[1, 2]"#
);
}
#[test]
fn test_comma_inside_string_preserved() {
let input = r#"{"a": "hello,}"#;
assert_eq!(strip_json_comments(input), input);
}
}

View File

@@ -273,6 +273,5 @@ pub fn find_client_certificate(
});
}
debug!("No matching client certificate found for {}", url_string);
None
}

View File

@@ -1,14 +1,16 @@
use yaak_models::models::AnyModel;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::UpdateSource;
use yaak_plugins::events::{
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
DeleteModelRequest, ErrorResponse, FindHttpResponsesRequest, GetCookieValueRequest,
GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse,
InternalEventPayload, ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse,
ListHttpRequestsRequest, ListHttpRequestsResponse, ListOpenWorkspacesRequest,
OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest, PromptTextRequest,
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
WindowInfoRequest,
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
};
pub struct SharedPluginEventContext<'a> {
@@ -37,6 +39,9 @@ pub enum SharedRequest<'a> {
GetHttpRequestById(&'a GetHttpRequestByIdRequest),
ListFolders(&'a ListFoldersRequest),
ListHttpRequests(&'a ListHttpRequestsRequest),
FindHttpResponses(&'a FindHttpResponsesRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
}
#[derive(Debug)]
@@ -45,9 +50,6 @@ pub enum HostRequest<'a> {
CopyText(&'a CopyTextRequest),
PromptText(&'a PromptTextRequest),
PromptForm(&'a PromptFormRequest),
FindHttpResponses(&'a FindHttpResponsesRequest),
UpsertModel(&'a UpsertModelRequest),
DeleteModel(&'a DeleteModelRequest),
RenderGrpcRequest(&'a RenderGrpcRequestRequest),
RenderHttpRequest(&'a RenderHttpRequestRequest),
TemplateRender(&'a TemplateRenderRequest),
@@ -71,9 +73,6 @@ impl HostRequest<'_> {
HostRequest::CopyText(_) => "copy_text_request".to_string(),
HostRequest::PromptText(_) => "prompt_text_request".to_string(),
HostRequest::PromptForm(_) => "prompt_form_request".to_string(),
HostRequest::FindHttpResponses(_) => "find_http_responses_request".to_string(),
HostRequest::UpsertModel(_) => "upsert_model_request".to_string(),
HostRequest::DeleteModel(_) => "delete_model_request".to_string(),
HostRequest::RenderGrpcRequest(_) => "render_grpc_request_request".to_string(),
HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(),
HostRequest::TemplateRender(_) => "template_render_request".to_string(),
@@ -135,13 +134,13 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
GroupedPluginRequest::Host(HostRequest::PromptForm(req))
}
InternalEventPayload::FindHttpResponsesRequest(req) => {
GroupedPluginRequest::Host(HostRequest::FindHttpResponses(req))
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
}
InternalEventPayload::UpsertModelRequest(req) => {
GroupedPluginRequest::Host(HostRequest::UpsertModel(req))
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
}
InternalEventPayload::DeleteModelRequest(req) => {
GroupedPluginRequest::Host(HostRequest::DeleteModel(req))
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
}
InternalEventPayload::RenderGrpcRequestRequest(req) => {
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
@@ -275,17 +274,175 @@ fn build_shared_reply(
http_requests,
})
}
SharedRequest::FindHttpResponses(req) => {
let http_responses = query_manager
.connect()
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
.unwrap_or_default();
InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
http_responses,
})
}
SharedRequest::UpsertModel(req) => {
use AnyModel::*;
let model = match &req.model {
HttpRequest(m) => {
match query_manager.connect().upsert_http_request(m, &UpdateSource::Plugin) {
Ok(model) => HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert HTTP request: {err}"),
});
}
}
}
GrpcRequest(m) => {
match query_manager.connect().upsert_grpc_request(m, &UpdateSource::Plugin) {
Ok(model) => GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert gRPC request: {err}"),
});
}
}
}
WebsocketRequest(m) => {
match query_manager.connect().upsert_websocket_request(m, &UpdateSource::Plugin)
{
Ok(model) => WebsocketRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert WebSocket request: {err}"),
});
}
}
}
Folder(m) => {
match query_manager.connect().upsert_folder(m, &UpdateSource::Plugin) {
Ok(model) => Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert folder: {err}"),
});
}
}
}
Environment(m) => {
match query_manager.connect().upsert_environment(m, &UpdateSource::Plugin) {
Ok(model) => Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert environment: {err}"),
});
}
}
}
Workspace(m) => {
match query_manager.connect().upsert_workspace(m, &UpdateSource::Plugin) {
Ok(model) => Workspace(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to upsert workspace: {err}"),
});
}
}
}
_ => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Upsert not supported for this model type".to_string(),
});
}
};
InternalEventPayload::UpsertModelResponse(UpsertModelResponse { model })
}
SharedRequest::DeleteModel(req) => {
let model = match req.model.as_str() {
"http_request" => {
match query_manager
.connect()
.delete_http_request_by_id(&req.id, &UpdateSource::Plugin)
{
Ok(model) => AnyModel::HttpRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete HTTP request: {err}"),
});
}
}
}
"grpc_request" => {
match query_manager
.connect()
.delete_grpc_request_by_id(&req.id, &UpdateSource::Plugin)
{
Ok(model) => AnyModel::GrpcRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete gRPC request: {err}"),
});
}
}
}
"websocket_request" => {
match query_manager
.connect()
.delete_websocket_request_by_id(&req.id, &UpdateSource::Plugin)
{
Ok(model) => AnyModel::WebsocketRequest(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete WebSocket request: {err}"),
});
}
}
}
"folder" => match query_manager
.connect()
.delete_folder_by_id(&req.id, &UpdateSource::Plugin)
{
Ok(model) => AnyModel::Folder(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete folder: {err}"),
});
}
},
"environment" => {
match query_manager
.connect()
.delete_environment_by_id(&req.id, &UpdateSource::Plugin)
{
Ok(model) => AnyModel::Environment(model),
Err(err) => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: format!("Failed to delete environment: {err}"),
});
}
}
}
_ => {
return InternalEventPayload::ErrorResponse(ErrorResponse {
error: "Delete not supported for this model type".to_string(),
});
}
};
InternalEventPayload::DeleteModelResponse(DeleteModelResponse { model })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use yaak_models::models::{Folder, HttpRequest, Workspace};
use tempfile::TempDir;
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
use yaak_models::util::UpdateSource;
fn seed_query_manager() -> QueryManager {
let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
fn seed_query_manager() -> (QueryManager, TempDir) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("db.sqlite");
let blob_path = temp_dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
@@ -332,12 +489,12 @@ mod tests {
)
.expect("Failed to seed request");
query_manager
(query_manager, temp_dir)
}
#[test]
fn list_requests_requires_workspace_when_folder_missing() {
let query_manager = seed_query_manager();
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
);
@@ -355,7 +512,7 @@ mod tests {
#[test]
fn list_requests_by_workspace_and_folder() {
let query_manager = seed_query_manager();
let (query_manager, _temp_dir) = seed_query_manager();
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
@@ -394,9 +551,83 @@ mod tests {
}
}
#[test]
fn find_http_responses_is_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::FindHttpResponsesRequest(FindHttpResponsesRequest {
request_id: "rq_test".to_string(),
limit: Some(1),
});
let result = handle_shared_plugin_event(
&query_manager,
&payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::FindHttpResponsesResponse(
resp,
))) => {
assert!(resp.http_responses.is_empty());
}
other => panic!("unexpected find responses result: {other:?}"),
}
}
#[test]
fn upsert_and_delete_model_are_shared_handled() {
let (query_manager, _temp_dir) = seed_query_manager();
let existing = query_manager
.connect()
.get_http_request("rq_test")
.expect("Failed to load seeded request");
let upsert_payload = InternalEventPayload::UpsertModelRequest(UpsertModelRequest {
model: AnyModel::HttpRequest(HttpRequest {
name: "Request Updated".to_string(),
..existing
}),
});
let upsert_result = handle_shared_plugin_event(
&query_manager,
&upsert_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match upsert_result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::UpsertModelResponse(resp))) => {
match resp.model {
AnyModel::HttpRequest(r) => assert_eq!(r.name, "Request Updated"),
other => panic!("unexpected upsert model type: {other:?}"),
}
}
other => panic!("unexpected upsert result: {other:?}"),
}
let delete_payload = InternalEventPayload::DeleteModelRequest(DeleteModelRequest {
model: "http_request".to_string(),
id: "rq_test".to_string(),
});
let delete_result = handle_shared_plugin_event(
&query_manager,
&delete_payload,
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
);
match delete_result {
GroupedPluginEvent::Handled(Some(InternalEventPayload::DeleteModelResponse(resp))) => {
match resp.model {
AnyModel::HttpRequest(r) => assert_eq!(r.id, "rq_test"),
other => panic!("unexpected delete model type: {other:?}"),
}
}
other => panic!("unexpected delete result: {other:?}"),
}
}
#[test]
fn host_request_classification_works() {
let query_manager = seed_query_manager();
let (query_manager, _temp_dir) = seed_query_manager();
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
label: "main".to_string(),
});

View File

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

View File

@@ -239,6 +239,7 @@ pub struct SendHttpRequestByIdParams<'a, T: TemplateCallback> {
pub cookie_jar_id: Option<String>,
pub response_dir: &'a Path,
pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,
pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,
pub cancelled_rx: Option<watch::Receiver<bool>>,
pub prepare_sendable_request: Option<&'a dyn PrepareSendableRequest>,
pub executor: Option<&'a dyn SendRequestExecutor>,
@@ -255,6 +256,7 @@ pub struct SendHttpRequestParams<'a, T: TemplateCallback> {
pub cookie_jar_id: Option<String>,
pub response_dir: &'a Path,
pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,
pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,
pub cancelled_rx: Option<watch::Receiver<bool>>,
pub auth_context_id: Option<String>,
pub existing_response: Option<HttpResponse>,
@@ -271,6 +273,7 @@ pub struct SendHttpRequestWithPluginsParams<'a> {
pub cookie_jar_id: Option<String>,
pub response_dir: &'a Path,
pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,
pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,
pub existing_response: Option<HttpResponse>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
@@ -288,6 +291,7 @@ pub struct SendHttpRequestByIdWithPluginsParams<'a> {
pub cookie_jar_id: Option<String>,
pub response_dir: &'a Path,
pub emit_events_to: Option<mpsc::Sender<SenderHttpResponseEvent>>,
pub emit_response_body_chunks_to: Option<mpsc::UnboundedSender<Vec<u8>>>,
pub plugin_manager: Arc<PluginManager>,
pub encryption_manager: Arc<EncryptionManager>,
pub plugin_context: &'a PluginContext,
@@ -353,6 +357,7 @@ pub async fn send_http_request_by_id_with_plugins(
cookie_jar_id: params.cookie_jar_id,
response_dir: params.response_dir,
emit_events_to: params.emit_events_to,
emit_response_body_chunks_to: params.emit_response_body_chunks_to,
existing_response: None,
plugin_manager: params.plugin_manager,
encryption_manager: params.encryption_manager,
@@ -397,6 +402,7 @@ pub async fn send_http_request_with_plugins(
cookie_jar_id: params.cookie_jar_id,
response_dir: params.response_dir,
emit_events_to: params.emit_events_to,
emit_response_body_chunks_to: params.emit_response_body_chunks_to,
cancelled_rx: params.cancelled_rx,
auth_context_id: None,
existing_response: params.existing_response,
@@ -427,6 +433,7 @@ pub async fn send_http_request_by_id<T: TemplateCallback>(
cookie_jar_id: params.cookie_jar_id,
response_dir: params.response_dir,
emit_events_to: params.emit_events_to,
emit_response_body_chunks_to: params.emit_response_body_chunks_to,
cancelled_rx: params.cancelled_rx,
existing_response: None,
prepare_sendable_request: params.prepare_sendable_request,
@@ -687,13 +694,17 @@ pub async fn send_http_request<T: TemplateCallback>(
Ok(n) => {
written_bytes += n;
let start_idx = response_body.len() - n;
file.write_all(&response_body[start_idx..]).await.map_err(|source| {
let chunk = &response_body[start_idx..];
file.write_all(chunk).await.map_err(|source| {
SendHttpRequestError::WriteResponseBody { path: body_path.clone(), source }
})?;
file.flush().await.map_err(|source| SendHttpRequestError::WriteResponseBody {
path: body_path.clone(),
source,
})?;
if let Some(tx) = params.emit_response_body_chunks_to.as_ref() {
let _ = tx.send(chunk.to_vec());
}
let now = Instant::now();
let should_update = now.duration_since(last_progress_update).as_millis()

View File

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

View File

@@ -16,7 +16,8 @@ function getBinaryPath() {
}
const result = childProcess.spawnSync(getBinaryPath(), process.argv.slice(2), {
stdio: "inherit"
stdio: "inherit",
env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? "npm" },
});
if (result.error) {

View File

@@ -15,6 +15,7 @@ function getBinaryPath() {
module.exports.runBinary = function runBinary(...args) {
childProcess.execFileSync(getBinaryPath(), args, {
stdio: "inherit"
stdio: "inherit",
env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? "npm" },
});
};

View File

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

729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -18,12 +18,12 @@ export type CallHttpAuthenticationActionRequest = { index: number, pluginRefId:
export type CallHttpAuthenticationRequest = { contextId: string, values: { [key in string]?: JsonPrimitive }, method: string, url: string, headers: Array<HttpHeader>, };
export type CallHttpAuthenticationResponse = {
export type CallHttpAuthenticationResponse = {
/**
* HTTP headers to add to the request. Existing headers will be replaced, while
* new headers will be added.
*/
setHeaders?: Array<HttpHeader>,
setHeaders?: Array<HttpHeader>,
/**
* Query parameters to add to the request. Existing params will be replaced, while
* new params will be added.
@@ -78,7 +78,7 @@ export type ExportHttpRequestRequest = { httpRequest: HttpRequest, };
export type ExportHttpRequestResponse = { content: string, };
export type FileFilter = { name: string,
export type FileFilter = { name: string,
/**
* File extensions to require
*/
@@ -100,149 +100,149 @@ export type FormInputAccordion = { label: string, inputs?: Array<FormInput>, hid
export type FormInputBanner = { inputs?: Array<FormInput>, hidden?: boolean, color?: Color, };
export type FormInputBase = {
export type FormInputBase = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputCheckbox = {
export type FormInputCheckbox = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputEditor = {
export type FormInputEditor = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Don't show the editor gutter (line numbers, folds, etc.)
*/
hideGutter?: boolean,
hideGutter?: boolean,
/**
* Language for syntax highlighting
*/
language?: EditorLanguage, readOnly?: boolean,
language?: EditorLanguage, readOnly?: boolean,
/**
* Fixed number of visible rows
*/
rows?: number, completionOptions?: Array<GenericCompletionOption>,
rows?: number, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputFile = {
export type FormInputFile = {
/**
* The title of the file selection window
*/
title: string,
title: string,
/**
* Allow selecting multiple files
*/
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
multiple?: boolean, directory?: boolean, defaultPath?: string, filters?: Array<FileFilter>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -250,63 +250,63 @@ description?: string, };
export type FormInputHStack = { inputs?: Array<FormInput>, hidden?: boolean, };
export type FormInputHttpRequest = {
export type FormInputHttpRequest = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
description?: string, };
export type FormInputKeyValue = {
export type FormInputKeyValue = {
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -314,36 +314,36 @@ description?: string, };
export type FormInputMarkdown = { content: string, hidden?: boolean, };
export type FormInputSelect = {
export type FormInputSelect = {
/**
* The options that will be available in the select input
*/
options: Array<FormInputSelectOption>,
options: Array<FormInputSelectOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -351,44 +351,44 @@ description?: string, };
export type FormInputSelectOption = { label: string, value: string, };
export type FormInputText = {
export type FormInputText = {
/**
* Placeholder for the text input
*/
placeholder?: string | null,
placeholder?: string | null,
/**
* Placeholder for the text input
*/
password?: boolean,
password?: boolean,
/**
* Whether to allow newlines in the input, like a <textarea/>
*/
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
multiLine?: boolean, completionOptions?: Array<GenericCompletionOption>,
/**
* The name of the input. The value will be stored at this object attribute in the resulting data
*/
name: string,
name: string,
/**
* Whether this input is visible for the given configuration. Use this to
* make branching forms.
*/
hidden?: boolean,
hidden?: boolean,
/**
* Whether the user must fill in the argument
*/
optional?: boolean,
optional?: boolean,
/**
* The label of the input
*/
label?: string,
label?: string,
/**
* Visually hide the label of the input
*/
hideLabel?: boolean,
hideLabel?: boolean,
/**
* The default value
*/
defaultValue?: string, disabled?: boolean,
defaultValue?: string, disabled?: boolean,
/**
* Longer description of the input, likely shown in a tooltip
*/
@@ -474,7 +474,7 @@ export type ListOpenWorkspacesResponse = { workspaces: Array<WorkspaceInfo>, };
export type OpenExternalUrlRequest = { url: string, };
export type OpenWindowRequest = { url: string,
export type OpenWindowRequest = { url: string,
/**
* Label for the window. If not provided, a random one will be generated.
*/
@@ -486,15 +486,15 @@ export type PromptFormRequest = { id: string, title: string, description?: strin
export type PromptFormResponse = { values: { [key in string]?: JsonPrimitive } | null, done?: boolean, };
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
export type PromptTextRequest = { id: string, title: string, label: string, description?: string, defaultValue?: string, placeholder?: string,
/**
* Text to add to the confirmation button
*/
confirmText?: string, password?: boolean,
confirmText?: string, password?: boolean,
/**
* Text to add to the cancel button
*/
cancelText?: string,
cancelText?: string,
/**
* Require the user to enter a non-empty value
*/
@@ -524,12 +524,12 @@ export type SetKeyValueResponse = {};
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
export type TemplateFunction = { name: string, previewType?: TemplateFunctionPreviewType, description?: string,
/**
* Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property
*/
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
@@ -546,23 +546,23 @@ export type TemplateRenderRequest = { data: JsonValue, purpose: RenderPurpose, }
export type TemplateRenderResponse = { data: JsonValue, };
export type Theme = {
export type Theme = {
/**
* How the theme is identified. This should never be changed
*/
id: string,
id: string,
/**
* The friendly name of the theme to be displayed to the user
*/
label: string,
label: string,
/**
* Whether the theme will be used for dark or light appearance
*/
dark: boolean,
dark: boolean,
/**
* The default top-level colors for the theme
*/
base: ThemeComponentColors,
base: ThemeComponentColors,
/**
* Optionally override theme for individual UI components for more control
*/

View File

@@ -18,7 +18,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -34,9 +39,17 @@ export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, up
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number, url: string, };
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -49,17 +62,24 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type PluginSource = "bundled" | "filesystem" | "registry";
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
@@ -77,7 +97,11 @@ export type WebsocketEvent = { model: "websocket_event", id: string, createdAt:
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string, urlParameters: Array<HttpUrlParameter>, };
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, };
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingDnsOverrides: Array<DnsOverride>, };

View File

@@ -76,10 +76,10 @@ export class PluginInstance {
this.#mod = {};
const fileChangeCallback = async () => {
await this.#mod?.dispose?.();
this.#importModule();
const ctx = this.#newCtx(workerData.context);
try {
await this.#mod?.dispose?.();
this.#importModule();
await this.#mod?.init?.(ctx);
this.#sendPayload(
workerData.context,
@@ -90,7 +90,7 @@ export class PluginInstance {
null,
);
} catch (err: unknown) {
ctx.toast.show({
await ctx.toast.show({
message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split('/').pop()}: ${err}`,
color: 'notice',
icon: 'alert_triangle',
@@ -1003,6 +1003,7 @@ function watchFile(filepath: string, cb: () => void) {
const stat = statSync(filepath, { throwIfNoEntry: false });
if (stat == null || stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
watchedFiles[filepath] = stat ?? null;
console.log('[plugin-runtime] watchFile triggered', filepath);
cb();
}
});

View File

@@ -13,12 +13,16 @@ describe('template-function-faker', () => {
it('renders date results as unquoted ISO strings', async () => {
const { plugin } = await import('../src/index');
const fn = plugin.templateFunctions?.find((fn) => fn.name === 'faker.date.future');
const onRender = fn?.onRender;
expect(fn?.onRender).toBeTypeOf('function');
expect(onRender).toBeTypeOf('function');
if (onRender == null) {
throw new Error("Expected template function 'faker.date.future' to define onRender");
}
const result = await fn!.onRender!(
{} as Parameters<NonNullable<typeof fn.onRender>>[0],
{ values: {} } as Parameters<NonNullable<typeof fn.onRender>>[1],
const result = await onRender(
{} as Parameters<typeof onRender>[0],
{ values: {} } as Parameters<typeof onRender>[1],
);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);

View File

@@ -206,7 +206,10 @@ export const plugin: PluginDefinition = {
// Create snippet generator
const snippet = new HTTPSnippet(harRequest);
const generateSnippet = (target: string, client: string): string => {
const result = snippet.convert(target as any, client);
const result = snippet.convert(
target as Parameters<typeof snippet.convert>[0],
client as Parameters<typeof snippet.convert>[1],
);
return (Array.isArray(result) ? result.join('\n') : result || '').replace(/\r\n/g, '\n');
};

View File

@@ -16,9 +16,9 @@
},
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@hono/node-server": "^1.19.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.11.10",
"hono": "^4.12.4",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@@ -11,7 +11,7 @@ export const plugin: PluginDefinition = {
async onSelect(ctx, args) {
const rendered_request = await ctx.grpcRequest.render({
grpcRequest: args.grpcRequest,
purpose: 'preview',
purpose: 'send',
});
const data = await convert(rendered_request, args.protoFiles);
await ctx.clipboard.copyText(data);
@@ -103,7 +103,7 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
// Add form params
if (request.message) {
xs.push('-d', `${quote(JSON.stringify(JSON.parse(request.message)))}`);
xs.push('-d', quote(request.message));
xs.push(NEWLINE);
}

View File

@@ -151,7 +151,26 @@ describe('exporter-curl', () => {
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{"foo":"bar","baz":1}'`,
`-d '{\n "foo": "bar",\n "baz": 1\n}'`,
'yaak.app',
].join(' \\\n '),
);
});
test('Sends data with unresolved template tags', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
message: '{"timestamp": ${[ faker "timestamp" ]}, "foo": "bar"}',
},
['/foo.proto'],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{"timestamp": \${[ faker "timestamp" ]}, "foo": "bar"}'`,
'yaak.app',
].join(' \\\n '),
);

View File

@@ -1,7 +1,7 @@
{
"name": "@yaak/action-send-folder",
"displayName": "Send All",
"description": "Send all HTTP requests in a folder sequentially",
"description": "Send all HTTP requests in a folder sequentially in tree order",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",

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