Compare commits

..

103 Commits

Author SHA1 Message Date
Gregory Schier
bdf89ac288 Fix platform check 2023-03-14 00:15:01 -07:00
Gregory Schier
debd3c8185 Some small changes 2023-03-14 00:08:03 -07:00
Gregory Schier
f81a3ae8e7 Move stuff around 2023-03-13 23:30:14 -07:00
Gregory Schier
7d4e9894c3 Refactor hooks to be easier to use 2023-03-13 23:25:41 -07:00
Gregory Schier
4bf22d8a60 Fix header editor and scroll in general 2023-03-13 19:37:36 -07:00
Gregory Schier
8be4971a23 Lazy load routes 2023-03-13 13:56:13 -07:00
Gregory Schier
359e916b73 Back to React 2023-03-13 09:50:49 -07:00
Gregory Schier
68058f3e41 Move some stuff around 2023-03-13 09:24:38 -07:00
Gregory Schier
0c6fa3e634 Fix URL bar 2023-03-13 00:13:25 -07:00
Gregory Schier
0fa25c6335 Fix ButtonLink and edit request names 2023-03-13 00:11:23 -07:00
Gregory Schier
5684479f1d Remove old rust cache action 2023-03-12 22:48:43 -07:00
Gregory Schier
2d1603601c Better rust cache 2023-03-12 22:47:43 -07:00
Gregory Schier
f5394b2210 Start GraphQL support 2023-03-12 22:43:25 -07:00
Gregory Schier
833db5df06 Fix artifact tag 2023-03-12 21:41:15 -07:00
Gregory Schier
525ac7e980 Remove wasm stuff 2023-03-12 21:25:31 -07:00
Gregory Schier
44a747c80a Use tauri action 2023-03-12 21:13:08 -07:00
Gregory Schier
2056e7f40a Fix traffic lights thingy 2023-03-12 20:47:52 -07:00
Gregory Schier
9b6c1ad364 Cache cargo bin for "install" 2023-03-12 19:10:39 -07:00
Gregory Schier
34987bcacb Refformat 2023-03-12 19:03:27 -07:00
Gregory Schier
b62c11222a Fix artifact upload 2023-03-12 19:01:48 -07:00
Gregory Schier
b3cee3ace3 Fix dev 2023-03-12 18:39:02 -07:00
Gregory Schier
222c054c95 Split out macos deps 2023-03-12 18:36:25 -07:00
Gregory Schier
46f18a2491 Cache workflow 2023-03-12 18:28:14 -07:00
Gregory Schier
f2ca8e2753 Add wasm-pack 2023-03-12 18:19:20 -07:00
Gregory Schier
b0d243c378 Install rsw 2023-03-12 18:14:38 -07:00
Gregory Schier
6161fb86c8 Fix artifact names 2023-03-12 18:13:00 -07:00
Gregory Schier
b09cc91fe5 Fix build command 2023-03-12 18:11:24 -07:00
Gregory Schier
ef1638cbb3 Update secrets context 2023-03-12 18:07:57 -07:00
Gregory Schier
00ef8743f2 Update workflow name 2023-03-12 18:05:45 -07:00
Gregory Schier
68222659e3 Fix workflow 2023-03-12 18:05:13 -07:00
Gregory Schier
69420a4bba Start of auto updates 2023-03-12 18:04:11 -07:00
Gregory Schier
0161bbaeb1 Fix tabbing to tabs 2023-03-11 23:32:39 -08:00
Gregory Schier
948dbfe3cc Fix eslint errors 2023-03-11 23:29:25 -08:00
Gregory Schier
338ba8b189 Got tab content scrolling working 2023-03-11 22:36:13 -08:00
Gregory Schier
ca4655b441 Removed some debug stuff 2023-03-10 10:43:15 -08:00
Gregory Schier
bf37499428 Refactor editor to update better 2023-03-10 10:39:23 -08:00
Gregory Schier
0b94b57e2a Fix headers persistence and better sending 2023-03-09 13:38:17 -08:00
Gregory Schier
fc40aead98 Hook up header editor! 2023-03-09 13:07:13 -08:00
Gregory Schier
7d7f934e6a Fix 2023-03-09 10:58:27 -08:00
Gregory Schier
d5fbf4d622 Fix blur de-select speed 2023-03-09 10:57:34 -08:00
Gregory Schier
e4f6c919dc Fix Codemirror performance!! 2023-03-09 10:50:55 -08:00
Gregory Schier
4d806ff2b1 Switch to Preact!!! 2023-03-09 00:47:25 -08:00
Gregory Schier
bf8f12274f Move some things around 2023-03-08 23:20:15 -08:00
Gregory Schier
f4f438d9fe Better scrollbar color 2023-03-08 19:23:24 -08:00
Gregory Schier
2434f373be Zoom, better sizes, color picker, sidebar footer 2023-03-08 19:22:04 -08:00
Gregory Schier
2bb2061f97 Read-only editor 2023-03-08 16:53:13 -08:00
Gregory Schier
2c011a5c2a More theme tweaks 2023-03-08 16:37:20 -08:00
Gregory Schier
f66b0ccea1 Debounce autocomplete 2023-03-08 11:25:20 -08:00
Gregory Schier
665dd8447d Minor theme updates again 2023-03-08 09:43:35 -08:00
Gregory Schier
1b61ce31e6 Editor tweaks 2023-03-07 23:05:33 -08:00
Gregory Schier
ef4d960698 Remove unneeded space 2023-03-07 22:58:13 -08:00
Gregory Schier
b6d557b632 Fix small view 2023-03-07 22:55:51 -08:00
Gregory Schier
b700bd356c Minor style tweaks 2023-03-07 22:21:58 -08:00
Gregory Schier
620dd7d3ef Lots more theme stuff 2023-03-07 21:52:21 -08:00
Gregory Schier
6575121902 Start of themes 2023-03-07 11:24:38 -08:00
Gregory Schier
7c1755a0dc More subtle layout tweaks 2023-03-06 08:57:57 -08:00
Gregory Schier
8ad301a666 More layout fiddling and error page 2023-03-04 22:26:00 -08:00
Gregory Schier
ae24cd4939 More work on the layout 2023-03-04 21:51:17 -08:00
Gregory Schier
7152e1845e Try new layout and a bunch of editor fixes 2023-03-04 19:06:12 -08:00
Gregory Schier
96c1dd4081 Fix autocomplete inside dialog 2023-03-03 17:03:20 -08:00
Gregory Schier
87c7b3a663 Beginnings of Header Editor 2023-03-03 13:18:57 -08:00
Gregory Schier
c1be46a539 Fix tailwind dark selector 2023-03-03 07:54:19 -08:00
Gregory Schier
4655e0018b Fix content type in URL 2023-03-02 23:17:09 -08:00
Gregory Schier
da5ba2e3be Add Dialog component 2023-03-02 18:46:10 -08:00
Gregory Schier
aaf95f565f More colors 2023-03-02 17:56:53 -08:00
Gregory Schier
f32b984e77 Minor style tweaks 2023-03-02 16:16:41 -08:00
Gregory Schier
548aa4c7cd Improved autocompletion! 2023-03-02 11:14:51 -08:00
Gregory Schier
0ccceaac77 Rename, fix autocomplete and singleline, etc... 2023-03-02 10:42:43 -08:00
Gregory Schier
70f534f1d8 Editor placeholder 2023-03-01 14:22:10 -08:00
Gregory Schier
61fe95b300 Some minor bugs 2023-03-01 14:16:02 -08:00
Gregory Schier
915e0e8613 Fix migrations for build and iframe rendering 2023-03-01 10:31:50 -08:00
Gregory Schier
aace2580da Tweaks 2023-03-01 10:19:21 -08:00
Gregory Schier
3d36905664 Response streaming 2023-03-01 09:05:00 -08:00
Gregory Schier
0d671423da Autocomplete, and more CM stuff! 2023-02-28 22:54:54 -08:00
Gregory Schier
aebfcb9437 Some small tweaks 2023-02-28 17:25:59 -08:00
Gregory Schier
be7ef7beb1 Better editor updating 2023-02-28 12:41:03 -08:00
Gregory Schier
d77ed0c5cc URL highlighting with inline CM 2023-02-28 11:26:26 -08:00
Gregory Schier
e57e7bcec5 Implement request deletion 2023-02-27 15:42:06 -08:00
Gregory Schier
a637842ce4 Tauri events for request model updates 2023-02-27 13:28:50 -08:00
Gregory Schier
fc54ec49af Split request upsert command 2023-02-27 10:00:57 -08:00
Gregory Schier
5c43d8510a Add toggle for pretty view 2023-02-27 09:08:48 -08:00
Gregory Schier
83f84ded8d Small tweaks 2023-02-26 15:25:55 -08:00
Gregory Schier
5658da34a2 Add variable highlighting widgets 2023-02-26 15:06:14 -08:00
Gregory Schier
38e8ef6535 Dropdown scrolling 2023-02-25 23:33:07 -08:00
Gregory Schier
8c89b06238 Show response body size 2023-02-25 23:08:19 -08:00
Gregory Schier
d85c021305 A bunch more small things 2023-02-25 23:04:31 -08:00
Gregory Schier
83bb18df03 Added react-router 2023-02-25 18:04:14 -08:00
Gregory Schier
93105a3e89 Migrations and initial data stuff 2023-02-25 16:39:18 -08:00
Gregory Schier
ba3b899115 Minor tweaks 2023-02-24 17:01:48 -08:00
Gregory Schier
fcfbc1d1da Dummy requests in sidebar 2023-02-24 16:46:56 -08:00
Gregory Schier
72486b448c Codemirror initial value support 2023-02-24 16:43:47 -08:00
Gregory Schier
7dea1b7870 Send request body 2023-02-24 16:09:19 -08:00
Gregory Schier
4de2c496c9 Vendor basicSetup 2023-02-24 14:51:56 -08:00
Gregory Schier
9e1393a392 Additional methods and tweaks 2023-02-24 14:10:25 -08:00
Gregory Schier
0901690ed6 Focus states 2023-02-24 12:35:13 -08:00
Gregory Schier
95303648cc Hook up theme and clear responses 2023-02-24 12:13:30 -08:00
Gregory Schier
1dbb08c045 SQLite store in proper dir 2023-02-22 20:18:14 -08:00
Gregory Schier
00a7d9a180 Started on grid layout 2023-02-22 19:44:44 -08:00
Gregory Schier
6c549dc086 Save responses in DB 2023-02-22 18:53:44 -08:00
Gregory Schier
dc368e326a Better URL bar 2023-02-22 16:15:25 -08:00
Gregory Schier
e42188a627 Cleaner URL bar and some improvements 2023-02-22 15:58:04 -08:00
Gregory Schier
7a6a337eff Refactor classname usage 2023-02-21 18:03:57 -08:00
Gregory Schier
3907344884 Some minor tweaks 2023-02-21 17:56:48 -08:00
1130 changed files with 18615 additions and 128673 deletions

View File

@@ -1,22 +0,0 @@
# Project Rules
## General Development
- **NEVER** commit or push without explicit confirmation
## Build and Lint
- **ALWAYS** run `npm run lint` after modifying TypeScript or JavaScript files
- Run `npm run bootstrap` after changing plugin runtime or MCP server code
## Plugin System
### Backend Constraints
- Always use `UpdateSource::Plugin` when calling database methods from plugin events
- Never send timestamps (`createdAt`, `updatedAt`) from TypeScript - Rust backend controls these
- Backend uses `NaiveDateTime` (no timezone) so avoid sending ISO timestamp strings
### MCP Server
- MCP server has **no active window context** - cannot call `window.workspaceId()`
- Get workspace ID from `workspaceCtx.yaak.workspace.list()` instead
## Rust Type Generation
- Run `cd src-tauri && cargo test --package yaak-plugins` to regenerate TypeScript bindings after modifying Rust event types

5
.eslintignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.prettierrc.cjs
.eslintrc.cjs
env.d.ts

31
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,31 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:import/recommended',
'plugin:jsx-a11y/recommended',
'plugin:@typescript-eslint/recommended',
'eslint-config-prettier',
],
ignorePatterns: ['src-tauri/**/*'],
settings: {
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src-web'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
rules: {
"jsx-a11y/no-autofocus": "warn",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/consistent-type-imports": ["error", {
prefer: "type-imports",
disallowTypeAnnotations: true,
fixStyle: "separate-type-imports",
}]
},
};

7
.gitattributes vendored
View File

