From 41bd89f9cf34caf9025efa588d6e97a035c9b604 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Wed, 10 Dec 2025 12:32:13 +0100 Subject: [PATCH 1/8] server package --- bun.lock | 59 ++- packages/server/.gitignore | 5 + packages/server/README.md | 1 + packages/server/package.json | 43 +++ packages/server/scripts/build.ts | 91 +++++ packages/server/src/agent-deployment.ts | 236 ++++++++++++ packages/server/src/chat.ts | 278 ++++++++++++++ packages/server/src/cli.ts | 78 ++++ packages/server/src/logger.ts | 29 ++ packages/server/src/postgres.ts | 225 +++++++++++ packages/server/src/server.ts | 474 ++++++++++++++++++++++++ packages/server/tsconfig.json | 10 + packages/server/tsdown.config.ts | 103 +++++ 13 files changed, 1617 insertions(+), 15 deletions(-) create mode 100644 packages/server/.gitignore create mode 100644 packages/server/README.md create mode 100644 packages/server/package.json create mode 100644 packages/server/scripts/build.ts create mode 100644 packages/server/src/agent-deployment.ts create mode 100644 packages/server/src/chat.ts create mode 100644 packages/server/src/cli.ts create mode 100644 packages/server/src/logger.ts create mode 100644 packages/server/src/postgres.ts create mode 100644 packages/server/src/server.ts create mode 100644 packages/server/tsconfig.json create mode 100644 packages/server/tsdown.config.ts diff --git a/bun.lock b/bun.lock index 51f8e99..96b4b40 100644 --- a/bun.lock +++ b/bun.lock @@ -286,6 +286,25 @@ "blink": ">= 1", }, }, + "packages/server": { + "name": "blink-server", + "version": "0.0.7", + "bin": { + "blink-server": "dist/cli.js", + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/pg": "^8.11.10", + "@types/ws": "^8.5.13", + "boxen": "^8.0.1", + "chalk": "^5.4.1", + "commander": "^12.1.0", + "drizzle-orm": "^0.44.5", + "fetch-to-node": "^2.1.0", + "pg": "^8.16.0", + "ws": "^8.18.0", + }, + }, "packages/site": { "name": "@blink.so/site", "dependencies": { @@ -1896,6 +1915,8 @@ "blink": ["blink@workspace:packages/blink"], + "blink-server": ["blink-server@workspace:packages/server"], + "bn.js": ["bn.js@5.2.2", "", {}, "sha512-v2YAxEmKaBLahNwE1mjp4WON6huMNeuDvagFZW+ASCuA/ku0bXR9hSMw0XpiqMoA3+rmnyck/tPRSFQkoC9Cuw=="], "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], @@ -1904,7 +1925,7 @@ "bowser": ["bowser@2.13.0", "", {}, "sha512-yHAbSRuT6LTeKi6k2aS40csueHqgAsFEgmrOsfRyFpJnFv5O2hl9FYmWEUZ97gZ/dG17U4IQQcTx4YAFYPuWRQ=="], - "boxen": ["boxen@7.1.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.1", "chalk": "^5.2.0", "cli-boxes": "^3.0.0", "string-width": "^5.1.2", "type-fest": "^2.13.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.1.0" } }, "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog=="], + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], @@ -1968,7 +1989,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "camelcase": ["camelcase@7.0.1", "", {}, "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="], + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], "caniuse-lite": ["caniuse-lite@1.0.30001748", "", {}, "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w=="], @@ -2432,6 +2453,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-to-node": ["fetch-to-node@2.1.0", "", {}, "sha512-Wq05j6LE1GrWpT2t1YbCkyFY6xKRJq3hx/oRJdWEJpZlik3g25MmdJS6RFm49iiMJw6zpZuBOrgihOgy2jGyAA=="], + "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], @@ -4422,6 +4445,8 @@ "@types/tedious/@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="], + "@types/update-notifier/boxen": ["boxen@7.1.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.1", "chalk": "^5.2.0", "cli-boxes": "^3.0.0", "string-width": "^5.1.2", "type-fest": "^2.13.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.1.0" } }, "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog=="], + "@types/ws/@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="], "@types/yauzl/@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="], @@ -4452,15 +4477,11 @@ "blink/tsdown": ["tsdown@0.14.2", "", { "dependencies": { "ansis": "^4.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "debug": "^4.4.1", "diff": "^8.0.2", "empathic": "^2.0.0", "hookable": "^5.5.3", "rolldown": "latest", "rolldown-plugin-dts": "^0.15.8", "semver": "^7.7.2", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.14", "tree-kill": "^1.2.2", "unconfig": "^7.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-6ThtxVZoTlR5YJov5rYvH8N1+/S/rD/pGfehdCLGznGgbxz+73EASV1tsIIZkLw2n+SXcERqHhcB/OkyxdKv3A=="], - "body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "boxen/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + "blink-server/@types/node": ["@types/node@22.18.8", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw=="], - "boxen/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "blink-server/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "boxen/widest-line": ["widest-line@4.0.1", "", { "dependencies": { "string-width": "^5.0.1" } }, "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig=="], - - "boxen/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "browserify-aes/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -4830,8 +4851,6 @@ "type-is/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], - "update-notifier/boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], - "update-notifier/is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], "update-notifier/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], @@ -5382,6 +5401,16 @@ "@types/tedious/@types/node/undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], + "@types/update-notifier/boxen/camelcase": ["camelcase@7.0.1", "", {}, "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="], + + "@types/update-notifier/boxen/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@types/update-notifier/boxen/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + + "@types/update-notifier/boxen/widest-line": ["widest-line@4.0.1", "", { "dependencies": { "string-width": "^5.0.1" } }, "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig=="], + + "@types/update-notifier/boxen/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@types/ws/@types/node/undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], "@types/yauzl/@types/node/undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], @@ -5404,6 +5433,8 @@ "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "blink-server/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "blink/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], "blink/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], @@ -5462,8 +5493,6 @@ "blink/tsdown/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "boxen/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "builder-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "builder-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -5816,8 +5845,6 @@ "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "update-notifier/boxen/camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], @@ -5958,6 +5985,8 @@ "@npmcli/move-file/rimraf/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "@types/update-notifier/boxen/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "bl/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], diff --git a/packages/server/.gitignore b/packages/server/.gitignore new file mode 100644 index 0000000..5d625fe --- /dev/null +++ b/packages/server/.gitignore @@ -0,0 +1,5 @@ +dist/ +node_modules/ +.env +.env.local + diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 0000000..7d8569d --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1 @@ +🚧🚧🚧 diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..1a39000 --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,43 @@ +{ + "name": "blink-server", + "description": "Agents as a Service", + "version": "0.0.7", + "author": { + "name": "Coder", + "email": "support@coder.com", + "url": "https://coder.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/coder/blink" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "bin": { + "blink-server": "dist/cli.js" + }, + "type": "module", + "scripts": { + "build": "bun scripts/build.ts", + "typecheck": "tsgo --noEmit" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/pg": "^8.11.10", + "@types/ws": "^8.5.13", + "chalk": "^5.4.1", + "boxen": "^8.0.1", + "commander": "^12.1.0", + "drizzle-orm": "^0.44.5", + "fetch-to-node": "^2.1.0", + "pg": "^8.16.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/server/scripts/build.ts b/packages/server/scripts/build.ts new file mode 100644 index 0000000..8e2fe13 --- /dev/null +++ b/packages/server/scripts/build.ts @@ -0,0 +1,91 @@ +import { build } from "bun"; +import { execSync } from "child_process"; +import { cpSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; + +const distDir = join(import.meta.dirname, "..", "dist"); +const repoRoot = join(import.meta.dirname, "..", "..", ".."); + +/** + * buildServer builds the CLI for the server. + */ +async function buildServer() { + await build({ + entrypoints: [join(__dirname, "..", "src", "cli.ts")], + outdir: "dist", + target: "node", + format: "esm", + minify: true, + }); +} + +/** + * buildNextSite builds the NextJS site and copies the necessary files to the dist directory. + */ +function buildNextSite() { + const sitePackage = join(repoRoot, "packages", "site"); + + execSync("bun run build", { + cwd: sitePackage, + stdio: "inherit", + env: { + ...process.env, + NODE_ENV: "production", + // This ensures the site is bundled alone. + NEXT_OUTPUT: "standalone", + }, + }); + + rmSync(join(distDir, "site"), { recursive: true, force: true }); + mkdirSync(join(distDir, "site"), { recursive: true }); + // This moves all of the compiled site and sources to run the server-side. + cpSync( + join(sitePackage, ".next", "standalone", "packages", "site", ".next"), + join(distDir, "site", ".next"), + { recursive: true } + ); + // This copies all of the static assets. + cpSync( + join(sitePackage, ".next", "static"), + join(distDir, "site", ".next", "static"), + { recursive: true } + ); + // This copies all public assets. + cpSync(join(sitePackage, "public"), join(distDir, "site", "public"), { + recursive: true, + }); + // This copies the required server node_modules. + cpSync( + join(sitePackage, ".next", "standalone", "node_modules"), + join(distDir, "site", "node_modules"), + { recursive: true } + ); + // Write minimal package.json for module.createRequire() to work. + writeFileSync( + join(distDir, "site", "package.json"), + JSON.stringify({ type: "module" }) + ); +} + +function copyMigrations() { + const databasePackage = join(repoRoot, "packages", "database"); + + rmSync(join(distDir, "migrations"), { recursive: true, force: true }); + cpSync(join(databasePackage, "migrations"), join(distDir, "migrations"), { + recursive: true, + }); +} + +console.time("buildServer"); +await buildServer(); +console.timeEnd("buildServer"); + +if (process.env.BUILD_SITE) { + console.time("buildNextSite"); + buildNextSite(); + console.timeEnd("buildNextSite"); +} + +console.time("copyMigrations"); +copyMigrations(); +console.timeEnd("copyMigrations"); diff --git a/packages/server/src/agent-deployment.ts b/packages/server/src/agent-deployment.ts new file mode 100644 index 0000000..a10d4bf --- /dev/null +++ b/packages/server/src/agent-deployment.ts @@ -0,0 +1,236 @@ +import type Querier from "@blink.so/database/querier"; +import type { AgentDeployment } from "@blink.so/database/schema"; +import { + InternalAPIServerListenPortEnvironmentVariable, + InternalAPIServerURLEnvironmentVariable, +} from "@blink.so/runtime/types"; +import { spawn } from "child_process"; +import { mkdir, writeFile } from "fs/promises"; +import { createServer } from "net"; +import { tmpdir } from "os"; +import { join } from "path"; + +interface DockerDeployOptions { + deployment: AgentDeployment; + querier: Querier; + baseUrl: string; + downloadFile: (id: string) => Promise<{ + stream: ReadableStream; + type: string; + name: string; + size: number; + }>; +} + +/** + * Janky Docker-based agent deployment for self-hosted + * This will download files, write them to a temp directory, + * and run them in a Docker container. + */ +export async function deployAgentWithDocker(opts: DockerDeployOptions) { + const { deployment, querier, baseUrl, downloadFile } = opts; + console.log(`Deploying agent ${deployment.agent_id} (${deployment.id})`); + + try { + await querier.updateAgentDeployment({ + id: deployment.id, + status: "deploying", + }); + + if (!deployment.output_files || deployment.output_files.length === 0) { + throw new Error("No output files provided"); + } + + // Create a temp directory for this deployment + const deploymentDir = join(tmpdir(), `blink-agent-${deployment.id}`); + await mkdir(deploymentDir, { recursive: true }); + + console.log(`Writing files to ${deploymentDir}`); + + // Download and write all files + for (const file of deployment.output_files) { + const fileData = await downloadFile(file.id); + const filePath = join(deploymentDir, file.path); + + // Create parent directories if needed + const parentDir = join(filePath, ".."); + await mkdir(parentDir, { recursive: true }); + + // Convert ReadableStream to Buffer + const reader = fileData.stream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const buffer = Buffer.concat(chunks); + + await writeFile(filePath, buffer); + console.log(`Wrote ${file.path} (${buffer.length} bytes)`); + } + + // Add the node wrapper runtime + const runtime = await import("@blink.so/runtime/node/wrapper"); + const wrapperPath = join(deploymentDir, "__wrapper.js"); + await writeFile(wrapperPath, runtime.default); + console.log(`Wrote __wrapper.js (runtime wrapper)`); + + // The original entrypoint becomes an env var for the wrapper + const originalEntrypoint = deployment.entrypoint; + const wrapperEntrypoint = "__wrapper.js"; + + // Get environment variables for the agent + const envs = await querier.selectAgentEnvironmentVariablesByAgentID({ + agentID: deployment.agent_id, + }); + const target = await querier.selectAgentDeploymentTargetByID( + deployment.target_id + ); + + // Find free ports for this agent (one for external access, one for internal API) + const externalPort = await findFreePort(); + const internalAPIPort = await findFreePort(); + + // Build Docker env args + const dockerEnvArgs: string[] = []; + // Wrapper runtime configuration + dockerEnvArgs.push("-e", `ENTRYPOINT=./${originalEntrypoint}`); + dockerEnvArgs.push( + "-e", + `${InternalAPIServerListenPortEnvironmentVariable}=${internalAPIPort}` + ); + dockerEnvArgs.push( + "-e", + `${InternalAPIServerURLEnvironmentVariable}=${baseUrl}` + ); + // Agent configuration + dockerEnvArgs.push("-e", `BLINK_REQUEST_URL=${baseUrl}`); + dockerEnvArgs.push("-e", `BLINK_REQUEST_ID=${target?.request_id}`); + dockerEnvArgs.push("-e", `PORT=${externalPort}`); + // User-defined environment variables + for (const envVar of envs) { + if (envVar.value !== null) { + dockerEnvArgs.push("-e", `${envVar.key}=${envVar.value}`); + } + } + + // Run docker container + // Mount the deployment directory as /app + // Expose the port so we can access the agent + const containerName = `blink-agent-${deployment.agent_id}`; + + // Stop and remove existing container if it exists + try { + await runCommand("docker", ["stop", containerName]); + await runCommand("docker", ["rm", containerName]); + } catch { + // Ignore errors if container doesn't exist + } + + const dockerArgs = [ + "run", + "-d", + "--name", + containerName, + "--restart", + "unless-stopped", + "--network", + "host", + "-v", + `${deploymentDir}:/app`, + "-w", + "/app", + ...dockerEnvArgs, + "node:22", + "node", + wrapperEntrypoint, + ]; + + console.log(`Running: docker ${dockerArgs.join(" ")}`); + const containerId = await runCommand("docker", dockerArgs); + + console.log(`Container started: ${containerId}`); + + // Update deployment status and set as active if target is production + await querier.tx(async (tx) => { + await tx.updateAgentDeployment({ + id: deployment.id, + status: "success", + direct_access_url: `http://localhost:${externalPort}`, + platform_metadata: { + type: "lambda", + arn: `container:${containerId.trim()}`, + }, + }); + + const deploymentTarget = await tx.selectAgentDeploymentTargetByID( + deployment.target_id + ); + // TODO: We should probably not have this hardcoded. + if (deploymentTarget && deploymentTarget.target === "production") { + await tx.updateAgent({ + id: deployment.agent_id, + active_deployment_id: deployment.id, + }); + } + }); + + console.log(`Deployment ${deployment.id} successful`); + } catch (error) { + console.error(`Deployment ${deployment.id} failed:`, error); + await querier.updateAgentDeployment({ + id: deployment.id, + status: "failed", + error_message: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args); + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Command failed with code ${code}: ${stderr}`)); + } + }); + + proc.on("error", (error) => { + reject(error); + }); + }); +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const address = server.address(); + if (address && typeof address !== "string") { + const port = address.port; + server.close(() => { + resolve(port); + }); + } else { + server.close(); + reject(new Error("Failed to get port")); + } + }); + server.on("error", reject); + }); +} diff --git a/packages/server/src/chat.ts b/packages/server/src/chat.ts new file mode 100644 index 0000000..18a3e14 --- /dev/null +++ b/packages/server/src/chat.ts @@ -0,0 +1,278 @@ +import type { StreamChatEvent } from "@blink.so/api"; +import { runChat } from "@blink.so/api/util/chat"; +import type Querier from "@blink.so/database/querier"; +import type { DBMessage } from "@blink.so/database/schema"; +import type { WebSocketServer } from "ws"; +import { WebSocket } from "ws"; + +class ChatSession { + private sseStreams: Set> = new Set(); + private streamingBuffer: string[] = []; + private streamAbortController?: AbortController; + private running = false; + + constructor(private id: string) {} + + addSSEStream(writer: WritableStreamDefaultWriter) { + this.sseStreams.add(writer); + writer.closed.then(() => { + this.sseStreams.delete(writer); + }); + + // Send buffered events to new connection + (async () => { + for (const encoded of this.streamingBuffer) { + await writer.write(encoded); + } + })(); + } + + broadcast( + event: StreamChatEvent, + wss: WebSocketServer, + wsDataMap: WeakMap< + WebSocket, + { type: "token"; id: string } | { type: "chat"; chatID: string } + > + ) { + const encoded = encodeStreamChatEvent(event); + + // Store message chunks for reconnecting clients + if (event.event === "message.chunk.added") { + this.streamingBuffer.push(encoded); + } + + // Broadcast to WebSockets + wss.clients.forEach((client) => { + const data = wsDataMap.get(client); + if ( + client.readyState === WebSocket.OPEN && + data?.type === "chat" && + data.chatID === this.id + ) { + client.send(encoded); + } + }); + + // Broadcast to SSE streams + for (const writer of this.sseStreams) { + writer.write(encoded).catch(() => { + // Client disconnected, ignore + }); + } + } + + async start(opts: { + interrupt: boolean; + db: Querier; + env: Record; + wss: WebSocketServer; + wsDataMap: WeakMap< + WebSocket, + { type: "token"; id: string } | { type: "chat"; chatID: string } + >; + }) { + if (opts.interrupt) { + this.streamAbortController?.abort(); + } + + if (this.running && !opts.interrupt) { + return; + } + + this.running = true; + this.executeChat(opts); + } + + stop() { + this.streamAbortController?.abort(); + this.running = false; + } + + private async executeChat(opts: { + db: Querier; + env: Record; + wss: WebSocketServer; + wsDataMap: WeakMap< + WebSocket, + { type: "token"; id: string } | { type: "chat"; chatID: string } + >; + }) { + this.streamAbortController?.abort(); + const controller = new AbortController(); + this.streamAbortController = controller; + + try { + this.streamingBuffer = []; + const result = await runChat({ + id: this.id, + signal: controller.signal, + db: opts.db, + broadcast: async (event) => { + this.broadcast(event, opts.wss, opts.wsDataMap); + }, + waitUntil: async (promise) => { + // In Node/Bun we can just let it run + promise.catch(console.error); + }, + env: opts.env as any, + writePlatformLog: async () => { + // No-op for now + }, + }); + + this.streamingBuffer = []; + + if (result.continue) { + // Continue executing + await this.executeChat(opts); + } else { + this.running = false; + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + // Expected when stopping + return; + } + console.error("Chat execution error:", error); + this.running = false; + } finally { + if (this.streamAbortController === controller) { + this.streamAbortController = undefined; + } + } + } + + async broadcastMessagesChanged( + event: "message.created" | "message.updated", + messages: DBMessage[], + wss: WebSocketServer, + wsDataMap: WeakMap< + WebSocket, + { type: "token"; id: string } | { type: "chat"; chatID: string } + > + ) { + for (const message of messages) { + this.broadcast( + { + event, + data: { + id: message.id, + chat_id: message.chat_id, + role: message.role, + parts: message.parts, + format: "ai-sdk", + created_at: message.created_at.toISOString(), + metadata: message.metadata, + }, + }, + wss, + wsDataMap + ); + } + } + + getBufferedEvents() { + return this.streamingBuffer; + } + + sendBufferedEvents(ws: any) { + for (const encoded of this.streamingBuffer) { + ws.send(encoded); + } + } +} + +export class ChatManager { + private sessions = new Map(); + + constructor( + private wss: WebSocketServer, + private wsDataMap: WeakMap< + WebSocket, + { type: "token"; id: string } | { type: "chat"; chatID: string } + >, + private getDB: () => Promise, + private env: Record + ) {} + + private getSession(id: string): ChatSession { + let session = this.sessions.get(id); + if (!session) { + session = new ChatSession(id); + this.sessions.set(id, session); + } + return session; + } + + async handleStream(id: string, request: Request): Promise { + const session = this.getSession(id); + + // Handle SSE + if (request.headers.get("Accept") === "text/event-stream") { + const transform = new TextEncoderStream(); + const writer = transform.writable.getWriter(); + session.addSSEStream(writer); + + return new Response(transform.readable, { + status: 200, + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + "transfer-encoding": "chunked", + connection: "keep-alive", + }, + }); + } + + return new Response("Bad Request", { status: 400 }); + } + + async handleStart(opts: { id: string; interrupt: boolean }) { + const session = this.getSession(opts.id); + const db = await this.getDB(); + await session.start({ + interrupt: opts.interrupt, + db, + env: this.env, + wss: this.wss, + wsDataMap: this.wsDataMap, + }); + } + + async handleStop(id: string) { + const session = this.sessions.get(id); + if (session) { + session.stop(); + } + } + + async handleMessagesChanged( + event: "message.created" | "message.updated", + id: string, + messages: DBMessage[] + ) { + const session = this.getSession(id); + await session.broadcastMessagesChanged( + event, + messages, + this.wss, + this.wsDataMap + ); + } + + sendBufferedEventsToWebSocket(chatID: string, ws: any) { + const session = this.sessions.get(chatID); + if (session) { + session.sendBufferedEvents(ws); + } + } +} + +function encodeStreamChatEvent(event: StreamChatEvent): string { + return [ + `event: ${event.event}`, + `data: ${JSON.stringify(event.data)}`, + "\n", + ].join("\n"); +} diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts new file mode 100644 index 0000000..7e60841 --- /dev/null +++ b/packages/server/src/cli.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import boxen from "boxen"; +import chalk from "chalk"; +import { Command } from "commander"; +import { version } from "../package.json"; +import * as logger from "./logger"; +import { ensurePostgres } from "./postgres"; +import { startServer } from "./server"; + +const program = new Command(); + +program + .name("blink-server") + .description("Self-hosted Blink server") + .version(version) + .option("-p, --port ", "Port to run the server on", "3005") + .action(async (options) => { + try { + await runServer(options); + } catch (error) { + console.error(error, error instanceof Error ? error.stack : undefined); + logger.error( + error instanceof Error ? error.message : "An unknown error occurred" + ); + process.exit(1); + } + }); + +async function runServer(options: { port: string }) { + const port = parseInt(options.port, 10); + if (isNaN(port) || port < 1 || port > 65535) { + throw new Error(`Invalid port: ${options.port}`); + } + + console.log(chalk.bold("blinkβ– "), version, chalk.gray("agents as a service")); + + // Check and setup environment variables + let postgresUrl = process.env.POSTGRES_URL || process.env.DATABASE_URL; + + if (!postgresUrl) { + postgresUrl = await ensurePostgres(); + } + + // Generate or use existing AUTH_SECRET + const authSecret = + process.env.AUTH_SECRET || "fake-random-string-should-be-in-db"; + + const baseUrl = process.env.BASE_URL || `http://localhost:${port}`; + + // Start the server + const srv = await startServer({ + port, + postgresUrl, + authSecret, + baseUrl, + }); + + const box = boxen( + [ + "View the Web UI:", + chalk.magenta.underline(baseUrl), + "", + `Set ${chalk.bold("BLINK_API_URL=" + baseUrl)} when using the Blink CLI.`, + ].join("\n"), + { + borderColor: "cyan", + padding: { + left: 4, + right: 4, + }, + textAlignment: "center", + } + ); + console.log(box); +} + +program.parse(); diff --git a/packages/server/src/logger.ts b/packages/server/src/logger.ts new file mode 100644 index 0000000..7e847a0 --- /dev/null +++ b/packages/server/src/logger.ts @@ -0,0 +1,29 @@ +import chalk from "chalk"; + +function formatTimestamp(): string { + const now = new Date(); + const date = now.toISOString().split("T")[0]!; + const time = now.toTimeString().split(" ")[0]!; + const ms = now.getMilliseconds().toString().padStart(3, "0"); + return chalk.gray(`${date} ${time}.${ms}`); +} + +export function info(message: string) { + console.log(`${formatTimestamp()} ${chalk.cyan("[info]")} ${message}`); +} + +export function warn(message: string) { + console.log(`${formatTimestamp()} ${chalk.yellow("[warn]")} ${message}`); +} + +export function error(message: string) { + console.log(`${formatTimestamp()} ${chalk.red("[error]")} ${message}`); +} + +export function success(message: string) { + console.log(`${formatTimestamp()} ${chalk.green("[info]")} ${message}`); +} + +export function plain(message: string) { + console.log(message); +} diff --git a/packages/server/src/postgres.ts b/packages/server/src/postgres.ts new file mode 100644 index 0000000..a3055b4 --- /dev/null +++ b/packages/server/src/postgres.ts @@ -0,0 +1,225 @@ +import { spawn } from "child_process"; +import { createServer } from "net"; +import * as logger from "./logger"; + +const CONTAINER_NAME = "blink-server-postgres"; +const POSTGRES_PASSWORD = "blink-server-dev-password"; +const POSTGRES_USER = "postgres"; +const POSTGRES_DB = "blink"; +const POSTGRES_PORT = 54321; + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(command, args); + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + proc.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + if (code === 0) { + resolve(stdout.trim()); + } else { + reject(new Error(`Command failed with code ${code}: ${stderr}`)); + } + }); + + proc.on("error", (error) => { + reject(error); + }); + }); +} + +async function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + + server.once("error", () => { + resolve(false); + }); + + server.once("listening", () => { + server.close(); + resolve(true); + }); + + server.listen(port); + }); +} + +async function isDockerRunning(): Promise { + try { + await runCommand("docker", ["info"]); + return true; + } catch { + return false; + } +} + +async function getContainerStatus(): Promise< + "running" | "stopped" | "not-found" +> { + try { + const output = await runCommand("docker", [ + "ps", + "-a", + "--filter", + `name=^${CONTAINER_NAME}$`, + "--format", + "{{.State}}", + ]); + + if (!output) { + return "not-found"; + } + + return output === "running" ? "running" : "stopped"; + } catch { + return "not-found"; + } +} + +async function startExistingContainer(): Promise { + logger.plain(`Starting existing PostgreSQL container: ${CONTAINER_NAME}`); + await runCommand("docker", ["start", CONTAINER_NAME]); + + // Wait for PostgreSQL to be ready + await waitForPostgres(); +} + +async function createAndStartContainer(): Promise { + logger.plain(`Creating PostgreSQL container: ${CONTAINER_NAME}`); + + const portAvailable = await isPortAvailable(POSTGRES_PORT); + if (!portAvailable) { + throw new Error( + `Port ${POSTGRES_PORT} is already in use. Please free the port or set POSTGRES_URL manually.` + ); + } + + await runCommand("docker", [ + "run", + "-d", + "--name", + CONTAINER_NAME, + "--restart", + "unless-stopped", + "-e", + `POSTGRES_PASSWORD=${POSTGRES_PASSWORD}`, + "-e", + `POSTGRES_DB=${POSTGRES_DB}`, + "-p", + `${POSTGRES_PORT}:5432`, + "pgvector/pgvector:pg17", + ]); + + logger.plain("PostgreSQL container created"); + + // Wait for PostgreSQL to be ready + await waitForPostgres(); +} + +async function waitForPostgres(): Promise { + logger.plain("Waiting for PostgreSQL to be ready..."); + + const maxAttempts = 30; + for (let i = 0; i < maxAttempts; i++) { + try { + await runCommand("docker", [ + "exec", + CONTAINER_NAME, + "pg_isready", + "-U", + POSTGRES_USER, + ]); + logger.plain("PostgreSQL is ready"); + return; + } catch { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + throw new Error("PostgreSQL failed to become ready in time"); +} + +async function promptUser(question: string): Promise { + logger.plain(question); + process.stdout.write("(y/n): "); + + return new Promise((resolve) => { + const stdin = process.stdin; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + + const onData = (key: string) => { + stdin.setRawMode(false); + stdin.pause(); + stdin.removeListener("data", onData); + + console.log(key); + + if (key === "y" || key === "Y") { + resolve(true); + } else { + resolve(false); + } + }; + + stdin.on("data", onData); + }); +} + +export async function ensurePostgres(): Promise { + // Check if Docker is running + const dockerRunning = await isDockerRunning(); + if (!dockerRunning) { + throw new Error( + "Docker is not running. Please start Docker or set POSTGRES_URL manually." + ); + } + + const status = await getContainerStatus(); + + if (status === "running") { + logger.info( + `Using Docker PostgreSQL '${CONTAINER_NAME}' because POSTGRES_URL is not set` + ); + return getConnectionString(); + } + + if (status === "stopped") { + await startExistingContainer(); + logger.info( + `Using Docker PostgreSQL '${CONTAINER_NAME}' because POSTGRES_URL is not set` + ); + return getConnectionString(); + } + + // Container doesn't exist, ask user if they want to create it + const shouldCreate = await promptUser( + "No PostgreSQL container found. Create one with Docker?" + ); + + if (!shouldCreate) { + throw new Error( + "PostgreSQL is required. Please set POSTGRES_URL manually." + ); + } + + await createAndStartContainer(); + logger.info( + `Using Docker PostgreSQL '${CONTAINER_NAME}' because POSTGRES_URL is not set` + ); + return getConnectionString(); +} + +export function getConnectionString(): string { + return `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}`; +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts new file mode 100644 index 0000000..c1a7d10 --- /dev/null +++ b/packages/server/src/server.ts @@ -0,0 +1,474 @@ +import api from "@blink.so/api/server"; +import connectToPostgres from "@blink.so/database/postgres"; +import Querier from "@blink.so/database/querier"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { existsSync } from "fs"; +import { readFile } from "fs/promises"; +import { createServer, IncomingMessage } from "http"; +import module from "module"; +import path, { join } from "path"; +import { parse } from "url"; +import { WebSocket, WebSocketServer } from "ws"; +import { deployAgentWithDocker } from "./agent-deployment"; +import { ChatManager } from "./chat"; + +type WSData = { type: "token"; id: string } | { type: "chat"; chatID: string }; + +interface ServerOptions { + port: number; + postgresUrl: string; + authSecret: string; + baseUrl: string; +} + +// Files are now stored in the database instead of in-memory + +export async function startServer(options: ServerOptions) { + const { port, postgresUrl, authSecret, baseUrl } = options; + + const db = await connectToPostgres(postgresUrl); + const querier = new Querier(db); + + // Here we find the correct directories for the site and migrations. + let siteDir = join(import.meta.dirname, "site"); + let migrationsDir = join(import.meta.dirname, "migrations"); + if (import.meta.filename.endsWith("server.ts")) { + // We're running in development mode, so we need to point to the dist directory. + const distDir = join(import.meta.dirname, "..", "dist"); + if (!existsSync(distDir)) { + throw new Error( + `Dist directory not found: ${distDir}. Run 'bun run build' to build the server.` + ); + } + siteDir = join(distDir, "site"); + migrationsDir = join(distDir, "migrations"); + } + + // Run database migrations... + await migrate(db, { migrationsFolder: migrationsDir }); + + const app = await startNextServer({ + siteDir, + postgresUrl, + authSecret, + baseUrl, + }); + await app.prepare(); + const nextHandler = app.getRequestHandler(); + + const chatManagerRef: { current?: ChatManager } = {}; + + // Store WebSocket metadata without monkey-patching + const wsDataMap = new WeakMap(); + + // Create WebSocket server first (needed in api.fetch below) + const wss = new WebSocketServer({ noServer: true }); + + // Helper to convert Node.js request to Fetch Request + const toFetchRequest = (nodeReq: IncomingMessage): Request => { + const protocol = "http"; + const host = nodeReq.headers.host || `localhost:${port}`; + const fullUrl = `${protocol}://${host}${nodeReq.url}`; + + const headers = new Headers(); + for (const [key, value] of Object.entries(nodeReq.headers)) { + if (value) { + if (Array.isArray(value)) { + for (const v of value) headers.append(key, v); + } else { + headers.set(key, value); + } + } + } + + // Node.js IncomingMessage is a ReadableStream but needs type assertion + // for the Fetch API Request constructor + const body = + nodeReq.method !== "GET" && nodeReq.method !== "HEAD" + ? (nodeReq as any) + : undefined; + + return new Request(fullUrl, { + method: nodeReq.method, + headers, + body, + // @ts-ignore - this is a NodeJS thing. + duplex: "half", + }); + }; + + // Create HTTP server + const server = createServer(async (nodeReq, nodeRes) => { + try { + const url = new URL( + nodeReq.url || "/", + `http://${nodeReq.headers.host || `localhost:${port}`}` + ); + + if (url.pathname.startsWith("/api")) { + const req = toFetchRequest(nodeReq); + const response = await api.fetch( + req, + { + AUTH_SECRET: authSecret, + NODE_ENV: "development", + agentStore: (deploymentTargetID) => { + return { + delete: async (key) => { + await querier.deleteAgentStorageKV({ + deployment_target_id: deploymentTargetID, + key, + }); + }, + get: async (key) => { + const value = await querier.selectAgentStorageKV({ + deployment_target_id: deploymentTargetID, + key, + }); + if (!value) { + return undefined; + } + return value.value; + }, + set: async (key, value) => { + const target = + await querier.selectAgentDeploymentTargetByID( + deploymentTargetID + ); + if (!target) { + throw new Error("Deployment target not found"); + } + await querier.upsertAgentStorageKV({ + agent_deployment_target_id: target.id, + agent_id: target.agent_id, + key: key, + value: value, + }); + }, + list: async (prefix, options) => { + const values = await querier.selectAgentStorageKVByPrefix({ + deployment_target_id: deploymentTargetID, + prefix: prefix ?? "", + limit: options?.limit ?? 100, + cursor: options?.cursor, + }); + return { + entries: values.items.map((value) => ({ + key: value.key, + value: value.value, + })), + cursor: values.next_cursor ? values.next_cursor : undefined, + }; + }, + }; + }, + database: async () => { + const conn = await connectToPostgres(postgresUrl); + return new Querier(conn); + }, + apiBaseURL: url, + auth: { + handleWebSocketTokenRequest: async (id, request) => { + // WebSocket upgrades are handled in the 'upgrade' event + return new Response(null, { status: 101 }); + }, + sendTokenToWebSocket: async (id, token) => { + wss.clients.forEach((client) => { + const data = wsDataMap.get(client); + if ( + client.readyState === WebSocket.OPEN && + data?.type === "token" && + data.id === id + ) { + client.send(token); + } + }); + }, + }, + chat: { + async handleMessagesChanged(event, id, messages) { + await chatManagerRef.current?.handleMessagesChanged( + event, + id, + messages + ); + }, + handleStart: async (opts) => { + await chatManagerRef.current?.handleStart(opts); + }, + handleStop: async (id) => { + await chatManagerRef.current?.handleStop(id); + }, + handleStream: async (id, req) => { + if (!chatManagerRef.current) { + return new Response("Server not ready", { status: 503 }); + } + // WebSocket upgrades are handled in the 'upgrade' event + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return new Response(null, { status: 101 }); + } + return await chatManagerRef.current.handleStream(id, req); + }, + generateTitle: async (opts) => { + // noop + }, + }, + deployAgent: async (deployment) => { + await deployAgentWithDocker({ + deployment, + querier, + baseUrl, + downloadFile: async (id: string) => { + const file = await querier.selectFileByID(id); + if (!file || !file.content) { + throw new Error("File not found"); + } + + // Convert buffer back to ReadableStream + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(file.content); + controller.close(); + }, + }); + + return { + stream, + type: file.content_type, + name: file.name, + size: file.byte_length, + }; + }, + }); + }, + files: { + upload: async (opts) => { + const id = crypto.randomUUID(); + + // Read file content into buffer + const arrayBuffer = await opts.file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Store file in database + await querier.insertFile({ + id, + name: opts.file.name, + message_id: null, + user_id: null, + organization_id: null, + content_type: opts.file.type, + byte_length: opts.file.size, + pdf_page_count: null, + content: buffer, + }); + + return { + id, + url: `${baseUrl}/api/files/${id}`, + }; + }, + download: async (id) => { + const file = await querier.selectFileByID(id); + if (!file || !file.content) { + throw new Error("File not found"); + } + + // Convert buffer back to ReadableStream + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(file.content); + controller.close(); + }, + }); + + return { + stream, + type: file.content_type, + name: file.name, + size: file.byte_length, + }; + }, + }, + logs: { + get: async (opts) => { + return querier.getAgentLogs(opts); + }, + write: async (opts) => { + await querier.writeAgentLog(opts); + }, + }, + traces: { + write: async (spans) => { + await querier.writeAgentTraces(spans); + }, + read: async (opts) => { + return querier.readAgentTraces(opts); + }, + }, + runtime: { + usage: async (opts) => { + // noop + throw new Error("Not implemented"); + }, + }, + }, + { + waitUntil: async (promise) => { + // noop + }, + passThroughOnException: () => { + // noop + }, + props: {}, + } + ); + + // Write Fetch Response to Node.js response + const headersObj: Record = {}; + response.headers.forEach((value, key) => { + const existing = headersObj[key]; + if (existing) { + if (Array.isArray(existing)) { + existing.push(value); + } else { + headersObj[key] = [existing, value]; + } + } else { + headersObj[key] = value; + } + }); + nodeRes.writeHead(response.status, response.statusText, headersObj); + + if (response.body) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + nodeRes.write(value); + } + } + nodeRes.end(); + return; + } + + // Handle Next.js routes + await nextHandler(nodeReq, nodeRes); + } catch (error) { + console.error("Request error:", error); + if (!nodeRes.headersSent) { + nodeRes.writeHead(500, { "Content-Type": "text/plain" }); + nodeRes.end("Internal Server Error"); + } + } + }); + + // Handle WebSocket upgrades + server.on("upgrade", (request, socket, head) => { + const { pathname, query } = parse(request.url || "", true); + + // Check if this is a token auth WebSocket + if (pathname?.startsWith("/api/auth/token")) { + const id = query.id as string; + wss.handleUpgrade(request, socket, head, (ws) => { + wsDataMap.set(ws, { type: "token", id }); + wss.emit("connection", ws, request); + }); + return; + } + + // Check if this is a chat WebSocket + const chatMatch = pathname?.match(/\/api\/chats\/([^/]+)\/stream/); + if (chatMatch?.[1]) { + const chatID = chatMatch[1]; + wss.handleUpgrade(request, socket, head, (ws) => { + wsDataMap.set(ws, { type: "chat", chatID }); + wss.emit("connection", ws, request); + }); + return; + } + + socket.destroy(); + }); + + wss.on("connection", (ws) => { + const data = wsDataMap.get(ws); + + if (data?.type === "chat") { + // Send buffered chunk events to reconnecting client + chatManagerRef.current?.sendBufferedEventsToWebSocket(data.chatID, ws); + } + + ws.on("close", () => { + wsDataMap.delete(ws); + }); + }); + + chatManagerRef.current = new ChatManager( + wss, + wsDataMap, + async () => { + const conn = await connectToPostgres(postgresUrl); + return new Querier(conn); + }, + process.env as Record + ); + + server.listen(port); + + return server; +} + +export interface StartNextServerOptions { + siteDir: string; + + postgresUrl: string; + authSecret: string; + baseUrl: string; +} + +/** + * startNextServer starts the Next.js server. + * It does this in a kinda convoluted way because we use the standalone + * mode but want to handle all the routes ourselves, not having it listen + * on it's own port and such. + */ +const startNextServer = async (opts: StartNextServerOptions) => { + // createRequire needs a filename (not directory) to establish module resolution context. + // We create a minimal package.json in the site dir during build for this purpose. + const packageJsonPath = path.join(opts.siteDir, "package.json"); + if (!existsSync(packageJsonPath)) { + throw new Error( + `package.json not found at ${packageJsonPath}. Make sure you built with BUILD_SITE=1.` + ); + } + const customRequire = module.createRequire(packageJsonPath); + + // These are env vars that the server needs to run. + // We could technically make these use the same DB instance somehow. + process.env.POSTGRES_URL = opts.postgresUrl; + process.env.AUTH_SECRET = opts.authSecret; + process.env.NEXT_PUBLIC_BASE_URL = opts.baseUrl; + + let nextConfig: any = {}; + try { + const content = await readFile( + path.join(opts.siteDir, ".next", "required-server-files.json"), + "utf-8" + ); + nextConfig = JSON.parse(content).config; + } catch (err) { + throw new Error( + `dev error: required next config file not found at ${path.join(opts.siteDir, ".next", "required-server-files.json")}: ${err}` + ); + } + // This is required for Next to not freak out about not having a config. + // Their standalone generated file does exactly this. + process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(nextConfig); + + const next = customRequire("next") as typeof import("next").default; + const app = next({ + dev: false, + dir: opts.siteDir, + }); + return app; +}; diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..99559bc --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/server/tsdown.config.ts b/packages/server/tsdown.config.ts new file mode 100644 index 0000000..f1f3628 --- /dev/null +++ b/packages/server/tsdown.config.ts @@ -0,0 +1,103 @@ +import { execSync } from "child_process"; +import { cpSync, existsSync, mkdirSync } from "fs"; +import { join } from "path"; +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: "src/main.ts", + format: ["esm"], + target: "node20", + outDir: "dist", + clean: true, + dts: false, + // Only externalize Next.js + external: [/^next/], + // Use shims to ensure circular deps work + shims: true, + onSuccess: async () => { + console.log("\nπŸ“¦ Building production assets..."); + + const rootDir = join(import.meta.dirname, "..", ".."); + const siteDir = join(rootDir, "packages", "site"); + const dbDir = join(rootDir, "packages", "database"); + const distDir = join(import.meta.dirname, "dist"); + const siteBuildDir = join(distDir, "site"); + + // Build Next.js site in its source directory + console.log("πŸ”¨ Building Next.js site..."); + execSync("bun run build", { + cwd: siteDir, + stdio: "inherit", + }); + + // Copy only essential parts of .next folder (exclude cache) + const nextBuildSource = join(siteDir, ".next"); + const nextBuildTarget = join(siteBuildDir, ".next"); + + if (existsSync(nextBuildSource)) { + console.log("πŸ“„ Copying Next.js build output (excluding cache)..."); + mkdirSync(siteBuildDir, { recursive: true }); + + // Copy only the essential directories + const essentialDirs = [ + "server", + "static", + "types", + "app-paths-manifest.json", + "build-manifest.json", + "package.json", + "prerender-manifest.json", + "react-loadable-manifest.json", + "required-server-files.json", + "routes-manifest.json", + ]; + + for (const item of essentialDirs) { + const src = join(nextBuildSource, item); + const dest = join(nextBuildTarget, item); + if (existsSync(src)) { + cpSync(src, dest, { recursive: true }); + } + } + } else { + throw new Error("Next.js build not found at " + nextBuildSource); + } + + // Copy public folder if exists + const publicSource = join(siteDir, "public"); + if (existsSync(publicSource)) { + console.log("πŸ“„ Copying public assets..."); + cpSync(publicSource, join(siteBuildDir, "public"), { recursive: true }); + } + + // Copy migrations + const migrationsSource = join(dbDir, "migrations"); + const migrationsTarget = join(distDir, "migrations"); + + if (existsSync(migrationsSource)) { + console.log("πŸ“„ Copying migrations..."); + cpSync(migrationsSource, migrationsTarget, { recursive: true }); + } + + // Create minimal package.json for external dependencies + const packageJsonPath = join(distDir, "package.json"); + const packageJson = { + type: "module", + dependencies: { + next: "*", + pg: "*", + "drizzle-orm": "*", + }, + }; + console.log("πŸ“„ Creating package.json..."); + cpSync(join(import.meta.dirname, "package.json"), packageJsonPath); + + console.log("βœ… Build complete!"); + console.log(` Server: ${distDir}/main.js + chunks`); + console.log(` Site: ${siteBuildDir}/`); + console.log(` Migrations: ${migrationsTarget}/`); + console.log(`\nπŸ“ Next steps:`); + console.log(` cd dist && bun install (for Next.js)`); + console.log(` bun run start:prod`); + }, +}); From 2068a72bfd0802f29bcf6f6dc128076a421e0837 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 9 Dec 2025 12:50:43 +0100 Subject: [PATCH 2/8] changes --- .gitignore | 4 ++- packages/server/scripts/build.ts | 53 +++++++++++++++++++++++++++++++- packages/server/src/postgres.ts | 4 +++ packages/server/src/server.ts | 6 ++-- packages/site/lib/database.ts | 20 +++++++----- packages/site/next-env.d.ts | 1 + 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index f0813fa..453725f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ dist/ *.tgz .env.* .sonda -.next \ No newline at end of file +.next +.blink +.next diff --git a/packages/server/scripts/build.ts b/packages/server/scripts/build.ts index 8e2fe13..dcfc756 100644 --- a/packages/server/scripts/build.ts +++ b/packages/server/scripts/build.ts @@ -1,6 +1,13 @@ import { build } from "bun"; import { execSync } from "child_process"; -import { cpSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { + cpSync, + mkdirSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; import { join } from "path"; const distDir = join(import.meta.dirname, "..", "dist"); @@ -65,6 +72,50 @@ function buildNextSite() { join(distDir, "site", "package.json"), JSON.stringify({ type: "module" }) ); + + // Create symlinks for packages in .bun directory so Node.js can resolve them. + // Bun uses a .bun directory structure instead of flat node_modules, so we need + // to create symlinks at the top level pointing to the actual packages. + const bunDir = join(distDir, "site", "node_modules", ".bun"); + const nodeModulesDir = join(distDir, "site", "node_modules"); + for (const entry of readdirSync(bunDir)) { + // Skip non-package entries + if (entry === "node_modules" || entry.startsWith(".")) continue; + + // Parse package name from entry (e.g., "next@15.5.6+..." -> "next") + // or ("@img+sharp-linux-arm64@0.34.5" -> "@img/sharp-linux-arm64") + const atIndex = entry.lastIndexOf("@"); + if (atIndex <= 0) continue; // Skip if no version found + + let packageName = entry.slice(0, atIndex); + // Handle scoped packages (bun uses + instead of /) + if (packageName.startsWith("@") && packageName.includes("+")) { + packageName = packageName.replace("+", "/"); + } + + const targetPath = packageName.includes("/") + ? join(nodeModulesDir, ...packageName.split("/")) + : join(nodeModulesDir, packageName); + + // Create parent directory for scoped packages + if (packageName.includes("/")) { + const scope = packageName.split("/")[0]!; + mkdirSync(join(nodeModulesDir, scope), { recursive: true }); + } + + // Create relative symlink + const relativePath = join( + ".bun", + entry, + "node_modules", + ...packageName.split("/") + ); + try { + symlinkSync(relativePath, targetPath); + } catch { + // Symlink may already exist + } + } } function copyMigrations() { diff --git a/packages/server/src/postgres.ts b/packages/server/src/postgres.ts index a3055b4..b59cae0 100644 --- a/packages/server/src/postgres.ts +++ b/packages/server/src/postgres.ts @@ -116,7 +116,11 @@ async function createAndStartContainer(): Promise { `POSTGRES_DB=${POSTGRES_DB}`, "-p", `${POSTGRES_PORT}:5432`, + "-v", + "blink-server-postgres-data:/var/lib/postgresql/data", "pgvector/pgvector:pg17", + "-c", + "max_connections=1000", ]); logger.plain("PostgreSQL container created"); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index c1a7d10..20676f7 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -163,8 +163,7 @@ export async function startServer(options: ServerOptions) { }; }, database: async () => { - const conn = await connectToPostgres(postgresUrl); - return new Querier(conn); + return querier; }, apiBaseURL: url, auth: { @@ -407,8 +406,7 @@ export async function startServer(options: ServerOptions) { wss, wsDataMap, async () => { - const conn = await connectToPostgres(postgresUrl); - return new Querier(conn); + return querier; }, process.env as Record ); diff --git a/packages/site/lib/database.ts b/packages/site/lib/database.ts index ed6d018..5738c3b 100644 --- a/packages/site/lib/database.ts +++ b/packages/site/lib/database.ts @@ -1,15 +1,19 @@ import connectToPostgres from "@blink.so/database/postgres"; import Querier from "@blink.so/database/querier"; +const querierCache = new Map(); + // getQuerier is a helper function for all functions in the site // that need to connect to the database. -// -// They do not need to be concerned about ending connections. -// This all runs serverless, and we have max idle time -// which will close the connection. +// TODO: it's janky that we're caching the querier globally like this. +// We should make it cleaner. export const getQuerier = async (): Promise => { - const conn = await connectToPostgres( - process.env.DATABASE_URL ?? process.env.POSTGRES_URL ?? "" - ); - return new Querier(conn); + const url = process.env.DATABASE_URL ?? process.env.POSTGRES_URL ?? ""; + let querier = querierCache.get(url); + if (!querier) { + const conn = await connectToPostgres(url); + querier = new Querier(conn); + querierCache.set(url, querier); + } + return querier; }; diff --git a/packages/site/next-env.d.ts b/packages/site/next-env.d.ts index 1b3be08..830fb59 100644 --- a/packages/site/next-env.d.ts +++ b/packages/site/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 713cb881ed844d667d0a19512561c739fb194918 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 9 Dec 2025 13:29:13 +0100 Subject: [PATCH 3/8] docker networking --- packages/server/src/agent-deployment.ts | 40 ++- .../server/src/check-docker-networking.ts | 264 ++++++++++++++++++ 2 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/check-docker-networking.ts diff --git a/packages/server/src/agent-deployment.ts b/packages/server/src/agent-deployment.ts index a10d4bf..6955801 100644 --- a/packages/server/src/agent-deployment.ts +++ b/packages/server/src/agent-deployment.ts @@ -9,6 +9,7 @@ import { mkdir, writeFile } from "fs/promises"; import { createServer } from "net"; import { tmpdir } from "os"; import { join } from "path"; +import { getDockerNetworkingConfig } from "./check-docker-networking"; interface DockerDeployOptions { deployment: AgentDeployment; @@ -88,10 +89,34 @@ export async function deployAgentWithDocker(opts: DockerDeployOptions) { deployment.target_id ); + // Determine the best Docker networking mode for this system + const networkConfig = await getDockerNetworkingConfig(); + console.log(`Docker networking config: ${JSON.stringify(networkConfig)}`); + + if (networkConfig.recommended === "none") { + throw new Error( + "Docker networking check failed: neither host networking nor port binding supports bidirectional communication between host and container. " + + "Please check your Docker configuration." + ); + } + + const useHostNetwork = + networkConfig.recommended === "host" || + networkConfig.recommended === "both"; + // Find free ports for this agent (one for external access, one for internal API) const externalPort = await findFreePort(); const internalAPIPort = await findFreePort(); + // Calculate the URL the container should use to reach the host + let containerBaseUrl = baseUrl; + if (!useHostNetwork && networkConfig.portBind.hostAddress) { + // Replace the host in baseUrl with the address that works from the container + const url = new URL(baseUrl); + url.hostname = networkConfig.portBind.hostAddress; + containerBaseUrl = url.toString().replace(/\/$/, ""); // Remove trailing slash + } + // Build Docker env args const dockerEnvArgs: string[] = []; // Wrapper runtime configuration @@ -102,10 +127,10 @@ export async function deployAgentWithDocker(opts: DockerDeployOptions) { ); dockerEnvArgs.push( "-e", - `${InternalAPIServerURLEnvironmentVariable}=${baseUrl}` + `${InternalAPIServerURLEnvironmentVariable}=${containerBaseUrl}` ); // Agent configuration - dockerEnvArgs.push("-e", `BLINK_REQUEST_URL=${baseUrl}`); + dockerEnvArgs.push("-e", `BLINK_REQUEST_URL=${containerBaseUrl}`); dockerEnvArgs.push("-e", `BLINK_REQUEST_ID=${target?.request_id}`); dockerEnvArgs.push("-e", `PORT=${externalPort}`); // User-defined environment variables @@ -128,6 +153,7 @@ export async function deployAgentWithDocker(opts: DockerDeployOptions) { // Ignore errors if container doesn't exist } + // Build docker args based on networking mode const dockerArgs = [ "run", "-d", @@ -135,8 +161,14 @@ export async function deployAgentWithDocker(opts: DockerDeployOptions) { containerName, "--restart", "unless-stopped", - "--network", - "host", + ...(useHostNetwork + ? ["--network", "host"] + : [ + "-p", + `${externalPort}:${externalPort}`, + "-p", + `${internalAPIPort}:${internalAPIPort}`, + ]), "-v", `${deploymentDir}:/app`, "-w", diff --git a/packages/server/src/check-docker-networking.ts b/packages/server/src/check-docker-networking.ts new file mode 100644 index 0000000..4a3deca --- /dev/null +++ b/packages/server/src/check-docker-networking.ts @@ -0,0 +1,264 @@ +import { spawn } from "node:child_process"; +import http from "node:http"; +import { createServer } from "node:net"; + +export interface NetworkingTestResult { + hostNetwork: { + hostToContainer: boolean; + containerToHost: boolean; + hostAddress: string | null; + }; + portBind: { + hostToContainer: boolean; + containerToHost: boolean; + hostAddress: string | null; + }; + recommended: "host" | "port-bind" | "both" | "none"; +} + +let cachedResult: NetworkingTestResult | null = null; + +/** + * Get the cached networking test result, or run the test if not cached. + */ +export async function getDockerNetworkingConfig(): Promise { + if (cachedResult) { + return cachedResult; + } + cachedResult = await checkDockerNetworking(); + return cachedResult; +} + +/** + * Clear the cached networking test result (useful for testing or if Docker config changes). + */ +export function clearDockerNetworkingCache(): void { + cachedResult = null; +} + +async function getRandomPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.listen(0, () => { + const addr = server.address(); + const port = typeof addr === "object" ? addr?.port : 0; + server.close(() => resolve(port!)); + }); + server.on("error", reject); + }); +} + +function startHostServer(): Promise<{ server: http.Server; port: number }> { + return new Promise((resolve) => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ source: "host" })); + }); + server.listen(0, "0.0.0.0", () => { + const addr = server.address(); + const port = typeof addr === "object" ? addr?.port : 0; + resolve({ server, port: port! }); + }); + }); +} + +function execDocker( + args: string[] +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const proc = spawn("docker", args, { stdio: "pipe" }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + proc.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 })); + }); +} + +async function dockerRun( + name: string, + args: string[], + script: string +): Promise { + await execDocker([ + "run", + "--rm", + "-d", + "--name", + name, + ...args, + "node:alpine", + "node", + "-e", + script, + ]); +} + +async function dockerRm(name: string): Promise { + await execDocker(["rm", "-f", name]); +} + +const CONTAINER_SERVER_SCRIPT = ` +const http = require("http"); +const port = process.env.PORT || 3000; +http.createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ source: "container" })); +}).listen(port, "0.0.0.0", () => console.log("ready")); +`; + +async function testConnection(url: string, timeoutMs = 2000): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeout); + return response.ok; + } catch { + return false; + } +} + +async function waitForServer( + url: string, + maxAttempts = 10, + delayMs = 300 +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (await testConnection(url)) return true; + await new Promise((r) => setTimeout(r, delayMs)); + } + return false; +} + +async function testContainerToHost( + containerName: string, + hostPort: number, + isHostNetwork: boolean +): Promise<{ success: boolean; address: string | null }> { + // Try multiple host addresses + const hostAddresses = [ + ...(isHostNetwork ? ["127.0.0.1"] : []), // localhost works with host networking + "host.docker.internal", + "172.17.0.1", // Common Docker bridge gateway + ]; + + for (const addr of hostAddresses) { + try { + const script = ` + fetch("http://${addr}:${hostPort}", { signal: AbortSignal.timeout(2000) }) + .then(r => r.text()) + .then(console.log) + .catch(() => process.exit(1)) + `; + const { stdout, code } = await execDocker([ + "exec", + containerName, + "node", + "-e", + script, + ]); + if (code === 0 && stdout.includes('"source":"host"')) { + return { success: true, address: addr }; + } + } catch { + // Continue to next address + } + } + return { success: false, address: null }; +} + +export async function checkDockerNetworking(): Promise { + const results: NetworkingTestResult = { + hostNetwork: { + hostToContainer: false, + containerToHost: false, + hostAddress: null, + }, + portBind: { + hostToContainer: false, + containerToHost: false, + hostAddress: null, + }, + recommended: "none", + }; + + // Start host server + const { server: hostServer, port: hostPort } = await startHostServer(); + + // Get random ports for containers + const hostNetPort = await getRandomPort(); + const bridgePort = await getRandomPort(); + + const HOST_CONTAINER = "blink-net-test-host"; + const BRIDGE_CONTAINER = "blink-net-test-bridge"; + + try { + // Start containers in parallel + await Promise.all([ + dockerRun( + HOST_CONTAINER, + ["--network", "host"], + CONTAINER_SERVER_SCRIPT.replace("3000", String(hostNetPort)) + ), + dockerRun( + BRIDGE_CONTAINER, + ["-p", `${bridgePort}:3000`], + CONTAINER_SERVER_SCRIPT + ), + ]); + + // Wait for containers to be ready + const [hostNetReady, bridgeReady] = await Promise.all([ + waitForServer(`http://localhost:${hostNetPort}`), + waitForServer(`http://localhost:${bridgePort}`), + ]); + + // Test host β†’ container + if (hostNetReady) { + results.hostNetwork.hostToContainer = await testConnection( + `http://localhost:${hostNetPort}` + ); + } + if (bridgeReady) { + results.portBind.hostToContainer = await testConnection( + `http://localhost:${bridgePort}` + ); + } + + // Test container β†’ host + const hostNetResult = await testContainerToHost( + HOST_CONTAINER, + hostPort, + true + ); + results.hostNetwork.containerToHost = hostNetResult.success; + results.hostNetwork.hostAddress = hostNetResult.address; + + const bridgeResult = await testContainerToHost( + BRIDGE_CONTAINER, + hostPort, + false + ); + results.portBind.containerToHost = bridgeResult.success; + results.portBind.hostAddress = bridgeResult.address; + + // Determine recommendation + const hostWorks = + results.hostNetwork.hostToContainer && + results.hostNetwork.containerToHost; + const bridgeWorks = + results.portBind.hostToContainer && results.portBind.containerToHost; + + if (hostWorks && bridgeWorks) results.recommended = "both"; + else if (hostWorks) results.recommended = "host"; + else if (bridgeWorks) results.recommended = "port-bind"; + else results.recommended = "none"; + } finally { + // Cleanup + hostServer.close(); + await Promise.all([dockerRm(HOST_CONTAINER), dockerRm(BRIDGE_CONTAINER)]); + } + + return results; +} From 43e7373754551a3006033ac1de2a3adf9f423249 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 9 Dec 2025 16:40:14 +0100 Subject: [PATCH 4/8] onboarding flow --- packages/api/src/client.browser.ts | 3 + .../routes/onboarding/onboarding.client.ts | 119 +++++++++ .../routes/onboarding/onboarding.server.ts | 244 ++++++++++++++++++ packages/api/src/server.ts | 3 + packages/server/src/server.ts | 2 + .../components/progress-indicator.tsx | 66 +++++ .../(app)/[organization]/onboarding/page.tsx | 48 ++++ .../onboarding/steps/api-keys.tsx | 196 ++++++++++++++ .../onboarding/steps/deploying.tsx | 180 +++++++++++++ .../onboarding/steps/github-setup.tsx | 179 +++++++++++++ .../onboarding/steps/slack-setup.tsx | 163 ++++++++++++ .../onboarding/steps/success.tsx | 53 ++++ .../onboarding/steps/welcome.tsx | 116 +++++++++ .../[organization]/onboarding/wizard.tsx | 188 ++++++++++++++ .../site/app/(app)/[organization]/page.tsx | 5 + 15 files changed, 1565 insertions(+) create mode 100644 packages/api/src/routes/onboarding/onboarding.client.ts create mode 100644 packages/api/src/routes/onboarding/onboarding.server.ts create mode 100644 packages/site/app/(app)/[organization]/onboarding/components/progress-indicator.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/page.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/steps/api-keys.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/steps/deploying.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/steps/github-setup.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/steps/slack-setup.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/steps/success.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/steps/welcome.tsx create mode 100644 packages/site/app/(app)/[organization]/onboarding/wizard.tsx diff --git a/packages/api/src/client.browser.ts b/packages/api/src/client.browser.ts index 103e44a..769408c 100644 --- a/packages/api/src/client.browser.ts +++ b/packages/api/src/client.browser.ts @@ -5,6 +5,7 @@ import ChatRuns from "./routes/chats/runs.client"; import Files from "./routes/files.client"; import Invites from "./routes/invites.client"; import Messages from "./routes/messages.client"; +import Onboarding from "./routes/onboarding/onboarding.client"; import Organizations from "./routes/organizations/organizations.client"; import Users from "./routes/users.client"; @@ -34,6 +35,7 @@ export default class Client { public readonly invites = new Invites(this); public readonly users = new Users(this); public readonly messages = new Messages(this); + public readonly onboarding = new Onboarding(this); public constructor(options?: ClientOptions) { this.baseURL = new URL( @@ -101,5 +103,6 @@ export * from "./routes/agents/traces.client"; export * from "./routes/chats/chats.client"; export * from "./routes/invites.client"; export * from "./routes/messages.client"; +export * from "./routes/onboarding/onboarding.client"; export * from "./routes/organizations/organizations.client"; export * from "./routes/users.client"; diff --git a/packages/api/src/routes/onboarding/onboarding.client.ts b/packages/api/src/routes/onboarding/onboarding.client.ts new file mode 100644 index 0000000..3bb7c82 --- /dev/null +++ b/packages/api/src/routes/onboarding/onboarding.client.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import { assertResponseStatus } from "../../client-helper"; +import type Client from "../../client.browser"; + +export const schemaDownloadAgentRequest = z.object({ + organization_id: z.string().uuid(), +}); + +export type DownloadAgentRequest = z.infer; + +export const schemaDownloadAgentResponse = z.object({ + file_id: z.string().uuid(), + entrypoint: z.string(), + version: z.string().optional(), +}); + +export type DownloadAgentResponse = z.infer; + +export const schemaDeployAgentRequest = z.object({ + organization_id: z.string().uuid(), + name: z.string().min(1).max(40), + file_id: z.string().uuid(), + env: z.array( + z.object({ + key: z.string(), + value: z.string(), + secret: z.boolean(), + }) + ), +}); + +export type DeployAgentRequest = z.infer; + +export const schemaDeployAgentResponse = z.object({ + id: z.string().uuid(), + name: z.string(), +}); + +export type DeployAgentResponse = z.infer; + +export const schemaValidateCredentialsRequest = z.object({ + type: z.enum(["github", "slack"]), + credentials: z.record(z.string(), z.string()), +}); + +export type ValidateCredentialsRequest = z.infer< + typeof schemaValidateCredentialsRequest +>; + +export const schemaValidateCredentialsResponse = z.object({ + valid: z.boolean(), + error: z.string().optional(), +}); + +export type ValidateCredentialsResponse = z.infer< + typeof schemaValidateCredentialsResponse +>; + +export default class Onboarding { + private readonly client: Client; + + public constructor(client: Client) { + this.client = client; + } + + /** + * Download the pre-built onboarding agent from GitHub Releases. + * + * @param request - The request body containing organization_id. + * @returns The file ID and entrypoint of the downloaded agent. + */ + public async downloadAgent( + request: DownloadAgentRequest + ): Promise { + const resp = await this.client.request( + "POST", + "/api/onboarding/download-agent", + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Deploy the onboarding agent with the provided configuration. + * + * @param request - The deployment configuration. + * @returns The created agent's ID and name. + */ + public async deployAgent( + request: DeployAgentRequest + ): Promise { + const resp = await this.client.request( + "POST", + "/api/onboarding/deploy-agent", + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Validate integration credentials before deployment. + * + * @param request - The credentials to validate. + * @returns Whether the credentials are valid and any error message. + */ + public async validateCredentials( + request: ValidateCredentialsRequest + ): Promise { + const resp = await this.client.request( + "POST", + "/api/onboarding/validate-credentials", + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } +} diff --git a/packages/api/src/routes/onboarding/onboarding.server.ts b/packages/api/src/routes/onboarding/onboarding.server.ts new file mode 100644 index 0000000..200a0dd --- /dev/null +++ b/packages/api/src/routes/onboarding/onboarding.server.ts @@ -0,0 +1,244 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { validator } from "hono/validator"; +import { authorizeOrganization, withAuth } from "../../middleware"; +import type { Bindings } from "../../server"; +import { createAgentDeployment } from "../agents/deployments.server"; +import { + schemaDeployAgentRequest, + schemaDownloadAgentRequest, + schemaValidateCredentialsRequest, +} from "./onboarding.client"; + +export default function mountOnboarding(app: Hono<{ Bindings: Bindings }>) { + // Download the onboarding agent artifact from GitHub Releases + app.post( + "/download-agent", + withAuth, + validator("json", (value) => { + return schemaDownloadAgentRequest.parse(value); + }), + async (c) => { + const req = c.req.valid("json"); + await authorizeOrganization(c, req.organization_id); + + const releaseUrl = c.env.ONBOARDING_AGENT_RELEASE_URL; + if (!releaseUrl) { + throw new HTTPException(500, { + message: "Onboarding agent release URL not configured", + }); + } + + // Fetch release info from GitHub API + const releaseResp = await fetch(releaseUrl, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Blink-Server", + }, + }); + if (!releaseResp.ok) { + throw new HTTPException(502, { + message: `Failed to fetch release info: ${releaseResp.status}`, + }); + } + + const release = (await releaseResp.json()) as { + tag_name?: string; + assets?: Array<{ + name: string; + browser_download_url: string; + }>; + }; + + const agentAsset = release.assets?.find((a) => a.name === "agent.js"); + if (!agentAsset) { + throw new HTTPException(404, { + message: "Agent artifact not found in release", + }); + } + + // Download the artifact + const artifactResp = await fetch(agentAsset.browser_download_url, { + headers: { + "User-Agent": "Blink-Server", + }, + }); + if (!artifactResp.ok) { + throw new HTTPException(502, { + message: `Failed to download artifact: ${artifactResp.status}`, + }); + } + + const artifactData = await artifactResp.text(); + + // Upload to file storage + const { id } = await c.env.files.upload({ + user_id: c.get("user_id"), + organization_id: req.organization_id, + file: new File([artifactData], "agent.js", { + type: "application/javascript", + }), + }); + + return c.json({ + file_id: id, + entrypoint: "agent.js", + version: release.tag_name, + }); + } + ); + + // Deploy the onboarding agent with provided configuration + app.post( + "/deploy-agent", + withAuth, + validator("json", (value) => { + return schemaDeployAgentRequest.parse(value); + }), + async (c) => { + const req = c.req.valid("json"); + const org = await authorizeOrganization(c, req.organization_id); + const db = await c.env.database(); + + const agent = await db.insertAgent({ + organization_id: org.id, + created_by: c.get("user_id"), + name: req.name, + description: + "AI agent with GitHub, Slack, web search, and compute capabilities", + visibility: "organization", + }); + + // Grant admin permission to creator + await db.upsertAgentPermission({ + agent_id: agent.id, + user_id: agent.created_by, + permission: "admin", + created_by: agent.created_by, + }); + + // Insert environment variables + for (const env of req.env) { + await db.insertAgentEnvironmentVariable({ + agent_id: agent.id, + key: env.key, + value: env.value, + secret: env.secret, + target: ["preview", "production"], + created_by: c.get("user_id"), + updated_by: c.get("user_id"), + }); + } + + // Create deployment with the downloaded file + await createAgentDeployment({ + req: c.req.raw, + db: db, + bindings: c.env, + outputFiles: [{ path: "agent.js", id: req.file_id }], + entrypoint: "agent.js", + agentID: agent.id, + userID: c.get("user_id"), + organizationID: org.id, + target: "production", + }); + + return c.json({ id: agent.id, name: agent.name }); + } + ); + + // Validate integration credentials + app.post( + "/validate-credentials", + withAuth, + validator("json", (value) => { + return schemaValidateCredentialsRequest.parse(value); + }), + async (c) => { + const req = c.req.valid("json"); + + if (req.type === "github") { + try { + const appId = req.credentials.appId as string | undefined; + const privateKey = req.credentials.privateKey as string | undefined; + if (!appId || !privateKey) { + return c.json({ + valid: false, + error: "App ID and Private Key are required", + }); + } + + // Validate the private key format + if ( + !privateKey.includes("-----BEGIN") || + !privateKey.includes("PRIVATE KEY-----") + ) { + return c.json({ + valid: false, + error: + "Private key must be in PEM format (-----BEGIN ... PRIVATE KEY-----)", + }); + } + + // Validate app ID is numeric + if (!/^\d+$/.test(appId)) { + return c.json({ + valid: false, + error: "App ID must be numeric", + }); + } + + // Basic validation passed - full validation happens at runtime + return c.json({ valid: true }); + } catch (error) { + return c.json({ + valid: false, + error: + error instanceof Error + ? error.message + : "Invalid GitHub credentials", + }); + } + } + + if (req.type === "slack") { + try { + const botToken = req.credentials.botToken as string | undefined; + if (!botToken) { + return c.json({ + valid: false, + error: "Bot Token is required", + }); + } + + // Verify Slack bot token + const resp = await fetch("https://slack.com/api/auth.test", { + method: "POST", + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const data = (await resp.json()) as { ok: boolean; error?: string }; + if (!data.ok) { + return c.json({ + valid: false, + error: data.error || "Invalid Slack token", + }); + } + return c.json({ valid: true }); + } catch (error) { + return c.json({ + valid: false, + error: + error instanceof Error + ? error.message + : "Failed to validate Slack token", + }); + } + } + + return c.json({ valid: false, error: "Unknown credential type" }); + } + ); +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 6d2e9eb..60415d2 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -18,6 +18,7 @@ import mountDevhook from "./routes/devhook.server"; import mountFiles from "./routes/files.server"; import mountInvites from "./routes/invites.server"; import mountMessages from "./routes/messages.server"; +import mountOnboarding from "./routes/onboarding/onboarding.server"; import mountOrganizations from "./routes/organizations/organizations.server"; import type { OtelSpan } from "./routes/otlp/convert"; import mountOtlp from "./routes/otlp/otlp.server"; @@ -220,6 +221,7 @@ export interface Bindings { readonly NODE_ENV: string; readonly AI_GATEWAY_API_KEY?: string; readonly TOOLS_EXA_API_KEY?: string; + readonly ONBOARDING_AGENT_RELEASE_URL?: string; // OAuth provider credentials readonly GITHUB_CLIENT_ID?: string; @@ -311,6 +313,7 @@ mountMessages(api.basePath("/messages")); mountTools(api.basePath("/tools")); mountOtlp(api.basePath("/otlp")); mountDevhook(api.basePath("/devhook")); +mountOnboarding(api.basePath("/onboarding")); // Webhook route for proxying requests to agents // The wildcard route handles subpaths like /api/webhook/:id/github/events diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 20676f7..14d98ad 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -112,6 +112,8 @@ export async function startServer(options: ServerOptions) { { AUTH_SECRET: authSecret, NODE_ENV: "development", + ONBOARDING_AGENT_RELEASE_URL: + "https://api.github.com/repos/hugodutka/blink-artifacts/releases/latest", agentStore: (deploymentTargetID) => { return { delete: async (key) => { diff --git a/packages/site/app/(app)/[organization]/onboarding/components/progress-indicator.tsx b/packages/site/app/(app)/[organization]/onboarding/components/progress-indicator.tsx new file mode 100644 index 0000000..ed50927 --- /dev/null +++ b/packages/site/app/(app)/[organization]/onboarding/components/progress-indicator.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; + +const stepLabels: Record = { + welcome: "Welcome", + "github-setup": "GitHub", + "slack-setup": "Slack", + "api-keys": "API Keys", + deploying: "Deploy", +}; + +interface ProgressIndicatorProps { + steps: string[]; + currentStep: string; +} + +export function ProgressIndicator({ + steps, + currentStep, +}: ProgressIndicatorProps) { + const currentIndex = steps.indexOf(currentStep); + + return ( +
+ {steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + + return ( +
+
+
+ {isComplete ? : index + 1} +
+ + {stepLabels[step] || step} + +
+ {index < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/packages/site/app/(app)/[organization]/onboarding/page.tsx b/packages/site/app/(app)/[organization]/onboarding/page.tsx new file mode 100644 index 0000000..704bec6 --- /dev/null +++ b/packages/site/app/(app)/[organization]/onboarding/page.tsx @@ -0,0 +1,48 @@ +import { auth } from "@/app/(auth)/auth"; +import Header from "@/components/header"; +import { getQuerier } from "@/lib/database"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getOrganization, getUser } from "../layout"; +import { OnboardingWizard } from "./wizard"; + +export const metadata: Metadata = { + title: "Get Started - Blink", +}; + +export default async function OnboardingPage({ + params, +}: { + params: Promise<{ organization: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) { + return redirect("/login"); + } + + const { organization: organizationName } = await params; + const db = await getQuerier(); + const organization = await getOrganization(session.user.id, organizationName); + const user = await getUser(session.user.id); + + // Check if org already has agents - redirect to dashboard if so + const agents = await db.selectAgentsForUser({ + userID: session.user.id, + organizationID: organization.id, + per_page: 1, + }); + + if (agents.items.length > 0) { + return redirect(`/${organizationName}`); + } + + return ( +
+
+ +
+ ); +} diff --git a/packages/site/app/(app)/[organization]/onboarding/steps/api-keys.tsx b/packages/site/app/(app)/[organization]/onboarding/steps/api-keys.tsx new file mode 100644 index 0000000..bd1dc81 --- /dev/null +++ b/packages/site/app/(app)/[organization]/onboarding/steps/api-keys.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { ArrowLeft, Key } from "lucide-react"; +import { useState } from "react"; + +type AIProvider = "anthropic" | "openai" | "vercel"; + +interface ApiKeysStepProps { + initialValues?: { + aiProvider?: AIProvider; + aiApiKey?: string; + exaApiKey?: string; + }; + onContinue: (values: { + aiProvider?: AIProvider; + aiApiKey?: string; + exaApiKey?: string; + }) => void; + onSkip: () => void; + onBack: () => void; +} + +const providers: { + id: AIProvider; + name: string; + description: string; + placeholder: string; + helpUrl: string; + helpText: string; +}[] = [ + { + id: "anthropic", + name: "Anthropic", + description: "Claude models", + placeholder: "sk-ant-...", + helpUrl: "https://console.anthropic.com/settings/keys", + helpText: "Get an Anthropic API key", + }, + { + id: "openai", + name: "OpenAI", + description: "GPT models", + placeholder: "sk-...", + helpUrl: "https://platform.openai.com/api-keys", + helpText: "Get an OpenAI API key", + }, + { + id: "vercel", + name: "Vercel AI Gateway", + description: "Unified gateway for multiple providers", + placeholder: "your-gateway-url", + helpUrl: "https://vercel.com/docs/ai-gateway", + helpText: "Learn about Vercel AI Gateway", + }, +]; + +export function ApiKeysStep({ + initialValues, + onContinue, + onSkip, + onBack, +}: ApiKeysStepProps) { + const [aiProvider, setAIProvider] = useState( + initialValues?.aiProvider + ); + const [aiApiKey, setAIApiKey] = useState(initialValues?.aiApiKey || ""); + const [exaApiKey, setExaApiKey] = useState(initialValues?.exaApiKey || ""); + + const selectedProvider = providers.find((p) => p.id === aiProvider); + + const handleContinue = () => { + onContinue({ + aiProvider: aiProvider, + aiApiKey: aiApiKey || undefined, + exaApiKey: exaApiKey || undefined, + }); + }; + + return ( + + +
+ + API Keys +
+ + Configure API keys for AI capabilities. You can add or change these + later in the agent settings. + +
+ +
+ +
+ {providers.map((provider) => ( + + ))} +
+
+ + {selectedProvider && ( +
+ + setAIApiKey(e.target.value)} + /> +

+ + {selectedProvider.helpText} + +

+
+ )} + +
+ + setExaApiKey(e.target.value)} + /> +

+ Enables web search capabilities.{" "} + + Get an API key + +

+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/packages/site/app/(app)/[organization]/onboarding/steps/deploying.tsx b/packages/site/app/(app)/[organization]/onboarding/steps/deploying.tsx new file mode 100644 index 0000000..6c23953 --- /dev/null +++ b/packages/site/app/(app)/[organization]/onboarding/steps/deploying.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import type Client from "@blink.so/api"; +import { Loader2, Rocket, AlertCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface DeployingStepProps { + client: Client; + organizationId: string; + fileId: string; + agentName: string; + github?: { + appId: string; + privateKey: string; + webhookSecret: string; + }; + slack?: { + botToken: string; + signingSecret: string; + }; + apiKeys?: { + aiProvider?: "anthropic" | "openai" | "vercel"; + aiApiKey?: string; + exaApiKey?: string; + }; + onSuccess: (agentId: string) => void; + onError: () => void; +} + +export function DeployingStep({ + client, + organizationId, + fileId, + agentName, + github, + slack, + apiKeys, + onSuccess, + onError, +}: DeployingStepProps) { + const [status, setStatus] = useState<"deploying" | "error">("deploying"); + const [errorMessage, setErrorMessage] = useState(null); + const [hasStarted, setHasStarted] = useState(false); + + useEffect(() => { + if (hasStarted) return; + setHasStarted(true); + + const deploy = async () => { + try { + // Build environment variables + const env: Array<{ key: string; value: string; secret: boolean }> = []; + + if (github?.appId) { + env.push({ key: "GITHUB_APP_ID", value: github.appId, secret: false }); + } + if (github?.privateKey) { + env.push({ + key: "GITHUB_APP_PRIVATE_KEY", + value: Buffer.from(github.privateKey).toString("base64"), + secret: true, + }); + } + if (github?.webhookSecret) { + env.push({ + key: "GITHUB_WEBHOOK_SECRET", + value: github.webhookSecret, + secret: true, + }); + } + if (slack?.botToken) { + env.push({ + key: "SLACK_BOT_TOKEN", + value: slack.botToken, + secret: true, + }); + } + if (slack?.signingSecret) { + env.push({ + key: "SLACK_SIGNING_SECRET", + value: slack.signingSecret, + secret: true, + }); + } + if (apiKeys?.exaApiKey) { + env.push({ + key: "EXA_API_KEY", + value: apiKeys.exaApiKey, + secret: true, + }); + } + // Set the appropriate API key based on the selected provider + if (apiKeys?.aiApiKey && apiKeys?.aiProvider) { + const envKeyMap: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + vercel: "AI_GATEWAY_API_KEY", + }; + env.push({ + key: envKeyMap[apiKeys.aiProvider], + value: apiKeys.aiApiKey, + secret: true, + }); + } + + const result = await client.onboarding.deployAgent({ + organization_id: organizationId, + name: agentName, + file_id: fileId, + env, + }); + + onSuccess(result.id); + } catch (error) { + setStatus("error"); + const message = + error instanceof Error ? error.message : "Deployment failed"; + setErrorMessage(message); + toast.error(message); + } + }; + + deploy(); + }, [ + hasStarted, + client, + organizationId, + fileId, + agentName, + github, + slack, + apiKeys, + onSuccess, + ]); + + if (status === "error") { + return ( + + +
+ +
+ Deployment Failed + + {errorMessage || "Something went wrong during deployment."} + +
+ + + +
+ ); + } + + return ( + + +
+ +
+ Deploying Your Agent + + This may take a moment. Please don't close this page. + +
+ + + +
+ ); +} diff --git a/packages/site/app/(app)/[organization]/onboarding/steps/github-setup.tsx b/packages/site/app/(app)/[organization]/onboarding/steps/github-setup.tsx new file mode 100644 index 0000000..651b332 --- /dev/null +++ b/packages/site/app/(app)/[organization]/onboarding/steps/github-setup.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type Client from "@blink.so/api"; +import { ArrowLeft, Check, Github, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; + +interface GitHubSetupStepProps { + client: Client; + initialValues?: { + appId: string; + privateKey: string; + webhookSecret: string; + }; + onContinue: (values: { + appId: string; + privateKey: string; + webhookSecret: string; + }) => void; + onSkip: () => void; + onBack: () => void; +} + +export function GitHubSetupStep({ + client, + initialValues, + onContinue, + onSkip, + onBack, +}: GitHubSetupStepProps) { + const [appId, setAppId] = useState(initialValues?.appId || ""); + const [privateKey, setPrivateKey] = useState(initialValues?.privateKey || ""); + const [webhookSecret, setWebhookSecret] = useState( + initialValues?.webhookSecret || "" + ); + const [validating, setValidating] = useState(false); + const [validated, setValidated] = useState(false); + + const handleValidate = async () => { + if (!appId || !privateKey) { + toast.error("App ID and Private Key are required"); + return; + } + + setValidating(true); + try { + const result = await client.onboarding.validateCredentials({ + type: "github", + credentials: { appId, privateKey }, + }); + + if (result.valid) { + setValidated(true); + toast.success("GitHub credentials validated"); + } else { + toast.error(result.error || "Invalid credentials"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Validation failed" + ); + } finally { + setValidating(false); + } + }; + + const handleContinue = () => { + if (!validated && (appId || privateKey)) { + toast.error("Please validate your credentials first"); + return; + } + onContinue({ appId, privateKey, webhookSecret }); + }; + + return ( + + +
+ + GitHub App Setup +
+ + Connect a GitHub App to enable PR reviews, issue responses, and + webhooks.{" "} + + Learn how to create a GitHub App + + +
+ +
+ + { + setAppId(e.target.value); + setValidated(false); + }} + /> +
+ +
+ +