mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-06 05:10:05 +01:00
Compare commits
32 Commits
codex/cli-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b563319bed | ||
|
|
3d577dd7d9 | ||
|
|
591c68c59c | ||
|
|
a0cb7f813f | ||
|
|
cfab62707e | ||
|
|
267508e533 | ||
|
|
242f55b609 | ||
|
|
67a3dd15ac | ||
|
|
543325613b | ||
|
|
88f5f0e045 | ||
|
|
615f3134d2 | ||
|
|
0c7051d59c | ||
|
|
30f006401a | ||
|
|
3c12074db6 | ||
|
|
851f12f149 | ||
|
|
cc0d31fdbb | ||
|
|
bab4fe899b | ||
|
|
0b250ff5b5 | ||
|
|
fbf0473b20 | ||
|
|
876b7ef454 | ||
|
|
96e8572758 | ||
|
|
f302dc39a2 | ||
|
|
2ca51125a4 | ||
|
|
2d99e26f19 | ||
|
|
da1e04d99e | ||
|
|
d875eaa5bf | ||
|
|
5fa2469cd6 | ||
|
|
49053cb423 | ||
|
|
37d0cabb22 | ||
|
|
435ee54140 | ||
|
|
407f2c9921 | ||
|
|
3a6630a14d |
@@ -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
|
|
||||||
@@ -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**: 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**: PRs by `@gschier` should not mention the @username
|
||||||
|
**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process.
|
||||||
|
|
||||||
## After Generating Release Notes
|
## After Generating Release Notes
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -32,6 +32,7 @@ Generate formatted markdown release notes for a Yaak tag.
|
|||||||
- Keep a blank line before and after the code fence.
|
- Keep a blank line before and after the code fence.
|
||||||
- Output the markdown code block last.
|
- Output the markdown code block last.
|
||||||
- Do not append `by @gschier` for PRs authored by `@gschier`.
|
- 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
|
## Release Creation Prompt
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
2
.github/workflows/release-app.yml
vendored
2
.github/workflows/release-app.yml
vendored
@@ -95,7 +95,7 @@ jobs:
|
|||||||
- name: Run JS Tests
|
- name: Run JS Tests
|
||||||
run: npm test
|
run: npm test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all
|
run: cargo test --all --exclude yaak-cli
|
||||||
|
|
||||||
- name: Set version
|
- name: Set version
|
||||||
run: npm run replace-version
|
run: npm run replace-version
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,3 +54,6 @@ flatpak/node-sources.json
|
|||||||
|
|
||||||
# Local Codex desktop env state
|
# Local Codex desktop env state
|
||||||
.codex/environments/environment.toml
|
.codex/environments/environment.toml
|
||||||
|
|
||||||
|
# Claude Code local settings
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
2
AGENTS.md
Normal file
2
AGENTS.md
Normal 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
|
||||||
244
Cargo.lock
generated
244
Cargo.lock
generated
@@ -173,6 +173,17 @@ version = "1.0.98"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
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]]
|
[[package]]
|
||||||
name = "append-only-vec"
|
name = "append-only-vec"
|
||||||
version = "0.1.8"
|
version = "0.1.8"
|
||||||
@@ -1200,7 +1211,7 @@ dependencies = [
|
|||||||
"encode_unicode",
|
"encode_unicode",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1347,6 +1358,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "countme"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cow-utils"
|
name = "cow-utils"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -1405,6 +1422,31 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
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]]
|
[[package]]
|
||||||
name = "crunchy"
|
name = "crunchy"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -2294,6 +2336,15 @@ dependencies = [
|
|||||||
"slab",
|
"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]]
|
[[package]]
|
||||||
name = "fxhash"
|
name = "fxhash"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -3164,6 +3215,24 @@ dependencies = [
|
|||||||
"generic-array",
|
"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]]
|
[[package]]
|
||||||
name = "interfaces"
|
name = "interfaces"
|
||||||
version = "0.0.8"
|
version = "0.0.8"
|
||||||
@@ -3756,6 +3825,18 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3851,6 +3932,15 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
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]]
|
[[package]]
|
||||||
name = "nibble_vec"
|
name = "nibble_vec"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3942,7 +4032,7 @@ dependencies = [
|
|||||||
"kqueue",
|
"kqueue",
|
||||||
"libc",
|
"libc",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
"mio",
|
"mio 1.0.4",
|
||||||
"notify-types",
|
"notify-types",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
@@ -4483,7 +4573,7 @@ checksum = "75b1853bc34cadaa90aa09f95713d8b77ec0c0d3e2d90ccf7a74216f40d20850"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"flate2",
|
"flate2",
|
||||||
"postcard",
|
"postcard",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
@@ -4501,7 +4591,7 @@ dependencies = [
|
|||||||
"textwrap",
|
"textwrap",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4526,7 +4616,7 @@ dependencies = [
|
|||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"oxc_data_structures",
|
"oxc_data_structures",
|
||||||
"oxc_estree",
|
"oxc_estree",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4582,7 +4672,7 @@ dependencies = [
|
|||||||
"oxc_index",
|
"oxc_index",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"petgraph 0.8.3",
|
"petgraph 0.8.3",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4603,7 +4693,7 @@ dependencies = [
|
|||||||
"oxc_sourcemap",
|
"oxc_sourcemap",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4615,7 +4705,7 @@ dependencies = [
|
|||||||
"cow-utils",
|
"cow-utils",
|
||||||
"oxc-browserslist",
|
"oxc-browserslist",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4690,7 +4780,7 @@ dependencies = [
|
|||||||
"oxc_ecmascript",
|
"oxc_ecmascript",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4707,7 +4797,7 @@ dependencies = [
|
|||||||
"oxc_semantic",
|
"oxc_semantic",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4732,7 +4822,7 @@ dependencies = [
|
|||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"oxc_traverse",
|
"oxc_traverse",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4754,7 +4844,7 @@ dependencies = [
|
|||||||
"oxc_regular_expression",
|
"oxc_regular_expression",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"seq-macro",
|
"seq-macro",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4770,7 +4860,7 @@ dependencies = [
|
|||||||
"oxc_diagnostics",
|
"oxc_diagnostics",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"unicode-id-start",
|
"unicode-id-start",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4787,7 +4877,7 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
"papaya",
|
"papaya",
|
||||||
"pnp",
|
"pnp",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"self_cell",
|
"self_cell",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -4817,7 +4907,7 @@ dependencies = [
|
|||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"self_cell",
|
"self_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4829,7 +4919,7 @@ checksum = "c7f89482522f3cd820817d48ee4ade5b10822060d6e5e4d419f05f6d8bd29d70"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"json-escape-simd",
|
"json-escape-simd",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -4893,7 +4983,7 @@ dependencies = [
|
|||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"oxc_traverse",
|
"oxc_traverse",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
@@ -4918,7 +5008,7 @@ dependencies = [
|
|||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"oxc_transformer",
|
"oxc_transformer",
|
||||||
"oxc_traverse",
|
"oxc_traverse",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4936,7 +5026,7 @@ dependencies = [
|
|||||||
"oxc_semantic",
|
"oxc_semantic",
|
||||||
"oxc_span",
|
"oxc_span",
|
||||||
"oxc_syntax",
|
"oxc_syntax",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5341,7 +5431,7 @@ dependencies = [
|
|||||||
"nodejs-built-in-modules",
|
"nodejs-built-in-modules",
|
||||||
"pathdiff",
|
"pathdiff",
|
||||||
"radix_trie",
|
"radix_trie",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
@@ -5460,6 +5550,18 @@ dependencies = [
|
|||||||
"termtree",
|
"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]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -5640,7 +5742,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
@@ -5660,7 +5762,7 @@ dependencies = [
|
|||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -6154,7 +6256,7 @@ dependencies = [
|
|||||||
"rolldown_tracing",
|
"rolldown_tracing",
|
||||||
"rolldown_utils",
|
"rolldown_utils",
|
||||||
"rolldown_watcher",
|
"rolldown_watcher",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"string_wizard",
|
"string_wizard",
|
||||||
@@ -6171,7 +6273,7 @@ version = "0.5.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967"
|
checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
"yansi",
|
"yansi",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6196,7 +6298,7 @@ dependencies = [
|
|||||||
"kqueue",
|
"kqueue",
|
||||||
"libc",
|
"libc",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
"mio",
|
"mio 1.0.4",
|
||||||
"rolldown-notify-types",
|
"rolldown-notify-types",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -6244,7 +6346,7 @@ dependencies = [
|
|||||||
"rolldown_sourcemap",
|
"rolldown_sourcemap",
|
||||||
"rolldown_std_utils",
|
"rolldown_std_utils",
|
||||||
"rolldown_utils",
|
"rolldown_utils",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"simdutf8",
|
"simdutf8",
|
||||||
@@ -6262,7 +6364,7 @@ dependencies = [
|
|||||||
"blake3",
|
"blake3",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"rolldown_debug_action",
|
"rolldown_debug_action",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6319,7 +6421,7 @@ dependencies = [
|
|||||||
"rolldown-ariadne",
|
"rolldown-ariadne",
|
||||||
"rolldown_utils",
|
"rolldown_utils",
|
||||||
"ropey",
|
"ropey",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"sugar_path",
|
"sugar_path",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6353,7 +6455,7 @@ dependencies = [
|
|||||||
"rolldown_resolver",
|
"rolldown_resolver",
|
||||||
"rolldown_sourcemap",
|
"rolldown_sourcemap",
|
||||||
"rolldown_utils",
|
"rolldown_utils",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"string_wizard",
|
"string_wizard",
|
||||||
@@ -6373,7 +6475,7 @@ dependencies = [
|
|||||||
"rolldown_common",
|
"rolldown_common",
|
||||||
"rolldown_plugin",
|
"rolldown_plugin",
|
||||||
"rolldown_utils",
|
"rolldown_utils",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
]
|
]
|
||||||
@@ -6444,7 +6546,7 @@ dependencies = [
|
|||||||
"oxc",
|
"oxc",
|
||||||
"oxc_sourcemap",
|
"oxc_sourcemap",
|
||||||
"rolldown_utils",
|
"rolldown_utils",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6496,7 +6598,7 @@ dependencies = [
|
|||||||
"regex 1.11.1",
|
"regex 1.11.1",
|
||||||
"regress",
|
"regress",
|
||||||
"rolldown_std_utils",
|
"rolldown_std_utils",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"simdutf8",
|
"simdutf8",
|
||||||
"sugar_path",
|
"sugar_path",
|
||||||
@@ -6526,6 +6628,18 @@ dependencies = [
|
|||||||
"str_indices",
|
"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]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.32.1"
|
version = "0.32.1"
|
||||||
@@ -6568,6 +6682,12 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -7173,6 +7293,27 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
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]]
|
[[package]]
|
||||||
name = "signal-hook-registry"
|
name = "signal-hook-registry"
|
||||||
version = "1.4.5"
|
version = "1.4.5"
|
||||||
@@ -7359,7 +7500,7 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
"oxc_index",
|
"oxc_index",
|
||||||
"oxc_sourcemap",
|
"oxc_sourcemap",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8060,6 +8201,12 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "text-size"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "textwrap"
|
name = "textwrap"
|
||||||
version = "0.16.2"
|
version = "0.16.2"
|
||||||
@@ -8068,7 +8215,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"smawk",
|
"smawk",
|
||||||
"unicode-linebreak",
|
"unicode-linebreak",
|
||||||
"unicode-width",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -8182,6 +8329,12 @@ dependencies = [
|
|||||||
"crunchy",
|
"crunchy",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_pretty"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650d82e943da333637be9f1567d33d605e76810a26464edfd7ae74f7ef181e95"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -8215,7 +8368,7 @@ checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio 1.0.4",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2 0.6.1",
|
"socket2 0.6.1",
|
||||||
@@ -8785,6 +8938,12 @@ version = "1.12.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.1.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -9562,6 +9721,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -10089,6 +10257,7 @@ dependencies = [
|
|||||||
"md5 0.8.0",
|
"md5 0.8.0",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openssl-sys",
|
"openssl-sys",
|
||||||
|
"pretty_graphql",
|
||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
@@ -10141,6 +10310,7 @@ dependencies = [
|
|||||||
name = "yaak-cli"
|
name = "yaak-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"arboard",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -10150,6 +10320,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"inquire",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log 0.4.29",
|
"log 0.4.29",
|
||||||
"oxc_resolver",
|
"oxc_resolver",
|
||||||
@@ -10166,6 +10337,7 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
"yaak",
|
"yaak",
|
||||||
|
"yaak-api",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
@@ -10287,6 +10459,7 @@ dependencies = [
|
|||||||
"urlencoding",
|
"urlencoding",
|
||||||
"yaak-common",
|
"yaak-common",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
|
"yaak-templates",
|
||||||
"yaak-tls",
|
"yaak-tls",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
@@ -10359,7 +10532,6 @@ dependencies = [
|
|||||||
"md5 0.7.0",
|
"md5 0.7.0",
|
||||||
"path-slash",
|
"path-slash",
|
||||||
"rand 0.9.1",
|
"rand 0.9.1",
|
||||||
"regex 1.11.1",
|
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -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": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
@@ -48,7 +48,8 @@
|
|||||||
"!src-web/routeTree.gen.ts",
|
"!src-web/routeTree.gen.ts",
|
||||||
"!packages/plugin-runtime-types/lib",
|
"!packages/plugin-runtime-types/lib",
|
||||||
"!**/bindings",
|
"!**/bindings",
|
||||||
"!flatpak"
|
"!flatpak",
|
||||||
|
"!npm"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ name = "yaak"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
arboard = "3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
console = "0.15"
|
console = "0.15"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
inquire = { version = "0.7", features = ["editor"] }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||||
@@ -32,6 +34,7 @@ walkdir = "2"
|
|||||||
webbrowser = "1"
|
webbrowser = "1"
|
||||||
zip = "4"
|
zip = "4"
|
||||||
yaak = { workspace = true }
|
yaak = { workspace = true }
|
||||||
|
yaak-api = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ pub struct Cli {
|
|||||||
#[arg(long, short, global = true)]
|
#[arg(long, short, global = true)]
|
||||||
pub environment: Option<String>,
|
pub environment: Option<String>,
|
||||||
|
|
||||||
|
/// Cookie jar ID to use when sending requests
|
||||||
|
#[arg(long = "cookie-jar", global = true, value_name = "COOKIE_JAR_ID")]
|
||||||
|
pub cookie_jar: Option<String>,
|
||||||
|
|
||||||
/// Enable verbose send output (events and streamed response body)
|
/// Enable verbose send output (events and streamed response body)
|
||||||
#[arg(long, short, global = true)]
|
#[arg(long, short, global = true)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
@@ -47,9 +51,20 @@ pub enum Commands {
|
|||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
Dev(PluginPathArg),
|
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 a request, folder, or workspace by ID
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
|
|
||||||
|
/// Cookie jar commands
|
||||||
|
CookieJar(CookieJarArgs),
|
||||||
|
|
||||||
/// Workspace commands
|
/// Workspace commands
|
||||||
Workspace(WorkspaceArgs),
|
Workspace(WorkspaceArgs),
|
||||||
|
|
||||||
@@ -77,6 +92,22 @@ pub struct SendArgs {
|
|||||||
pub fail_fast: bool,
|
pub fail_fast: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
#[command(disable_help_subcommand = true)]
|
||||||
|
pub struct CookieJarArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: CookieJarCommands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum CookieJarCommands {
|
||||||
|
/// List cookie jars in a workspace
|
||||||
|
List {
|
||||||
|
/// Workspace ID (optional when exactly one workspace exists)
|
||||||
|
workspace_id: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
#[command(disable_help_subcommand = true)]
|
#[command(disable_help_subcommand = true)]
|
||||||
pub struct WorkspaceArgs {
|
pub struct WorkspaceArgs {
|
||||||
@@ -150,8 +181,8 @@ pub struct RequestArgs {
|
|||||||
pub enum RequestCommands {
|
pub enum RequestCommands {
|
||||||
/// List requests in a workspace
|
/// List requests in a workspace
|
||||||
List {
|
List {
|
||||||
/// Workspace ID
|
/// Workspace ID (optional when exactly one workspace exists)
|
||||||
workspace_id: String,
|
workspace_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show a request as JSON
|
/// Show a request as JSON
|
||||||
@@ -259,8 +290,8 @@ pub struct FolderArgs {
|
|||||||
pub enum FolderCommands {
|
pub enum FolderCommands {
|
||||||
/// List folders in a workspace
|
/// List folders in a workspace
|
||||||
List {
|
List {
|
||||||
/// Workspace ID
|
/// Workspace ID (optional when exactly one workspace exists)
|
||||||
workspace_id: String,
|
workspace_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Show a folder as JSON
|
/// Show a folder as JSON
|
||||||
@@ -316,8 +347,8 @@ pub struct EnvironmentArgs {
|
|||||||
pub enum EnvironmentCommands {
|
pub enum EnvironmentCommands {
|
||||||
/// List environments in a workspace
|
/// List environments in a workspace
|
||||||
List {
|
List {
|
||||||
/// Workspace ID
|
/// Workspace ID (optional when exactly one workspace exists)
|
||||||
workspace_id: String,
|
workspace_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Output JSON schema for environment create/update payloads
|
/// Output JSON schema for environment create/update payloads
|
||||||
@@ -413,6 +444,9 @@ pub enum PluginCommands {
|
|||||||
/// Generate a "Hello World" Yaak plugin
|
/// Generate a "Hello World" Yaak plugin
|
||||||
Generate(GenerateArgs),
|
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 a Yaak plugin version to the plugin registry
|
||||||
Publish(PluginPathArg),
|
Publish(PluginPathArg),
|
||||||
}
|
}
|
||||||
@@ -433,3 +467,9 @@ pub struct GenerateArgs {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub dir: Option<PathBuf>,
|
pub dir: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Clone)]
|
||||||
|
pub struct InstallPluginArgs {
|
||||||
|
/// Local plugin directory path, or registry plugin spec (@org/plugin[@version])
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|||||||
42
crates-cli/yaak-cli/src/commands/cookie_jar.rs
Normal file
42
crates-cli/yaak-cli/src/commands/cookie_jar.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use crate::utils::json::{
|
|||||||
parse_required_json, require_id, validate_create_id,
|
parse_required_json, require_id, validate_create_id,
|
||||||
};
|
};
|
||||||
use crate::utils::schema::append_agent_hints;
|
use crate::utils::schema::append_agent_hints;
|
||||||
|
use crate::utils::workspace::resolve_workspace_id;
|
||||||
use schemars::schema_for;
|
use schemars::schema_for;
|
||||||
use yaak_models::models::Environment;
|
use yaak_models::models::Environment;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
@@ -14,7 +15,7 @@ type CommandResult<T = ()> = std::result::Result<T, String>;
|
|||||||
|
|
||||||
pub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 {
|
pub fn run(ctx: &CliContext, args: EnvironmentArgs) -> i32 {
|
||||||
let result = match args.command {
|
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::Schema { pretty } => schema(pretty),
|
||||||
EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id),
|
EnvironmentCommands::Show { environment_id } => show(ctx, &environment_id),
|
||||||
EnvironmentCommands::Create { workspace_id, name, json } => {
|
EnvironmentCommands::Create { workspace_id, name, json } => {
|
||||||
@@ -45,10 +46,11 @@ fn schema(pretty: bool) -> CommandResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
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, "environment list")?;
|
||||||
let environments = ctx
|
let environments = ctx
|
||||||
.db()
|
.db()
|
||||||
.list_environments_ensure_base(workspace_id)
|
.list_environments_ensure_base(&workspace_id)
|
||||||
.map_err(|e| format!("Failed to list environments: {e}"))?;
|
.map_err(|e| format!("Failed to list environments: {e}"))?;
|
||||||
|
|
||||||
if environments.is_empty() {
|
if environments.is_empty() {
|
||||||
@@ -92,8 +94,14 @@ fn create(
|
|||||||
validate_create_id(&payload, "environment")?;
|
validate_create_id(&payload, "environment")?;
|
||||||
let mut environment: Environment = serde_json::from_value(payload)
|
let mut environment: Environment = serde_json::from_value(payload)
|
||||||
.map_err(|e| format!("Failed to parse environment create JSON: {e}"))?;
|
.map_err(|e| format!("Failed to parse environment create JSON: {e}"))?;
|
||||||
|
let fallback_workspace_id =
|
||||||
|
if workspace_id_arg.is_none() && environment.workspace_id.is_empty() {
|
||||||
|
Some(resolve_workspace_id(ctx, None, "environment create")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
merge_workspace_id_arg(
|
merge_workspace_id_arg(
|
||||||
workspace_id_arg.as_deref(),
|
workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),
|
||||||
&mut environment.workspace_id,
|
&mut environment.workspace_id,
|
||||||
"environment create",
|
"environment create",
|
||||||
)?;
|
)?;
|
||||||
@@ -111,9 +119,8 @@ fn create(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let workspace_id = workspace_id_arg.ok_or_else(|| {
|
let workspace_id =
|
||||||
"environment create requires workspace_id unless JSON payload is provided".to_string()
|
resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "environment create")?;
|
||||||
})?;
|
|
||||||
let name = name.ok_or_else(|| {
|
let name = name.ok_or_else(|| {
|
||||||
"environment create requires --name unless JSON payload is provided".to_string()
|
"environment create requires --name unless JSON payload is provided".to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::utils::json::{
|
|||||||
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
|
apply_merge_patch, is_json_shorthand, merge_workspace_id_arg, parse_optional_json,
|
||||||
parse_required_json, require_id, validate_create_id,
|
parse_required_json, require_id, validate_create_id,
|
||||||
};
|
};
|
||||||
|
use crate::utils::workspace::resolve_workspace_id;
|
||||||
use yaak_models::models::Folder;
|
use yaak_models::models::Folder;
|
||||||
use yaak_models::util::UpdateSource;
|
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 {
|
pub fn run(ctx: &CliContext, args: FolderArgs) -> i32 {
|
||||||
let result = match args.command {
|
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::Show { folder_id } => show(ctx, &folder_id),
|
||||||
FolderCommands::Create { workspace_id, name, json } => {
|
FolderCommands::Create { workspace_id, name, json } => {
|
||||||
create(ctx, 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 =
|
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() {
|
if folders.is_empty() {
|
||||||
println!("No folders found in workspace {}", workspace_id);
|
println!("No folders found in workspace {}", workspace_id);
|
||||||
} else {
|
} else {
|
||||||
@@ -72,8 +74,14 @@ fn create(
|
|||||||
validate_create_id(&payload, "folder")?;
|
validate_create_id(&payload, "folder")?;
|
||||||
let mut 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}"))?;
|
.map_err(|e| format!("Failed to parse folder create JSON: {e}"))?;
|
||||||
|
let fallback_workspace_id = if workspace_id_arg.is_none() && folder.workspace_id.is_empty()
|
||||||
|
{
|
||||||
|
Some(resolve_workspace_id(ctx, None, "folder create")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
merge_workspace_id_arg(
|
merge_workspace_id_arg(
|
||||||
workspace_id_arg.as_deref(),
|
workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),
|
||||||
&mut folder.workspace_id,
|
&mut folder.workspace_id,
|
||||||
"folder create",
|
"folder create",
|
||||||
)?;
|
)?;
|
||||||
@@ -87,9 +95,7 @@ fn create(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let workspace_id = workspace_id_arg.ok_or_else(|| {
|
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "folder create")?;
|
||||||
"folder create requires workspace_id unless JSON payload is provided".to_string()
|
|
||||||
})?;
|
|
||||||
let name = name.ok_or_else(|| {
|
let name = name.ok_or_else(|| {
|
||||||
"folder create requires --name unless JSON payload is provided".to_string()
|
"folder create requires --name unless JSON payload is provided".to_string()
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod cookie_jar;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use crate::cli::{GenerateArgs, PluginArgs, PluginCommands, PluginPathArg};
|
use crate::cli::{GenerateArgs, InstallPluginArgs, PluginPathArg};
|
||||||
|
use crate::context::CliContext;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
use crate::utils::http;
|
use crate::utils::http;
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
use rolldown::{
|
use rolldown::{
|
||||||
Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat, Platform,
|
BundleEvent, Bundler, BundlerOptions, ExperimentalOptions, InputItem, LogLevel, OutputFormat,
|
||||||
WatchOption, Watcher,
|
Platform, WatchOption, Watcher, WatcherEvent,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -15,6 +16,11 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use walkdir::WalkDir;
|
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::CompressionMethod;
|
||||||
use zip::write::SimpleFileOptions;
|
use zip::write::SimpleFileOptions;
|
||||||
|
|
||||||
@@ -57,12 +63,13 @@ pub async fn run_build(args: PluginPathArg) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(args: PluginArgs) -> i32 {
|
pub async fn run_install(context: &CliContext, args: InstallPluginArgs) -> i32 {
|
||||||
match args.command {
|
match install(context, args).await {
|
||||||
PluginCommands::Build(args) => run_build(args).await,
|
Ok(()) => 0,
|
||||||
PluginCommands::Dev(args) => run_dev(args).await,
|
Err(error) => {
|
||||||
PluginCommands::Generate(args) => run_generate(args).await,
|
ui::error(&error);
|
||||||
PluginCommands::Publish(args) => run_publish(args).await,
|
1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +121,53 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
|||||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||||
|
|
||||||
ui::info(&format!("Watching plugin {}...", plugin_dir.display()));
|
ui::info(&format!("Watching plugin {}...", plugin_dir.display()));
|
||||||
ui::info("Press Ctrl-C to stop");
|
|
||||||
|
|
||||||
let bundler = Bundler::new(bundler_options(&plugin_dir, true))
|
let bundler = Bundler::new(bundler_options(&plugin_dir, true))
|
||||||
.map_err(|err| format!("Failed to initialize Rolldown watcher: {err}"))?;
|
.map_err(|err| format!("Failed to initialize Rolldown watcher: {err}"))?;
|
||||||
let watcher = Watcher::new(vec![Arc::new(Mutex::new(bundler))], None)
|
let watcher = Watcher::new(vec![Arc::new(Mutex::new(bundler))], None)
|
||||||
.map_err(|err| format!("Failed to start Rolldown watcher: {err}"))?;
|
.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;
|
watcher.start().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -209,6 +257,113 @@ async fn publish(args: PluginPathArg) -> CommandResult {
|
|||||||
Ok(())
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct PublishResponse {
|
struct PublishResponse {
|
||||||
version: String,
|
version: String,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use crate::utils::json::{
|
|||||||
parse_required_json, require_id, validate_create_id,
|
parse_required_json, require_id, validate_create_id,
|
||||||
};
|
};
|
||||||
use crate::utils::schema::append_agent_hints;
|
use crate::utils::schema::append_agent_hints;
|
||||||
|
use crate::utils::workspace::resolve_workspace_id;
|
||||||
use schemars::schema_for;
|
use schemars::schema_for;
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -24,13 +25,16 @@ pub async fn run(
|
|||||||
ctx: &CliContext,
|
ctx: &CliContext,
|
||||||
args: RequestArgs,
|
args: RequestArgs,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
|
cookie_jar_id: Option<&str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
let result = match args.command {
|
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::Show { request_id } => show(ctx, &request_id),
|
||||||
RequestCommands::Send { 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,
|
Ok(()) => 0,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("Error: {error}");
|
eprintln!("Error: {error}");
|
||||||
@@ -63,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
|
let requests = ctx
|
||||||
.db()
|
.db()
|
||||||
.list_http_requests(workspace_id)
|
.list_http_requests(&workspace_id)
|
||||||
.map_err(|e| format!("Failed to list requests: {e}"))?;
|
.map_err(|e| format!("Failed to list requests: {e}"))?;
|
||||||
if requests.is_empty() {
|
if requests.is_empty() {
|
||||||
println!("No requests found in workspace {}", workspace_id);
|
println!("No requests found in workspace {}", workspace_id);
|
||||||
@@ -350,8 +355,14 @@ fn create(
|
|||||||
validate_create_id(&payload, "request")?;
|
validate_create_id(&payload, "request")?;
|
||||||
let mut 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}"))?;
|
.map_err(|e| format!("Failed to parse request create JSON: {e}"))?;
|
||||||
|
let fallback_workspace_id = if workspace_id_arg.is_none() && request.workspace_id.is_empty()
|
||||||
|
{
|
||||||
|
Some(resolve_workspace_id(ctx, None, "request create")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
merge_workspace_id_arg(
|
merge_workspace_id_arg(
|
||||||
workspace_id_arg.as_deref(),
|
workspace_id_arg.as_deref().or(fallback_workspace_id.as_deref()),
|
||||||
&mut request.workspace_id,
|
&mut request.workspace_id,
|
||||||
"request create",
|
"request create",
|
||||||
)?;
|
)?;
|
||||||
@@ -365,9 +376,7 @@ fn create(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let workspace_id = workspace_id_arg.ok_or_else(|| {
|
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
||||||
"request create requires workspace_id unless JSON payload is provided".to_string()
|
|
||||||
})?;
|
|
||||||
let name = name.unwrap_or_default();
|
let name = name.unwrap_or_default();
|
||||||
let url = url.unwrap_or_default();
|
let url = url.unwrap_or_default();
|
||||||
let method = method.unwrap_or_else(|| "GET".to_string());
|
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||||
@@ -436,6 +445,7 @@ pub async fn send_request_by_id(
|
|||||||
ctx: &CliContext,
|
ctx: &CliContext,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
|
cookie_jar_id: Option<&str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let request =
|
let request =
|
||||||
@@ -447,6 +457,7 @@ pub async fn send_request_by_id(
|
|||||||
&http_request.id,
|
&http_request.id,
|
||||||
&http_request.workspace_id,
|
&http_request.workspace_id,
|
||||||
environment,
|
environment,
|
||||||
|
cookie_jar_id,
|
||||||
verbose,
|
verbose,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -465,9 +476,13 @@ async fn send_http_request_by_id(
|
|||||||
request_id: &str,
|
request_id: &str,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
|
cookie_jar_id: Option<&str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> Result<(), String> {
|
) -> 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 plugin_context =
|
||||||
|
PluginContext::new(Some("cli".to_string()), Some(workspace_id.to_string()));
|
||||||
|
|
||||||
let (event_tx, mut event_rx) = mpsc::channel::<SenderHttpResponseEvent>(100);
|
let (event_tx, mut event_rx) = mpsc::channel::<SenderHttpResponseEvent>(100);
|
||||||
let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
let (body_chunk_tx, mut body_chunk_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||||
@@ -495,7 +510,7 @@ async fn send_http_request_by_id(
|
|||||||
request_id,
|
request_id,
|
||||||
environment_id: environment,
|
environment_id: environment,
|
||||||
update_source: UpdateSource::Sync,
|
update_source: UpdateSource::Sync,
|
||||||
cookie_jar_id: None,
|
cookie_jar_id,
|
||||||
response_dir: &response_dir,
|
response_dir: &response_dir,
|
||||||
emit_events_to: Some(event_tx),
|
emit_events_to: Some(event_tx),
|
||||||
emit_response_body_chunks_to: Some(body_chunk_tx),
|
emit_response_body_chunks_to: Some(body_chunk_tx),
|
||||||
@@ -512,3 +527,22 @@ async fn send_http_request_by_id(
|
|||||||
result.map_err(|e| e.to_string())?;
|
result.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::cli::SendArgs;
|
|||||||
use crate::commands::request;
|
use crate::commands::request;
|
||||||
use crate::context::CliContext;
|
use crate::context::CliContext;
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
|
use yaak_models::queries::any_request::AnyRequest;
|
||||||
|
|
||||||
enum ExecutionMode {
|
enum ExecutionMode {
|
||||||
Sequential,
|
Sequential,
|
||||||
@@ -12,9 +13,10 @@ pub async fn run(
|
|||||||
ctx: &CliContext,
|
ctx: &CliContext,
|
||||||
args: SendArgs,
|
args: SendArgs,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
|
cookie_jar_id: Option<&str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
match send_target(ctx, args, environment, verbose).await {
|
match send_target(ctx, args, environment, cookie_jar_id, verbose).await {
|
||||||
Ok(()) => 0,
|
Ok(()) => 0,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("Error: {error}");
|
eprintln!("Error: {error}");
|
||||||
@@ -27,30 +29,70 @@ async fn send_target(
|
|||||||
ctx: &CliContext,
|
ctx: &CliContext,
|
||||||
args: SendArgs,
|
args: SendArgs,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
|
cookie_jar_id: Option<&str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential };
|
let mode = if args.parallel { ExecutionMode::Parallel } else { ExecutionMode::Sequential };
|
||||||
|
|
||||||
if ctx.db().get_any_request(&args.id).is_ok() {
|
if let Ok(request) = ctx.db().get_any_request(&args.id) {
|
||||||
return request::send_request_by_id(ctx, &args.id, environment, verbose).await;
|
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)?;
|
let request_ids = collect_folder_request_ids(ctx, &args.id)?;
|
||||||
if request_ids.is_empty() {
|
if request_ids.is_empty() {
|
||||||
println!("No requests found in folder {}", args.id);
|
println!("No requests found in folder {}", args.id);
|
||||||
return Ok(());
|
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)?;
|
let request_ids = collect_workspace_request_ids(ctx, &args.id)?;
|
||||||
if request_ids.is_empty() {
|
if request_ids.is_empty() {
|
||||||
println!("No requests found in workspace {}", args.id);
|
println!("No requests found in workspace {}", args.id);
|
||||||
return Ok(());
|
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))
|
Err(format!("Could not resolve ID '{}' as request, folder, or workspace", args.id))
|
||||||
@@ -131,6 +173,7 @@ async fn send_many(
|
|||||||
mode: ExecutionMode,
|
mode: ExecutionMode,
|
||||||
fail_fast: bool,
|
fail_fast: bool,
|
||||||
environment: Option<&str>,
|
environment: Option<&str>,
|
||||||
|
cookie_jar_id: Option<&str>,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut success_count = 0usize;
|
let mut success_count = 0usize;
|
||||||
@@ -139,7 +182,15 @@ async fn send_many(
|
|||||||
match mode {
|
match mode {
|
||||||
ExecutionMode::Sequential => {
|
ExecutionMode::Sequential => {
|
||||||
for request_id in request_ids {
|
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,
|
Ok(()) => success_count += 1,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
failures.push((request_id, error));
|
failures.push((request_id, error));
|
||||||
@@ -156,7 +207,14 @@ async fn send_many(
|
|||||||
.map(|request_id| async move {
|
.map(|request_id| async move {
|
||||||
(
|
(
|
||||||
request_id.clone(),
|
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<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -31,18 +31,13 @@ pub fn run(ctx: &CliContext, args: WorkspaceArgs) -> i32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn schema(pretty: bool) -> CommandResult {
|
fn schema(pretty: bool) -> CommandResult {
|
||||||
let mut schema =
|
let mut schema = serde_json::to_value(schema_for!(Workspace))
|
||||||
serde_json::to_value(schema_for!(Workspace)).map_err(|e| format!(
|
.map_err(|e| format!("Failed to serialize workspace schema: {e}"))?;
|
||||||
"Failed to serialize workspace schema: {e}"
|
|
||||||
))?;
|
|
||||||
append_agent_hints(&mut schema);
|
append_agent_hints(&mut schema);
|
||||||
|
|
||||||
let output = if pretty {
|
let output =
|
||||||
serde_json::to_string_pretty(&schema)
|
if pretty { serde_json::to_string_pretty(&schema) } else { serde_json::to_string(&schema) }
|
||||||
} else {
|
.map_err(|e| format!("Failed to format workspace schema JSON: {e}"))?;
|
||||||
serde_json::to_string(&schema)
|
|
||||||
}
|
|
||||||
.map_err(|e| format!("Failed to format workspace schema JSON: {e}"))?;
|
|
||||||
println!("{output}");
|
println!("{output}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const EMBEDDED_PLUGIN_RUNTIME: &str = include_str!(concat!(
|
|||||||
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
static EMBEDDED_VENDORED_PLUGINS: Dir<'_> =
|
||||||
include_dir!("$CARGO_MANIFEST_DIR/../../crates-tauri/yaak-app/vendored/plugins");
|
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 {
|
pub struct CliContext {
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
query_manager: QueryManager,
|
query_manager: QueryManager,
|
||||||
@@ -28,67 +36,71 @@ pub struct CliContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 db_path = data_dir.join("db.sqlite");
|
||||||
let blob_path = data_dir.join("blobs.sqlite");
|
let blob_path = data_dir.join("blobs.sqlite");
|
||||||
|
let (query_manager, blob_manager, _rx) =
|
||||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(&db_path, &blob_path)
|
match yaak_models::init_standalone(&db_path, &blob_path) {
|
||||||
.expect("Failed to initialize database");
|
Ok(v) => v,
|
||||||
|
|
||||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
|
||||||
|
|
||||||
let plugin_manager = if with_plugins {
|
|
||||||
let embedded_vendored_plugin_dir = data_dir.join("vendored-plugins");
|
|
||||||
let bundled_plugin_dir =
|
|
||||||
resolve_bundled_plugin_dir_for_cli(&embedded_vendored_plugin_dir);
|
|
||||||
let installed_plugin_dir = data_dir.join("installed-plugins");
|
|
||||||
let node_bin_path = PathBuf::from("node");
|
|
||||||
|
|
||||||
if bundled_plugin_dir == embedded_vendored_plugin_dir {
|
|
||||||
prepare_embedded_vendored_plugins(&embedded_vendored_plugin_dir)
|
|
||||||
.expect("Failed to prepare bundled plugins");
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugin_runtime_main =
|
|
||||||
std::env::var("YAAK_PLUGIN_RUNTIME").map(PathBuf::from).unwrap_or_else(|_| {
|
|
||||||
prepare_embedded_plugin_runtime(&data_dir)
|
|
||||||
.expect("Failed to prepare embedded plugin runtime")
|
|
||||||
});
|
|
||||||
|
|
||||||
match PluginManager::new(
|
|
||||||
bundled_plugin_dir,
|
|
||||||
embedded_vendored_plugin_dir,
|
|
||||||
installed_plugin_dir,
|
|
||||||
node_bin_path,
|
|
||||||
plugin_runtime_main,
|
|
||||||
&query_manager,
|
|
||||||
&PluginContext::new_empty(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(plugin_manager) => Some(Arc::new(plugin_manager)),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("Warning: Failed to initialize plugins: {err}");
|
eprintln!("Error: Failed to initialize database: {err}");
|
||||||
None
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
} else {
|
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let plugin_event_bridge = if let Some(plugin_manager) = &plugin_manager {
|
|
||||||
Some(CliPluginEventBridge::start(plugin_manager.clone(), query_manager.clone()).await)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
data_dir,
|
data_dir,
|
||||||
query_manager,
|
query_manager,
|
||||||
blob_manager,
|
blob_manager,
|
||||||
encryption_manager,
|
encryption_manager,
|
||||||
plugin_manager,
|
plugin_manager: None,
|
||||||
plugin_event_bridge: Mutex::new(plugin_event_bridge),
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,20 +147,3 @@ fn prepare_embedded_vendored_plugins(vendored_plugin_dir: &Path) -> std::io::Res
|
|||||||
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
|
EMBEDDED_VENDORED_PLUGINS.extract(vendored_plugin_dir)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_bundled_plugin_dir_for_cli(embedded_vendored_plugin_dir: &Path) -> PathBuf {
|
|
||||||
if !cfg!(debug_assertions) {
|
|
||||||
return embedded_vendored_plugin_dir.to_path_buf();
|
|
||||||
}
|
|
||||||
|
|
||||||
let plugins_dir = match std::env::current_dir() {
|
|
||||||
Ok(cwd) => cwd.join("plugins"),
|
|
||||||
Err(_) => return embedded_vendored_plugin_dir.to_path_buf(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if !plugins_dir.is_dir() {
|
|
||||||
return embedded_vendored_plugin_dir.to_path_buf();
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins_dir.canonicalize().unwrap_or(plugins_dir)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ mod plugin_events;
|
|||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod version;
|
mod version;
|
||||||
|
mod version_check;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use cli::{Cli, Commands, RequestCommands};
|
use cli::{Cli, Commands, PluginCommands, RequestCommands};
|
||||||
use context::CliContext;
|
use context::{CliContext, CliExecutionContext};
|
||||||
|
use yaak_models::queries::any_request::AnyRequest;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let Cli { data_dir, environment, verbose, log, command } = Cli::parse();
|
let Cli { data_dir, environment, cookie_jar, verbose, log, command } = Cli::parse();
|
||||||
|
|
||||||
if let Some(log_level) = log {
|
if let Some(log_level) = log {
|
||||||
match log_level {
|
match log_level {
|
||||||
@@ -32,70 +34,208 @@ async fn main() {
|
|||||||
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
dirs::data_dir().expect("Could not determine data directory").join(app_id)
|
||||||
});
|
});
|
||||||
|
|
||||||
let needs_context = matches!(
|
version_check::maybe_check_for_updates().await;
|
||||||
&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
|
|
||||||
};
|
|
||||||
|
|
||||||
let exit_code = match command {
|
let exit_code = match command {
|
||||||
Commands::Auth(args) => commands::auth::run(args).await,
|
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::Build(args) => commands::plugin::run_build(args).await,
|
||||||
Commands::Dev(args) => commands::plugin::run_dev(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(args) => {
|
||||||
commands::send::run(
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
context.as_ref().expect("context initialized for send"),
|
match resolve_send_execution_context(
|
||||||
args,
|
&context,
|
||||||
|
&args.id,
|
||||||
environment.as_deref(),
|
environment.as_deref(),
|
||||||
verbose,
|
cookie_jar.as_deref(),
|
||||||
)
|
) {
|
||||||
.await
|
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(args) => {
|
||||||
commands::request::run(
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
context.as_ref().expect("context initialized for request"),
|
let execution_context_result = match &args.command {
|
||||||
args,
|
RequestCommands::Send { request_id } => resolve_request_execution_context(
|
||||||
environment.as_deref(),
|
&context,
|
||||||
verbose,
|
request_id,
|
||||||
)
|
environment.as_deref(),
|
||||||
.await
|
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(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 {
|
if exit_code != 0 {
|
||||||
std::process::exit(exit_code);
|
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
@@ -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) {
|
pub fn success(message: &str) {
|
||||||
if io::stdout().is_terminal() {
|
if io::stdout().is_terminal() {
|
||||||
println!("{:<8} {}", style("SUCCESS").green().bold(), style(message).green());
|
println!("{:<8} {}", style("SUCCESS").green().bold(), style(message).green());
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod confirm;
|
|||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod workspace;
|
||||||
|
|||||||
19
crates-cli/yaak-cli/src/utils/workspace.rs
Normal file
19
crates-cli/yaak-cli/src/utils/workspace.rs
Normal 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.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
226
crates-cli/yaak-cli/src/version_check.rs
Normal file
226
crates-cli/yaak-cli/src/version_check.rs
Normal 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()
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
use std::io::{Read, Write};
|
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::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
pub struct TestHttpServer {
|
pub struct TestHttpServer {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
addr: SocketAddr,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
handle: Option<thread::JoinHandle<()>>,
|
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 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 addr = listener.local_addr().expect("Failed to get local addr");
|
||||||
let url = format!("http://{addr}/test");
|
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 body_bytes = body.as_bytes().to_vec();
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
let handle = thread::spawn(move || {
|
||||||
if let Ok((mut stream, _)) = listener.accept() {
|
while !shutdown_signal.load(Ordering::Relaxed) {
|
||||||
let mut request_buf = [0u8; 4096];
|
match listener.accept() {
|
||||||
let _ = stream.read(&mut request_buf);
|
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!(
|
let response = format!(
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
body_bytes.len()
|
body_bytes.len()
|
||||||
);
|
);
|
||||||
let _ = stream.write_all(response.as_bytes());
|
let _ = stream.write_all(response.as_bytes());
|
||||||
let _ = stream.write_all(&body_bytes);
|
let _ = stream.write_all(&body_bytes);
|
||||||
let _ = stream.flush();
|
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 {
|
impl Drop for TestHttpServer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
self.shutdown.store(true, Ordering::Relaxed);
|
||||||
|
let _ = TcpStream::connect(self.addr);
|
||||||
|
|
||||||
if let Some(handle) = self.handle.take() {
|
if let Some(handle) = self.handle.take() {
|
||||||
let _ = handle.join();
|
let _ = handle.join();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ fn workspace_schema_outputs_json_schema() {
|
|||||||
.stdout(contains("\"type\":\"object\""))
|
.stdout(contains("\"type\":\"object\""))
|
||||||
.stdout(contains("\"x-yaak-agent-hints\""))
|
.stdout(contains("\"x-yaak-agent-hints\""))
|
||||||
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
|
.stdout(contains("\"templateVariableSyntax\":\"${[ my_var ]}\""))
|
||||||
.stdout(contains("\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\""))
|
.stdout(contains(
|
||||||
|
"\"templateFunctionSyntax\":\"${[ namespace.my_func(a='aaa',b='bbb') ]}\"",
|
||||||
|
))
|
||||||
.stdout(contains("\"name\""));
|
.stdout(contains("\"name\""));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ eventsource-client = { git = "https://github.com/yaakapp/rust-eventsource-client
|
|||||||
http = { version = "1.2.0", default-features = false }
|
http = { version = "1.2.0", default-features = false }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
md5 = "0.8.0"
|
md5 = "0.8.0"
|
||||||
|
pretty_graphql = "0.2"
|
||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
r2d2_sqlite = "0.25.0"
|
r2d2_sqlite = "0.25.0"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
|
|||||||
@@ -31,14 +31,16 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::block_in_place;
|
use tokio::task::block_in_place;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
use yaak_common::command::new_checked_command;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||||
|
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||||
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
use yaak_grpc::{Code, ServiceDefinition, serialize_message};
|
||||||
use yaak_mac_window::AppHandleMacWindowExt;
|
use yaak_mac_window::AppHandleMacWindowExt;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Plugin,
|
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||||
Workspace, WorkspaceMeta,
|
WorkspaceMeta,
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
@@ -97,6 +99,7 @@ impl<R: Runtime> PluginContextExt<R> for WebviewWindow<R> {
|
|||||||
struct AppMetaData {
|
struct AppMetaData {
|
||||||
is_dev: bool,
|
is_dev: bool,
|
||||||
version: String,
|
version: String,
|
||||||
|
cli_version: Option<String>,
|
||||||
name: String,
|
name: String,
|
||||||
app_data_dir: String,
|
app_data_dir: String,
|
||||||
app_log_dir: String,
|
app_log_dir: String,
|
||||||
@@ -113,9 +116,11 @@ async fn cmd_metadata(app_handle: AppHandle) -> YaakResult<AppMetaData> {
|
|||||||
let vendored_plugin_dir =
|
let vendored_plugin_dir =
|
||||||
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
|
app_handle.path().resolve("vendored/plugins", BaseDirectory::Resource)?;
|
||||||
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
|
let default_project_dir = app_handle.path().home_dir()?.join("YaakProjects");
|
||||||
|
let cli_version = detect_cli_version().await;
|
||||||
Ok(AppMetaData {
|
Ok(AppMetaData {
|
||||||
is_dev: is_dev(),
|
is_dev: is_dev(),
|
||||||
version: app_handle.package_info().version.to_string(),
|
version: app_handle.package_info().version.to_string(),
|
||||||
|
cli_version,
|
||||||
name: app_handle.package_info().name.to_string(),
|
name: app_handle.package_info().name.to_string(),
|
||||||
app_data_dir: app_data_dir.to_string_lossy().to_string(),
|
app_data_dir: app_data_dir.to_string_lossy().to_string(),
|
||||||
app_log_dir: app_log_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]
|
#[tauri::command]
|
||||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
@@ -411,6 +434,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
result.expect("Failed to render template")
|
result.expect("Failed to render template")
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
let msg = strip_json_comments(&msg);
|
||||||
in_msg_tx.try_send(msg.clone()).unwrap();
|
in_msg_tx.try_send(msg.clone()).unwrap();
|
||||||
}
|
}
|
||||||
Ok(IncomingMsg::Commit) => {
|
Ok(IncomingMsg::Commit) => {
|
||||||
@@ -446,6 +470,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
&RenderOptions { error_behavior: RenderErrorBehavior::Throw },
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let msg = strip_json_comments(&msg);
|
||||||
|
|
||||||
app_handle.db().upsert_grpc_event(
|
app_handle.db().upsert_grpc_event(
|
||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
@@ -847,6 +872,14 @@ async fn cmd_format_json(text: &str) -> YaakResult<String> {
|
|||||||
Ok(format_json(text, " "))
|
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]
|
#[tauri::command]
|
||||||
async fn cmd_http_response_body<R: Runtime>(
|
async fn cmd_http_response_body<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
@@ -1345,29 +1378,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
Ok(r)
|
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]
|
#[tauri::command]
|
||||||
async fn cmd_reload_plugins<R: Runtime>(
|
async fn cmd_reload_plugins<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
@@ -1639,6 +1649,7 @@ pub fn run() {
|
|||||||
cmd_http_request_body,
|
cmd_http_request_body,
|
||||||
cmd_http_response_body,
|
cmd_http_response_body,
|
||||||
cmd_format_json,
|
cmd_format_json,
|
||||||
|
cmd_format_graphql,
|
||||||
cmd_get_http_authentication_summaries,
|
cmd_get_http_authentication_summaries,
|
||||||
cmd_get_http_authentication_config,
|
cmd_get_http_authentication_config,
|
||||||
cmd_get_sse_events,
|
cmd_get_sse_events,
|
||||||
@@ -1652,7 +1663,6 @@ pub fn run() {
|
|||||||
cmd_workspace_actions,
|
cmd_workspace_actions,
|
||||||
cmd_folder_actions,
|
cmd_folder_actions,
|
||||||
cmd_import_data,
|
cmd_import_data,
|
||||||
cmd_install_plugin,
|
|
||||||
cmd_metadata,
|
cmd_metadata,
|
||||||
cmd_new_child_window,
|
cmd_new_child_window,
|
||||||
cmd_new_main_window,
|
cmd_new_main_window,
|
||||||
@@ -1721,6 +1731,7 @@ pub fn run() {
|
|||||||
git_ext::cmd_git_rm_remote,
|
git_ext::cmd_git_rm_remote,
|
||||||
//
|
//
|
||||||
// Plugin commands
|
// Plugin commands
|
||||||
|
plugins_ext::cmd_plugins_install_from_directory,
|
||||||
plugins_ext::cmd_plugins_search,
|
plugins_ext::cmd_plugins_search,
|
||||||
plugins_ext::cmd_plugins_install,
|
plugins_ext::cmd_plugins_install,
|
||||||
plugins_ext::cmd_plugins_uninstall,
|
plugins_ext::cmd_plugins_uninstall,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use yaak_models::error::Result;
|
|||||||
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
use yaak_models::models::{AnyModel, GraphQlIntrospection, GrpcEvent, Settings, WebsocketEvent};
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||||
@@ -255,23 +256,32 @@ pub(crate) fn models_upsert_graphql_introspection<R: Runtime>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn models_workspace_models<R: Runtime>(
|
pub(crate) async fn models_workspace_models<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
workspace_id: Option<&str>,
|
workspace_id: Option<&str>,
|
||||||
|
plugin_manager: State<'_, PluginManager>,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let db = window.db();
|
|
||||||
let mut l: Vec<AnyModel> = Vec::new();
|
let mut l: Vec<AnyModel> = Vec::new();
|
||||||
|
|
||||||
// Add the settings
|
// Add the global models
|
||||||
l.push(db.get_settings().into());
|
{
|
||||||
|
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
|
let plugins = {
|
||||||
l.append(&mut db.list_workspaces()?.into_iter().map(Into::into).collect());
|
let db = window.db();
|
||||||
l.append(&mut db.list_key_values()?.into_iter().map(Into::into).collect());
|
db.list_plugins()?
|
||||||
l.append(&mut db.list_plugins()?.into_iter().map(Into::into).collect());
|
};
|
||||||
|
|
||||||
|
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
|
// Add the workspace children
|
||||||
if let Some(wid) = workspace_id {
|
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_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_environments_ensure_base(wid)?.into_iter().map(Into::into).collect());
|
||||||
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
l.append(&mut db.list_folders(wid)?.into_iter().map(Into::into).collect());
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
|
||||||
use ts_rs::TS;
|
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_common::platform::get_os_str;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ impl YaakNotifier {
|
|||||||
|
|
||||||
let launch_info = get_or_upsert_launch_info(app_handle);
|
let launch_info = get_or_upsert_launch_info(app_handle);
|
||||||
let app_version = app_handle.package_info().version.to_string();
|
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")
|
.request(Method::GET, "https://notify.yaak.app/notifications")
|
||||||
.query(&[
|
.query(&[
|
||||||
("version", &launch_info.current_version),
|
("version", &launch_info.current_version),
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ use yaak::plugin_events::{
|
|||||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||||
};
|
};
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
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::queries::any_request::AnyRequest;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::error::Error::PluginErr;
|
use yaak_plugins::error::Error::PluginErr;
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
Color, EmptyPayload, ErrorResponse, FindHttpResponsesResponse, GetCookieValueResponse, Icon,
|
Color, EmptyPayload, ErrorResponse, GetCookieValueResponse, Icon, InternalEvent,
|
||||||
InternalEvent, InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
|
InternalEventPayload, ListCookieNamesResponse, ListOpenWorkspacesResponse,
|
||||||
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
RenderGrpcRequestResponse, RenderHttpRequestResponse, SendHttpRequestResponse,
|
||||||
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
ShowToastRequest, TemplateRenderResponse, WindowInfoResponse, WindowNavigateEvent,
|
||||||
WorkspaceInfo,
|
WorkspaceInfo,
|
||||||
@@ -118,7 +118,7 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
&InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
||||||
message: format!("Reloaded plugin {}@{}", info.name, info.version),
|
message: format!("Reloaded plugin {}@{}", info.name, info.version),
|
||||||
icon: Some(Icon::Info),
|
icon: Some(Icon::Info),
|
||||||
timeout: Some(3000),
|
timeout: Some(5000),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}),
|
}),
|
||||||
None,
|
None,
|
||||||
@@ -190,71 +190,6 @@ async fn handle_host_plugin_request<R: Runtime>(
|
|||||||
Ok(None)
|
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) => {
|
HostRequest::RenderGrpcRequest(req) => {
|
||||||
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
let window = get_window_from_plugin_context(app_handle, plugin_context)?;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use crate::error::Result;
|
|||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -22,8 +21,9 @@ use tauri::{
|
|||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_api::yaak_api_client;
|
use yaak_api::{ApiClientKind, yaak_api_client};
|
||||||
use yaak_models::models::Plugin;
|
use yaak_models::models::{Plugin, PluginSource};
|
||||||
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::api::{
|
use yaak_plugins::api::{
|
||||||
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse, check_plugin_updates,
|
||||||
search_plugins,
|
search_plugins,
|
||||||
@@ -73,7 +73,7 @@ impl PluginUpdater {
|
|||||||
info!("Checking for plugin updates");
|
info!("Checking for plugin updates");
|
||||||
|
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
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 plugins = window.app_handle().db().list_plugins()?;
|
||||||
let updates = check_plugin_updates(&http_client, plugins.clone()).await?;
|
let updates = check_plugin_updates(&http_client, plugins.clone()).await?;
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ pub async fn cmd_plugins_search<R: Runtime>(
|
|||||||
query: &str,
|
query: &str,
|
||||||
) -> Result<PluginSearchResponse> {
|
) -> Result<PluginSearchResponse> {
|
||||||
let app_version = app_handle.package_info().version.to_string();
|
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?)
|
Ok(search_plugins(&http_client, query).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
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 query_manager = window.state::<yaak_models::query_manager::QueryManager>();
|
||||||
let plugin_context = window.plugin_context();
|
let plugin_context = window.plugin_context();
|
||||||
download_and_install(
|
download_and_install(
|
||||||
@@ -165,6 +165,28 @@ pub async fn cmd_plugins_install<R: Runtime>(
|
|||||||
Ok(())
|
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]
|
#[command]
|
||||||
pub async fn cmd_plugins_uninstall<R: Runtime>(
|
pub async fn cmd_plugins_uninstall<R: Runtime>(
|
||||||
plugin_id: &str,
|
plugin_id: &str,
|
||||||
@@ -181,7 +203,7 @@ pub async fn cmd_plugins_updates<R: Runtime>(
|
|||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
) -> Result<PluginUpdatesResponse> {
|
) -> Result<PluginUpdatesResponse> {
|
||||||
let app_version = app_handle.package_info().version.to_string();
|
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()?;
|
let plugins = app_handle.db().list_plugins()?;
|
||||||
Ok(check_plugin_updates(&http_client, plugins).await?)
|
Ok(check_plugin_updates(&http_client, plugins).await?)
|
||||||
}
|
}
|
||||||
@@ -191,7 +213,7 @@ pub async fn cmd_plugins_update_all<R: Runtime>(
|
|||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
) -> Result<Vec<PluginNameVersion>> {
|
) -> Result<Vec<PluginNameVersion>> {
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
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()?;
|
let plugins = window.db().list_plugins()?;
|
||||||
|
|
||||||
// Get list of available updates (already filtered to only registry plugins)
|
// Get list of available updates (already filtered to only registry plugins)
|
||||||
@@ -244,11 +266,6 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.path()
|
.path()
|
||||||
.resolve("vendored/plugins", BaseDirectory::Resource)
|
.resolve("vendored/plugins", BaseDirectory::Resource)
|
||||||
.expect("failed to resolve plugin directory resource");
|
.expect("failed to resolve plugin directory resource");
|
||||||
let bundled_plugin_dir = if is_dev() {
|
|
||||||
resolve_workspace_plugins_dir().unwrap_or_else(|| vendored_plugin_dir.clone())
|
|
||||||
} else {
|
|
||||||
vendored_plugin_dir.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let installed_plugin_dir = app_handle
|
let installed_plugin_dir = app_handle
|
||||||
.path()
|
.path()
|
||||||
@@ -272,6 +289,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.expect("failed to resolve plugin runtime")
|
.expect("failed to resolve plugin runtime")
|
||||||
.join("index.cjs");
|
.join("index.cjs");
|
||||||
|
|
||||||
|
let dev_mode = is_dev();
|
||||||
let query_manager =
|
let query_manager =
|
||||||
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
app_handle.state::<yaak_models::query_manager::QueryManager>().inner().clone();
|
||||||
|
|
||||||
@@ -279,13 +297,13 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
let manager = PluginManager::new(
|
let manager = PluginManager::new(
|
||||||
bundled_plugin_dir,
|
|
||||||
vendored_plugin_dir,
|
vendored_plugin_dir,
|
||||||
installed_plugin_dir,
|
installed_plugin_dir,
|
||||||
node_bin_path,
|
node_bin_path,
|
||||||
plugin_runtime_main,
|
plugin_runtime_main,
|
||||||
&query_manager,
|
&query_manager,
|
||||||
&PluginContext::new_empty(),
|
&PluginContext::new_empty(),
|
||||||
|
dev_mode,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to initialize plugins");
|
.expect("Failed to initialize plugins");
|
||||||
@@ -327,11 +345,3 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
})
|
})
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_workspace_plugins_dir() -> Option<PathBuf> {
|
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("../..")
|
|
||||||
.join("plugins")
|
|
||||||
.canonicalize()
|
|
||||||
.ok()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use log::info;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||||
pub use yaak::render::render_http_request;
|
use yaak_models::models::Environment;
|
||||||
use yaak_models::models::{Environment, GrpcRequest, HttpRequestHeader};
|
|
||||||
use yaak_models::render::make_vars_hashmap;
|
use yaak_models::render::make_vars_hashmap;
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
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);
|
let vars = &make_vars_hashmap(environment_chain);
|
||||||
render_json_value_raw(value, vars, cb, opt).await
|
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() })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::fs;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
|
||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
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_models::util::generate_id;
|
||||||
use yaak_plugins::events::{Color, ShowToastRequest};
|
use yaak_plugins::events::{Color, ShowToastRequest};
|
||||||
use yaak_plugins::install::download_and_install;
|
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 plugin_manager = Arc::new((*window.state::<PluginManager>()).clone());
|
||||||
let query_manager = app_handle.db_manager();
|
let query_manager = app_handle.db_manager();
|
||||||
let app_version = app_handle.package_info().version.to_string();
|
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 plugin_context = window.plugin_context();
|
||||||
let pv = download_and_install(
|
let pv = download_and_install(
|
||||||
plugin_manager,
|
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 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 json = resp.bytes().await?;
|
||||||
let p = app_handle
|
let p = app_handle
|
||||||
.path()
|
.path()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use yaak_models::util::UpdateSource;
|
|||||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
|
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||||
use yaak_tls::find_client_certificate;
|
use yaak_tls::find_client_certificate;
|
||||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||||
@@ -72,8 +73,10 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let message = maybe_strip_json_comments(&request.message);
|
||||||
|
|
||||||
let mut ws_manager = ws_manager.lock().await;
|
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(
|
app_handle.db().upsert_websocket_event(
|
||||||
&WebsocketEvent {
|
&WebsocketEvent {
|
||||||
@@ -82,7 +85,7 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
workspace_id: connection.workspace_id.clone(),
|
workspace_id: connection.workspace_id.clone(),
|
||||||
is_server: false,
|
is_server: false,
|
||||||
message_type: WebsocketEventType::Text,
|
message_type: WebsocketEventType::Text,
|
||||||
message: request.message.into(),
|
message: message.into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use std::ops::Add;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow, is_dev};
|
||||||
use ts_rs::TS;
|
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_common::platform::get_os_str;
|
||||||
use yaak_models::db_context::DbContext;
|
use yaak_models::db_context::DbContext;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
@@ -119,7 +119,7 @@ pub async fn activate_license<R: Runtime>(
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
info!("Activating license {}", license_key);
|
info!("Activating license {}", license_key);
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
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 {
|
let payload = ActivateLicenseRequestPayload {
|
||||||
license_key: license_key.to_string(),
|
license_key: license_key.to_string(),
|
||||||
app_platform: get_os_str().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 activation_id = get_activation_id(app_handle).await;
|
||||||
|
|
||||||
let app_version = window.app_handle().package_info().version.to_string();
|
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 path = format!("/licenses/activations/{}/deactivate", activation_id);
|
||||||
let payload =
|
let payload =
|
||||||
DeactivateLicenseRequestPayload { app_platform: get_os_str().to_string(), app_version };
|
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, _) => {
|
(true, _) => {
|
||||||
info!("Checking license activation");
|
info!("Checking license activation");
|
||||||
// A license has been activated, so let's check the license server
|
// 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 path = format!("/licenses/activations/{activation_id}/check-v2");
|
||||||
let response = client.post(build_url(&path)).json(&payload).send().await?;
|
let response = client.post(build_url(&path)).json(&payload).send().await?;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ unsafe impl Sync for UnsafeWindowHandle {}
|
|||||||
|
|
||||||
const WINDOW_CONTROL_PAD_X: f64 = 13.0;
|
const WINDOW_CONTROL_PAD_X: f64 = 13.0;
|
||||||
const WINDOW_CONTROL_PAD_Y: f64 = 18.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_";
|
const MAIN_WINDOW_PREFIX: &str = "main_";
|
||||||
|
|
||||||
pub(crate) fn update_window_title<R: Runtime>(window: Window<R>, title: String) {
|
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);
|
ns_window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||||
let zoom = ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
let zoom = ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||||
|
|
||||||
let title_bar_container_view = close.superview().superview();
|
|
||||||
|
|
||||||
let close_rect: NSRect = msg_send![close, frame];
|
let close_rect: NSRect = msg_send![close, frame];
|
||||||
let button_height = close_rect.size.height;
|
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);
|
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||||
title_bar_rect.size.height = title_bar_frame_height;
|
title_bar_rect.size.height = title_bar_frame_height;
|
||||||
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
|
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
|
||||||
|
|||||||
@@ -8,14 +8,24 @@ use reqwest::header::{HeaderMap, HeaderValue};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use yaak_common::platform::{get_ua_arch, get_ua_platform};
|
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.
|
/// Build a reqwest Client configured for Yaak's own API calls.
|
||||||
///
|
///
|
||||||
/// Includes a custom User-Agent, JSON accept header, 20s timeout, gzip,
|
/// Includes a custom User-Agent, JSON accept header, 20s timeout, gzip,
|
||||||
/// and automatic OS-level proxy detection via sysproxy.
|
/// 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 platform = get_ua_platform();
|
||||||
let arch = get_ua_arch();
|
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();
|
let mut default_headers = HeaderMap::new();
|
||||||
default_headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());
|
default_headers.insert("Accept", HeaderValue::from_str("application/json").unwrap());
|
||||||
|
|||||||
@@ -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")]
|
#[cfg(target_os = "windows")]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
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
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
Some(v) => v.as_str().unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_bool_map(v: &BTreeMap<String, Value>, key: &str, fallback: bool) -> bool {
|
||||||
|
match v.get(key) {
|
||||||
|
None => fallback,
|
||||||
|
Some(v) => v.as_bool().unwrap_or(fallback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::error::Error::GitNotFound;
|
use crate::error::Error::GitNotFound;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Stdio;
|
|
||||||
use tokio::process::Command;
|
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
|
/// Create a git command that runs in the specified directory
|
||||||
pub(crate) async fn new_binary_command(dir: &Path) -> Result<Command> {
|
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)
|
/// Create a git command without a specific directory (for global operations)
|
||||||
pub(crate) async fn new_binary_command_global() -> Result<Command> {
|
pub(crate) async fn new_binary_command_global() -> Result<Command> {
|
||||||
// 1. Probe that `git` exists and is runnable
|
new_checked_command("git", "--version").await.map_err(|_| GitNotFound)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ tower-service = "0.3.3"
|
|||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
yaak-common = { workspace = true }
|
yaak-common = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
|
yaak-templates = { workspace = true }
|
||||||
yaak-tls = { workspace = true }
|
yaak-tls = { workspace = true }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::client::HttpConnectionOptions;
|
use crate::client::HttpConnectionOptions;
|
||||||
use crate::dns::LocalhostResolver;
|
use crate::dns::LocalhostResolver;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use log::info;
|
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub enum HttpResponseEvent {
|
|||||||
url: String,
|
url: String,
|
||||||
status: u16,
|
status: u16,
|
||||||
behavior: RedirectBehavior,
|
behavior: RedirectBehavior,
|
||||||
|
dropped_body: bool,
|
||||||
|
dropped_headers: Vec<String>,
|
||||||
},
|
},
|
||||||
SendUrl {
|
SendUrl {
|
||||||
method: String,
|
method: String,
|
||||||
@@ -67,12 +69,28 @@ impl Display for HttpResponseEvent {
|
|||||||
match self {
|
match self {
|
||||||
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
HttpResponseEvent::Setting(name, value) => write!(f, "* Setting {}={}", name, value),
|
||||||
HttpResponseEvent::Info(s) => write!(f, "* {}", s),
|
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 {
|
let behavior_str = match behavior {
|
||||||
RedirectBehavior::Preserve => "preserve",
|
RedirectBehavior::Preserve => "preserve",
|
||||||
RedirectBehavior::DropBody => "drop body",
|
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 {
|
HttpResponseEvent::SendUrl {
|
||||||
method,
|
method,
|
||||||
@@ -130,13 +148,21 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
|
|||||||
match event {
|
match event {
|
||||||
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
HttpResponseEvent::Setting(name, value) => D::Setting { name, value },
|
||||||
HttpResponseEvent::Info(message) => D::Info { message },
|
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,
|
url,
|
||||||
status,
|
status,
|
||||||
behavior: match behavior {
|
behavior: match behavior {
|
||||||
RedirectBehavior::Preserve => "preserve".to_string(),
|
RedirectBehavior::Preserve => "preserve".to_string(),
|
||||||
RedirectBehavior::DropBody => "drop_body".to_string(),
|
RedirectBehavior::DropBody => "drop_body".to_string(),
|
||||||
},
|
},
|
||||||
|
dropped_body,
|
||||||
|
dropped_headers,
|
||||||
},
|
},
|
||||||
HttpResponseEvent::SendUrl {
|
HttpResponseEvent::SendUrl {
|
||||||
method,
|
method,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::cookies::CookieStore;
|
use crate::cookies::CookieStore;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
|
use crate::sender::{HttpResponse, HttpResponseEvent, HttpSender, RedirectBehavior};
|
||||||
use crate::types::SendableHttpRequest;
|
use crate::types::{SendableBody, SendableHttpRequest};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::watch::Receiver;
|
use tokio::sync::watch::Receiver;
|
||||||
@@ -87,6 +87,11 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build request for this iteration
|
// Build request for this iteration
|
||||||
|
let preserved_body = match ¤t_body {
|
||||||
|
Some(SendableBody::Bytes(b)) => Some(SendableBody::Bytes(b.clone())),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let request_had_body = current_body.is_some();
|
||||||
let req = SendableHttpRequest {
|
let req = SendableHttpRequest {
|
||||||
url: current_url.clone(),
|
url: current_url.clone(),
|
||||||
method: current_method.clone(),
|
method: current_method.clone(),
|
||||||
@@ -182,8 +187,6 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
format!("{}/{}", base_path, location)
|
format!("{}/{}", base_path, location)
|
||||||
};
|
};
|
||||||
|
|
||||||
Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url);
|
|
||||||
|
|
||||||
// Determine redirect behavior based on status code and method
|
// Determine redirect behavior based on status code and method
|
||||||
let behavior = if status == 303 {
|
let behavior = if status == 303 {
|
||||||
// 303 See Other always changes to GET
|
// 303 See Other always changes to GET
|
||||||
@@ -197,11 +200,8 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
RedirectBehavior::Preserve
|
RedirectBehavior::Preserve
|
||||||
};
|
};
|
||||||
|
|
||||||
send_event(HttpResponseEvent::Redirect {
|
let mut dropped_headers =
|
||||||
url: current_url.clone(),
|
Self::remove_sensitive_headers(&mut current_headers, &previous_url, ¤t_url);
|
||||||
status,
|
|
||||||
behavior: behavior.clone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle method changes for certain redirect codes
|
// Handle method changes for certain redirect codes
|
||||||
if matches!(behavior, RedirectBehavior::DropBody) {
|
if matches!(behavior, RedirectBehavior::DropBody) {
|
||||||
@@ -211,13 +211,40 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
// Remove content-related headers
|
// Remove content-related headers
|
||||||
current_headers.retain(|h| {
|
current_headers.retain(|h| {
|
||||||
let name_lower = h.0.to_lowercase();
|
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)
|
// Restore body for Preserve redirects (307/308), drop for others.
|
||||||
// For redirects that change method to GET or for all redirects since body was consumed
|
// Stream bodies can't be replayed (same limitation as reqwest).
|
||||||
current_body = None;
|
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;
|
redirect_count += 1;
|
||||||
}
|
}
|
||||||
@@ -231,7 +258,8 @@ impl<S: HttpSender> HttpTransaction<S> {
|
|||||||
headers: &mut Vec<(String, String)>,
|
headers: &mut Vec<(String, String)>,
|
||||||
previous_url: &str,
|
previous_url: &str,
|
||||||
next_url: &str,
|
next_url: &str,
|
||||||
) {
|
) -> Vec<String> {
|
||||||
|
let mut dropped_headers = Vec::new();
|
||||||
let previous_host = Url::parse(previous_url).ok().and_then(|u| {
|
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)))
|
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 {
|
if previous_host != next_host {
|
||||||
headers.retain(|h| {
|
headers.retain(|h| {
|
||||||
let name_lower = h.0.to_lowercase();
|
let name_lower = h.0.to_lowercase();
|
||||||
name_lower != "authorization"
|
let should_drop = name_lower == "authorization"
|
||||||
&& name_lower != "cookie"
|
|| name_lower == "cookie"
|
||||||
&& name_lower != "cookie2"
|
|| name_lower == "cookie2"
|
||||||
&& name_lower != "proxy-authorization"
|
|| name_lower == "proxy-authorization"
|
||||||
&& name_lower != "www-authenticate"
|
|| 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
|
/// Check if a status code indicates a redirect
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ use std::collections::BTreeMap;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::AsyncRead;
|
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_models::models::HttpRequest;
|
||||||
|
use yaak_templates::strip_json_comments::{maybe_strip_json_comments, strip_json_comments};
|
||||||
|
|
||||||
pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
|
pub(crate) const MULTIPART_BOUNDARY: &str = "------YaakFormBoundary";
|
||||||
|
|
||||||
@@ -134,16 +135,69 @@ pub fn append_query_params(url: &str, params: Vec<(String, String)>) -> String {
|
|||||||
result
|
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 {
|
fn build_url(r: &HttpRequest) -> String {
|
||||||
let (url_string, params) = apply_path_placeholders(&ensure_proto(&r.url), &r.url_parameters);
|
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,
|
&url_string,
|
||||||
params
|
params
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.enabled && !p.name.is_empty())
|
.filter(|p| p.enabled && !p.name.is_empty())
|
||||||
.map(|p| (p.name.clone(), p.value.clone()))
|
.map(|p| (p.name.clone(), p.value.clone()))
|
||||||
.collect(),
|
.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)> {
|
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()))
|
(build_form_body(&body), Some("application/x-www-form-urlencoded".to_string()))
|
||||||
}
|
}
|
||||||
"multipart/form-data" => build_multipart_body(&body, &headers).await?,
|
"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 => {
|
t => {
|
||||||
warn!("Unsupported body type: {}", t);
|
warn!("Unsupported body type: {}", t);
|
||||||
(None, None)
|
(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");
|
let text = get_str_map(body, "text");
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
None
|
return None;
|
||||||
} else {
|
|
||||||
Some(SendableBodyWithMeta::Bytes(Bytes::from(text.to_string())))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
fn build_graphql_body(
|
||||||
@@ -266,7 +327,7 @@ fn build_graphql_body(
|
|||||||
body: &BTreeMap<String, serde_json::Value>,
|
body: &BTreeMap<String, serde_json::Value>,
|
||||||
) -> Option<SendableBodyWithMeta> {
|
) -> Option<SendableBodyWithMeta> {
|
||||||
let query = get_str_map(body, "query");
|
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" {
|
if method.to_lowercase() == "get" {
|
||||||
// GraphQL GET requests use query parameters, not a body
|
// GraphQL GET requests use query parameters, not a body
|
||||||
@@ -684,7 +745,7 @@ mod tests {
|
|||||||
let mut body = BTreeMap::new();
|
let mut body = BTreeMap::new();
|
||||||
body.insert("text".to_string(), json!("Hello, World!"));
|
body.insert("text".to_string(), json!("Hello, World!"));
|
||||||
|
|
||||||
let result = build_text_body(&body);
|
let result = build_text_body(&body, "application/json");
|
||||||
match result {
|
match result {
|
||||||
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
Some(SendableBodyWithMeta::Bytes(bytes)) => {
|
||||||
assert_eq!(bytes, Bytes::from("Hello, World!"))
|
assert_eq!(bytes, Bytes::from("Hello, World!"))
|
||||||
@@ -698,7 +759,7 @@ mod tests {
|
|||||||
let mut body = BTreeMap::new();
|
let mut body = BTreeMap::new();
|
||||||
body.insert("text".to_string(), json!(""));
|
body.insert("text".to_string(), json!(""));
|
||||||
|
|
||||||
let result = build_text_body(&body);
|
let result = build_text_body(&body, "application/json");
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,10 +767,57 @@ mod tests {
|
|||||||
async fn test_text_body_missing() {
|
async fn test_text_body_missing() {
|
||||||
let body = BTreeMap::new();
|
let body = BTreeMap::new();
|
||||||
|
|
||||||
let result = build_text_body(&body);
|
let result = build_text_body(&body, "application/json");
|
||||||
assert!(result.is_none());
|
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]
|
#[tokio::test]
|
||||||
async fn test_form_urlencoded_body() -> Result<()> {
|
async fn test_form_urlencoded_body() -> Result<()> {
|
||||||
let mut body = BTreeMap::new();
|
let mut body = BTreeMap::new();
|
||||||
|
|||||||
6
crates/yaak-models/bindings/gen_models.ts
generated
6
crates/yaak-models/bindings/gen_models.ts
generated
@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
|
|||||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* 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 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 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, };
|
export type PluginKeyValue = { model: "plugin_key_value", createdAt: string, updatedAt: string, pluginName: string, key: string, value: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1499,6 +1499,10 @@ pub enum HttpResponseEventData {
|
|||||||
url: String,
|
url: String,
|
||||||
status: u16,
|
status: u16,
|
||||||
behavior: String,
|
behavior: String,
|
||||||
|
#[serde(default)]
|
||||||
|
dropped_body: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
dropped_headers: Vec<String>,
|
||||||
},
|
},
|
||||||
SendUrl {
|
SendUrl {
|
||||||
method: String,
|
method: String,
|
||||||
@@ -2074,6 +2078,46 @@ pub struct Plugin {
|
|||||||
pub directory: String,
|
pub directory: String,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub url: Option<String>,
|
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 {
|
impl UpsertModelInfo for Plugin {
|
||||||
@@ -2109,6 +2153,7 @@ impl UpsertModelInfo for Plugin {
|
|||||||
(Directory, self.directory.into()),
|
(Directory, self.directory.into()),
|
||||||
(Url, self.url.into()),
|
(Url, self.url.into()),
|
||||||
(Enabled, self.enabled.into()),
|
(Enabled, self.enabled.into()),
|
||||||
|
(Source, self.source.to_string().into()),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2119,6 +2164,7 @@ impl UpsertModelInfo for Plugin {
|
|||||||
PluginIden::Directory,
|
PluginIden::Directory,
|
||||||
PluginIden::Url,
|
PluginIden::Url,
|
||||||
PluginIden::Enabled,
|
PluginIden::Enabled,
|
||||||
|
PluginIden::Source,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2135,6 +2181,7 @@ impl UpsertModelInfo for Plugin {
|
|||||||
url: row.get("url")?,
|
url: row.get("url")?,
|
||||||
directory: row.get("directory")?,
|
directory: row.get("directory")?,
|
||||||
enabled: row.get("enabled")?,
|
enabled: row.get("enabled")?,
|
||||||
|
source: PluginSource::from_str(row.get::<_, String>("source")?.as_str()).unwrap(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ impl<'a> DbContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn upsert_plugin(&self, plugin: &Plugin, source: &UpdateSource) -> Result<Plugin> {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ log = { workspace = true }
|
|||||||
md5 = "0.7.0"
|
md5 = "0.7.0"
|
||||||
path-slash = "0.2.1"
|
path-slash = "0.2.1"
|
||||||
rand = "0.9.0"
|
rand = "0.9.0"
|
||||||
regex = "1.10.6"
|
|
||||||
reqwest = { workspace = true, features = ["json"] }
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
38
crates/yaak-plugins/bindings/gen_models.ts
generated
38
crates/yaak-plugins/bindings/gen_models.ts
generated
@@ -18,7 +18,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
|||||||
|
|
||||||
export type EncryptedKey = { encryptedKey: string, };
|
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, };
|
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 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, };
|
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.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* 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 HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
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 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" };
|
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 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>, };
|
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>, };
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,7 @@ export async function checkPluginUpdates() {
|
|||||||
export async function updateAllPlugins() {
|
export async function updateAllPlugins() {
|
||||||
return invoke<PluginNameVersion[]>('cmd_plugins_update_all', {});
|
return invoke<PluginNameVersion[]>('cmd_plugins_update_all', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function installPluginFromDirectory(directory: string) {
|
||||||
|
return invoke<void>('cmd_plugins_install_from_directory', { directory });
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use yaak_models::models::Plugin;
|
use yaak_models::models::{Plugin, PluginSource};
|
||||||
|
|
||||||
/// Get plugin info from the registry.
|
/// Get plugin info from the registry.
|
||||||
pub async fn get_plugin(
|
pub async fn get_plugin(
|
||||||
@@ -58,7 +58,7 @@ pub async fn check_plugin_updates(
|
|||||||
) -> Result<PluginUpdatesResponse> {
|
) -> Result<PluginUpdatesResponse> {
|
||||||
let name_versions: Vec<PluginNameVersion> = plugins
|
let name_versions: Vec<PluginNameVersion> = plugins
|
||||||
.into_iter()
|
.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)) {
|
.filter_map(|p| match get_plugin_meta(&Path::new(&p.directory)) {
|
||||||
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
|
Ok(m) => Some(PluginNameVersion { name: m.name, version: m.version }),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use log::info;
|
|||||||
use std::fs::{create_dir_all, remove_dir_all};
|
use std::fs::{create_dir_all, remove_dir_all};
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use yaak_models::models::Plugin;
|
use yaak_models::models::{Plugin, PluginSource};
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ pub async fn download_and_install(
|
|||||||
directory: plugin_dir_str.clone(),
|
directory: plugin_dir_str.clone(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: Some(plugin_version.url.clone()),
|
url: Some(plugin_version.url.clone()),
|
||||||
|
source: PluginSource::Registry,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::Background,
|
&UpdateSource::Background,
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ use crate::events::{
|
|||||||
use crate::native_template_functions::{template_function_keyring, template_function_secure};
|
use crate::native_template_functions::{template_function_keyring, template_function_secure};
|
||||||
use crate::nodejs::start_nodejs_plugin_runtime;
|
use crate::nodejs::start_nodejs_plugin_runtime;
|
||||||
use crate::plugin_handle::PluginHandle;
|
use crate::plugin_handle::PluginHandle;
|
||||||
|
use crate::plugin_meta::get_plugin_meta;
|
||||||
use crate::server_ws::PluginRuntimeServerWebsocket;
|
use crate::server_ws::PluginRuntimeServerWebsocket;
|
||||||
use log::{error, info, warn};
|
use log::{error, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::env;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -32,7 +34,7 @@ use tokio::net::TcpListener;
|
|||||||
use tokio::sync::mpsc::error::TrySendError;
|
use tokio::sync::mpsc::error::TrySendError;
|
||||||
use tokio::sync::{Mutex, mpsc, oneshot};
|
use tokio::sync::{Mutex, mpsc, oneshot};
|
||||||
use tokio::time::{Instant, timeout};
|
use tokio::time::{Instant, timeout};
|
||||||
use yaak_models::models::Plugin;
|
use yaak_models::models::{Plugin, PluginSource};
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
use yaak_models::util::{UpdateSource, generate_id};
|
use yaak_models::util::{UpdateSource, generate_id};
|
||||||
use yaak_templates::error::Error::RenderError;
|
use yaak_templates::error::Error::RenderError;
|
||||||
@@ -45,9 +47,9 @@ pub struct PluginManager {
|
|||||||
kill_tx: tokio::sync::watch::Sender<bool>,
|
kill_tx: tokio::sync::watch::Sender<bool>,
|
||||||
killed_rx: Arc<Mutex<Option<oneshot::Receiver<()>>>>,
|
killed_rx: Arc<Mutex<Option<oneshot::Receiver<()>>>>,
|
||||||
ws_service: Arc<PluginRuntimeServerWebsocket>,
|
ws_service: Arc<PluginRuntimeServerWebsocket>,
|
||||||
bundled_plugin_dir: PathBuf,
|
|
||||||
vendored_plugin_dir: PathBuf,
|
vendored_plugin_dir: PathBuf,
|
||||||
pub(crate) installed_plugin_dir: PathBuf,
|
pub(crate) installed_plugin_dir: PathBuf,
|
||||||
|
dev_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback for plugin initialization events (e.g., toast notifications)
|
/// Callback for plugin initialization events (e.g., toast notifications)
|
||||||
@@ -57,21 +59,21 @@ impl PluginManager {
|
|||||||
/// Create a new PluginManager with the given paths.
|
/// Create a new PluginManager with the given paths.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `bundled_plugin_dir` - Directory to scan for bundled plugins
|
|
||||||
/// * `vendored_plugin_dir` - Path to vendored plugins directory
|
/// * `vendored_plugin_dir` - Path to vendored plugins directory
|
||||||
/// * `installed_plugin_dir` - Path to installed plugins directory
|
/// * `installed_plugin_dir` - Path to installed plugins directory
|
||||||
/// * `node_bin_path` - Path to the yaaknode binary
|
/// * `node_bin_path` - Path to the yaaknode binary
|
||||||
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
|
/// * `plugin_runtime_main` - Path to the plugin runtime index.cjs
|
||||||
/// * `query_manager` - Query manager for bundled plugin registration and loading
|
/// * `query_manager` - Query manager for bundled plugin registration and loading
|
||||||
/// * `plugin_context` - Context to use while initializing plugins
|
/// * `plugin_context` - Context to use while initializing plugins
|
||||||
|
/// * `dev_mode` - Whether the app is in dev mode (affects plugin loading)
|
||||||
pub async fn new(
|
pub async fn new(
|
||||||
bundled_plugin_dir: PathBuf,
|
|
||||||
vendored_plugin_dir: PathBuf,
|
vendored_plugin_dir: PathBuf,
|
||||||
installed_plugin_dir: PathBuf,
|
installed_plugin_dir: PathBuf,
|
||||||
node_bin_path: PathBuf,
|
node_bin_path: PathBuf,
|
||||||
plugin_runtime_main: PathBuf,
|
plugin_runtime_main: PathBuf,
|
||||||
query_manager: &QueryManager,
|
query_manager: &QueryManager,
|
||||||
plugin_context: &PluginContext,
|
plugin_context: &PluginContext,
|
||||||
|
dev_mode: bool,
|
||||||
) -> Result<PluginManager> {
|
) -> Result<PluginManager> {
|
||||||
let (events_tx, mut events_rx) = mpsc::channel(2048);
|
let (events_tx, mut events_rx) = mpsc::channel(2048);
|
||||||
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
|
let (kill_server_tx, kill_server_rx) = tokio::sync::watch::channel(false);
|
||||||
@@ -88,9 +90,9 @@ impl PluginManager {
|
|||||||
ws_service: Arc::new(ws_service.clone()),
|
ws_service: Arc::new(ws_service.clone()),
|
||||||
kill_tx: kill_server_tx,
|
kill_tx: kill_server_tx,
|
||||||
killed_rx: Arc::new(Mutex::new(Some(killed_rx))),
|
killed_rx: Arc::new(Mutex::new(Some(killed_rx))),
|
||||||
bundled_plugin_dir,
|
|
||||||
vendored_plugin_dir,
|
vendored_plugin_dir,
|
||||||
installed_plugin_dir,
|
installed_plugin_dir,
|
||||||
|
dev_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Forward events to subscribers
|
// Forward events to subscribers
|
||||||
@@ -161,13 +163,14 @@ impl PluginManager {
|
|||||||
|
|
||||||
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
let bundled_dirs = plugin_manager.list_bundled_plugin_dirs().await?;
|
||||||
let db = query_manager.connect();
|
let db = query_manager.connect();
|
||||||
for dir in bundled_dirs {
|
for dir in &bundled_dirs {
|
||||||
if db.get_plugin_by_directory(&dir).is_none() {
|
if db.get_plugin_by_directory(dir).is_none() {
|
||||||
db.upsert_plugin(
|
db.upsert_plugin(
|
||||||
&Plugin {
|
&Plugin {
|
||||||
directory: dir,
|
directory: dir.clone(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
url: None,
|
url: None,
|
||||||
|
source: PluginSource::Bundled,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&UpdateSource::Background,
|
&UpdateSource::Background,
|
||||||
@@ -191,11 +194,76 @@ impl PluginManager {
|
|||||||
Ok(plugin_manager)
|
Ok(plugin_manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the vendored plugin directory path (resolves dev mode path if applicable)
|
||||||
|
pub fn get_plugins_dir(&self) -> PathBuf {
|
||||||
|
if self.dev_mode {
|
||||||
|
// Use plugins directly for easy development
|
||||||
|
// Tauri runs from crates-tauri/yaak-app/, so go up two levels to reach project root
|
||||||
|
env::current_dir()
|
||||||
|
.map(|cwd| cwd.join("../../plugins").canonicalize().unwrap())
|
||||||
|
.unwrap_or_else(|_| self.vendored_plugin_dir.clone())
|
||||||
|
} else {
|
||||||
|
self.vendored_plugin_dir.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read plugin directories from disk and return their paths.
|
/// Read plugin directories from disk and return their paths.
|
||||||
/// This is useful for discovering bundled plugins.
|
/// This is useful for discovering bundled plugins.
|
||||||
pub async fn list_bundled_plugin_dirs(&self) -> Result<Vec<String>> {
|
pub async fn list_bundled_plugin_dirs(&self) -> Result<Vec<String>> {
|
||||||
info!("Loading bundled plugins from {:?}", self.bundled_plugin_dir);
|
let plugins_dir = self.get_plugins_dir();
|
||||||
read_plugins_dir(&self.bundled_plugin_dir).await
|
info!("Loading bundled plugins from {plugins_dir:?}");
|
||||||
|
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<()> {
|
pub async fn uninstall(&self, plugin_context: &PluginContext, dir: &str) -> Result<()> {
|
||||||
@@ -272,7 +340,8 @@ impl PluginManager {
|
|||||||
Ok(())
|
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.
|
/// Returns a list of (plugin_directory, error_message) for any plugins that failed to initialize.
|
||||||
pub async fn initialize_all_plugins(
|
pub async fn initialize_all_plugins(
|
||||||
&self,
|
&self,
|
||||||
@@ -282,15 +351,18 @@ impl PluginManager {
|
|||||||
info!("Initializing all plugins");
|
info!("Initializing all plugins");
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut errors = Vec::new();
|
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 {
|
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 {
|
if let Err(e) = self.add_plugin(plugin_context, &plugin).await {
|
||||||
warn!("Failed to add plugin {} {e:?}", plugin.directory);
|
warn!("Failed to add plugin {} {e:?}", plugin.directory);
|
||||||
errors.push((plugin.directory.clone(), e.to_string()));
|
errors.push((plugin.directory.clone(), e.to_string()));
|
||||||
@@ -1048,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>> {
|
async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
|
||||||
let mut result = read_dir(dir).await?;
|
let mut result = read_dir(dir).await?;
|
||||||
let mut dirs: Vec<String> = vec![];
|
let mut dirs: Vec<String> = vec![];
|
||||||
@@ -1066,16 +1156,10 @@ async fn read_plugins_dir(dir: &PathBuf) -> Result<Vec<String>> {
|
|||||||
fn fix_windows_paths(p: &PathBuf) -> String {
|
fn fix_windows_paths(p: &PathBuf) -> String {
|
||||||
use dunce;
|
use dunce;
|
||||||
use path_slash::PathBufExt;
|
use path_slash::PathBufExt;
|
||||||
use regex::Regex;
|
|
||||||
|
|
||||||
// 1. Remove UNC prefix for Windows paths to pass to sidecar
|
// 1. Remove UNC prefix for Windows paths
|
||||||
let safe_path = dunce::simplified(p.as_path()).to_string_lossy().to_string();
|
let safe_path = dunce::simplified(p.as_path());
|
||||||
|
|
||||||
// 2. Remove the drive letter
|
// 2. Convert backslashes to forward slashes for Node.js compatibility
|
||||||
let safe_path = Regex::new("^[a-zA-Z]:").unwrap().replace(safe_path.as_str(), "");
|
PathBuf::from(safe_path).to_slash_lossy().to_string()
|
||||||
|
|
||||||
// 3. Convert backslashes to forward
|
|
||||||
let safe_path = PathBuf::from(safe_path.to_string()).to_slash_lossy().to_string();
|
|
||||||
|
|
||||||
safe_path
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
crates/yaak-templates/pkg/yaak_templates.d.ts
generated
vendored
4
crates/yaak-templates/pkg/yaak_templates.d.ts
generated
vendored
@@ -1,5 +1,5 @@
|
|||||||
/* tslint:disable */
|
/* tslint:disable */
|
||||||
/* eslint-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 escape_template(template: string): any;
|
||||||
|
export function parse_template(template: string): any;
|
||||||
|
export function unescape_template(template: string): any;
|
||||||
|
|||||||
8
crates/yaak-templates/pkg/yaak_templates_bg.js
generated
8
crates/yaak-templates/pkg/yaak_templates_bg.js
generated
@@ -165,10 +165,10 @@ function takeFromExternrefTable0(idx) {
|
|||||||
* @param {string} template
|
* @param {string} template
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function unescape_template(template) {
|
export function escape_template(template) {
|
||||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
const len0 = WASM_VECTOR_LEN;
|
const len0 = WASM_VECTOR_LEN;
|
||||||
const ret = wasm.unescape_template(ptr0, len0);
|
const ret = wasm.escape_template(ptr0, len0);
|
||||||
if (ret[2]) {
|
if (ret[2]) {
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
}
|
}
|
||||||
@@ -193,10 +193,10 @@ export function parse_template(template) {
|
|||||||
* @param {string} template
|
* @param {string} template
|
||||||
* @returns {any}
|
* @returns {any}
|
||||||
*/
|
*/
|
||||||
export function escape_template(template) {
|
export function unescape_template(template) {
|
||||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
const len0 = WASM_VECTOR_LEN;
|
const len0 = WASM_VECTOR_LEN;
|
||||||
const ret = wasm.escape_template(ptr0, len0);
|
const ret = wasm.unescape_template(ptr0, len0);
|
||||||
if (ret[2]) {
|
if (ret[2]) {
|
||||||
throw takeFromExternrefTable0(ret[1]);
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
crates/yaak-templates/pkg/yaak_templates_bg.wasm
generated
BIN
crates/yaak-templates/pkg/yaak_templates_bg.wasm
generated
Binary file not shown.
@@ -11,6 +11,7 @@ pub fn format_json(text: &str, tab: &str) -> String {
|
|||||||
let mut new_json = "".to_string();
|
let mut new_json = "".to_string();
|
||||||
let mut depth = 0;
|
let mut depth = 0;
|
||||||
let mut state = FormatState::None;
|
let mut state = FormatState::None;
|
||||||
|
let mut saw_newline_in_whitespace = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let rest_of_chars = chars.clone();
|
let rest_of_chars = chars.clone();
|
||||||
@@ -61,6 +62,62 @@ pub fn format_json(text: &str, tab: &str) -> String {
|
|||||||
continue;
|
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 {
|
match current_char {
|
||||||
',' => {
|
',' => {
|
||||||
new_json.push(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 == '\t'
|
||||||
|| current_char == '\r'
|
|| current_char == '\r'
|
||||||
{
|
{
|
||||||
|
if current_char == '\n' {
|
||||||
|
saw_newline_in_whitespace = true;
|
||||||
|
}
|
||||||
// Don't add these
|
// Don't add these
|
||||||
} else {
|
} else {
|
||||||
|
saw_newline_in_whitespace = false;
|
||||||
new_json.push(current_char);
|
new_json.push(current_char);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace only lines containing whitespace with nothing
|
// Filter out whitespace-only lines, but preserve empty lines inside block comments
|
||||||
new_json
|
let mut result_lines: Vec<&str> = Vec::new();
|
||||||
.lines()
|
let mut in_block_comment = false;
|
||||||
.filter(|line| !line.trim().is_empty()) // Filter out whitespace-only lines
|
for line in new_json.lines() {
|
||||||
.collect::<Vec<&str>>() // Collect the non-empty lines into a vector
|
if in_block_comment {
|
||||||
.join("\n") // Join the lines back into a single string
|
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)]
|
#[cfg(test)]
|
||||||
@@ -297,6 +371,161 @@ mod tests {
|
|||||||
r#"
|
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()
|
.trim()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod escape;
|
pub mod escape;
|
||||||
pub mod format_json;
|
pub mod format_json;
|
||||||
|
pub mod strip_json_comments;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod renderer;
|
pub mod renderer;
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|||||||
318
crates/yaak-templates/src/strip_json_comments.rs
Normal file
318
crates/yaak-templates/src/strip_json_comments.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
|
use yaak_models::models::AnyModel;
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::util::UpdateSource;
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
||||||
DeleteModelRequest, ErrorResponse, FindHttpResponsesRequest, GetCookieValueRequest,
|
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
||||||
GetHttpRequestByIdRequest, GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse,
|
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
||||||
InternalEventPayload, ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse,
|
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
|
||||||
ListHttpRequestsRequest, ListHttpRequestsResponse, ListOpenWorkspacesRequest,
|
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
|
||||||
OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest, PromptTextRequest,
|
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
|
||||||
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
|
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
|
||||||
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
|
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
|
||||||
WindowInfoRequest,
|
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct SharedPluginEventContext<'a> {
|
pub struct SharedPluginEventContext<'a> {
|
||||||
@@ -37,6 +39,9 @@ pub enum SharedRequest<'a> {
|
|||||||
GetHttpRequestById(&'a GetHttpRequestByIdRequest),
|
GetHttpRequestById(&'a GetHttpRequestByIdRequest),
|
||||||
ListFolders(&'a ListFoldersRequest),
|
ListFolders(&'a ListFoldersRequest),
|
||||||
ListHttpRequests(&'a ListHttpRequestsRequest),
|
ListHttpRequests(&'a ListHttpRequestsRequest),
|
||||||
|
FindHttpResponses(&'a FindHttpResponsesRequest),
|
||||||
|
UpsertModel(&'a UpsertModelRequest),
|
||||||
|
DeleteModel(&'a DeleteModelRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -45,9 +50,6 @@ pub enum HostRequest<'a> {
|
|||||||
CopyText(&'a CopyTextRequest),
|
CopyText(&'a CopyTextRequest),
|
||||||
PromptText(&'a PromptTextRequest),
|
PromptText(&'a PromptTextRequest),
|
||||||
PromptForm(&'a PromptFormRequest),
|
PromptForm(&'a PromptFormRequest),
|
||||||
FindHttpResponses(&'a FindHttpResponsesRequest),
|
|
||||||
UpsertModel(&'a UpsertModelRequest),
|
|
||||||
DeleteModel(&'a DeleteModelRequest),
|
|
||||||
RenderGrpcRequest(&'a RenderGrpcRequestRequest),
|
RenderGrpcRequest(&'a RenderGrpcRequestRequest),
|
||||||
RenderHttpRequest(&'a RenderHttpRequestRequest),
|
RenderHttpRequest(&'a RenderHttpRequestRequest),
|
||||||
TemplateRender(&'a TemplateRenderRequest),
|
TemplateRender(&'a TemplateRenderRequest),
|
||||||
@@ -71,9 +73,6 @@ impl HostRequest<'_> {
|
|||||||
HostRequest::CopyText(_) => "copy_text_request".to_string(),
|
HostRequest::CopyText(_) => "copy_text_request".to_string(),
|
||||||
HostRequest::PromptText(_) => "prompt_text_request".to_string(),
|
HostRequest::PromptText(_) => "prompt_text_request".to_string(),
|
||||||
HostRequest::PromptForm(_) => "prompt_form_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::RenderGrpcRequest(_) => "render_grpc_request_request".to_string(),
|
||||||
HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(),
|
HostRequest::RenderHttpRequest(_) => "render_http_request_request".to_string(),
|
||||||
HostRequest::TemplateRender(_) => "template_render_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))
|
GroupedPluginRequest::Host(HostRequest::PromptForm(req))
|
||||||
}
|
}
|
||||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||||
GroupedPluginRequest::Host(HostRequest::FindHttpResponses(req))
|
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
|
||||||
}
|
}
|
||||||
InternalEventPayload::UpsertModelRequest(req) => {
|
InternalEventPayload::UpsertModelRequest(req) => {
|
||||||
GroupedPluginRequest::Host(HostRequest::UpsertModel(req))
|
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
|
||||||
}
|
}
|
||||||
InternalEventPayload::DeleteModelRequest(req) => {
|
InternalEventPayload::DeleteModelRequest(req) => {
|
||||||
GroupedPluginRequest::Host(HostRequest::DeleteModel(req))
|
GroupedPluginRequest::Shared(SharedRequest::DeleteModel(req))
|
||||||
}
|
}
|
||||||
InternalEventPayload::RenderGrpcRequestRequest(req) => {
|
InternalEventPayload::RenderGrpcRequestRequest(req) => {
|
||||||
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
|
GroupedPluginRequest::Host(HostRequest::RenderGrpcRequest(req))
|
||||||
@@ -275,17 +274,175 @@ fn build_shared_reply(
|
|||||||
http_requests,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
fn seed_query_manager() -> QueryManager {
|
fn seed_query_manager() -> (QueryManager, TempDir) {
|
||||||
let temp_dir = tempfile::TempDir::new().expect("Failed to create temp dir");
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
let db_path = temp_dir.path().join("db.sqlite");
|
let db_path = temp_dir.path().join("db.sqlite");
|
||||||
let blob_path = temp_dir.path().join("blobs.sqlite");
|
let blob_path = temp_dir.path().join("blobs.sqlite");
|
||||||
let (query_manager, _blob_manager, _rx) =
|
let (query_manager, _blob_manager, _rx) =
|
||||||
@@ -332,12 +489,12 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("Failed to seed request");
|
.expect("Failed to seed request");
|
||||||
|
|
||||||
query_manager
|
(query_manager, temp_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_requests_requires_workspace_when_folder_missing() {
|
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(
|
let payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||||
);
|
);
|
||||||
@@ -355,7 +512,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_requests_by_workspace_and_folder() {
|
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(
|
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
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]
|
#[test]
|
||||||
fn host_request_classification_works() {
|
fn host_request_classification_works() {
|
||||||
let query_manager = seed_query_manager();
|
let (query_manager, _temp_dir) = seed_query_manager();
|
||||||
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
||||||
label: "main".to_string(),
|
label: "main".to_string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use log::info;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
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_models::render::make_vars_hashmap;
|
||||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
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() })
|
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 {
|
fn strip_disabled_form_entries(v: Value) -> Value {
|
||||||
match v {
|
match v {
|
||||||
Value::Array(items) => Value::Array(
|
Value::Array(items) => Value::Array(
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ function getBinaryPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = childProcess.spawnSync(getBinaryPath(), process.argv.slice(2), {
|
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) {
|
if (result.error) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function getBinaryPath() {
|
|||||||
|
|
||||||
module.exports.runBinary = function runBinary(...args) {
|
module.exports.runBinary = function runBinary(...args) {
|
||||||
childProcess.execFileSync(getBinaryPath(), args, {
|
childProcess.execFileSync(getBinaryPath(), args, {
|
||||||
stdio: "inherit"
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, YAAK_CLI_INSTALL_SOURCE: process.env.YAAK_CLI_INSTALL_SOURCE ?? "npm" },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
729
package-lock.json
generated
729
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -97,7 +97,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.13",
|
"@biomejs/biome": "^2.3.13",
|
||||||
"@tauri-apps/cli": "^2.9.6",
|
"@tauri-apps/cli": "^2.9.6",
|
||||||
"@yaakapp/cli": "^0.4.0",
|
"@yaakapp/cli": "^0.5.1",
|
||||||
"dotenv-cli": "^11.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"nodejs-file-downloader": "^4.13.0",
|
"nodejs-file-downloader": "^4.13.0",
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
|
|||||||
|
|
||||||
export type EncryptedKey = { encryptedKey: string, };
|
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, };
|
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 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, };
|
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.
|
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
* 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 HttpResponseHeader = { name: string, value: string, };
|
||||||
|
|
||||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
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 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" };
|
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 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>, };
|
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>, };
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ export class PluginInstance {
|
|||||||
this.#mod = {};
|
this.#mod = {};
|
||||||
|
|
||||||
const fileChangeCallback = async () => {
|
const fileChangeCallback = async () => {
|
||||||
await this.#mod?.dispose?.();
|
|
||||||
this.#importModule();
|
|
||||||
const ctx = this.#newCtx(workerData.context);
|
const ctx = this.#newCtx(workerData.context);
|
||||||
try {
|
try {
|
||||||
|
await this.#mod?.dispose?.();
|
||||||
|
this.#importModule();
|
||||||
await this.#mod?.init?.(ctx);
|
await this.#mod?.init?.(ctx);
|
||||||
this.#sendPayload(
|
this.#sendPayload(
|
||||||
workerData.context,
|
workerData.context,
|
||||||
@@ -90,7 +90,7 @@ export class PluginInstance {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
ctx.toast.show({
|
await ctx.toast.show({
|
||||||
message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split('/').pop()}: ${err}`,
|
message: `Failed to initialize plugin ${this.#workerData.bootRequest.dir.split('/').pop()}: ${err}`,
|
||||||
color: 'notice',
|
color: 'notice',
|
||||||
icon: 'alert_triangle',
|
icon: 'alert_triangle',
|
||||||
@@ -1003,6 +1003,7 @@ function watchFile(filepath: string, cb: () => void) {
|
|||||||
const stat = statSync(filepath, { throwIfNoEntry: false });
|
const stat = statSync(filepath, { throwIfNoEntry: false });
|
||||||
if (stat == null || stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
|
if (stat == null || stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
|
||||||
watchedFiles[filepath] = stat ?? null;
|
watchedFiles[filepath] = stat ?? null;
|
||||||
|
console.log('[plugin-runtime] watchFile triggered', filepath);
|
||||||
cb();
|
cb();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ describe('template-function-faker', () => {
|
|||||||
it('renders date results as unquoted ISO strings', async () => {
|
it('renders date results as unquoted ISO strings', async () => {
|
||||||
const { plugin } = await import('../src/index');
|
const { plugin } = await import('../src/index');
|
||||||
const fn = plugin.templateFunctions?.find((fn) => fn.name === 'faker.date.future');
|
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!(
|
const result = await onRender(
|
||||||
{} as Parameters<NonNullable<typeof fn.onRender>>[0],
|
{} as Parameters<typeof onRender>[0],
|
||||||
{ values: {} } as Parameters<NonNullable<typeof fn.onRender>>[1],
|
{ 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$/);
|
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||||
|
|||||||
@@ -206,7 +206,10 @@ export const plugin: PluginDefinition = {
|
|||||||
// Create snippet generator
|
// Create snippet generator
|
||||||
const snippet = new HTTPSnippet(harRequest);
|
const snippet = new HTTPSnippet(harRequest);
|
||||||
const generateSnippet = (target: string, client: string): string => {
|
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');
|
return (Array.isArray(result) ? result.join('\n') : result || '').replace(/\r\n/g, '\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/mcp": "^0.2.3",
|
"@hono/mcp": "^0.2.3",
|
||||||
"@hono/node-server": "^1.19.7",
|
"@hono/node-server": "^1.19.10",
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
"hono": "^4.11.10",
|
"hono": "^4.12.4",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const plugin: PluginDefinition = {
|
|||||||
async onSelect(ctx, args) {
|
async onSelect(ctx, args) {
|
||||||
const rendered_request = await ctx.grpcRequest.render({
|
const rendered_request = await ctx.grpcRequest.render({
|
||||||
grpcRequest: args.grpcRequest,
|
grpcRequest: args.grpcRequest,
|
||||||
purpose: 'preview',
|
purpose: 'send',
|
||||||
});
|
});
|
||||||
const data = await convert(rendered_request, args.protoFiles);
|
const data = await convert(rendered_request, args.protoFiles);
|
||||||
await ctx.clipboard.copyText(data);
|
await ctx.clipboard.copyText(data);
|
||||||
@@ -103,7 +103,7 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
|
|||||||
|
|
||||||
// Add form params
|
// Add form params
|
||||||
if (request.message) {
|
if (request.message) {
|
||||||
xs.push('-d', `${quote(JSON.stringify(JSON.parse(request.message)))}`);
|
xs.push('-d', quote(request.message));
|
||||||
xs.push(NEWLINE);
|
xs.push(NEWLINE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,26 @@ describe('exporter-curl', () => {
|
|||||||
[
|
[
|
||||||
`grpcurl -import-path '/'`,
|
`grpcurl -import-path '/'`,
|
||||||
`-proto '/foo.proto'`,
|
`-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',
|
'yaak.app',
|
||||||
].join(' \\\n '),
|
].join(' \\\n '),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import type { Context } from '@yaakapp/api';
|
import type { Context } from '@yaakapp/api';
|
||||||
|
|
||||||
export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect';
|
export const HOSTED_CALLBACK_URL_BASE = 'https://oauth.yaak.app/redirect';
|
||||||
export const DEFAULT_LOCALHOST_PORT = 8765;
|
export const DEFAULT_LOCALHOST_PORT = 8765;
|
||||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
@@ -176,12 +176,15 @@ export function startCallbackServer(options: {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the redirect URI for the hosted callback page.
|
* Build the redirect URI for the hosted callback page.
|
||||||
* The hosted page will redirect to the local server with the OAuth response.
|
* The port is encoded in the URL path so the hosted page can redirect
|
||||||
|
* to the local server without relying on query params (which some OAuth
|
||||||
|
* providers strip). The default port is omitted for a cleaner URL.
|
||||||
*/
|
*/
|
||||||
export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string {
|
export function buildHostedCallbackRedirectUri(localPort: number): string {
|
||||||
const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`;
|
if (localPort === DEFAULT_LOCALHOST_PORT) {
|
||||||
// The hosted callback page will read params and redirect to the local server
|
return HOSTED_CALLBACK_URL_BASE;
|
||||||
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
|
}
|
||||||
|
return `${HOSTED_CALLBACK_URL_BASE}/${localPort}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,14 +216,9 @@ export async function getRedirectUrlViaExternalBrowser(
|
|||||||
): Promise<{ callbackUrl: string; redirectUri: string }> {
|
): Promise<{ callbackUrl: string; redirectUri: string }> {
|
||||||
const { callbackType, callbackPort } = options;
|
const { callbackType, callbackPort } = options;
|
||||||
|
|
||||||
// Determine port based on callback type:
|
const port = callbackPort ?? DEFAULT_LOCALHOST_PORT;
|
||||||
// - localhost: use specified port or default stable port
|
|
||||||
// - hosted: use random port (0) since hosted page redirects to local
|
|
||||||
const port = callbackType === 'localhost' ? (callbackPort ?? DEFAULT_LOCALHOST_PORT) : 0;
|
|
||||||
|
|
||||||
console.log(
|
console.log(`[oauth2] Starting callback server (type: ${callbackType}, port: ${port})`);
|
||||||
`[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const server = await startCallbackServer({
|
const server = await startCallbackServer({
|
||||||
port,
|
port,
|
||||||
@@ -232,7 +230,7 @@ export async function getRedirectUrlViaExternalBrowser(
|
|||||||
let oauthRedirectUri: string;
|
let oauthRedirectUri: string;
|
||||||
|
|
||||||
if (callbackType === 'hosted') {
|
if (callbackType === 'hosted') {
|
||||||
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback');
|
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port);
|
||||||
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
|
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
|
||||||
} else {
|
} else {
|
||||||
oauthRedirectUri = server.redirectUri;
|
oauthRedirectUri = server.redirectUri;
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import type {
|
|||||||
PluginDefinition,
|
PluginDefinition,
|
||||||
} from '@yaakapp/api';
|
} from '@yaakapp/api';
|
||||||
import type { Algorithm } from 'jsonwebtoken';
|
import type { Algorithm } from 'jsonwebtoken';
|
||||||
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL, stopActiveServer } from './callbackServer';
|
import {
|
||||||
|
buildHostedCallbackRedirectUri,
|
||||||
|
DEFAULT_LOCALHOST_PORT,
|
||||||
|
stopActiveServer,
|
||||||
|
} from './callbackServer';
|
||||||
import {
|
import {
|
||||||
type CallbackType,
|
type CallbackType,
|
||||||
DEFAULT_PKCE_METHOD,
|
DEFAULT_PKCE_METHOD,
|
||||||
@@ -300,8 +304,7 @@ export const plugin: PluginDefinition = {
|
|||||||
optional: true,
|
optional: true,
|
||||||
dynamic: hiddenIfNot(
|
dynamic: hiddenIfNot(
|
||||||
['authorization_code', 'implicit'],
|
['authorization_code', 'implicit'],
|
||||||
({ useExternalBrowser, callbackType }) =>
|
({ useExternalBrowser }) => !!useExternalBrowser,
|
||||||
!!useExternalBrowser && callbackType === 'localhost',
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -328,11 +331,11 @@ export const plugin: PluginDefinition = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compute the redirect URI based on callback type
|
// Compute the redirect URI based on callback type
|
||||||
|
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
|
||||||
let redirectUri: string;
|
let redirectUri: string;
|
||||||
if (callbackType === 'hosted') {
|
if (callbackType === 'hosted') {
|
||||||
redirectUri = HOSTED_CALLBACK_URL;
|
redirectUri = buildHostedCallbackRedirectUri(port);
|
||||||
} else {
|
} else {
|
||||||
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
|
|
||||||
redirectUri = `http://127.0.0.1:${port}/callback`;
|
redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ function splitCommands(rawData: string): string[] {
|
|||||||
let inDollarQuote = false;
|
let inDollarQuote = false;
|
||||||
|
|
||||||
for (let i = 0; i < joined.length; i++) {
|
for (let i = 0; i < joined.length; i++) {
|
||||||
const ch = joined[i]!;
|
if (joined[i] === undefined) break; // Make TS happy
|
||||||
|
|
||||||
|
const ch = joined[i];
|
||||||
const next = joined[i + 1];
|
const next = joined[i + 1];
|
||||||
|
|
||||||
// Track quoting state to avoid splitting inside quoted strings
|
// Track quoting state to avoid splitting inside quoted strings
|
||||||
@@ -121,7 +123,11 @@ function splitCommands(rawData: string): string[] {
|
|||||||
const inQuote = inSingleQuote || inDoubleQuote || inDollarQuote;
|
const inQuote = inSingleQuote || inDoubleQuote || inDollarQuote;
|
||||||
|
|
||||||
// Split on ;, newline, or CRLF when not inside quotes and not escaped
|
// Split on ;, newline, or CRLF when not inside quotes and not escaped
|
||||||
if (!inQuote && !isEscaped(i) && (ch === ';' || ch === '\n' || (ch === '\r' && next === '\n'))) {
|
if (
|
||||||
|
!inQuote &&
|
||||||
|
!isEscaped(i) &&
|
||||||
|
(ch === ';' || ch === '\n' || (ch === '\r' && next === '\n'))
|
||||||
|
) {
|
||||||
if (ch === '\r') i++; // Skip the \n in \r\n
|
if (ch === '\r') i++; // Skip the \n in \r\n
|
||||||
if (current.trim()) {
|
if (current.trim()) {
|
||||||
commands.push(current.trim());
|
commands.push(current.trim());
|
||||||
|
|||||||
@@ -7,6 +7,29 @@ describe('importer-openapi', () => {
|
|||||||
const p = path.join(__dirname, 'fixtures');
|
const p = path.join(__dirname, 'fixtures');
|
||||||
const fixtures = fs.readdirSync(p);
|
const fixtures = fs.readdirSync(p);
|
||||||
|
|
||||||
|
test('Maps operation description to request description', async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: { title: 'Description Test', version: '1.0.0' },
|
||||||
|
paths: {
|
||||||
|
'/klanten': {
|
||||||
|
get: {
|
||||||
|
description: 'Lijst van klanten',
|
||||||
|
responses: { '200': { description: 'ok' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
description: 'Lijst van klanten',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('Skips invalid file', async () => {
|
test('Skips invalid file', async () => {
|
||||||
const imported = await convertOpenApi('{}');
|
const imported = await convertOpenApi('{}');
|
||||||
expect(imported).toBeUndefined();
|
expect(imported).toBeUndefined();
|
||||||
|
|||||||
@@ -55,19 +55,11 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
folders: [],
|
folders: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const rawDescription = info.description;
|
|
||||||
const description =
|
|
||||||
typeof rawDescription === 'object' && rawDescription != null && 'content' in rawDescription
|
|
||||||
? String(rawDescription.content)
|
|
||||||
: rawDescription == null
|
|
||||||
? undefined
|
|
||||||
: String(rawDescription);
|
|
||||||
|
|
||||||
const workspace: ExportResources['workspaces'][0] = {
|
const workspace: ExportResources['workspaces'][0] = {
|
||||||
model: 'workspace',
|
model: 'workspace',
|
||||||
id: generateId('workspace'),
|
id: generateId('workspace'),
|
||||||
name: info.name ? String(info.name) : 'Postman Import',
|
name: info.name ? String(info.name) : 'Postman Import',
|
||||||
description,
|
description: importDescription(info.description),
|
||||||
...globalAuth,
|
...globalAuth,
|
||||||
};
|
};
|
||||||
exportResources.workspaces.push(workspace);
|
exportResources.workspaces.push(workspace);
|
||||||
@@ -139,7 +131,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
folderId,
|
folderId,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
description: r.description ? String(r.description) : undefined,
|
description: importDescription(r.description),
|
||||||
method: typeof r.method === 'string' ? r.method : 'GET',
|
method: typeof r.method === 'string' ? r.method : 'GET',
|
||||||
url,
|
url,
|
||||||
urlParameters,
|
urlParameters,
|
||||||
@@ -509,6 +501,26 @@ function toArray<T>(value: unknown): T[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function importDescription(rawDescription: unknown): string | undefined {
|
||||||
|
if (rawDescription == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawDescription === 'string') {
|
||||||
|
return rawDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawDescription === 'object' && !Array.isArray(rawDescription)) {
|
||||||
|
const description = toRecord(rawDescription);
|
||||||
|
if ('content' in description && description.content != null) {
|
||||||
|
return String(description.content);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(rawDescription);
|
||||||
|
}
|
||||||
|
|
||||||
/** Recursively render all nested object properties */
|
/** Recursively render all nested object properties */
|
||||||
function convertTemplateSyntax<T>(obj: T): T {
|
function convertTemplateSyntax<T>(obj: T): T {
|
||||||
if (typeof obj === 'string') {
|
if (typeof obj === 'string') {
|
||||||
|
|||||||
@@ -22,4 +22,39 @@ describe('importer-postman', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('Imports object descriptions without [object Object]', () => {
|
||||||
|
const result = convertPostman(
|
||||||
|
JSON.stringify({
|
||||||
|
info: {
|
||||||
|
name: 'Description Test',
|
||||||
|
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
||||||
|
},
|
||||||
|
item: [
|
||||||
|
{
|
||||||
|
name: 'Request 1',
|
||||||
|
request: {
|
||||||
|
method: 'GET',
|
||||||
|
description: {
|
||||||
|
content: 'Lijst van klanten',
|
||||||
|
type: 'text/plain',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.resources.workspaces).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'Description Test',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(result?.resources.httpRequests).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'Request 1',
|
||||||
|
description: 'Lijst van klanten',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -317,7 +317,8 @@ async function getResponse(
|
|||||||
finalBehavior === 'always' ||
|
finalBehavior === 'always' ||
|
||||||
(finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl))
|
(finalBehavior === BEHAVIOR_TTL && shouldSendExpired(response, ttl))
|
||||||
) {
|
) {
|
||||||
// NOTE: Render inside this conditional, or we'll get infinite recursion (render->render->...)
|
// Explicitly render the request before send (instead of relying on send() to render) so that we can
|
||||||
|
// preserve the render purpose.
|
||||||
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
|
const renderedHttpRequest = await ctx.httpRequest.render({ httpRequest, purpose });
|
||||||
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
|
response = await ctx.httpRequest.send({ httpRequest: renderedHttpRequest });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@yaak/template-function-timestamp",
|
"name": "@yaak/template-function-timestamp",
|
||||||
|
"displayName": "Timestamp Template Functions",
|
||||||
|
"description": "Template functions for dealing with timestamps",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ const args = [
|
|||||||
...additionalArgs
|
...additionalArgs
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = spawnSync('tauri', args, { stdio: 'inherit', shell: false, env: process.env });
|
// Invoke the tauri CLI JS entry point directly via node to avoid shell escaping issues on Windows
|
||||||
|
const tauriJs = path.join(rootDir, 'node_modules', '@tauri-apps', 'cli', 'tauri.js');
|
||||||
|
|
||||||
|
const result = spawnSync(process.execPath, [tauriJs, ...args], { stdio: 'inherit', env: process.env });
|
||||||
|
|
||||||
process.exit(result.status || 0);
|
process.exit(result.status || 0);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
|||||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||||
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||||
|
import { appInfo } from '../lib/appInfo';
|
||||||
|
import { copyToClipboard } from '../lib/copy';
|
||||||
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
|
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
|
||||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||||
import { showDialog } from '../lib/dialog';
|
import { showDialog } from '../lib/dialog';
|
||||||
@@ -162,6 +164,14 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
|||||||
label: 'Send Request',
|
label: 'Send Request',
|
||||||
onSelect: () => sendRequest(activeRequest.id),
|
onSelect: () => sendRequest(activeRequest.id),
|
||||||
});
|
});
|
||||||
|
if (appInfo.cliVersion != null) {
|
||||||
|
commands.push({
|
||||||
|
key: 'request.copy_cli_send',
|
||||||
|
searchText: `copy cli send yaak request send ${activeRequest.id}`,
|
||||||
|
label: 'Copy CLI Send Command',
|
||||||
|
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
httpRequestActions.forEach((a, i) => {
|
httpRequestActions.forEach((a, i) => {
|
||||||
commands.push({
|
commands.push({
|
||||||
key: `http_request_action.${i}`,
|
key: `http_request_action.${i}`,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { jsonLanguage } from '@codemirror/lang-json';
|
import { jsoncLanguage } from '@shopify/lang-jsonc';
|
||||||
import { linter } from '@codemirror/lint';
|
import { linter } from '@codemirror/lint';
|
||||||
import type { EditorView } from '@codemirror/view';
|
import type { EditorView } from '@codemirror/view';
|
||||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||||
@@ -115,7 +115,7 @@ export function GrpcEditor({
|
|||||||
delay: 200,
|
delay: 200,
|
||||||
needsRefresh: handleRefresh,
|
needsRefresh: handleRefresh,
|
||||||
}),
|
}),
|
||||||
jsonLanguage.data.of({
|
jsoncLanguage.data.of({
|
||||||
autocomplete: jsonCompletion(),
|
autocomplete: jsonCompletion(),
|
||||||
}),
|
}),
|
||||||
stateExtensions({}),
|
stateExtensions({}),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function HeaderSize({
|
|||||||
} else if (type() === 'macos') {
|
} else if (type() === 'macos') {
|
||||||
if (!isFullscreen) {
|
if (!isFullscreen) {
|
||||||
// Add large padding for window controls
|
// Add large padding for window controls
|
||||||
s.paddingLeft = 72 / settings.interfaceScale;
|
s.paddingLeft = 76 / settings.interfaceScale;
|
||||||
}
|
}
|
||||||
} else if (!ignoreControlsSpacing && !settings.hideWindowControls) {
|
} else if (!ignoreControlsSpacing && !settings.hideWindowControls) {
|
||||||
s.paddingRight = WINDOW_CONTROLS_WIDTH;
|
s.paddingRight = WINDOW_CONTROLS_WIDTH;
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { FormMultipartEditor } from './FormMultipartEditor';
|
|||||||
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
||||||
import { HeadersEditor } from './HeadersEditor';
|
import { HeadersEditor } from './HeadersEditor';
|
||||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||||
|
import { JsonBodyEditor } from './JsonBodyEditor';
|
||||||
import { MarkdownEditor } from './MarkdownEditor';
|
import { MarkdownEditor } from './MarkdownEditor';
|
||||||
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||||
import { UrlBar } from './UrlBar';
|
import { UrlBar } from './UrlBar';
|
||||||
@@ -257,7 +258,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
const handleBodyTextChange = useCallback(
|
||||||
(text: string) => patchModel(activeRequest, { body: { text } }),
|
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -370,16 +371,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
<TabContent value={TAB_BODY}>
|
<TabContent value={TAB_BODY}>
|
||||||
<ConfirmLargeRequestBody request={activeRequest}>
|
<ConfirmLargeRequestBody request={activeRequest}>
|
||||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||||
<Editor
|
<JsonBodyEditor
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
autocompleteFunctions
|
|
||||||
autocompleteVariables
|
|
||||||
placeholder="..."
|
|
||||||
heightMode={fullHeight ? 'full' : 'auto'}
|
heightMode={fullHeight ? 'full' : 'auto'}
|
||||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
request={activeRequest}
|
||||||
language="json"
|
|
||||||
onChange={handleBodyTextChange}
|
|
||||||
stateKey={`json.${activeRequest.id}`}
|
|
||||||
/>
|
/>
|
||||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||||
<Editor
|
<Editor
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { ComponentType, CSSProperties } from 'react';
|
import type { ComponentType, CSSProperties } from 'react';
|
||||||
import { lazy, Suspense, useMemo } from 'react';
|
import { lazy, Suspense, useMemo } from 'react';
|
||||||
@@ -18,11 +18,14 @@ import { CountBadge } from './core/CountBadge';
|
|||||||
import { HotkeyList } from './core/HotkeyList';
|
import { HotkeyList } from './core/HotkeyList';
|
||||||
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
||||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||||
|
import { Icon } from './core/Icon';
|
||||||
import { LoadingIcon } from './core/LoadingIcon';
|
import { LoadingIcon } from './core/LoadingIcon';
|
||||||
|
import { PillButton } from './core/PillButton';
|
||||||
import { SizeTag } from './core/SizeTag';
|
import { SizeTag } from './core/SizeTag';
|
||||||
import { HStack, VStack } from './core/Stacks';
|
import { HStack, VStack } from './core/Stacks';
|
||||||
import type { TabItem } from './core/Tabs/Tabs';
|
import type { TabItem } from './core/Tabs/Tabs';
|
||||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||||
|
import { Tooltip } from './core/Tooltip';
|
||||||
import { EmptyStateText } from './EmptyStateText';
|
import { EmptyStateText } from './EmptyStateText';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
||||||
@@ -57,6 +60,11 @@ const TAB_TIMELINE = 'timeline';
|
|||||||
|
|
||||||
export type TimelineViewMode = 'timeline' | 'text';
|
export type TimelineViewMode = 'timeline' | 'text';
|
||||||
|
|
||||||
|
interface RedirectDropWarning {
|
||||||
|
droppedBodyCount: number;
|
||||||
|
droppedHeaders: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||||
@@ -65,6 +73,12 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||||
|
|
||||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||||
|
const redirectDropWarning = useMemo(
|
||||||
|
() => getRedirectDropWarning(responseEvents.data),
|
||||||
|
[responseEvents.data],
|
||||||
|
);
|
||||||
|
const shouldShowRedirectDropWarning =
|
||||||
|
activeResponse?.state === 'closed' && redirectDropWarning != null;
|
||||||
|
|
||||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||||
|
|
||||||
@@ -162,32 +176,77 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeResponse && (
|
{activeResponse && (
|
||||||
<HStack
|
<div
|
||||||
space={2}
|
|
||||||
alignItems="center"
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]',
|
||||||
'cursor-default select-none',
|
'cursor-default select-none',
|
||||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
<HStack space={2} className="w-full flex-shrink-0">
|
||||||
<HttpStatusTag showReason response={activeResponse} />
|
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
||||||
<span>•</span>
|
<HttpStatusTag showReason response={activeResponse} />
|
||||||
<HttpResponseDurationTag response={activeResponse} />
|
<span>•</span>
|
||||||
<span>•</span>
|
<HttpResponseDurationTag response={activeResponse} />
|
||||||
<SizeTag
|
<span>•</span>
|
||||||
contentLength={activeResponse.contentLength ?? 0}
|
<SizeTag
|
||||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
contentLength={activeResponse.contentLength ?? 0}
|
||||||
/>
|
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||||
|
/>
|
||||||
<div className="ml-auto">
|
</HStack>
|
||||||
|
{shouldShowRedirectDropWarning ? (
|
||||||
|
<Tooltip
|
||||||
|
tabIndex={0}
|
||||||
|
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||||
|
content={
|
||||||
|
<VStack alignItems="start" space={1} className="text-xs">
|
||||||
|
<span className="font-medium text-warning">
|
||||||
|
Redirect changed this request
|
||||||
|
</span>
|
||||||
|
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||||
|
<span>
|
||||||
|
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
|
||||||
|
{redirectDropWarning.droppedBodyCount === 1
|
||||||
|
? 'redirect hop'
|
||||||
|
: 'redirect hops'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||||
|
<span>
|
||||||
|
Headers dropped:{' '}
|
||||||
|
<span className="font-mono">
|
||||||
|
{redirectDropWarning.droppedHeaders.join(', ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-text-subtle">See Timeline for details.</span>
|
||||||
|
</VStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="inline-flex min-w-0">
|
||||||
|
<PillButton
|
||||||
|
color="warning"
|
||||||
|
className="font-sans text-sm !flex-shrink max-w-full"
|
||||||
|
innerClassName="flex items-center"
|
||||||
|
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{getRedirectWarningLabel(redirectDropWarning)}
|
||||||
|
</span>
|
||||||
|
</PillButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="justify-self-end flex-shrink-0">
|
||||||
<RecentHttpResponsesDropdown
|
<RecentHttpResponsesDropdown
|
||||||
responses={responses}
|
responses={responses}
|
||||||
activeResponse={activeResponse}
|
activeResponse={activeResponse}
|
||||||
onPinnedResponseId={setPinnedResponseId}
|
onPinnedResponseId={setPinnedResponseId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</HStack>
|
</div>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|
||||||
@@ -274,6 +333,54 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRedirectDropWarning(
|
||||||
|
events: HttpResponseEvent[] | undefined,
|
||||||
|
): RedirectDropWarning | null {
|
||||||
|
if (events == null || events.length === 0) return null;
|
||||||
|
|
||||||
|
let droppedBodyCount = 0;
|
||||||
|
const droppedHeaders = new Set<string>();
|
||||||
|
for (const e of events) {
|
||||||
|
const event = e.event;
|
||||||
|
if (event.type !== 'redirect') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.dropped_body) {
|
||||||
|
droppedBodyCount += 1;
|
||||||
|
}
|
||||||
|
for (const headerName of event.dropped_headers ?? []) {
|
||||||
|
pushHeaderName(droppedHeaders, headerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
droppedBodyCount,
|
||||||
|
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||||
|
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||||
|
if (existing == null) {
|
||||||
|
headers.add(headerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||||
|
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||||
|
return 'Dropped body and headers';
|
||||||
|
}
|
||||||
|
if (warning.droppedBodyCount > 0) {
|
||||||
|
return 'Dropped body';
|
||||||
|
}
|
||||||
|
return 'Dropped headers';
|
||||||
|
}
|
||||||
|
|
||||||
function EnsureCompleteResponse({
|
function EnsureCompleteResponse({
|
||||||
response,
|
response,
|
||||||
Component,
|
Component,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
|||||||
import { Editor } from './core/Editor/LazyEditor';
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
|
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
|
||||||
import { EventViewerRow } from './core/EventViewerRow';
|
import { EventViewerRow } from './core/EventViewerRow';
|
||||||
import { HttpMethodTagRaw } from './core/HttpMethodTag';
|
|
||||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||||
import { Icon, type IconProps } from './core/Icon';
|
import { Icon, type IconProps } from './core/Icon';
|
||||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||||
@@ -188,6 +187,7 @@ function EventDetails({
|
|||||||
|
|
||||||
// Redirect - show status, URL, and behavior
|
// Redirect - show status, URL, and behavior
|
||||||
if (e.type === 'redirect') {
|
if (e.type === 'redirect') {
|
||||||
|
const droppedHeaders = e.dropped_headers ?? [];
|
||||||
return (
|
return (
|
||||||
<KeyValueRows>
|
<KeyValueRows>
|
||||||
<KeyValueRow label="Status">
|
<KeyValueRow label="Status">
|
||||||
@@ -197,6 +197,10 @@ function EventDetails({
|
|||||||
<KeyValueRow label="Behavior">
|
<KeyValueRow label="Behavior">
|
||||||
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
|
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
|
||||||
</KeyValueRow>
|
</KeyValueRow>
|
||||||
|
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow>
|
||||||
|
<KeyValueRow label="Headers Dropped">
|
||||||
|
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'}
|
||||||
|
</KeyValueRow>
|
||||||
</KeyValueRows>
|
</KeyValueRows>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -269,7 +273,17 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
|||||||
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
||||||
case 'redirect': {
|
case 'redirect': {
|
||||||
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
||||||
return { prefix: '*', text: `Redirect ${event.status} -> ${event.url} (${behavior})` };
|
const droppedHeaders = event.dropped_headers ?? [];
|
||||||
|
const dropped = [
|
||||||
|
event.dropped_body ? 'body dropped' : null,
|
||||||
|
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
return {
|
||||||
|
prefix: '*',
|
||||||
|
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case 'setting':
|
case 'setting':
|
||||||
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
||||||
@@ -324,13 +338,23 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
|||||||
label: 'Info',
|
label: 'Info',
|
||||||
summary: event.message,
|
summary: event.message,
|
||||||
};
|
};
|
||||||
case 'redirect':
|
case 'redirect': {
|
||||||
|
const droppedHeaders = event.dropped_headers ?? [];
|
||||||
|
const dropped = [
|
||||||
|
event.dropped_body ? 'drop body' : null,
|
||||||
|
droppedHeaders.length > 0
|
||||||
|
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
return {
|
return {
|
||||||
icon: 'arrow_big_right_dash',
|
icon: 'arrow_big_right_dash',
|
||||||
color: 'success',
|
color: 'success',
|
||||||
label: 'Redirect',
|
label: 'Redirect',
|
||||||
summary: `Redirecting ${event.status} ${event.url}${event.behavior === 'drop_body' ? ' (drop body)' : ''}`,
|
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'send_url':
|
case 'send_url':
|
||||||
return {
|
return {
|
||||||
icon: 'arrow_big_up_dash',
|
icon: 'arrow_big_up_dash',
|
||||||
|
|||||||
122
src-web/components/JsonBodyEditor.tsx
Normal file
122
src-web/components/JsonBodyEditor.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { linter } from '@codemirror/lint';
|
||||||
|
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||||
|
import { patchModel } from '@yaakapp-internal/models';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useKeyValue } from '../hooks/useKeyValue';
|
||||||
|
import { textLikelyContainsJsonComments } from '../lib/jsonComments';
|
||||||
|
import { Banner } from './core/Banner';
|
||||||
|
import type { DropdownItem } from './core/Dropdown';
|
||||||
|
import { Dropdown } from './core/Dropdown';
|
||||||
|
import type { EditorProps } from './core/Editor/Editor';
|
||||||
|
import { jsonParseLinter } from './core/Editor/json-lint';
|
||||||
|
import { Editor } from './core/Editor/LazyEditor';
|
||||||
|
import { Icon } from './core/Icon';
|
||||||
|
import { IconButton } from './core/IconButton';
|
||||||
|
import { IconTooltip } from './core/IconTooltip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
forceUpdateKey: string;
|
||||||
|
heightMode: EditorProps['heightMode'];
|
||||||
|
request: HttpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(text: string) => patchModel(request, { body: { ...request.body, text } }),
|
||||||
|
[request],
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoFix = request.body?.sendJsonComments !== true;
|
||||||
|
|
||||||
|
const lintExtension = useMemo(
|
||||||
|
() =>
|
||||||
|
linter(
|
||||||
|
jsonParseLinter(
|
||||||
|
autoFix
|
||||||
|
? { allowComments: true, allowTrailingCommas: true }
|
||||||
|
: { allowComments: false, allowTrailingCommas: false },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[autoFix],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasComments = useMemo(
|
||||||
|
() => textLikelyContainsJsonComments(request.body?.text ?? ''),
|
||||||
|
[request.body?.text],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
|
||||||
|
namespace: 'no_sync',
|
||||||
|
key: ['json-fix-3', request.workspaceId],
|
||||||
|
fallback: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleAutoFix = useCallback(() => {
|
||||||
|
const newBody = { ...request.body };
|
||||||
|
if (autoFix) {
|
||||||
|
newBody.sendJsonComments = true;
|
||||||
|
} else {
|
||||||
|
delete newBody.sendJsonComments;
|
||||||
|
}
|
||||||
|
patchModel(request, { body: newBody });
|
||||||
|
}, [request, autoFix]);
|
||||||
|
|
||||||
|
const handleDropdownOpen = useCallback(() => {
|
||||||
|
if (!bannerDismissed) {
|
||||||
|
setBannerDismissed(true);
|
||||||
|
}
|
||||||
|
}, [bannerDismissed, setBannerDismissed]);
|
||||||
|
|
||||||
|
const showBanner = hasComments && autoFix && !bannerDismissed;
|
||||||
|
|
||||||
|
const stripMessage = 'Automatically strip comments and trailing commas before sending';
|
||||||
|
const actions = useMemo<EditorProps['actions']>(
|
||||||
|
() => [
|
||||||
|
showBanner && (
|
||||||
|
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
|
||||||
|
<p className="inline-flex items-center gap-1 min-w-0">
|
||||||
|
<span className="truncate">Auto-fix enabled</span>
|
||||||
|
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||||
|
</p>
|
||||||
|
</Banner>
|
||||||
|
),
|
||||||
|
<div key="settings" className="!opacity-100 !shadow">
|
||||||
|
<Dropdown
|
||||||
|
onOpen={handleDropdownOpen}
|
||||||
|
items={
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Automatically Fix JSON',
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: handleToggleAutoFix,
|
||||||
|
rightSlot: <IconTooltip content={stripMessage} />,
|
||||||
|
leftSlot: (
|
||||||
|
<Icon icon={autoFix ? 'check_square_checked' : 'check_square_unchecked'} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
] satisfies DropdownItem[]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
||||||
|
</Dropdown>
|
||||||
|
</div>,
|
||||||
|
],
|
||||||
|
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editor
|
||||||
|
forceUpdateKey={forceUpdateKey}
|
||||||
|
autocompleteFunctions
|
||||||
|
autocompleteVariables
|
||||||
|
placeholder="..."
|
||||||
|
heightMode={heightMode}
|
||||||
|
defaultValue={`${request.body?.text ?? ''}`}
|
||||||
|
language="json"
|
||||||
|
onChange={handleChange}
|
||||||
|
stateKey={`json.${request.id}`}
|
||||||
|
actions={actions}
|
||||||
|
lintExtension={lintExtension}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,9 +39,9 @@ const tabs = [
|
|||||||
TAB_THEME,
|
TAB_THEME,
|
||||||
TAB_INTERFACE,
|
TAB_INTERFACE,
|
||||||
TAB_SHORTCUTS,
|
TAB_SHORTCUTS,
|
||||||
|
TAB_PLUGINS,
|
||||||
TAB_CERTIFICATES,
|
TAB_CERTIFICATES,
|
||||||
TAB_PROXY,
|
TAB_PROXY,
|
||||||
TAB_PLUGINS,
|
|
||||||
TAB_LICENSE,
|
TAB_LICENSE,
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsTab = (typeof tabs)[number];
|
export type SettingsTab = (typeof tabs)[number];
|
||||||
@@ -120,7 +120,7 @@ export default function Settings({ hide }: Props) {
|
|||||||
value === TAB_CERTIFICATES ? (
|
value === TAB_CERTIFICATES ? (
|
||||||
<CountBadge count={settings.clientCertificates.length} />
|
<CountBadge count={settings.clientCertificates.length} />
|
||||||
) : value === TAB_PLUGINS ? (
|
) : value === TAB_PLUGINS ? (
|
||||||
<CountBadge count={plugins.length} />
|
<CountBadge count={plugins.filter((p) => p.source !== 'bundled').length} />
|
||||||
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
|
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
|
||||||
<CountBadge count />
|
<CountBadge count />
|
||||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
|
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
|
||||||
@@ -141,7 +141,7 @@ export default function Settings({ hide }: Props) {
|
|||||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
|
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
|
||||||
<SettingsHotkeys />
|
<SettingsHotkeys />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
|
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
||||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
|
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { Link } from '../core/Link';
|
|||||||
import { PlainInput } from '../core/PlainInput';
|
import { PlainInput } from '../core/PlainInput';
|
||||||
import { Separator } from '../core/Separator';
|
import { Separator } from '../core/Separator';
|
||||||
import { HStack, VStack } from '../core/Stacks';
|
import { HStack, VStack } from '../core/Stacks';
|
||||||
import { LocalImage } from '../LocalImage';
|
|
||||||
|
|
||||||
export function SettingsLicense() {
|
export function SettingsLicense() {
|
||||||
return (
|
return (
|
||||||
@@ -41,11 +40,7 @@ function SettingsLicenseCmp() {
|
|||||||
|
|
||||||
case 'trialing':
|
case 'trialing':
|
||||||
return (
|
return (
|
||||||
<Banner color="info" className="@container flex items-center gap-x-5 max-w-xl">
|
<Banner color="info" className="max-w-lg">
|
||||||
<LocalImage
|
|
||||||
src="static/greg.jpeg"
|
|
||||||
className="hidden @sm:block rounded-full h-14 w-14"
|
|
||||||
/>
|
|
||||||
<p className="w-full">
|
<p className="w-full">
|
||||||
<strong>
|
<strong>
|
||||||
{pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))}
|
{pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))}
|
||||||
@@ -55,10 +50,6 @@ function SettingsLicenseCmp() {
|
|||||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href="mailto:support@yaak.app">
|
|
||||||
Contact Support
|
|
||||||
</Link>
|
|
||||||
<Icon icon="dot" size="sm" color="secondary" />
|
|
||||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -69,24 +60,16 @@ function SettingsLicenseCmp() {
|
|||||||
|
|
||||||
case 'personal_use':
|
case 'personal_use':
|
||||||
return (
|
return (
|
||||||
<Banner color="notice" className="@container flex items-center gap-x-5 max-w-xl">
|
<Banner color="notice" className="max-w-lg">
|
||||||
<LocalImage
|
|
||||||
src="static/greg.jpeg"
|
|
||||||
className="hidden @sm:block rounded-full h-14 w-14"
|
|
||||||
/>
|
|
||||||
<p className="w-full">
|
<p className="w-full">
|
||||||
Your commercial-use trial has ended.
|
Your commercial-use trial has ended.
|
||||||
<br />
|
<br />
|
||||||
<span className="opacity-50">
|
<span className="opacity-50">
|
||||||
You may continue using Yaak for personal use free, forever.
|
You may continue using Yaak for personal use only.
|
||||||
<br />A license is required for commercial use.
|
<br />A license is required for commercial use.
|
||||||
</span>
|
</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href="mailto:support@yaak.app">
|
|
||||||
Contact Support
|
|
||||||
</Link>
|
|
||||||
<Icon icon="dot" size="sm" color="secondary" />
|
|
||||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import {
|
|||||||
searchPlugins,
|
searchPlugins,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
} from '@yaakapp-internal/plugins';
|
} from '@yaakapp-internal/plugins';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
||||||
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
|
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
|
||||||
import { usePluginInfo } from '../../hooks/usePluginInfo';
|
import { usePluginInfo } from '../../hooks/usePluginInfo';
|
||||||
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
|
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
|
||||||
import { appInfo } from '../../lib/appInfo';
|
|
||||||
import { showConfirmDelete } from '../../lib/confirm';
|
import { showConfirmDelete } from '../../lib/confirm';
|
||||||
import { minPromiseMillis } from '../../lib/minPromiseMillis';
|
import { minPromiseMillis } from '../../lib/minPromiseMillis';
|
||||||
import { Button } from '../core/Button';
|
import { Button } from '../core/Button';
|
||||||
@@ -33,16 +33,6 @@ import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
|||||||
import { EmptyStateText } from '../EmptyStateText';
|
import { EmptyStateText } from '../EmptyStateText';
|
||||||
import { SelectFile } from '../SelectFile';
|
import { SelectFile } from '../SelectFile';
|
||||||
|
|
||||||
function isPluginBundled(plugin: Plugin, vendoredPluginDir: string): boolean {
|
|
||||||
const normalizedDir = plugin.directory.replace(/\\/g, '/');
|
|
||||||
const normalizedVendoredDir = vendoredPluginDir.replace(/\\/g, '/');
|
|
||||||
return (
|
|
||||||
normalizedDir.includes(normalizedVendoredDir) ||
|
|
||||||
normalizedDir.includes('vendored/plugins') ||
|
|
||||||
normalizedDir.includes('/plugins/')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsPluginsProps {
|
interface SettingsPluginsProps {
|
||||||
defaultSubtab?: string;
|
defaultSubtab?: string;
|
||||||
}
|
}
|
||||||
@@ -50,8 +40,8 @@ interface SettingsPluginsProps {
|
|||||||
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||||
const [directory, setDirectory] = useState<string | null>(null);
|
const [directory, setDirectory] = useState<string | null>(null);
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
const plugins = useAtomValue(pluginsAtom);
|
||||||
const bundledPlugins = plugins.filter((p) => isPluginBundled(p, appInfo.vendoredPluginDir));
|
const bundledPlugins = plugins.filter((p) => p.source === 'bundled');
|
||||||
const installedPlugins = plugins.filter((p) => !isPluginBundled(p, appInfo.vendoredPluginDir));
|
const installedPlugins = plugins.filter((p) => p.source !== 'bundled');
|
||||||
const createPlugin = useInstallPlugin();
|
const createPlugin = useInstallPlugin();
|
||||||
const refreshPlugins = useRefreshPlugins();
|
const refreshPlugins = useRefreshPlugins();
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +50,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|||||||
defaultValue={defaultSubtab}
|
defaultValue={defaultSubtab}
|
||||||
label="Plugins"
|
label="Plugins"
|
||||||
addBorders
|
addBorders
|
||||||
|
tabListClassName="px-6 pt-2"
|
||||||
tabs={[
|
tabs={[
|
||||||
{ label: 'Discover', value: 'search' },
|
{ label: 'Discover', value: 'search' },
|
||||||
{
|
{
|
||||||
@@ -74,13 +65,13 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabContent value="search">
|
<TabContent value="search" className="px-6">
|
||||||
<PluginSearch />
|
<PluginSearch />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value="installed" className="pb-0">
|
<TabContent value="installed" className="pb-0">
|
||||||
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||||
<InstalledPlugins plugins={installedPlugins} />
|
<InstalledPlugins plugins={installedPlugins} className="px-6" />
|
||||||
<footer className="grid grid-cols-[minmax(0,1fr)_auto] -mx-4 py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
|
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
|
||||||
<SelectFile
|
<SelectFile
|
||||||
size="xs"
|
size="xs"
|
||||||
noun="Plugin"
|
noun="Plugin"
|
||||||
@@ -122,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value="bundled" className="pb-0">
|
<TabContent value="bundled" className="pb-0 px-6">
|
||||||
<BundledPlugins plugins={bundledPlugins} />
|
<BundledPlugins plugins={bundledPlugins} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -341,9 +332,9 @@ function PluginSearch() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
|
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
|
||||||
return plugins.length === 0 ? (
|
return plugins.length === 0 ? (
|
||||||
<div className="pb-4">
|
<div className={classNames(className, 'pb-4')}>
|
||||||
<EmptyStateText className="text-center">
|
<EmptyStateText className="text-center">
|
||||||
Plugins extend the functionality of Yaak.
|
Plugins extend the functionality of Yaak.
|
||||||
<br />
|
<br />
|
||||||
@@ -351,7 +342,7 @@ function InstalledPlugins({ plugins }: { plugins: Plugin[] }) {
|
|||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table scrollable>
|
<Table scrollable className={className}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell className="w-0" />
|
<TableHeaderCell className="w-0" />
|
||||||
|
|||||||
@@ -64,19 +64,27 @@ export function SettingsDropdown() {
|
|||||||
onSelect: () => openUrl('https://yaak.app/button/new'),
|
onSelect: () => openUrl('https://yaak.app/button/new'),
|
||||||
},
|
},
|
||||||
{ type: 'separator', label: `Yaak v${appInfo.version}` },
|
{ type: 'separator', label: `Yaak v${appInfo.version}` },
|
||||||
{
|
|
||||||
label: 'Purchase License',
|
|
||||||
color: 'success',
|
|
||||||
hidden: check.data == null || check.data.status === 'active',
|
|
||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
|
||||||
onSelect: () => openSettings.mutate('license'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Check for Updates',
|
label: 'Check for Updates',
|
||||||
leftSlot: <Icon icon="update" />,
|
leftSlot: <Icon icon="update" />,
|
||||||
hidden: !appInfo.featureUpdater,
|
hidden: !appInfo.featureUpdater,
|
||||||
onSelect: () => checkForUpdates.mutate(),
|
onSelect: () => checkForUpdates.mutate(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Purchase License',
|
||||||
|
color: 'success',
|
||||||
|
hidden: check.data == null || check.data.status === 'active',
|
||||||
|
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||||
|
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||||
|
onSelect: () => openUrl('https://yaak.app/pricing'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Install CLI',
|
||||||
|
hidden: appInfo.cliVersion != null,
|
||||||
|
leftSlot: <Icon icon="square_terminal" />,
|
||||||
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
|
onSelect: () => openUrl('https://yaak.app/docs/cli'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Feedback',
|
label: 'Feedback',
|
||||||
leftSlot: <Icon icon="chat" />,
|
leftSlot: <Icon icon="chat" />,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useMemo } from 'react';
|
|||||||
import { Overlay } from '../Overlay';
|
import { Overlay } from '../Overlay';
|
||||||
import { Heading } from './Heading';
|
import { Heading } from './Heading';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
import { DialogSize } from '@yaakapp-internal/plugins';
|
import type { DialogSize } from '@yaakapp-internal/plugins';
|
||||||
|
|
||||||
export interface DialogProps {
|
export interface DialogProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user