@@ -1,7 +0,0 @@
src-tauri/vendored/**/* linguist-generated=true
src-tauri/gen/schemas/**/* linguist-generated=true
**/bindings/* linguist-generated=true
src-tauri/yaak-templates/pkg/* linguist-generated=true
# Ensure consistent line endings for test files that check exact content
src-tauri/yaak-http/tests/test.txt text eol=lf

3
.github/FUNDING.yml vendored
View File

@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: gschier

View File

@@ -1,38 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Bugs, Feedback, Feature Requests, and Questions
url: https://feedback.yaak.app
about: "Please report to Yaak's public feedback board. Issues will be created and linked here when applicable."

49
.github/workflows/artifacts.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
runs-on: ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-latest, macos-latest, windows-latest ]
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- name: Cache Rust
uses: actions/cache@v2
with:
path: |
~/.cargo/registry
~/.cargo/git
./src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('src-tauri/Cargo.lock') }}
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'npm'
- name: install dependencies (ubuntu only)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
with:
tagName: v__VERSION__
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false

View File

@@ -1,31 +0,0 @@
on:
pull_request:
push:
branches:
- main
name: Lint and Test
permissions:
contents: read
jobs:
test:
name: Lint/Test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: 'src-tauri'
shared-key: ci
cache-on-failure: true
- run: npm ci
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
working-directory: src-tauri

View File

@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View File

@@ -1,131 +0,0 @@
name: Generate Artifacts
on:
push:
tags: [ v* ]
jobs:
build-artifacts:
permissions:
contents: write
name: Build
strategy:
fail-fast: false
matrix:
include:
- platform: 'macos-latest' # for Arm-based Macs (M1 and above).
args: '--target aarch64-apple-darwin'
yaak_arch: 'arm64'
os: 'macos'
targets: 'aarch64-apple-darwin'
- platform: 'macos-latest' # for Intel-based Macs.
args: '--target x86_64-apple-darwin'
yaak_arch: 'x64'
os: 'macos'
targets: 'x86_64-apple-darwin'
- platform: 'ubuntu-22.04'
args: ''
yaak_arch: 'x64'
os: 'ubuntu'
targets: ''
- platform: 'ubuntu-22.04-arm'
args: ''
yaak_arch: 'arm64'
os: 'ubuntu'
targets: ''
- platform: 'windows-latest'
args: ''
yaak_arch: 'x64'
os: 'windows'
targets: ''
# Windows ARM64
- platform: 'windows-latest'
args: '--target aarch64-pc-windows-msvc'
yaak_arch: 'arm64'
os: 'windows'
targets: 'aarch64-pc-windows-msvc'
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.targets }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: 'src-tauri'
shared-key: ci
cache-on-failure: true
- name: install dependencies (Linux only)
if: matrix.os == 'ubuntu'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install trusted-signing-cli (Windows only)
if: matrix.os == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$dir = "$env:USERPROFILE\trusted-signing"
New-Item -ItemType Directory -Force -Path $dir | Out-Null
$url = "https://github.com/Levminer/trusted-signing-cli/releases/download/0.8.0/trusted-signing-cli.exe"
$exe = Join-Path $dir "trusted-signing-cli.exe"
Invoke-WebRequest -Uri $url -OutFile $exe
echo $dir >> $env:GITHUB_PATH
& $exe --version
- run: npm ci
- run: npm run lint
- name: Run JS Tests
run: npm test
- name: Run Rust Tests
run: cargo test --all
working-directory: src-tauri
- name: Set version
run: npm run replace-version
env:
YAAK_VERSION: ${{ github.ref_name }}
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
# Apple signing stuff
APPLE_CERTIFICATE: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ matrix.os == 'macos' && secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ matrix.os == 'macos' && secrets.APPLE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ matrix.os == 'macos' && secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos' && secrets.APPLE_TEAM_ID }}
# Windows signing stuff
AZURE_CLIENT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
tagName: 'v__VERSION__'
releaseName: 'Release __VERSION__'
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
releaseDraft: true
prerelease: true
args: '${{ matrix.args }} --config ./src-tauri/tauri.release.conf.json'

View File

@@ -1,44 +0,0 @@
name: Generate Sponsors README
on:
workflow_dispatch:
schedule:
- cron: 30 15 * * 0-6
permissions:
contents: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout 🛎️
uses: actions/checkout@v2
- name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_PAT }}
file: 'README.md'
maximum: 1999
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="50px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
active-only: false
include-private: true
marker: 'sponsors-base'
- name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_PAT }}
file: 'README.md'
minimum: 2000
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="80px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
active-only: false
include-private: true
marker: 'sponsors-premium'
# ⚠️ Note: You can use any deployment step here to automatically push the README
# changes back to your branch.
- name: Commit Changes
uses: JamesIves/github-pages-deploy-action@v4
with:
branch: main
force: false
folder: '.'

12
.gitignore vendored
View File

@@ -15,8 +15,6 @@ dist-ssr
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/launch.json
.idea
.DS_Store
*.suo
@@ -24,15 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.eslintcache
out
*.sqlite
*.sqlite-*
.cargo
.tmp
tmp
.zed
codebook.toml

2
.nvmrc
View File

@@ -1 +1 @@
20
18

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.prettierrc.cjs

8
.prettierrc.cjs Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"singleQuote": true,
"printWidth": 100,
"bracketSpacing": true
}

14
.run/Dev Desktop.run.xml Normal file
View File

@@ -0,0 +1,14 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Dev Desktop" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="tauri-dev" />
</scripts>
<node-interpreter value="project" />
<envs>
<env name="DATABASE_URL" value="sqlite://$USER_HOME$/Library/Application%20Support/co.schier.yaak/db.sqlite" />
</envs>
<method v="2" />
</configuration>
</component>

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["biomejs.biome", "rust-lang.rust-analyzer", "bradlc.vscode-tailwindcss"]
}

26
.vscode/launch.json vendored
View File

@@ -1,26 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Dev App",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start"]
},
{
"type": "node",
"request": "launch",
"name": "Build App",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start"]
},
{
"type": "node",
"request": "launch",
"name": "Bootstrap",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "bootstrap"]
}
]
}

View File

@@ -1,6 +0,0 @@
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"biome.enabled": true,
"biome.lint.format.enable": true
}

View File

@@ -1,88 +0,0 @@
# Developer Setup
Yaak is a combined Node.js and Rust monorepo. It is a [Tauri](https://tauri.app) project, so
uses Rust and HTML/CSS/JS for the main application but there is also a plugin system powered
by a Node.js sidecar that communicates to the app over gRPC.
Because of the moving parts, there are a few setup steps required before development can
begin.
## Prerequisites
Make sure you have the following tools installed:
- [Node.js](https://nodejs.org/en/download/package-manager)
- [Rust](https://www.rust-lang.org/tools/install)
Check the installations with the following commands:
```shell
node -v
npm -v
rustc --version
```
Install the NPM dependencies:
```shell
npm install
```
Run the `bootstrap` command to do some initial setup:
```shell
npm run bootstrap
```
## Run the App
After bootstrapping, start the app in development mode:
```shell
npm start
```
## SQLite Migrations
New migrations can be created from the `src-tauri/` directory:
```shell
npm run migration
```
Rerun the app to apply the migrations.
_Note: For safety, development builds use a separate database location from production builds._
## Lezer Grammar Generation
```sh
# Example
lezer-generator components/core/Editor/<LANG>/<LANG>.grammar > components/core/Editor/<LANG>/<LANG>.ts
```
## Linting & Formatting
This repo uses Biome for linting and formatting (replacing ESLint + Prettier).
- Lint the entire repo:
```sh
npm run lint
```
- Auto-fix lint issues where possible:
```sh
npm run lint:fix
```
- Format code:
```sh
npm run format
```
Notes:
- Many workspace packages also expose the same scripts (`lint`, `lint:fix`, and `format`).
- TypeScript type-checking still runs separately via `tsc --noEmit` in relevant packages.

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 Yaak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,70 +1,3 @@
<p align="center">
<a href="https://github.com/JamesIves/github-sponsors-readme-action">
<img width="200px" src="https://github.com/mountain-loop/yaak/raw/main/src-tauri/icons/icon.png">
</a>
</p>
# Tauri REST Client
<h1 align="center">
💫 Yaak ➟ Desktop API Client 💫
</h1>
<p align="center">
A fast, privacy-first API client for REST, GraphQL, SSE, WebSocket, and gRPC built with Tauri, Rust, and React.
</p>
<p align="center">
Development is funded by community-purchased <a href="https://yaak.app/pricing">licenses</a>. You can also <a href="https://github.com/sponsors/gschier">become a sponsor</a> to have your logo appear below. 💖
</p>
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)
## Features
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
Built with [Tauri](https://tauri.app), Rust, and React, its fast, lightweight, and private. No telemetry, no VC funding, and no cloud lock-in.
### 🌐 Work with any API
- Import collections from Postman, Insomnia, OpenAPI, Swagger, or Curl.
- Send requests via REST, GraphQL, gRPC, WebSocket, or Server-Sent Events.
- Filter and inspect responses with JSONPath or XPath.
### 🔐 Stay secure
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
- Secure sensitive values with encrypted secrets.
- Store secrets in your OS keychain.
### ☁️ Organize & collaborate
- Group requests into workspaces and nested folders.
- Use environment variables to switch between dev, staging, and prod.
- Mirror workspaces to your filesystem for versioning in Git or syncing with Dropbox.
### 🧩 Extend & customize
- Insert dynamic values like UUIDs or timestamps with template tags.
- Pick from built-in themes or build your own.
- Create plugins to extend authentication, template tags, or the UI.
## Contribution Policy
Yaak is open source but only accepting contributions for bug fixes. To get started,
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
## Useful Resources
- [Feedback and Bug Reports](https://feedback.yaak.app)
- [Documentation](https://feedback.yaak.app/help)
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
It's a REST client, yo.

View File

@@ -1,51 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.7/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useKeyWithClickEvents": "off"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100,
"bracketSpacing": true
},
"css": {
"parser": {
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double",
"trailingCommas": "all",
"semicolons": "always"
}
},
"files": {
"includes": [
"**",
"!**/node_modules",
"!**/dist",
"!**/build",
"!scripts",
"!packages/plugin-runtime",
"!packages/plugin-runtime-types",
"!src-tauri",
"!src-web/tailwind.config.cjs",
"!src-web/postcss.config.cjs",
"!src-web/vite.config.ts",
"!src-web/routeTree.gen.ts"
]
}
}

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
</head>
<body>
<div id="root"></div>
<div id="cm-portal" class="cm-portal" style="pointer-events: auto"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/src-web/main.tsx"></script>
</body>
</html>

29757
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,101 +1,75 @@
{
"name": "yaak-app",
"name": "tauri-app",
"private": true,
"version": "0.0.0",
"repository": {
"type": "git",
"url": "git+https://github.com/mountain-loop/yaak.git"
},
"workspaces": [
"packages/common-lib",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"plugins-external/mcp-server",
"plugins-external/template-function-faker",
"plugins/action-copy-curl",
"plugins/action-copy-grpcurl",
"plugins/auth-apikey",
"plugins/auth-aws",
"plugins/auth-basic",
"plugins/auth-bearer",
"plugins/auth-jwt",
"plugins/auth-ntlm",
"plugins/auth-oauth2",
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-curl",
"plugins/importer-insomnia",
"plugins/importer-openapi",
"plugins/importer-postman",
"plugins/importer-postman-environment",
"plugins/importer-yaak",
"plugins/template-function-1password",
"plugins/template-function-cookie",
"plugins/template-function-ctx",
"plugins/template-function-encode",
"plugins/template-function-fs",
"plugins/template-function-hash",
"plugins/template-function-json",
"plugins/template-function-prompt",
"plugins/template-function-random",
"plugins/template-function-regex",
"plugins/template-function-timestamp",
"plugins/template-function-uuid",
"plugins/template-function-xml",
"plugins/template-function-request",
"plugins/template-function-response",
"plugins/themes-yaak",
"src-tauri",
"src-tauri/yaak-crypto",
"src-tauri/yaak-fonts",
"src-tauri/yaak-git",
"src-tauri/yaak-license",
"src-tauri/yaak-mac-window",
"src-tauri/yaak-models",
"src-tauri/yaak-plugins",
"src-tauri/yaak-sse",
"src-tauri/yaak-sync",
"src-tauri/yaak-templates",
"src-tauri/yaak-ws",
"src-web"
],
"type": "module",
"scripts": {
"start": "npm run app-dev",
"app-build": "tauri build",
"app-dev": "tauri dev --no-watch --config ./src-tauri/tauri.development.conf.json",
"migration": "node scripts/create-migration.cjs",
"build": "npm run --workspaces --if-present build",
"build-plugins": "npm run --workspaces --if-present build",
"test": "npm run --workspaces --if-present test",
"icons": "run-p icons:*",
"icons:dev": "tauri icon src-tauri/icons/icon-dev.png --output src-tauri/icons/dev",
"icons:release": "tauri icon src-tauri/icons/icon.png --output src-tauri/icons/release",
"bootstrap": "run-s bootstrap:*",
"bootstrap:install-wasm-pack": "node scripts/install-wasm-pack.cjs",
"bootstrap:build": "npm run build",
"bootstrap:vendor": "npm run vendor",
"vendor": "run-p vendor:*",
"vendor:vendor-plugins": "node scripts/vendor-plugins.cjs",
"vendor:vendor-protoc": "node scripts/vendor-protoc.cjs",
"vendor:vendor-node": "node scripts/vendor-node.cjs",
"lint": "run-p lint:*",
"lint:biome": "biome lint",
"lint:extra": "npm run --workspaces --if-present lint",
"format": "biome format --write .",
"replace-version": "node scripts/replace-version.cjs",
"tauri": "tauri",
"tauri-before-build": "npm run bootstrap",
"tauri-before-dev": "workspaces-run --parallel -- npm run --workspaces --if-present dev"
"tauri-dev": "tauri dev",
"tauri-build": "npm run build:icon && tauri build",
"build": "npm run build:frontend",
"dev": "vite dev",
"lint": "tsc && eslint . --ext .ts,.tsx",
"build:icon": "tauri icon src-tauri/icons/icon.png",
"build:frontend": "vite build",
"test": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@codemirror/commands": "^6.2.1",
"@codemirror/lang-html": "^6.4.2",
"@codemirror/lang-javascript": "^6.1.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.6.0",
"@codemirror/search": "^6.2.3",
"@lezer/generator": "^1.2.2",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-icons": "^1.2.0",
"@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-scroll-area": "^1.0.2",
"@radix-ui/react-separator": "^1.0.1",
"@radix-ui/react-tabs": "^1.0.3",
"@tanstack/react-query": "^4.24.10",
"@tauri-apps/api": "^1.2.0",
"@vitejs/plugin-react": "^3.1.0",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"cm6-graphql": "^0.0.4-canary-b30a2325.0",
"codemirror": "^6.0.1",
"format-graphql": "^1.4.0",
"framer-motion": "^9.0.4",
"parse-color": "^1.0.0",
"react-helmet-async": "^1.3.0",
"react-use": "^17.4.0"
},
"devDependencies": {
"@biomejs/biome": "^2.3.7",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4",
"nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5",
"typescript": "^5.8.3",
"vitest": "^3.2.4",
"workspaces-run": "^1.0.2"
"@tailwindcss/nesting": "^0.0.0-insiders.565cd3e",
"@tauri-apps/cli": "^1.2.2",
"@types/node": "^18.7.10",
"@types/parse-color": "^1.0.1",
"@types/parse-json": "^4.0.0",
"@types/react-dom": "^18.0.6",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.6.0",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.2",
"postcss": "^8.4.21",
"postcss-nesting": "^11.2.1",
"prettier": "^2.8.4",
"tailwindcss": "^3.2.7",
"typescript": "^4.6.4",
"vite": "^4.0.0",
"vite-plugin-top-level-await": "^1.2.4",
"vitest": "^0.29.2"
}
}

