From 2068a72bfd0802f29bcf6f6dc128076a421e0837 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Tue, 9 Dec 2025 12:50:43 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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); + }} + /> +
+ +
+ +