├── .cursorrules ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── apps ├── forge │ ├── .env.example │ ├── .gitignore │ ├── db.ts │ ├── docker-runner.ts │ ├── index.ts │ ├── package.json │ ├── port-manager.ts │ ├── project-detector.ts │ ├── proxy-server.ts │ └── utils.ts ├── herald │ ├── .gitignore │ ├── config.ts │ ├── db.ts │ ├── index.ts │ ├── oauth-handlers.ts │ ├── package.json │ ├── queue.ts │ ├── server.ts │ ├── tsconfig.json │ └── webhook-handlers.ts └── nexus │ ├── .gitignore │ ├── README.md │ ├── app │ ├── api │ │ ├── auth │ │ │ └── [...all] │ │ │ │ └── route.ts │ │ ├── github │ │ │ ├── auth │ │ │ │ └── route.ts │ │ │ ├── callback │ │ │ │ └── route.ts │ │ │ └── repositories │ │ │ │ └── route.ts │ │ └── projects │ │ │ ├── import │ │ │ └── route.ts │ │ │ └── route.ts │ ├── auth-client.ts │ ├── auth.ts │ ├── components │ │ ├── landing.tsx │ │ ├── nav.tsx │ │ └── ui │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input-group.tsx │ │ │ ├── input.tsx │ │ │ └── textarea.tsx │ ├── dashboard │ │ └── page.tsx │ ├── favicon.ico │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ ├── new │ │ ├── import-repositories.tsx │ │ └── page.tsx │ └── page.tsx │ ├── db │ ├── auth-schema.ts │ ├── index.ts │ ├── project-schema.ts │ └── schema.ts │ ├── drizzle.config.ts │ ├── drizzle │ ├── 0000_daily_hydra.sql │ ├── 0001_shocking_queen_noir.sql │ ├── 0002_calm_sumo.sql │ └── meta │ │ ├── 0000_snapshot.json │ │ ├── 0001_snapshot.json │ │ ├── 0002_snapshot.json │ │ └── _journal.json │ ├── eslint.config.js │ ├── lib │ └── queue.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ ├── file-text.svg │ ├── globe.svg │ ├── next.svg │ ├── turborepo-dark.svg │ ├── turborepo-light.svg │ ├── vercel.svg │ └── window.svg │ └── tsconfig.json ├── package.json ├── packages ├── common │ ├── index.ts │ ├── package.json │ ├── tsconfig.json │ └── utils.ts ├── eslint-config │ ├── README.md │ ├── base.js │ ├── next.js │ ├── package.json │ └── react-internal.js ├── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── eslint.config.mjs │ ├── package.json │ ├── src │ ├── button.tsx │ ├── card.tsx │ └── code.tsx │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.cursorrules: -------------------------------------------------------------------------------- 1 | # GitHub Apps Development Rules 2 | 3 | ## Core Principles 4 | 5 | - Use `@octokit/oauth-app` for GitHub App OAuth flows 6 | - GitHub Apps do not support scopes (unlike OAuth Apps) 7 | - GitHub Apps use `clientType: "github-app"` with client IDs starting with `lv1.` 8 | - Prefer expiring user tokens for enhanced security 9 | 10 | ## Authentication & Token Management 11 | 12 | ### OAuth App Initialization 13 | 14 | ```typescript 15 | import { OAuthApp, createNodeMiddleware } from "@octokit/oauth-app"; 16 | 17 | const app = new OAuthApp({ 18 | clientType: "github-app", // Required for GitHub Apps 19 | clientId: "lv1.xxxx", // GitHub App client ID 20 | clientSecret: "xxxx", // GitHub App client secret 21 | }); 22 | ``` 23 | 24 | ### Token Events 25 | 26 | Always handle token lifecycle events: 27 | 28 | ```typescript 29 | app.on("token", async ({ token, octokit, expiresAt }) => { 30 | // Handle token creation/refresh 31 | // Automatically called for token.created, token.refreshed, token.scoped 32 | }); 33 | 34 | app.on("token.deleted", async ({ token }) => { 35 | // Clean up when tokens are deleted 36 | }); 37 | 38 | app.on("authorization.deleted", async ({ token }) => { 39 | // Handle when user revokes app authorization 40 | }); 41 | ``` 42 | 43 | **Available Events:** 44 | 45 | - `token.created` - New OAuth token created 46 | - `token.reset` - Token was reset 47 | - `token.refreshed` - Token auto-refreshed (GitHub Apps only) 48 | - `token.scoped` - Scoped token created (GitHub Apps only) 49 | - `token.deleted` - Token invalidated 50 | - `authorization.deleted` - Authorization revoked 51 | 52 | ### Token Auto-Refresh 53 | 54 | If expiring user tokens are enabled for your GitHub App, tokens will automatically refresh: 55 | 56 | ```typescript 57 | const octokit = await app.getUserOctokit({ code: "code123" }); 58 | // octokit will automatically refresh expired tokens 59 | ``` 60 | 61 | ## API Endpoints 62 | 63 | Default middleware exposes these routes (prefix: `/api/github/oauth`): 64 | 65 | | Route | Method | Description | 66 | | ---------------- | ------ | ------------------------------------------------------------------ | 67 | | `/login` | GET | Redirects to GitHub authorization (accepts `?state` and `?scopes`) | 68 | | `/callback` | GET | **OAuth callback endpoint** (GitHub redirects here after auth) | 69 | | `/token` | POST | Exchange authorization code for token | 70 | | `/token` | GET | Check token validity | 71 | | `/token` | PATCH | Reset token (invalidate + return new) | 72 | | `/refresh-token` | PATCH | Refresh expiring token | 73 | | `/token/scoped` | POST | Create scoped token (GitHub Apps only) | 74 | | `/token` | DELETE | Logout (invalidate token) | 75 | | `/grant` | DELETE | Revoke authorization (uninstall) | 76 | 77 | ## Callback URL Configuration 78 | 79 | **For local development:** 80 | 81 | ``` 82 | http://localhost:{PORT}/api/github/oauth/callback 83 | ``` 84 | 85 | **For production:** 86 | 87 | ``` 88 | https://your-domain.com/api/github/oauth/callback 89 | ``` 90 | 91 | Configure this in your GitHub App settings under "OAuth callback URL". 92 | 93 | ## Webhook Configuration 94 | 95 | ### Webhook URL Setup 96 | 97 | **For local development (using tunnel like ngrok, localtunnel, or smee.io):** 98 | 99 | ``` 100 | https://your-tunnel-url.ngrok.io/api/github/webhook 101 | ``` 102 | 103 | **For production:** 104 | 105 | ``` 106 | https://your-domain.com/api/github/webhook 107 | ``` 108 | 109 | Configure this in your GitHub App settings under "Webhook URL". 110 | 111 | ### Webhook Setup with @octokit/webhooks 112 | 113 | ```typescript 114 | import { 115 | Webhooks, 116 | createNodeMiddleware as createWebhookMiddleware, 117 | } from "@octokit/webhooks"; 118 | 119 | const webhooks = new Webhooks({ 120 | secret: process.env.GITHUB_APP_WEBHOOK_SECRET, // Set in GitHub App settings 121 | }); 122 | 123 | // Handle specific events 124 | webhooks.on("issues.opened", async ({ payload }) => { 125 | console.log(`Issue opened: ${payload.issue.title}`); 126 | }); 127 | 128 | webhooks.on("pull_request.opened", async ({ payload }) => { 129 | console.log(`PR opened: ${payload.pull_request.title}`); 130 | }); 131 | 132 | webhooks.on("push", async ({ payload }) => { 133 | console.log(`Push to ${payload.repository.full_name}`); 134 | }); 135 | 136 | // Handle all events 137 | webhooks.onAny(async ({ name, payload }) => { 138 | console.log(`Received webhook event: ${name}`); 139 | }); 140 | 141 | // Mount webhook middleware 142 | app.use("/api/github/webhook", createWebhookMiddleware(webhooks)); 143 | ``` 144 | 145 | ### Webhook Secret 146 | 147 | Generate a secure webhook secret and configure it in: 148 | 149 | 1. GitHub App settings → Webhook secret field 150 | 2. Your application environment variables 151 | 152 | ```bash 153 | # Generate a secure secret (example) 154 | openssl rand -hex 20 155 | ``` 156 | 157 | ### Local Development with Webhooks 158 | 159 | Since GitHub can't reach localhost directly, use a tunnel service: 160 | 161 | **Using smee.io (recommended for testing):** 162 | 163 | ```bash 164 | npm install --global smee-client 165 | smee -u https://smee.io/your-channel -t http://localhost:4000/api/github/webhook 166 | ``` 167 | 168 | **Using ngrok:** 169 | 170 | ```bash 171 | ngrok http 4000 172 | # Use the generated HTTPS URL in GitHub App settings 173 | ``` 174 | 175 | ### Available Webhook Events 176 | 177 | Common events to handle: 178 | 179 | - `issues` - Issue events (opened, edited, closed, etc.) 180 | - `pull_request` - PR events (opened, closed, merged, etc.) 181 | - `push` - Code pushed to repository 182 | - `pull_request_review` - PR review submitted 183 | - `pull_request_review_comment` - Comment on PR review 184 | - `issue_comment` - Comment on issue or PR 185 | - `commit_comment` - Comment on commit 186 | - `create` - Branch or tag created 187 | - `delete` - Branch or tag deleted 188 | - `release` - Release published 189 | - `repository` - Repository created, deleted, etc. 190 | - `installation` - App installed/uninstalled 191 | - `installation_repositories` - Repositories added/removed from installation 192 | 193 | Full list: https://docs.github.com/en/webhooks/webhook-events-and-payloads 194 | 195 | ### Custom Path Prefix 196 | 197 | ```typescript 198 | const middleware = createNodeMiddleware(app, { 199 | pathPrefix: "/custom/path", // Callback becomes /custom/path/callback 200 | }); 201 | ``` 202 | 203 | ## Middleware Options 204 | 205 | ### Node.js (Express/Native HTTP) 206 | 207 | ```typescript 208 | import { createNodeMiddleware } from "@octokit/oauth-app"; 209 | import { createServer } from "node:http"; 210 | 211 | const middleware = createNodeMiddleware(app, { 212 | pathPrefix: "/api/github/oauth", // Optional, defaults to this 213 | }); 214 | 215 | createServer(middleware).listen(3000); 216 | ``` 217 | 218 | ### Cloudflare Workers / Deno 219 | 220 | ```typescript 221 | import { createWebWorkerHandler } from "@octokit/oauth-app"; 222 | 223 | const handleRequest = createWebWorkerHandler(app); 224 | ``` 225 | 226 | ### AWS Lambda (API Gateway V2) 227 | 228 | ```typescript 229 | import { createAWSLambdaAPIGatewayV2Handler } from "@octokit/oauth-app"; 230 | 231 | export const handler = createAWSLambdaAPIGatewayV2Handler(app); 232 | ``` 233 | 234 | ## Token Operations 235 | 236 | ### Get User Octokit Instance 237 | 238 | ```typescript 239 | // After OAuth callback with code 240 | const octokit = await app.getUserOctokit({ code: "code123" }); 241 | ``` 242 | 243 | ### Check Token Validity 244 | 245 | ```typescript 246 | const { 247 | created_at, 248 | app: appInfo, 249 | user, 250 | } = await app.checkToken({ 251 | token: "usertoken123", 252 | }); 253 | ``` 254 | 255 | ### Reset Token 256 | 257 | ```typescript 258 | const { data, authentication } = await app.resetToken({ 259 | token: "token123", 260 | }); 261 | // Old token is now invalid, use authentication.token 262 | ``` 263 | 264 | ### Refresh Token (GitHub Apps Only) 265 | 266 | ```typescript 267 | const { data, authentication } = await app.refreshToken({ 268 | refreshToken: "refreshtoken123", 269 | }); 270 | ``` 271 | 272 | ### Scope Token (GitHub Apps Only) 273 | 274 | Limit access to specific installation, repositories, and permissions: 275 | 276 | ```typescript 277 | const { data, authentication } = await app.scopeToken({ 278 | token: "usertoken123", 279 | target: "octokit", // Organization or user name 280 | repositories: ["oauth-app.js"], 281 | permissions: { 282 | issues: "write", 283 | contents: "read", 284 | }, 285 | }); 286 | ``` 287 | 288 | ### Delete Token (Logout) 289 | 290 | ```typescript 291 | await app.deleteToken({ token: "token123" }); 292 | ``` 293 | 294 | ### Delete Authorization (Uninstall) 295 | 296 | ```typescript 297 | await app.deleteAuthorization({ token: "token123" }); 298 | // All tokens are revoked, app must be re-authorized 299 | ``` 300 | 301 | ## Web Flow 302 | 303 | ### 1. Generate Authorization URL 304 | 305 | ```typescript 306 | const { url } = app.getWebFlowAuthorizationUrl({ 307 | state: "random-state-string", // CSRF protection 308 | login: "suggested-username", // Optional 309 | redirectUrl: "https://your-domain.com/custom-callback", // Optional 310 | allowSignup: true, // Optional 311 | }); 312 | 313 | // Redirect user to `url` 314 | ``` 315 | 316 | ### 2. Handle Callback 317 | 318 | The middleware automatically handles the callback at `/api/github/oauth/callback` and triggers the `token` event. 319 | 320 | ```typescript 321 | app.on("token", async ({ token, octokit, authentication }) => { 322 | // Save token to database 323 | // Authenticate user in your app 324 | const { data: user } = await octokit.request("GET /user"); 325 | }); 326 | ``` 327 | 328 | ## Device Flow 329 | 330 | For CLI apps or devices without browsers: 331 | 332 | ```typescript 333 | const { token } = await app.createToken({ 334 | async onVerification(verification) { 335 | console.log("Open:", verification.verification_uri); 336 | console.log("Enter code:", verification.user_code); 337 | // Wait for user to complete authorization 338 | }, 339 | }); 340 | ``` 341 | 342 | ## Custom Octokit Configuration 343 | 344 | ### For GitHub Enterprise 345 | 346 | ```typescript 347 | import { Octokit } from "@octokit/core"; 348 | 349 | const app = new OAuthApp({ 350 | clientId: "...", 351 | clientSecret: "...", 352 | Octokit: Octokit.defaults({ 353 | baseUrl: "https://github.enterprise.com/api/v3", 354 | }), 355 | }); 356 | ``` 357 | 358 | ### With Plugins 359 | 360 | ```typescript 361 | import { Octokit } from "@octokit/core"; 362 | import { retry } from "@octokit/plugin-retry"; 363 | import { throttling } from "@octokit/plugin-throttling"; 364 | 365 | const MyOctokit = Octokit.plugin(retry, throttling); 366 | 367 | const app = new OAuthApp({ 368 | clientId: "...", 369 | clientSecret: "...", 370 | Octokit: MyOctokit.defaults({ 371 | throttle: { 372 | /* options */ 373 | }, 374 | }), 375 | }); 376 | ``` 377 | 378 | ## Security Best Practices 379 | 380 | 1. **Use expiring user tokens** - Enable in GitHub App settings 381 | 2. **Validate state parameter** - Prevent CSRF attacks 382 | 3. **Store tokens securely** - Use encrypted database storage 383 | 4. **Implement token refresh** - Handle expiration gracefully 384 | 5. **Use scoped tokens** - Limit access to minimum required permissions 385 | 6. **Validate webhook signatures** - Verify GitHub webhook authenticity 386 | 7. **Use environment variables** - Never hardcode credentials 387 | 8. **Implement rate limiting** - Respect GitHub API limits 388 | 9. **Log token events** - Monitor for suspicious activity 389 | 10. **Revoke on logout** - Delete tokens when users log out 390 | 391 | ## Environment Variables 392 | 393 | ```bash 394 | # GitHub App OAuth 395 | GITHUB_APP_CLIENT_ID=lv1.xxxx 396 | GITHUB_APP_CLIENT_SECRET=xxxx 397 | GITHUB_APP_REDIRECT_URL=https://your-domain.com/api/github/oauth/callback 398 | 399 | # GitHub App Webhooks 400 | GITHUB_APP_WEBHOOK_SECRET=xxxx 401 | 402 | # Server 403 | NODE_ENV=production 404 | PORT=3000 405 | ``` 406 | 407 | ## Error Handling 408 | 409 | ```typescript 410 | try { 411 | const octokit = await app.getUserOctokit({ code: "code123" }); 412 | } catch (error) { 413 | if (error.status === 401) { 414 | // Invalid or expired token 415 | } else if (error.status === 404) { 416 | // Token not found 417 | } else { 418 | // Other errors 419 | console.error(error); 420 | } 421 | } 422 | ``` 423 | 424 | ## Testing 425 | 426 | Use mocks for testing OAuth flows: 427 | 428 | ```typescript 429 | const app = new OAuthApp({ 430 | clientId: "test-client-id", 431 | clientSecret: "test-client-secret", 432 | }); 433 | 434 | // Mock token event 435 | app.on("token", async ({ token, octokit }) => { 436 | // Test token handling 437 | }); 438 | ``` 439 | 440 | ## Common Patterns 441 | 442 | ### Store User Token 443 | 444 | ```typescript 445 | app.on("token.created", async ({ token, authentication, octokit }) => { 446 | const { data: user } = await octokit.request("GET /user"); 447 | 448 | await database.users.upsert({ 449 | githubId: user.id, 450 | username: user.login, 451 | accessToken: encrypt(authentication.token), 452 | refreshToken: encrypt(authentication.refreshToken), 453 | expiresAt: authentication.expiresAt, 454 | }); 455 | }); 456 | ``` 457 | 458 | ### Automatic Token Refresh 459 | 460 | ```typescript 461 | async function getOctokit(userId: string) { 462 | const user = await database.users.findById(userId); 463 | 464 | return await app.getUserOctokit({ 465 | token: decrypt(user.accessToken), 466 | refreshToken: decrypt(user.refreshToken), 467 | expiresAt: user.expiresAt, 468 | }); 469 | // Token automatically refreshes if expired 470 | } 471 | ``` 472 | 473 | ### Scoped Access for Organization 474 | 475 | ```typescript 476 | async function getScopedOctokit( 477 | token: string, 478 | org: string, 479 | repos: Array 480 | ) { 481 | const { authentication } = await app.scopeToken({ 482 | token, 483 | target: org, 484 | repositories: repos, 485 | permissions: { 486 | contents: "read", 487 | issues: "write", 488 | pull_requests: "write", 489 | }, 490 | }); 491 | 492 | return await app.getUserOctokit({ token: authentication.token }); 493 | } 494 | ``` 495 | 496 | ## Resources 497 | 498 | - [GitHub Apps Documentation](https://docs.github.com/en/apps) 499 | - [@octokit/oauth-app on GitHub](https://github.com/octokit/oauth-app.js) 500 | - [GitHub App Permissions](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps) 501 | - [Octokit Documentation](https://github.com/octokit/octokit.js) 502 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | 31 | # Debug 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | 36 | # Misc 37 | .DS_Store 38 | *.pem 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsargam/aether/0bb5ac703710375fbef7f14f22ebdaf953f883af/.npmrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aether 2 | 3 | A deployment platform for nextjs similar to vercel 4 | 5 | ## The Names 6 | 7 | **Aether** means pure air in Greek Mythology. There are three main services in Aether: 8 | 9 | - **Nexus** - A web app which is central point for all interactions 10 | - **Herald** - The messenger between github and internal queue system 11 | - **Forge** - Worker that takes builds the project and handle deployments 12 | 13 | ## Architecture 14 | 15 | ``` 16 | ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ 17 | │ Nexus │─────▶│ Herald │─────▶│ Forge │ 18 | │ (Next.js) │ │ (GitHub App) │ │ (Builder) │ 19 | └─────────────┘ └──────────────┘ └─────────────┘ 20 | │ │ │ 21 | ▼ ▼ ▼ 22 | PostgreSQL BullMQ/Redis Docker 23 | ``` 24 | 25 | ## Tech Stack 26 | 27 | - **Frontend:** Next.js 15, React 19, Tailwind CSS 28 | - **Backend:** Node.js, TypeScript 29 | - **Database:** PostgreSQL + Drizzle ORM 30 | - **Queue:** BullMQ + Redis 31 | - **Build:** Docker 32 | - **Auth:** Better Auth + GitHub OAuth 33 | - **Monorepo:** Turborepo + pnpm 34 | 35 | Requires: Node.js 18+, pnpm, Docker, PostgreSQL, Redis, GitHub App credentials 36 | -------------------------------------------------------------------------------- /apps/forge/.env.example: -------------------------------------------------------------------------------- 1 | # Redis Configuration 2 | REDIS_HOST=localhost 3 | REDIS_PORT=6379 4 | -------------------------------------------------------------------------------- /apps/forge/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | repos/ 4 | dist/ 5 | *.log 6 | .DS_Store 7 | 8 | -------------------------------------------------------------------------------- /apps/forge/db.ts: -------------------------------------------------------------------------------- 1 | import { sql } from "drizzle-orm"; 2 | import { drizzle } from "drizzle-orm/node-postgres"; 3 | import { Pool } from "pg"; 4 | 5 | const pool = new Pool({ 6 | connectionString: process.env.DATABASE_URL!, 7 | }); 8 | 9 | export const db = drizzle(pool); 10 | 11 | // Helper function to update project status 12 | export async function updateProjectStatus( 13 | projectId: string, 14 | status: "pending" | "building" | "deployed" | "failed" 15 | ) { 16 | await db.execute( 17 | sql`UPDATE project SET status = ${status}, updated_at = NOW() WHERE id = ${projectId}` 18 | ); 19 | } 20 | 21 | // Helper function to update deployment status 22 | export async function updateDeploymentStatus( 23 | deploymentId: string, 24 | status: "pending" | "building" | "success" | "failed", 25 | data?: { 26 | url?: string; 27 | error?: string; 28 | buildLogs?: string; 29 | } 30 | ) { 31 | if (data?.url) { 32 | await db.execute( 33 | sql`UPDATE deployment SET status = ${status}, url = ${data.url}, completed_at = NOW() WHERE id = ${deploymentId}` 34 | ); 35 | } else if (data?.error) { 36 | await db.execute( 37 | sql`UPDATE deployment SET status = ${status}, error = ${data.error}, build_logs = ${data.buildLogs || null}, completed_at = NOW() WHERE id = ${deploymentId}` 38 | ); 39 | } else { 40 | await db.execute( 41 | sql`UPDATE deployment SET status = ${status} WHERE id = ${deploymentId}` 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/forge/docker-runner.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import { promisify } from "node:util"; 3 | import { writeFile } from "node:fs/promises"; 4 | import { join } from "node:path"; 5 | 6 | const execAsync = promisify(exec); 7 | 8 | export type DockerRunResult = { 9 | success: boolean; 10 | containerId?: string; 11 | imageName?: string; 12 | buildLogs: string; 13 | port?: number; 14 | error?: string; 15 | }; 16 | 17 | function generateNextJsDockerfile(): string { 18 | return `FROM node:22-alpine 19 | 20 | WORKDIR /app 21 | 22 | # Install pnpm 23 | RUN npm install -g pnpm 24 | 25 | # Copy package files 26 | COPY package.json pnpm-lock.yaml ./ 27 | 28 | # Install dependencies 29 | RUN pnpm install --frozen-lockfile 30 | 31 | # Copy source code 32 | COPY . . 33 | 34 | # Build the application 35 | RUN pnpm run build 36 | 37 | EXPOSE 3000 38 | 39 | # Start the application 40 | CMD ["pnpm", "start"] 41 | `; 42 | } 43 | 44 | export async function buildAndRunInDocker( 45 | repoPath: string, 46 | hostPort: number, 47 | appId: string, 48 | timeout: number = 600000 49 | ): Promise { 50 | let imageName: string | undefined; 51 | let containerId: string | undefined; 52 | 53 | try { 54 | // Generate Dockerfile 55 | const dockerfile = generateNextJsDockerfile(); 56 | const dockerfilePath = join(repoPath, "Dockerfile.aether"); 57 | await writeFile(dockerfilePath, dockerfile); 58 | 59 | console.log(`Generated Dockerfile for Next.js project`); 60 | 61 | // Generate unique image and container names 62 | const uniqueId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 63 | imageName = `aether-app-${uniqueId}`; 64 | const containerName = `aether-container-${uniqueId}`; 65 | 66 | // Build Docker image 67 | console.log(`Building Docker image: ${imageName}...`); 68 | const buildCommand = `docker build -f ${dockerfilePath} -t ${imageName} ${repoPath}`; 69 | 70 | let buildLogs = ""; 71 | try { 72 | const { stdout, stderr } = await execAsync(buildCommand, { 73 | timeout, 74 | maxBuffer: 50 * 1024 * 1024, // 50MB buffer for build logs 75 | }); 76 | buildLogs = stdout + stderr; 77 | console.log(`Docker build completed successfully`); 78 | } catch (error: any) { 79 | console.error(`Failed to build Docker image:`, error.message); 80 | buildLogs = (error.stdout || "") + (error.stderr || ""); 81 | 82 | return { 83 | success: false, 84 | buildLogs, 85 | error: `Build failed: ${error.message}`, 86 | }; 87 | } 88 | 89 | // Run the container (no base path needed with subdomain approach) 90 | console.log(`Starting container on port ${hostPort}...`); 91 | const runCommand = `docker run -d --name ${containerName} -p ${hostPort}:3000 ${imageName}`; 92 | 93 | try { 94 | const { stdout } = await execAsync(runCommand); 95 | containerId = stdout.trim(); 96 | console.log( 97 | `Container started: ${containerId.substring(0, 12)} on port ${hostPort}` 98 | ); 99 | 100 | // Wait a bit for the app to start 101 | await new Promise((resolve) => setTimeout(resolve, 5000)); 102 | 103 | // Check if container is still running 104 | const { stdout: statusOutput } = await execAsync( 105 | `docker inspect -f '{{.State.Running}}' ${containerId}` 106 | ); 107 | const isRunning = statusOutput.trim() === "true"; 108 | 109 | if (!isRunning) { 110 | // Get logs to see what went wrong 111 | const { stdout: logs } = await execAsync( 112 | `docker logs ${containerId}` 113 | ).catch(() => ({ stdout: "Could not fetch logs" })); 114 | 115 | return { 116 | success: false, 117 | containerId, 118 | imageName, 119 | buildLogs: buildLogs + "\n\n=== Container Logs ===\n" + logs, 120 | error: "Container exited unexpectedly", 121 | }; 122 | } 123 | 124 | return { 125 | success: true, 126 | containerId, 127 | imageName, 128 | buildLogs, 129 | port: hostPort, 130 | }; 131 | } catch (error: any) { 132 | console.error(`Failed to start container:`, error.message); 133 | return { 134 | success: false, 135 | imageName, 136 | buildLogs, 137 | error: `Failed to start container: ${error.message}`, 138 | }; 139 | } 140 | } catch (error: any) { 141 | console.error(`Error in Docker build and run:`, error); 142 | return { 143 | success: false, 144 | buildLogs: "", 145 | error: error.message, 146 | }; 147 | } 148 | } 149 | 150 | export async function stopContainer(containerId: string): Promise { 151 | try { 152 | console.log(`Stopping container ${containerId.substring(0, 12)}...`); 153 | await execAsync(`docker stop ${containerId}`); 154 | await execAsync(`docker rm ${containerId}`); 155 | console.log(`Container stopped and removed`); 156 | } catch (error: any) { 157 | console.error(`Error stopping container:`, error.message); 158 | } 159 | } 160 | 161 | export async function removeImage(imageName: string): Promise { 162 | try { 163 | console.log(`Removing image ${imageName}...`); 164 | await execAsync(`docker rmi ${imageName}`); 165 | console.log(`Image removed`); 166 | } catch (error: any) { 167 | console.error(`Error removing image:`, error.message); 168 | } 169 | } 170 | 171 | export async function getContainerLogs(containerId: string): Promise { 172 | try { 173 | const { stdout, stderr } = await execAsync( 174 | `docker logs ${containerId} --tail 100` 175 | ); 176 | return stdout + stderr; 177 | } catch (error: any) { 178 | return (error.stdout || "") + (error.stderr || ""); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /apps/forge/index.ts: -------------------------------------------------------------------------------- 1 | import { JOB_NAMES, QUEUE_NAME } from "@aether/common"; 2 | import { Worker } from "bullmq"; 3 | import "dotenv/config"; 4 | import IORedis from "ioredis"; 5 | import { updateDeploymentStatus, updateProjectStatus } from "./db"; 6 | import { 7 | buildAndRunInDocker, 8 | removeImage, 9 | stopContainer, 10 | } from "./docker-runner"; 11 | import { allocatePort, releasePort } from "./port-manager"; 12 | import { detectProjectType } from "./project-detector"; 13 | import { registerApp, startProxyServer, unregisterApp } from "./proxy-server"; 14 | import { cloneRepository } from "./utils"; 15 | 16 | const connection = new IORedis({ 17 | host: process.env.REDIS_HOST!, 18 | port: Number(process.env.REDIS_PORT!), 19 | maxRetriesPerRequest: null, 20 | }); 21 | 22 | // Start the reverse proxy server 23 | startProxyServer(); 24 | 25 | const worker = new Worker( 26 | QUEUE_NAME, 27 | async (job) => { 28 | console.log(`Processing job ${job.id}: ${job.name}`); 29 | 30 | if (job.name === JOB_NAMES.CLONE_REPOSITORY) { 31 | const { 32 | token, 33 | repo, 34 | branch, 35 | prNumber, 36 | owner, 37 | repoName, 38 | commentId, 39 | projectId, 40 | deploymentId, 41 | } = job.data; 42 | let allocatedPort: number | undefined; 43 | let containerId: string | undefined; 44 | let imageName: string | undefined; 45 | 46 | try { 47 | // Update status to building 48 | if (projectId && deploymentId) { 49 | await updateProjectStatus(projectId, "building"); 50 | await updateDeploymentStatus(deploymentId, "building"); 51 | console.log(`📦 Updated project ${projectId} status to building`); 52 | } 53 | 54 | // Step 1: Clone the repository 55 | console.log(`\nStep 1: Cloning repository...`); 56 | const cloneDir = await cloneRepository(token, repo, branch); 57 | console.log(`Repository cloned to: ${cloneDir}`); 58 | 59 | // Step 2: Detect if it's a Next.js project with pnpm 60 | console.log(`\nStep 2: Checking project type...`); 61 | const projectType = await detectProjectType(cloneDir); 62 | 63 | if (!projectType.isNextJs) { 64 | return { 65 | success: false, 66 | cloneDir, 67 | repo, 68 | branch, 69 | prNumber, 70 | owner, 71 | repoName, 72 | installationId: job.data.installationId, 73 | commentId, 74 | projectId, 75 | deploymentId, 76 | error: "Not a Next.js project", 77 | }; 78 | } 79 | 80 | if (!projectType.hasPnpm) { 81 | return { 82 | success: false, 83 | cloneDir, 84 | repo, 85 | branch, 86 | prNumber, 87 | owner, 88 | repoName, 89 | installationId: job.data.installationId, 90 | commentId, 91 | projectId, 92 | deploymentId, 93 | error: "Project does not use pnpm (no pnpm-lock.yaml found)", 94 | }; 95 | } 96 | 97 | console.log(`Next.js project with pnpm detected`); 98 | 99 | // Step 3: Allocate a port 100 | console.log(`\nStep 3: Allocating port...`); 101 | allocatedPort = await allocatePort(); 102 | 103 | // Step 4: Build and run in Docker 104 | console.log( 105 | `\nStep 4: Building and running 'pnpm run build' in Docker...` 106 | ); 107 | const appId = `${owner}-${repoName}-pr${prNumber}`; 108 | const dockerResult = await buildAndRunInDocker( 109 | cloneDir, 110 | allocatedPort, 111 | appId, 112 | 600000 113 | ); 114 | 115 | containerId = dockerResult.containerId; 116 | imageName = dockerResult.imageName; 117 | 118 | if (!dockerResult.success) { 119 | console.error(`Build/Run failed`); 120 | if (allocatedPort) { 121 | releasePort(allocatedPort); 122 | } 123 | return { 124 | success: false, 125 | cloneDir, 126 | repo, 127 | branch, 128 | prNumber, 129 | owner, 130 | repoName, 131 | installationId: job.data.installationId, 132 | commentId, 133 | projectId, 134 | deploymentId, 135 | error: dockerResult.error, 136 | buildLogs: dockerResult.buildLogs, 137 | }; 138 | } 139 | 140 | console.log(`Build and run completed successfully`); 141 | 142 | // Step 5: Register with reverse proxy 143 | console.log(`\nStep 5: Registering with reverse proxy...`); 144 | const proxyUrl = registerApp(appId, allocatedPort); 145 | 146 | // Track running container for cleanup 147 | runningContainers.set(containerId!, { 148 | containerId: containerId!, 149 | imageName: imageName!, 150 | port: allocatedPort, 151 | appId, 152 | }); 153 | 154 | // Return success data 155 | return { 156 | success: true, 157 | cloneDir, 158 | repo, 159 | branch, 160 | prNumber, 161 | owner, 162 | repoName, 163 | installationId: job.data.installationId, 164 | commentId, 165 | projectId, 166 | deploymentId, 167 | buildLogs: dockerResult.buildLogs, 168 | containerId, 169 | imageName, 170 | port: allocatedPort, 171 | proxyUrl, 172 | appId, 173 | }; 174 | } catch (error) { 175 | console.error(`Failed to process repository:`, error); 176 | 177 | // Cleanup on error 178 | if (allocatedPort) { 179 | releasePort(allocatedPort); 180 | } 181 | if (containerId) { 182 | await stopContainer(containerId).catch(console.error); 183 | } 184 | if (imageName) { 185 | await removeImage(imageName).catch(console.error); 186 | } 187 | 188 | throw error; 189 | } 190 | } 191 | }, 192 | { connection } 193 | ); 194 | 195 | worker.on("completed", async (job) => { 196 | console.log(`\nJob ${job.id} (${job.name}) has completed!`); 197 | 198 | const result = job.returnvalue; 199 | if (result?.proxyUrl) { 200 | console.log(`\nApplication is now accessible at:`); 201 | console.log(` ${result.proxyUrl}`); 202 | } 203 | }); 204 | 205 | worker.on("failed", async (job, err) => { 206 | console.error( 207 | `\nJob ${job?.id} (${job?.name}) has failed with ${err.message}` 208 | ); 209 | 210 | // Cleanup on failure 211 | const data = job?.data; 212 | if (data?.appId) { 213 | unregisterApp(data.appId); 214 | } 215 | }); 216 | 217 | worker.on("error", (err) => { 218 | console.error("Worker error:", err); 219 | }); 220 | 221 | // Track running containers for cleanup 222 | const runningContainers = new Map< 223 | string, 224 | { containerId: string; imageName: string; port: number; appId: string } 225 | >(); 226 | 227 | // Graceful shutdown handler 228 | async function cleanup() { 229 | console.log("\n\nShutting down gracefully..."); 230 | 231 | if (runningContainers.size > 0) { 232 | console.log(`Cleaning up ${runningContainers.size} running containers...`); 233 | 234 | for (const [, info] of runningContainers) { 235 | console.log(` • Stopping ${info.appId}...`); 236 | await stopContainer(info.containerId).catch(console.error); 237 | await removeImage(info.imageName).catch(console.error); 238 | releasePort(info.port); 239 | unregisterApp(info.appId); 240 | } 241 | } 242 | 243 | await worker.close(); 244 | await connection.quit(); 245 | console.log("Cleanup complete. Goodbye!\n"); 246 | process.exit(0); 247 | } 248 | 249 | process.on("SIGTERM", cleanup); 250 | process.on("SIGINT", cleanup); 251 | 252 | console.log("\nForge worker started and listening for jobs..."); 253 | console.log( 254 | `Connected to Redis at ${process.env.REDIS_HOST || "localhost"}:${ 255 | process.env.REDIS_PORT || 6379 256 | }` 257 | ); 258 | console.log(`Docker enabled - will build and run Next.js projects with pnpm`); 259 | console.log(`\nWaiting for jobs...\n`); 260 | -------------------------------------------------------------------------------- /apps/forge/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forge", 3 | "version": "1.0.0", 4 | "description": "Heavy task processing service for Aether", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "tsx watch index.ts", 9 | "start": "node index.js" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "@aether/common": "workspace:*", 16 | "bullmq": "^5.61.0", 17 | "dotenv": "^16.4.7", 18 | "drizzle-orm": "^0.44.6", 19 | "ioredis": "^5.4.2", 20 | "pg": "^8.16.3", 21 | "simple-git": "^3.27.0", 22 | "uuid": "^13.0.0" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^22.15.3", 26 | "@types/pg": "^8.15.5", 27 | "tsx": "^4.19.2" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/forge/port-manager.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "node:child_process"; 2 | import { promisify } from "node:util"; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | // Port range for running applications 7 | const PORT_RANGE_START = 5000; 8 | const PORT_RANGE_END = 5100; 9 | 10 | const allocatedPorts = new Set(); 11 | 12 | async function isPortAvailable(port: number): Promise { 13 | try { 14 | // Check if port is in use using lsof 15 | await execAsync(`lsof -i :${port}`); 16 | return false; // Port is in use 17 | } catch { 18 | return true; // Port is available 19 | } 20 | } 21 | 22 | export async function allocatePort(): Promise { 23 | for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) { 24 | if (!allocatedPorts.has(port) && (await isPortAvailable(port))) { 25 | allocatedPorts.add(port); 26 | console.log(`Allocated port ${port}`); 27 | return port; 28 | } 29 | } 30 | 31 | throw new Error( 32 | `No available ports in range ${PORT_RANGE_START}-${PORT_RANGE_END}` 33 | ); 34 | } 35 | 36 | export function releasePort(port: number): void { 37 | allocatedPorts.delete(port); 38 | console.log(`Released port ${port}`); 39 | } 40 | 41 | export function getAllocatedPorts(): Array { 42 | return Array.from(allocatedPorts); 43 | } 44 | -------------------------------------------------------------------------------- /apps/forge/project-detector.ts: -------------------------------------------------------------------------------- 1 | import { readdir, readFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | 4 | export type ProjectType = { 5 | isNextJs: boolean; 6 | hasPnpm: boolean; 7 | }; 8 | 9 | export async function detectProjectType( 10 | repoPath: string 11 | ): Promise { 12 | try { 13 | const files = await readdir(repoPath); 14 | 15 | // Check for Next.js and pnpm 16 | const hasPackageJson = files.includes("package.json"); 17 | const hasPnpmLock = files.includes("pnpm-lock.yaml"); 18 | 19 | if (!hasPackageJson) { 20 | return { isNextJs: false, hasPnpm: false }; 21 | } 22 | 23 | const packageJson = JSON.parse( 24 | await readFile(join(repoPath, "package.json"), "utf-8") 25 | ); 26 | 27 | const isNextJs = 28 | !!packageJson.dependencies?.next || !!packageJson.devDependencies?.next; 29 | 30 | return { 31 | isNextJs, 32 | hasPnpm: hasPnpmLock, 33 | }; 34 | } catch (error) { 35 | console.error("Error detecting project type:", error); 36 | return { isNextJs: false, hasPnpm: false }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/forge/proxy-server.ts: -------------------------------------------------------------------------------- 1 | import { createServer, IncomingMessage, ServerResponse } from "node:http"; 2 | import { request as httpRequest } from "node:http"; 3 | 4 | const PROXY_PORT = Number(process.env.PROXY_PORT) || 8080; 5 | 6 | // Map of app IDs to their ports 7 | const appPortMap = new Map(); 8 | 9 | function proxyRequest( 10 | req: IncomingMessage, 11 | res: ServerResponse, 12 | targetPort: number 13 | ) { 14 | const options = { 15 | hostname: "localhost", 16 | port: targetPort, 17 | path: req.url, 18 | method: req.method, 19 | headers: req.headers, 20 | }; 21 | 22 | const proxyReq = httpRequest(options, (proxyRes) => { 23 | res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); 24 | proxyRes.pipe(res); 25 | }); 26 | 27 | proxyReq.on("error", (err) => { 28 | console.error(`Proxy error for port ${targetPort}:`, err.message); 29 | res.writeHead(502); 30 | res.end("Bad Gateway - Unable to reach application"); 31 | }); 32 | 33 | req.pipe(proxyReq); 34 | } 35 | 36 | const server = createServer((req, res) => { 37 | const url = req.url || "/"; 38 | 39 | // Health check endpoint 40 | if (url === "/health") { 41 | res.writeHead(200, { "Content-Type": "application/json" }); 42 | res.end( 43 | JSON.stringify({ 44 | status: "ok", 45 | activeApps: appPortMap.size, 46 | apps: Array.from(appPortMap.entries()).map(([id, port]) => ({ 47 | id, 48 | port, 49 | subdomain: `${id}.localhost:${PROXY_PORT}`, 50 | })), 51 | }) 52 | ); 53 | return; 54 | } 55 | 56 | // Extract subdomain from Host header 57 | const hostHeader = req.headers.host || ""; 58 | const host = Array.isArray(hostHeader) ? hostHeader[0] : hostHeader; 59 | 60 | // Parse subdomain: .localhost:8080 or .localhost 61 | const subdomainMatch = host.match(/^([^.]+)\.localhost/); 62 | 63 | if (!subdomainMatch) { 64 | // No subdomain, show app list 65 | res.writeHead(200, { "Content-Type": "text/html" }); 66 | res.end(` 67 | 68 | 69 | Aether Reverse Proxy 70 | 95 | 96 | 97 |