View File

@@ -1,13 +0,0 @@
// biome-ignore lint/suspicious/noExplicitAny: none
export function debounce(fn: (...args: any[]) => void, delay = 500) {
let timer: ReturnType<typeof setTimeout>;
// biome-ignore lint/suspicious/noExplicitAny: none
const result = (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
result.cancel = () => {
clearTimeout(timer);
};
return result;
}

View File

@@ -1,20 +0,0 @@
export function formatSize(bytes: number): string {
let num: number;
let unit: string;
if (bytes > 1000 * 1000 * 1000) {
num = bytes / 1000 / 1000 / 1000;
unit = 'GB';
} else if (bytes > 1000 * 1000) {
num = bytes / 1000 / 1000;
unit = 'MB';
} else if (bytes > 1000) {
num = bytes / 1000;
unit = 'KB';
} else {
num = bytes;
unit = 'B';
}
return `${Math.round(num * 10) / 10} ${unit}`;
}

View File

@@ -1,3 +0,0 @@
export * from './debounce';
export * from './formatSize';
export * from './templateFunction';

View File

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

View File

@@ -1,49 +0,0 @@
import type {
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} from '@yaakapp-internal/plugins';
export function validateTemplateFunctionArgs(
fnName: string,
args: TemplateFunctionArg[],
values: CallTemplateFunctionArgs['values'],
): string | null {
for (const arg of args) {
if ('inputs' in arg && arg.inputs) {
// Recurse down
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
if (err) return err;
}
if (!('name' in arg)) continue;
if (arg.optional) continue;
if (arg.defaultValue != null) continue;
if (arg.hidden) continue;
if (values[arg.name] != null) continue;
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
}
return null;
}
/** Recursively apply form input defaults to a set of values */
export function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonPrimitive | undefined },
) {
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
for (const input of inputs) {
if ('defaultValue' in input && values[input.name] === undefined) {
newValues[input.name] = input.defaultValue;
}
if (input.type === 'checkbox' && values[input.name] === undefined) {
newValues[input.name] = false;
}
// Recurse down to all child inputs
if ('inputs' in input) {
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
}
}
return newValues;
}

View File

@@ -1,2 +0,0 @@
lib
node_modules

View File

@@ -1,28 +0,0 @@
# Yaak Plugin API
Yaak is a desktop [API client](https://yaak.app/blog/yet-another-api-client) for
interacting with REST, GraphQL, Server Sent Events (SSE), WebSocket, and gRPC APIs. It's
built using Tauri, Rust, and ReactJS.
Plugins can be created in TypeScript, which are executed alongside Yaak in a NodeJS
runtime. This package contains the TypeScript type definitions required to make building
Yaak plugins a breeze.
## Quick Start
The easiest way to get started is by generating a plugin with the Yaak CLI:
```shell
npx @yaakapp/cli generate
```
For more details on creating plugins, check out
the [Quick Start Guide](https://feedback.yaak.app/help/articles/6911763-plugins-quick-start)
## Installation
If you prefer starting from scratch, manually install the types package:
```shell
npm install -D @yaakapp/api
```

View File

@@ -1,39 +0,0 @@
{
"name": "@yaakapp/api",
"version": "0.7.1",
"keywords": [
"api-client",
"insomnia-alternative",
"bruno-alternative",
"postman-alternative"
],
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak"
},
"bugs": {
"url": "https://feedback.yaak.app"
},
"homepage": "https://yaak.app",
"main": "lib/index.js",
"typings": "./lib/index.d.ts",
"files": [
"lib/**/*"
],
"scripts": {
"bootstrap": "npm run build",
"build": "run-s build:copy-types build:tsc",
"build:tsc": "tsc",
"build:copy-types": "run-p build:copy-types:*",
"build:copy-types:root": "cpy --flat ../../src-tauri/yaak-plugins/bindings/*.ts ./src/bindings",
"build:copy-types:next": "cpy --flat ../../src-tauri/yaak-plugins/bindings/serde_json/*.ts ./src/bindings/serde_json",
"publish": "npm publish",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@types/node": "^24.0.13"
},
"devDependencies": {
"cpy-cli": "^5.0.0"
}
}

View File

@@ -1,8 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginVersion } from "./gen_search";
export type PluginNameVersion = { name: string, version: string, };
export type PluginSearchResponse = { plugins: Array<PluginVersion>, };
export type PluginUpdatesResponse = { plugins: Array<PluginNameVersion>, };

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,82 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type Cookie = { raw_cookie: string, domain: CookieDomain, expires: CookieExpires, path: [string, boolean], };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, };
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnectionState = "initialized" | "connected" | "closed";
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
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 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 HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: 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, };
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
export type HttpUrlParameter = { enabled?: boolean, name: string, value: string, id?: string, };
export type 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 ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
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 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, };
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };

View File

@@ -1,5 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginMetadata = { version: string, name: string, displayName: string, description: string | null, homepageUrl: string | null, repositoryUrl: string | null, };
export type PluginVersion = { id: string, version: string, url: string, description: string | null, name: string, displayName: string, homepageUrl: string | null, repositoryUrl: string | null, checksum: string, readme: string | null, yanked: boolean, };

View File

@@ -1,3 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type JsonValue = number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null;

View File

@@ -1,2 +0,0 @@
export type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
export type MaybePromise<T> = Promise<T> | T;

View File

@@ -1,9 +0,0 @@
export type * from './plugins';
export type * from './themes';
export * from './bindings/gen_models';
export * from './bindings/gen_events';
// Some extras for utility
export type { PartialImportResources } from './plugins/ImporterPlugin';

View File

@@ -1,44 +0,0 @@
import {
CallHttpAuthenticationActionArgs,
CallHttpAuthenticationRequest,
CallHttpAuthenticationResponse,
FormInput,
GetHttpAuthenticationSummaryResponse,
HttpAuthenticationAction,
} from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: CallHttpAuthenticationActionArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallHttpAuthenticationActionArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicAuthenticationArg = AddDynamic<FormInput>;
export type AuthenticationPlugin = GetHttpAuthenticationSummaryResponse & {
args: DynamicAuthenticationArg[];
onApply(
ctx: Context,
args: CallHttpAuthenticationRequest,
): MaybePromise<CallHttpAuthenticationResponse>;
actions?: (HttpAuthenticationAction & {
onSelect(ctx: Context, args: CallHttpAuthenticationActionArgs): Promise<void> | void;
})[];
};

View File

@@ -1,95 +0,0 @@
import type {
FindHttpResponsesRequest,
FindHttpResponsesResponse,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdRequest,
GetHttpRequestByIdResponse,
ListCookieNamesResponse,
ListFoldersRequest,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
OpenWindowRequest,
PromptTextRequest,
PromptTextResponse,
RenderGrpcRequestRequest,
RenderGrpcRequestResponse,
RenderHttpRequestRequest,
RenderHttpRequestResponse,
SendHttpRequestRequest,
SendHttpRequestResponse,
ShowToastRequest,
TemplateRenderRequest,
WorkspaceInfo,
} from '../bindings/gen_events.ts';
import type { HttpRequest } from '../bindings/gen_models.ts';
import type { JsonValue } from '../bindings/serde_json/JsonValue';
export type WorkspaceHandle = Pick<WorkspaceInfo, 'id' | 'name'>;
export interface Context {
clipboard: {
copyText(text: string): Promise<void>;
};
toast: {
show(args: ShowToastRequest): Promise<void>;
};
prompt: {
text(args: PromptTextRequest): Promise<PromptTextResponse['value']>;
};
store: {
set<T>(key: string, value: T): Promise<void>;
get<T>(key: string): Promise<T | undefined>;
delete(key: string): Promise<boolean>;
};
window: {
requestId(): Promise<string | null>;
workspaceId(): Promise<string | null>;
environmentId(): Promise<string | null>;
openUrl(
args: OpenWindowRequest & {
onNavigate?: (args: { url: string }) => void;
onClose?: () => void;
},
): Promise<{ close: () => void }>;
};
cookies: {
listNames(): Promise<ListCookieNamesResponse['names']>;
getValue(args: GetCookieValueRequest): Promise<GetCookieValueResponse['value']>;
};
grpcRequest: {
render(args: RenderGrpcRequestRequest): Promise<RenderGrpcRequestResponse['grpcRequest']>;
};
httpRequest: {
send(args: SendHttpRequestRequest): Promise<SendHttpRequestResponse['httpResponse']>;
getById(args: GetHttpRequestByIdRequest): Promise<GetHttpRequestByIdResponse['httpRequest']>;
render(args: RenderHttpRequestRequest): Promise<RenderHttpRequestResponse['httpRequest']>;
list(args?: ListHttpRequestsRequest): Promise<ListHttpRequestsResponse['httpRequests']>;
create(
args: Omit<Partial<HttpRequest>, 'id' | 'model' | 'createdAt' | 'updatedAt'> &
Pick<HttpRequest, 'workspaceId' | 'url'>,
): Promise<HttpRequest>;
update(
args: Omit<Partial<HttpRequest>, 'model' | 'createdAt' | 'updatedAt'> &
Pick<HttpRequest, 'id'>,
): Promise<HttpRequest>;
delete(args: { id: string }): Promise<HttpRequest>;
};
folder: {
list(args?: ListFoldersRequest): Promise<ListFoldersResponse['folders']>;
};
httpResponse: {
find(args: FindHttpResponsesRequest): Promise<FindHttpResponsesResponse['httpResponses']>;
};
templates: {
render<T extends JsonValue>(args: TemplateRenderRequest & { data: T }): Promise<T>;
};
plugin: {
reload(): void;
};
workspace: {
list(): Promise<WorkspaceHandle[]>;
withContext(handle: WorkspaceHandle): Context;
};
}

View File

@@ -1,11 +0,0 @@
import { FilterResponse } from '../bindings/gen_events';
import type { Context } from './Context';
export type FilterPlugin = {
name: string;
description?: string;
onFilter(
ctx: Context,
args: { payload: string; filter: string; mimeType: string },
): Promise<FilterResponse> | FilterResponse;
};

View File

