Compare commits

..

28 Commits

Author SHA1 Message Date
Gregory Schier 67cbb06bb9 Use native TLS when certificate validation is disabled for legacy server compatibility
When "Validate TLS certificates" is disabled, use the OS native TLS stack
(Secure Transport/SChannel/OpenSSL) instead of rustls. This adds support for
TLS 1.0+ connections to legacy servers like IBM WebSphere, which rustls cannot
handle since it only implements TLS 1.2+.

Ref: https://yaak.app/feedback/posts/tls-handshake-eof-when-connecting-to-private-ibm-websphere-endpoint-works-when-s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:33:47 -07:00
Gregory Schier c8ba35e268 Gracefully handle plugin init failures (#424)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:55:46 -07:00
Gregory Schier 8a330ad1ec Auto-detect WSL and resolve Windows data directory for CLI
When running the CLI in WSL, dirs::data_dir() returns the Linux path
instead of the Windows host path where the Yaak desktop app stores data.
This detects WSL via /proc/version, resolves %APPDATA% through cmd.exe,
and converts it to a WSL mount path using wslpath.

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:56:19 -08:00
dependabot[bot] 67a3dd15ac Bump @hono/node-server from 1.19.9 to 1.19.10 (#417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 13:24:21 -08:00
dependabot[bot] 543325613b Bump hono from 4.11.10 to 4.12.4 (#416)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 13:24:04 -08:00
Gregory Schier 88f5f0e045 Add redirect drop metadata and warning UI (#418) 2026-03-05 06:14:11 -08:00
Gregory Schier 615f3134d2 Fix plugin settings layout 2026-03-04 09:21:15 -08:00
Gregory Schier 0c7051d59c Better external OAuth callback format 2026-03-04 09:10:49 -08:00
Gregory Schier 30f006401a CLI plugin host: handle send/render HTTP requests (#415)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:41:53 -08:00
Gregory Schier 3c12074db6 Mention CLI in main dropdown 2026-03-03 06:50:25 -08:00
Gregory Schier 851f12f149 app: detect CLI availability and add command palette copy action 2026-03-02 14:55:04 -08:00
Gregory Schier cc0d31fdbb cli: use update.yaak.app /cli/check for version checks 2026-03-02 14:52:23 -08:00
Gregory Schier bab4fe899b Upgrade Yaak CLI 2026-03-02 08:01:02 -08:00
Gregory Schier 0b250ff5b5 Fix lint 2026-03-02 07:52:08 -08:00
Gregory Schier fbf0473b20 Fix plugin dev rebuild signaling and reload toast timing 2026-03-02 07:02:28 -08:00
Gregory Schier 876b7ef454 Add top-level generate and publish CLI aliases 2026-03-02 06:28:04 -08:00
Gregory Schier 96e8572758 Add CLI update check and API client kind identity 2026-03-02 06:21:00 -08:00
Gregory Schier f302dc39a2 Move local plugin install command into plugins_ext 2026-03-01 16:42:13 -08:00
Gregory Schier 2ca51125a4 Improve plugin source modeling and runtime dedup (#414) 2026-03-01 16:30:43 -08:00
Gregory Schier 2d99e26f19 plugin-events: route model/find requests through shared handler (#409) 2026-02-28 14:16:32 -08:00
Gregory Schier da1e04d99e Fix Copy as gRPCurl with template-tag payloads (#413) 2026-02-28 07:39:44 -08:00
105 changed files with 4413 additions and 1294 deletions
@@ -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
@@ -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 -1
View File
@@ -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
+1
View File
@@ -1 +1,2 @@
- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging. - 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
Generated
+209 -35
View File
@@ -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",
@@ -10275,6 +10447,7 @@ dependencies = [
"hyper-util", "hyper-util",
"log 0.4.29", "log 0.4.29",
"mime_guess", "mime_guess",
"native-tls",
"regex 1.11.1", "regex 1.11.1",
"reqwest", "reqwest",
"serde", "serde",
@@ -10287,6 +10460,7 @@ dependencies = [
"urlencoding", "urlencoding",
"yaak-common", "yaak-common",
"yaak-models", "yaak-models",
"yaak-templates",
"yaak-tls", "yaak-tls",
"zstd", "zstd",
] ]
+3 -2
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json",
"linter": { "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"
] ]
} }
} }
+3
View File
@@ -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 }
+46 -6
View File
@@ -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,
}
@@ -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()
})?; })?;
+13 -7
View File
@@ -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
View File
@@ -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;
+165 -10
View File
@@ -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,
+44 -10
View File
@@ -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)
}
+67 -9
View File
@@ -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<_>>();
+5 -10
View File
@@ -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(())
} }
+63 -47
View File
@@ -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,63 +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 vendored_plugin_dir = data_dir.join("vendored-plugins");
let installed_plugin_dir = 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(&data_dir)
.expect("Failed to prepare embedded plugin runtime")
});
match PluginManager::new(
vendored_plugin_dir,
installed_plugin_dir,
node_bin_path,
plugin_runtime_main,
&query_manager,
&PluginContext::new_empty(),
false,
)
.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}");
}
} }
} }
+237 -55
View File
@@ -5,14 +5,17 @@ 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 std::path::PathBuf;
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 {
@@ -28,74 +31,253 @@ async fn main() {
let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" }; let app_id = if cfg!(debug_assertions) { "app.yaak.desktop.dev" } else { "app.yaak.desktop" };
let data_dir = data_dir.unwrap_or_else(|| { let data_dir = data_dir.unwrap_or_else(|| resolve_data_dir(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)
}
fn resolve_data_dir(app_id: &str) -> PathBuf {
if let Some(dir) = wsl_data_dir(app_id) {
return dir;
}
dirs::data_dir().expect("Could not determine data directory").join(app_id)
}
/// Detect WSL and resolve the Windows AppData\Roaming path for the Yaak data directory.
fn wsl_data_dir(app_id: &str) -> Option<PathBuf> {
if !cfg!(target_os = "linux") {
return None;
}
let proc_version = std::fs::read_to_string("/proc/version").ok()?;
let is_wsl = proc_version.to_lowercase().contains("microsoft");
if !is_wsl {
return None;
}
// We're in WSL, so try to resolve the Yaak app's data directory in Windows
// Get the Windows %APPDATA% path via cmd.exe
let appdata_output =
std::process::Command::new("cmd.exe").args(["/C", "echo", "%APPDATA%"]).output().ok()?;
let win_path = String::from_utf8(appdata_output.stdout).ok()?.trim().to_string();
if win_path.is_empty() || win_path == "%APPDATA%" {
return None;
}
// Convert Windows path to WSL path using wslpath (handles custom mount points)
let wslpath_output = std::process::Command::new("wslpath").arg(&win_path).output().ok()?;
let wsl_appdata = String::from_utf8(wslpath_output.stdout).ok()?.trim().to_string();
if wsl_appdata.is_empty() {
return None;
}
let wsl_path = PathBuf::from(wsl_appdata).join(app_id);
if wsl_path.exists() { Some(wsl_path) } else { None }
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -17,6 +17,14 @@ pub fn warning(message: &str) {
} }
} }
pub fn warning_stderr(message: &str) {
if io::stderr().is_terminal() {
eprintln!("{:<8} {}", style("WARNING").yellow().bold(), style(message).yellow());
} else {
eprintln!("WARNING {message}");
}
}
pub fn success(message: &str) { 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());
+1
View File
@@ -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;
@@ -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
View File
@@ -0,0 +1,226 @@
use crate::ui;
use crate::version;
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use yaak_api::{ApiClientKind, yaak_api_client};
const CACHE_FILE_NAME: &str = "cli-version-check.json";
const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60;
const REQUEST_TIMEOUT: Duration = Duration::from_millis(800);
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
struct VersionCheckResponse {
outdated: bool,
latest_version: Option<String>,
upgrade_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
struct CacheRecord {
checked_at_epoch_secs: u64,
response: VersionCheckResponse,
last_warned_at_epoch_secs: Option<u64>,
last_warned_version: Option<String>,
}
impl Default for CacheRecord {
fn default() -> Self {
Self {
checked_at_epoch_secs: 0,
response: VersionCheckResponse::default(),
last_warned_at_epoch_secs: None,
last_warned_version: None,
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct VersionCheckRequest<'a> {
current_version: &'a str,
channel: String,
install_source: String,
platform: &'a str,
arch: &'a str,
}
pub async fn maybe_check_for_updates() {
if should_skip_check() {
return;
}
let now = unix_epoch_secs();
let cache_path = cache_path();
let cached = read_cache(&cache_path);
if let Some(cache) = cached.as_ref().filter(|c| !is_expired(c.checked_at_epoch_secs, now)) {
let mut record = cache.clone();
maybe_warn_outdated(&mut record, now);
write_cache(&cache_path, &record);
return;
}
let fresh = fetch_version_check().await;
match fresh {
Some(response) => {
let mut record = CacheRecord {
checked_at_epoch_secs: now,
response: response.clone(),
last_warned_at_epoch_secs: cached
.as_ref()
.and_then(|c| c.last_warned_at_epoch_secs),
last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),
};
maybe_warn_outdated(&mut record, now);
write_cache(&cache_path, &record);
}
None => {
let fallback = cached.as_ref().map(|cache| cache.response.clone()).unwrap_or_default();
let mut record = CacheRecord {
checked_at_epoch_secs: now,
response: fallback,
last_warned_at_epoch_secs: cached
.as_ref()
.and_then(|c| c.last_warned_at_epoch_secs),
last_warned_version: cached.as_ref().and_then(|c| c.last_warned_version.clone()),
};
maybe_warn_outdated(&mut record, now);
write_cache(&cache_path, &record);
}
}
}
fn should_skip_check() -> bool {
if std::env::var("YAAK_CLI_NO_UPDATE_CHECK")
.is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
{
return true;
}
if std::env::var("CI").is_ok() {
return true;
}
!std::io::stdout().is_terminal()
}
async fn fetch_version_check() -> Option<VersionCheckResponse> {
let api_url = format!("{}/cli/check", update_base_url());
let current_version = version::cli_version();
let payload = VersionCheckRequest {
current_version,
channel: release_channel(current_version),
install_source: install_source(),
platform: std::env::consts::OS,
arch: std::env::consts::ARCH,
};
let client = yaak_api_client(ApiClientKind::Cli, current_version).ok()?;
let request = client.post(api_url).json(&payload);
let response = tokio::time::timeout(REQUEST_TIMEOUT, request.send()).await.ok()?.ok()?;
if !response.status().is_success() {
return None;
}
tokio::time::timeout(REQUEST_TIMEOUT, response.json::<VersionCheckResponse>()).await.ok()?.ok()
}
fn release_channel(version: &str) -> String {
version
.split_once('-')
.and_then(|(_, suffix)| suffix.split('.').next())
.unwrap_or("stable")
.to_string()
}
fn install_source() -> String {
std::env::var("YAAK_CLI_INSTALL_SOURCE")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "source".to_string())
}
fn update_base_url() -> &'static str {
match std::env::var("ENVIRONMENT").ok().as_deref() {
Some("development") => "http://localhost:9444",
_ => "https://update.yaak.app",
}
}
fn maybe_warn_outdated(record: &mut CacheRecord, now: u64) {
if !record.response.outdated {
return;
}
let latest =
record.response.latest_version.clone().unwrap_or_else(|| "a newer release".to_string());
let warn_suppressed = record.last_warned_version.as_deref() == Some(latest.as_str())
&& record.last_warned_at_epoch_secs.is_some_and(|t| !is_expired(t, now));
if warn_suppressed {
return;
}
let hint = record.response.upgrade_hint.clone().unwrap_or_else(default_upgrade_hint);
ui::warning_stderr(&format!("A newer Yaak CLI version is available ({latest}). {hint}"));
record.last_warned_version = Some(latest);
record.last_warned_at_epoch_secs = Some(now);
}
fn default_upgrade_hint() -> String {
if install_source() == "npm" {
let channel = release_channel(version::cli_version());
if channel == "stable" {
return "Run `npm install -g @yaakapp/cli@latest` to update.".to_string();
}
return format!("Run `npm install -g @yaakapp/cli@{channel}` to update.");
}
"Update your Yaak CLI installation to the latest release.".to_string()
}
fn cache_path() -> PathBuf {
std::env::temp_dir().join("yaak-cli").join(format!("{}-{CACHE_FILE_NAME}", environment_name()))
}
fn environment_name() -> &'static str {
match std::env::var("ENVIRONMENT").ok().as_deref() {
Some("staging") => "staging",
Some("development") => "development",
_ => "production",
}
}
fn read_cache(path: &Path) -> Option<CacheRecord> {
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str::<CacheRecord>(&contents).ok()
}
fn write_cache(path: &Path, record: &CacheRecord) {
let Some(parent) = path.parent() else {
return;
};
if fs::create_dir_all(parent).is_err() {
return;
}
let Ok(json) = serde_json::to_string(record) else {
return;
};
let _ = fs::write(path, json);
}
fn is_expired(checked_at_epoch_secs: u64, now: u64) -> bool {
now.saturating_sub(checked_at_epoch_secs) >= CHECK_INTERVAL_SECS
}
fn unix_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_secs()
}
@@ -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\""));
} }
+1
View File
@@ -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"
+41 -30
View File
@@ -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,41 +1378,17 @@ 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>,
window: WebviewWindow<R>, window: WebviewWindow<R>,
plugin_manager: State<'_, PluginManager>, plugin_manager: State<'_, PluginManager>,
) -> YaakResult<()> { ) -> YaakResult<Vec<(String, String)>> {
let plugins = app_handle.db().list_plugins()?; let plugins = app_handle.db().list_plugins()?;
let plugin_context = let plugin_context =
PluginContext::new(Some(window.label().to_string()), window.workspace_id()); PluginContext::new(Some(window.label().to_string()), window.workspace_id());
let _errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await; let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
// Note: errors are returned but we don't show toasts here since this is a manual reload Ok(errors)
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -1639,6 +1648,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 +1662,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 +1730,8 @@ pub fn run() {
git_ext::cmd_git_rm_remote, git_ext::cmd_git_rm_remote,
// //
// Plugin commands // Plugin commands
plugins_ext::cmd_plugin_init_errors,
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,
+18 -8
View File
@@ -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());
+2 -2
View File
@@ -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),
+4 -69
View File
@@ -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)?;
+38 -8
View File
@@ -21,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,
@@ -72,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?;
@@ -137,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?)
} }
@@ -149,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(
@@ -164,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,
@@ -175,12 +198,19 @@ pub async fn cmd_plugins_uninstall<R: Runtime>(
Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?) Ok(delete_and_uninstall(plugin_manager, &query_manager, &plugin_context, plugin_id).await?)
} }
#[command]
pub async fn cmd_plugin_init_errors(
plugin_manager: State<'_, PluginManager>,
) -> Result<Vec<(String, String)>> {
Ok(plugin_manager.take_init_errors().await)
}
#[command] #[command]
pub async fn cmd_plugins_updates<R: Runtime>( 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?)
} }
@@ -190,7 +220,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)
@@ -283,7 +313,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
dev_mode, dev_mode,
) )
.await .await
.expect("Failed to initialize plugins"); .expect("Failed to start plugin runtime");
app_handle_clone.manage(manager); app_handle_clone.manage(manager);
}); });
+2 -62
View File
@@ -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() })
}
+4 -3
View File
@@ -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()
+5 -2
View File
@@ -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()),
+4 -4
View File
@@ -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?;
+25 -3
View File
@@ -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;
+12 -2
View File
@@ -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());
+27 -1
View File
@@ -1,4 +1,6 @@
use std::ffi::OsStr; use std::ffi::{OsStr, OsString};
use std::io::{self, ErrorKind};
use std::process::Stdio;
#[cfg(target_os = "windows")] #[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))
}
+7
View File
@@ -21,3 +21,10 @@ pub fn get_str_map<'a>(v: &'a BTreeMap<String, Value>, key: &str) -> &'a str {
Some(v) => v.as_str().unwrap_or_default(), 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),
}
}
+2 -15
View File
@@ -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)
} }
+3 -1
View File
@@ -18,8 +18,9 @@ zstd = "0.13"
hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] } hyper-util = { version = "0.1.17", default-features = false, features = ["client-legacy"] }
log = { workspace = true } log = { workspace = true }
mime_guess = "2.0.5" mime_guess = "2.0.5"
native-tls = "0.2"
regex = "1.11.1" regex = "1.11.1"
reqwest = { workspace = true, features = ["rustls-tls-manual-roots-no-provider", "socks", "http2", "stream"] } reqwest = { workspace = true, features = ["rustls-tls-manual-roots-no-provider", "native-tls", "socks", "http2", "stream"] }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@@ -29,4 +30,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 }
+61 -4
View File
@@ -6,6 +6,56 @@ use std::sync::Arc;
use yaak_models::models::DnsOverride; use yaak_models::models::DnsOverride;
use yaak_tls::{ClientCertificateConfig, get_tls_config}; use yaak_tls::{ClientCertificateConfig, get_tls_config};
/// Build a native-tls connector for maximum compatibility when certificate
/// validation is disabled. Unlike rustls, native-tls uses the OS TLS stack
/// (Secure Transport on macOS, SChannel on Windows, OpenSSL on Linux) which
/// supports TLS 1.0+ for legacy servers.
fn build_native_tls_connector(
client_cert: Option<ClientCertificateConfig>,
) -> Result<native_tls::TlsConnector> {
let mut builder = native_tls::TlsConnector::builder();
builder.danger_accept_invalid_certs(true);
builder.danger_accept_invalid_hostnames(true);
builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10));
if let Some(identity) = build_native_tls_identity(client_cert)? {
builder.identity(identity);
}
Ok(builder.build()?)
}
fn build_native_tls_identity(
client_cert: Option<ClientCertificateConfig>,
) -> Result<Option<native_tls::Identity>> {
let config = match client_cert {
None => return Ok(None),
Some(c) => c,
};
// Try PFX/PKCS12 first
if let Some(pfx_path) = &config.pfx_file {
if !pfx_path.is_empty() {
let pfx_data = std::fs::read(pfx_path)?;
let password = config.passphrase.as_deref().unwrap_or("");
let identity = native_tls::Identity::from_pkcs12(&pfx_data, password)?;
return Ok(Some(identity));
}
}
// Try CRT + KEY files
if let (Some(crt_path), Some(key_path)) = (&config.crt_file, &config.key_file) {
if !crt_path.is_empty() && !key_path.is_empty() {
let crt_data = std::fs::read(crt_path)?;
let key_data = std::fs::read(key_path)?;
let identity = native_tls::Identity::from_pkcs8(&crt_data, &key_data)?;
return Ok(Some(identity));
}
}
Ok(None)
}
#[derive(Clone)] #[derive(Clone)]
pub struct HttpConnectionProxySettingAuth { pub struct HttpConnectionProxySettingAuth {
pub user: String, pub user: String,
@@ -51,10 +101,17 @@ impl HttpConnectionOptions {
// This is needed so we can emit DNS timing events for each request // This is needed so we can emit DNS timing events for each request
.pool_max_idle_per_host(0); .pool_max_idle_per_host(0);
// Configure TLS with optional client certificate // Configure TLS
let config = if self.validate_certificates {
get_tls_config(self.validate_certificates, true, self.client_certificate.clone())?; // Use rustls with platform certificate verification (TLS 1.2+ only)
client = client.use_preconfigured_tls(config); let config = get_tls_config(true, true, self.client_certificate.clone())?;
client = client.use_preconfigured_tls(config);
} else {
// Use native TLS for maximum compatibility (supports TLS 1.0+)
let connector =
build_native_tls_connector(self.client_certificate.clone())?;
client = client.use_preconfigured_tls(connector);
}
// Configure DNS resolver - keep a reference to configure per-request // Configure DNS resolver - keep a reference to configure per-request
let resolver = LocalhostResolver::new(self.dns_overrides.clone()); let resolver = LocalhostResolver::new(self.dns_overrides.clone());
+6
View File
@@ -9,6 +9,12 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
TlsError(#[from] yaak_tls::error::Error), TlsError(#[from] yaak_tls::error::Error),
#[error("Native TLS error: {0}")]
NativeTlsError(#[from] native_tls::Error),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Request failed with {0:?}")] #[error("Request failed with {0:?}")]
RequestError(String), RequestError(String),
+29 -3
View File
@@ -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,
+57 -18
View File
@@ -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 &current_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, &current_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, &current_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
+120 -12
View File
@@ -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();
+4 -2
View File
@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. * 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);
+47
View File
@@ -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(),
}) })
} }
} }
+5 -1
View File
@@ -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)
} }
} }
+31 -7
View File
@@ -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>, };
+4
View File
@@ -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 });
}
+2 -2
View File
@@ -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) => {
+2 -1
View File
@@ -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,
+101 -19
View File
@@ -21,9 +21,10 @@ 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::env;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -33,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;
@@ -49,6 +50,8 @@ pub struct PluginManager {
vendored_plugin_dir: PathBuf, vendored_plugin_dir: PathBuf,
pub(crate) installed_plugin_dir: PathBuf, pub(crate) installed_plugin_dir: PathBuf,
dev_mode: bool, dev_mode: bool,
/// Errors from plugin initialization, retrievable once via `take_init_errors`.
init_errors: Arc<Mutex<Vec<(String, String)>>>,
} }
/// Callback for plugin initialization events (e.g., toast notifications) /// Callback for plugin initialization events (e.g., toast notifications)
@@ -92,6 +95,7 @@ impl PluginManager {
vendored_plugin_dir, vendored_plugin_dir,
installed_plugin_dir, installed_plugin_dir,
dev_mode, dev_mode,
init_errors: Default::default(),
}; };
// Forward events to subscribers // Forward events to subscribers
@@ -162,13 +166,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,
@@ -181,17 +186,21 @@ impl PluginManager {
let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await; let init_errors = plugin_manager.initialize_all_plugins(plugins, plugin_context).await;
if !init_errors.is_empty() { if !init_errors.is_empty() {
let joined = init_errors for (dir, err) in &init_errors {
.into_iter() warn!("Plugin failed to initialize: {dir}: {err}");
.map(|(dir, err)| format!("{dir}: {err}")) }
.collect::<Vec<_>>() *plugin_manager.init_errors.lock().await = init_errors;
.join("; ");
return Err(PluginErr(format!("Failed to initialize plugin(s): {joined}")));
} }
Ok(plugin_manager) Ok(plugin_manager)
} }
/// Take any initialization errors, clearing them from the manager.
/// Returns a list of `(plugin_directory, error_message)` pairs.
pub async fn take_init_errors(&self) -> Vec<(String, String)> {
std::mem::take(&mut *self.init_errors.lock().await)
}
/// Get the vendored plugin directory path (resolves dev mode path if applicable) /// Get the vendored plugin directory path (resolves dev mode path if applicable)
pub fn get_plugins_dir(&self) -> PathBuf { pub fn get_plugins_dir(&self) -> PathBuf {
if self.dev_mode { if self.dev_mode {
@@ -213,6 +222,57 @@ impl PluginManager {
read_plugins_dir(&plugins_dir).await 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<()> {
let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?; let plugin = self.get_plugin_by_dir(dir).await.ok_or(PluginNotFoundErr(dir.to_string()))?;
self.remove_plugin(plugin_context, &plugin).await self.remove_plugin(plugin_context, &plugin).await
@@ -287,7 +347,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,
@@ -297,15 +358,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()));
@@ -1063,6 +1127,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![];
+235 -6
View File
@@ -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
View File
@@ -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;
@@ -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);
}
}
+255 -24
View File
@@ -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(),
}); });
+59 -1
View File
@@ -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(
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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" },
}); });
}; };
+47 -348
View File
@@ -73,7 +73,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.3.13", "@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6", "@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.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",
@@ -1480,9 +1480,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.9", "version": "1.19.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
@@ -2584,6 +2584,16 @@
"ebnf": "^1.9.1" "ebnf": "^1.9.1"
} }
}, },
"node_modules/@shopify/lang-jsonc": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@shopify/lang-jsonc/-/lang-jsonc-1.0.1.tgz",
"integrity": "sha512-KrBrRFhvr1qJiZBODAtqbL1u1e67UR3plBN79Z8nd5TQAAzpx66jS4zs7Ss9M22ygGrpWFhyhSoNVlp5VCYktQ==",
"license": "MIT",
"dependencies": {
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.7"
}
},
"node_modules/@svgr/babel-plugin-add-jsx-attribute": { "node_modules/@svgr/babel-plugin-add-jsx-attribute": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz",
@@ -4303,9 +4313,9 @@
"link": true "link": true
}, },
"node_modules/@yaakapp/cli": { "node_modules/@yaakapp/cli": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli/-/cli-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli/-/cli-0.5.1.tgz",
"integrity": "sha512-8xnu2oFWlgV+xeIAHMuEgsqX6Sxq4UYrSH2WbafwDLbSep6fxpO74tiBH7xp4wakt/7Bcy9a2Q5R9nkAc1ZUdA==", "integrity": "sha512-kAhX9SvjAiEsg2xwCuyuEOJRyEIg7jEGzGFCGzWy9I9Ew2hD0huIDGy9l4IMJUR84gh1V/62dxeiZREptWpIFg==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"bin": { "bin": {
@@ -4313,18 +4323,18 @@
"yaakcli": "bin/cli.js" "yaakcli": "bin/cli.js"
}, },
"optionalDependencies": { "optionalDependencies": {
"@yaakapp/cli-darwin-arm64": "0.4.0", "@yaakapp/cli-darwin-arm64": "0.5.1",
"@yaakapp/cli-darwin-x64": "0.4.0", "@yaakapp/cli-darwin-x64": "0.5.1",
"@yaakapp/cli-linux-arm64": "0.4.0", "@yaakapp/cli-linux-arm64": "0.5.1",
"@yaakapp/cli-linux-x64": "0.4.0", "@yaakapp/cli-linux-x64": "0.5.1",
"@yaakapp/cli-win32-arm64": "0.4.0", "@yaakapp/cli-win32-arm64": "0.5.1",
"@yaakapp/cli-win32-x64": "0.4.0" "@yaakapp/cli-win32-x64": "0.5.1"
} }
}, },
"node_modules/@yaakapp/cli-darwin-arm64": { "node_modules/@yaakapp/cli-darwin-arm64": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-arm64/-/cli-darwin-arm64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-arm64/-/cli-darwin-arm64-0.5.1.tgz",
"integrity": "sha512-bl8+VQNPMabXNGQCa7u6w0JGe3CmzYZPsGE8Q+5wGSxa3trGf1bmq/fMW5JXrMi1P7Laepnyad0TGGP/2C8uwQ==", "integrity": "sha512-08FJ35vYGUXVC5r4/kLchEUI8YJN0iiB6KPZ9NuNHD0QmEWAN1451roUrXzz+dGFUw9tLb7HDGuZ6c4YL4Va7A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4335,9 +4345,9 @@
] ]
}, },
"node_modules/@yaakapp/cli-darwin-x64": { "node_modules/@yaakapp/cli-darwin-x64": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-x64/-/cli-darwin-x64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli-darwin-x64/-/cli-darwin-x64-0.5.1.tgz",
"integrity": "sha512-R+ETXNBWvmA3W88ZoTk/JtG/PZaUb85y3SwBgMbwcgdhBVwNS/g+DbCspcTFI5zs8Txsf5VuiFU+dW9M9olZ6A==", "integrity": "sha512-UqN9Bn2Z5Ns9ATWWQyvhlCJ3qdk1rM5b9CbGzV61F/LkfcPbvBuTFGAWprQVTun7iy7PjI35R6Cfj126+Z/ehA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4348,9 +4358,9 @@
] ]
}, },
"node_modules/@yaakapp/cli-linux-arm64": { "node_modules/@yaakapp/cli-linux-arm64": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-arm64/-/cli-linux-arm64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-arm64/-/cli-linux-arm64-0.5.1.tgz",
"integrity": "sha512-Pf7VyQf4r85FsI0qYnnst7URQF8/RxSZZj79cXLai0FnN3fDiypX4CmHx765bJxgfQZlBvqVmvPAaMW/TeiJEQ==", "integrity": "sha512-YdQXNNPLSzkmwWEqnv2V8C8Bl9atQFQYI3FtPTYa2Ljp54omgMxuikn0gauhsHFMtFg8GOStVEENbBFW0W0Ovg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4361,9 +4371,9 @@
] ]
}, },
"node_modules/@yaakapp/cli-linux-x64": { "node_modules/@yaakapp/cli-linux-x64": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-x64/-/cli-linux-x64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli-linux-x64/-/cli-linux-x64-0.5.1.tgz",
"integrity": "sha512-bYWWfHAIW81A+ydJChjH1Qo3+aihz9gFLh7/9MOa6CJgnC6H3V5cnapmh50Hddt9l5ic02aA1FB8ORQOXxb01A==", "integrity": "sha512-03sVYmD3ksH6lJr6YEwqEQCADoVP5fzq6vBkoCBSrQOj9iInk06DuFHME7IjEa7uKXBaF4WeUms/yQEA03jHBA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4374,9 +4384,9 @@
] ]
}, },
"node_modules/@yaakapp/cli-win32-arm64": { "node_modules/@yaakapp/cli-win32-arm64": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-arm64/-/cli-win32-arm64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-arm64/-/cli-win32-arm64-0.5.1.tgz",
"integrity": "sha512-8X12xkyidyYZ5vtarZGFSYR6HJbUMFUsNxYPNQccnYJIY+soNkjJHOWDjaRvBzCbR8MLT9N04Y5PE/Jv20gXpA==", "integrity": "sha512-bYBe0PpgvjEx/4jvDKOA9/Kg9qzAP1NmUCcPWZueOJItqPdPk2b/1/7pX1eeoh80Qsj7nSmz5PbJaxkygfa0IQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4387,9 +4397,9 @@
] ]
}, },
"node_modules/@yaakapp/cli-win32-x64": { "node_modules/@yaakapp/cli-win32-x64": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-x64/-/cli-win32-x64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@yaakapp/cli-win32-x64/-/cli-win32-x64-0.5.1.tgz",
"integrity": "sha512-wansfrCCycFcFclowQQxfsNLIAyATyqnnbITED5gUfUrBf8NFHrG0sWVCWlXUhHU7YvpmqL7CsdtlMkIGiZCPQ==", "integrity": "sha512-Mx/LelqQ7X8Fz9og3qOauuU8kemqja5kQzVQ0pHDJhb9bbmhXUK4dSBYTLdkW3a/X+ofo3K5Z/PkS7FMyXxwdA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -5330,105 +5340,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cliui/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/cliui/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clone-regexp": { "node_modules/clone-regexp": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz", "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz",
@@ -6047,15 +5958,6 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": { "node_modules/decode-named-character-reference": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -7200,19 +7102,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/focus-trap": { "node_modules/focus-trap": {
"version": "7.7.1", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.7.1.tgz", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.7.1.tgz",
@@ -7305,30 +7194,6 @@
"node": ">=0.4.x" "node": ">=0.4.x"
} }
}, },
"node_modules/format-graphql": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/format-graphql/-/format-graphql-1.5.0.tgz",
"integrity": "sha512-ZZWcjwJ1IMdnW9l3CeYccC/J7skqOB18tY3autO5OUQuGVZpQu6Es3SThRm25SfiMeZO1+UbzIqnGbjAURu/UA==",
"dependencies": {
"graphql": "^15.1.0",
"yargs": "^15.3.1"
},
"bin": {
"format-graphql": "dist/bin/index.js"
},
"engines": {
"node": ">=10.0"
}
},
"node_modules/format-graphql/node_modules/graphql": {
"version": "15.10.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.1.tgz",
"integrity": "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg==",
"license": "MIT",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -8008,9 +7873,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.11.10", "version": "4.12.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.10.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
"integrity": "sha512-kyWP5PAiMooEvGrA9jcD3IXF7ATu8+o7B3KCbPXid5se52NPqnOpM/r9qeW2heMnOekF4kqR1fXJqCYeCLKrZg==", "integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
@@ -9271,18 +9136,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -11605,33 +11458,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-map": { "node_modules/p-map": {
"version": "7.0.4", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
@@ -11658,15 +11484,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -11760,15 +11577,6 @@
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -12883,12 +12691,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resize-observer-polyfill": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -13356,12 +13158,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -15601,12 +15397,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.19", "version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
@@ -15822,12 +15612,6 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -15850,91 +15634,6 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs-parser/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yauzl": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
@@ -16064,9 +15763,9 @@
"version": "0.2.1", "version": "0.2.1",
"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": {
@@ -16323,6 +16022,7 @@
"@replit/codemirror-emacs": "^6.1.0", "@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0", "@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2", "@replit/codemirror-vscode-keymap": "^6.0.2",
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13", "@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",
@@ -16342,7 +16042,6 @@
"deep-equal": "^2.2.3", "deep-equal": "^2.2.3",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"focus-trap-react": "^11.0.4", "focus-trap-react": "^11.0.4",
"format-graphql": "^1.5.0",
"fuzzbunny": "^1.0.1", "fuzzbunny": "^1.0.1",
"hexy": "^0.3.5", "hexy": "^0.3.5",
"history": "^5.3.0", "history": "^5.3.0",
+1 -1
View File
@@ -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",
+31 -7
View File
@@ -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();
} }
}); });
+8 -4
View File
@@ -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$/);
+4 -1
View File
@@ -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');
}; };
+2 -2
View File
@@ -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": {
+12 -14
View File
@@ -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;
+8 -5
View File
@@ -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`;
} }
+8 -2
View File
@@ -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());
@@ -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": {
@@ -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}`,
+2 -2
View File
@@ -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({}),
+1 -1
View File
@@ -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;
+4 -9
View File
@@ -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
+123 -16
View File
@@ -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>&bull;</span> <HttpStatusTag showReason response={activeResponse} />
<HttpResponseDurationTag response={activeResponse} /> <span>&bull;</span>
<span>&bull;</span> <HttpResponseDurationTag response={activeResponse} />
<SizeTag <span>&bull;</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,
+28 -4
View File
@@ -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
View 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}
/>
);
}
+3 -3
View File
@@ -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>
+11 -20
View File
@@ -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" />
+15 -7
View File
@@ -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" />,
+1 -1
View File
@@ -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;
+3 -3
View File
@@ -896,9 +896,9 @@ function MenuItem({
}; };
const rightSlot = item.submenu ? ( const rightSlot = item.submenu ? (
<Icon icon="chevron_right" color='secondary' /> <Icon icon="chevron_right" color="secondary" />
) : ( ) : (
(item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />) (item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
); );
return ( return (
@@ -937,7 +937,7 @@ function MenuItem({
)} )}
{...props} {...props}
> >
<div className={classNames('truncate')}>{item.label}</div> <div className={classNames('truncate min-w-[5rem]')}>{item.label}</div>
</Button> </Button>
); );
} }
+7 -2
View File
@@ -78,6 +78,7 @@ export interface EditorProps {
hideGutter?: boolean; hideGutter?: boolean;
id?: string; id?: string;
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null; language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null; graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void; onBlur?: () => void;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -124,6 +125,7 @@ function EditorInner({
hideGutter, hideGutter,
graphQLSchema, graphQLSchema,
language, language,
lintExtension,
onBlur, onBlur,
onChange, onChange,
onFocus, onFocus,
@@ -325,13 +327,14 @@ function EditorInner({
); );
// Update the language extension when the language changes // Update the language extension when the language changes
// biome-ignore lint/correctness/useExhaustiveDependencies: none // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally limited deps
useEffect(() => { useEffect(() => {
if (cm.current === null) return; if (cm.current === null) return;
const { view, languageCompartment } = cm.current; const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({ const ext = getLanguageExtension({
useTemplating, useTemplating,
language, language,
lintExtension,
hideGutter, hideGutter,
environmentVariables, environmentVariables,
autocomplete, autocomplete,
@@ -344,6 +347,7 @@ function EditorInner({
view.dispatch({ effects: languageCompartment.reconfigure(ext) }); view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [ }, [
language, language,
lintExtension,
autocomplete, autocomplete,
environmentVariables, environmentVariables,
onClickFunction, onClickFunction,
@@ -357,7 +361,7 @@ function EditorInner({
]); ]);
// Initialize the editor when ref mounts // Initialize the editor when ref mounts
// biome-ignore lint/correctness/useExhaustiveDependencies: Only reinitialize when necessary // biome-ignore lint/correctness/useExhaustiveDependencies: only reinitialize when necessary
const initEditorRef = useCallback( const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) { function initEditorRef(container: HTMLDivElement | null) {
if (container === null) { if (container === null) {
@@ -371,6 +375,7 @@ function EditorInner({
const langExt = getLanguageExtension({ const langExt = getLanguageExtension({
useTemplating, useTemplating,
language, language,
lintExtension,
completionOptions, completionOptions,
autocomplete, autocomplete,
environmentVariables, environmentVariables,
+11 -6
View File
@@ -8,7 +8,6 @@ import { history, historyKeymap } from '@codemirror/commands';
import { go } from '@codemirror/lang-go'; import { go } from '@codemirror/lang-go';
import { java } from '@codemirror/lang-java'; import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php'; import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python'; import { python } from '@codemirror/lang-python';
@@ -34,7 +33,6 @@ import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { shell } from '@codemirror/legacy-modes/mode/shell'; import { shell } from '@codemirror/legacy-modes/mode/shell';
import { swift } from '@codemirror/legacy-modes/mode/swift'; import { swift } from '@codemirror/legacy-modes/mode/swift';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint'; import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search'; import { search, searchKeymap } from '@codemirror/search';
import type { Extension } from '@codemirror/state'; import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
@@ -50,6 +48,7 @@ import {
rectangularSelection, rectangularSelection,
} from '@codemirror/view'; } from '@codemirror/view';
import { tags as t } from '@lezer/highlight'; import { tags as t } from '@lezer/highlight';
import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc';
import { graphql } from 'cm6-graphql'; import { graphql } from 'cm6-graphql';
import type { GraphQLSchema } from 'graphql'; import type { GraphQLSchema } from 'graphql';
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId'; import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
@@ -61,13 +60,13 @@ import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms';
import type { EditorProps } from './Editor'; import type { EditorProps } from './Editor';
import { jsonParseLinter } from './json-lint'; import { jsonParseLinter } from './json-lint';
import { pairs } from './pairs/extension'; import { pairs } from './pairs/extension';
import { searchMatchCount } from './searchMatchCount';
import { text } from './text/extension'; import { text } from './text/extension';
import { timeline } from './timeline/extension'; import { timeline } from './timeline/extension';
import type { TwigCompletionOption } from './twig/completion'; import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension'; import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters'; import { pathParametersPlugin } from './twig/pathParameters';
import { url } from './url/extension'; import { url } from './url/extension';
import { searchMatchCount } from './searchMatchCount';
export const syntaxHighlightStyle = HighlightStyle.define([ export const syntaxHighlightStyle = HighlightStyle.define([
{ {
@@ -107,7 +106,7 @@ const syntaxExtensions: Record<
null | (() => LanguageSupport) null | (() => LanguageSupport)
> = { > = {
graphql: null, graphql: null,
json: json, json: jsonc,
javascript: javascript, javascript: javascript,
// HTML as XML because HTML is oddly slow // HTML as XML because HTML is oddly slow
html: xml, html: xml,
@@ -140,6 +139,7 @@ const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript
export function getLanguageExtension({ export function getLanguageExtension({
useTemplating, useTemplating,
language = 'text', language = 'text',
lintExtension,
environmentVariables, environmentVariables,
autocomplete, autocomplete,
hideGutter, hideGutter,
@@ -156,7 +156,7 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void; onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[]; completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null; graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) { } & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter' | 'lintExtension'>) {
const extraExtensions: Extension[] = []; const extraExtensions: Extension[] = [];
if (language === 'url') { if (language === 'url') {
@@ -193,7 +193,12 @@ export function getLanguageExtension({
} }
if (language === 'json') { if (language === 'json') {
extraExtensions.push(linter(jsonParseLinter())); extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
}),
);
if (!hideGutter) { if (!hideGutter) {
extraExtensions.push(lintGutter()); extraExtensions.push(lintGutter());
} }
+10 -2
View File
@@ -4,14 +4,22 @@ import { parse as jsonLintParse } from '@prantlf/jsonlint';
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g; const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
export function jsonParseLinter() { interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
}
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => { return (view: EditorView): Diagnostic[] => {
try { try {
const doc = view.state.doc.toString(); const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template // We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct. // syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length)); const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length));
jsonLintParse(escapedDoc); jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? 'cjson' : 'json',
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// biome-ignore lint/suspicious/noExplicitAny: none // biome-ignore lint/suspicious/noExplicitAny: none
} catch (err: any) { } catch (err: any) {
if (!('location' in err)) { if (!('location' in err)) {
+44 -19
View File
@@ -20,8 +20,15 @@ const hiddenStyles: CSSProperties = {
opacity: 0, opacity: 0,
}; };
type TooltipPosition = 'top' | 'bottom';
interface TooltipOpenState {
styles: CSSProperties;
position: TooltipPosition;
}
export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) { export function Tooltip({ children, className, content, tabIndex, size = 'md' }: TooltipProps) {
const [isOpen, setIsOpen] = useState<CSSProperties>(); const [openState, setOpenState] = useState<TooltipOpenState | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<NodeJS.Timeout>(undefined); const showTimeout = useRef<NodeJS.Timeout>(undefined);
@@ -29,16 +36,25 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
const handleOpenImmediate = () => { const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return; if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current); clearTimeout(showTimeout.current);
setIsOpen(undefined);
const triggerRect = triggerRef.current.getBoundingClientRect(); const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect();
const docRect = document.documentElement.getBoundingClientRect(); const viewportHeight = document.documentElement.clientHeight;
const margin = 8;
const spaceAbove = Math.max(0, triggerRect.top - margin);
const spaceBelow = Math.max(0, viewportHeight - triggerRect.bottom - margin);
const preferBottom = spaceAbove < tooltipRect.height + margin && spaceBelow > spaceAbove;
const position: TooltipPosition = preferBottom ? 'bottom' : 'top';
const styles: CSSProperties = { const styles: CSSProperties = {
bottom: docRect.height - triggerRect.top,
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2), left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
maxHeight: triggerRect.top, maxHeight: position === 'top' ? spaceAbove : spaceBelow,
...(position === 'top'
? { bottom: viewportHeight - triggerRect.top }
: { top: triggerRect.bottom }),
}; };
setIsOpen(styles);
setOpenState({ styles, position });
}; };
const handleOpen = () => { const handleOpen = () => {
@@ -48,16 +64,16 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
const handleClose = () => { const handleClose = () => {
clearTimeout(showTimeout.current); clearTimeout(showTimeout.current);
setIsOpen(undefined); setOpenState(null);
}; };
const handleToggleImmediate = () => { const handleToggleImmediate = () => {
if (isOpen) handleClose(); if (openState) handleClose();
else handleOpenImmediate(); else handleOpenImmediate();
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (isOpen && e.key === 'Escape') { if (openState && e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
handleClose(); handleClose();
@@ -71,10 +87,10 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
<Portal name="tooltip"> <Portal name="tooltip">
<div <div
ref={tooltipRef} ref={tooltipRef}
style={isOpen ?? hiddenStyles} style={openState?.styles ?? hiddenStyles}
id={id.current} id={id.current}
role="tooltip" role="tooltip"
aria-hidden={!isOpen} aria-hidden={openState == null}
onMouseEnter={handleOpenImmediate} onMouseEnter={handleOpenImmediate}
onMouseLeave={handleClose} onMouseLeave={handleClose}
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]" className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
@@ -88,14 +104,17 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
> >
{content} {content}
</div> </div>
<Triangle className="text-border mb-2" /> <Triangle
className="text-border"
position={openState?.position === 'bottom' ? 'top' : 'bottom'}
/>
</div> </div>
</Portal> </Portal>
{/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */} {/** biome-ignore lint/a11y/useSemanticElements: Needs to be usable in other buttons */}
<span <span
ref={triggerRef} ref={triggerRef}
role="button" role="button"
aria-describedby={isOpen ? id.current : undefined} aria-describedby={openState ? id.current : undefined}
tabIndex={tabIndex ?? -1} tabIndex={tabIndex ?? -1}
className={classNames(className, 'flex-grow-0 flex items-center')} className={classNames(className, 'flex-grow-0 flex items-center')}
onClick={handleToggleImmediate} onClick={handleToggleImmediate}
@@ -111,7 +130,9 @@ export function Tooltip({ children, className, content, tabIndex, size = 'md' }:
); );
} }
function Triangle({ className }: { className?: string }) { function Triangle({ className, position }: { className?: string; position: 'top' | 'bottom' }) {
const isBottom = position === 'bottom';
return ( return (
<svg <svg
aria-hidden aria-hidden
@@ -120,15 +141,19 @@ function Triangle({ className }: { className?: string }) {
shapeRendering="crispEdges" shapeRendering="crispEdges"
className={classNames( className={classNames(
className, className,
'absolute z-50 border-t-[2px] border-surface-highlight', 'absolute z-50 left-[calc(50%-0.4rem)] h-[0.5rem] w-[0.8rem]',
'-bottom-[calc(0.5rem-3px)] left-[calc(50%-0.4rem)]', isBottom
'h-[0.5rem] w-[0.8rem]', ? 'border-t-[2px] border-surface-highlight -bottom-[calc(0.5rem-3px)] mb-2'
: 'border-b-[2px] border-surface-highlight -top-[calc(0.5rem-3px)] mt-2',
)} )}
> >
<title>Triangle</title> <title>Triangle</title>
<polygon className="fill-surface-highlight" points="0,0 30,0 15,10" /> <polygon
className="fill-surface-highlight"
points={isBottom ? '0,0 30,0 15,10' : '0,10 30,10 15,0'}
/>
<path <path
d="M0 0 L15 9 L30 0" d={isBottom ? 'M0 0 L15 9 L30 0' : 'M0 10 L15 1 L30 10'}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1" strokeWidth="1"
+1 -2
View File
@@ -1,4 +1,4 @@
import { useCachedNode } from '@dnd-kit/core/dist/hooks/utilities';
import type { GitStatusEntry } from '@yaakapp-internal/git'; import type { GitStatusEntry } from '@yaakapp-internal/git';
import { useGit } from '@yaakapp-internal/git'; import { useGit } from '@yaakapp-internal/git';
import type { import type {
@@ -12,7 +12,6 @@ import type {
import classNames from 'classnames'; import classNames from 'classnames';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { modelToYaml } from '../../lib/diffYaml'; import { modelToYaml } from '../../lib/diffYaml';
import { isSubEnvironment } from '../../lib/model_util';
import { resolvedModelName } from '../../lib/resolvedModelName'; import { resolvedModelName } from '../../lib/resolvedModelName';
import { showErrorToast } from '../../lib/toast'; import { showErrorToast } from '../../lib/toast';
import { Banner } from '../core/Banner'; import { Banner } from '../core/Banner';
+1 -1
View File
@@ -75,7 +75,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const currentBranch = status.data.headRefShorthand; const currentBranch = status.data.headRefShorthand;
const hasChanges = status.data.entries.some((e) => e.status !== 'current'); const hasChanges = status.data.entries.some((e) => e.status !== 'current');
const hasRemotes = (status.data.origins ?? []).length > 0; const _hasRemotes = (status.data.origins ?? []).length > 0;
const { ahead, behind } = status.data; const { ahead, behind } = status.data;
const tryCheckout = (branch: string, force: boolean) => { const tryCheckout = (branch: string, force: boolean) => {
+3 -2
View File
@@ -1,6 +1,5 @@
import type { HttpRequest } from '@yaakapp-internal/models'; import type { HttpRequest } from '@yaakapp-internal/models';
import { formatSdl } from 'format-graphql';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use'; import { useLocalStorage } from 'react-use';
@@ -16,6 +15,7 @@ import { Editor } from '../core/Editor/LazyEditor';
import { FormattedError } from '../core/FormattedError'; import { FormattedError } from '../core/FormattedError';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { Separator } from '../core/Separator'; import { Separator } from '../core/Separator';
import { tryFormatGraphql } from '../../lib/formatters';
import { showGraphQLDocExplorerAtom } from './graphqlAtoms'; import { showGraphQLDocExplorerAtom } from './graphqlAtoms';
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & { type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
@@ -156,6 +156,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
{ type: 'separator', label: 'Setting' }, { type: 'separator', label: 'Setting' },
{ {
label: 'Automatic Introspection', label: 'Automatic Introspection',
keepOpenOnSelect: true,
onSelect: () => { onSelect: () => {
setAutoIntrospectDisabled({ setAutoIntrospectDisabled({
...autoIntrospectDisabled, ...autoIntrospectDisabled,
@@ -210,7 +211,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
language="graphql" language="graphql"
heightMode="auto" heightMode="auto"
graphQLSchema={schema} graphQLSchema={schema}
format={formatSdl} format={tryFormatGraphql}
defaultValue={currentBody.query} defaultValue={currentBody.query}
onChange={handleChangeQuery} onChange={handleChangeQuery}
placeholder="..." placeholder="..."
+2 -2
View File
@@ -1,11 +1,11 @@
import { invokeCmd } from '../lib/tauri'; import { installPluginFromDirectory } from '@yaakapp-internal/plugins';
import { useFastMutation } from './useFastMutation'; import { useFastMutation } from './useFastMutation';
export function useInstallPlugin() { export function useInstallPlugin() {
return useFastMutation<void, unknown, string>({ return useFastMutation<void, unknown, string>({
mutationKey: ['install_plugin'], mutationKey: ['install_plugin'],
mutationFn: async (directory: string) => { mutationFn: async (directory: string) => {
await invokeCmd('cmd_install_plugin', { directory }); await installPluginFromDirectory(directory);
}, },
}); });
} }
+1
View File
@@ -4,6 +4,7 @@ import { invokeCmd } from './tauri';
export interface AppInfo { export interface AppInfo {
isDev: boolean; isDev: boolean;
version: string; version: string;
cliVersion: string | null;
name: string; name: string;
appDataDir: string; appDataDir: string;
appLogDir: string; appLogDir: string;
+1 -1
View File
@@ -1,4 +1,4 @@
export const HEADER_SIZE_MD = '27px'; export const HEADER_SIZE_MD = '30px';
export const HEADER_SIZE_LG = '40px'; export const HEADER_SIZE_LG = '40px';
export const WINDOW_CONTROLS_WIDTH = '10.5rem'; export const WINDOW_CONTROLS_WIDTH = '10.5rem';
+12
View File
@@ -20,6 +20,18 @@ export async function tryFormatJson(text: string): Promise<string> {
return text; return text;
} }
export async function tryFormatGraphql(text: string): Promise<string> {
if (text === '') return text;
try {
return await invokeCmd<string>('cmd_format_graphql', { text });
} catch (err) {
console.warn('Failed to format GraphQL', err);
}
return text;
}
export async function tryFormatXml(text: string): Promise<string> { export async function tryFormatXml(text: string): Promise<string> {
if (text === '') return text; if (text === '') return text;
+33
View File
@@ -123,6 +123,39 @@ export function initGlobalListeners() {
console.log('Got plugin updates event', payload); console.log('Got plugin updates event', payload);
showPluginUpdatesToast(payload); showPluginUpdatesToast(payload);
}); });
// Check for plugin initialization errors
invokeCmd<[string, string][]>('cmd_plugin_init_errors').then((errors) => {
for (const [dir, message] of errors) {
const dirBasename = dir.split('/').pop() ?? dir;
showToast({
id: `plugin-init-error-${dirBasename}`,
color: 'warning',
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Plugin failed to load</h2>
<p className="text-text-subtle text-sm">
{dirBasename}: {message}
</p>
</VStack>
),
action: ({ hide }) => (
<Button
size="xs"
color="warning"
variant="border"
onClick={() => {
hide();
openSettings.mutate('plugins:installed');
}}
>
View Plugins
</Button>
),
});
}
});
} }
function showUpdateInstalledToast(version: string) { function showUpdateInstalledToast(version: string) {
+30
View File
@@ -0,0 +1,30 @@
/**
* Simple heuristic to detect if a string likely contains JSON/JSONC comments.
* Checks for // and /* patterns that are NOT inside double-quoted strings.
* Used for UI hints only doesn't need to be perfect.
*/
export function textLikelyContainsJsonComments(text: string): boolean {
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '"') {
inString = false;
} else if (ch === '\\') {
i++; // skip escaped char
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '/' && i + 1 < text.length) {
const next = text[i + 1];
if (next === '/' || next === '*') {
return true;
}
}
}
return false;
}
+2 -1
View File
@@ -17,6 +17,7 @@ type TauriCmd =
| 'cmd_delete_send_history' | 'cmd_delete_send_history'
| 'cmd_dismiss_notification' | 'cmd_dismiss_notification'
| 'cmd_export_data' | 'cmd_export_data'
| 'cmd_format_graphql'
| 'cmd_format_json' | 'cmd_format_json'
| 'cmd_get_http_authentication_config' | 'cmd_get_http_authentication_config'
| 'cmd_get_http_authentication_summaries' | 'cmd_get_http_authentication_summaries'
@@ -36,12 +37,12 @@ type TauriCmd =
| 'cmd_http_request_body' | 'cmd_http_request_body'
| 'cmd_http_response_body' | 'cmd_http_response_body'
| 'cmd_import_data' | 'cmd_import_data'
| 'cmd_install_plugin'
| 'cmd_metadata' | 'cmd_metadata'
| 'cmd_restart' | 'cmd_restart'
| 'cmd_new_child_window' | 'cmd_new_child_window'
| 'cmd_new_main_window' | 'cmd_new_main_window'
| 'cmd_plugin_info' | 'cmd_plugin_info'
| 'cmd_plugin_init_errors'
| 'cmd_reload_plugins' | 'cmd_reload_plugins'
| 'cmd_render_template' | 'cmd_render_template'
| 'cmd_save_response' | 'cmd_save_response'

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