Aether Reverse Proxy

98 |

Active deployments: ${appPortMap.size}

99 |

Access apps using subdomains: <appId>.localhost:${PROXY_PORT}

100 | 101 | ${ 102 | appPortMap.size === 0 103 | ? '

No apps running. Deploy a PR to see it here!

' 104 | : ` 105 |
106 | ${Array.from(appPortMap.entries()) 107 | .map( 108 | ([id, port]) => ` 109 |
110 | 111 | ${id} 112 | 113 |
114 | Port: ${port} • 115 | URL: http://${id}.localhost:${PROXY_PORT} 116 |
117 |
118 | ` 119 | ) 120 | .join("")} 121 |
122 | ` 123 | } 124 | 125 | 126 | `); 127 | return; 128 | } 129 | 130 | const appId = subdomainMatch[1]; 131 | const targetPort = appPortMap.get(appId); 132 | 133 | if (!targetPort) { 134 | res.writeHead(404, { "Content-Type": "text/html" }); 135 | res.end(` 136 | 137 | 138 |

404 - App Not Found

139 |

No deployment found for: ${appId}

140 |

View active deployments

141 | 142 | 143 | `); 144 | return; 145 | } 146 | 147 | // Proxy the request to the target port 148 | proxyRequest(req, res, targetPort); 149 | }); 150 | 151 | export function startProxyServer(): void { 152 | server.listen(PROXY_PORT, () => { 153 | console.log(`Reverse proxy server listening on port ${PROXY_PORT}`); 154 | console.log(` Dashboard: http://localhost:${PROXY_PORT}`); 155 | console.log(` Apps: http://.localhost:${PROXY_PORT}`); 156 | }); 157 | } 158 | 159 | export function registerApp(appId: string, port: number): string { 160 | appPortMap.set(appId, port); 161 | const proxyUrl = `http://${appId}.localhost:${PROXY_PORT}`; 162 | console.log(`Registered app "${appId}" → port ${port}`); 163 | console.log(` Accessible at: ${proxyUrl}`); 164 | return proxyUrl; 165 | } 166 | 167 | export function unregisterApp(appId: string): void { 168 | const port = appPortMap.get(appId); 169 | appPortMap.delete(appId); 170 | if (port) { 171 | console.log(`Unregistered app "${appId}" from port ${port}`); 172 | } 173 | } 174 | 175 | export function getRegisteredApps(): Map { 176 | return new Map(appPortMap); 177 | } 178 | -------------------------------------------------------------------------------- /apps/forge/utils.ts: -------------------------------------------------------------------------------- 1 | import { simpleGit } from "simple-git"; 2 | import { mkdir } from "node:fs/promises"; 3 | import { join } from "node:path"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | 6 | export async function cloneRepository( 7 | token: string, 8 | repoFullName: string, 9 | branch: string 10 | ) { 11 | try { 12 | // Parse repo owner and name 13 | const [owner, repo] = repoFullName.split("/"); 14 | 15 | // Create clone directory 16 | const cloneDir = join( 17 | process.cwd(), 18 | "repos", 19 | `${owner}-${repo}-${branch}-${uuidv4()}` 20 | ); 21 | await mkdir(cloneDir, { recursive: true }); 22 | 23 | // Clone with authentication using token from queue 24 | const cloneUrl = `https://x-access-token:${token}@github.com/${repoFullName}.git`; 25 | 26 | console.log( 27 | `Cloning ${repoFullName} (branch: ${branch}) to ${cloneDir}...` 28 | ); 29 | 30 | const git = simpleGit(); 31 | await git.clone(cloneUrl, cloneDir, [ 32 | "--branch", 33 | branch, 34 | "--single-branch", 35 | ]); 36 | 37 | console.log(`Successfully cloned ${repoFullName} to ${cloneDir}`); 38 | 39 | return cloneDir; 40 | } catch (error) { 41 | console.error(`Error cloning repository:`, error); 42 | throw error; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/herald/.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | 4 | # Dependencies 5 | node_modules/ 6 | 7 | # Build output 8 | dist/ 9 | 10 | # Cloned repositories 11 | repos/ 12 | 13 | # OS files 14 | .DS_Store 15 | 16 | -------------------------------------------------------------------------------- /apps/herald/config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { App } from "@octokit/app"; 3 | import { OAuthApp } from "@octokit/oauth-app"; 4 | import { Webhooks } from "@octokit/webhooks"; 5 | import IORedis from "ioredis"; 6 | 7 | export const connection = new IORedis({ 8 | host: process.env.REDIS_HOST!, 9 | port: Number(process.env.REDIS_PORT!), 10 | maxRetriesPerRequest: null, 11 | }); 12 | 13 | export const githubApp = new App({ 14 | appId: process.env.GITHUB_APP_ID!, 15 | privateKey: process.env.GITHUB_APP_PRIVATE_KEY!.replace(/\\n/g, "\n"), 16 | webhooks: { 17 | secret: process.env.GITHUB_APP_WEBHOOK_SECRET!, 18 | }, 19 | }); 20 | 21 | export const oauthApp = new OAuthApp({ 22 | clientType: "github-app", 23 | clientId: process.env.GITHUB_APP_CLIENT_ID!, 24 | clientSecret: process.env.GITHUB_APP_CLIENT_SECRET!, 25 | }); 26 | 27 | export const webhooks = new Webhooks({ 28 | secret: process.env.GITHUB_APP_WEBHOOK_SECRET!, 29 | }); 30 | 31 | export const PORT = process.env.PORT || 4000; 32 | -------------------------------------------------------------------------------- /apps/herald/db.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/node-postgres"; 2 | import { Pool } from "pg"; 3 | 4 | const pool = new Pool({ 5 | connectionString: process.env.DATABASE_URL!, 6 | }); 7 | 8 | export const db = drizzle(pool); 9 | -------------------------------------------------------------------------------- /apps/herald/index.ts: -------------------------------------------------------------------------------- 1 | import { setupQueueHandlers } from "./queue"; 2 | import { setupOAuthHandlers } from "./oauth-handlers"; 3 | import { setupWebhookHandlers } from "./webhook-handlers"; 4 | import { startServer } from "./server"; 5 | 6 | setupQueueHandlers(); 7 | setupOAuthHandlers(); 8 | setupWebhookHandlers(); 9 | startServer(); 10 | -------------------------------------------------------------------------------- /apps/herald/oauth-handlers.ts: -------------------------------------------------------------------------------- 1 | import { oauthApp } from "./config"; 2 | 3 | export function setupOAuthHandlers() { 4 | oauthApp.on("token", async ({ token, octokit }) => { 5 | console.log(`Token retrieved for ${token}`); 6 | const { data } = await octokit.request("GET /user"); 7 | console.log(`Token retrieved for ${data.login}`); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /apps/herald/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "herald", 3 | "version": "1.0.0", 4 | "description": "GitHub App Bot", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "dev": "tsx watch index.ts", 9 | "build": "tsc", 10 | "start": "node dist/index.js" 11 | }, 12 | "keywords": [ 13 | "github", 14 | "bot", 15 | "octokit" 16 | ], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "@aether/common": "workspace:*", 21 | "@octokit/app": "^16.1.1", 22 | "@octokit/oauth-app": "^8.0.3", 23 | "@octokit/webhooks": "^14.1.3", 24 | "bullmq": "^5.61.0", 25 | "dotenv": "^17.2.3", 26 | "drizzle-orm": "^0.44.6", 27 | "ioredis": "^5.8.1", 28 | "pg": "^8.16.3", 29 | "simple-git": "^3.28.0" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^22.15.3", 33 | "@types/pg": "^8.15.5", 34 | "tsx": "^4.19.2", 35 | "typescript": "^5.7.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/herald/queue.ts: -------------------------------------------------------------------------------- 1 | import { QUEUE_NAME } from "@aether/common"; 2 | import { Queue, QueueEvents } from "bullmq"; 3 | import { sql } from "drizzle-orm"; 4 | import { connection, githubApp } from "./config"; 5 | import { db } from "./db"; 6 | 7 | export const queue = new Queue(QUEUE_NAME, { connection }); 8 | export const queueEvents = new QueueEvents(QUEUE_NAME, { connection }); 9 | 10 | export function setupQueueHandlers() { 11 | queueEvents.on("completed", async ({ jobId, returnvalue }) => { 12 | console.log(`Job ${jobId} completed with result:`, returnvalue); 13 | 14 | try { 15 | const result = returnvalue as any; 16 | 17 | // Update database if projectId and deploymentId are present 18 | if (result.projectId && result.deploymentId) { 19 | if (result.success && result.proxyUrl) { 20 | // Update project status to deployed 21 | await db.execute( 22 | sql`UPDATE project SET status = 'deployed', deployment_url = ${result.proxyUrl}, last_deployed_at = NOW(), updated_at = NOW() WHERE id = ${result.projectId}` 23 | ); 24 | 25 | // Update deployment status to success 26 | await db.execute( 27 | sql`UPDATE deployment SET status = 'success', url = ${result.proxyUrl}, completed_at = NOW() WHERE id = ${result.deploymentId}` 28 | ); 29 | 30 | console.log( 31 | `✅ Updated project ${result.projectId} status to deployed` 32 | ); 33 | } else if (!result.success && result.error) { 34 | // Update project status to failed 35 | await db.execute( 36 | sql`UPDATE project SET status = 'failed', updated_at = NOW() WHERE id = ${result.projectId}` 37 | ); 38 | 39 | // Update deployment status to failed 40 | await db.execute( 41 | sql`UPDATE deployment SET status = 'failed', error = ${result.error}, build_logs = ${result.buildLogs || null}, completed_at = NOW() WHERE id = ${result.deploymentId}` 42 | ); 43 | 44 | console.log( 45 | `❌ Updated project ${result.projectId} status to failed` 46 | ); 47 | } 48 | } 49 | 50 | // Only post GitHub comments if installationId exists (PR deployments) 51 | if (result.success && result.proxyUrl && result.installationId) { 52 | const { 53 | owner, 54 | repoName, 55 | prNumber, 56 | branch, 57 | proxyUrl, 58 | appId, 59 | commentId, 60 | } = result; 61 | const octokit = await githubApp.getInstallationOctokit( 62 | result.installationId 63 | ); 64 | 65 | const commentBody = `**Deployed** → **[${proxyUrl}](${proxyUrl})** 66 | 67 | Branch: \`${branch}\` · App ID: \`${appId}\``; 68 | 69 | if (commentId) { 70 | // Update the existing comment 71 | await octokit.request( 72 | "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", 73 | { 74 | owner, 75 | repo: repoName, 76 | comment_id: commentId, 77 | body: commentBody, 78 | } 79 | ); 80 | console.log(`Updated comment on PR #${prNumber}`); 81 | } else { 82 | // Fallback: create new comment if no commentId 83 | await octokit.request( 84 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 85 | { 86 | owner, 87 | repo: repoName, 88 | issue_number: prNumber, 89 | body: commentBody, 90 | } 91 | ); 92 | console.log(`Posted deployment URL on PR #${prNumber}: ${proxyUrl}`); 93 | } 94 | } else if (result.success && result.installationId) { 95 | // Fallback for jobs that complete but don't have a proxyUrl (only for PR deployments) 96 | const { owner, repoName, prNumber, branch, commentId } = result; 97 | const octokit = await githubApp.getInstallationOctokit( 98 | result.installationId 99 | ); 100 | 101 | const commentBody = `**Processed** \`${branch}\``; 102 | 103 | if (commentId) { 104 | await octokit.request( 105 | "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", 106 | { 107 | owner, 108 | repo: repoName, 109 | comment_id: commentId, 110 | body: commentBody, 111 | } 112 | ); 113 | console.log(`Updated comment on PR #${prNumber}`); 114 | } else { 115 | await octokit.request( 116 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 117 | { 118 | owner, 119 | repo: repoName, 120 | issue_number: prNumber, 121 | body: commentBody, 122 | } 123 | ); 124 | console.log(`Posted success comment on PR #${prNumber}`); 125 | } 126 | } else if (!result.success && result.error && result.installationId) { 127 | // Handle failures (only for PR deployments) 128 | const { owner, repoName, prNumber, error, buildLogs, commentId } = 129 | result; 130 | const octokit = await githubApp.getInstallationOctokit( 131 | result.installationId 132 | ); 133 | 134 | const errorMessage = `**Deployment failed**: ${error}${ 135 | buildLogs 136 | ? `\n\n
Build logs\n\n\`\`\`\n${buildLogs.slice(-2000)}\n\`\`\`\n
` 137 | : "" 138 | }`; 139 | 140 | if (commentId) { 141 | await octokit.request( 142 | "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", 143 | { 144 | owner, 145 | repo: repoName, 146 | comment_id: commentId, 147 | body: errorMessage, 148 | } 149 | ); 150 | console.log(`Updated comment with failure on PR #${prNumber}`); 151 | } else { 152 | await octokit.request( 153 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 154 | { 155 | owner, 156 | repo: repoName, 157 | issue_number: prNumber, 158 | body: errorMessage, 159 | } 160 | ); 161 | console.log(`Posted failure comment on PR #${prNumber}`); 162 | } 163 | } 164 | } catch (error) { 165 | console.error("Error handling job completion:", error); 166 | } 167 | }); 168 | 169 | queueEvents.on("failed", async ({ jobId, failedReason, prev }) => { 170 | console.error(`Job ${jobId} failed: ${failedReason}`); 171 | 172 | try { 173 | // Try to update the failure comment if we have the necessary data 174 | const jobData = (prev as any)?.data; 175 | 176 | // Update database if projectId and deploymentId are present 177 | if (jobData?.projectId && jobData?.deploymentId) { 178 | await db.execute( 179 | sql`UPDATE project SET status = 'failed', updated_at = NOW() WHERE id = ${jobData.projectId}` 180 | ); 181 | 182 | await db.execute( 183 | sql`UPDATE deployment SET status = 'failed', error = ${failedReason}, completed_at = NOW() WHERE id = ${jobData.deploymentId}` 184 | ); 185 | 186 | console.log( 187 | `❌ Updated project ${jobData.projectId} status to failed (job failed)` 188 | ); 189 | } 190 | 191 | if ( 192 | jobData?.owner && 193 | jobData?.repoName && 194 | jobData?.prNumber && 195 | jobData?.installationId 196 | ) { 197 | const { owner, repoName, prNumber, installationId, commentId } = 198 | jobData; 199 | const octokit = await githubApp.getInstallationOctokit(installationId); 200 | 201 | const errorBody = `**Job failed**: ${failedReason}`; 202 | 203 | if (commentId) { 204 | await octokit.request( 205 | "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", 206 | { 207 | owner, 208 | repo: repoName, 209 | comment_id: commentId, 210 | body: errorBody, 211 | } 212 | ); 213 | console.log(`Updated comment with job failure on PR #${prNumber}`); 214 | } else { 215 | await octokit.request( 216 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 217 | { 218 | owner, 219 | repo: repoName, 220 | issue_number: prNumber, 221 | body: errorBody, 222 | } 223 | ); 224 | console.log(`Posted job failure comment on PR #${prNumber}`); 225 | } 226 | } 227 | } catch (error) { 228 | console.error("Error posting failure comment:", error); 229 | } 230 | }); 231 | } 232 | -------------------------------------------------------------------------------- /apps/herald/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer, IncomingMessage, ServerResponse } from "node:http"; 2 | import { createNodeMiddleware } from "@octokit/oauth-app"; 3 | import { createNodeMiddleware as createWebhookMiddleware } from "@octokit/webhooks"; 4 | import { oauthApp, webhooks, PORT } from "./config"; 5 | 6 | const oauthMiddleware = createNodeMiddleware(oauthApp); 7 | const webhookMiddleware = createWebhookMiddleware(webhooks, { 8 | path: "/api/github/webhook", 9 | }); 10 | 11 | export const server = createServer( 12 | async (req: IncomingMessage, res: ServerResponse) => { 13 | if (await oauthMiddleware(req, res)) { 14 | return; 15 | } 16 | 17 | if (await webhookMiddleware(req, res)) { 18 | return; 19 | } 20 | 21 | res.statusCode = 404; 22 | res.end("Not found"); 23 | } 24 | ); 25 | 26 | export function startServer() { 27 | server.listen(PORT, () => { 28 | console.log(`GitHub app listening on port ${PORT}`); 29 | console.log( 30 | `OAuth callback: http://localhost:${PORT}/api/github/oauth/callback` 31 | ); 32 | console.log( 33 | `Webhook endpoint: http://localhost:${PORT}/api/github/webhook` 34 | ); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /apps/herald/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "outDir": "./dist", 13 | "rootDir": "./", 14 | "types": ["node"] 15 | }, 16 | "include": ["**/*.ts"], 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | 20 | -------------------------------------------------------------------------------- /apps/herald/webhook-handlers.ts: -------------------------------------------------------------------------------- 1 | import { JOB_NAMES } from "@aether/common"; 2 | import { webhooks, githubApp } from "./config"; 3 | import { queue } from "./queue"; 4 | 5 | async function handlePullRequestOpened(payload: any) { 6 | const repo = payload.repository.full_name; 7 | const ref = payload.pull_request.head.ref; 8 | const installationId = payload.installation!.id; 9 | const prNumber = payload.pull_request.number; 10 | const [owner, repoName] = repo.split("/"); 11 | 12 | console.log(`PR opened in ${repo} on branch ${ref}`); 13 | 14 | try { 15 | const octokit = await githubApp.getInstallationOctokit(installationId); 16 | const { data: installationToken } = await octokit.request( 17 | "POST /app/installations/{installation_id}/access_tokens", 18 | { 19 | installation_id: installationId, 20 | } 21 | ); 22 | 23 | const job = await queue.add(JOB_NAMES.CLONE_REPOSITORY, { 24 | token: installationToken.token, 25 | repo, 26 | branch: ref, 27 | prNumber, 28 | owner, 29 | repoName, 30 | installationId, 31 | }); 32 | 33 | console.log( 34 | `Added clone job to queue for PR #${prNumber} (Job ID: ${job.id})` 35 | ); 36 | 37 | const { data: comment } = await octokit.request( 38 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 39 | { 40 | owner, 41 | repo: repoName, 42 | issue_number: prNumber, 43 | body: `**Deploying** \`${ref}\` — usually takes 2-5 minutes`, 44 | } 45 | ); 46 | 47 | // Update job with comment ID so we can edit it later 48 | await job.updateData({ 49 | ...job.data, 50 | commentId: comment.id, 51 | }); 52 | 53 | console.log( 54 | `Posted comment on PR #${prNumber} (Comment ID: ${comment.id})` 55 | ); 56 | } catch (error) { 57 | console.error(`Failed to queue clone job:`, error); 58 | 59 | try { 60 | const octokit = await githubApp.getInstallationOctokit(installationId); 61 | await octokit.request( 62 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 63 | { 64 | owner, 65 | repo: repoName, 66 | issue_number: prNumber, 67 | body: `**Failed to queue job**: ${error}`, 68 | } 69 | ); 70 | } catch (commentError) { 71 | console.error(`Failed to post error comment:`, commentError); 72 | } 73 | } 74 | } 75 | 76 | async function handlePullRequestReopened(payload: any) { 77 | const repo = payload.repository.full_name; 78 | const ref = payload.pull_request.head.ref; 79 | const installationId = payload.installation!.id; 80 | const prNumber = payload.pull_request.number; 81 | const [owner, repoName] = repo.split("/"); 82 | 83 | console.log(`PR reopened in ${repo} on branch ${ref}`); 84 | 85 | try { 86 | const octokit = await githubApp.getInstallationOctokit(installationId); 87 | const { data: installationToken } = await octokit.request( 88 | "POST /app/installations/{installation_id}/access_tokens", 89 | { 90 | installation_id: installationId, 91 | } 92 | ); 93 | 94 | const { data: comment } = await octokit.request( 95 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 96 | { 97 | owner, 98 | repo: repoName, 99 | issue_number: prNumber, 100 | body: `**Redeploying** \`${ref}\` — usually takes 2-5 minutes`, 101 | } 102 | ); 103 | 104 | console.log( 105 | `Posted comment on PR #${prNumber} (Comment ID: ${comment.id})` 106 | ); 107 | 108 | const job = await queue.add(JOB_NAMES.CLONE_REPOSITORY, { 109 | token: installationToken.token, 110 | repo, 111 | branch: ref, 112 | prNumber, 113 | owner, 114 | repoName, 115 | installationId, 116 | commentId: comment.id, 117 | }); 118 | 119 | console.log( 120 | `Added clone job to queue for PR #${prNumber} (Job ID: ${job.id})` 121 | ); 122 | } catch (error) { 123 | console.error(`Failed to queue clone job:`, error); 124 | 125 | try { 126 | const octokit = await githubApp.getInstallationOctokit(installationId); 127 | await octokit.request( 128 | "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", 129 | { 130 | owner, 131 | repo: repoName, 132 | issue_number: prNumber, 133 | body: `**Failed to queue job**: ${error}`, 134 | } 135 | ); 136 | } catch (commentError) { 137 | console.error(`Failed to post error comment:`, commentError); 138 | } 139 | } 140 | } 141 | 142 | export function setupWebhookHandlers() { 143 | webhooks.on("issues.opened", async ({ payload }) => { 144 | console.log(`Issue opened: ${payload.issue.title}`); 145 | }); 146 | 147 | webhooks.on("pull_request.opened", async ({ payload }) => { 148 | console.log(`PR opened: ${payload.pull_request.title}`); 149 | await handlePullRequestOpened(payload); 150 | }); 151 | 152 | webhooks.on("pull_request.reopened", async ({ payload }) => { 153 | await handlePullRequestReopened(payload); 154 | }); 155 | 156 | webhooks.on("push", async ({ payload }) => { 157 | console.log(`Push to ${payload.repository.full_name}`); 158 | }); 159 | 160 | webhooks.on("installation.created", async ({ payload }) => { 161 | console.log(`Installation created: ${payload.installation.id}`); 162 | }); 163 | 164 | webhooks.on("issue_comment.created", async ({ payload }) => { 165 | console.log(`Issue comment created: ${payload.comment.body}`); 166 | }); 167 | 168 | webhooks.onAny(async ({ name, payload }) => { 169 | console.log(`Received webhook event: ${name}`); 170 | }); 171 | } 172 | -------------------------------------------------------------------------------- /apps/nexus/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # env files (can opt-in for commiting if needed) 29 | .env* 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /apps/nexus/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /apps/nexus/app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { toNextJsHandler } from "better-auth/next-js"; 2 | import { auth } from "../../../auth"; 3 | 4 | export const { POST, GET } = toNextJsHandler(auth); 5 | -------------------------------------------------------------------------------- /apps/nexus/app/api/github/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "../../../auth"; 3 | import { headers } from "next/headers"; 4 | 5 | export async function GET(req: NextRequest) { 6 | const session = await auth.api.getSession({ 7 | headers: await headers(), 8 | }); 9 | 10 | if (!session) { 11 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 12 | } 13 | 14 | const clientId = process.env.GITHUB_APP_CLIENT_ID!; 15 | const redirectUri = `${process.env.NEXT_PUBLIC_APP_URL}/api/github/callback`; 16 | const state = session.user.id; // Use user ID as state for simplicity 17 | 18 | const authUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`; 19 | 20 | return NextResponse.redirect(authUrl); 21 | } 22 | 23 | -------------------------------------------------------------------------------- /apps/nexus/app/api/github/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { NextRequest, NextResponse } from "next/server"; 3 | import { db } from "../../../../db"; 4 | import { account } from "../../../../db/schema"; 5 | 6 | export async function GET(req: NextRequest) { 7 | const searchParams = req.nextUrl.searchParams; 8 | const code = searchParams.get("code"); 9 | const state = searchParams.get("state"); // This is the user ID 10 | 11 | if (!code || !state) { 12 | return NextResponse.redirect( 13 | `${process.env.NEXT_PUBLIC_APP_URL}/new?error=missing_params` 14 | ); 15 | } 16 | 17 | try { 18 | // Exchange code for access token 19 | const tokenResponse = await fetch( 20 | "https://github.com/login/oauth/access_token", 21 | { 22 | method: "POST", 23 | headers: { 24 | "Content-Type": "application/json", 25 | Accept: "application/json", 26 | }, 27 | body: JSON.stringify({ 28 | client_id: process.env.GITHUB_APP_CLIENT_ID!, 29 | client_secret: process.env.GITHUB_APP_CLIENT_SECRET!, 30 | code, 31 | }), 32 | } 33 | ); 34 | 35 | const tokenData = await tokenResponse.json(); 36 | 37 | if (tokenData.error) { 38 | throw new Error(tokenData.error_description || tokenData.error); 39 | } 40 | 41 | // Check if GitHub account already exists for this user 42 | const existingAccount = await db.query.account.findFirst({ 43 | where: and(eq(account.userId, state), eq(account.providerId, "github")), 44 | }); 45 | 46 | if (existingAccount) { 47 | // Update existing account 48 | await db 49 | .update(account) 50 | .set({ 51 | accessToken: tokenData.access_token, 52 | refreshToken: tokenData.refresh_token || null, 53 | accessTokenExpiresAt: tokenData.expires_in 54 | ? new Date(Date.now() + tokenData.expires_in * 1000) 55 | : null, 56 | }) 57 | .where(eq(account.id, existingAccount.id)); 58 | } else { 59 | // Create new account entry 60 | await db.insert(account).values({ 61 | id: `github_${state}_${Date.now()}`, 62 | userId: state, 63 | providerId: "github", 64 | accountId: tokenData.access_token, // We'll update this with actual GitHub user ID 65 | accessToken: tokenData.access_token, 66 | refreshToken: tokenData.refresh_token || null, 67 | accessTokenExpiresAt: tokenData.expires_in 68 | ? new Date(Date.now() + tokenData.expires_in * 1000) 69 | : null, 70 | }); 71 | } 72 | 73 | // Redirect back to new page with success 74 | return NextResponse.redirect( 75 | `${process.env.NEXT_PUBLIC_APP_URL}/new?success=true` 76 | ); 77 | } catch (error) { 78 | console.error("GitHub OAuth error:", error); 79 | return NextResponse.redirect( 80 | `${process.env.NEXT_PUBLIC_APP_URL}/new?error=oauth_failed` 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /apps/nexus/app/api/github/repositories/route.ts: -------------------------------------------------------------------------------- 1 | import { and, eq } from "drizzle-orm"; 2 | import { headers } from "next/headers"; 3 | import { NextResponse } from "next/server"; 4 | import { db } from "../../../../db"; 5 | import { account } from "../../../../db/schema"; 6 | import { auth } from "../../../auth"; 7 | 8 | export async function GET() { 9 | const session = await auth.api.getSession({ 10 | headers: await headers(), 11 | }); 12 | 13 | if (!session) { 14 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 15 | } 16 | 17 | try { 18 | // Get user's GitHub account from database 19 | const githubAccount = await db.query.account.findFirst({ 20 | where: and( 21 | eq(account.userId, session.user.id), 22 | eq(account.providerId, "github") 23 | ), 24 | }); 25 | 26 | if (!githubAccount?.accessToken) { 27 | return NextResponse.json( 28 | { error: "GitHub not connected" }, 29 | { status: 400 } 30 | ); 31 | } 32 | 33 | // Fetch repositories from GitHub 34 | const response = await fetch( 35 | "https://api.github.com/user/repos?sort=updated&per_page=100", 36 | { 37 | headers: { 38 | Authorization: `Bearer ${githubAccount.accessToken}`, 39 | Accept: "application/vnd.github.v3+json", 40 | }, 41 | } 42 | ); 43 | 44 | if (!response.ok) { 45 | throw new Error("Failed to fetch repositories"); 46 | } 47 | 48 | const repos = await response.json(); 49 | 50 | // Return simplified repo data 51 | const simplifiedRepos = repos.map((repo: any) => ({ 52 | id: repo.id, 53 | name: repo.name, 54 | fullName: repo.full_name, 55 | description: repo.description, 56 | private: repo.private, 57 | htmlUrl: repo.html_url, 58 | updatedAt: repo.updated_at, 59 | language: repo.language, 60 | defaultBranch: repo.default_branch, 61 | })); 62 | 63 | return NextResponse.json({ repositories: simplifiedRepos }); 64 | } catch (error) { 65 | console.error("Error fetching repositories:", error); 66 | return NextResponse.json( 67 | { error: "Failed to fetch repositories" }, 68 | { status: 500 } 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /apps/nexus/app/api/projects/import/route.ts: -------------------------------------------------------------------------------- 1 | import { JOB_NAMES } from "@aether/common"; 2 | import { and, eq } from "drizzle-orm"; 3 | import { headers } from "next/headers"; 4 | import { NextResponse } from "next/server"; 5 | import { db } from "../../../../db"; 6 | import { account, deployment, project } from "../../../../db/schema"; 7 | import { queue } from "../../../../lib/queue"; 8 | import { auth } from "../../../auth"; 9 | 10 | export async function POST(req: Request) { 11 | const session = await auth.api.getSession({ 12 | headers: await headers(), 13 | }); 14 | 15 | if (!session) { 16 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 17 | } 18 | 19 | try { 20 | const body = await req.json(); 21 | const { repositoryFullName, defaultBranch, name } = body; 22 | 23 | if (!repositoryFullName || !defaultBranch || !name) { 24 | return NextResponse.json( 25 | { error: "Missing required fields" }, 26 | { status: 400 } 27 | ); 28 | } 29 | 30 | // Get user's GitHub account 31 | const githubAccount = await db.query.account.findFirst({ 32 | where: and( 33 | eq(account.userId, session.user.id), 34 | eq(account.providerId, "github") 35 | ), 36 | }); 37 | 38 | if (!githubAccount?.accessToken) { 39 | return NextResponse.json( 40 | { error: "GitHub not connected" }, 41 | { status: 400 } 42 | ); 43 | } 44 | 45 | // Create project 46 | const projectId = `proj_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; 47 | await db.insert(project).values({ 48 | id: projectId, 49 | userId: session.user.id, 50 | name, 51 | repositoryFullName, 52 | defaultBranch, 53 | status: "pending", 54 | }); 55 | 56 | // Create deployment record 57 | const deploymentId = `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; 58 | await db.insert(deployment).values({ 59 | id: deploymentId, 60 | projectId, 61 | branch: defaultBranch, 62 | status: "pending", 63 | }); 64 | 65 | // Parse repo owner and name 66 | const [owner, repoName] = repositoryFullName.split("/"); 67 | 68 | // Add job to queue 69 | await queue.add(JOB_NAMES.CLONE_REPOSITORY, { 70 | token: githubAccount.accessToken, 71 | repo: repositoryFullName, 72 | branch: defaultBranch, 73 | owner, 74 | repoName, 75 | projectId, 76 | deploymentId, 77 | // For initial import, we don't have PR-specific data 78 | prNumber: 0, 79 | commentId: null, 80 | installationId: null, 81 | }); 82 | 83 | return NextResponse.json({ 84 | success: true, 85 | projectId, 86 | deploymentId, 87 | }); 88 | } catch (error) { 89 | console.error("Error importing project:", error); 90 | return NextResponse.json( 91 | { error: "Failed to import project" }, 92 | { status: 500 } 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /apps/nexus/app/api/projects/route.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import { headers } from "next/headers"; 3 | import { NextResponse } from "next/server"; 4 | import { db } from "../../../db"; 5 | import { project } from "../../../db/schema"; 6 | import { auth } from "../../auth"; 7 | 8 | export async function GET() { 9 | const session = await auth.api.getSession({ 10 | headers: await headers(), 11 | }); 12 | 13 | if (!session) { 14 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 15 | } 16 | 17 | try { 18 | const projects = await db.query.project.findMany({ 19 | where: eq(project.userId, session.user.id), 20 | orderBy: (project, { desc }) => [desc(project.createdAt)], 21 | }); 22 | 23 | return NextResponse.json({ projects }); 24 | } catch (error) { 25 | console.error("Error fetching projects:", error); 26 | return NextResponse.json( 27 | { error: "Failed to fetch projects" }, 28 | { status: 500 } 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/nexus/app/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | export const authClient = createAuthClient({ 3 | /** The base URL of the server (optional if you're using the same domain) */ 4 | baseURL: "http://localhost:3000", 5 | }); 6 | -------------------------------------------------------------------------------- /apps/nexus/app/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth, type BetterAuthOptions } from "better-auth"; 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 3 | import { db } from "../db"; 4 | import * as schema from "../db/schema"; 5 | 6 | const authOptions: BetterAuthOptions = { 7 | database: drizzleAdapter(db, { 8 | provider: "pg", 9 | schema: { 10 | user: schema.user, 11 | session: schema.session, 12 | account: schema.account, 13 | verification: schema.verification, 14 | }, 15 | }), 16 | emailAndPassword: { 17 | enabled: true, 18 | }, 19 | socialProviders: { 20 | github: { 21 | clientId: process.env.GITHUB_APP_CLIENT_ID!, 22 | clientSecret: process.env.GITHUB_APP_CLIENT_SECRET!, 23 | }, 24 | }, 25 | } satisfies BetterAuthOptions; 26 | 27 | export const auth = betterAuth(authOptions); 28 | -------------------------------------------------------------------------------- /apps/nexus/app/components/landing.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Link2, Lock, LucideGithub, Zap } from "lucide-react"; 4 | import { authClient } from "../auth-client"; 5 | import { Button } from "./ui/button"; 6 | 7 | export function Landing() { 8 | return ( 9 |
10 |
11 |