@@ -1,6 +0,0 @@
import type { CallFolderActionArgs, FolderAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type FolderActionPlugin = FolderAction & {
onSelect(ctx: Context, args: CallFolderActionArgs): Promise<void> | void;
};

View File

@@ -1,6 +0,0 @@
import { CallGrpcRequestActionArgs, GrpcRequestAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type GrpcRequestActionPlugin = GrpcRequestAction & {
onSelect(ctx: Context, args: CallGrpcRequestActionArgs): Promise<void> | void;
};

View File

@@ -1,6 +0,0 @@
import type { CallHttpRequestActionArgs, HttpRequestAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type HttpRequestActionPlugin = HttpRequestAction & {
onSelect(ctx: Context, args: CallHttpRequestActionArgs): Promise<void> | void;
};

View File

@@ -1,28 +0,0 @@
import { ImportResources } from '../bindings/gen_events';
import { AtLeast, MaybePromise } from '../helpers';
import type { Context } from './Context';
type RootFields = 'name' | 'id' | 'model';
type CommonFields = RootFields | 'workspaceId';
export type PartialImportResources = {
workspaces: Array<AtLeast<ImportResources['workspaces'][0], RootFields>>;
environments: Array<AtLeast<ImportResources['environments'][0], CommonFields>>;
folders: Array<AtLeast<ImportResources['folders'][0], CommonFields>>;
httpRequests: Array<AtLeast<ImportResources['httpRequests'][0], CommonFields>>;
grpcRequests: Array<AtLeast<ImportResources['grpcRequests'][0], CommonFields>>;
websocketRequests: Array<AtLeast<ImportResources['websocketRequests'][0], CommonFields>>;
};
export type ImportPluginResponse = null | {
resources: PartialImportResources;
};
export type ImporterPlugin = {
name: string;
description?: string;
onImport(
ctx: Context,
args: { text: string },
): MaybePromise<ImportPluginResponse | null | undefined>;
};

View File

@@ -1,31 +0,0 @@
import { CallTemplateFunctionArgs, FormInput, TemplateFunction } from '../bindings/gen_events';
import { MaybePromise } from '../helpers';
import { Context } from './Context';
type AddDynamicMethod<T> = {
dynamic?: (
ctx: Context,
args: CallTemplateFunctionArgs,
) => MaybePromise<Partial<T> | null | undefined>;
};
type AddDynamic<T> = T extends any
? T extends { inputs?: FormInput[] }
? Omit<T, 'inputs'> & {
inputs: Array<AddDynamic<FormInput>>;
dynamic?: (
ctx: Context,
args: CallTemplateFunctionArgs,
) => MaybePromise<
Partial<Omit<T, 'inputs'> & { inputs: Array<AddDynamic<FormInput>> }> | null | undefined
>;
}
: T & AddDynamicMethod<T>
: never;
export type DynamicTemplateFunctionArg = AddDynamic<FormInput>;
export type TemplateFunctionPlugin = Omit<TemplateFunction, 'args'> & {
args: DynamicTemplateFunctionArg[];
onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null>;
};

View File

@@ -1,3 +0,0 @@
import { Theme } from '../bindings/gen_events';
export type ThemePlugin = Theme;

View File

@@ -1,6 +0,0 @@
import type { CallWebsocketRequestActionArgs, WebsocketRequestAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type WebsocketRequestActionPlugin = WebsocketRequestAction & {
onSelect(ctx: Context, args: CallWebsocketRequestActionArgs): Promise<void> | void;
};

View File

@@ -1,6 +0,0 @@
import type { CallWorkspaceActionArgs, WorkspaceAction } from '../bindings/gen_events';
import type { Context } from './Context';
export type WorkspaceActionPlugin = WorkspaceAction & {
onSelect(ctx: Context, args: CallWorkspaceActionArgs): Promise<void> | void;
};

View File

@@ -1,37 +0,0 @@
import { AuthenticationPlugin } from './AuthenticationPlugin';
import type { Context } from './Context';
import type { FilterPlugin } from './FilterPlugin';
import { GrpcRequestActionPlugin } from './GrpcRequestActionPlugin';
import type { HttpRequestActionPlugin } from './HttpRequestActionPlugin';
import type { WebsocketRequestActionPlugin } from './WebsocketRequestActionPlugin';
import type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
import type { FolderActionPlugin } from './FolderActionPlugin';
import type { ImporterPlugin } from './ImporterPlugin';
import type { TemplateFunctionPlugin } from './TemplateFunctionPlugin';
import type { ThemePlugin } from './ThemePlugin';
export type { Context };
export type { DynamicTemplateFunctionArg } from './TemplateFunctionPlugin';
export type { DynamicAuthenticationArg } from './AuthenticationPlugin';
export type { TemplateFunctionPlugin };
export type { WorkspaceActionPlugin } from './WorkspaceActionPlugin';
export type { FolderActionPlugin } from './FolderActionPlugin';
/**
* The global structure of a Yaak plugin
*/
export type PluginDefinition = {
init?: (ctx: Context) => void | Promise<void>;
dispose?: () => void | Promise<void>;
importer?: ImporterPlugin;
themes?: ThemePlugin[];
filter?: FilterPlugin;
authentication?: AuthenticationPlugin;
httpRequestActions?: HttpRequestActionPlugin[];
websocketRequestActions?: WebsocketRequestActionPlugin[];
workspaceActions?: WorkspaceActionPlugin[];
folderActions?: FolderActionPlugin[];
grpcRequestActions?: GrpcRequestActionPlugin[];
templateFunctions?: TemplateFunctionPlugin[];
};

View File

@@ -1,44 +0,0 @@
export type Colors = {
surface: string;
surfaceHighlight?: string;
surfaceActive?: string;
text: string;
textSubtle?: string;
textSubtlest?: string;
border?: string;
borderSubtle?: string;
borderFocus?: string;
shadow?: string;
backdrop?: string;
selection?: string;
primary?: string;
secondary?: string;
info?: string;
success?: string;
notice?: string;
warning?: string;
danger?: string;
};
export type Index = Colors & {
id: string;
name: string;
components?: Partial<{
dialog: Partial<Colors>;
menu: Partial<Colors>;
toast: Partial<Colors>;
sidebar: Partial<Colors>;
responsePane: Partial<Colors>;
appHeader: Partial<Colors>;
button: Partial<Colors>;
banner: Partial<Colors>;
placeholder: Partial<Colors>;
urlBar: Partial<Colors>;
editor: Partial<Colors>;
input: Partial<Colors>;
}>;
};

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"module": "node16",
"target": "es6",
"lib": [
"es2021",
"dom"
],
"declaration": true,
"declarationDir": "./lib",
"outDir": "./lib",
"strict": true,
"types": [
"node"
]
},
"files": [
"src/index.ts"
]
}

View File

@@ -1,14 +0,0 @@
{
"name": "@yaakapp-internal/plugin-runtime",
"scripts": {
"bootstrap": "npm run build",
"build": "run-p build:*",
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../src-tauri/vendored/plugin-runtime/index.cjs"
},
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.13"
}
}

View File

@@ -1,19 +0,0 @@
import type { InternalEvent } from '@yaakapp/api';
export class EventChannel {
#listeners = new Set<(event: InternalEvent) => void>();
emit(e: InternalEvent) {
for (const l of this.#listeners) {
l(e);
}
}
listen(cb: (e: InternalEvent) => void) {
this.#listeners.add(cb);
}
unlisten(cb: (e: InternalEvent) => void) {
this.#listeners.delete(cb);
}
}

View File

@@ -1,26 +0,0 @@
import { PluginContext } from '@yaakapp-internal/plugins';
import type { BootRequest, InternalEvent } from '@yaakapp/api';
import type { EventChannel } from './EventChannel';
import { PluginInstance, PluginWorkerData } from './PluginInstance';
export class PluginHandle {
#instance: PluginInstance;
constructor(
pluginRefId: string,
context: PluginContext,
bootRequest: BootRequest,
pluginToAppEvents: EventChannel,
) {
const workerData: PluginWorkerData = { pluginRefId, context, bootRequest };
this.#instance = new PluginInstance(workerData, pluginToAppEvents);
}
sendToWorker(event: InternalEvent) {
this.#instance.postMessage(event);
}
async terminate() {
await this.#instance.terminate();
}
}

View File

@@ -1,861 +0,0 @@
import { applyFormInputDefaults, validateTemplateFunctionArgs } from '@yaakapp-internal/lib/templateFunction';
import {
BootRequest,
DeleteKeyValueResponse,
DeleteModelResponse,
FindHttpResponsesResponse,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetKeyValueResponse,
GrpcRequestAction,
HttpAuthenticationAction,
HttpRequest,
HttpRequestAction,
InternalEvent,
InternalEventPayload,
ListCookieNamesResponse,
ListWorkspacesResponse,
PluginContext,
PromptTextResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateRenderResponse,
UpsertModelResponse,
WindowInfoResponse,
} from '@yaakapp-internal/plugins';
import { Context, PluginDefinition } from '@yaakapp/api';
import console from 'node:console';
import { type Stats, statSync, watch } from 'node:fs';
import path from 'node:path';
import { applyDynamicFormInput } from './common';
import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations';
export interface PluginWorkerData {
bootRequest: BootRequest;
pluginRefId: string;
context: PluginContext;
}
export class PluginInstance {
#workerData: PluginWorkerData;
#mod: PluginDefinition;
#pluginToAppEvents: EventChannel;
#appToPluginEvents: EventChannel;
constructor(workerData: PluginWorkerData, pluginEvents: EventChannel) {
this.#workerData = workerData;
this.#pluginToAppEvents = pluginEvents;
this.#appToPluginEvents = new EventChannel();
// Forward incoming events to onMessage()
this.#appToPluginEvents.listen(async (event) => {
await this.#onMessage(event);
});
this.#mod = {} as any;
const fileChangeCallback = async () => {
await this.#mod?.dispose?.();
this.#importModule();
await this.#mod?.init?.(this.#newCtx(workerData.context));
return this.#sendPayload(
workerData.context,
{
type: 'reload_response',
silent: false,
},
null,
);
};
if (this.#workerData.bootRequest.watch) {
watchFile(this.#pathMod(), fileChangeCallback);
watchFile(this.#pathPkg(), fileChangeCallback);
}
this.#importModule();
}
postMessage(event: InternalEvent) {
this.#appToPluginEvents.emit(event);
}
async terminate() {
await this.#mod?.dispose?.();
this.#unimportModule();
}
async #onMessage(event: InternalEvent) {
const ctx = this.#newCtx(event.context);
const { context, payload, id: replyId } = event;
try {
if (payload.type === 'boot_request') {
await this.#mod?.init?.(ctx);
this.#sendPayload(context, { type: 'boot_response' }, replyId);
return;
}
if (payload.type === 'terminate_request') {
const payload: InternalEventPayload = {
type: 'terminate_response',
};
await this.terminate();
this.#sendPayload(context, payload, replyId);
return;
}
if (
payload.type === 'import_request' &&
typeof this.#mod?.importer?.onImport === 'function'
) {
const reply = await this.#mod.importer.onImport(ctx, {
text: payload.content,
});
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: 'import_response',
// deno-lint-ignore no-explicit-any
resources: reply.resources as any,
};
this.#sendPayload(context, replyPayload, replyId);
return;
} else {
// Send back an empty reply (below)
}
}
if (payload.type === 'filter_request' && typeof this.#mod?.filter?.onFilter === 'function') {
const reply = await this.#mod.filter.onFilter(ctx, {
filter: payload.filter,
payload: payload.content,
mimeType: payload.type,
});
this.#sendPayload(context, { type: 'filter_response', ...reply }, replyId);
return;
}
if (
payload.type === 'get_grpc_request_actions_request' &&
Array.isArray(this.#mod?.grpcRequestActions)
) {
const reply: GrpcRequestAction[] = this.#mod.grpcRequestActions.map((a) => ({
...a,
// Add everything except onSelect
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_grpc_request_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (
payload.type === 'get_http_request_actions_request' &&
Array.isArray(this.#mod?.httpRequestActions)
) {
const reply: HttpRequestAction[] = this.#mod.httpRequestActions.map((a) => ({
...a,
// Add everything except onSelect
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_http_request_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (
payload.type === 'get_websocket_request_actions_request' &&
Array.isArray(this.#mod?.websocketRequestActions)
) {
const reply = this.#mod.websocketRequestActions.map((a) => ({
...a,
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_websocket_request_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (
payload.type === 'get_workspace_actions_request' &&
Array.isArray(this.#mod?.workspaceActions)
) {
const reply = this.#mod.workspaceActions.map((a) => ({
...a,
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_workspace_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (
payload.type === 'get_folder_actions_request' &&
Array.isArray(this.#mod?.folderActions)
) {
const reply = this.#mod.folderActions.map((a) => ({
...a,
onSelect: undefined,
}));
const replyPayload: InternalEventPayload = {
type: 'get_folder_actions_response',
pluginRefId: this.#workerData.pluginRefId,
actions: reply,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (payload.type === 'get_themes_request' && Array.isArray(this.#mod?.themes)) {
const replyPayload: InternalEventPayload = {
type: 'get_themes_response',
themes: this.#mod.themes,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (
payload.type === 'get_template_function_summary_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const functions: TemplateFunction[] = this.#mod.templateFunctions.map(
(templateFunction) => {
return {
...migrateTemplateFunctionSelectOptions(templateFunction),
// Add everything except render
onRender: undefined,
};
},
);
const replyPayload: InternalEventPayload = {
type: 'get_template_function_summary_response',
pluginRefId: this.#workerData.pluginRefId,
functions,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (
payload.type === 'get_template_function_config_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
let templateFunction = this.#mod.templateFunctions.find((f) => f.name === payload.name);
if (templateFunction == null) {
this.#sendEmpty(context, replyId);
return;
}
const fn = {
...migrateTemplateFunctionSelectOptions(templateFunction),
onRender: undefined,
};
payload.values = applyFormInputDefaults(fn.args, payload.values);
const p = { ...payload, purpose: 'preview' } as const;
const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, p);
const replyPayload: InternalEventPayload = {
type: 'get_template_function_config_response',
pluginRefId: this.#workerData.pluginRefId,
function: { ...fn, args: resolvedArgs },
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_summary_request' && this.#mod?.authentication) {
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_summary_response',
...this.#mod.authentication,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (payload.type === 'get_http_authentication_config_request' && this.#mod?.authentication) {
const { args, actions } = this.#mod.authentication;
payload.values = applyFormInputDefaults(args, payload.values);
const resolvedArgs = await applyDynamicFormInput(ctx, args, payload);
const resolvedActions: HttpAuthenticationAction[] = [];
for (const { onSelect, ...action } of actions ?? []) {
resolvedActions.push(action);
}
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_config_response',
args: resolvedArgs,
actions: resolvedActions,
pluginRefId: this.#workerData.pluginRefId,
};
this.#sendPayload(context, replyPayload, replyId);
return;
}
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') {
auth.args = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(auth.args, payload.values);
this.#sendPayload(
context,
{
type: 'call_http_authentication_response',
...(await auth.onApply(ctx, payload)),
},
replyId,
);
return;
}
}
if (
payload.type === 'call_http_authentication_action_request' &&
this.#mod.authentication != null
) {
const action = this.#mod.authentication.actions?.[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(context, replyId);
return;
}
}
if (
payload.type === 'call_http_request_action_request' &&
Array.isArray(this.#mod.httpRequestActions)
) {
const action = this.#mod.httpRequestActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(context, replyId);
return;
}
}
if (
payload.type === 'call_websocket_request_action_request' &&
Array.isArray(this.#mod.websocketRequestActions)
) {
const action = this.#mod.websocketRequestActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(context, replyId);
return;
}
}
if (
payload.type === 'call_workspace_action_request' &&
Array.isArray(this.#mod.workspaceActions)
) {
const action = this.#mod.workspaceActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(context, replyId);
return;
}
}
if (
payload.type === 'call_folder_action_request' &&
Array.isArray(this.#mod.folderActions)
) {
const action = this.#mod.folderActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(context, replyId);
return;
}
}
if (
payload.type === 'call_grpc_request_action_request' &&
Array.isArray(this.#mod.grpcRequestActions)
) {
const action = this.#mod.grpcRequestActions[payload.index];
if (typeof action?.onSelect === 'function') {
await action.onSelect(ctx, payload.args);
this.#sendEmpty(context, replyId);
return;
}
}
if (
payload.type === 'call_template_function_request' &&
Array.isArray(this.#mod?.templateFunctions)
) {
const fn = this.#mod.templateFunctions.find((a) => a.name === payload.name);
if (
payload.args.purpose === 'preview' &&
(fn?.previewType === 'click' || fn?.previewType === 'none')
) {
// Send empty render response
this.#sendPayload(
context,
{
type: 'call_template_function_response',
value: null,
error: 'Live preview disabled for this function',
},
replyId,
);
} else if (typeof fn?.onRender === 'function') {
const resolvedArgs = await applyDynamicFormInput(ctx, fn.args, payload.args);
const values = applyFormInputDefaults(resolvedArgs, payload.args.values);
const error = validateTemplateFunctionArgs(fn.name, resolvedArgs, values);
if (error && payload.args.purpose !== 'preview') {
this.#sendPayload(
context,
{ type: 'call_template_function_response', value: null, error },
replyId,
);
return;
}
try {
const result = await fn.onRender(ctx, { ...payload.args, values });
this.#sendPayload(
context,
{ type: 'call_template_function_response', value: result ?? null },
replyId,
);
} catch (err) {
this.#sendPayload(
context,
{
type: 'call_template_function_response',
value: null,
error: `${err}`.replace(/^Error:\s*/g, ''),
},
replyId,
);
}
return;
}
}
} catch (err) {
const error = `${err}`.replace(/^Error:\s*/g, '');
console.log('Plugin call threw exception', payload.type, '→', error);
this.#sendPayload(context, { type: 'error_response', error }, replyId);
return;
}
// No matches, so send back an empty response so the caller doesn't block forever
this.#sendEmpty(context, replyId);
}
#pathMod() {
return path.posix.join(this.#workerData.bootRequest.dir, 'build', 'index.js');
}
#pathPkg() {
return path.join(this.#workerData.bootRequest.dir, 'package.json');
}
#unimportModule() {
const id = require.resolve(this.#pathMod());
delete require.cache[id];
}
#importModule() {
const id = require.resolve(this.#pathMod());
delete require.cache[id];
this.#mod = require(id).plugin;
}
#buildEventToSend(
context: PluginContext,
payload: InternalEventPayload,
replyId: string | null = null,
): InternalEvent {
return {
pluginRefId: this.#workerData.pluginRefId,
pluginName: path.basename(this.#workerData.bootRequest.dir),
id: genId(),
replyId,
payload,
context,
};
}
#sendPayload(
context: PluginContext,
payload: InternalEventPayload,
replyId: string | null,
): string {
const event = this.#buildEventToSend(context, payload, replyId);
this.#sendEvent(event);
return event.id;
}
#sendEvent(event: InternalEvent) {
// if (event.payload.type !== 'empty_response') {
// console.log('Sending event to app', this.#pkg.name, event.id, event.payload.type);
// }
this.#pluginToAppEvents.emit(event);
}
#sendEmpty(context: PluginContext, replyId: string | null = null): string {
return this.#sendPayload(context, { type: 'empty_response' }, replyId);
}
#sendForReply<T extends Omit<InternalEventPayload, 'type'>>(
context: PluginContext,
payload: InternalEventPayload,
): Promise<T> {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(context, payload, null);
// 2. Spawn listener in background
const promise = new Promise<T>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
this.#appToPluginEvents.unlisten(cb); // Unlisten, now that we're done
const { type: _, ...payload } = event.payload;
resolve(payload as T);
}
};
this.#appToPluginEvents.listen(cb);
});
// 3. Send the event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
// 4. Return the listener promise
return promise as unknown as Promise<T>;
}
#sendAndListenForEvents(
context: PluginContext,
payload: InternalEventPayload,
onEvent: (event: InternalEventPayload) => void,
): void {
// 1. Build event to send
const eventToSend = this.#buildEventToSend(context, payload, null);
// 2. Listen for replies in the background
this.#appToPluginEvents.listen((event: InternalEvent) => {
if (event.replyId === eventToSend.id) {
onEvent(event.payload);
}
});
// 3. Send the event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
}
#newCtx(context: PluginContext): Context {
const _windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
}
const payload: InternalEventPayload = {
type: 'window_info_request',
label: context.label,
};
return this.#sendForReply<WindowInfoResponse>(context, payload);
};
return {
clipboard: {
copyText: async (text) => {
await this.#sendForReply(context, {
type: 'copy_text_request',
text,
});
},
},
toast: {
show: async (args) => {
await this.#sendForReply(context, {
type: 'show_toast_request',
// Handle default here because null/undefined both convert to None in Rust translation
timeout: args.timeout === undefined ? 5000 : args.timeout,
...args,
});
},
},
window: {
requestId: async () => {
return (await _windowInfo()).requestId;
},
async workspaceId(): Promise<string | null> {
return (await _windowInfo()).workspaceId;
},
async environmentId(): Promise<string | null> {
return (await _windowInfo()).environmentId;
},
openUrl: async ({ onNavigate, onClose, ...args }) => {
args.label = args.label || `${Math.random()}`;
const payload: InternalEventPayload = { type: 'open_window_request', ...args };
const onEvent = (event: InternalEventPayload) => {
if (event.type === 'window_navigate_event') {
onNavigate?.(event);
} else if (event.type === 'window_close_event') {
onClose?.();
}
};
this.#sendAndListenForEvents(context, payload, onEvent);
return {
close: () => {
const closePayload: InternalEventPayload = {
type: 'close_window_request',
label: args.label,
};
this.#sendPayload(context, closePayload, null);
},
};
},
},
prompt: {
text: async (args) => {
const reply: PromptTextResponse = await this.#sendForReply(context, {
type: 'prompt_text_request',
...args,
});
return reply.value;
},
},
httpResponse: {
find: async (args) => {
const payload = {
type: 'find_http_responses_request',
...args,
} as const;
const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(
context,
payload,
);
return httpResponses;
},
},
grpcRequest: {
render: async (args) => {
const payload = {
type: 'render_grpc_request_request',
...args,
} as const;
const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(
context,
payload,
);
return grpcRequest;
},
},
httpRequest: {
getById: async (args) => {
const payload = {
type: 'get_http_request_by_id_request',
...args,
} as const;
const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(
context,
payload,
);
return httpRequest;
},
send: async (args) => {
const payload = {
type: 'send_http_request_request',
...args,
} as const;
const { httpResponse } = await this.#sendForReply<SendHttpRequestResponse>(
context,
payload,
);
return httpResponse;
},
render: async (args) => {
const payload = {
type: 'render_http_request_request',
...args,
} as const;
const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(
context,
payload,
);
return httpRequest;
},
list: async (args?: { folderId?: string }) => {
const payload = {
type: 'list_http_requests_request',
folderId: args?.folderId,
} as any;
const { httpRequests } = await this.#sendForReply<any>(context, payload);
return httpRequests as any[];
},
create: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
name: '',
method: 'GET',
...args,
id: '',
model: 'http_request',
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as HttpRequest;
},
update: async (args) => {
const payload = {
type: 'upsert_model_request',
model: {
model: 'http_request',
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as HttpRequest;
},
delete: async (args) => {
const payload = {
type: 'delete_model_request',
model: 'http_request',
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as HttpRequest;
},
},
folder: {
list: async () => {
const payload = {
type: 'list_folders_request',
} as any;
const { folders } = await this.#sendForReply<any>(context, payload);
return folders as any[];
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {
const payload = {
type: 'get_cookie_value_request',
...args,
} as const;
const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);
return value;
},
listNames: async () => {
const payload = { type: 'list_cookie_names_request' } as const;
const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);
return names;
},
},
templates: {
/**
* Invoke Yaak's template engine to render a value. If the value is a nested type
* (eg. object), it will be recursively rendered.
*/
render: async (args) => {
const payload = { type: 'template_render_request', ...args } as const;
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
return result.data as any;
},
},
store: {
get: async <T>(key: string) => {
const payload = { type: 'get_key_value_request', key } as const;
const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
const valueStr = JSON.stringify(value);
const payload: InternalEventPayload = {
type: 'set_key_value_request',
key,
value: valueStr,
};
await this.#sendForReply<GetKeyValueResponse>(context, payload);
},
delete: async (key: string) => {
const payload = { type: 'delete_key_value_request', key } as const;
const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);
return result.deleted;
},
},
plugin: {
reload: () => {
this.#sendPayload(context, { type: 'reload_response', silent: true }, null);
},
},
workspace: {
list: async () => {
const payload = {
type: 'list_workspaces_request'
} as InternalEventPayload;
const response = await this.#sendForReply<ListWorkspacesResponse>(context, payload);
return response.workspaces.map((w) => ({
id: w.id,
name: w.name,
// Hide label from plugin authors, but keep it for internal routing
_label: (w as any).label as string,
}));
},
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
// Create a new context with the workspace's window label
const newContext: PluginContext = {
...context,
label: workspaceHandle._label || null,
workspaceId: workspaceHandle.id,
};
return this.#newCtx(newContext);
},
},
};
}
}
function genId(len = 5): string {
const alphabet = '01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
let id = '';
for (let i = 0; i < len; i++) {
id += alphabet[Math.floor(Math.random() * alphabet.length)];
}
return id;
}
const watchedFiles: Record<string, Stats | null> = {};
/**
* Watch a file and trigger a callback on change.
*
* We also track the stat for each file because fs.watch() will
* trigger a "change" event when the access date changes.
*/
function watchFile(filepath: string, cb: () => void) {
watch(filepath, () => {
const stat = statSync(filepath, { throwIfNoEntry: false });
if (stat == null || stat.mtimeMs !== watchedFiles[filepath]?.mtimeMs) {
watchedFiles[filepath] = stat ?? null;
cb();
}
});
}

