├── .changeset ├── README.md └── config.json ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── apps ├── cli │ ├── cli-core │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── index.ts │ │ ├── package.json │ │ ├── src │ │ │ ├── constants.ts │ │ │ ├── get-sample-hooks.ts │ │ │ ├── open-folder.ts │ │ │ ├── templateSubstitution.ts │ │ │ ├── trpc.ts │ │ │ ├── update-config.ts │ │ │ └── utils │ │ │ │ └── get-full-path.ts │ │ ├── transformer.ts │ │ └── tsconfig.json │ ├── cli-web │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── index.html │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── public │ │ │ ├── favicon.png │ │ │ ├── favicon.svg │ │ │ ├── plsbl.js │ │ │ └── vite.svg │ │ ├── src │ │ │ ├── App.tsx │ │ │ ├── assets │ │ │ │ └── react.svg │ │ │ ├── components │ │ │ │ ├── common │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── logo.tsx │ │ │ │ │ ├── modal.tsx │ │ │ │ │ └── tooltip.tsx │ │ │ │ ├── file-form-modal.tsx │ │ │ │ ├── filebrowser.tsx │ │ │ │ ├── filerunner.tsx │ │ │ │ ├── folder-form-modal.tsx │ │ │ │ └── response-viewer.tsx │ │ │ ├── index.css │ │ │ ├── main.tsx │ │ │ ├── router.tsx │ │ │ ├── utils │ │ │ │ ├── api-wrapper.tsx │ │ │ │ ├── api.ts │ │ │ │ ├── classnames.ts │ │ │ │ ├── configTransforms.ts │ │ │ │ ├── useConnectionStateToasts.tsx │ │ │ │ ├── useCurrentUrl.ts │ │ │ │ └── useRoute.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.cjs │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── cli │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── scripts │ │ └── build.js │ │ ├── src │ │ ├── consts.ts │ │ ├── index.ts │ │ ├── server.ts │ │ └── utils │ │ │ ├── createFile.ts │ │ │ ├── forceThingExists.ts │ │ │ ├── parseNameAndPath.ts │ │ │ └── renderTitle.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts ├── docs │ ├── .eslintrc.js │ ├── .gitignore │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── pages │ │ ├── _meta.json │ │ ├── img │ │ │ ├── download-samples.png │ │ │ ├── home.png │ │ │ └── run-samples.png │ │ └── index.mdx │ ├── pnpm-lock.yaml │ ├── public │ │ ├── favicon.ico │ │ ├── favicon.png │ │ ├── favicon.svg │ │ └── img │ │ │ └── logo.svg │ ├── theme.config.tsx │ └── tsconfig.json └── marketing │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.cjs │ ├── public │ ├── favicon.png │ └── favicon.svg │ ├── src │ ├── env │ │ ├── client.mjs │ │ ├── schema.mjs │ │ └── server.mjs │ ├── pages │ │ ├── _app.tsx │ │ ├── api │ │ │ └── trpc │ │ │ │ └── [trpc].ts │ │ └── index.tsx │ ├── server │ │ └── api │ │ │ ├── root.ts │ │ │ ├── routers │ │ │ └── example.ts │ │ │ └── trpc.ts │ ├── styles │ │ └── globals.css │ └── utils │ │ ├── api.ts │ │ └── db.ts │ ├── tailwind.config.cjs │ └── tsconfig.json ├── package.json ├── packages ├── eslint-config-custom │ ├── index.js │ └── package.json ├── logger │ ├── .eslintrc.js │ ├── .gitignore │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── tailwind-config │ ├── index.js │ ├── package.json │ └── postcss.js └── tsconfig │ ├── base.json │ ├── cli.json │ ├── nextjs.json │ ├── package.json │ ├── react-library.json │ └── vite.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ["custom"], 5 | settings: { 6 | next: { 7 | rootDir: ["apps/*/"], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build-lint-and-typecheck: 11 | env: 12 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 13 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 14 | DATABASE_URL: file:./db.sqlite 15 | DB_HOST: ${{ secrets.FAKE_SECRET }} 16 | DB_PORT: ${{ secrets.FAKE_SECRET }} 17 | DB_NAME: ${{ secrets.FAKE_SECRET }} 18 | DB_USER: ${{ secrets.FAKE_SECRET }} 19 | DB_PASS: ${{ secrets.FAKE_SECRET }} 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - name: Checkout repo 25 | uses: actions/checkout@v3 26 | 27 | - name: Setup pnpm 28 | uses: pnpm/action-setup@v2.2.2 29 | 30 | - name: Setup Node 16 31 | uses: actions/setup-node@v3 32 | with: 33 | node-version: 16 34 | 35 | - name: Get pnpm store directory 36 | id: pnpm-cache 37 | run: | 38 | echo "::set-output name=pnpm_cache_dir::$(pnpm store path)" 39 | 40 | - name: Setup pnpm cache 41 | uses: actions/cache@v3 42 | with: 43 | path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} 44 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 45 | restore-keys: | 46 | ${{ runner.os }}-pnpm-store- 47 | 48 | - name: Install deps (with cache) 49 | run: pnpm install 50 | 51 | - name: Next.js cache 52 | uses: actions/cache@v3 53 | with: 54 | path: ${{ github.workspace }}/.next/cache 55 | key: ${{ runner.os }}-${{ runner.node }}-${{ hashFiles('**/pnpm-lock.yaml') }}-nextjs 56 | 57 | - name: Build 58 | run: pnpm turbo build 59 | 60 | - name: Lint & Typecheck 61 | run: pnpm turbo typecheck lint 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # database 12 | prisma/db.sqlite 13 | prisma/db.sqlite-journal 14 | 15 | # next.js 16 | .next/ 17 | out/ 18 | 19 | # expo 20 | .expo/ 21 | dist/ 22 | 23 | # production 24 | build 25 | 26 | # misc 27 | .DS_Store 28 | *.pem 29 | 30 | # debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | .pnpm-debug.log* 35 | 36 | # local env files 37 | .env 38 | .env*.local 39 | 40 | # vercel 41 | .vercel 42 | 43 | # typescript 44 | *.tsbuildinfo 45 | 46 | # turbo 47 | .turbo 48 | 49 | # don't bundle .thing for us pls 50 | .thing 51 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Expo doesn't play nice with pnpm by default. 2 | # The symbolic links of pnpm break the rules of Expo monorepos. 3 | # @link https://docs.expo.dev/guides/monorepos/#common-issues 4 | node-linker=hoisted 5 | 6 | # Theo's grown to believe peer deps were built for a world 7 | # without monorepos. So many problems with Prisma expecting NextAuth, 8 | # trpc expecting Prisma, etc, all are solved by this being false 9 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "bradlc.vscode-tailwindcss", 6 | "Prisma.prisma" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "eslint.workingDirectories": [ 10 | "apps/cli/cli", 11 | "apps/cli/cli-core", 12 | "apps/cli/cli-web", 13 | "apps/docs", 14 | "apps/marketing", 15 | "packages/logger" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ping.gg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webhookthing 2 | 3 | Monorepo for [webhookthing](https://webhookthing.com) 4 | 5 | [![NPM version][npm-image]][npm-url] 6 | [![Downloads][downloads-image]][npm-url] 7 | 8 | ## Getting Started 9 | 10 | ```bash 11 | pnpm install 12 | pnpm dev 13 | ``` 14 | 15 | ### For just cli 16 | 17 | ```bash 18 | pnpm dev-cli 19 | ``` 20 | 21 | [downloads-image]: https://img.shields.io/npm/dm/webhookthing?color=364fc7&logoColor=364fc7 22 | [npm-url]: https://www.npmjs.com/package/webhookthing 23 | [npm-image]: https://img.shields.io/npm/v/webhookthing?color=0b7285&logoColor=0b7285 24 | 25 | ## Publishing Instructions 26 | 27 | First, add changes with changesets: 28 | 29 | ```bash 30 | npx changeset add 31 | ``` 32 | 33 | Then, make a new version & publish 34 | 35 | ```bash 36 | pnpm publish-cli 37 | ``` 38 | 39 | ## Making a new package 40 | 41 | This is half a guide half our "lessons learned" from doing this too many times. 42 | 43 | [Example PR where we added the logger package](https://github.com/pingdotgg/captain/pull/75) 44 | 45 | 1. Create a new folder in either `packages/` or `apps/` 46 | a. Generally we recommend putting things in `packages/` if they'll be used in >1 thing in `apps/` 47 | 2. Create a package.json that imports the shared eslint and tsconfig 48 | a. Probably easiest to copy-paste a minimal package at this point, `@captain/logger` is a good one 49 | 3. Add the new package's path to all the weird places it needs to be listed 50 | a. `pnpm-workspace.yaml` (note: Might be covered already with one of the `/*` instances) 51 | b. `.vscode/settings.json` -> `eslint.workingDirectories` 52 | c. If being used in `cli`, `apps/cli/cli/tsup.config.ts` -> `noExternal` 53 | 4. Do one last `pnpm install` and you should be good to go! 54 | -------------------------------------------------------------------------------- /apps/cli/cli-core/.gitignore: -------------------------------------------------------------------------------- 1 | /devbuild 2 | /dist 3 | 4 | node_modules -------------------------------------------------------------------------------- /apps/cli/cli-core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @captain/cli-core 2 | 3 | ## 0.3.0 4 | 5 | ### Minor Changes 6 | 7 | - UI overhaul, folder support 8 | 9 | ## 0.2.2 10 | 11 | ### Patch Changes 12 | 13 | - Fix hook paths 14 | 15 | ## 0.2.1 16 | 17 | ### Patch Changes 18 | 19 | - 88d836c: Fix build process to include package.json bumps 20 | 21 | ## 0.2.0 22 | 23 | ### Minor Changes 24 | 25 | - better error messages, cleaned up repo 26 | -------------------------------------------------------------------------------- /apps/cli/cli-core/index.ts: -------------------------------------------------------------------------------- 1 | export type { CliApiRouter } from "./src/trpc"; 2 | export { cliApiRouter } from "./src/trpc"; 3 | -------------------------------------------------------------------------------- /apps/cli/cli-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@captain/cli-core", 3 | "version": "0.3.0", 4 | "main": "./src/index.ts", 5 | "types": "./src/index.ts", 6 | "private": true, 7 | "scripts": { 8 | "clean": "rm -rf .turbo node_modules", 9 | "typecheck": "tsc", 10 | "lint": "eslint --ext .ts,tsx --ignore-path .gitignore src", 11 | "lint:fix": "eslint --ext .ts,tsx --ignore-path .gitignore --fix src", 12 | "format": "prettier --write --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore", 13 | "format:check": "prettier --check --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore" 14 | }, 15 | "dependencies": { 16 | "@captain/logger": "workspace:*", 17 | "@trpc/client": "10.9.0", 18 | "@trpc/server": "10.9.0", 19 | "node-fetch": "^3.3.0", 20 | "superjson": "^1.12.1", 21 | "zod": "^3.19.1" 22 | }, 23 | "devDependencies": { 24 | "@captain/tsconfig": "workspace:*", 25 | "eslint": "^7.32.0", 26 | "eslint-config-custom": "workspace:*", 27 | "typescript": "^4.9.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/constants.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export const HOOK_PATH = path.join(process.cwd(), ".thing", "hooks"); 4 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/get-sample-hooks.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs"; 3 | import fsPromise from "fs/promises"; 4 | import fetch from "node-fetch"; 5 | 6 | import { HOOK_PATH } from "./constants"; 7 | import logger from "@captain/logger"; 8 | 9 | export async function getSampleHooks() { 10 | // Create the directory if it doesn't exist 11 | if (!fs.existsSync(HOOK_PATH)) { 12 | logger.warn(`Could not find .thing directory, creating it now!`); 13 | fs.mkdirSync(HOOK_PATH, { recursive: true }); 14 | } 15 | 16 | const files = (await fetch( 17 | `https://api.github.com/repos/pingdotgg/sample_hooks/contents/` 18 | ).then((res) => res.json())) as { 19 | name: string; 20 | download_url: string; 21 | }[]; 22 | 23 | const filteredFiles = files.filter((file) => file.name.endsWith(".json")); 24 | 25 | const hookCount = filteredFiles.filter( 26 | (file) => !file.name.endsWith(".config.json") 27 | ).length; 28 | 29 | logger.info(`Downloading ${hookCount} sample hooks.`); 30 | 31 | const promiseMap = filteredFiles.map(async (file) => { 32 | logger.info(`Downloading ${file.name}`); 33 | const fileContent = await fetch(file.download_url).then((res) => 34 | res.text() 35 | ); 36 | 37 | const newFilePath = path.join(HOOK_PATH, file.name); 38 | try { 39 | return await fsPromise.writeFile(newFilePath, fileContent); 40 | } catch (e) { 41 | logger.error(`Could not write file ${file.name}`); 42 | logger.error(e); 43 | throw e; 44 | } 45 | }); 46 | 47 | return await Promise.allSettled(promiseMap); 48 | } 49 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/open-folder.ts: -------------------------------------------------------------------------------- 1 | import childProcess from "child_process"; 2 | import { promisify } from "util"; 3 | import os from "os"; 4 | import fs from "fs"; 5 | 6 | import logger from "@captain/logger"; 7 | 8 | const promisifiedExecFile = promisify(childProcess.execFile); 9 | 10 | const getCommand = () => { 11 | const plat = os 12 | .platform() 13 | .toLowerCase() 14 | .replace(/[0-9]/g, ``) 15 | .replace(`darwin`, `macos`); 16 | 17 | if (plat === "win") return "explorer.exe"; 18 | if (plat === "linux") return "xdg-open"; 19 | if (plat === "macos") return "open"; 20 | 21 | throw new Error("Idk what os this is"); 22 | }; 23 | 24 | // Ripped from https://www.npmjs.com/package/open-file-explorer 25 | export async function openInExplorer(path: string) { 26 | // Create the directory if it doesn't exist 27 | if (!fs.existsSync(path)) { 28 | logger.warn(`Could not find .thing directory, creating it now!`); 29 | fs.mkdirSync(path, { recursive: true }); 30 | } 31 | 32 | return await promisifiedExecFile(getCommand(), [path]); 33 | } 34 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/templateSubstitution.ts: -------------------------------------------------------------------------------- 1 | import logger from "@captain/logger"; 2 | 3 | export const substituteTemplate = (input: { 4 | template: string; 5 | sanitize?: boolean; 6 | }) => { 7 | const { template: defTemp, sanitize = false } = input; 8 | let template = defTemp; 9 | 10 | const regex = /\%%(.*?)%%/g; 11 | let match; 12 | 13 | // check if template contains any template variables `%%VARIABLE%%` 14 | while ((match = regex.exec(template))) { 15 | const variable = match[1]?.trim(); 16 | 17 | if (!variable) { 18 | logger.error(`Invalid template configuration`); 19 | throw new Error(`Invalid template configuration`); 20 | } 21 | const sanitizedString = `%%${variable}%%`; 22 | 23 | const fromEnv = process.env[variable]; 24 | 25 | if (!fromEnv) { 26 | logger.error(`Environment variable ${variable} not found`); 27 | throw new Error(`Environment variable ${variable} not found`); 28 | } 29 | 30 | const value = sanitize ? sanitizedString : fromEnv; 31 | 32 | // replace the template variable with the value 33 | template = template.replace(match[0], `${value}`); 34 | } 35 | 36 | // evaluate the template string 37 | template = eval("`" + template + "`") as string; 38 | 39 | return template; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import { ConfigValidatorType } from "./update-config"; 2 | import { initTRPC } from "@trpc/server"; 3 | import superjson from "superjson"; 4 | import { z } from "zod"; 5 | import fetch from "node-fetch"; 6 | import fsPromises from "fs/promises"; 7 | import fs from "fs"; 8 | import path from "path"; 9 | 10 | import { openInExplorer } from "./open-folder"; 11 | import { getSampleHooks } from "./get-sample-hooks"; 12 | import { HOOK_PATH } from "./constants"; 13 | import { configValidator, updateConfig } from "./update-config"; 14 | import { substituteTemplate } from "./templateSubstitution"; 15 | 16 | export type { ConfigValidatorType } from "./update-config"; 17 | type ExtendedConfigValidatorType = ConfigValidatorType & { 18 | method: "POST" | "GET" | "PUT" | "DELETE" | "PATCH"; 19 | }; 20 | 21 | import logger from "@captain/logger"; 22 | import { observable } from "@trpc/server/observable"; 23 | 24 | import type { LogLevels } from "@captain/logger"; 25 | import { getFullPath, getRoute } from "./utils/get-full-path"; 26 | 27 | export const t = initTRPC.create({ 28 | transformer: superjson, 29 | }); 30 | export const cliApiRouter = t.router({ 31 | onLog: t.procedure.subscription(() => { 32 | return observable<{ message: string; level: LogLevels; ts: number }>( 33 | (emit) => { 34 | const onLog = (m: { 35 | message: string; 36 | level: LogLevels; 37 | ts: number; 38 | }) => { 39 | emit.next(m); 40 | }; 41 | 42 | logger.subscribe(onLog); 43 | 44 | return () => { 45 | logger.unsubscribe(onLog); 46 | }; 47 | } 48 | ); 49 | }), 50 | 51 | getBlobs: t.procedure 52 | .input( 53 | z.object({ 54 | path: z.array(z.string()), 55 | }) 56 | ) 57 | .query(async ({ input }) => { 58 | const fullPath = getFullPath(input.path); 59 | 60 | logger.debug(`Getting blobs from ${fullPath}`); 61 | 62 | if (!fs.existsSync(fullPath)) { 63 | // TODO: this should probably be an error, and the frontend should handle it 64 | return []; 65 | } 66 | 67 | const hooks = await fsPromises.readdir(fullPath); 68 | 69 | const res = hooks 70 | .filter( 71 | (hookFile) => 72 | hookFile.includes(".json") && !hookFile.includes(".config.json") 73 | ) 74 | .map(async (hook) => { 75 | const bodyPromise = fsPromises.readFile( 76 | path.join(fullPath, hook), 77 | "utf-8" 78 | ); 79 | 80 | const configPath = hook.replace(".json", "") + ".config.json"; 81 | 82 | let config; 83 | if (fs.existsSync(path.join(fullPath, configPath))) { 84 | config = await fsPromises.readFile( 85 | path.join(fullPath, configPath), 86 | "utf-8" 87 | ); 88 | } 89 | 90 | return { 91 | name: hook, 92 | body: await bodyPromise, 93 | config: config // TODO: validate config 94 | ? (JSON.parse(config) as ConfigValidatorType) 95 | : undefined, 96 | }; 97 | }); 98 | 99 | return Promise.all(res); 100 | }), 101 | 102 | getFilesAndFolders: t.procedure 103 | .input( 104 | z.object({ 105 | path: z.array(z.string()), 106 | }) 107 | ) 108 | .query(({ input }) => { 109 | const fullPath = getFullPath(input.path); 110 | 111 | const dirListing: { folders: string[]; files: string[] } = { 112 | folders: [], 113 | files: [], 114 | }; 115 | 116 | if (!fs.existsSync(fullPath)) { 117 | logger.warn(`Path ${fullPath} does not exist`); 118 | return dirListing; 119 | } 120 | 121 | fs.readdirSync(fullPath).forEach((file) => { 122 | if (fs.lstatSync(`${fullPath}/${file}`).isDirectory()) { 123 | dirListing.folders.push(file); 124 | } else { 125 | if (file.startsWith(".")) return; // skip hidden files 126 | dirListing.files.push(file); 127 | } 128 | }); 129 | 130 | return dirListing; 131 | }), 132 | 133 | openFolder: t.procedure 134 | .input(z.object({ path: z.string() })) 135 | .mutation(async ({ input }) => { 136 | // if running in codespace, early return 137 | // eslint-disable-next-line turbo/no-undeclared-env-vars 138 | if (process.env.CODESPACES) { 139 | throw new Error( 140 | "Sorry, opening folders in codespaces is not supported yet." 141 | ); 142 | } 143 | // if running over ssh, early return 144 | if ( 145 | // eslint-disable-next-line turbo/no-undeclared-env-vars 146 | process.env.SSH_CONNECTION || 147 | // eslint-disable-next-line turbo/no-undeclared-env-vars 148 | process.env.SSH_CLIENT || 149 | // eslint-disable-next-line turbo/no-undeclared-env-vars 150 | process.env.SSH_TTY 151 | ) { 152 | throw new Error( 153 | "Sorry, opening folders on remote connections is not supported yet." 154 | ); 155 | } 156 | 157 | try { 158 | await openInExplorer(path.join(HOOK_PATH, input.path)); 159 | } catch (e) { 160 | logger.error( 161 | "Failed to open folder (unless you're on Windows, then this just happens)", 162 | e 163 | ); 164 | } 165 | }), 166 | 167 | getSampleHooks: t.procedure.mutation(async () => { 168 | await getSampleHooks(); 169 | }), 170 | 171 | runFile: t.procedure 172 | .input( 173 | z.object({ 174 | file: z.string(), 175 | }) 176 | ) 177 | .mutation(async ({ input }) => { 178 | const { file } = input; 179 | let hasCustomConfig = false; 180 | logger.info(`Reading file ${file}`); 181 | 182 | let config = { 183 | url: "", 184 | query: undefined, 185 | headers: { 186 | "Content-Type": "application/json", 187 | }, 188 | method: "POST", 189 | } as ExtendedConfigValidatorType; 190 | 191 | const fileName = file.replace(".json", ""); 192 | 193 | const configName = `${fileName}.config.json`; 194 | 195 | if (fs.existsSync(path.join(HOOK_PATH, configName))) { 196 | hasCustomConfig = true; 197 | 198 | logger.info(`Found ${configName}, reading it`); 199 | 200 | const configFileContents = await fsPromises 201 | .readFile(path.join(HOOK_PATH, configName)) 202 | .then( 203 | (x) => 204 | // TODO: validate config 205 | JSON.parse(x.toString()) as ExtendedConfigValidatorType 206 | ); 207 | 208 | config = { 209 | ...config, 210 | ...configFileContents, 211 | }; 212 | 213 | // template substitution for header values 214 | if (config.headers) { 215 | config.headers = Object.fromEntries( 216 | Object.entries(config.headers).map(([key, value]) => { 217 | return [key, substituteTemplate({ template: value })]; 218 | }) 219 | ); 220 | } 221 | } 222 | const data = await fsPromises.readFile(path.join(HOOK_PATH, file)); 223 | 224 | if (!config.url) { 225 | logger.error( 226 | `Missing URL, please add it to the configuration for this hook` 227 | ); 228 | throw new Error( 229 | `Missing URL, please add it to the configuration for this hook` 230 | ); 231 | } 232 | 233 | try { 234 | logger.info( 235 | `Sending to ${config.url} ${ 236 | hasCustomConfig ? `with custom config from ${configName}` : "" 237 | }\n` 238 | ); 239 | 240 | const fetchedResult = await fetch(config.url, { 241 | method: config.method, 242 | headers: config.headers, 243 | body: config.method !== "GET" ? data.toString() : undefined, 244 | }).then((res) => res.json()); 245 | 246 | logger.info( 247 | `Got response: \n\n${JSON.stringify(fetchedResult, null, 2)}\n` 248 | ); 249 | return fetchedResult; 250 | } catch (e) { 251 | if ((e as { code: string }).code === "ECONNREFUSED") { 252 | logger.error("Connection refused. Is the server running?"); 253 | } else { 254 | logger.error(e); 255 | } 256 | throw e; 257 | } 258 | }), 259 | 260 | createHook: t.procedure 261 | .input( 262 | z.object({ 263 | name: z.string(), 264 | body: z.string(), 265 | config: configValidator.optional(), 266 | path: z.array(z.string()).optional(), 267 | }) 268 | ) 269 | .mutation(async ({ input }) => { 270 | const { name, body, config } = input; 271 | 272 | const fullPath = getFullPath(input.path); 273 | 274 | logger.info(`Creating ${name}.json`); 275 | 276 | await fsPromises.writeFile(path.join(fullPath, `${name}.json`), body); 277 | if (config?.url || config?.query || config?.headers) { 278 | logger.info(`Config specified, creating ${name}.config.json`); 279 | return await updateConfig({ name, config }); 280 | } 281 | }), 282 | 283 | updateHook: t.procedure 284 | .input( 285 | z.object({ 286 | name: z.string(), 287 | body: z.string(), 288 | config: configValidator.optional(), 289 | path: z.array(z.string()).optional(), 290 | }) 291 | ) 292 | .mutation(async ({ input }) => { 293 | const { body, config } = input; 294 | const fullPath = getFullPath(input.path); 295 | 296 | const name = input.name.split(".json")[0]; 297 | 298 | if (!name) throw new Error("No name"); 299 | 300 | const bodyPath = path.join(fullPath, `${name}.json`); 301 | 302 | logger.info(`Updating ${bodyPath}`); 303 | 304 | const existingBody = await fsPromises.readFile(bodyPath, "utf-8"); 305 | 306 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 307 | const updatedBody = { 308 | ...JSON.parse(existingBody), 309 | ...JSON.parse(body), 310 | }; 311 | 312 | await fsPromises.writeFile( 313 | bodyPath, 314 | JSON.stringify(updatedBody, null, 2) 315 | ); 316 | 317 | if ( 318 | config?.url || 319 | config?.query || 320 | config?.headers || 321 | fs.existsSync(path.join(fullPath, `${name}.config.json`)) 322 | ) { 323 | logger.info(`Config specified, updating ${name}.config.json`); 324 | return await updateConfig({ name, config, path: fullPath }); 325 | } 326 | }), 327 | 328 | createFolder: t.procedure 329 | .input( 330 | z.object({ 331 | name: z.string(), 332 | path: z.array(z.string()).optional(), 333 | }) 334 | ) 335 | .mutation(({ input }) => { 336 | const pathArr = input.path ? [...input.path, input.name] : [input.name]; 337 | 338 | const fullPath = getFullPath(pathArr); 339 | const route = getRoute(pathArr); 340 | 341 | logger.info(`Creating new folder: ${fullPath}`); 342 | 343 | fs.mkdirSync(fullPath); 344 | 345 | return { 346 | route: `/${route}/`, 347 | }; 348 | }), 349 | 350 | createFile: t.procedure 351 | .input( 352 | z.object({ 353 | name: z.string(), 354 | path: z.array(z.string()).optional(), 355 | }) 356 | ) 357 | .mutation(({ input }) => { 358 | const pathArr = input.path 359 | ? [...input.path, `${input.name}.json`] 360 | : [`${input.name}.json`]; 361 | 362 | const fullPath = getFullPath(pathArr); 363 | const route = getRoute(pathArr); 364 | 365 | logger.info(`Creating new file: ${fullPath}`); 366 | 367 | fs.writeFileSync(fullPath, "{}"); 368 | 369 | return { 370 | route: `/${route}`, 371 | }; 372 | }), 373 | 374 | parseUrl: t.procedure 375 | .input( 376 | z.object({ 377 | url: z.string(), 378 | }) 379 | ) 380 | .query(async ({ input }) => { 381 | const { url } = input; 382 | 383 | const fullPath = path.join( 384 | HOOK_PATH, 385 | ...url.split("/").map((x) => decodeURI(x)) 386 | ); 387 | 388 | if (!fs.existsSync(fullPath)) { 389 | const d = { 390 | type: "notFound", 391 | path: decodeURI(url), 392 | data: {}, 393 | } as const; 394 | 395 | return d; 396 | } 397 | 398 | if (url.endsWith(".json")) { 399 | const configPath = fullPath.replace(".json", "") + ".config.json"; 400 | 401 | const bodyPromise = fsPromises.readFile(fullPath, "utf-8"); 402 | 403 | const configPromise = fsPromises 404 | .readFile(configPath, "utf-8") 405 | // TODO: validate config 406 | .then((x) => JSON.parse(x) as ConfigValidatorType) 407 | .catch(() => undefined); 408 | 409 | const hookData = { 410 | name: fullPath.split("/").pop(), 411 | body: await bodyPromise, 412 | config: await configPromise, 413 | } as const; 414 | 415 | const d = { 416 | type: "file" as const, 417 | path: decodeURI(url), 418 | data: hookData, 419 | } as const; 420 | 421 | return d; 422 | } else { 423 | // get folders and files in folder 424 | const dirListing: { 425 | folders: string[]; 426 | files: { 427 | name: string; 428 | body: string; 429 | config: ConfigValidatorType | undefined; 430 | }[]; 431 | } = { 432 | folders: [], 433 | files: [], 434 | }; 435 | 436 | const listingPromises = fs 437 | .readdirSync(fullPath) 438 | .map(async (maybeFile) => { 439 | if (fs.lstatSync(`${fullPath}/${maybeFile}`).isDirectory()) { 440 | dirListing.folders.push(maybeFile); 441 | } else { 442 | if (maybeFile.startsWith(".")) return; // skip hidden files 443 | if (maybeFile.endsWith(".config.json")) return; // skip config files 444 | 445 | const filePath = path.join(fullPath, maybeFile); 446 | const configPath = filePath.replace(".json", "") + ".config.json"; 447 | 448 | const bodyPromise = fsPromises.readFile(filePath, "utf-8"); 449 | 450 | const configPromise = fsPromises 451 | .readFile(configPath, "utf-8") 452 | .then((x) => JSON.parse(x) as ConfigValidatorType) 453 | .catch(() => undefined); 454 | 455 | dirListing.files.push({ 456 | name: maybeFile, 457 | body: await bodyPromise, 458 | config: await configPromise, 459 | }); 460 | } 461 | }); 462 | 463 | await Promise.allSettled(listingPromises); 464 | 465 | const d = { 466 | type: "folder" as const, 467 | path: decodeURI(url), 468 | data: dirListing, 469 | } as const; 470 | 471 | return d; 472 | } 473 | }), 474 | }); 475 | 476 | // export type definition of API 477 | export type CliApiRouter = typeof cliApiRouter; 478 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/update-config.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import fsPromises from "fs/promises"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | 6 | export const configValidator = z.object({ 7 | url: z.string().url().optional(), 8 | query: z.record(z.string()).optional(), 9 | headers: z.record(z.string()).optional(), 10 | }); 11 | 12 | export type ConfigValidatorType = z.infer; 13 | 14 | export const updateConfig = async (input: { 15 | name: string; 16 | config?: ConfigValidatorType; 17 | path?: string; 18 | }) => { 19 | const { name, config } = input; 20 | 21 | if (!config) { 22 | return; 23 | } 24 | // If there is a URL, make sure it is a valid one 25 | if (config?.url) { 26 | z.string().url().parse(config.url); 27 | } 28 | 29 | // Remove empty values from config 30 | Object.keys(config).forEach((key) => { 31 | if ( 32 | config[key as keyof typeof config] === "" || 33 | JSON.stringify(config[key as keyof typeof config]) === "{}" 34 | ) { 35 | delete config[key as keyof typeof config]; 36 | } 37 | }); 38 | 39 | // Generate config file path 40 | const configPath = path.join(input.path ?? "",`${name}.config.json`) 41 | 42 | // Create config file if it doesn't exist 43 | if (!fs.existsSync(configPath)) { 44 | await fsPromises.writeFile( 45 | configPath, 46 | JSON.stringify(config, null, 2) 47 | ); 48 | } else { 49 | // Otherwise, update existing config file 50 | const existingConfig = await fsPromises.readFile( 51 | configPath, 52 | "utf-8" 53 | ); 54 | 55 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 56 | const parsedConfig = JSON.parse(existingConfig) as Record; 57 | 58 | const updatedConfig = { 59 | ...parsedConfig, 60 | ...config, 61 | } as Record; // eslint-disable-line @typescript-eslint/no-explicit-any 62 | 63 | // if value not in config, remove it 64 | Object.keys(parsedConfig).forEach((key) => { 65 | if (!config?.[key as keyof typeof config]) { 66 | delete updatedConfig[key]; 67 | } 68 | }); 69 | 70 | await fsPromises.writeFile( 71 | configPath, 72 | JSON.stringify(updatedConfig, null, 2) 73 | ); 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /apps/cli/cli-core/src/utils/get-full-path.ts: -------------------------------------------------------------------------------- 1 | import { HOOK_PATH } from "../constants"; 2 | 3 | export const getFullPath = (path?: string[]) => { 4 | path = path?.filter((p) => p !== ""); 5 | 6 | return path ? `${HOOK_PATH}/${path.join("/")}` : HOOK_PATH; 7 | }; 8 | 9 | export const getRoute = (path?: string[]) => { 10 | path = path?.filter((p) => p !== ""); 11 | 12 | return path ? `${path.join("/")}` : "/"; 13 | }; 14 | -------------------------------------------------------------------------------- /apps/cli/cli-core/transformer.ts: -------------------------------------------------------------------------------- 1 | import superjson from "superjson"; 2 | export const transformer = superjson; 3 | -------------------------------------------------------------------------------- /apps/cli/cli-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@captain/tsconfig/cli.json", 3 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /apps/cli/cli-web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /apps/cli/cli-web/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @captain/cli-web 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - UI overhaul, folder support 8 | 9 | ## 0.1.0 10 | 11 | ### Minor Changes 12 | 13 | - better error messages, cleaned up repo 14 | -------------------------------------------------------------------------------- /apps/cli/cli-web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | webhookthing 9 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /apps/cli/cli-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@captain/cli-web", 3 | "private": true, 4 | "version": "0.2.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint --ext .ts,tsx --ignore-path .gitignore src", 11 | "lint:fix": "eslint --ext .ts,tsx --ignore-path .gitignore --fix src", 12 | "format": "prettier --write --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore", 13 | "format:check": "prettier --check --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore" 14 | }, 15 | "dependencies": { 16 | "@captain/logger": "workspace:*", 17 | "@headlessui/react": "^1.7.8", 18 | "@heroicons/react": "^2.0.12", 19 | "@hookform/resolvers": "^2.9.10", 20 | "@tanstack/react-query": "^4.7.2", 21 | "@tippyjs/react": "^4.2.6", 22 | "@trpc/client": "10.9.0", 23 | "@trpc/react-query": "10.9.0", 24 | "@trpc/server": "10.9.0", 25 | "jotai": "^1.13.1", 26 | "prism-react-renderer": "^1.3.5", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "react-hook-form": "^7.43.0", 30 | "react-hot-toast": "^2.4.0", 31 | "superjson": "^1.12.1", 32 | "wouter": "^2.10.0", 33 | "zod": "^3.19.1" 34 | }, 35 | "devDependencies": { 36 | "@captain/cli-core": "*", 37 | "@captain/tailwind-config": "*", 38 | "@captain/tsconfig": "workspace:*", 39 | "@tailwindcss/forms": "^0.5.3", 40 | "@types/react": "^18.0.27", 41 | "@types/react-dom": "^18.0.10", 42 | "@vitejs/plugin-react": "^3.0.0", 43 | "autoprefixer": "^10.4.7", 44 | "eslint": "^7.32.0", 45 | "eslint-config-custom": "workspace:*", 46 | "postcss": "^8.4.14", 47 | "tailwindcss": "^3.1.8", 48 | "typescript": "^4.9.4", 49 | "vite": "^4.0.0", 50 | "vite-plugin-rewrite-all": "^1.0.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /apps/cli/cli-web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@captain/tailwind-config/postcss"); 2 | -------------------------------------------------------------------------------- /apps/cli/cli-web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pingdotgg/webhookthing/28fe5f09050203ba1500ad5eb21a1feaf4e69265/apps/cli/cli-web/public/favicon.png -------------------------------------------------------------------------------- /apps/cli/cli-web/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /apps/cli/cli-web/public/plsbl.js: -------------------------------------------------------------------------------- 1 | !(function () { 2 | "use strict"; 3 | var s = window.location, 4 | a = window.document, 5 | r = a.currentScript, 6 | o = r.getAttribute("data-api") || new URL(r.src).origin + "/api/event"; 7 | function l(t) { 8 | console.warn("Ignoring Event: " + t); 9 | } 10 | function t(t, e) { 11 | if (s.host.includes("localhost:5173")) return l("localhost"); 12 | if ( 13 | !( 14 | window._phantom || 15 | window.__nightmare || 16 | window.navigator.webdriver || 17 | window.Cypress 18 | ) 19 | ) { 20 | try { 21 | if ("true" === window.localStorage.plausible_ignore) 22 | return l("localStorage flag"); 23 | } catch (t) {} 24 | var n = {}; 25 | (n.n = t), 26 | (n.u = s.href), 27 | (n.d = r.getAttribute("data-domain")), 28 | (n.r = a.referrer || null), 29 | (n.w = window.innerWidth), 30 | e && e.meta && (n.m = JSON.stringify(e.meta)), 31 | e && e.props && (n.p = e.props); 32 | var i = new XMLHttpRequest(); 33 | i.open("POST", o, !0), 34 | i.setRequestHeader("Content-Type", "text/plain"), 35 | i.send(JSON.stringify(n)), 36 | (i.onreadystatechange = function () { 37 | 4 === i.readyState && e && e.callback && e.callback(); 38 | }); 39 | } 40 | } 41 | var e = (window.plausible && window.plausible.q) || []; 42 | window.plausible = t; 43 | for (var n, i = 0; i < e.length; i++) t.apply(this, e[i]); 44 | function p() { 45 | n !== s.pathname && ((n = s.pathname), t("pageview")); 46 | } 47 | var c, 48 | u = window.history; 49 | u.pushState && 50 | ((c = u.pushState), 51 | (u.pushState = function () { 52 | c.apply(this, arguments), p(); 53 | }), 54 | window.addEventListener("popstate", p)), 55 | "prerender" === a.visibilityState 56 | ? a.addEventListener("visibilitychange", function () { 57 | n || "visible" !== a.visibilityState || p(); 58 | }) 59 | : p(); 60 | var d = 1; 61 | function f(t) { 62 | if ("auxclick" !== t.type || t.button === d) { 63 | var e, 64 | n, 65 | i, 66 | a, 67 | r, 68 | o = (function (t) { 69 | for ( 70 | ; 71 | t && 72 | (void 0 === t.tagName || 73 | !(e = t) || 74 | !e.tagName || 75 | "a" !== e.tagName.toLowerCase() || 76 | !t.href); 77 | 78 | ) 79 | t = t.parentNode; 80 | var e; 81 | return t; 82 | })(t.target); 83 | o && o.href && o.href.split("?")[0]; 84 | if ((r = o) && r.href && r.host && r.host !== s.host) 85 | return ( 86 | (e = t), 87 | (i = { 88 | name: "Outbound Link: Click", 89 | props: { 90 | url: (n = o).href, 91 | }, 92 | }), 93 | (a = !1), 94 | void (!(function (t, e) { 95 | if (!t.defaultPrevented) { 96 | var n = !e.target || e.target.match(/^_(self|parent|top)$/i), 97 | i = 98 | !(t.ctrlKey || t.metaKey || t.shiftKey) && "click" === t.type; 99 | return n && i; 100 | } 101 | })(e, n) 102 | ? plausible(i.name, { 103 | props: i.props, 104 | }) 105 | : (plausible(i.name, { 106 | props: i.props, 107 | callback: l, 108 | }), 109 | setTimeout(l, 5e3), 110 | e.preventDefault())) 111 | ); 112 | } 113 | function l() { 114 | a || ((a = !0), (window.location = n.href)); 115 | } 116 | } 117 | a.addEventListener("click", f), a.addEventListener("auxclick", f); 118 | })(); 119 | -------------------------------------------------------------------------------- /apps/cli/cli-web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Disclosure, Menu, Transition } from "@headlessui/react"; 2 | import { Toaster } from "react-hot-toast"; 3 | import { Link } from "wouter"; 4 | import { 5 | ArchiveBoxIcon, 6 | ArrowPathIcon, 7 | BookOpenIcon, 8 | EllipsisVerticalIcon, 9 | QuestionMarkCircleIcon, 10 | } from "@heroicons/react/20/solid"; 11 | import { Fragment } from "react"; 12 | 13 | import { useConnectionStateToasts } from "./utils/useConnectionStateToasts"; 14 | import { ResponseViewer } from "./components/response-viewer"; 15 | import { FileBrowser } from "./components/filebrowser"; 16 | import { classNames } from "./utils/classnames"; 17 | import { useFileRoute } from "./utils/useRoute"; 18 | import { FileRunner } from "./components/filerunner"; 19 | import { cliApi } from "./utils/api"; 20 | 21 | const SubscriptionsHelper = () => { 22 | useConnectionStateToasts(); 23 | 24 | return null; 25 | }; 26 | 27 | const PageContent = () => { 28 | const location = useFileRoute(); 29 | 30 | const { data, isLoading } = cliApi.parseUrl.useQuery({ url: location }); 31 | 32 | if (isLoading || !data) 33 | return ( 34 |
35 |
37 | ); 38 | 39 | if (data.type === "file") 40 | return ; 41 | if (data.type === "folder") 42 | return ; 43 | if (data.type === "notFound") 44 | return ( 45 |
46 |

{`404`}

47 |

{`File not found`}

48 |

{`It looks like you haven't created this file yet. 49 | `}

50 |
51 | 55 | {`Go back home`} 56 | 57 |
58 |
59 | ); 60 | 61 | throw new Error("unreachable"); 62 | }; 63 | 64 | export default function AppCore() { 65 | return ( 66 | <> 67 | 68 | 69 |
70 |
71 | 72 | {() => ( 73 | <> 74 |
75 |
76 |
77 |
78 |

79 | 80 | 81 | {`webhook`} 82 | {`thing`} 83 | 84 | 85 | 86 | {`...by `} 87 | {`Ping`} 91 | 92 |

93 |
94 |
95 |
96 | 97 |
98 |
99 |
100 | 101 | )} 102 |
103 |
104 | 105 |
106 |
107 |
108 | {/* File browser / Hook Editor / 404 */} 109 | 110 |
111 |
112 | {/* Logs */} 113 | 114 |
115 |
116 |
117 |
118 | 119 | ); 120 | } 121 | 122 | const NavMenu = () => { 123 | return ( 124 | 125 |
126 | 127 | {`Open options`} 128 | 130 |
131 | 132 | 141 | 142 |
143 | 144 | {({ active }) => ( 145 | 152 | 155 | )} 156 | 157 | 158 | {({ active }) => ( 159 | 166 | 169 | )} 170 | 171 | 172 | {({ active }) => ( 173 | 180 | 183 | )} 184 | 185 |
186 |
187 |
188 |
189 | ); 190 | }; 191 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/common/button.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from "react"; 2 | import { Menu, Transition } from "@headlessui/react"; 3 | import { ChevronDownIcon } from "@heroicons/react/20/solid"; 4 | import { classNames } from "../../utils/classnames"; 5 | 6 | type ListItem = { 7 | name: string; 8 | action: () => void; 9 | }; 10 | 11 | export default function SplitButtonDropdown({ 12 | items, 13 | label, 14 | icon, 15 | onClick, 16 | }: { 17 | items: ListItem[]; 18 | label: string; 19 | icon?: React.ReactNode; 20 | onClick?: () => void; 21 | }) { 22 | return ( 23 |
24 | {onClick ? ( 25 | 32 | ) : ( 33 |
34 | {icon} 35 | {label} 36 |
37 | )} 38 | 39 | 40 | 41 | {`Open options`} 42 | 44 | 53 | 54 |
55 | {items.map((item) => ( 56 | 57 | {({ active }) => ( 58 | 67 | )} 68 | 69 | ))} 70 |
71 |
72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/common/logo.tsx: -------------------------------------------------------------------------------- 1 | type SvgProps = React.SVGProps; 2 | 3 | export const LogoMark: React.FC = ({ ...rest }) => { 4 | return ( 5 | 11 | 12 | 13 | 14 | 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/common/modal.tsx: -------------------------------------------------------------------------------- 1 | import type { Dispatch, MutableRefObject, SetStateAction } from "react"; 2 | import { Fragment } from "react"; 3 | import { Dialog, Transition } from "@headlessui/react"; 4 | 5 | export const Modal: React.FC<{ 6 | children: React.ReactNode | React.ReactNode[]; 7 | openState: [boolean, Dispatch>]; 8 | initialFocus?: MutableRefObject; 9 | onClose?: () => void; 10 | }> & { 11 | Title: typeof Dialog.Title; 12 | Description: typeof Dialog.Description; 13 | } = ({ openState, initialFocus, children, onClose }) => { 14 | const [open, setOpen] = openState; 15 | 16 | const handleClose = () => { 17 | setOpen(false); 18 | onClose && onClose(); 19 | }; 20 | 21 | return ( 22 | 23 | 29 |
30 | 39 | 40 | 41 | 42 | {/* This element is to trick the browser into centering the modal contents. */} 43 | 50 | 51 | 60 | {children} 61 | 62 |
63 |
64 |
65 | ); 66 | }; 67 | 68 | Modal.Title = Dialog.Title; 69 | Modal.Description = Dialog.Description; 70 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/common/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import type { TippyProps } from "@tippyjs/react"; 2 | import Tippy from "@tippyjs/react"; 3 | 4 | export const Tooltip: React.FC = ({ 5 | content, 6 | children, 7 | rawContent = false, 8 | ...rest 9 | }) => { 10 | // if no tooltip content, just render children (otherwise, it'll be a blank tooltip) 11 | if (!content) return <>{children}; 12 | 13 | if (rawContent) { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | } 20 | 21 | const tip = ( 22 | 23 | {content} 24 | 25 | ); 26 | 27 | return ( 28 | 29 | {children} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/file-form-modal.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import { zodResolver } from "@hookform/resolvers/zod"; 3 | import { z } from "zod"; 4 | import { DocumentPlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; 5 | import toast from "react-hot-toast"; 6 | 7 | import { cliApi } from "../utils/api"; 8 | import { Modal } from "./common/modal"; 9 | 10 | const formValidator = z.object({ 11 | name: z.string().max(100).min(1, { message: "Required" }), 12 | }); 13 | 14 | type FormValidatorType = z.infer; 15 | 16 | export const FileFormModal = (input: { 17 | openState: [boolean, React.Dispatch>]; 18 | prefill?: FormValidatorType; 19 | onClose?: () => void; 20 | path: string[]; 21 | }) => { 22 | const { openState, onClose, prefill } = input; 23 | 24 | const ctx = cliApi.useContext(); 25 | 26 | const { data: existing } = cliApi.getFilesAndFolders.useQuery({ 27 | path: input.path, 28 | }); 29 | 30 | const { mutate: createFile } = cliApi.createFile.useMutation({ 31 | onSuccess: async ({ route }) => { 32 | await ctx.parseUrl.invalidate(); 33 | onClose && onClose(); 34 | openState[1](false); 35 | reset(); 36 | window.open(route, "_blank"); 37 | }, 38 | onError: (err) => { 39 | toast.error(err.message); 40 | }, 41 | }); 42 | 43 | const { 44 | register, 45 | handleSubmit, 46 | formState: { errors, isValid }, 47 | reset, 48 | } = useForm({ 49 | defaultValues: prefill, 50 | resolver: zodResolver(formValidator), 51 | mode: "onBlur", 52 | }); 53 | 54 | const onSubmit = (data: FormValidatorType) => { 55 | // Don't allow duplicate names (this overwrites the existing hook) 56 | if (existing?.folders.find((b) => b === data.name)) { 57 | return toast.error("Folder with that name already exists"); 58 | } 59 | createFile({ 60 | name: data.name.trim(), 61 | path: input.path, 62 | }); 63 | }; 64 | 65 | const submitHandler = handleSubmit(onSubmit); 66 | 67 | return ( 68 | 69 |
70 |
71 | 79 |
80 |
81 |
82 |
87 |
88 |

89 | {`Create a new hook`} 90 |

91 |
92 |
void submitHandler(e)} id="form"> 93 | 99 | {errors.name && ( 100 |

101 | {errors.name?.message ?? errors.name.type} 102 |

103 | )} 104 |
105 | 112 | 113 | {`.json`} 114 | 115 |
116 |
117 |
118 |
119 |
120 |
121 | 129 | 138 |
139 |
140 |
141 | ); 142 | }; 143 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/filebrowser.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CloudArrowDownIcon, 3 | DocumentDuplicateIcon, 4 | DocumentPlusIcon, 5 | ExclamationCircleIcon, 6 | FolderIcon, 7 | FolderPlusIcon, 8 | HomeIcon, 9 | InformationCircleIcon, 10 | PlayIcon, 11 | PlusIcon, 12 | } from "@heroicons/react/20/solid"; 13 | import { FolderPlusIcon as FolderPlusOutline } from "@heroicons/react/24/outline"; 14 | import { Fragment, useState } from "react"; 15 | import toast from "react-hot-toast"; 16 | import { Link } from "wouter"; 17 | import { Menu, Transition } from "@headlessui/react"; 18 | import { CliApiRouter } from "@captain/cli-core"; 19 | import { inferRouterOutputs } from "@trpc/server"; 20 | 21 | import { Tooltip } from "./common/tooltip"; 22 | import { FolderFormModal } from "./folder-form-modal"; 23 | 24 | import { cliApi } from "../utils/api"; 25 | import { classNames } from "../utils/classnames"; 26 | import { useFileRoute } from "../utils/useRoute"; 27 | import { FileFormModal } from "./file-form-modal"; 28 | 29 | const pathArrToUrl = (pathArr: string[], nav?: string) => { 30 | const url = nav ? `${pathArr.concat(nav).join("/")}` : `${pathArr.join("/")}`; 31 | 32 | // make sure we always have a leading slash 33 | if (!url.startsWith("/")) return `/${url}`; 34 | return url; 35 | }; 36 | 37 | type DataResponse = inferRouterOutputs["parseUrl"]; 38 | export type FolderDataType = Extract["data"]; 39 | 40 | export const FileBrowser = (input: { path: string; data: FolderDataType }) => { 41 | const { path, data } = input; 42 | const location = useFileRoute(); 43 | 44 | const pathArr = path.split("/").slice(1); 45 | 46 | const { mutate: openFolder } = cliApi.openFolder.useMutation({ 47 | onError: (err) => { 48 | toast.error(err.message); 49 | }, 50 | }); 51 | 52 | const { mutate: runFile } = cliApi.runFile.useMutation({ 53 | onSuccess: () => { 54 | toast.success(`Got response from server! Check console for details.`); 55 | }, 56 | onError: (err) => { 57 | toast.error(err.message); 58 | }, 59 | }); 60 | 61 | const ctx = cliApi.useContext(); 62 | 63 | const { mutate: downloadSampleHooks } = cliApi.getSampleHooks.useMutation({ 64 | onSuccess: async () => { 65 | await ctx.parseUrl.invalidate(); 66 | }, 67 | }); 68 | 69 | const addHookModalState = useState(false); 70 | const addFolderModalState = useState(false); 71 | 72 | return ( 73 |
74 | {/* breadcrumbs */} 75 | 226 | {/* folders section */} 227 |
228 |

229 | {`Folders`} 230 |

231 |
232 | {data.folders.map((folder) => ( 233 |
234 | 235 | 236 | 245 | 246 | 247 |
248 | ))} 249 |
250 | 262 |
263 |
264 |
265 | {/* files section */} 266 |
267 |

268 | {`Files`} 269 |

270 |
271 | {data.files.length === 0 ? ( 272 |
273 | 274 |

275 | {`No hook files found!`} 276 |

277 |

278 | {`Get started by `} 279 | 283 | {location === "/" ? ( 284 | <> 285 | {`, or download some sample hooks with the button below.`} 286 |

287 | 298 |
299 | 300 | ) : ( 301 | <>{`.`} 302 | )} 303 |

304 |
305 | ) : ( 306 |
    307 | {data.files.map((file) => ( 308 |
  • 309 | 310 | 329 | 330 | 342 |
  • 343 | ))} 344 |
345 | )} 346 |
347 |
348 |
349 | ); 350 | }; 351 | -------------------------------------------------------------------------------- /apps/cli/cli-web/src/components/filerunner.tsx: -------------------------------------------------------------------------------- 1 | import { CliApiRouter } from "@captain/cli-core"; 2 | import { PlayIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { inferRouterOutputs } from "@trpc/server"; 5 | import { useForm, useFieldArray } from "react-hook-form"; 6 | import { z } from "zod"; 7 | 8 | import { cliApi } from "../utils/api"; 9 | import { classNames } from "../utils/classnames"; 10 | import { 11 | generateConfigFromState, 12 | generatePrefillFromConfig, 13 | } from "../utils/configTransforms"; 14 | 15 | const jsonValidator = () => 16 | z.string().refine( 17 | (v) => { 18 | if (!v) return true; 19 | try { 20 | JSON.parse(v); 21 | return true; 22 | } catch (e) { 23 | return false; 24 | } 25 | }, 26 | { message: "Invalid JSON" } 27 | ); 28 | 29 | const formValidator = z.object({ 30 | name: z.string().max(100).min(1, { message: "Required" }), 31 | body: z.optional(jsonValidator()), 32 | config: z.object({ 33 | url: z 34 | .string() 35 | .min(1, { message: "Required" }) 36 | .trim() 37 | .url() 38 | .refine((v) => v.match(/^https?:\/\//), { 39 | message: "Must start with http(s):// ", 40 | }), 41 | headers: z 42 | .array(z.object({ key: z.string(), value: z.string() })) 43 | .optional(), 44 | query: z.array(z.object({ key: z.string(), value: z.string() })).optional(), 45 | }), 46 | }); 47 | 48 | type DataResponse = inferRouterOutputs["parseUrl"]; 49 | type FileDataType = Extract["data"]; 50 | 51 | export const FileRunner = (input: { path: string; data: FileDataType }) => { 52 | const { path: file, data } = input; 53 | 54 | const path = decodeURI(file).split("/").slice(0, -1); 55 | 56 | if (path[0] === "") { 57 | path.shift(); 58 | } 59 | 60 | const ctx = cliApi.useContext(); 61 | 62 | const { mutate: updateHook } = cliApi.updateHook.useMutation({ 63 | onSuccess: async () => { 64 | await ctx.parseUrl.invalidate(); 65 | }, 66 | }); 67 | 68 | const { mutate: runFile } = cliApi.runFile.useMutation(); 69 | 70 | const prefill = { 71 | name: data.name ?? "", 72 | body: data.body, 73 | config: generatePrefillFromConfig(input.data.config ?? {}), 74 | }; 75 | 76 | const { 77 | register, 78 | control, 79 | handleSubmit, 80 | formState: { errors, isValid }, 81 | getValues, 82 | setValue, 83 | trigger, 84 | } = useForm({ 85 | defaultValues: prefill, 86 | resolver: zodResolver(formValidator), 87 | mode: "onBlur", 88 | }); 89 | 90 | const { 91 | fields: headerFields, 92 | remove: removeHeader, 93 | append: appendHeader, 94 | } = useFieldArray({ 95 | control, 96 | name: "config.headers", 97 | }); 98 | const { 99 | fields: queryFields, 100 | remove: removeQuery, 101 | append: appendQuery, 102 | } = useFieldArray({ 103 | control, 104 | name: "config.query", 105 | }); 106 | 107 | const updateQuery = () => { 108 | // get url & query from config 109 | const url = getValues("config.url")?.split("?")[0] ?? ""; 110 | const query = getValues("config.query"); 111 | 112 | if (!query?.length) { 113 | setValue("config.url", url); 114 | return; 115 | } 116 | 117 | // construct new url with query 118 | const newUrl = `${url}?${query 119 | .map((q) => `${q.key}=${q.value as string}`) 120 | .join("&")}`; 121 | 122 | // set new url 123 | setValue("config.url", newUrl); 124 | }; 125 | 126 | const submitHandler = handleSubmit((data) => 127 | updateHook({ 128 | name: data.name, 129 | body: data.body ?? "", 130 | config: generateConfigFromState(data.config ?? {}), 131 | path, 132 | }) 133 | ); 134 | 135 | return ( 136 | <> 137 |
138 |
139 |
140 |
141 |

142 | {`Settings: ${prefill.name}`} 143 |

144 |
145 |

146 | {`Configure your webhook below.`} 147 |

148 |
149 |
150 |
151 | 176 |
177 |
178 |
179 |
void submitHandler(e)} 181 | id="form" 182 | className="space-y-3 py-2" 183 | > 184 |
185 | 191 |

192 | {errors.config?.url?.message} 193 |

194 | ) => { 199 | void trigger(); 200 | const value = e.currentTarget.value; 201 | if (value) { 202 | const url = new URL(value); 203 | const queryFields = Array.from( 204 | url.searchParams.entries() 205 | ).map(([key, value]) => ({ 206 | key, 207 | value, 208 | })); 209 | setValue("config.query", queryFields); 210 | } 211 | }, 212 | })} 213 | /> 214 |
215 |
216 | 222 | {errors.body && ( 223 |

{errors.body.message}

224 | )} 225 |