12 | Test every pull request 13 |
14 | instantly 15 |

16 |

17 | Get ready-to-test URLs for every pull request. No manual deployments, 18 | no waiting. Just push and preview. 19 |

20 |
21 | 33 |
34 |
35 | 36 |
37 |

38 | Built for speed 39 |

40 |
41 | } 43 | title="Instant deploys" 44 | description="Every PR gets a unique URL within seconds of pushing code" 45 | /> 46 | } 48 | title="Shareable links" 49 | description="Share preview URLs with your team or stakeholders instantly" 50 | /> 51 | } 53 | title="Secure by default" 54 | description="Private repositories stay private with secure authentication" 55 | /> 56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | function Feature({ 63 | icon, 64 | title, 65 | description, 66 | }: { 67 | icon: React.ReactNode; 68 | title: string; 69 | description: string; 70 | }) { 71 | return ( 72 |
73 |
{icon}
74 |

{title}

75 |

{description}

76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /apps/nexus/app/components/nav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LoaderIcon, LucideGithub } from "lucide-react"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/navigation"; 6 | import { authClient } from "../auth-client"; 7 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; 8 | import { Button } from "./ui/button"; 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | DropdownMenuLabel, 14 | DropdownMenuSeparator, 15 | DropdownMenuTrigger, 16 | } from "./ui/dropdown-menu"; 17 | 18 | export function Nav() { 19 | const { data, isPending } = authClient.useSession(); 20 | const router = useRouter(); 21 | 22 | return ( 23 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /apps/nexus/app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@aether/common"; 7 | 8 | function Avatar({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ); 22 | } 23 | 24 | function AvatarImage({ 25 | className, 26 | ...props 27 | }: React.ComponentProps) { 28 | return ( 29 | 34 | ); 35 | } 36 | 37 | function AvatarFallback({ 38 | className, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 50 | ); 51 | } 52 | 53 | export { Avatar, AvatarImage, AvatarFallback }; 54 | -------------------------------------------------------------------------------- /apps/nexus/app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@aether/common"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: 20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | "icon-sm": "size-8", 29 | "icon-lg": "size-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ); 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean; 48 | }) { 49 | const Comp = asChild ? Slot : "button"; 50 | 51 | return ( 52 | 57 | ); 58 | } 59 | 60 | export { Button, buttonVariants }; 61 | -------------------------------------------------------------------------------- /apps/nexus/app/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@aether/common"; 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ); 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ); 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ); 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ); 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ); 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ); 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ); 82 | } 83 | 84 | export { 85 | Card, 86 | CardAction, 87 | CardContent, 88 | CardDescription, 89 | CardFooter, 90 | CardHeader, 91 | CardTitle, 92 | }; 93 | -------------------------------------------------------------------------------- /apps/nexus/app/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; 6 | 7 | import { cn } from "@aether/common"; 8 | 9 | function DropdownMenu({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return ; 13 | } 14 | 15 | function DropdownMenuPortal({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ); 21 | } 22 | 23 | function DropdownMenuTrigger({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 31 | ); 32 | } 33 | 34 | function DropdownMenuContent({ 35 | className, 36 | sideOffset = 4, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 41 | 50 | 51 | ); 52 | } 53 | 54 | function DropdownMenuGroup({ 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | ); 60 | } 61 | 62 | function DropdownMenuItem({ 63 | className, 64 | inset, 65 | variant = "default", 66 | ...props 67 | }: React.ComponentProps & { 68 | inset?: boolean; 69 | variant?: "default" | "destructive"; 70 | }) { 71 | return ( 72 | 82 | ); 83 | } 84 | 85 | function DropdownMenuCheckboxItem({ 86 | className, 87 | children, 88 | checked, 89 | ...props 90 | }: React.ComponentProps) { 91 | return ( 92 | 101 | 102 | 103 | 104 | 105 | 106 | {children} 107 | 108 | ); 109 | } 110 | 111 | function DropdownMenuRadioGroup({ 112 | ...props 113 | }: React.ComponentProps) { 114 | return ( 115 | 119 | ); 120 | } 121 | 122 | function DropdownMenuRadioItem({ 123 | className, 124 | children, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | ); 144 | } 145 | 146 | function DropdownMenuLabel({ 147 | className, 148 | inset, 149 | ...props 150 | }: React.ComponentProps & { 151 | inset?: boolean; 152 | }) { 153 | return ( 154 | 163 | ); 164 | } 165 | 166 | function DropdownMenuSeparator({ 167 | className, 168 | ...props 169 | }: React.ComponentProps) { 170 | return ( 171 | 176 | ); 177 | } 178 | 179 | function DropdownMenuShortcut({ 180 | className, 181 | ...props 182 | }: React.ComponentProps<"span">) { 183 | return ( 184 | 192 | ); 193 | } 194 | 195 | function DropdownMenuSub({ 196 | ...props 197 | }: React.ComponentProps) { 198 | return ; 199 | } 200 | 201 | function DropdownMenuSubTrigger({ 202 | className, 203 | inset, 204 | children, 205 | ...props 206 | }: React.ComponentProps & { 207 | inset?: boolean; 208 | }) { 209 | return ( 210 | 219 | {children} 220 | 221 | 222 | ); 223 | } 224 | 225 | function DropdownMenuSubContent({ 226 | className, 227 | ...props 228 | }: React.ComponentProps) { 229 | return ( 230 | 238 | ); 239 | } 240 | 241 | export { 242 | DropdownMenu, 243 | DropdownMenuPortal, 244 | DropdownMenuTrigger, 245 | DropdownMenuContent, 246 | DropdownMenuGroup, 247 | DropdownMenuLabel, 248 | DropdownMenuItem, 249 | DropdownMenuCheckboxItem, 250 | DropdownMenuRadioGroup, 251 | DropdownMenuRadioItem, 252 | DropdownMenuSeparator, 253 | DropdownMenuShortcut, 254 | DropdownMenuSub, 255 | DropdownMenuSubTrigger, 256 | DropdownMenuSubContent, 257 | }; 258 | -------------------------------------------------------------------------------- /apps/nexus/app/components/ui/input-group.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@aether/common"; 7 | import { Button } from "./button"; 8 | import { Input } from "./input"; 9 | import { Textarea } from "./textarea"; 10 | 11 | function InputGroup({ className, ...props }: React.ComponentProps<"div">) { 12 | return ( 13 |
textarea]:h-auto", 19 | 20 | // Variants based on alignment. 21 | "has-[>[data-align=inline-start]]:[&>input]:pl-2", 22 | "has-[>[data-align=inline-end]]:[&>input]:pr-2", 23 | "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", 24 | "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", 25 | 26 | // Focus state. 27 | "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", 28 | 29 | // Error state. 30 | "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", 31 | 32 | className 33 | )} 34 | {...props} 35 | /> 36 | ); 37 | } 38 | 39 | const inputGroupAddonVariants = cva( 40 | "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", 41 | { 42 | variants: { 43 | align: { 44 | "inline-start": 45 | "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", 46 | "inline-end": 47 | "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", 48 | "block-start": 49 | "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", 50 | "block-end": 51 | "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", 52 | }, 53 | }, 54 | defaultVariants: { 55 | align: "inline-start", 56 | }, 57 | } 58 | ); 59 | 60 | function InputGroupAddon({ 61 | className, 62 | align = "inline-start", 63 | ...props 64 | }: React.ComponentProps<"div"> & VariantProps) { 65 | return ( 66 |
{ 72 | if ((e.target as HTMLElement).closest("button")) { 73 | return; 74 | } 75 | e.currentTarget.parentElement?.querySelector("input")?.focus(); 76 | }} 77 | {...props} 78 | /> 79 | ); 80 | } 81 | 82 | const inputGroupButtonVariants = cva( 83 | "text-sm shadow-none flex gap-2 items-center", 84 | { 85 | variants: { 86 | size: { 87 | xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", 88 | sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", 89 | "icon-xs": 90 | "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", 91 | "icon-sm": "size-8 p-0 has-[>svg]:p-0", 92 | }, 93 | }, 94 | defaultVariants: { 95 | size: "xs", 96 | }, 97 | } 98 | ); 99 | 100 | function InputGroupButton({ 101 | className, 102 | type = "button", 103 | variant = "ghost", 104 | size = "xs", 105 | ...props 106 | }: Omit, "size"> & 107 | VariantProps) { 108 | return ( 109 |