View File

@@ -1,37 +0,0 @@
import { CallHttpAuthenticationActionArgs, CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicTemplateFunctionArg[],
callArgs: CallTemplateFunctionArgs,
): Promise<DynamicTemplateFunctionArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: DynamicAuthenticationArg[],
callArgs: CallHttpAuthenticationActionArgs,
): Promise<DynamicAuthenticationArg[]>;
export async function applyDynamicFormInput(
ctx: Context,
args: (DynamicTemplateFunctionArg | DynamicAuthenticationArg)[],
callArgs: CallTemplateFunctionArgs | CallHttpAuthenticationActionArgs,
): Promise<(DynamicTemplateFunctionArg | DynamicAuthenticationArg)[]> {
const resolvedArgs: any[] = [];
for (const { dynamic, ...arg } of args) {
const newArg: any = {
...arg,
...(typeof dynamic === 'function' ? await dynamic(ctx, callArgs as any) : undefined),
};
if ('inputs' in newArg && Array.isArray(newArg.inputs)) {
try {
newArg.inputs = await applyDynamicFormInput(ctx, newArg.inputs, callArgs as any);
} catch (e) {
console.error('Failed to apply dynamic form input', e);
}
}
resolvedArgs.push(newArg);
}
return resolvedArgs;
}

View File

@@ -1,64 +0,0 @@
import type { InternalEvent } from '@yaakapp/api';
import { EventChannel } from './EventChannel';
import { PluginHandle } from './PluginHandle';
import WebSocket from 'ws';
const port = process.env.PORT;
if (!port) {
throw new Error('Plugin runtime missing PORT')
}
const host = process.env.HOST;
if (!host) {
throw new Error('Plugin runtime missing HOST')
}
const pluginToAppEvents = new EventChannel();
const plugins: Record<string, PluginHandle> = {};
const ws = new WebSocket(`ws://${host}:${port}`);
ws.on('message', async (e: Buffer) => {
try {
await handleIncoming(e.toString());
} catch (err) {
console.log('Failed to handle incoming plugin event', err);
}
});
ws.on('open', () => console.log('Plugin runtime connected to websocket'));
ws.on('error', (err: any) => console.error('Plugin runtime websocket error', err));
ws.on('close', (code: number) => console.log('Plugin runtime websocket closed', code));
// Listen for incoming events from plugins
pluginToAppEvents.listen((e) => {
const eventStr = JSON.stringify(e);
ws.send(eventStr);
});
async function handleIncoming(msg: string) {
const pluginEvent: InternalEvent = JSON.parse(msg);
// Handle special event to bootstrap plugin
if (pluginEvent.payload.type === 'boot_request') {
const plugin = new PluginHandle(pluginEvent.pluginRefId, pluginEvent.context, pluginEvent.payload, pluginToAppEvents);
plugins[pluginEvent.pluginRefId] = plugin;
}
// Once booted, forward all events to the plugin worker
const plugin = plugins[pluginEvent.pluginRefId];
if (!plugin) {
console.warn('Failed to get plugin for event by', pluginEvent.pluginRefId);
return;
}
if (pluginEvent.payload.type === 'terminate_request') {
await plugin.terminate();
console.log('Terminated plugin worker', pluginEvent.pluginRefId);
delete plugins[pluginEvent.pluginRefId];
}
plugin.sendToWorker(pluginEvent);
}
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

View File

@@ -1,37 +0,0 @@
import process from "node:process";
export function interceptStdout(
intercept: (text: string) => string,
) {
const old_stdout_write = process.stdout.write;
const old_stderr_write = process.stderr.write;
process.stdout.write = (function (write) {
return function (text: string) {
arguments[0] = interceptor(text, intercept);
// deno-lint-ignore no-explicit-any
write.apply(process.stdout, arguments as any);
return true;
};
})(process.stdout.write);
process.stderr.write = (function (write) {
return function (text: string) {
arguments[0] = interceptor(text, intercept);
// deno-lint-ignore no-explicit-any
write.apply(process.stderr, arguments as any);
return true;
};
})(process.stderr.write);
// puts back to original
return function unhook() {
process.stdout.write = old_stdout_write;
process.stderr.write = old_stderr_write;
};
}
function interceptor(text: string, fn: (text: string) => string) {
return fn(text).replace(/\n$/, "") +
(fn(text) && /\n$/.test(text) ? "\n" : "");
}

View File

@@ -1,17 +0,0 @@
import type { TemplateFunctionPlugin } from '@yaakapp/api';
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === 'select') {
a.options = a.options.map((o) => ({
...o,
label: o.label || (o as any).name,
}));
}
return a;
});
return { ...f, args: migratedArgs };
}

View File

