diff --git a/.env.development b/.env.development index 39b25125c..e9029f539 100644 --- a/.env.development +++ b/.env.development @@ -71,3 +71,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection NODE_ENV=development DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true + +SOURCEBOT_LIGHTHOUSE_URL=http://localhost:3003 diff --git a/CHANGELOG.md b/CHANGELOG.md index a71130d57..f0c95111c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added progress bar when navigating between pages. [#1204](https://github.com/sourcebot-dev/sourcebot/pull/1204) + +### Changed +- Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1097](https://github.com/sourcebot-dev/sourcebot/pull/1097) +- Expired offline license keys no longer crash the process. An expired key now degrades to the unlicensed state. [#1109](https://github.com/sourcebot-dev/sourcebot/pull/1109) + ## [4.17.2] - 2026-05-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 44ffd8b4b..ff9177b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,18 @@ if (condition) doSomething(); - Events fired from multiple sources (web app, MCP server, API) must NOT use the `wa_` prefix (e.g., `ask_message_sent`, `tool_used`). - Multi-source events should include a `source` property to identify the origin (e.g., `'sourcebot-web-client'`, `'sourcebot-mcp-server'`, `'sourcebot-ask-agent'`). +## Conditional ClassNames + +Use `cn()` from `@/lib/utils` for conditional classNames instead of template literal interpolation: + +```tsx +// Correct +className={cn("border-b transition-colors", isActive ? "border-foreground" : "border-transparent")} + +// Incorrect +className={`border-b transition-colors ${isActive ? "border-foreground" : "border-transparent"}`} +``` + ## Tailwind CSS Use Tailwind color classes directly instead of CSS variable syntax: diff --git a/docker-compose.yml b/docker-compose.yml index 5e954067c..e0272ba35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: volumes: - ./config.json:/data/config.json - sourcebot_data:/data + env_file: + - path: .env + required: false environment: - CONFIG_PATH=/data/config.json - AUTH_URL=${AUTH_URL:-http://localhost:3000} @@ -22,7 +25,6 @@ services: - SOURCEBOT_ENCRYPTION_KEY=${SOURCEBOT_ENCRYPTION_KEY:-000000000000000000000000000000000} # CHANGEME: generate via `openssl rand -base64 24` - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/postgres} # CHANGEME - REDIS_URL=${REDIS_URL:-redis://redis:6379} # CHANGEME - - SOURCEBOT_EE_LICENSE_KEY=${SOURCEBOT_EE_LICENSE_KEY:-} # For the full list of environment variables see: # https://docs.sourcebot.dev/docs/configuration/environment-variables diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json index 0a235eaf7..4c6cd8bc6 100644 --- a/docs/api-reference/sourcebot-public.openapi.json +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -1147,8 +1147,7 @@ "type": "string", "enum": [ "OWNER", - "MEMBER", - "GUEST" + "MEMBER" ] }, "createdAt": { diff --git a/docs/docs.json b/docs/docs.json index d70056533..82679013a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -126,6 +126,7 @@ ] }, "docs/license-key", + "docs/billing", "docs/configuration/transactional-emails", "docs/configuration/structured-logging", "docs/configuration/audit-logs" diff --git a/docs/docs/billing.mdx b/docs/docs/billing.mdx new file mode 100644 index 000000000..96cd0c120 --- /dev/null +++ b/docs/docs/billing.mdx @@ -0,0 +1,38 @@ +--- +title: Billing +sidebarTitle: Billing +--- + +Sourcebot Enterprise is available on monthly and yearly plans. Both are seat-based. This page explains how seats are billed, how changes mid-term are handled, and what happens at renewal. + +## Seat count +Your seat count is the number of active users in your Sourcebot instance. Seat usage is reported to Sourcebot on a daily interval, and your subscription quantity is kept in sync with that number. + +## Monthly plans +Monthly subscriptions are billed at the start of each billing cycle. Users added mid-cycle are prorated across the remaining days and appear on your next invoice. Users removed mid-cycle take effect at the next cycle. There is no refund for the remainder of the current one. + +In short: you can scale up at any time and pay the prorated difference. Scaling down is effectively free until the cycle rolls over. + +## Yearly plans + +Yearly subscriptions are billed upfront for a committed seat count. As users are added during the term, the seat count rises but you aren't charged immediately. Every three months we reconcile. Any seats added that quarter are billed, prorated across the quarters remaining in the term. + +Seats only move upward during the term. Shrinking the user count does not refund, and does not reduce the seat count until renewal. At renewal, you're invoiced at your current seat count, and that number becomes the committed baseline for the next year. + +### Example + +Suppose you start a yearly plan in January with 100 seats. + +- In Q1, your user count grows to 110. At the end of Q1, you're invoiced for 10 seats prorated across the 3 remaining quarters. +- In Q2, your user count stays at 110. No reconciliation invoice is generated. +- In Q3, your user count grows to 120. At the end of Q3, you're invoiced for 10 seats prorated across the 1 remaining quarter. +- In Q4, reconciliation does not generate a charge (there are no remaining quarters to prorate across). +- At renewal in January, you're invoiced at 120 seats for the next year. 120 becomes the new committed baseline. + +## Cancellation + +Cancelling a subscription takes effect at the end of the current billing cycle (monthly) or term (yearly). You retain access to Sourcebot Enterprise features until that point. + +## Questions? + +For billing questions, [contact us](mailto:support@sourcebot.dev). diff --git a/docs/docs/deployment/docker-compose.mdx b/docs/docs/deployment/docker-compose.mdx index 15055a8cc..8362bbeda 100644 --- a/docs/docs/deployment/docker-compose.mdx +++ b/docs/docs/deployment/docker-compose.mdx @@ -2,59 +2,68 @@ title: "Docker Compose" --- -This guide will walk you through deploying Sourcebot locally or on a VM using Docker Compose. We will use the [docker-compose.yml](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml) file from the [Sourcebot repository](https://github.com/sourcebot-dev/sourcebot). This is the simplest way to get started with Sourcebot. +This guide will walk you through deploying Sourcebot locally or on a VM using [Docker Compose](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml). This is the simplest way to get started with Sourcebot. If you are looking to deploy onto Kubernetes, see the [Kubernetes (Helm)](/docs/deployment/k8s) guide. -## Get started - - - - - docker & docker compose. Use [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Mac or Windows. - - - Download the [docker-compose.yml](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml) file from the Sourcebot repository. - - ```bash wrap icon="terminal" - curl -o docker-compose.yml https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/docker-compose.yml - ``` - - - - - In the same directory as the `docker-compose.yml` file, create a [configuration file](/docs/configuration/config-file). The configuration file is a JSON file that configures Sourcebot's behaviour, including what repositories to index, language model providers, auth providers, and more. - - ```bash wrap icon="terminal" Create example config - touch config.json - echo '{ - "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", - // Comments are supported. - // This config creates a single connection to GitHub.com that - // indexes the Sourcebot repository - "connections": { - "starter-connection": { - "type": "github", - "repos": [ - "sourcebot-dev/sourcebot" - ] - } - } - }' > config.json - ``` - - - - Update the secrets in the `docker-compose.yml` and then run Sourcebot using: - - ```bash wrap icon="terminal" - docker compose up - ``` - - - - You're all set! Navigate to [http://localhost:3000](http://localhost:3000) to access your Sourcebot instance. - - +## System requirements +- RAM: Ensure your environment has at least 4GB of RAM. Insufficient memory can cause processes to crash. +- Docker & Docker Compose: Make sure both are installed and up-to-date. +- Node.js 18+: Required for the setup CLI + +## Option 1: Setup CLI + +The setup CLI will guide you through configuring your Sourcebot instance to connect to your code hosts and LLM providers. From a empty folder, run the following command: + +``` +npx setup-sourcebot +``` + + + npx setup-sourcebot + + +## Option 2: Manual steps + +### Obtain the Docker Compose file + +Download the [docker-compose.yml](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml) file from the Sourcebot repository. + +```bash wrap icon="terminal" +curl -o docker-compose.yml https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/docker-compose.yml +``` + +### Create a config.json + +In the same directory as the `docker-compose.yml` file, create a [configuration file](/docs/configuration/config-file). The configuration file is a JSON file that configures Sourcebot's behaviour, including what repositories to index, language model providers, auth providers, and more. + +```bash wrap icon="terminal" Create example config +touch config.json +echo '{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + // Comments are supported. + // This config creates a single connection to GitHub.com that + // indexes the Sourcebot repository + "connections": { + "starter-connection": { + "type": "github", + "repos": [ + "sourcebot-dev/sourcebot" + ] + } + } +}' > config.json +``` + +### Launch your instance + +Update the secrets in the `docker-compose.yml` and then run Sourcebot using: + +```bash wrap icon="terminal" +docker compose up +``` + +Navigate to [http://localhost:3000](http://localhost:3000) to access your Sourcebot instance. ## Next steps diff --git a/docs/docs/license-key.mdx b/docs/docs/license-key.mdx index e1f565b0b..ecc4eebda 100644 --- a/docs/docs/license-key.mdx +++ b/docs/docs/license-key.mdx @@ -45,4 +45,6 @@ docker run \ ## Questions? -If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file +For how seats are priced and reconciled across billing cycles, see [Billing](/docs/billing). + +For any other licensing questions, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file diff --git a/docs/images/setup_sourcebot_splash.png b/docs/images/setup_sourcebot_splash.png new file mode 100644 index 000000000..fadd9cc5b Binary files /dev/null and b/docs/images/setup_sourcebot_splash.png differ diff --git a/packages/backend/src/__mocks__/prisma.ts b/packages/backend/src/__mocks__/prisma.ts new file mode 100644 index 000000000..7a07a85b4 --- /dev/null +++ b/packages/backend/src/__mocks__/prisma.ts @@ -0,0 +1,7 @@ +import { vi } from 'vitest'; + +export const prisma = { + license: { + findUnique: vi.fn().mockResolvedValue(null), + }, +}; diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index 3a5b40cd0..0fde426bf 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -1,5 +1,6 @@ import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db'; -import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { createLogger, env, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared'; +import { hasEntitlement } from './entitlements.js'; import express, { Request, Response } from 'express'; import 'express-async-errors'; import * as http from "http"; @@ -100,7 +101,7 @@ export class Api { } private async triggerAccountPermissionSync(req: Request, res: Response) { - if (env.PERMISSION_SYNC_ENABLED !== 'true' || !hasEntitlement('permission-syncing')) { + if (env.PERMISSION_SYNC_ENABLED !== 'true' || !await hasEntitlement('permission-syncing')) { res.status(403).json({ error: 'Permission syncing is not enabled.' }); return; } diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index f8e32b745..1e4bfff15 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,6 +1,7 @@ import * as Sentry from "@sentry/node"; import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; -import { env, hasEntitlement, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { env, createLogger, loadConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { ensureFreshAccountToken } from "./tokenRefresh.js"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -50,8 +51,8 @@ export class AccountPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 8f2c348ce..fc69e9aef 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -1,7 +1,8 @@ import * as Sentry from "@sentry/node"; import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { Job, Queue, Worker } from 'bullmq'; import { Redis } from 'ioredis'; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; @@ -44,8 +45,8 @@ export class RepoPermissionSyncer { this.worker.on('failed', this.onJobFailed.bind(this)); } - public startScheduler() { - if (!hasEntitlement('permission-syncing')) { + public async startScheduler() { + if (!await hasEntitlement('permission-syncing')) { throw new Error('Permission syncing is not supported in current plan.'); } diff --git a/packages/backend/src/ee/syncSearchContexts.test.ts b/packages/backend/src/ee/syncSearchContexts.test.ts index bfd2f8b1f..9aa1decfd 100644 --- a/packages/backend/src/ee/syncSearchContexts.test.ts +++ b/packages/backend/src/ee/syncSearchContexts.test.ts @@ -12,12 +12,15 @@ vi.mock('@sourcebot/shared', async (importOriginal) => { error: vi.fn(), debug: vi.fn(), })), - hasEntitlement: vi.fn(() => true), - getPlan: vi.fn(() => 'enterprise'), SOURCEBOT_SUPPORT_EMAIL: 'support@sourcebot.dev', }; }); +vi.mock('../entitlements.js', () => ({ + hasEntitlement: vi.fn(() => Promise.resolve(true)), + getPlan: vi.fn(() => Promise.resolve('enterprise')), +})); + import { syncSearchContexts } from './syncSearchContexts.js'; // Helper to build a repo record with GitLab topics stored in metadata. diff --git a/packages/backend/src/ee/syncSearchContexts.ts b/packages/backend/src/ee/syncSearchContexts.ts index f91977ba7..6993c6c21 100644 --- a/packages/backend/src/ee/syncSearchContexts.ts +++ b/packages/backend/src/ee/syncSearchContexts.ts @@ -1,7 +1,8 @@ import micromatch from "micromatch"; import { createLogger } from "@sourcebot/shared"; import { PrismaClient } from "@sourcebot/db"; -import { getPlan, hasEntitlement, repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { repoMetadataSchema, SOURCEBOT_SUPPORT_EMAIL } from "@sourcebot/shared"; +import { hasEntitlement } from "../entitlements.js"; import { SearchContext } from "@sourcebot/schemas/v3/index.type"; const logger = createLogger('sync-search-contexts'); @@ -15,10 +16,9 @@ interface SyncSearchContextsParams { export const syncSearchContexts = async (params: SyncSearchContextsParams) => { const { contexts, orgId, db } = params; - if (!hasEntitlement("search-contexts")) { + if (!await hasEntitlement("search-contexts")) { if (contexts) { - const plan = getPlan(); - logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); + logger.warn(`Skipping search context sync. Reason: "Search contexts are not supported in your current plan. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}."`); } return false; } diff --git a/packages/backend/src/entitlements.ts b/packages/backend/src/entitlements.ts new file mode 100644 index 000000000..7959cd79b --- /dev/null +++ b/packages/backend/src/entitlements.ts @@ -0,0 +1,23 @@ +import { + Entitlement, + _hasEntitlement, + _getEntitlements, +} from "@sourcebot/shared"; +import { prisma } from "./prisma.js"; +import { SINGLE_TENANT_ORG_ID } from "./constants.js"; + +const getLicense = async () => { + return prisma.license.findUnique({ + where: { orgId: SINGLE_TENANT_ORG_ID }, + }); +} + +export const hasEntitlement = async (entitlement: Entitlement): Promise => { + const license = await getLicense(); + return _hasEntitlement(entitlement, license); +} + +export const getEntitlements = async (): Promise => { + const license = await getLicense(); + return _getEntitlements(license); +} diff --git a/packages/backend/src/github.ts b/packages/backend/src/github.ts index f82bb2282..998563408 100644 --- a/packages/backend/src/github.ts +++ b/packages/backend/src/github.ts @@ -4,7 +4,8 @@ import * as Sentry from "@sentry/node"; import { getTokenFromConfig } from "@sourcebot/shared"; import { createLogger } from "@sourcebot/shared"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; -import { env, hasEntitlement } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import micromatch from "micromatch"; import pLimit from "p-limit"; import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js"; @@ -124,7 +125,7 @@ const getOctokitWithGithubApp = async ( url: string | undefined, context: string ): Promise => { - if (!hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { + if (!await hasEntitlement('github-app') || !GithubAppManager.getInstance().appsConfigured()) { return octokit; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 9bd3a63c6..69687ac2f 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,8 +1,9 @@ import "./instrument.js"; import * as Sentry from "@sentry/node"; -import { PrismaClient } from "@sourcebot/db"; -import { createLogger, env, getConfigSettings, getDBConnectionString, hasEntitlement } from "@sourcebot/shared"; +import { createLogger, env, getConfigSettings } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; +import { prisma } from "./prisma.js"; import 'express-async-errors'; import { existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -31,13 +32,6 @@ if (!existsSync(indexPath)) { await mkdir(indexPath, { recursive: true }); } -const prisma = new PrismaClient({ - datasources: { - db: { - url: getDBConnectionString(), - }, - }, -}); try { await redis.ping(); @@ -51,7 +45,7 @@ const promClient = new PromClient(); const settings = await getConfigSettings(env.CONFIG_PATH); -if (hasEntitlement('github-app')) { +if (await hasEntitlement('github-app')) { await GithubAppManager.getInstance().init(prisma); } @@ -66,15 +60,15 @@ connectionManager.startScheduler(); await repoIndexManager.startScheduler(); auditLogPruner.startScheduler(); -if (env.PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) { +if (env.PERMISSION_SYNC_ENABLED === 'true' && !await hasEntitlement('permission-syncing')) { logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.'); process.exit(1); } -else if (env.PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')) { +else if (env.PERMISSION_SYNC_ENABLED === 'true' && await hasEntitlement('permission-syncing')) { if (env.PERMISSION_SYNC_REPO_DRIVEN_ENABLED === 'true') { - repoPermissionSyncer.startScheduler(); + await repoPermissionSyncer.startScheduler(); } - accountPermissionSyncer.startScheduler(); + await accountPermissionSyncer.startScheduler(); } const api = new Api( diff --git a/packages/backend/src/posthog.ts b/packages/backend/src/posthog.ts index b7b82acef..24c04c28b 100644 --- a/packages/backend/src/posthog.ts +++ b/packages/backend/src/posthog.ts @@ -26,6 +26,7 @@ export function captureEvent(event: E, properties: Posth sourcebot_version: SOURCEBOT_VERSION, install_id: env.SOURCEBOT_INSTALL_ID, }, + groups: { company: env.SOURCEBOT_INSTALL_ID }, }); } diff --git a/packages/backend/src/prisma.ts b/packages/backend/src/prisma.ts new file mode 100644 index 000000000..325d50db7 --- /dev/null +++ b/packages/backend/src/prisma.ts @@ -0,0 +1,10 @@ +import { PrismaClient } from "@sourcebot/db"; +import { getDBConnectionString } from "@sourcebot/shared"; + +export const prisma = new PrismaClient({ + datasources: { + db: { + url: getDBConnectionString(), + }, + }, +}); diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index a69508515..d99727ea3 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -5,7 +5,7 @@ import { getTokenFromConfig } from "@sourcebot/shared"; import * as Sentry from "@sentry/node"; import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, BitbucketConnectionConfig, AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/connection.type'; import { GithubAppManager } from "./ee/githubAppManager.js"; -import { hasEntitlement } from "@sourcebot/shared"; +import { hasEntitlement } from "./entitlements.js"; import { StatusCodes } from "http-status-codes"; import { isOctokitRequestError } from "./github.js"; @@ -116,7 +116,7 @@ export const fetchWithRetry = async ( // may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referencing. export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logger?: Logger): Promise => { // If we have github apps configured we assume that we must use them for github service auth - if (repo.external_codeHostType === 'github' && hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { + if (repo.external_codeHostType === 'github' && await hasEntitlement('github-app') && GithubAppManager.getInstance().appsConfigured()) { logger?.debug(`Using GitHub App for service auth for repo ${repo.displayName} hosted at ${repo.external_codeHostUrl}`); const owner = repo.displayName?.split('/')[0]; diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts index 5b2ab0d5d..6bdbe819b 100644 --- a/packages/backend/vitest.config.ts +++ b/packages/backend/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import path from 'path'; export default defineConfig({ test: { @@ -6,6 +7,9 @@ export default defineConfig({ watch: false, env: { DATA_CACHE_DIR: 'test-data' - } + }, + alias: { + './prisma.js': path.resolve(__dirname, 'src/__mocks__/prisma.ts'), + }, } }); \ No newline at end of file diff --git a/packages/db/prisma/migrations/20260502000000_remove_guest_org_role/migration.sql b/packages/db/prisma/migrations/20260502000000_remove_guest_org_role/migration.sql new file mode 100644 index 000000000..036828359 --- /dev/null +++ b/packages/db/prisma/migrations/20260502000000_remove_guest_org_role/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - The values [GUEST] on the enum `OrgRole` will be removed. If these variants are still used in the database, this will fail. + +*/ + +-- Remove the guest user and its membership (only holder of GUEST role) +DELETE FROM "UserToOrg" WHERE "role" = 'GUEST'; +DELETE FROM "User" WHERE id = '1'; + +-- AlterEnum +BEGIN; +CREATE TYPE "OrgRole_new" AS ENUM ('OWNER', 'MEMBER'); +ALTER TABLE "UserToOrg" ALTER COLUMN "role" DROP DEFAULT; +ALTER TABLE "UserToOrg" ALTER COLUMN "role" TYPE "OrgRole_new" USING ("role"::text::"OrgRole_new"); +ALTER TYPE "OrgRole" RENAME TO "OrgRole_old"; +ALTER TYPE "OrgRole_new" RENAME TO "OrgRole"; +DROP TYPE "OrgRole_old"; +ALTER TABLE "UserToOrg" ALTER COLUMN "role" SET DEFAULT 'MEMBER'; +COMMIT; diff --git a/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql b/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql new file mode 100644 index 000000000..af5d848e7 --- /dev/null +++ b/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql @@ -0,0 +1,34 @@ +-- AlterTable +ALTER TABLE "Org" ADD COLUMN "trialUsedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "License" ( + "id" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + "activationCode" TEXT NOT NULL, + "entitlements" TEXT[], + "seats" INTEGER, + "status" TEXT, + "planName" TEXT, + "unitAmount" INTEGER, + "currency" TEXT, + "interval" TEXT, + "intervalCount" INTEGER, + "nextRenewalAt" TIMESTAMP(3), + "nextRenewalAmount" INTEGER, + "cancelAt" TIMESTAMP(3), + "trialEnd" TIMESTAMP(3), + "hasPaymentMethod" BOOLEAN, + "lastSyncAt" TIMESTAMP(3), + "lastSyncErrorCode" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "License_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "License_orgId_key" ON "License"("orgId"); + +-- AddForeignKey +ALTER TABLE "License" ADD CONSTRAINT "License_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c81bfd62d..7e1af6be7 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,12 +291,41 @@ model Org { searchContexts SearchContext[] chats Chat[] + + license License? + + /// Set the first time this instance is seen to be on a trial subscription. + /// Never cleared. Used to gate the "Start trial" CTA in the UI. + trialUsedAt DateTime? +} + +model License { + id String @id @default(cuid()) + orgId Int @unique + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + activationCode String + entitlements String[] + seats Int? + status String? /// See LicenseStatus in packages/shared/src/types.ts + planName String? + unitAmount Int? + currency String? + interval String? + intervalCount Int? + nextRenewalAt DateTime? + nextRenewalAmount Int? + cancelAt DateTime? + trialEnd DateTime? + hasPaymentMethod Boolean? + lastSyncAt DateTime? + lastSyncErrorCode String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum OrgRole { OWNER MEMBER - GUEST } model UserToOrg { diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts index 9732b9a84..599409053 100644 --- a/packages/db/tools/scriptRunner.ts +++ b/packages/db/tools/scriptRunner.ts @@ -7,6 +7,7 @@ import { injectUserData } from "./scripts/inject-user-data"; import { confirmAction } from "./utils"; import { injectRepoData } from "./scripts/inject-repo-data"; import { testRepoQueryPerf } from "./scripts/test-repo-query-perf"; +import { injectChatData } from "./scripts/inject-chat-data"; export interface Script { run: (prisma: PrismaClient) => Promise; @@ -19,12 +20,13 @@ export const scripts: Record = { "inject-user-data": injectUserData, "inject-repo-data": injectRepoData, "test-repo-query-perf": testRepoQueryPerf, + "inject-chat-data": injectChatData, } const parser = new ArgumentParser(); parser.add_argument("--url", { required: true, help: "Database URL" }); parser.add_argument("--script", { required: true, help: "Script to run" }); -const args = parser.parse_args(); +const [args] = parser.parse_known_args(); (async () => { if (!(args.script in scripts)) { diff --git a/packages/db/tools/scripts/inject-chat-data.ts b/packages/db/tools/scripts/inject-chat-data.ts new file mode 100644 index 000000000..89c0befa4 --- /dev/null +++ b/packages/db/tools/scripts/inject-chat-data.ts @@ -0,0 +1,86 @@ +import { Script } from "../scriptRunner"; +import { PrismaClient } from "../../dist"; +import { confirmAction } from "../utils"; + +const chatNames = [ + "How does the auth middleware work?", + "Explain the search indexing pipeline", + "Where are API routes defined?", + "How to add a new database migration", + "What is the repo sync process?", + "Understanding the chat architecture", + "How does SSO integration work?", + "Explain the permission model", + "Where is the webhook handler?", + "How to configure environment variables", + "Understanding the billing system", + "How does the worker process jobs?", + "Explain the caching strategy", + "Where are the shared types defined?", + "How does code search ranking work?", + "Understanding the notification system", + "How to add a new API endpoint", + "Explain the deployment pipeline", + "Where is error handling centralized?", + "How does real-time updates work?", + "Understanding the plugin system", + "How to write integration tests", + "Explain the file indexing process", + "Where are the email templates?", + "How does rate limiting work?", + "Understanding the monorepo structure", + "How to add a new feature flag", + "Explain the logging setup", + "Where is the GraphQL schema?", + "How does the sidebar component work?", +]; + +export const injectChatData: Script = { + run: async (prisma: PrismaClient) => { + const orgId = 1; + + const org = await prisma.org.findUnique({ + where: { id: orgId } + }); + + if (!org) { + console.error(`Organization with id ${orgId} not found.`); + return; + } + + const userIdArg = process.argv.find(arg => arg.startsWith("--user-id="))?.split("=")[1]; + + const user = userIdArg + ? await prisma.user.findUnique({ where: { id: userIdArg } }) + : await prisma.user.findFirst({ + where: { + orgs: { + some: { orgId } + } + } + }); + + if (!user) { + console.error(userIdArg + ? `User with id "${userIdArg}" not found.` + : `No user found in org ${orgId}.` + ); + return; + } + + await confirmAction(`This will create ${chatNames.length} chats for user "${user.name ?? user.email}" in org ${orgId}.`); + + for (const name of chatNames) { + await prisma.chat.create({ + data: { + name, + orgId, + createdById: user.id, + messages: [], + } + }); + } + + console.log(`Created ${chatNames.length} chats.`); + } +}; diff --git a/packages/setupWizard/README.md b/packages/setupWizard/README.md new file mode 100644 index 000000000..ddd2be912 --- /dev/null +++ b/packages/setupWizard/README.md @@ -0,0 +1,25 @@ +# setup-sourcebot + +Interactive CLI wizard for setting up a self-hosted [Sourcebot](https://sourcebot.dev) instance. + +## Usage + +Run from an empty directory: + +```bash +npx setup-sourcebot +``` + +The wizard walks you through: + +- **Code hosts** — GitHub, GitLab, Bitbucket (Cloud or Data Center), Azure DevOps (Cloud or Server), Gitea, Gerrit, a local folder of cloned repos, or any other git URL. +- **AI providers** (optional) — Anthropic, OpenAI, Google Gemini, Google Vertex, DeepSeek, Mistral, xAI, OpenRouter, OpenAI-compatible endpoints, Amazon Bedrock, or Azure OpenAI. Powers [Ask](https://docs.sourcebot.dev/docs/features/ask/overview). + +## Requirements + +- Node.js 18+ +- Docker and Docker Compose + +## Docs + +Full deployment guide: [docs.sourcebot.dev/docs/deployment/docker-compose](https://docs.sourcebot.dev/docs/deployment/docker-compose) diff --git a/packages/setupWizard/package.json b/packages/setupWizard/package.json new file mode 100644 index 000000000..1a5748e72 --- /dev/null +++ b/packages/setupWizard/package.json @@ -0,0 +1,32 @@ +{ + "name": "setup-sourcebot", + "version": "0.1.2", + "description": "CLI wizard for creating a Sourcebot configuration.", + "type": "module", + "bin": "./dist/index.js", + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "dev": "tsx src/index.ts", + "prepublishOnly": "yarn build" + }, + "dependencies": { + "@inquirer/prompts": "^8.4.3", + "chalk": "^5.6.2", + "inquirer-select-pro": "^1.0.0-alpha.9", + "ora": "^9.4.0" + }, + "devDependencies": { + "@sourcebot/schemas": "workspace:^", + "@types/node": "^22.7.5", + "tsx": "^4.21.0", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist", + "README.md" + ] +} diff --git a/packages/setupWizard/src/azuredevops.ts b/packages/setupWizard/src/azuredevops.ts new file mode 100644 index 000000000..085b77d67 --- /dev/null +++ b/packages/setupWizard/src/azuredevops.ts @@ -0,0 +1,99 @@ +import { checkbox, confirm, input, password, select } from '@inquirer/prompts'; +import type { AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/azuredevops.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { multiInput, note, toEnvKey } from './utils.js'; + +export async function collectAzureDevOpsConfig(connectionName: string): Promise { + const env: EnvVars = {}; + + const deploymentType = await select<'cloud' | 'server'>({ + message: 'Which Azure DevOps deployment?', + choices: [ + { value: 'cloud', name: 'Azure DevOps Cloud', description: 'dev.azure.com' }, + { value: 'server', name: 'Azure DevOps Server', description: 'self-hosted' }, + ], + }); + + const config: AzureDevOpsConnectionConfig = { + type: 'azuredevops', + deploymentType, + token: { env: '' }, + }; + + if (deploymentType === 'server') { + const url = await input({ + message: 'Azure DevOps Server URL (e.g. https://ado.example.com)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + config.url = url; + + const useTfsPath = await confirm({ + message: 'Use legacy TFS path format (/tfs in API URLs)?', + default: false, + }); + if (useTfsPath) { + config.useTfsPath = true; + } + } + + note( + [ + 'Create a Personal Access Token at:', + deploymentType === 'cloud' + ? ' https://dev.azure.com//_usersSettings/tokens' + : ' /_usersSettings/tokens', + 'Grant `Code (Read)` scope so Sourcebot can find and clone your repos.', + ].join('\n'), + 'Azure DevOps Personal Access Token', + ); + + const envKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `Azure DevOps Personal Access Token (stored as ${envKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[envKey] = token; + config.token = { env: envKey }; + + const orgLabel = deploymentType === 'cloud' ? 'organization' : 'collection'; + const orgLabelPlural = deploymentType === 'cloud' ? 'Organizations' : 'Collections'; + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'orgs', name: orgLabelPlural, description: `all projects in a ${orgLabel}` }, + { value: 'projects', name: 'Specific projects', description: `${orgLabel}/project format` }, + { value: 'repos', name: 'Specific repositories', description: `${orgLabel}/project/repo format` }, + ], + required: true, + }); + + if (targets.includes('orgs')) { + config.orgs = await multiInput({ + message: `${orgLabelPlural} to index`, + }); + } + + if (targets.includes('projects')) { + config.projects = await multiInput({ + message: `Projects to index (${orgLabel}/project)`, + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: `Repositories to index (${orgLabel}/project/repo)`, + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/setupWizard/src/bitbucket.ts b/packages/setupWizard/src/bitbucket.ts new file mode 100644 index 000000000..83035dc16 --- /dev/null +++ b/packages/setupWizard/src/bitbucket.ts @@ -0,0 +1,235 @@ +import { checkbox, confirm, input, password, select } from '@inquirer/prompts'; +import type { BitbucketConnectionConfig } from '@sourcebot/schemas/v3/bitbucket.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { multiInput, note, toEnvKey } from './utils.js'; + +export async function collectBitbucketConfig(connectionName: string): Promise { + const env: EnvVars = {}; + + const deploymentType = await select<'cloud' | 'server'>({ + message: 'Which Bitbucket deployment?', + choices: [ + { value: 'cloud', name: 'Bitbucket Cloud', description: 'bitbucket.org' }, + { value: 'server', name: 'Bitbucket Data Center', description: 'self-hosted' }, + ], + }); + + const config: BitbucketConnectionConfig = { + type: 'bitbucket', + deploymentType, + }; + + if (deploymentType === 'cloud') { + return collectBitbucketCloud(connectionName, config, env); + } + return collectBitbucketServer(connectionName, config, env); +} + +async function collectBitbucketCloud( + connectionName: string, + config: BitbucketConnectionConfig, + env: EnvVars, +): Promise { + const authMethod = await select<'api-token' | 'access-token' | 'app-password'>({ + message: 'How will you authenticate?', + choices: [ + { value: 'api-token', name: 'API Token', description: 'Recommended by Atlassian' }, + { value: 'access-token', name: 'Access Token', description: 'Scoped to a repo, project, or workspace' }, + { value: 'app-password', name: 'App Password (deprecated)', description: 'Deprecated by Atlassian' }, + ], + }); + + if (authMethod === 'api-token') { + note( + 'The email you use to sign in to Atlassian (e.g. you@example.com).', + 'Atlassian account email', + ); + + const email = await input({ + message: 'Atlassian account email', + validate: (v) => !v?.trim() ? 'Email is required' : true, + }); + config.user = email; + + note( + [ + 'Your Bitbucket username (separate from your Atlassian email).', + ' Find it at: https://bitbucket.org/account/settings/', + ].join('\n'), + 'Bitbucket username', + ); + + const gitUser = await input({ + message: 'Bitbucket username', + validate: (v) => !v?.trim() ? 'Username is required' : true, + }); + config.gitUser = gitUser; + + note( + [ + 'Create an API Token at:', + ' https://id.atlassian.com/manage-profile/security/api-tokens', + 'Click "Create API token with scopes", choose Bitbucket, and grant:', + ' read:repository:bitbucket', + ' read:workspace:bitbucket', + ].join('\n'), + 'Bitbucket Cloud API Token', + ); + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `API Token (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } else if (authMethod === 'access-token') { + note( + [ + 'Create an Access Token scoped to a repo, project, or workspace.', + ' https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/', + ].join('\n'), + 'Create a Bitbucket Cloud Access Token', + ); + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `Access Token (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } else { + note( + [ + '⚠ App Passwords are deprecated. Prefer an API Token if possible.', + '', + 'Create an App Password:', + ' https://bitbucket.org/account/settings/app-passwords/new', + ' Required permissions: Repositories (read), Workspaces (read)', + ].join('\n'), + 'Create a Bitbucket Cloud App Password', + ); + + const username = await input({ + message: 'Bitbucket username', + validate: (v) => !v?.trim() ? 'Username is required' : true, + }); + config.user = username; + + const tokenEnvKey = toEnvKey(connectionName, 'APP_PASSWORD'); + const token = await password({ + message: `Bitbucket App Password (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'App Password is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'workspaces', name: 'Workspaces', description: 'Index every repo each chosen workspace owns' }, + { value: 'repos', name: 'Specific repositories', description: 'Hand-pick individual repos to index' }, + ], + required: true, + }); + + if (targets.includes('workspaces')) { + config.workspaces = await multiInput({ + message: 'Workspaces to index', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (workspace/repo)', + }); + } + + return { connections: [{ config }], env }; +} + +async function collectBitbucketServer( + connectionName: string, + config: BitbucketConnectionConfig, + env: EnvVars, +): Promise { + const url = await input({ + message: 'Bitbucket Data Center URL (e.g. https://bitbucket.example.com)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + config.url = url; + + note( + [ + 'Create an HTTP Access Token:', + ' Profile → Manage account → HTTP access tokens', + ' Required permissions: Project read, Repository read', + '', + 'Use a user-account token for cross-project access,', + 'or a project/repository-scoped token for narrower access.', + ].join('\n'), + 'Create a Bitbucket Data Center HTTP Access Token', + ); + + const username = await input({ + message: 'Bitbucket username (leave blank if using a project/repo-scoped token)', + }); + if (username.trim()) { + config.user = username; + } + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `Bitbucket HTTP Access Token (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + + const indexAll = await confirm({ + message: 'Index every repository visible to the token?', + default: false, + }); + + if (indexAll) { + config.all = true; + return { connections: [{ config }], env }; + } + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'projects', name: 'Projects', description: 'Index every repo in each chosen project' }, + { value: 'repos', name: 'Specific repositories', description: 'Hand-pick individual repos to index' }, + ], + required: true, + }); + + if (targets.includes('projects')) { + config.projects = await multiInput({ + message: 'Project keys to index (e.g. MYPROJ)', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (project/repo)', + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/setupWizard/src/genericGit.ts b/packages/setupWizard/src/genericGit.ts new file mode 100644 index 000000000..5f636220e --- /dev/null +++ b/packages/setupWizard/src/genericGit.ts @@ -0,0 +1,25 @@ +import { input } from '@inquirer/prompts'; +import type { GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/genericGitHost.type'; +import type { CollectResult } from './utils.js'; + +export async function collectGenericGitConfig(): Promise { + const url = await input({ + message: 'Git clone URL (e.g. https://github.com/sourcebot-dev/sourcebot)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + + const config: GenericGitHostConnectionConfig = { + type: 'git', + url, + }; + + return { connections: [{ config }], env: {} }; +} diff --git a/packages/setupWizard/src/gerrit.ts b/packages/setupWizard/src/gerrit.ts new file mode 100644 index 000000000..51ccd97e0 --- /dev/null +++ b/packages/setupWizard/src/gerrit.ts @@ -0,0 +1,37 @@ +import { confirm, input } from '@inquirer/prompts'; +import type { GerritConnectionConfig } from '@sourcebot/schemas/v3/gerrit.type'; +import type { CollectResult } from './utils.js'; +import { multiInput } from './utils.js'; + +export async function collectGerritConfig(): Promise { + const url = await input({ + message: 'Gerrit URL (e.g. https://gerrit.example.com)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + + const config: GerritConnectionConfig = { + type: 'gerrit', + url, + }; + + const indexAll = await confirm({ + message: 'Index all projects?', + default: true, + }); + + if (!indexAll) { + config.projects = await multiInput({ + message: 'Projects to index', + }); + } + + return { connections: [{ config }], env: {} }; +} diff --git a/packages/setupWizard/src/gitea.ts b/packages/setupWizard/src/gitea.ts new file mode 100644 index 000000000..08d716a5e --- /dev/null +++ b/packages/setupWizard/src/gitea.ts @@ -0,0 +1,66 @@ +import { checkbox, input, password } from '@inquirer/prompts'; +import type { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { multiInput, toEnvKey } from './utils.js'; + +export async function collectGiteaConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: GiteaConnectionConfig = { type: 'gitea' }; + + const url = await input({ + message: 'Gitea URL', + default: 'https://gitea.com', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + if (url !== 'https://gitea.com') { + config.url = url; + } + + const giteaEnvKey = toEnvKey(connectionName, 'TOKEN'); + const giteaToken = await password({ + message: `Gitea Access Token (stored as ${giteaEnvKey}, leave blank for public repos only)`, + mask: true, + }); + if (giteaToken.trim()) { + env[giteaEnvKey] = giteaToken; + config.token = { env: giteaEnvKey }; + } + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'orgs', name: 'Organizations' }, + { value: 'repos', name: 'Specific repositories', description: 'owner/repo format' }, + { value: 'users', name: 'Users' }, + ], + required: true, + }); + + if (targets.includes('orgs')) { + config.orgs = await multiInput({ + message: 'Organizations to index', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (owner/repo)', + }); + } + + if (targets.includes('users')) { + config.users = await multiInput({ + message: 'Users to index', + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/setupWizard/src/github.ts b/packages/setupWizard/src/github.ts new file mode 100644 index 000000000..73527c865 --- /dev/null +++ b/packages/setupWizard/src/github.ts @@ -0,0 +1,190 @@ +import { checkbox, input, password } from '@inquirer/prompts'; +import { select as searchSelect, Separator } from 'inquirer-select-pro'; +import type { GithubConnectionConfig } from '@sourcebot/schemas/v3/github.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { note, toEnvKey } from './utils.js'; + +function githubApiBase(url: string): string { + try { + const u = new URL(url); + if (u.hostname === 'github.com') { + return 'https://api.github.com'; + } + return `${u.protocol}//${u.hostname}/api/v3`; + } catch { + return 'https://api.github.com'; + } +} + +type SearchOption = { name: string; value: string }; +type GitHubSearchType = 'org' | 'user' | 'repo'; +const githubSearchCache = new Map>(); +const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/; + +async function searchGitHub( + apiBase: string, + query: string, + token: string, + type: GitHubSearchType, +): Promise> { + const cacheKey = `${apiBase}|${type}|${query}`; + const cached = githubSearchCache.get(cacheKey); + if (cached) { + return cached; + } + + const headers: Record = { + 'User-Agent': 'setup-sourcebot', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const url = type === 'repo' + ? `${apiBase}/search/repositories?q=${encodeURIComponent(query)}&per_page=8` + : `${apiBase}/search/users?q=${encodeURIComponent(query)}+type:${type}&per_page=8`; + const res = await fetch(url, { headers }); + const data = await res.json() as { items?: Array<{ login?: string; full_name?: string }> }; + + const literalFallback = (): SearchOption | null => { + return { name: query, value: query }; + }; + + if (!res.ok) { + const warning = + (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') + ? '⚠ Autocomplete disabled — GitHub rate limit exceeded.' + : '⚠ Autocomplete disabled — authentication failed, check your PAT.'; + const fallback = literalFallback(); + return fallback ? [fallback, new Separator(warning)] : [new Separator(warning)]; + } + + const results: SearchOption[] = (data.items ?? []).map((item) => { + const value = type === 'repo' ? item.full_name! : item.login!; + return { name: value, value }; + }); + if (results.length === 0) { + const fallback = literalFallback(); + return fallback ? [fallback] : []; + } + githubSearchCache.set(cacheKey, results); + return results; +} + +export async function collectGitHubConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: GithubConnectionConfig = { type: 'github' }; + + const url = await input({ + message: 'GitHub URL', + default: 'https://github.com', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + if (url !== 'https://github.com') { + config.url = url; + } + + note( + [ + 'Fine-grained PAT (recommended):', + ` ${url}/settings/personal-access-tokens/new`, + ' Required permissions: Contents (read), Metadata (read)', + '', + 'Classic PAT:', + ` ${url}/settings/tokens/new`, + ' Required scope: repo', + ].join('\n'), + 'Create a GitHub Personal Access Token', + ); + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `GitHub Personal Access Token (stored as ${tokenEnvKey}, leave blank for public repos only)`, + mask: true, + }); + if (token.trim()) { + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } + + const apiBase = githubApiBase(url); + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'repos', name: 'Specific repositories', description: 'Hand-pick individual repos to index' }, + { value: 'orgs', name: 'Organizations', description: 'Index every repo each chosen org owns' }, + { value: 'users', name: 'Users', description: 'Index every repo each chosen user owns' }, + ], + required: true, + }); + + if (targets.includes('repos')) { + const repos = await searchSelect({ + message: 'Repositories to index (type to search, or type owner/repo)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitHub(apiBase, search, token, 'repo'); + }, + validate: (selected) => { + for (const opt of selected) { + if (!REPO_PATTERN.test(opt.value)) { + return `Invalid format: "${opt.value}" — expected owner/repo`; + } + } + return true; + }, + }); + config.repos = repos; + } + + if (targets.includes('orgs')) { + const orgs = await searchSelect({ + message: 'Organizations to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitHub(apiBase, search, token, 'org'); + }, + }); + config.orgs = orgs; + } + + if (targets.includes('users')) { + const users = await searchSelect({ + message: 'GitHub users to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitHub(apiBase, search, token, 'user'); + }, + }); + config.users = users; + } + + return { connections: [{ config }], env }; +} diff --git a/packages/setupWizard/src/gitlab.ts b/packages/setupWizard/src/gitlab.ts new file mode 100644 index 000000000..f50e5b5cd --- /dev/null +++ b/packages/setupWizard/src/gitlab.ts @@ -0,0 +1,208 @@ +import { checkbox, input, password } from '@inquirer/prompts'; +import { select as searchSelect, Separator } from 'inquirer-select-pro'; +import type { GitlabConnectionConfig } from '@sourcebot/schemas/v3/gitlab.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { note, toEnvKey } from './utils.js'; + +function gitlabApiBase(url: string): string { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}/api/v4`; + } catch { + return 'https://gitlab.com/api/v4'; + } +} + +type SearchOption = { name: string; value: string }; +type GitLabSearchType = 'group' | 'project' | 'user'; +const gitlabSearchCache = new Map>(); +const PROJECT_PATTERN = /^[\w.-]+(\/[\w.-]+)+$/; + +async function searchGitLab( + apiBase: string, + query: string, + token: string, + type: GitLabSearchType, +): Promise> { + const cacheKey = `${apiBase}|${type}|${query}`; + const cached = gitlabSearchCache.get(cacheKey); + if (cached) { + return cached; + } + + const headers: Record = { + 'User-Agent': 'setup-sourcebot', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const endpoint = type === 'group' ? 'groups' : type === 'project' ? 'projects' : 'users'; + const extraParams = type === 'project' ? '&simple=true' : ''; + const url = `${apiBase}/${endpoint}?search=${encodeURIComponent(query)}&per_page=8${extraParams}`; + const res = await fetch(url, { headers }); + + const literalFallback = (): SearchOption | null => { + if (type === 'project') { + return PROJECT_PATTERN.test(query) ? { name: query, value: query } : null; + } + return { name: query, value: query }; + }; + + if (!res.ok) { + const warning = res.status === 401 + ? '⚠ Autocomplete disabled — authentication failed, check your PAT.' + : `⚠ Autocomplete disabled — GitLab API error (${res.status}).`; + const fallback = literalFallback(); + return fallback ? [fallback, new Separator(warning)] : [new Separator(warning)]; + } + + const data = await res.json() as Array<{ + full_path?: string; + path_with_namespace?: string; + username?: string; + }>; + + const results: SearchOption[] = data.map((item) => { + let value: string; + if (type === 'group') { + value = item.full_path!; + } else if (type === 'project') { + value = item.path_with_namespace!; + } else { + value = item.username!; + } + return { name: value, value }; + }); + + if (results.length === 0) { + const fallback = literalFallback(); + return fallback ? [fallback] : []; + } + + gitlabSearchCache.set(cacheKey, results); + return results; +} + +export async function collectGitLabConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: GitlabConnectionConfig = { type: 'gitlab' }; + + const url = await input({ + message: 'GitLab URL', + default: 'https://gitlab.com', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + if (url !== 'https://gitlab.com') { + config.url = url; + } + + note( + [ + 'Create a PAT:', + ` ${url}/-/user_settings/personal_access_tokens`, + ' Required scope: read_api', + ].join('\n'), + 'Create a GitLab Personal Access Token', + ); + + const gitlabEnvKey = toEnvKey(connectionName, 'TOKEN'); + const gitlabToken = await password({ + message: `GitLab Personal Access Token (stored as ${gitlabEnvKey}, leave blank for public repos only)`, + mask: true, + }); + if (gitlabToken.trim()) { + env[gitlabEnvKey] = gitlabToken; + config.token = { env: gitlabEnvKey }; + } + + const apiBase = gitlabApiBase(url); + const isSelfHosted = url !== 'https://gitlab.com'; + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + ...(isSelfHosted + ? [{ value: 'all', name: 'Everything', description: 'Index every project visible to the token on this self-hosted instance' }] + : []), + { value: 'groups', name: 'Groups', description: 'Index every project each chosen group owns' }, + { value: 'projects', name: 'Specific projects', description: 'Hand-pick individual projects to index' }, + { value: 'users', name: 'Users', description: 'Index every project each chosen user owns' }, + ], + required: true, + }); + + if (targets.includes('all')) { + config.all = true; + } + + if (targets.includes('groups')) { + const groups = await searchSelect({ + message: 'Groups to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitLab(apiBase, search, gitlabToken, 'group'); + }, + }); + config.groups = groups; + } + + if (targets.includes('projects')) { + const projects = await searchSelect({ + message: 'Projects to index (type to search, or type group/project)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitLab(apiBase, search, gitlabToken, 'project'); + }, + validate: (selected) => { + for (const opt of selected) { + if (!PROJECT_PATTERN.test(opt.value)) { + return `Invalid format: "${opt.value}" — expected group/project`; + } + } + return true; + }, + }); + config.projects = projects; + } + + if (targets.includes('users')) { + const users = await searchSelect({ + message: 'Users to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitLab(apiBase, search, gitlabToken, 'user'); + }, + }); + config.users = users; + } + + return { connections: [{ config }], env }; +} diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts new file mode 100644 index 000000000..6c349e3d1 --- /dev/null +++ b/packages/setupWizard/src/index.ts @@ -0,0 +1,334 @@ +#!/usr/bin/env node +import { confirm, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import ora from 'ora'; +import { spawn } from 'node:child_process'; +import { existsSync, writeFileSync } from 'fs'; +import { writeFile } from 'fs/promises'; +import { collectAzureDevOpsConfig } from './azuredevops.js'; +import { collectBitbucketConfig } from './bitbucket.js'; +import { collectGenericGitConfig } from './genericGit.js'; +import { collectGerritConfig } from './gerrit.js'; +import { collectGiteaConfig } from './gitea.js'; +import { collectGitHubConfig } from './github.js'; +import { collectGitLabConfig } from './gitlab.js'; +import { collectLocalReposConfig } from './localRepos.js'; +import { collectModels, PROVIDER_ENV_KEYS } from './models.js'; +import { + type CollectResult, + type ConnectionConfig, + type EnvVars, + generateConnectionName, + generateSecret, + note, +} from './utils.js'; + +// @nocheckin: change this to main +const DOCKER_COMPOSE_BRANCH = 'v5'; +const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; + +const SOURCEBOT_URL = 'http://localhost:3000'; + +function openBrowser(url: string): void { + const cmd = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'cmd' + : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]; + spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); +} + +async function openBrowserWhenReady(url: string, timeoutMs = 120_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + if (res.status < 500) { + openBrowser(url); + return; + } + } catch { + // not yet ready + } + await new Promise((r) => setTimeout(r, 2000)); + } +} + +const PLATFORM_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + bitbucket: 'Bitbucket', + gitea: 'Gitea', + azuredevops: 'Azure DevOps', + gerrit: 'Gerrit', + local: 'Local Git repositories', + git: 'Other Git host', +}; + +async function main() { + console.log(String.raw` +███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗██████╗ ██████╗ ████████╗ +██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔═══██╗╚══██╔══╝ +███████╗██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝██║ ██║ ██║ +╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝ ██╔══██╗██║ ██║ ██║ +███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗██████╔╝╚██████╔╝ ██║██╗ +╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝╚═╝ +`); + + const connections: Record = {}; + const allEnv: EnvVars = {}; + const localRepoIndex = new Map(); + + // eslint-disable-next-line no-constant-condition + while (true) { + const platform = await select({ + message: 'Which code host do you want to connect?', + loop: false, + choices: [ + { value: 'github', name: 'GitHub', description: 'github.com, GitHub Enterprise Server, or GitHub Enterprise Cloud' }, + { value: 'gitlab', name: 'GitLab', description: 'gitlab.com, GitLab Self Managed, or GitLab Dedicated' }, + { value: 'local', name: 'Local git repositories', description: 'git repositories in a local directory' }, + { value: 'git', name: 'Remote git repository', description: 'Arbitrary git URL' }, + { value: 'azuredevops', name: 'Azure DevOps', description: 'dev.azure.com or Azure Devops Server' }, + { value: 'bitbucket', name: 'Bitbucket', description: 'Bitbucket Cloud or Bitbucket Data Center' }, + { value: 'gitea', name: 'Gitea', description: 'Gitea Cloud or Gitea self-hosted' }, + { value: 'gerrit', name: 'Gerrit' }, + ], + }); + + const connectionName = generateConnectionName(platform, connections); + + note(`Configuring ${PLATFORM_LABELS[platform] ?? platform}`, connectionName); + + let result: CollectResult; + + switch (platform) { + case 'github': + result = await collectGitHubConfig(connectionName); + break; + case 'gitlab': + result = await collectGitLabConfig(connectionName); + break; + case 'bitbucket': + result = await collectBitbucketConfig(connectionName); + break; + case 'gitea': + result = await collectGiteaConfig(connectionName); + break; + case 'azuredevops': + result = await collectAzureDevOpsConfig(connectionName); + break; + case 'gerrit': + result = await collectGerritConfig(); + break; + case 'local': + result = await collectLocalReposConfig(localRepoIndex); + break; + case 'git': + result = await collectGenericGitConfig(); + break; + default: + continue; + } + + for (const { name, config } of result.connections) { + const finalName = name + ? generateConnectionName(name, connections) + : connectionName; + connections[finalName] = config; + } + Object.assign(allEnv, result.env); + + const addAnother = await confirm({ + message: 'Add another code host?', + default: false, + }); + + if (!addAnother) { + break; + } + } + + const { models, env: modelEnv } = await collectModels(); + Object.assign(allEnv, modelEnv); + + if (existsSync('config.json')) { + const overwrite = await confirm({ + message: 'config.json already exists. Overwrite?', + default: true, + }); + if (!overwrite) { + console.log(); + console.log(chalk.red('✗ ') + 'config.json was not overwritten.'); + process.exit(0); + } + } + + if (existsSync('.env')) { + const overwrite = await confirm({ + message: '.env already exists. Overwrite?', + default: true, + }); + if (!overwrite) { + console.log(); + console.log(chalk.red('✗ ') + '.env was not overwritten.'); + process.exit(0); + } + } + + if (localRepoIndex.size > 0 && existsSync('docker-compose.override.yml')) { + const overwrite = await confirm({ + message: 'docker-compose.override.yml already exists. Overwrite?', + default: true, + }); + if (!overwrite) { + console.log(); + console.log(chalk.red('✗ ') + 'docker-compose.override.yml was not overwritten.'); + process.exit(0); + } + } + + const s = ora('Writing configuration files...').start(); + + const configOutput: Record = { + $schema: 'https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json', + connections, + }; + if (models.length > 0) { + configOutput.models = models; + } + const configJson = JSON.stringify(configOutput, null, 4); + + const connectionEnv = Object.fromEntries( + Object.entries(allEnv).filter(([k]) => !Object.values(PROVIDER_ENV_KEYS).includes(k) && !['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].includes(k)) + ); + const aiEnv = Object.fromEntries( + Object.entries(allEnv).filter(([k]) => Object.values(PROVIDER_ENV_KEYS).includes(k) || ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].includes(k)) + ); + + const envLines: string[] = [ + '# Generated by setup-sourcebot', + '', + '# Auto-generated secrets — do not change after first run', + `AUTH_SECRET=${generateSecret(33)}`, + `SOURCEBOT_ENCRYPTION_KEY=${generateSecret(24)}`, + ]; + + if (Object.keys(connectionEnv).length > 0) { + envLines.push('', '# Code host credentials'); + for (const [key, value] of Object.entries(connectionEnv)) { + envLines.push(`${key}=${value}`); + } + } + + if (Object.keys(aiEnv).length > 0) { + envLines.push('', '# AI provider credentials'); + for (const [key, value] of Object.entries(aiEnv)) { + envLines.push(`${key}=${value}`); + } + } + + writeFileSync('config.json', configJson + '\n'); + writeFileSync('.env', envLines.join('\n') + '\n'); + + const writtenFiles = ['config.json', '.env']; + + if (localRepoIndex.size > 0) { + const mounts = [...localRepoIndex.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([p, i]) => ` - ${p}:/repos/${i}:ro`); + const overrideYaml = [ + '# Generated by setup-sourcebot', + '# Merged with docker-compose.yml at `docker compose up` time.', + 'services:', + ' sourcebot:', + ' volumes:', + ...mounts, + '', + ].join('\n'); + writeFileSync('docker-compose.override.yml', overrideYaml); + writtenFiles.push('docker-compose.override.yml'); + } + + s.succeed(`Wrote ${writtenFiles.join(', ')}`); + + let downloadedCompose = false; + + if (!existsSync('docker-compose.yml')) { + const download = await confirm({ + message: 'Download docker-compose.yml?', + default: true, + }); + + if (download) { + const ds = ora('Downloading docker-compose.yml...').start(); + try { + const res = await fetch(DOCKER_COMPOSE_URL); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + await writeFile('docker-compose.yml', await res.text()); + ds.succeed('Downloaded docker-compose.yml'); + downloadedCompose = true; + } catch { + ds.fail('Download failed — you can get it manually (see next steps)'); + } + } + } else { + downloadedCompose = true; + } + + console.log(); + console.log(chalk.green('✓ ') + chalk.bold('Your Sourcebot configuration is ready!')); + + if (downloadedCompose) { + const startNow = await confirm({ + message: 'Start Sourcebot now? (runs `docker compose up`)', + default: true, + }); + + if (startNow) { + note( + `Sourcebot will open at ${SOURCEBOT_URL} once it's ready.\nPress Ctrl+C to stop.`, + 'Starting Sourcebot', + ); + void openBrowserWhenReady(SOURCEBOT_URL).catch(() => { /* best effort */ }); + await new Promise((resolve) => { + const child = spawn('docker', ['compose', 'up'], { stdio: 'inherit' }); + child.on('exit', () => resolve()); + child.on('error', (err) => { + console.error(chalk.red('✗ ') + 'Failed to run `docker compose up`: ' + (err instanceof Error ? err.message : String(err))); + resolve(); + }); + }); + return; + } + } + + const nextSteps: string[] = []; + let step = 1; + + if (!downloadedCompose) { + nextSteps.push(`${step++}. Download docker-compose.yml:`); + nextSteps.push(` curl -o docker-compose.yml ${DOCKER_COMPOSE_URL}`); + nextSteps.push(''); + } + + nextSteps.push(`${step++}. Start Sourcebot:`); + nextSteps.push(' docker compose up'); + nextSteps.push(''); + nextSteps.push(`${step}. Open http://localhost:3000`); + + note(nextSteps.join('\n'), 'Next steps'); +} + +main().catch(err => { + const isExitPrompt = err instanceof Error + && (err.name === 'ExitPromptError' || err.message?.startsWith('User force closed the prompt')); + if (isExitPrompt) { + console.log(); + console.log(chalk.red('✗ ') + 'Setup cancelled.'); + process.exit(0); + } + console.error(err); + process.exit(1); +}); diff --git a/packages/setupWizard/src/localRepos.ts b/packages/setupWizard/src/localRepos.ts new file mode 100644 index 000000000..d5c7b6e52 --- /dev/null +++ b/packages/setupWizard/src/localRepos.ts @@ -0,0 +1,170 @@ +import { checkbox, input } from '@inquirer/prompts'; +import { existsSync, statSync } from 'fs'; +import { readdir } from 'fs/promises'; +import { homedir } from 'os'; +import { basename, join, relative, resolve } from 'path'; +import ora from 'ora'; +import type { GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/genericGitHost.type'; +import type { CollectResult } from './utils.js'; +import { note } from './utils.js'; + +const MAX_DEPTH = 5; + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + 'target', + 'vendor', + 'coverage', + '__pycache__', +]); + +function expandHostPath(p: string): string { + const trimmed = p.trim(); + if (trimmed.startsWith('~')) { + return resolve(join(homedir(), trimmed.slice(1))); + } + return resolve(trimmed); +} + +async function findGitRepos(root: string, maxDepth: number): Promise { + const repos: string[] = []; + + async function walk(dir: string, depth: number): Promise { + if (existsSync(join(dir, '.git'))) { + repos.push(dir); + return; + } + if (depth >= maxDepth) { + return; + } + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith('.')) { + continue; + } + if (SKIP_DIRS.has(entry.name)) { + continue; + } + await walk(join(dir, entry.name), depth + 1); + } + } + + await walk(root, 0); + return repos.sort(); +} + +export async function collectLocalReposConfig( + localRepoIndex: Map, +): Promise { + note( + [ + 'Point at a directory on your machine that contains git repositories.', + `The wizard will scan up to ${MAX_DEPTH} levels deep and let you pick which to index.`, + 'Local repos are treated as read-only.', + ].join('\n'), + 'Local Git repositories', + ); + + let hostPath: string; + let repos: string[]; + + // eslint-disable-next-line no-constant-condition + while (true) { + const rawPath = await input({ + message: 'Path to your repos directory (e.g. ~/code)', + validate: (v) => { + if (!v?.trim()) { + return 'Path is required'; + } + const resolved = expandHostPath(v); + if (!existsSync(resolved)) { + return `Path does not exist: ${resolved}`; + } + if (!statSync(resolved).isDirectory()) { + return `Not a directory: ${resolved}`; + } + return true; + }, + }); + + hostPath = expandHostPath(rawPath); + + const spinner = ora(`Scanning ${hostPath} for git repositories...`).start(); + repos = await findGitRepos(hostPath, MAX_DEPTH); + if (repos.length === 0) { + spinner.fail(`No git repositories found under ${hostPath}`); + continue; + } + spinner.succeed(`Found ${repos.length} repositor${repos.length === 1 ? 'y' : 'ies'}`); + break; + } + + let index = localRepoIndex.get(hostPath); + if (index === undefined) { + index = localRepoIndex.size; + localRepoIndex.set(hostPath, index); + } + const containerRoot = `/repos/${index}`; + + const hostPathIsRepo = repos.length === 1 && repos[0] === hostPath; + if (hostPathIsRepo) { + return { + connections: [{ + name: basename(hostPath), + config: { + type: 'git', + url: `file://${containerRoot}`, + } satisfies GenericGitHostConnectionConfig, + }], + env: {}, + localRepoHostPath: hostPath, + }; + } + + const choices = repos.map((repoPath) => ({ + name: relative(hostPath, repoPath) || basename(repoPath), + value: repoPath, + checked: true, + })); + + const selected = await checkbox({ + message: 'Which repositories should be indexed?', + choices, + required: true, + pageSize: 15, + loop: false, + }); + + const posixRel = (p: string): string => relative(hostPath, p).split('\\').join('/'); + + const allSelected = selected.length === repos.length; + const allAtDepthOne = repos.every((p) => !posixRel(p).includes('/')); + + const connections = allSelected && allAtDepthOne + ? [{ + config: { + type: 'git', + url: `file://${containerRoot}/*`, + } satisfies GenericGitHostConnectionConfig, + }] + : selected.map((repoPath) => { + const config: GenericGitHostConnectionConfig = { + type: 'git', + url: `file://${containerRoot}/${posixRel(repoPath)}`, + }; + return { name: basename(repoPath), config }; + }); + + return { connections, env: {}, localRepoHostPath: hostPath }; +} diff --git a/packages/setupWizard/src/models.ts b/packages/setupWizard/src/models.ts new file mode 100644 index 000000000..c7b0d4862 --- /dev/null +++ b/packages/setupWizard/src/models.ts @@ -0,0 +1,375 @@ +import { confirm, input, password, select } from '@inquirer/prompts'; +import { select as searchSelect } from 'inquirer-select-pro'; +import type { + AmazonBedrockLanguageModel, + AzureLanguageModel, + GoogleVertexAnthropicLanguageModel, + GoogleVertexLanguageModel, + LanguageModel, + OpenAICompatibleLanguageModel, +} from '@sourcebot/schemas/v3/languageModel.type'; +import { note, type EnvVars } from './utils.js'; + +type Provider = LanguageModel['provider']; + +export const PROVIDER_ENV_KEYS: Record = { + 'anthropic': 'ANTHROPIC_API_KEY', + 'openai': 'OPENAI_API_KEY', + 'google-generative-ai': 'GOOGLE_GENERATIVE_AI_API_KEY', + 'deepseek': 'DEEPSEEK_API_KEY', + 'mistral': 'MISTRAL_API_KEY', + 'xai': 'XAI_API_KEY', + 'openrouter': 'OPENROUTER_API_KEY', + 'openai-compatible': 'OPENAI_COMPATIBLE_API_KEY', + 'azure': 'AZURE_OPENAI_API_KEY', +}; + +// ─── models.dev catalog ──────────────────────────────────────────────────── + +type ModelsDevModel = { + id: string; + name?: string; + release_date?: string; +}; + +type ModelsDevProvider = { + id: string; + name?: string; + models?: Record; +}; + +type ModelsDevCatalog = Record; + +type ModelOption = { + id: string; + name: string; + releaseDate?: string; +}; + +const MODELS_DEV_API_URL = 'https://models.dev/api.json'; +const FETCH_TIMEOUT_MS = 8000; + +const PROVIDER_ID_OVERRIDES: Record = { + 'google-generative-ai': 'google', +}; + +let catalogPromise: Promise | null = null; + +async function loadCatalog(): Promise { + if (!catalogPromise) { + catalogPromise = (async () => { + try { + const response = await fetch(MODELS_DEV_API_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + return null; + } + return await response.json() as ModelsDevCatalog; + } catch { + return null; + } + })(); + } + return catalogPromise; +} + +async function getModelOptionsForProvider(providerKey: string): Promise { + const catalog = await loadCatalog(); + if (!catalog) { + return null; + } + const providerId = PROVIDER_ID_OVERRIDES[providerKey] ?? providerKey; + const provider = catalog[providerId]; + if (!provider || !provider.models) { + return null; + } + const models = Object.values(provider.models); + if (models.length === 0) { + return null; + } + return models + .map((m) => ({ + id: m.id, + name: m.name || m.id, + releaseDate: m.release_date, + })) + .sort((a, b) => { + if (a.releaseDate && b.releaseDate) { + return b.releaseDate.localeCompare(a.releaseDate); + } + if (a.releaseDate) { + return -1; + } + if (b.releaseDate) { + return 1; + } + return a.name.localeCompare(b.name); + }); +} + +// ─── prompts ─────────────────────────────────────────────────────────────── + +async function searchModel(options: { + message: string; + models: ModelOption[]; +}): Promise { + const choices = options.models.map((m) => ({ + name: m.name === m.id ? m.id : `${m.id} · ${m.name}`, + value: m.id, + })); + + const result = await searchSelect({ + message: options.message, + multiple: false, + loop: false, + clearInputWhenSelected: false, + placeholder: 'Type to search models, or enter a custom name', + options: async (search) => { + const trimmed = (search ?? '').trim(); + if (!trimmed) { + return choices; + } + const lowered = trimmed.toLowerCase(); + const filtered = choices.filter((c) => + c.value.toLowerCase().includes(lowered) || c.name.toLowerCase().includes(lowered), + ); + const hasExact = choices.some((c) => c.value === trimmed); + if (!hasExact) { + filtered.unshift({ name: `${trimmed} (custom)`, value: trimmed }); + } + return filtered; + }, + }); + if (result === null) { + throw new Error('Model name is required'); + } + return result; +} + +async function ensureApiKey(provider: Provider, env: EnvVars): Promise { + const envKey = PROVIDER_ENV_KEYS[provider] ?? `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`; + if (!env[envKey]) { + const apiKey = await password({ + message: `API key (stored as ${envKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'API key is required' : true, + }); + env[envKey] = apiKey; + } + return envKey; +} + +async function collectModelConfig( + provider: Provider, + model: string, + env: EnvVars, +): Promise { + switch (provider) { + case 'anthropic': + case 'openai': + case 'google-generative-ai': + case 'deepseek': + case 'mistral': + case 'xai': + case 'openrouter': { + const envKey = await ensureApiKey(provider, env); + return { provider, model, token: { env: envKey } } satisfies LanguageModel; + } + case 'openai-compatible': { + const baseUrl = await input({ + message: 'Base URL (e.g. https://your-endpoint.example.com/v1)', + validate: (v) => { + if (!v?.trim()) { + return 'Base URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + const envKey = await ensureApiKey(provider, env); + const config: OpenAICompatibleLanguageModel = { + provider, + model, + baseUrl, + token: { env: envKey }, + }; + return config; + } + case 'azure': { + const resourceName = await input({ + message: 'Azure resource name', + validate: (v) => !v?.trim() ? 'Resource name is required' : true, + }); + const apiVersion = await input({ + message: 'API version', + default: '2024-08-01-preview', + validate: (v) => !v?.trim() ? 'API version is required' : true, + }); + const envKey = await ensureApiKey(provider, env); + const config: AzureLanguageModel = { + provider, + model, + resourceName, + apiVersion, + token: { env: envKey }, + }; + return config; + } + case 'amazon-bedrock': { + const useDefaultChain = await confirm({ + message: 'Use the default AWS credential chain? (No to provide Access Key ID and Secret explicitly)', + default: true, + }); + + const config: AmazonBedrockLanguageModel = { provider, model }; + + if (!useDefaultChain) { + if (!env['AWS_ACCESS_KEY_ID']) { + env['AWS_ACCESS_KEY_ID'] = await input({ + message: 'AWS Access Key ID (stored as AWS_ACCESS_KEY_ID)', + validate: (v) => !v?.trim() ? 'Access Key ID is required' : true, + }); + } + config.accessKeyId = { env: 'AWS_ACCESS_KEY_ID' }; + + if (!env['AWS_SECRET_ACCESS_KEY']) { + env['AWS_SECRET_ACCESS_KEY'] = await password({ + message: 'AWS Secret Access Key (stored as AWS_SECRET_ACCESS_KEY)', + mask: true, + validate: (v) => !v?.trim() ? 'Secret Access Key is required' : true, + }); + } + config.accessKeySecret = { env: 'AWS_SECRET_ACCESS_KEY' }; + } + + config.region = await input({ + message: 'AWS region', + default: 'us-east-1', + validate: (v) => !v?.trim() ? 'Region is required' : true, + }); + return config; + } + case 'google-vertex': + case 'google-vertex-anthropic': { + if (!env['GOOGLE_VERTEX_PROJECT']) { + env['GOOGLE_VERTEX_PROJECT'] = await input({ + message: 'Google Cloud project ID (stored as GOOGLE_VERTEX_PROJECT)', + validate: (v) => !v?.trim() ? 'Project ID is required' : true, + }); + } + if (!env['GOOGLE_VERTEX_REGION']) { + env['GOOGLE_VERTEX_REGION'] = await input({ + message: 'Google Cloud region (stored as GOOGLE_VERTEX_REGION)', + default: 'us-central1', + validate: (v) => !v?.trim() ? 'Region is required' : true, + }); + } + + const useAppDefault = await confirm({ + message: 'Use Application Default Credentials? (No to provide a service account credentials file path)', + default: true, + }); + + const config: GoogleVertexLanguageModel | GoogleVertexAnthropicLanguageModel = { + provider, + model, + }; + + if (!useAppDefault) { + if (!env['GOOGLE_APPLICATION_CREDENTIALS']) { + env['GOOGLE_APPLICATION_CREDENTIALS'] = await input({ + message: 'Path to service account credentials JSON (stored as GOOGLE_APPLICATION_CREDENTIALS)', + validate: (v) => !v?.trim() ? 'Credentials path is required' : true, + }); + } + config.credentials = { env: 'GOOGLE_APPLICATION_CREDENTIALS' }; + } + return config; + } + } +} + +export async function collectModels(): Promise<{ models: LanguageModel[]; env: EnvVars }> { + const models: LanguageModel[] = []; + const env: EnvVars = {}; + + note( + [ + 'AI features include Ask, which lets you ask questions about your codebase', + 'in natural language and get answers grounded in your indexed code.', + ' https://docs.sourcebot.dev/docs/features/ask/overview', + '', + 'You\'ll need an API key from at least one supported provider', + '(Anthropic, OpenAI, Google, etc.) to enable these features.', + ].join('\n'), + 'AI features', + ); + + const wantsAI = await confirm({ + message: 'Would you like to configure AI features?', + default: true, + }); + + if (!wantsAI) { + return { models, env }; + } + + // eslint-disable-next-line no-constant-condition + while (true) { + const provider = await select({ + message: 'Which AI provider?', + loop: false, + choices: [ + { value: 'anthropic', name: 'Anthropic' }, + { value: 'openai', name: 'OpenAI' }, + { value: 'openai-compatible', name: 'OpenAI-compatible', description: 'self-hosted / custom endpoint' }, + { value: 'amazon-bedrock', name: 'Amazon Bedrock' }, + { value: 'google-generative-ai', name: 'Google Gemini' }, + { value: 'google-vertex', name: 'Google Vertex AI', description: 'Gemini via Vertex' }, + { value: 'google-vertex-anthropic', name: 'Google Vertex AI (Anthropic)', description: 'Claude via Vertex' }, + { value: 'azure', name: 'Azure OpenAI' }, + { value: 'deepseek', name: 'DeepSeek' }, + { value: 'mistral', name: 'Mistral' }, + { value: 'openrouter', name: 'OpenRouter' }, + { value: 'xai', name: 'xAI', description: 'Grok' }, + ], + }); + + const modelOptions = provider === 'openai-compatible' + ? null + : await getModelOptionsForProvider(provider); + const model = modelOptions && modelOptions.length > 0 + ? await searchModel({ + message: 'Model name', + models: modelOptions, + }) + : await input({ + message: 'Model name', + validate: (v) => !v?.trim() ? 'Model name is required' : true, + }); + + const config = await collectModelConfig(provider, model, env); + + const displayName = (await input({ + message: 'Display name (optional, press enter to skip)', + })).trim(); + if (displayName) { + config.displayName = displayName; + } + models.push(config); + + const addAnother = await confirm({ + message: 'Add another model?', + default: false, + }); + + if (!addAnother) { + break; + } + } + + return { models, env }; +} diff --git a/packages/setupWizard/src/utils.ts b/packages/setupWizard/src/utils.ts new file mode 100644 index 000000000..99a553f92 --- /dev/null +++ b/packages/setupWizard/src/utils.ts @@ -0,0 +1,83 @@ +import chalk from 'chalk'; +import { randomBytes } from 'crypto'; +import { select as searchSelect } from 'inquirer-select-pro'; +import type { ConnectionConfig } from '@sourcebot/schemas/v3/index.type'; + +export type { ConnectionConfig }; +export type EnvVars = Record; +export type CollectResult = { + /** + * One or more connections produced by the host's collect function. Single-connection + * hosts return a single entry with no `name` (main() uses the platform-derived + * connection name). Multi-connection hosts provide a `name` per entry. + */ + connections: Array<{ name?: string; config: ConnectionConfig }>; + env: EnvVars; + /** + * Optional host path that needs to be mounted into the Sourcebot container. + * Surfaced in the wizard's next-steps so users get the matching volume mount line. + */ + localRepoHostPath?: string; +}; + +export function generateSecret(bytes: number): string { + return randomBytes(bytes).toString('base64'); +} + +export function toEnvKey(connectionName: string, suffix: string): string { + return `${connectionName.toUpperCase().replace(/-/g, '_')}_${suffix}`; +} + +export function generateConnectionName(platform: string, existing: Record): string { + if (!existing[platform]) { + return platform; + } + let i = 1; + while (existing[`${platform}-${i}`]) { + i++; + } + return `${platform}-${i}`; +} + +export async function multiInput(options: { + message: string; + placeholder?: string; + validate?: (value: string) => string | true; +}): Promise { + return searchSelect({ + message: options.message, + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: options.placeholder ?? 'Type a value and press space to add, enter to finish', + options: async (search) => { + if (!search) { + return []; + } + return [{ name: search, value: search }]; + }, + validate: options.validate + ? (selected) => { + for (const opt of selected) { + const result = options.validate!(opt.value); + if (result !== true) { + return result; + } + } + return true; + } + : undefined, + }); +} + +export function note(message: string, title?: string): void { + console.log(); + if (title) { + console.log(chalk.cyan('◆ ') + chalk.bold(title)); + } + for (const line of message.split('\n')) { + console.log(chalk.gray('│ ') + line); + } + console.log(); +} diff --git a/packages/setupWizard/tsconfig.json b/packages/setupWizard/tsconfig.json new file mode 100644 index 000000000..efb889845 --- /dev/null +++ b/packages/setupWizard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "noEmitOnError": true, + "noImplicitAny": true, + "pretty": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "lib": ["ES2023"], + "types": ["node"], + "strict": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 6dd5836fc..38a30bf59 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -12,8 +12,6 @@ export const API_KEY_PREFIX = 'sbk_'; export const OAUTH_ACCESS_TOKEN_PREFIX = 'sboa_'; export const OAUTH_REFRESH_TOKEN_PREFIX = 'sbor_'; -export const SOURCEBOT_UNLIMITED_SEATS = -1; - /** * Default settings. */ diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index ae2aa423c..fbb4be79b 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -27,6 +27,21 @@ export function encrypt(text: string): { iv: string; encryptedData: string } { return { iv: iv.toString('hex'), encryptedData: encrypted }; } +export function decrypt(iv: string, encryptedText: string): string { + const encryptionKey = Buffer.from(env.SOURCEBOT_ENCRYPTION_KEY, 'ascii'); + + const ivBuffer = Buffer.from(iv, 'hex'); + const encryptedBuffer = Buffer.from(encryptedText, 'hex'); + + const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer); + + let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} + + export function hashSecret(text: string): string { return crypto.createHmac('sha256', env.SOURCEBOT_ENCRYPTION_KEY).update(text).digest('hex'); } @@ -61,20 +76,6 @@ export function generateOAuthRefreshToken(): { token: string; hash: string } { }; } -export function decrypt(iv: string, encryptedText: string): string { - const encryptionKey = Buffer.from(env.SOURCEBOT_ENCRYPTION_KEY, 'ascii'); - - const ivBuffer = Buffer.from(iv, 'hex'); - const encryptedBuffer = Buffer.from(encryptedText, 'hex'); - - const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer); - - let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; -} - export function verifySignature(data: string, signature: string, publicKeyPath: string): boolean { try { let publicKey = publicKeyCache.get(publicKeyPath); @@ -226,3 +227,13 @@ export function decryptOAuthToken(encryptedText: string | null | undefined): str return encryptedText; } } + +export function encryptActivationCode(code: string): string { + const { iv, encryptedData } = encrypt(code); + return Buffer.from(JSON.stringify({ iv, encryptedData })).toString('base64'); +} + +export function decryptActivationCode(encrypted: string): string { + const { iv, encryptedData } = JSON.parse(Buffer.from(encrypted, 'base64').toString('utf8')); + return decrypt(iv, encryptedData); +} \ No newline at end of file diff --git a/packages/shared/src/entitlements.test.ts b/packages/shared/src/entitlements.test.ts new file mode 100644 index 000000000..906ef516e --- /dev/null +++ b/packages/shared/src/entitlements.test.ts @@ -0,0 +1,276 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import type { License } from '@sourcebot/db'; + +const mocks = vi.hoisted(() => ({ + env: { + SOURCEBOT_PUBLIC_KEY_PATH: '/tmp/test-key', + SOURCEBOT_EE_LICENSE_KEY: undefined as string | undefined, + } as Record, + verifySignature: vi.fn(() => true), +})); + +vi.mock('./env.server.js', () => ({ + env: mocks.env, +})); + +vi.mock('./crypto.js', () => ({ + verifySignature: mocks.verifySignature, +})); + +vi.mock('./logger.js', () => ({ + createLogger: () => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), +})); + +import { + isAnonymousAccessAvailable, + getEntitlements, + hasEntitlement, +} from './entitlements.js'; + +const encodeOfflineKey = (payload: object): string => { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `sourcebot_ee_${encoded}`; +}; + +const futureDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toISOString(); +const pastDate = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(); + +const validOfflineKey = (overrides: { seats?: number; expiryDate?: string } = {}) => + encodeOfflineKey({ + id: 'test-customer', + expiryDate: overrides.expiryDate ?? futureDate, + ...(overrides.seats !== undefined ? { seats: overrides.seats } : {}), + sig: 'fake-sig', + }); + +const makeLicense = (overrides: Partial = {}): License => ({ + id: 'lic_1', + orgId: 1, + activationCode: 'code', + entitlements: [], + seats: null, + status: null, + planName: null, + unitAmount: null, + currency: null, + interval: null, + intervalCount: null, + nextRenewalAt: null, + nextRenewalAmount: null, + cancelAt: null, + trialEnd: null, + hasPaymentMethod: null, + lastSyncAt: new Date(), + lastSyncErrorCode: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +beforeEach(() => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = undefined; + mocks.verifySignature.mockReturnValue(true); +}); + +describe('isAnonymousAccessAvailable', () => { + describe('without any license', () => { + test('returns true when license is null', () => { + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('returns true when license has no status', () => { + expect(isAnonymousAccessAvailable(makeLicense())).toBe(true); + }); + + test('returns true when license status is canceled', () => { + expect(isAnonymousAccessAvailable(makeLicense({ status: 'canceled' }))).toBe(true); + }); + }); + + describe('with an active online license', () => { + test.each(['active', 'trialing', 'past_due'] as const)( + 'returns false when status is %s', + (status) => { + expect(isAnonymousAccessAvailable(makeLicense({ status }))).toBe(false); + } + ); + }); + + describe('with an offline license key', () => { + test('returns false when offline key has a seat count', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + expect(isAnonymousAccessAvailable(null)).toBe(false); + }); + + test('returns true when offline key has no seat count (unlimited)', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('unlimited offline key beats an active online license', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(true); + }); + + test('falls through to online license check when offline key is expired', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100, expiryDate: pastDate }); + expect(isAnonymousAccessAvailable(null)).toBe(true); + expect(isAnonymousAccessAvailable(makeLicense({ status: 'active' }))).toBe(false); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-valid-base64-or-json'; + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('falls through when offline key has wrong prefix', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'bogus_prefix_xyz'; + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + + test('falls through when offline key signature is invalid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 100 }); + mocks.verifySignature.mockReturnValue(false); + expect(isAnonymousAccessAvailable(null)).toBe(true); + }); + }); +}); + +describe('getEntitlements', () => { + test('returns empty array when no license and no offline key', () => { + expect(getEntitlements(null)).toEqual([]); + }); + + test('returns license.entitlements when license is active', () => { + const license = makeLicense({ status: 'active', entitlements: ['sso', 'audit'] }); + expect(getEntitlements(license)).toEqual(['sso', 'audit']); + }); + + test('returns empty when license has no status', () => { + expect(getEntitlements(makeLicense({ entitlements: ['sso'] }))).toEqual([]); + }); + + test('returns all entitlements when offline key is valid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); + const result = getEntitlements(null); + expect(result).toContain('sso'); + expect(result).toContain('audit'); + expect(result).toContain('search-contexts'); + }); + + test('falls through when offline key is expired', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50, expiryDate: pastDate }); + expect(getEntitlements(null)).toEqual([]); + expect( + getEntitlements(makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toEqual(['sso']); + }); + + test('falls through when offline key is malformed', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = 'sourcebot_ee_not-a-valid-payload'; + expect(getEntitlements(null)).toEqual([]); + }); + + describe('online license staleness', () => { + const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; + + test('returns entitlements when lastSyncAt is recent', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(Date.now() - 24 * 60 * 60 * 1000), // 1 day ago + }); + expect(getEntitlements(license)).toEqual(['sso']); + }); + + test('returns empty when lastSyncAt is past the stale threshold', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS - 60 * 1000), // 7d + 1min + }); + expect(getEntitlements(license)).toEqual([]); + }); + + test('returns empty when lastSyncAt is null', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: null, + }); + expect(getEntitlements(license)).toEqual([]); + }); + + test('returns entitlements at the threshold boundary', () => { + // Exactly at the threshold should still be treated as valid (<=). + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(Date.now() - STALE_THRESHOLD_MS + 1000), + }); + expect(getEntitlements(license)).toEqual(['sso']); + }); + }); + + describe('online license rebound elsewhere', () => { + test('returns empty when lastSyncErrorCode is ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', () => { + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(), + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }); + expect(getEntitlements(license)).toEqual([]); + }); + + test('returns entitlements when lastSyncErrorCode is some other error code', () => { + // Only ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE invalidates the + // local license. Other sync errors are persisted for visibility but + // don't strip entitlements (avoids paging operators on transient + // upstream issues). + const license = makeLicense({ + status: 'active', + entitlements: ['sso'], + lastSyncAt: new Date(), + lastSyncErrorCode: 'UNKNOWN_STRIPE_PRODUCT', + }); + expect(getEntitlements(license)).toEqual(['sso']); + }); + + test('offline license overrides the rebound-elsewhere gate', () => { + // Offline licenses don't rely on /ping, so a stale online error + // should not affect them. + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey(); + const license = makeLicense({ + status: 'active', + lastSyncErrorCode: 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE', + }); + expect(getEntitlements(license).length).toBeGreaterThan(0); + }); + }); +}); + +describe('hasEntitlement', () => { + test('returns true when entitlement is present in license', () => { + expect( + hasEntitlement('sso', makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toBe(true); + }); + + test('returns false when entitlement is absent from license', () => { + expect( + hasEntitlement('audit', makeLicense({ status: 'active', entitlements: ['sso'] })) + ).toBe(false); + }); + + test('returns true for any entitlement when offline key is valid', () => { + mocks.env.SOURCEBOT_EE_LICENSE_KEY = validOfflineKey({ seats: 50 }); + expect(hasEntitlement('sso', null)).toBe(true); + expect(hasEntitlement('audit', null)).toBe(true); + }); +}); diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index de841a0dd..8ba587b43 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -2,36 +2,32 @@ import { base64Decode } from "./utils.js"; import { z } from "zod"; import { createLogger } from "./logger.js"; import { env } from "./env.server.js"; -import { SOURCEBOT_SUPPORT_EMAIL, SOURCEBOT_UNLIMITED_SEATS } from "./constants.js"; import { verifySignature } from "./crypto.js"; +import { License } from "@sourcebot/db"; +import { LicenseStatus } from "./types.js"; const logger = createLogger('entitlements'); -const eeLicenseKeyPrefix = "sourcebot_ee_"; - -const eeLicenseKeyPayloadSchema = z.object({ +const offlineLicensePrefix = "sourcebot_ee_"; +const offlineLicensePayloadSchema = z.object({ id: z.string(), - seats: z.number(), + seats: z.number().optional(), // ISO 8601 date string expiryDate: z.string().datetime(), sig: z.string(), }); -type LicenseKeyPayload = z.infer; +type getValidOfflineLicense = z.infer; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const planLabels = { - oss: "OSS", - "self-hosted:enterprise": "Enterprise (Self-Hosted)", - "self-hosted:enterprise-unlimited": "Enterprise (Self-Hosted) Unlimited", -} as const; -export type Plan = keyof typeof planLabels; +const ACTIVE_ONLINE_LICENSE_STATUSES: LicenseStatus[] = [ + 'active', + 'trialing', + 'past_due', +]; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const entitlements = [ +const ALL_ENTITLEMENTS = [ "search-contexts", - "anonymous-access", - "multi-tenancy", "sso", "code-nav", "audit", @@ -42,100 +38,144 @@ const entitlements = [ "org-management", "oauth", ] as const; -export type Entitlement = (typeof entitlements)[number]; - -const entitlementsByPlan: Record = { - oss: [ - "anonymous-access", - ], - "self-hosted:enterprise": [ - "search-contexts", - "sso", - "code-nav", - "audit", - "analytics", - "permission-syncing", - "github-app", - "chat-sharing", - "org-management", - "oauth", - ], - "self-hosted:enterprise-unlimited": [ - "anonymous-access", - "search-contexts", - "sso", - "code-nav", - "audit", - "analytics", - "permission-syncing", - "github-app", - "chat-sharing", - "org-management", - "oauth", - ], -} as const; - - -const decodeLicenseKeyPayload = (payload: string): LicenseKeyPayload => { +export type Entitlement = (typeof ALL_ENTITLEMENTS)[number]; + +const decodeOfflineLicenseKeyPayload = (payload: string): getValidOfflineLicense | null => { try { const decodedPayload = base64Decode(payload); const payloadJson = JSON.parse(decodedPayload); - const licenseData = eeLicenseKeyPayloadSchema.parse(payloadJson); - + const licenseData = offlineLicensePayloadSchema.parse(payloadJson); + const dataToVerify = JSON.stringify({ expiryDate: licenseData.expiryDate, id: licenseData.id, seats: licenseData.seats }); - + const isSignatureValid = verifySignature(dataToVerify, licenseData.sig, env.SOURCEBOT_PUBLIC_KEY_PATH); if (!isSignatureValid) { logger.error('License key signature verification failed'); - process.exit(1); + return null; } - + return licenseData; } catch (error) { logger.error(`Failed to decode license key payload: ${error}`); - process.exit(1); + return null; } } -export const getLicenseKey = (): LicenseKeyPayload | null => { +const getDecodedOfflineLicense = (): getValidOfflineLicense | null => { const licenseKey = env.SOURCEBOT_EE_LICENSE_KEY; - if (licenseKey && licenseKey.startsWith(eeLicenseKeyPrefix)) { - const payload = licenseKey.substring(eeLicenseKeyPrefix.length); - return decodeLicenseKeyPayload(payload); + if (!licenseKey || !licenseKey.startsWith(offlineLicensePrefix)) { + return null; + } + + return decodeOfflineLicenseKeyPayload(licenseKey.substring(offlineLicensePrefix.length)); +} + +const getValidOfflineLicense = (): getValidOfflineLicense | null => { + const payload = getDecodedOfflineLicense(); + if (!payload) { + return null; + } + + const expiryDate = new Date(payload.expiryDate); + if (expiryDate.getTime() < new Date().getTime()) { + return null; + } + + return payload; +} + +// If the license hasn't successfully synced with Lighthouse for this long, +// the locally-cached state is no longer trusted. This guards against an +// operator blocking egress to prevent the license row from hearing about +// a canceled or past-due subscription. 7 days absorbs week-long transient +// outages (weekends, firewall rollouts) without punishing legitimate +// customers. +export const STALE_ONLINE_LICENSE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000; + +// Surface a UI warning (banner + "refreshed" timestamp color) when the +// license hasn't synced for this long. Must be < the enforcement threshold +// so the warning has a chance to fire before entitlements are stripped. +export const STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000; + +const getValidOnlineLicense = (_license: License | null): License | null => { + if ( + _license && + _license.status && + ACTIVE_ONLINE_LICENSE_STATUSES.includes(_license.status as LicenseStatus) && + _license.lastSyncAt && + (Date.now() - _license.lastSyncAt.getTime()) <= STALE_ONLINE_LICENSE_THRESHOLD_MS && + _license.lastSyncErrorCode !== 'ACTIVATION_CODE_BOUND_TO_DIFFERENT_INSTANCE' + ) { + return _license; } + return null; } -export const getPlan = (): Plan => { - const licenseKey = getLicenseKey(); - if (licenseKey) { - const expiryDate = new Date(licenseKey.expiryDate); - if (expiryDate.getTime() < new Date().getTime()) { - logger.error(`The provided license key has expired (${expiryDate.toLocaleString()}). Please contact ${SOURCEBOT_SUPPORT_EMAIL} for support.`); - process.exit(1); - } +export const isAnonymousAccessAvailable = (_license: License | null): boolean => { + const offlineKey = getValidOfflineLicense(); + if (offlineKey) { + return offlineKey.seats === undefined; + } - return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; - } else { - return "oss"; + const onlineLicense = getValidOnlineLicense(_license); + if (onlineLicense) { + return false; } + return true; } -export const getSeats = (): number => { -const licenseKey = getLicenseKey(); - return licenseKey?.seats ?? SOURCEBOT_UNLIMITED_SEATS; +export const getEntitlements = (_license: License | null): Entitlement[] => { + const offlineLicense = getValidOfflineLicense(); + if (offlineLicense) { + return ALL_ENTITLEMENTS as unknown as Entitlement[]; + } + + const onlineLicense = getValidOnlineLicense(_license); + if (onlineLicense) { + return onlineLicense.entitlements as unknown as Entitlement[]; + } + else { + return []; + } } -export const hasEntitlement = (entitlement: Entitlement) => { - const entitlements = getEntitlements(); +export const hasEntitlement = (entitlement: Entitlement, _license: License | null) => { + const entitlements = getEntitlements(_license); return entitlements.includes(entitlement); } -export const getEntitlements = (): Entitlement[] => { - const plan = getPlan(); - return entitlementsByPlan[plan]; +export type OfflineLicenseMetadata = { + id: string; + seats?: number; + expiryDate: string; +} + +// Returns the metadata of the offline license if one is configured, even +// if it has expired. Callers that only care about active entitlements +// should use `getEntitlements` / `getValidOfflineLicense` instead. +export const getOfflineLicenseMetadata = (): OfflineLicenseMetadata | null => { + const license = getDecodedOfflineLicense(); + if (!license) { + return null; + } + + return { + id: license.id, + seats: license.seats, + expiryDate: license.expiryDate, + }; } + +export const getSeatCap = (): number | undefined => { + const offlineLicense = getValidOfflineLicense(); + if (offlineLicense?.seats && offlineLicense.seats > 0) { + return offlineLicense.seats; + } + + return undefined; +} \ No newline at end of file diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 460afc29b..036655018 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -311,6 +311,7 @@ const options = { SOURCEBOT_ENCRYPTION_KEY: z.string(), SOURCEBOT_INSTALL_ID: z.string().default("unknown"), + SOURCEBOT_LIGHTHOUSE_URL: z.string().url().default("https://license.sourcebot.dev"), FALLBACK_GITHUB_CLOUD_TOKEN: z.string().optional(), FALLBACK_GITLAB_CLOUD_TOKEN: z.string().optional(), diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index a1eb34204..8be1b35c2 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -1,18 +1,24 @@ +// types prefixed with _ are intended to be wrapped +// by the consumer. See web/entitlements.ts and +// backend/entitlements.ts export { - hasEntitlement, - getLicenseKey, - getPlan, - getSeats, - getEntitlements, + hasEntitlement as _hasEntitlement, + getEntitlements as _getEntitlements, + isAnonymousAccessAvailable as _isAnonymousAccessAvailable, + getSeatCap, + getOfflineLicenseMetadata, + STALE_ONLINE_LICENSE_THRESHOLD_MS, + STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS, } from "./entitlements.js"; export type { - Plan, Entitlement, + OfflineLicenseMetadata, } from "./entitlements.js"; export type { RepoMetadata, RepoIndexingJobMetadata, IdentityProviderType, + LicenseStatus, } from "./types.js"; export { repoMetadataSchema, @@ -49,6 +55,8 @@ export { verifySignature, encryptOAuthToken, decryptOAuthToken, + encryptActivationCode, + decryptActivationCode, } from "./crypto.js"; export { getDBConnectionString, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f5de58476..5951bab6b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -72,4 +72,15 @@ export const repoIndexingJobMetadataSchema = z.object({ export type RepoIndexingJobMetadata = z.infer; -export type IdentityProviderType = IdentityProviderConfig['provider']; \ No newline at end of file +export type IdentityProviderType = IdentityProviderConfig['provider']; + +// @see: https://docs.stripe.com/api/subscriptions/object#subscription_object-status +export type LicenseStatus = + 'active' | + 'trialing' | + 'past_due' | + 'unpaid' | + 'canceled' | + 'incomplete' | + 'incomplete_expired' | + 'paused'; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 148b3a18b..74728183f 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ NODE_ENV: 'test', CONFIG_PATH: '/tmp/test-config.json', SOURCEBOT_ENCRYPTION_KEY: 'test-encryption-key-32-characters!', + SOURCEBOT_LIGHTHOUSE_URL: 'http://localhost:3003', } } }); diff --git a/packages/web/package.json b/packages/web/package.json index ab4274153..8825a21c5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -27,6 +27,7 @@ "@ai-sdk/xai": "^3.0.83", "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/credential-providers": "^3.1036.0", + "@bprogress/next": "^3.2.12", "@codemirror/commands": "^6.6.0", "@codemirror/lang-cpp": "^6.0.2", "@codemirror/lang-css": "^6.3.0", @@ -72,7 +73,7 @@ "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-hover-card": "^1.1.6", "@radix-ui/react-icons": "^1.3.0", @@ -81,14 +82,14 @@ "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", - "@radix-ui/react-tooltip": "^1.1.4", + "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^1.0.12", "@react-email/render": "^2.0.8", "@replit/codemirror-lang-csharp": "^6.2.0", @@ -117,7 +118,8 @@ "ai": "^6.0.167", "ajv": "^8.17.1", "bcryptjs": "^3.0.2", - "class-variance-authority": "^0.7.0", + "canvas-confetti": "^1.9.4", + "class-variance-authority": "^0.7.1", "client-only": "^0.0.1", "clsx": "^2.1.1", "cm6-graphql": "^0.2.0", @@ -152,7 +154,7 @@ "langfuse": "^3.38.4", "langfuse-vercel": "^3.38.4", "linguist-languages": "^9.3.1", - "lucide-react": "^0.517.0", + "lucide-react": "^1.7.0", "micromatch": "^4.0.8", "minidenticons": "^4.2.1", "next": "^16.2.6", @@ -205,6 +207,7 @@ "@tanstack/eslint-plugin-query": "^5.74.7", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", + "@types/canvas-confetti": "^1.9.0", "@types/glob-to-regexp": "^0.4.4", "@types/micromatch": "^4.0.9", "@types/node": "^20", diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 2a53ca69f..1cc8ea3e9 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -20,7 +20,8 @@ export const MOCK_ORG: Org = { metadata: null, memberApprovalRequired: false, inviteLinkEnabled: false, - inviteLinkId: null + inviteLinkId: null, + trialUsedAt: null, } export const MOCK_API_KEY: ApiKey = { diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index f7350a8d8..8fe400fb3 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -1,11 +1,10 @@ 'use server'; -import { getAuditService } from "@/ee/features/audit/factory"; +import { createAudit } from "@/ee/features/audit/audit"; import { env, getSMTPConnectionURL } from "@sourcebot/shared"; -import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; import { ErrorCode } from "@/lib/errorCodes"; -import { notAuthenticated, notFound, orgNotFound, ServiceError } from "@/lib/serviceError"; -import { getOrgMetadata, isHttpError, isServiceError } from "@/lib/utils"; +import { notAuthenticated, notFound, ServiceError } from "@/lib/serviceError"; +import { getOrgMetadata, isHttpError } from "@/lib/utils"; import { __unsafePrisma } from "@/prisma"; import { render } from "@react-email/components"; import { generateApiKey, getTokenFromConfig } from "@sourcebot/shared"; @@ -14,13 +13,11 @@ import { createLogger } from "@sourcebot/shared"; import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type"; import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"; import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type"; -import { getPlan, hasEntitlement } from "@sourcebot/shared"; +import { isAnonymousAccessAvailable } from "@/lib/entitlements"; import { StatusCodes } from "http-status-codes"; import { cookies } from "next/headers"; import { createTransport } from "nodemailer"; import { Octokit } from "octokit"; -import InviteUserEmail from "./emails/inviteUserEmail"; -import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; import { AGENTIC_SEARCH_TUTORIAL_DISMISSED_COOKIE_NAME, MOBILE_UNSUPPORTED_SPLASH_SCREEN_DISMISSED_COOKIE_NAME, SINGLE_TENANT_ORG_ID, SOURCEBOT_SUPPORT_EMAIL } from "./lib/constants"; import { RepositoryQuery } from "./lib/types"; @@ -30,7 +27,6 @@ import { getBrowsePath } from "./app/(app)/browse/hooks/utils"; import { sew } from "@/middleware/sew"; const logger = createLogger('web-actions'); -const auditService = getAuditService(); ////// Actions /////// export const completeOnboarding = async (): Promise<{ success: boolean } | ServiceError> => sew(() => @@ -66,7 +62,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv }); if (existingApiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.creation_failed", actor: { id: user.id, @@ -99,7 +95,7 @@ export const createApiKey = async (name: string): Promise<{ key: string } | Serv } }); - await auditService.createAudit({ + await createAudit({ action: "api_key.created", actor: { id: user.id, @@ -127,7 +123,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }); if (!apiKey) { - await auditService.createAudit({ + await createAudit({ action: "api_key.deletion_failed", actor: { id: user.id, @@ -156,7 +152,7 @@ export const deleteApiKey = async (name: string): Promise<{ success: boolean } | }, }); - await auditService.createAudit({ + await createAudit({ action: "api_key.deleted", actor: { id: user.id, @@ -519,304 +515,6 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin } })); -export const getCurrentUserRole = async (): Promise => sew(() => - withOptionalAuth(async ({ role }) => { - return role; - })); - -export const createInvites = async (emails: string[]): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: error, - emails: emails.join(", ") - } - }); - } - - const hasAvailability = await orgHasAvailability(); - if (!hasAvailability) { - await auditService.createAudit({ - action: "user.invite_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - message: "Organization has reached maximum number of seats", - emails: emails.join(", ") - } - }); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.ORG_SEAT_COUNT_REACHED, - message: "The organization has reached the maximum number of seats. Unable to create a new invite", - } satisfies ServiceError; - } - - // Check for existing invites - const existingInvites = await prisma.invite.findMany({ - where: { - recipientEmail: { - in: emails - }, - orgId: org.id, - } - }); - - if (existingInvites.length > 0) { - await failAuditCallback("A pending invite already exists for one or more of the provided emails"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `A pending invite already exists for one or more of the provided emails.`, - } satisfies ServiceError; - } - - // Check for members that are already in the org - const existingMembers = await prisma.userToOrg.findMany({ - where: { - user: { - email: { - in: emails, - } - }, - orgId: org.id, - }, - }); - - if (existingMembers.length > 0) { - await failAuditCallback("One or more of the provided emails are already members of this org"); - return { - statusCode: StatusCodes.BAD_REQUEST, - errorCode: ErrorCode.INVALID_INVITE, - message: `One or more of the provided emails are already members of this org.`, - } satisfies ServiceError; - } - - await prisma.invite.createMany({ - data: emails.map((email) => ({ - recipientEmail: email, - hostUserId: user.id, - orgId: org.id, - })), - skipDuplicates: true, - }); - - // Send invites to recipients - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - await Promise.all(emails.map(async (email) => { - const invite = await prisma.invite.findUnique({ - where: { - recipientEmail_orgId: { - recipientEmail: email, - orgId: org.id, - }, - }, - include: { - org: true, - } - }); - - if (!invite) { - return; - } - - const recipient = await prisma.user.findUnique({ - where: { - email, - }, - }); - const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`; - const transport = createTransport(smtpConnectionUrl); - const html = await render(InviteUserEmail({ - baseUrl: env.AUTH_URL, - host: { - name: user.name ?? undefined, - email: user.email!, - avatarUrl: user.image ?? undefined, - }, - recipient: { - name: recipient?.name ?? undefined, - }, - orgName: invite.org.name, - orgImageUrl: invite.org.imageUrl ?? undefined, - inviteLink, - })); - - const result = await transport.sendMail({ - to: email, - from: env.EMAIL_FROM_ADDRESS, - subject: `Join ${invite.org.name} on Sourcebot`, - html, - text: `Join ${invite.org.name} on Sourcebot by clicking here: ${inviteLink}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send invite email to ${email}: ${failed}`); - } - })); - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping invite email to ${emails.join(", ")}`); - } - - await auditService.createAudit({ - action: "user.invites_created", - actor: { - id: user.id, - type: "user" - }, - target: { - id: org.id.toString(), - type: "org" - }, - orgId: org.id, - metadata: { - emails: emails.join(", ") - } - }); - return { - success: true, - } - }) - )); - -export const cancelInvite = async (inviteId: string): Promise<{ success: boolean } | ServiceError> => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const invite = await prisma.invite.findUnique({ - where: { - id: inviteId, - orgId: org.id, - }, - }); - - if (!invite) { - return notFound(); - } - - await prisma.invite.delete({ - where: { - id: inviteId, - }, - }); - - return { - success: true, - } - }) - )); - -export const getMe = async () => sew(() => - withAuth(async ({ user, prisma }) => { - const userWithOrgs = await prisma.user.findUnique({ - where: { - id: user.id, - }, - include: { - orgs: { - include: { - org: true, - } - }, - } - }); - - if (!userWithOrgs) { - return notFound(); - } - - return { - id: userWithOrgs.id, - email: userWithOrgs.email, - name: userWithOrgs.name, - image: userWithOrgs.image, - memberships: userWithOrgs.orgs.map((org) => ({ - id: org.orgId, - role: org.role, - name: org.org.name, - })) - } - })); - -export const getOrgMembers = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const members = await prisma.userToOrg.findMany({ - where: { - orgId: org.id, - role: { - not: OrgRole.GUEST, - } - }, - include: { - user: true, - }, - }); - - return members.map((member) => ({ - id: member.userId, - email: member.user.email!, - name: member.user.name ?? undefined, - avatarUrl: member.user.image ?? undefined, - role: member.role, - joinedAt: member.joinedAt, - })); - })); - -export const getOrgInvites = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const invites = await prisma.invite.findMany({ - where: { - orgId: org.id, - }, - }); - - return invites.map((invite) => ({ - id: invite.id, - email: invite.recipientEmail, - createdAt: invite.createdAt, - })); - })); - -export const getOrgAccountRequests = async () => sew(() => - withAuth(async ({ org, prisma }) => { - const requests = await prisma.accountRequest.findMany({ - where: { - orgId: org.id, - }, - include: { - requestedBy: true, - }, - }); - - return requests.map((request) => ({ - id: request.id, - email: request.requestedBy.email!, - createdAt: request.createdAt, - name: request.requestedBy.name ?? undefined, - image: request.requestedBy.image ?? undefined, - })); - })); - // eslint-disable-next-line authz/require-auth-wrapper -- calls getAuthenticatedUser() directly; runs pre-org-membership so cannot use withAuth export const createAccountRequest = async () => sew(async () => { const authResult = await getAuthenticatedUser(); @@ -921,21 +619,6 @@ export const createAccountRequest = async () => sew(async () => { } }); -// eslint-disable-next-line authz/require-auth-wrapper -- public org-config bit consulted on login/signup screens before any session exists -export const getMemberApprovalRequired = async (): Promise => sew(async () => { - const org = await __unsafePrisma.org.findUnique({ - where: { - id: SINGLE_TENANT_ORG_ID, - }, - }); - - if (!org) { - return orgNotFound(); - } - - return org.memberApprovalRequired; -}); - export const setMemberApprovalRequired = async (required: boolean): Promise<{ success: boolean } | ServiceError> => sew(async () => withAuth(async ({ org, role, prisma }) => withMinimumOrgRole(role, OrgRole.OWNER, async () => { @@ -966,135 +649,6 @@ export const setInviteLinkEnabled = async (enabled: boolean): Promise<{ success: ) ); -export const approveAccountRequest = async (requestId: string) => sew(async () => - withAuth(async ({ org, user, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const failAuditCallback = async (error: string) => { - await auditService.createAudit({ - action: "user.join_request_approve_failed", - actor: { - id: user.id, - type: "user" - }, - target: { - id: requestId, - type: "account_join_request" - }, - orgId: org.id, - metadata: { - message: error, - } - }); - } - - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - include: { - requestedBy: true, - }, - }); - - if (!request || request.orgId !== org.id) { - await failAuditCallback("Request not found"); - return notFound(); - } - - const addUserToOrgRes = await addUserToOrganization(request.requestedById, org.id); - if (isServiceError(addUserToOrgRes)) { - await failAuditCallback(addUserToOrgRes.message); - return addUserToOrgRes; - } - - await auditService.createAudit({ - action: "user.join_request_approved", - actor: { - id: user.id, - type: "user" - }, - orgId: org.id, - target: { - id: requestId, - type: "account_join_request" - } - }); - - await auditService.createAudit({ - action: "org.member_added", - actor: { id: user.id, type: "user" }, - target: { id: request.requestedById, type: "user" }, - orgId: org.id, - metadata: { - message: `${user.id} approved join request ${requestId} for ${request.requestedById}`, - }, - }); - - // Send approval email to the user - const smtpConnectionUrl = getSMTPConnectionURL(); - if (smtpConnectionUrl && env.EMAIL_FROM_ADDRESS) { - try { - const html = await render(JoinRequestApprovedEmail({ - baseUrl: env.AUTH_URL, - user: { - name: request.requestedBy.name ?? undefined, - email: request.requestedBy.email!, - avatarUrl: request.requestedBy.image ?? undefined, - }, - orgName: org.name, - })); - - const transport = createTransport(smtpConnectionUrl); - const result = await transport.sendMail({ - to: request.requestedBy.email!, - from: env.EMAIL_FROM_ADDRESS, - subject: `Your request to join ${org.name} has been approved`, - html, - text: `Your request to join ${org.name} on Sourcebot has been approved. You can now access the organization at ${env.AUTH_URL}`, - }); - - const failed = result.rejected.concat(result.pending).filter(Boolean); - if (failed.length > 0) { - logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${failed}`); - } - } catch (e) { - logger.error(`Failed to send approval email to ${request.requestedBy.email}: ${e}`); - } - } else { - logger.warn(`SMTP_CONNECTION_URL or EMAIL_FROM_ADDRESS not set. Skipping approval email to ${request.requestedBy.email}`); - } - return { - success: true, - } - }) - )); - -export const rejectAccountRequest = async (requestId: string) => sew(() => - withAuth(async ({ org, role, prisma }) => - withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const request = await prisma.accountRequest.findUnique({ - where: { - id: requestId, - }, - }); - - if (!request || request.orgId !== org.id) { - return notFound(); - } - - await prisma.accountRequest.delete({ - where: { - id: requestId, - }, - }); - - return { - success: true, - } - }) - )); - - export const getSearchContexts = async () => sew(() => withOptionalAuth(async ({ org, prisma }) => { const searchContexts = await prisma.searchContext.findMany({ @@ -1183,43 +737,12 @@ export const getRepoImage = async (repoId: number): Promise => sew(async () => { - const org = await __unsafePrisma.org.findUnique({ - where: { id: SINGLE_TENANT_ORG_ID }, - }); - if (!org) { - return { - statusCode: StatusCodes.NOT_FOUND, - errorCode: ErrorCode.NOT_FOUND, - message: "Organization not found", - } satisfies ServiceError; - } - - // If no metadata is set we don't try to parse it since it'll result in a parse error - if (org.metadata === null) { - return false; - } - - const orgMetadata = getOrgMetadata(org); - if (!orgMetadata) { - return { - statusCode: StatusCodes.INTERNAL_SERVER_ERROR, - errorCode: ErrorCode.INVALID_ORG_METADATA, - message: "Invalid organization metadata", - } satisfies ServiceError; - } - - return !!orgMetadata.anonymousAccessEnabled; -}); - export const setAnonymousAccessStatus = async (enabled: boolean): Promise => sew(async () => { return await withAuth(async ({ org, role, prisma }) => { return await withMinimumOrgRole(role, OrgRole.OWNER, async () => { - const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); - if (!hasAnonymousAccessEntitlement) { - const plan = getPlan(); - console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); + const anonymousAccessAvailable = await isAnonymousAccessAvailable(); + if (!anonymousAccessAvailable) { + console.error(`Anonymous access isn't supported in your current plan. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); return { statusCode: StatusCodes.FORBIDDEN, errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/(default)/[...slug]/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/[...slug]/page.tsx new file mode 100644 index 000000000..0959482a2 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/[...slug]/page.tsx @@ -0,0 +1,5 @@ +import { DefaultSidebar } from "../../../components/defaultSidebar"; + +export default async function Page() { + return ; +} diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/(default)/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/page.tsx new file mode 100644 index 000000000..c6b333f88 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/(default)/page.tsx @@ -0,0 +1,5 @@ +import { DefaultSidebar } from "../../components/defaultSidebar"; + +export default async function Page() { + return ; +} diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/settings/[...slug]/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/settings/[...slug]/page.tsx new file mode 100644 index 000000000..6b8474b84 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/settings/[...slug]/page.tsx @@ -0,0 +1,6 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsSidebar } from "../../../components/settingsSidebar"; + +export default authenticatedPage(async () => { + return ; +}); diff --git a/packages/web/src/app/(app)/@sidebar/(routes)/settings/page.tsx b/packages/web/src/app/(app)/@sidebar/(routes)/settings/page.tsx new file mode 100644 index 000000000..0de76d7b4 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/(routes)/settings/page.tsx @@ -0,0 +1,6 @@ +import { authenticatedPage } from "@/middleware/authenticatedPage"; +import { SettingsSidebar } from "../../components/settingsSidebar"; + +export default authenticatedPage(async () => { + return ; +}); diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/chatHistory.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/chatHistory.tsx new file mode 100644 index 000000000..b9f29a490 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/chatHistory.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { ChatActionsDropdown } from "@/app/(app)/chat/components/chatActionsDropdown"; +import { DeleteChatDialog } from "@/app/(app)/chat/components/deleteChatDialog"; +import { DuplicateChatDialog } from "@/app/(app)/chat/components/duplicateChatDialog"; +import { RenameChatDialog } from "@/app/(app)/chat/components/renameChatDialog"; +import { useToast } from "@/components/hooks/use-toast"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { deleteChat, duplicateChat, updateChatName } from "@/features/chat/actions"; +import { captureEvent } from "@/hooks/useCaptureEvent"; +import { isServiceError } from "@/lib/utils"; +import { EllipsisIcon, MessagesSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; + +export interface ChatHistoryItem { + id: string; + name: string | null; + createdAt: Date; +} + +interface ChatHistoryProps { + chatHistory: ChatHistoryItem[]; + hasMore?: boolean; +} + +export function ChatHistory({ chatHistory, hasMore }: ChatHistoryProps) { + const pathname = usePathname(); + const router = useRouter(); + const { toast } = useToast(); + + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [chatIdToRename, setChatIdToRename] = useState(null); + const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); + const [chatIdToDuplicate, setChatIdToDuplicate] = useState(null); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [chatIdToDelete, setChatIdToDelete] = useState(null); + + const onRenameChat = useCallback(async (name: string, chatId: string): Promise => { + const response = await updateChatName({ chatId, name }); + if (isServiceError(response)) { + toast({ description: `Failed to rename chat. Reason: ${response.message}` }); + return false; + } + toast({ description: "Chat renamed successfully" }); + captureEvent('wa_chat_renamed', { chatId }); + router.refresh(); + return true; + }, [router, toast]); + + const onDeleteChat = useCallback(async (chatIdToDelete: string): Promise => { + const response = await deleteChat({ chatId: chatIdToDelete }); + if (isServiceError(response)) { + toast({ description: `Failed to delete chat. Reason: ${response.message}` }); + return false; + } + toast({ description: "Chat deleted successfully" }); + captureEvent('wa_chat_deleted', { chatId: chatIdToDelete }); + if (pathname === `/chat/${chatIdToDelete}`) { + router.push("/chat"); + } else { + router.refresh(); + } + return true; + }, [pathname, router, toast]); + + const onDuplicateChat = useCallback(async (newName: string, chatIdToDuplicate: string): Promise => { + const response = await duplicateChat({ chatId: chatIdToDuplicate, newName }); + if (isServiceError(response)) { + toast({ description: `Failed to duplicate chat. Reason: ${response.message}` }); + return null; + } + toast({ description: "Chat duplicated successfully" }); + captureEvent('wa_chat_duplicated', { chatId: chatIdToDuplicate }); + router.push(`/chat/${response.id}`); + return response.id; + }, [router, toast]); + + if (chatHistory.length === 0) { + return null; + } + + return ( + <> + + Recent Chats + + + {chatHistory.map((chat) => ( + + + + {chat.name ?? "Untitled chat"} + + + { + setChatIdToRename(chat.id); + setIsRenameDialogOpen(true); + }} + onDuplicateClick={() => { + setChatIdToDuplicate(chat.id); + setIsDuplicateDialogOpen(true); + }} + onDeleteClick={() => { + setChatIdToDelete(chat.id); + setIsDeleteDialogOpen(true); + }} + > + + + + + + ))} + {hasMore && ( + + + + + All chats + + + + )} + + + + { + if (chatIdToRename) { + return await onRenameChat(name, chatIdToRename); + } + return false; + }} + currentName={chatHistory.find((chat) => chat.id === chatIdToRename)?.name ?? "Untitled chat"} + /> + { + if (chatIdToDelete) { + return await onDeleteChat(chatIdToDelete); + } + return false; + }} + /> + { + if (chatIdToDuplicate) { + return await onDuplicateChat(newName, chatIdToDuplicate); + } + return null; + }} + currentName={chatHistory.find((chat) => chat.id === chatIdToDuplicate)?.name ?? "Untitled chat"} + /> + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx new file mode 100644 index 000000000..c215af57c --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/index.tsx @@ -0,0 +1,88 @@ +import { cookies } from "next/headers"; +import { auth } from "@/auth"; +import { HOME_VIEW_COOKIE_NAME } from "@/lib/constants"; +import { HomeView } from "@/hooks/useHomeView"; +import { getConnectionStats } from "@/actions"; +import { getOrgAccountRequests } from "@/features/userManagement/actions"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { __unsafePrisma } from "@/prisma"; +import { SINGLE_TENANT_ORG_ID } from "@/lib/constants"; +import { OrgRole } from "@prisma/client"; +import { SidebarBase } from "@/app/(app)/@sidebar/components/sidebarBase"; +import { Nav } from "./nav"; +import { ChatHistory } from "./chatHistory"; +import { withAuth } from "@/middleware/withAuth"; +import { sew } from "@/middleware/sew"; + +const SIDEBAR_CHAT_LIMIT = 30; + +export async function DefaultSidebar() { + const session = await auth(); + const cookieStore = await cookies(); + const homeView = (cookieStore.get(HOME_VIEW_COOKIE_NAME)?.value ?? "search") as HomeView; + + const chatHistory = session ? await getUserChatHistory() : []; + if (isServiceError(chatHistory)) { + throw new ServiceErrorException(chatHistory); + } + + const isSettingsNotificationVisible = await (async () => { + if (!session) { + return false; + } + const membership = await __unsafePrisma.userToOrg.findUnique({ + where: { orgId_userId: { orgId: SINGLE_TENANT_ORG_ID, userId: session.user.id } }, + select: { role: true }, + }); + if (membership?.role !== OrgRole.OWNER) { + return false; + } + const connectionStats = await getConnectionStats(); + const joinRequests = await getOrgAccountRequests(); + const hasConnectionNotification = !isServiceError(connectionStats) && connectionStats.numberOfConnectionsWithFirstTimeSyncJobsInProgress > 0; + const hasJoinRequestNotification = !isServiceError(joinRequests) && joinRequests.length > 0; + return hasConnectionNotification || hasJoinRequestNotification; + })(); + + return ( + + } + > + SIDEBAR_CHAT_LIMIT} + /> + + ); +} + +const getUserChatHistory = async () => sew(() => + withAuth(async ({ org, user, prisma }) => { + const chats = await prisma.chat.findMany({ + where: { + orgId: org.id, + createdById: user.id, + }, + orderBy: { + updatedAt: 'desc', + }, + take: SIDEBAR_CHAT_LIMIT + 1, + }); + + return chats.map((chat) => ({ + id: chat.id, + createdAt: chat.createdAt, + name: chat.name, + visibility: chat.visibility, + })) + }) +); \ No newline at end of file diff --git a/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx new file mode 100644 index 000000000..775db2bac --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/defaultSidebar/nav.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { BookMarkedIcon, type LucideIcon, MessageCircleIcon, MessagesSquareIcon, SearchIcon, SettingsIcon } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { HomeView } from "@/hooks/useHomeView"; +import { NotificationDot } from "../../../components/notificationDot"; +import { useMemo } from "react"; +import Link from "next/link"; + +interface NavItem { + title: string; + href: string; + icon: LucideIcon; + key: string; + requiresAuth?: boolean; +} + +interface NavProps { + isSettingsNotificationVisible?: boolean; + isSignedIn?: boolean; + homeView: HomeView; +} + +export function Nav({ isSettingsNotificationVisible, isSignedIn, homeView }: NavProps) { + const pathname = usePathname(); + + const baseItems = useMemo((): NavItem[] => { + + const searchItem: NavItem = { + title: "Code Search", + href: "/search", + icon: SearchIcon, + key: "search", + } + + const askItem: NavItem = { + title: "Ask", + href: "/chat", + icon: MessageCircleIcon, + key: "chat" + } + + return [ + ...(homeView === "search" ? [ + searchItem, + askItem, + ] : [ + askItem, + searchItem, + ]), + { + title: "Chats", + href: "/chats", + icon: MessagesSquareIcon, + key: "chats", + requiresAuth: true, + }, + { + title: "Repositories", + href: "/repos", + icon: BookMarkedIcon, + key: "repos" + }, + { + title: "Settings", + href: "/settings", + icon: SettingsIcon, + key: "settings", + requiresAuth: true + }, + ] + + + }, [homeView]); + + const isActive = (href: string) => { + if (pathname === "/") { + return ( + (homeView === "ask" && href === "/chat") || + (homeView === "search" && href === "/search") + ) + } + + if (href === "/search") { + return pathname.startsWith("/search"); + } + + if (href === "/chat") { + return pathname === "/chat"; + } + return pathname.startsWith(href); + }; + + return ( + + {baseItems.filter((item) => !item.requiresAuth || isSignedIn).map((item) => { + const showNotification = + (item.key === "settings" && isSettingsNotificationVisible); + return ( + + + + + {item.title} + {showNotification && } + + + + ); + })} + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx new file mode 100644 index 000000000..ab2d6b3ab --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/header.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; + +export function SettingsSidebarHeader() { + return ( + + + + + + Back to app + + + + + ); +} diff --git a/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx new file mode 100644 index 000000000..fc30ad7f4 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/settingsSidebar/index.tsx @@ -0,0 +1,26 @@ +import { auth } from "@/auth"; +import { isServiceError } from "@/lib/utils"; +import { ServiceErrorException } from "@/lib/serviceError"; +import { getSidebarNavGroups } from "@/app/(app)/settings/layout"; +import { SidebarBase } from "../sidebarBase"; +import { Nav } from "./nav"; +import { SettingsSidebarHeader } from "./header"; + +export async function SettingsSidebar() { + const session = await auth(); + + const sidebarNavGroups = await getSidebarNavGroups(); + if (isServiceError(sidebarNavGroups)) { + throw new ServiceErrorException(sidebarNavGroups); + } + + return ( + } + > +