@@ -1,151 +0,0 @@
import { applyFormInputDefaults } from '@yaakapp-internal/lib/templateFunction';
import { CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
import { Context, DynamicTemplateFunctionArg } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { applyDynamicFormInput } from '../src/common';
describe('applyFormInputDefaults', () => {
test('Works with top-level select', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
});
test('Works with existing value', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
];
expect(applyFormInputDefaults(args, { test: 'explicit' })).toEqual({
test: 'explicit',
});
});
test('Works with recursive select', () => {
const args: DynamicTemplateFunctionArg[] = [
{ type: 'text', name: 'dummy', defaultValue: 'top' },
{
type: 'accordion',
label: 'Test',
inputs: [
{ type: 'text', name: 'name', defaultValue: 'hello' },
{
type: 'select',
name: 'test',
options: [{ label: 'Option 1', value: 'one' }],
defaultValue: 'one',
},
],
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
dummy: 'top',
test: 'one',
name: 'hello',
});
});
test('Works with dynamic options', () => {
const args: DynamicTemplateFunctionArg[] = [
{
type: 'select',
name: 'test',
defaultValue: 'one',
options: [],
dynamic() {
return { options: [{ label: 'Option 1', value: 'one' }] };
},
},
];
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
expect(applyFormInputDefaults(args, {})).toEqual({
test: 'one',
});
});
});
describe('applyDynamicFormInput', () => {
test('Works with plain input', async () => {
const ctx = {} as Context;
const args: DynamicTemplateFunctionArg[] = [
{ type: 'text', name: 'name' },
{ type: 'checkbox', name: 'checked' },
];
const callArgs: CallTemplateFunctionArgs = {
values: {},
purpose: 'preview',
};
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{ type: 'text', name: 'name' },
{ type: 'checkbox', name: 'checked' },
]);
});
test('Works with dynamic input', async () => {
const ctx = {} as Context;
const args: DynamicTemplateFunctionArg[] = [
{
type: 'text',
name: 'name',
async dynamic(_ctx, _args) {
return { hidden: true };
},
},
];
const callArgs: CallTemplateFunctionArgs = {
values: {},
purpose: 'preview',
};
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{ type: 'text', name: 'name', hidden: true },
]);
});
test('Works with recursive dynamic input', async () => {
const ctx = {} as Context;
const callArgs: CallTemplateFunctionArgs = {
values: { hello: 'world' },
purpose: 'preview',
};
const args: DynamicTemplateFunctionArg[] = [
{
type: 'banner',
inputs: [
{
type: 'text',
name: 'name',
async dynamic(_ctx, args) {
return { hidden: args.values.hello === 'world' };
},
},
],
},
];
expect(await applyDynamicFormInput(ctx, args, callArgs)).toEqual([
{
type: 'banner',
inputs: [
{
type: 'text',
name: 'name',
hidden: true,
},
],
},
]);
});
});

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"module": "node16",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "es2021",
"lib": ["es2021"],
"noImplicitAny": false,
"moduleResolution": "node16",
"resolveJsonModule": true,
"sourceMap": true,
"outDir": "build",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
"src/types/*"
]
}
},
"include": [
"src"
]
}

View File

@@ -1,76 +0,0 @@
# Yaak Faker Plugin
This is a template function that generates realistic fake data
for testing and development using [FakerJS](https://fakerjs.dev).
![CleanShot 2024-09-19 at 13 56 33@2x](https://github.com/user-attachments/assets/2f935110-4af2-4236-a50d-18db5454176d)
## Example JSON Body
Here's an example JSON body that uses fake data:
```json
{
"id": "${[ faker.string.uuid() ]}",
"name": "${[ faker.person.fullName() ]}",
"email": "${[ faker.internet.email() ]}",
"phone": "${[ faker.phone.number() ]}",
"address": {
"street": "${[ faker.location.streetAddress() ]}",
"city": "${[ faker.location.city() ]}",
"country": "${[ faker.location.country() ]}",
"zipCode": "${[ faker.location.zipCode() ]}"
},
"company": "${[ faker.company.name() ]}",
"website": "${[ faker.internet.url() ]}"
}
```
This will generate a random JSON body on every request:
```json
{
"id": "589f0aec-7310-4bf2-81c4-0b1bb7f1c3c1",
"name": "Lucy Gottlieb-Weissnat",
"email": "Destiny_Herzog@gmail.com",
"phone": "411.805.2871 x699",
"address": {
"street": "846 Christ Mills",
"city": "Spencerfurt",
"country": "United Kingdom",
"zipCode": "20354"
},
"company": "Emard, Kohler and Rutherford",
"website": "https://watery-detective.org"
}
```
## Available Categories
The plugin provides access to all FakerJS modules and their methods:
| Category | Description | Example Methods |
|------------|---------------------------|--------------------------------------------|
| `airline` | Airline-related data | `aircraftType`, `airline`, `airplane` |
| `animal` | Animal names and types | `bear`, `bird`, `cat`, `dog`, `fish` |
| `color` | Colors in various formats | `human`, `rgb`, `hex`, `hsl` |
| `commerce` | E-commerce data | `department`, `product`, `price` |
| `company` | Company information | `name`, `catchPhrase`, `bs` |
| `database` | Database-related data | `column`, `type`, `collation` |
| `date` | Date and time values | `recent`, `future`, `past`, `between` |
| `finance` | Financial data | `account`, `amount`, `currency` |
| `git` | Git-related data | `branch`, `commitEntry`, `commitSha` |
| `hacker` | Tech/hacker terminology | `abbreviation`, `noun`, `phrase` |
| `image` | Image URLs and data | `avatar`, `url`, `dataUri` |
| `internet` | Internet-related data | `email`, `url`, `ip`, `userAgent` |
| `location` | Geographic data | `city`, `country`, `latitude`, `longitude` |
| `lorem` | Lorem ipsum text | `word`, `sentence`, `paragraph` |
| `person` | Personal information | `firstName`, `lastName`, `fullName` |
| `music` | Music-related data | `genre`, `songName`, `artist` |
| `number` | Numeric data | `int`, `float`, `binary`, `hex` |
| `phone` | Phone numbers | `number`, `imei` |
| `science` | Scientific data | `chemicalElement`, `unit` |
| `string` | String utilities | `uuid`, `alpha`, `alphanumeric` |
| `system` | System-related data | `fileName`, `mimeType`, `fileExt` |
| `vehicle` | Vehicle information | `vehicle`, `manufacturer`, `model` |
| `word` | Word generation | `adjective`, `adverb`, `conjunction` |

View File

@@ -1,23 +0,0 @@
{
"name": "@yaakapp/faker",
"private": true,
"version": "0.1.0",
"displayName": "Faker",
"description": "Template functions for generating fake data using FakerJS",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins-external/faker"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"@faker-js/faker": "^10.1.0"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
}

View File

@@ -1,106 +0,0 @@
import { faker } from '@faker-js/faker';
import type { PluginDefinition, TemplateFunctionArg } from '@yaakapp/api';
const modules = [
'airline',
'animal',
'color',
'commerce',
'company',
'database',
'date',
'finance',
'git',
'hacker',
'image',
'internet',
'location',
'lorem',
'person',
'music',
'number',
'phone',
'science',
'string',
'system',
'vehicle',
'word',
];
function normalizeResult(result: unknown): string {
if (typeof result === 'string') return result;
return JSON.stringify(result);
}
// Whatever Yaaks arg type shape is rough example
function args(modName: string, fnName: string): TemplateFunctionArg[] {
return [
{
type: 'banner',
color: 'info',
inputs: [
{
type: 'markdown',
content: `Need help? View documentation for [\`${modName}.${fnName}(…)\`](https://fakerjs.dev/api/${encodeURIComponent(modName)}.html#${encodeURIComponent(fnName)})`,
},
],
},
{
name: 'options',
label: 'Arguments',
type: 'editor',
language: 'json',
optional: true,
placeholder: 'e.g. { "min": 1, "max": 10 } or 10 or ["en","US"]',
},
];
}
export const plugin: PluginDefinition = {
templateFunctions: modules.flatMap((modName) => {
// @ts-expect-error - Dynamic access to faker modules
const mod = faker[modName];
return Object.keys(mod)
.filter((n) => n !== 'faker')
.map((fnName) => ({
name: ['faker', modName, fnName].join('.'),
args: args(modName, fnName),
async onRender(_ctx, args) {
const fn = mod[fnName] as (...a: unknown[]) => unknown;
const options = args.values.options;
// No options supplied
if (options == null || options === '') {
return normalizeResult(fn());
}
// Try JSON first
let parsed: unknown = options;
if (typeof options === 'string') {
try {
parsed = JSON.parse(options);
} catch {
// Not valid JSON maybe just a scalar
const n = Number(options);
if (!Number.isNaN(n)) {
parsed = n;
} else {
parsed = options;
}
}
}
let result: unknown;
if (Array.isArray(parsed)) {
// Treat as positional arguments
result = fn(...parsed);
} else {
// Treat as a single argument (option object or scalar)
result = fn(parsed);
}
return normalizeResult(result);
},
}));
}),
};

View File

@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -1,54 +0,0 @@
# Yaak MCP Server Plugin
A Yaak plugin that exposes Yaak's functionality via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), allowing AI assistants and other tools to interact with Yaak programmatically.
## Features
This plugin starts an MCP server on `http://127.0.0.1:64343/mcp` that provides tools for:
### HTTP Requests
- `list_http_requests` - List all HTTP requests in a workspace
- `get_http_request` - Get details of a specific HTTP request
- `send_http_request` - Send an HTTP request and get the response
- `create_http_request` - Create a new HTTP request
- `update_http_request` - Update an existing HTTP request
- `delete_http_request` - Delete an HTTP request
### Folders
- `list_folders` - List all folders in a workspace
### Workspaces
- `list_workspaces` - List all open workspaces in Yaak
### Clipboard
- `copy_to_clipboard` - Copy text to the system clipboard
### Window
- `get_workspace_id` - Get the current workspace ID
- `get_environment_id` - Get the current environment ID
### Toast Notifications
- `show_toast` - Show a toast notification in Yaak
## Usage
Once the plugin is installed and Yaak is running, the MCP server will be available at:
```
http://127.0.0.1:64343/mcp
```
Configure your MCP client to connect to this endpoint to start interacting with Yaak.
## Development
```bash
# Install dependencies
npm install
# Build the plugin
npm run build
# Development mode with auto-rebuild
npm run dev
```

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{
"name": "@yaakapp/mcp-server",
"private": true,
"version": "0.1.0",
"displayName": "MCP Server",
"description": "Expose Yaak functionality via Model Context Protocol",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins-external/mcp-server"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
},
"dependencies": {
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.1",
"hono": "^4.11.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^5.9.3"
}
}

View File

@@ -1,23 +0,0 @@
import type { Context, PluginDefinition } from '@yaakapp/api';
import { createMcpServer } from './server.js';
const serverPort = 64343;
let mcpServer: Awaited<ReturnType<typeof createMcpServer>> | null = null;
export const plugin: PluginDefinition = {
async init(ctx: Context) {
console.log('Initializing MCP Server plugin');
mcpServer = createMcpServer({ yaak: ctx }, serverPort);
},
async dispose() {
console.log('Disposing MCP Server plugin');
if (mcpServer) {
await mcpServer.close();
mcpServer = null;
}
},
};

View File

@@ -1,55 +0,0 @@
import { serve } from '@hono/node-server';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { Hono } from 'hono';
import { registerClipboardTools } from './tools/clipboard.js';
import { registerFolderTools } from './tools/folder.js';
import { registerHttpRequestTools } from './tools/httpRequest.js';
import { registerToastTools } from './tools/toast.js';
import { registerWindowTools } from './tools/window.js';
import { registerWorkspaceTools } from './tools/workspace.js';
import type { McpServerContext } from './types.js';
export function createMcpServer(ctx: McpServerContext, port: number) {
const server = new McpServer({
name: 'yaak-mcp-server',
version: '0.1.0',
});
// Register all tools
registerToastTools(server, ctx);
registerClipboardTools(server, ctx);
registerHttpRequestTools(server, ctx);
registerFolderTools(server, ctx);
registerWindowTools(server, ctx);
registerWorkspaceTools(server, ctx);
// Create a stateless transport
const transport = new WebStandardStreamableHTTPServerTransport();
// Create Hono app
const app = new Hono();
// MCP endpoint - reuse the same transport for all requests
app.all('/mcp', (c) => transport.handleRequest(c.req.raw));
// Connect server to transport
server.connect(transport).then(() => {
console.log(`MCP Server running at http://127.0.0.1:${port}/mcp`);
});
// Start the HTTP server
const honoServer = serve({
fetch: app.fetch,
port,
hostname: '127.0.0.1',
});
return {
server,
close: async () => {
honoServer.close();
await server.close();
},
};
}

View File

@@ -1,28 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
export function registerClipboardTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
'copy_to_clipboard',
{
title: 'Copy to Clipboard',
description: 'Copy text to the system clipboard',
inputSchema: z.object({
text: z.string().describe('The text to copy'),
}),
},
async ({ text }) => {
await ctx.yaak.clipboard.copyText(text);
return {
content: [
{
type: 'text' as const,
text: `✓ Copied to clipboard: "${text.substring(0, 100)}${text.length > 100 ? '...' : ''}"`,
},
],
};
},
);
}

View File

@@ -1,33 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
export function registerFolderTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
'list_folders',
{
title: 'List Folders',
description: 'List all folders in a workspace',
inputSchema: z.object({
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
async ({ workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const folders = await workspaceCtx.yaak.folder.list();
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(folders, null, 2),
},
],
};
},
);
}

View File

@@ -1,32 +0,0 @@
import type { McpServerContext } from '../types.js';
export async function getWorkspaceContext(
ctx: McpServerContext,
workspaceId?: string,
): Promise<McpServerContext> {
const workspaces = await ctx.yaak.workspace.list();
if (!workspaceId && workspaces.length > 1) {
const workspaceList = workspaces.map((w, i) => `${i + 1}. ${w.name} (ID: ${w.id})`).join('\n');
throw new Error(
`Multiple workspaces are open. Please specify which workspace to use.\n\n` +
`Currently open workspaces:\n${workspaceList}\n\n` +
`You can use the list_workspaces tool to get workspace IDs, then use other tools ` +
`with the workspace context. For example, ask the user which workspace they want ` +
`to work with by presenting them with the numbered list above.`,
);
}
const workspace = workspaceId ? workspaces.find((w) => w.id === workspaceId) : workspaces[0];
if (!workspace) {
const workspaceList = workspaces.map((w) => `- ${w.name} (ID: ${w.id})`).join('\n');
throw new Error(
`Workspace with ID "${workspaceId}" not found.\n\n` +
`Available workspaces:\n${workspaceList}`,
);
}
return {
yaak: ctx.yaak.workspace.withContext(workspace),
};
}

View File

@@ -1,219 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
export function registerHttpRequestTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
'list_http_requests',
{
title: 'List HTTP Requests',
description: 'List all HTTP requests in a workspace',
inputSchema: z.object({
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
async ({ workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const requests = await workspaceCtx.yaak.httpRequest.list();
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(requests, null, 2),
},
],
};
},
);
server.registerTool(
'get_http_request',
{
title: 'Get HTTP Request',
description: 'Get details of a specific HTTP request by ID',
inputSchema: z.object({
id: z.string().describe('The HTTP request ID'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const request = await workspaceCtx.yaak.httpRequest.getById({ id });
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(request, null, 2),
},
],
};
},
);
server.registerTool(
'send_http_request',
{
title: 'Send HTTP Request',
description: 'Send an HTTP request and get the response',
inputSchema: z.object({
id: z.string().describe('The HTTP request ID to send'),
environmentId: z.string().optional().describe('Optional environment ID to use'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
}),
},
async ({ id, workspaceId }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const httpRequest = await workspaceCtx.yaak.httpRequest.getById({ id });
if (httpRequest == null) {
throw new Error(`HTTP request with ID ${id} not found`);
}
const response = await workspaceCtx.yaak.httpRequest.send({ httpRequest });
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(response, null, 2),
},
],
};
},
);
server.registerTool(
'create_http_request',
{
title: 'Create HTTP Request',
description: 'Create a new HTTP request',
inputSchema: z.object({
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
name: z
.string()
.optional()
.describe('Request name (empty string to auto-generate from URL)'),
url: z.string().describe('Request URL'),
method: z.string().optional().describe('HTTP method (defaults to GET)'),
folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'),
headers: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
}),
},
async ({ workspaceId: ogWorkspaceId, ...args }) => {
const workspaceCtx = await getWorkspaceContext(ctx, ogWorkspaceId);
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
if (!workspaceId) {
throw new Error('No workspace is open');
}
const httpRequest = await workspaceCtx.yaak.httpRequest.create({
workspaceId: workspaceId,
...args,
});
return {
content: [{ type: 'text' as const, text: JSON.stringify(httpRequest, null, 2) }],
};
},
);
server.registerTool(
'update_http_request',
{
title: 'Update HTTP Request',
description: 'Update an existing HTTP request',
inputSchema: z.object({
id: z.string().describe('HTTP request ID to update'),
workspaceId: z
.string()
.optional()
.describe('Workspace ID (required if multiple workspaces are open)'),
name: z.string().optional().describe('Request name'),
url: z.string().optional().describe('Request URL'),
method: z.string().optional().describe('HTTP method'),
folderId: z.string().optional().describe('Parent folder ID'),
description: z.string().optional().describe('Request description'),
headers: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('Request headers'),
urlParameters: z
.array(
z.object({
name: z.string(),
value: z.string(),
enabled: z.boolean().default(true),
}),
)
.optional()
.describe('URL query parameters'),
}),
},
async ({ id, workspaceId, ...updates }) => {
const workspaceCtx = await getWorkspaceContext(ctx, workspaceId);
const httpRequest = await workspaceCtx.yaak.httpRequest.update({ id, ...updates });
return {
content: [{ type: 'text' as const, text: JSON.stringify(httpRequest, null, 2) }],
};
},
);
server.registerTool(
'delete_http_request',
{
title: 'Delete HTTP Request',
description: 'Delete an HTTP request by ID',
inputSchema: z.object({
id: z.string().describe('HTTP request ID to delete'),
}),
},
async ({ id }) => {
const httpRequest = await ctx.yaak.httpRequest.delete({ id });
return {
content: [
{ type: 'text' as const, text: `Deleted: ${httpRequest.name} (${httpRequest.id})` },
],
};
},
);
}

View File

@@ -1,59 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { Color, Icon } from '@yaakapp/api';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
const ICON_VALUES = [
'alert_triangle',
'check',
'check_circle',
'chevron_down',
'copy',
'info',
'pin',
'search',
'trash',
] as const satisfies readonly Icon[];
const COLOR_VALUES = [
'primary',
'secondary',
'info',
'success',
'notice',
'warning',
'danger',
] as const satisfies readonly Color[];
export function registerToastTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
'show_toast',
{
title: 'Show Toast',
description: 'Show a toast notification in Yaak',
inputSchema: z.object({
message: z.string().describe('The message to display'),
icon: z.enum(ICON_VALUES).optional().describe('Icon name'),
color: z.enum(COLOR_VALUES).optional().describe('Toast color'),
timeout: z.number().optional().describe('Timeout in milliseconds'),
}),
},
async ({ message, icon, color, timeout }) => {
await ctx.yaak.toast.show({
message,
icon,
color,
timeout,
});
return {
content: [
{
type: 'text' as const,
text: `✓ Toast shown: "${message}"`,
},
],
};
},
);
}

View File

@@ -1,50 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
import { getWorkspaceContext } from './helpers.js';
export function registerWindowTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
'get_workspace_id',
{
title: 'Get Workspace ID',
description: 'Get the current workspace ID',
inputSchema: z.object({}),
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);
const workspaceId = await workspaceCtx.yaak.window.workspaceId();
return {
content: [
{
type: 'text' as const,
text: workspaceId || 'No workspace open',
},
],
};
},
);
server.registerTool(
'get_environment_id',
{
title: 'Get Environment ID',
description: 'Get the current environment ID',
inputSchema: z.object({}),
},
async () => {
const workspaceCtx = await getWorkspaceContext(ctx);
const environmentId = await workspaceCtx.yaak.window.environmentId();
return {
content: [
{
type: 'text' as const,
text: environmentId || 'No environment selected',
},
],
};
},
);
}

View File

@@ -1,26 +0,0 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import * as z from 'zod/v4';
import type { McpServerContext } from '../types.js';
export function registerWorkspaceTools(server: McpServer, ctx: McpServerContext) {
server.registerTool(
'list_workspaces',
{
title: 'List Workspaces',
description: 'List all open workspaces in Yaak',
inputSchema: z.object({}),
},
async () => {
const workspaces = await ctx.yaak.workspace.list();
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(workspaces, null, 2),
},
],
};
},
);
}

View File

@@ -1,5 +0,0 @@
import type { Context } from '@yaakapp/api';
export interface McpServerContext {
yaak: Context;
}

View File

@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}

1
plugins/.gitignore vendored
View File

@@ -1 +0,0 @@
*/build

View File

@@ -1,68 +0,0 @@
# Copy as cUrl
A request action plugin for Yaak that converts HTTP requests into [curl](https://curl.se)
commands, making it easy to share, debug, and execute requests outside Yaak.
![Screenshot of context menu](screenshot.png)
## Overview
This plugin adds a 'Copy as Curl' action to HTTP requests, converting any request into its
equivalent curl command. This is useful for debugging, sharing requests with team members,
and executing requests in terminal environments where `curl` is available.
## How It Works
The plugin analyzes the given HTTP request and generates a properly formatted curl command
that includes:
- HTTP method (GET, POST, PUT, DELETE, etc.)
- Request URL with query parameters
- Headers (including authentication headers)
- Request body (for POST, PUT, PATCH requests)
- Authentication credentials
## Usage
1. Configure an HTTP request as usual in Yaak
2. Right-click on the request in the sidebar
3. Select 'Copy as Curl'
4. The command is copied to your clipboard
5. Share or execute the command
## Generated Curl Examples
### Simple GET Request
```bash
curl -X GET 'https://api.example.com/users' \
--header 'Accept: application/json'
```
### POST Request with JSON Data
```bash
curl -X POST 'https://api.example.com/users' \
--header 'Content-Type: application/json' \
--header 'Accept: application/json' \
--data '{
"name": "John Doe",
"email": "john@example.com"
}'
```
### Request with Multi-part Form Data
```bash
curl -X POST 'yaak.app' \
--header 'Content-Type: multipart/form-data' \
--form 'hello=world' \
--form file=@/path/to/file.json
```
### Request with Authentication
```bash
curl -X GET 'https://api.example.com/protected' \
--user 'username:password'
```

View File

@@ -1,17 +0,0 @@
{
"name": "@yaak/action-copy-curl",
"displayName": "Copy as Curl",
"description": "Copy request as a curl command",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-copy-curl"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

View File

@@ -1,173 +0,0 @@
import type { HttpRequest, PluginDefinition } from '@yaakapp/api';
const NEWLINE = '\\\n ';
export const plugin: PluginDefinition = {
httpRequestActions: [
{
label: 'Copy as Curl',
icon: 'copy',
async onSelect(ctx, args) {
const rendered_request = await ctx.httpRequest.render({
httpRequest: args.httpRequest,
purpose: 'preview',
});
const data = await convertToCurl(rendered_request);
await ctx.clipboard.copyText(data);
await ctx.toast.show({
message: 'Command copied to clipboard',
icon: 'copy',
color: 'success',
});
},
},
],
};
export async function convertToCurl(request: Partial<HttpRequest>) {
const xs = ['curl'];
// Add method and URL all on first line
if (request.method) xs.push('-X', request.method);
// Build final URL with parameters (compatible with old curl)
let finalUrl = request.url || '';
const urlParams = (request.urlParameters ?? []).filter(onlyEnabled);
if (urlParams.length > 0) {
// Build url
const [base, hash] = finalUrl.split('#');
const separator = base?.includes('?') ? '&' : '?';
const queryString = urlParams
.map((p) => `${encodeURIComponent(p.name)}=${encodeURIComponent(p.value)}`)
.join('&');
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Add API key authentication
if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = finalUrl.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
} else {
request.headers = request.headers ?? [];
request.headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
}
}
xs.push(quote(finalUrl));
xs.push(NEWLINE);
// Add headers
for (const h of (request.headers ?? []).filter(onlyEnabled)) {
xs.push('--header', quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add form params
const type = request.bodyType ?? 'none';
if (
(type === 'multipart/form-data' || type === 'application/x-www-form-urlencoded') &&
Array.isArray(request.body?.form)
) {
const flag = request.bodyType === 'multipart/form-data' ? '--form' : '--data';
for (const p of (request.body?.form ?? []).filter(onlyEnabled)) {
if (p.file) {
let v = `${p.name}=@${p.file}`;
v += p.contentType ? `;type=${p.contentType}` : '';
xs.push(flag, v);
} else {
xs.push(flag, quote(`${p.name}=${p.value}`));
}
xs.push(NEWLINE);
}
} else if (type === 'graphql' && typeof request.body?.query === 'string') {
const body = {
query: request.body.query || '',
variables: maybeParseJSON(request.body.variables, undefined),
};
xs.push('--data', quote(JSON.stringify(body)));
xs.push(NEWLINE);
} else if (type !== 'none' && typeof request.body?.text === 'string') {
xs.push('--data', quote(request.body.text));
xs.push(NEWLINE);
}
// Add basic/digest authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic' || request.authenticationType === 'digest') {
if (request.authenticationType === 'digest') xs.push('--digest');
xs.push(
'--user',
quote(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
),
);
xs.push(NEWLINE);
}
// Add bearer authentication
if (request.authenticationType === 'bearer') {
const value =
`${request.authentication?.prefix ?? 'Bearer'} ${request.authentication?.token ?? ''}`.trim();
xs.push('--header', quote(`Authorization: ${value}`));
xs.push(NEWLINE);
}
if (request.authenticationType === 'auth-aws-sig-v4') {
xs.push(
'--aws-sigv4',
[
'aws',
'amz',
request.authentication?.region ?? '',
request.authentication?.service ?? '',
].join(':'),
);
xs.push(NEWLINE);
xs.push(
'--user',
quote(
`${request.authentication?.accessKeyId ?? ''}:${request.authentication?.secretAccessKey ?? ''}`,
),
);
if (request.authentication?.sessionToken) {
xs.push(NEWLINE);
xs.push('--header', quote(`X-Amz-Security-Token: ${request.authentication.sessionToken}`));
}
xs.push(NEWLINE);
}
}
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);
}
return xs.join(' ');
}
function quote(arg: string): string {
const escaped = arg.replace(/'/g, "\\'");
return `'${escaped}'`;
}
function onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {
return v.enabled !== false && !!v.name;
}
function maybeParseJSON<T>(v: string, fallback: T) {
try {
return JSON.parse(v);
} catch {
return fallback;
}
}

View File

@@ -1,414 +0,0 @@
import { describe, expect, test } from 'vitest';
import { convertToCurl } from '../src';
describe('exporter-curl', () => {
test('Exports GET with params', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
urlParameters: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
}),
).toEqual([`curl 'https://yaak.app?a=aaa&b=bbb'`].join(' \\n '));
});
test('Exports GET with params and hash', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app/path#section',
urlParameters: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
}),
).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(' \\n '));
});
test('Exports POST with url form data', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/x-www-form-urlencoded',
body: {
form: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data 'a=aaa'`, `--data 'b=bbb'`].join(' \\\n '),
);
});
test('Exports POST with GraphQL data', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'graphql',
body: {
query: '{foo,bar}',
variables: '{"a": "aaa", "b": "bbb"}',
},
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--data '{"query":"{foo,bar}","variables":{"a":"aaa","b":"bbb"}}'`,
].join(' \\\n '),
);
});
test('Exports POST with GraphQL data no variables', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'graphql',
body: {
query: '{foo,bar}',
},
}),
).toEqual(
[`curl -X POST 'https://yaak.app'`, `--data '{"query":"{foo,bar}"}'`].join(' \\\n '),
);
});
test('Exports PUT with multipart form', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'PUT',
bodyType: 'multipart/form-data',
body: {
form: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
{ name: 'f', file: '/foo/bar.png', contentType: 'image/png' },
],
},
}),
).toEqual(
[
`curl -X PUT 'https://yaak.app'`,
`--form 'a=aaa'`,
`--form 'b=bbb'`,
'--form f=@/foo/bar.png;type=image/png',
].join(' \\\n '),
);
});
test('Exports JSON body', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
body: {
text: `{"foo":"bar's"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar\\'s"}'`,
].join(' \\\n '),
);
});
test('Exports multi-line JSON body', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
method: 'POST',
bodyType: 'application/json',
body: {
text: `{"foo":"bar",\n"baz":"qux"}`,
},
headers: [{ name: 'Content-Type', value: 'application/json' }],
}),
).toEqual(
[
`curl -X POST 'https://yaak.app'`,
`--header 'Content-Type: application/json'`,
`--data '{"foo":"bar",\n"baz":"qux"}'`,
].join(' \\\n '),
);
});
test('Exports headers', async () => {
expect(
await convertToCurl({
headers: [
{ name: 'a', value: 'aaa' },
{ name: 'b', value: 'bbb', enabled: true },
{ name: 'c', value: 'ccc', enabled: false },
],
}),
).toEqual([`curl ''`, `--header 'a: aaa'`, `--header 'b: bbb'`].join(' \\\n '));
});
test('Basic auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(' \\\n '));
});
test('Basic auth disabled', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
disabled: true,
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`].join(' \\\n '));
});
test('Broken basic auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {},
}),
).toEqual([`curl 'https://yaak.app'`, `--user ':'`].join(' \\\n '));
});
test('Digest auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'digest',
authentication: {
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--digest --user 'user:pass'`].join(' \\\n '));
});
test('Bearer auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
token: 'tok',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer tok'`].join(' \\\n '));
});
test('Bearer auth with custom prefix', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
token: 'abc123',
prefix: 'Token',
},
}),
).toEqual(
[`curl 'https://yaak.app'`, `--header 'Authorization: Token abc123'`].join(' \\\n '),
);
});
test('Bearer auth with empty prefix', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
token: 'xyz789',
prefix: '',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: xyz789'`].join(' \\\n '));
});
test('Broken bearer auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'bearer',
authentication: {
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer'`].join(' \\\n '));
});
test('AWS v4 auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'auth-aws-sig-v4',
authentication: {
accessKeyId: 'ak',
secretAccessKey: 'sk',
sessionToken: '',
region: 'us-east-1',
service: 's3',
},
}),
).toEqual(
[`curl 'https://yaak.app'`, '--aws-sigv4 aws:amz:us-east-1:s3', `--user 'ak:sk'`].join(
' \\\n ',
),
);
});
test('AWS v4 auth with session', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'auth-aws-sig-v4',
authentication: {
accessKeyId: 'ak',
secretAccessKey: 'sk',
sessionToken: 'st',
region: 'us-east-1',
service: 's3',
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
'--aws-sigv4 aws:amz:us-east-1:s3',
`--user 'ak:sk'`,
`--header 'X-Amz-Security-Token: st'`,
].join(' \\\n '),
);
});
test('API key auth header', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'header',
key: 'X-Header',
value: 'my-token',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Header: my-token'`].join(' \\\n '));
});
test('API key auth header query', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?hi=there',
urlParameters: [{ name: 'param', value: 'hi' }],
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'foo',
value: 'bar',
},
}),
).toEqual([`curl 'https://yaak.app?hi=there&param=hi&foo=bar'`].join(' \\\n '));
});
test('API key auth header query with params', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
urlParameters: [{ name: 'param', value: 'hi' }],
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'foo',
value: 'bar',
},
}),
).toEqual([`curl 'https://yaak.app?param=hi&foo=bar'`].join(' \\\n '));
});
test('API key auth header default', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'header',
},
}),
).toEqual([`curl 'https://yaak.app'`, `--header 'X-Api-Key: '`].join(' \\\n '));
});
test('API key auth query', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'foo',
value: 'bar-baz',
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar-baz'`].join(' \\\n '));
});
test('API key auth query with existing', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'hi',
value: 'there',
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`].join(' \\\n '));
});
test('API key auth query default', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
authentication: {
location: 'query',
},
}),
).toEqual([`curl 'https://yaak.app?foo=bar&baz=qux&token='`].join(' \\\n '));
});
test('Stale body data', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
bodyType: 'none',
body: {
text: 'ignore me',
},
}),
).toEqual([`curl 'https://yaak.app'`].join(' \\\n '));
});
});

View File

@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -1,76 +0,0 @@
# Copy as gRPCurl
An HTTP request action plugin that converts gRPC requests
into [gRPCurl](https://github.com/fullstorydev/grpcurl) commands, enabling easy sharing,
debugging, and execution of gRPC calls outside Yaak.
![Screenshot of context menu](screenshot.png)
## Overview
This plugin adds a "Copy as gRPCurl" action to gRPC requests, converting any gRPC request
into its equivalent executable command. This is useful for debugging gRPC services,
sharing requests with team members, or executing gRPC calls in terminal environments where
`grpcurl` is available.
## How It Works
The plugin analyzes your gRPC request configuration and generates a properly formatted
`grpcurl` command that includes:
- gRPC service and method names
- Server address and port
- Request message data (JSON format)
- Metadata (headers)
- Authentication credentials
- Protocol buffer definitions
## Usage
1. Configure a gRPC request as usual in Yaak
2. Right-click on the request sidebar item
3. Select "Copy as gRPCurl" from the available actions
4. The command is copied to your clipboard
5. Share or execute the command
## Generated gRPCurl Examples
### Simple Unary Call
```bash
grpcurl -plaintext \
-d '{"name": "John Doe"}' \
localhost:9090 \
user.UserService/GetUser
```
### Call with Metadata
```bash
grpcurl -plaintext \
-H "authorization: Bearer my-token" \
-H "x-api-version: v1" \
-d '{"user_id": "12345"}' \
api.example.com:443 \
user.UserService/GetUserProfile
```
### Call with TLS
```bash
grpcurl \
-d '{"query": "search term"}' \
secure-api.example.com:443 \
search.SearchService/Search
```
### Call with Proto Files
```bash
grpcurl -import-path /path/to/protos \
-proto /other/path/to/user.proto \
-d '{"email": "user@example.com"}' \
localhost:9090 \
user.UserService/CreateUser
```

View File

@@ -1,17 +0,0 @@
{
"name": "@yaak/action-copy-grpcurl",
"displayName": "Copy as gRPCurl",
"description": "Copy gRPC request as a grpcurl command",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/action-copy-grpcurl"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vitest --run tests"
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

View File

@@ -1,155 +0,0 @@
import path from 'node:path';
import type { GrpcRequest, PluginDefinition } from '@yaakapp/api';
const NEWLINE = '\\\n ';
export const plugin: PluginDefinition = {
grpcRequestActions: [
{
label: 'Copy as gRPCurl',
icon: 'copy',
async onSelect(ctx, args) {
const rendered_request = await ctx.grpcRequest.render({
grpcRequest: args.grpcRequest,
purpose: 'preview',
});
const data = await convert(rendered_request, args.protoFiles);
await ctx.clipboard.copyText(data);
await ctx.toast.show({
message: 'Command copied to clipboard',
icon: 'copy',
color: 'success',
});
},
},
],
};
export async function convert(request: Partial<GrpcRequest>, allProtoFiles: string[]) {
const xs = ['grpcurl'];
if (request.url?.startsWith('http://')) {
xs.push('-plaintext');
}
const protoIncludes = allProtoFiles.filter((f) => !f.endsWith('.proto'));
const protoFiles = allProtoFiles.filter((f) => f.endsWith('.proto'));
const inferredIncludes = new Set<string>();
for (const f of protoFiles) {
const protoDir = findParentProtoDir(f);
if (protoDir) {
inferredIncludes.add(protoDir);
} else {
inferredIncludes.add(path.posix.join(f, '..'));
inferredIncludes.add(path.posix.join(f, '..', '..'));
}
}
for (const f of protoIncludes) {
xs.push('-import-path', quote(f));
xs.push(NEWLINE);
}
for (const f of inferredIncludes.values()) {
xs.push('-import-path', quote(f));
xs.push(NEWLINE);
}
for (const f of protoFiles) {
xs.push('-proto', quote(f));
xs.push(NEWLINE);
}
// Add headers
for (const h of (request.metadata ?? []).filter(onlyEnabled)) {
xs.push('-H', quote(`${h.name}: ${h.value}`));
xs.push(NEWLINE);
}
// Add basic authentication
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const user = request.authentication?.username ?? '';
const pass = request.authentication?.password ?? '';
const encoded = btoa(`${user}:${pass}`);
xs.push('-H', quote(`Authorization: Basic ${encoded}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'bearer') {
// Add bearer authentication
xs.push('-H', quote(`Authorization: Bearer ${request.authentication?.token ?? ''}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = request.url?.includes('?') ? '&' : '?';
request.url = [
request.url,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
} else {
xs.push(
'-H',
quote(
`${request.authentication?.key ?? 'X-Api-Key'}: ${request.authentication?.value ?? ''}`,
),
);
}
xs.push(NEWLINE);
}
}
// Add form params
if (request.message) {
xs.push('-d', `${quote(JSON.stringify(JSON.parse(request.message)))}`);
xs.push(NEWLINE);
}
// Add the server address
if (request.url) {
const server = request.url.replace(/^https?:\/\//, ''); // remove protocol
xs.push(server);
xs.push(NEWLINE);
}
// Add service + method
if (request.service && request.method) {
xs.push(`${request.service}/${request.method}`);
xs.push(NEWLINE);
}
// Remove trailing newline
if (xs[xs.length - 1] === NEWLINE) {
xs.splice(xs.length - 1, 1);
}
return xs.join(' ');
}
function quote(arg: string): string {
const escaped = arg.replace(/'/g, "\\'");
return `'${escaped}'`;
}
function onlyEnabled(v: { name?: string; enabled?: boolean }): boolean {
return v.enabled !== false && !!v.name;
}
function findParentProtoDir(startPath: string): string | null {
let dir = path.resolve(startPath);
while (true) {
if (path.basename(dir) === 'proto') {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) {
return null; // Reached root
}
dir = parent;
}
}

View File

@@ -1,159 +0,0 @@
import { describe, expect, test } from 'vitest';
import { convert } from '../src';
describe('exporter-curl', () => {
test('Simple example', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
},
[],
),
).toEqual(['grpcurl yaak.app'].join(' \\\n '));
});
test('Basic metadata', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
metadata: [
{ name: 'aaa', value: 'AAA' },
{ enabled: true, name: 'bbb', value: 'BBB' },
{ enabled: false, name: 'disabled', value: 'ddd' },
],
},
[],
),
).toEqual([`grpcurl -H 'aaa: AAA'`, `-H 'bbb: BBB'`, 'yaak.app'].join(' \\\n '));
});
test('Basic auth', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
username: 'user',
password: 'pass',
},
},
[],
),
).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, 'yaak.app'].join(' \\\n '));
});
test('API key auth', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
key: 'X-Token',
value: 'tok',
},
},
[],
),
).toEqual([`grpcurl -H 'X-Token: tok'`, 'yaak.app'].join(' \\\n '));
});
test('API key auth', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'token',
value: 'tok 1',
},
},
[],
),
).toEqual(['grpcurl', 'yaak.app?token=tok%201'].join(' \\\n '));
});
test('Single proto file', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/foo/bar/baz.proto'])).toEqual(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/baz.proto'`,
'yaak.app',
].join(' \\\n '),
);
});
test('Multiple proto files, same dir', async () => {
expect(
await convert({ url: 'https://yaak.app' }, ['/foo/bar/aaa.proto', '/foo/bar/bbb.proto']),
).toEqual(
[
`grpcurl -import-path '/foo/bar'`,
`-import-path '/foo'`,
`-proto '/foo/bar/aaa.proto'`,
`-proto '/foo/bar/bbb.proto'`,
'yaak.app',
].join(' \\\n '),
);
});
test('Multiple proto files, different dir', async () => {
expect(
await convert({ url: 'https://yaak.app' }, ['/aaa/bbb/ccc.proto', '/xxx/yyy/zzz.proto']),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
`-import-path '/aaa'`,
`-import-path '/xxx/yyy'`,
`-import-path '/xxx'`,
`-proto '/aaa/bbb/ccc.proto'`,
`-proto '/xxx/yyy/zzz.proto'`,
'yaak.app',
].join(' \\\n '),
);
});
test('Single include dir', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/aaa/bbb'])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, 'yaak.app'].join(' \\\n '),
);
});
test('Multiple include dir', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/aaa/bbb', '/xxx/yyy'])).toEqual(
[`grpcurl -import-path '/aaa/bbb'`, `-import-path '/xxx/yyy'`, 'yaak.app'].join(' \\\n '),
);
});
test('Mixed proto and dirs', async () => {
expect(
await convert({ url: 'https://yaak.app' }, ['/aaa/bbb', '/xxx/yyy', '/foo/bar.proto']),
).toEqual(
[
`grpcurl -import-path '/aaa/bbb'`,
`-import-path '/xxx/yyy'`,
`-import-path '/foo'`,
`-import-path '/'`,
`-proto '/foo/bar.proto'`,
'yaak.app',
].join(' \\\n '),
);
});
test('Sends data', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
message: JSON.stringify({ foo: 'bar', baz: 1.0 }, null, 2),
},
['/foo.proto'],
),
).toEqual(
[
`grpcurl -import-path '/'`,
`-proto '/foo.proto'`,
`-d '{"foo":"bar","baz":1}'`,
'yaak.app',
].join(' \\\n '),
);
});
});

View File

@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -1,16 +0,0 @@
{
"name": "@yaak/auth-apikey",
"displayName": "API Key Authentication",
"description": "Authenticate requests using an API key",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-apikey"
},
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
}
}

View File

@@ -1,54 +0,0 @@
import type { PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
authentication: {
name: 'apikey',
label: 'API Key',
shortLabel: 'API Key',
args: [
{
type: 'select',
name: 'location',
label: 'Behavior',
defaultValue: 'header',
options: [
{ label: 'Insert Header', value: 'header' },
{ label: 'Append Query Parameter', value: 'query' },
],
},
{
type: 'text',
name: 'key',
label: 'Key',
dynamic: (_ctx, { values }) => {
return values.location === 'query'
? {
label: 'Parameter Name',
description: 'The name of the query parameter to add to the request',
}
: {
label: 'Header Name',
description: 'The name of the header to add to the request',
};
},
},
{
type: 'text',
name: 'value',
label: 'API Key',
optional: true,
password: true,
},
],
async onApply(_ctx, { values }) {
const key = String(values.key ?? '');
const value = String(values.value ?? '');
const location = String(values.location);
if (location === 'query') {
return { setQueryParameters: [{ name: key, value }] };
}
return { setHeaders: [{ name: key, value }] };
},
},
};

View File

@@ -1,3 +0,0 @@
{
"extends": "../../tsconfig.json"
}

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