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/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml deleted file mode 100644 index 4d63a525b..000000000 --- a/.github/workflows/release-mcp.yml +++ /dev/null @@ -1,157 +0,0 @@ -name: Release MCP Package - -permissions: - contents: write - id-token: write - -on: - workflow_dispatch: - inputs: - bump_type: - description: "Type of version bump to apply" - required: true - type: choice - options: - - patch - - minor - - major - -concurrency: - group: release-mcp - cancel-in-progress: false - -jobs: - release: - runs-on: ubuntu-latest - - steps: - - name: Generate GitHub App token - id: generate_token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - - - name: Checkout repository - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - token: ${{ steps.generate_token.outputs.token }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - - name: Calculate new version - id: calculate_version - run: | - # Extract current version from package.json - CURRENT_VERSION=$(node -p "require('./packages/mcp/package.json').version") - - if [ -z "$CURRENT_VERSION" ]; then - echo "Error: Could not extract current version from package.json" - exit 1 - fi - - echo "Current version: $CURRENT_VERSION" - - # Parse version components - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" - - # Apply bump based on input - BUMP_TYPE="${{ inputs.bump_type }}" - case "$BUMP_TYPE" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - *) - echo "Error: Invalid bump type: $BUMP_TYPE" - exit 1 - ;; - esac - - NEW_VERSION="$MAJOR.$MINOR.$PATCH" - echo "New version: $NEW_VERSION" - - # Export to GITHUB_ENV for use in subsequent steps - echo "VERSION=$NEW_VERSION" >> $GITHUB_ENV - - # Export to GITHUB_OUTPUT for use in other jobs - echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT - - - name: Check if version already exists - run: | - if grep -q "## \[$VERSION\]" packages/mcp/CHANGELOG.md; then - echo "Error: Version $VERSION already exists in CHANGELOG.md" - exit 1 - fi - if git tag | grep -q "^mcp-v$VERSION$"; then - echo "Error: Tag mcp-v$VERSION already exists" - exit 1 - fi - - - name: Update CHANGELOG.md - run: | - DATE=$(date +%Y-%m-%d) - - # Insert the new version header after the [Unreleased] line - sed -i "/## \[Unreleased\]/a\\ - \\ - ## [$VERSION] - $DATE" packages/mcp/CHANGELOG.md - - echo "Updated CHANGELOG.md with version $VERSION" - cat packages/mcp/CHANGELOG.md | head -n 20 - - - name: Update package.json version - run: | - node -e " - const fs = require('fs'); - const path = 'packages/mcp/package.json'; - const pkg = JSON.parse(fs.readFileSync(path, 'utf8')); - pkg.version = process.env.VERSION; - fs.writeFileSync(path, JSON.stringify(pkg, null, 4) + '\n'); - " - echo "Updated package.json to version $VERSION" - head -n 5 packages/mcp/package.json - - - name: Configure git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Commit changes - run: | - git add packages/mcp/CHANGELOG.md packages/mcp/package.json - git commit -m "Release @sourcebot/mcp v$VERSION" - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Build MCP package - run: yarn workspace @sourcebot/mcp build - - - name: Publish to npm - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - cd packages/mcp - npm publish --provenance --access public - - - name: Push main - env: - GH_TOKEN: ${{ steps.generate_token.outputs.token }} - run: | - git push origin main - echo "✓ Pushed release commit to main" diff --git a/AGENTS.md b/AGENTS.md index 200241b78..69e435ef4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,6 @@ Standard dev commands are documented in `CONTRIBUTING.md` and `package.json`. Ke - **Build deps only:** `yarn build:deps` (builds shared packages: schemas, db, shared, query-language) - **DB migrations:** `yarn dev:prisma:migrate:dev` -### Deprecated Packages - -- **`packages/mcp`** - This standalone MCP package is deprecated. Do NOT modify it. MCP functionality is now handled by the web package at `packages/web/src/features/mcp/`. - ### Non-obvious Caveats - **Docker must be running** before `yarn dev`. Start it with `docker compose -f docker-compose-dev.yml up -d`. The backend will fail to connect to Redis/PostgreSQL otherwise. diff --git a/CHANGELOG.md b/CHANGELOG.md index a754c85cb..956ee8106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added ask connectors: connect 3rd party MCP servers to your ask agent. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) +- Added progress bar when navigating between pages. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) +- Added a integrated changelog into the sidebar. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) + +### Changed +- [**Breaking Change**] Changed the default role assignment to `Owner` for organizations on the free tier. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) +- [**Breaking Change**] Relicensed Ask Sourcebot and MCP under ee. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) +- Redesigned the app layout with a new collapsible sidebar navigation, replacing the previous top navigation bar. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) +- Expired offline license keys no longer crash the process. An expired key now degrades to the unlicensed state. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) +- Improved the `setup-sourcebot` wizard: prompts for a setup directory, clarifies that secrets are stored locally in `.env`, switches multi-select to Tab, hides "No results" until a real search runs, and detects/cleans up conflicting Docker deployments and volumes before starting. [#1106](https://github.com/sourcebot-dev/sourcebot/pull/1106) + ## [4.17.4] - 2026-05-30 ### Changed @@ -23,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [EE] Fixed Bitbucket Cloud account-driven permission sync after Atlassian's CHANGE-2770 removed `GET /2.0/user/permissions/repositories`. [#1217](https://github.com/sourcebot-dev/sourcebot/pull/1217) - Fixed issue where session invalidation (signout, user deletion, removal from org) was not reflected by `/api/auth/session`. [#1219](https://github.com/sourcebot-dev/sourcebot/pull/1219) - [EE] Fixed issue where an OAuth account-linking attempt without a valid signed-in session would silently create an orphan User row instead of rejecting the request. [#1221](https://github.com/sourcebot-dev/sourcebot/pull/1221) + ## [4.17.2] - 2026-05-16 ### Added @@ -1026,7 +1039,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added audit logging. [#355](https://github.com/sourcebot-dev/sourcebot/pull/355) - ### Fixed - Delete account join request when redeeming an invite. [#352](https://github.com/sourcebot-dev/sourcebot/pull/352) 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/CONTRIBUTING.md b/CONTRIBUTING.md index febcbb812..44a5ce8d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,7 +131,7 @@ Then restart the dev server. Components that re-render will be highlighted in th - Keep pull requests small and focused - Explain the issue and why your change fixes it - Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase -- Update `CHANGELOG.md` with an entry under `[Unreleased]` linking to your PR. New entries should be placed at the bottom of their section. If your change touches `packages/mcp`, update `packages/mcp/CHANGELOG.md` instead. +- Update `CHANGELOG.md` with an entry under `[Unreleased]` linking to your PR. New entries should be placed at the bottom of their section. ### UI Changes diff --git a/Makefile b/Makefile index 8db819228..0c58c00ac 100644 --- a/Makefile +++ b/Makefile @@ -37,8 +37,6 @@ clean: packages/db/dist \ packages/schemas/node_modules \ packages/schemas/dist \ - packages/mcp/node_modules \ - packages/mcp/dist \ packages/shared/node_modules \ packages/shared/dist \ .sourcebot 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 b64c3bc0d..c22b94b07 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": { @@ -2290,7 +2289,7 @@ "summary": "Get a user", "description": "Fetches profile details for a single organization member by `userId`. Only organization owners can access this endpoint.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "parameters": [ { @@ -2365,7 +2364,7 @@ "summary": "Delete a user", "description": "Permanently deletes a user and all associated records. Only organization owners can delete other users.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "parameters": [ { @@ -2442,7 +2441,7 @@ "summary": "List users", "description": "Returns all members of the organization. Only organization owners can access this endpoint.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "responses": { "200": { @@ -2487,7 +2486,7 @@ "summary": "List audit records", "description": "Returns a paginated list of audit log entries. Only organization owners can access this endpoint.", "x-mint": { - "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it.\n" + "content": "\nThis API is only available with an active Enterprise license. Please add your [license key](/docs/activating-a-subscription) to activate it.\n" }, "parameters": [ { diff --git a/docs/docs.json b/docs/docs.json index d70056533..c33a653dd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -40,18 +40,10 @@ "pages": [ "docs/overview", { - "group": "Deployment", + "group": "Deploy Sourcebot", "pages": [ "docs/deployment/docker-compose", - "docs/deployment/k8s", - "docs/deployment/sizing-guide", - { - "group": "Infrastructure", - "pages": [ - "docs/deployment/infrastructure/architecture", - "docs/deployment/infrastructure/redis" - ] - } + "docs/deployment/k8s" ] } ] @@ -61,8 +53,8 @@ "pages": [ { "group": "Code Search", + "root": "docs/features/search/code-search", "pages": [ - "docs/features/search/overview", "docs/features/search/syntax-reference", "docs/features/search/ai-search-assist", "docs/features/search/multi-branch-indexing", @@ -71,8 +63,9 @@ }, { "group": "Ask Sourcebot", + "root": "docs/features/ask/ask-sourcebot", "pages": [ - "docs/features/ask/overview", + "docs/features/ask/connectors", "docs/features/ask/chat-sharing", "docs/features/ask/add-model-providers" ] @@ -84,8 +77,8 @@ { "group": "Agents", "tag": "experimental", + "root": "docs/features/agents/agents", "pages": [ - "docs/features/agents/overview", "docs/features/agents/review-agent" ] } @@ -98,8 +91,8 @@ "docs/configuration/environment-variables", { "group": "Indexing your code", + "root": "docs/connections/indexing-your-code", "pages": [ - "docs/connections/overview", "docs/connections/github", "docs/connections/gitlab", "docs/connections/bitbucket-cloud", @@ -117,23 +110,48 @@ "docs/configuration/idp", { "group": "Authentication", + "root": "docs/configuration/auth/authentication", "pages": [ - "docs/configuration/auth/overview", "docs/configuration/auth/providers", "docs/configuration/auth/access-settings", "docs/configuration/auth/roles-and-permissions", "docs/configuration/auth/faq" ] }, - "docs/license-key", "docs/configuration/transactional-emails", "docs/configuration/structured-logging", "docs/configuration/audit-logs" ] }, + { + "group": "Subscribe", + "pages": [ + "docs/activating-a-subscription", + "docs/billing", + "docs/free-trial" + ] + }, + { + "group": "Misc", + "pages": [ + "docs/misc/architecture", + "docs/misc/scalability", + "docs/misc/telemetry", + "docs/misc/service-ping", + "docs/deployment/sizing-guide", + { + "group": "Infrastructure", + "pages": [ + "docs/deployment/infrastructure/architecture", + "docs/deployment/infrastructure/redis" + ] + } + ] + }, { "group": "Upgrade", "pages": [ + "docs/upgrade/v4-to-v5-guide", "docs/upgrade/v3-to-v4-guide", "docs/upgrade/v2-to-v3-guide" ] @@ -230,5 +248,31 @@ "appearance": { "default": "dark", "strict": false - } + }, + "redirects": [ + { + "source": "/docs/features/search/overview", + "destination": "/docs/features/search/code-search" + }, + { + "source": "/docs/features/ask/overview", + "destination": "/docs/features/ask/ask-sourcebot" + }, + { + "source": "/docs/features/agents/overview", + "destination": "/docs/features/agents/agents" + }, + { + "source": "/docs/connections/overview", + "destination": "/docs/connections/indexing-your-code" + }, + { + "source": "/docs/configuration/auth/overview", + "destination": "/docs/configuration/auth/authentication" + }, + { + "source": "/docs/license-key", + "destination": "/docs/activating-a-subscription" + } + ] } diff --git a/docs/docs/activating-a-subscription.mdx b/docs/docs/activating-a-subscription.mdx new file mode 100644 index 000000000..01eb010bf --- /dev/null +++ b/docs/docs/activating-a-subscription.mdx @@ -0,0 +1,39 @@ +--- +title: Activating a Subscription +sidebarTitle: Activating a subscription +--- + +You can activate a paid plan subscription by using either an Activation Code or a License Key. + +# Activation Code (Online Licensing) + +Your Sourcebot deployment must be able to send a [Service Ping](/docs/misc/service-ping) to validate your Activation Code. If your deployment is unable to send a service ping for 7 days it will downgrade to the free plan until a successul ping is sent. + +The default mechanism for activating a subscription is through an Activation Code. This activation mechanism allows your Sourcebot deployment to automatically manage its subscription by regularly +sychronizing with our license server. + +### What data does Sourcebot collect? + +To enable online licensing, your Sourcebot deployment must be able to communicate with our license server through our [Service Ping](/docs/misc/service-ping). The data that is transmitted is limited to information that is +required to administer your Sourcebot license and support dynamic seat increases. No sensitive data (source code, AI inputs/outputs, credentials, or user information) will ever be transmitted. For more information, please check out our [privacy policy](https://www.sourcebot.dev/privacy) + +# License Key (Offline Licensing) + +The default mechanism for activating a subscription is now through an [Activation Code](#activation-code-online-licensing). Offline licensing using a License Key may be requested by reaching out using our enterprise contact form on our [pricing page](https://www.sourcebot.dev/pricing) + +After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. + +If you'd like to increase the number of seats on your Sourcebot deployment you must request a new license key, manually update this environment variable, and then restart your deployment. + +```bash +docker run \ + -e SOURCEBOT_EE_LICENSE_KEY= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +## FAQ + +#### What happens when my subscription expires? + +When a paid plan subscription expires, your deployment will automatically be downgraded to the free plan. You must upgrade your subscription in order to continue using the paid features. \ No newline at end of file diff --git a/docs/docs/billing.mdx b/docs/docs/billing.mdx new file mode 100644 index 000000000..7e4c6b860 --- /dev/null +++ b/docs/docs/billing.mdx @@ -0,0 +1,42 @@ +--- +title: Seat Reconciliation +sidebarTitle: Seat Reconciliation +--- + +When using an [Activation Code](/docs/activating-a-subscription#activation-code-online-licensing), your Sourcebot deployment is permitted to exceed the number of seats you've purchsed. Seat reconciliation refers +to how these excess seats are billed, and it depends on your billing period. + +Seat reconciliation doesn't apply if you're using a [License Key](/docs/activating-a-subscription#license-key-offline-licensing) since the key sets a hard cap on seats. If you're using a license key and would like more seats, please reach out to us to purchase a new license key. + +For a more detailed breakdown, please refer to our [Software License Agreeement](https://www.sourcebot.dev/terms) which is the source of truth on how seats are handled and billed. + +# Monthly Billing + +Sourcebot supports monthly with standard proration behavior. Users added during the month are prorated across the remaining days and appear on your next invoice. Users removed during the month take effect at the next cycle, and are not refunded for the current cycle. + +# Yearly Billing + +## Quarterly Reconciliation +The standard mechanism for seat reconciliation in yearly plans is through quarterly reconciliation. You are billed per quarter on a prorated basis for the remaining portion of your subscription term for any additional seats you activate. + +To prevent overages, you can restrict you may register on your Sourcebot deployment through our [access controls](/docs/configuration/auth/access-settings). + + + +### 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/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index aed74bcac..1db8f6a66 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -5,7 +5,7 @@ sidebarTitle: Audit logs import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + Audit logs are a collection of notable events performed by users within a Sourcebot deployment. Each audit log records information on the action taken, the user who performed the action, and when the action took place. @@ -19,7 +19,7 @@ Audit logs are enabled by default and can be controlled with the `SOURCEBOT_EE_A By default, audit logs older than 180 days are automatically pruned daily. You can configure the retention period using the `SOURCEBOT_EE_AUDIT_RETENTION_DAYS` [environment variable](/docs/configuration/environment-variables). Set it to `0` to disable automatic pruning and retain logs indefinitely. ## Fetching Audit Logs -Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API: +Audit logs are stored in the [postgres database](/docs/misc/architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API: ```bash icon="terminal" Fetch audit logs curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ diff --git a/docs/docs/configuration/auth/access-settings.mdx b/docs/docs/configuration/auth/access-settings.mdx index 95782fd3a..add0c3827 100644 --- a/docs/docs/configuration/auth/access-settings.mdx +++ b/docs/docs/configuration/auth/access-settings.mdx @@ -5,16 +5,6 @@ sidebarTitle: Access settings There are various settings to control how users access your Sourcebot deployment. -# Anonymous access - -Anonymous access cannot be enabled if you have an enterprise license. If you have any questions about this restriction [reach out to us](https://www.sourcebot.dev/contact). - -By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access. - -This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable. - -When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role. - # Member Approval By default, Sourcebot requires new members to be approved by an owner of the deployment. This section explains how approvals work and how @@ -37,4 +27,15 @@ Owners can see and manage all pending join requests by navigating to **Settings If member approval is required, an owner of the deployment can enable an invite link. When enabled, users can use this invite link to register and be automatically added to the organization without approval: -![Invite Link Toggle](/images/invite_link_toggle.png) \ No newline at end of file +![Invite Link Toggle](/images/invite_link_toggle.png) + + +# Anonymous access + +Anonymous access is only available in the free plan. If you have any questions about this restriction [reach out to us](https://www.sourcebot.dev/contact). + +By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access. + +This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable. + +When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role. diff --git a/docs/docs/configuration/auth/overview.mdx b/docs/docs/configuration/auth/authentication.mdx similarity index 98% rename from docs/docs/configuration/auth/overview.mdx rename to docs/docs/configuration/auth/authentication.mdx index 3b138e35b..ad8024d37 100644 --- a/docs/docs/configuration/auth/overview.mdx +++ b/docs/docs/configuration/auth/authentication.mdx @@ -1,5 +1,5 @@ --- -title: Overview +title: Authentication --- If you're deploying Sourcebot behind a domain, you must set the [AUTH_URL](/docs/configuration/environment-variables) environment variable. diff --git a/docs/docs/configuration/auth/providers.mdx b/docs/docs/configuration/auth/providers.mdx index 5a7638bee..61e682a09 100644 --- a/docs/docs/configuration/auth/providers.mdx +++ b/docs/docs/configuration/auth/providers.mdx @@ -27,4 +27,4 @@ See [transactional emails](/docs/configuration/transactional-emails) for more de # Enterprise Authentication Providers Sourcebot supports authentication using several different [external identity providers](/docs/configuration/idp) as well. These identity providers require an -[enterprise license](/docs/license-key) \ No newline at end of file +[enterprise license](/docs/activating-a-subscription) \ No newline at end of file diff --git a/docs/docs/configuration/auth/roles-and-permissions.mdx b/docs/docs/configuration/auth/roles-and-permissions.mdx index 690400be5..8840e2961 100644 --- a/docs/docs/configuration/auth/roles-and-permissions.mdx +++ b/docs/docs/configuration/auth/roles-and-permissions.mdx @@ -1,23 +1,30 @@ --- -title: Roles and Permissions -sidebarTitle: Roles and permissions +title: Members and roles +sidebarTitle: Members and roles --- -Each member has a role which defines their permissions within an organization: +Sourcebot provides different role types to help you control access and permissions across your organization. | Role | Permission | | :--- | :--------- | -| `Owner` | An organization can have one or more `Owner`s. Owners have full access rights, including: connection management, organization management, and inviting new members. | -| `Member` | Read-only access to the organization. A `Member` can search across the repos indexed by an organization's connections, as well as view the organizations configuration and member list. However, they cannot modify this configuration or invite new members. | -| `Guest` | When accessing Sourcebot [anonymously](/docs/configuration/auth/access-settings#anonymous-access), a user has the `Guest` role. `Guest`'s can search across repos indexed by an organization's connections, but cannot view any information regarding the organizations configuration or members. | +| `Owner` | Owners have full administrative control over all organization-level settings, including user management, access control, billing, and audit logs, in addition to all permissions a member can perform. | +| `Member` | Members have access and to use all standard features, such as code search, ask, mcp, etc., as well as their account settings. Members **cannot access** organization-level administration pages. | +| `Guest` | Guests are users that access Sourcebot without a account when [anonymous access](/docs/configuration/auth/access-settings#anonymous-access) is enabled. Guests can use some features, such as code search and browsing files, with additional limitations like not having chat history. Guests **cannot access** organization-level administration pages. | -## Managing owners +Note that when [permission syncing](/docs/features/permission-syncing) is enabled, users will only be able to view repositories they have access to. This applies to all roles, **including Owners**. + + +On the free plan, all signed-in users are given the `Owner` role. + + + +## Managing member roles import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + -organizations support multiple owners, allowing you to share administrative responsibilities across your team. Owners can promote members to owner and demote other owners back to member from **Settings -> Members**. +Owners can change the role of any user in the organization from **Settings → Members**. This lets you control who has administrative access by promoting trusted members to `Owner`, and scoping access back down to `Member` when administrative responsibilities change. Members settings page showing team members and their roles diff --git a/docs/docs/configuration/config-file.mdx b/docs/docs/configuration/config-file.mdx index 4e63cde85..d6162451d 100644 --- a/docs/docs/configuration/config-file.mdx +++ b/docs/docs/configuration/config-file.mdx @@ -23,7 +23,7 @@ The config file tells Sourcebot which repos to index, what language models to us The config file you provide Sourcebot must follow the [schema](https://github.com/sourcebot-dev/sourcebot/blob/main/schemas/v3/index.json). This schema consists of the following properties: -- [Connections](/docs/connections/overview) (`connections`): Defines a set of connections that tell Sourcebot which repos to index and from where +- [Connections](/docs/connections/indexing-your-code) (`connections`): Defines a set of connections that tell Sourcebot which repos to index and from where - [Language Models](/docs/configuration/language-model-providers) (`models`): Defines a set of language model providers for use with [Ask Sourcebot](/docs/features/ask) - [Settings](#settings) (`settings`): Additional settings to tweak your Sourcebot deployment - [Search Contexts](/docs/features/search/search-contexts) (`contexts`): Groupings of repos that you can search against diff --git a/docs/docs/configuration/declarative-config.mdx b/docs/docs/configuration/declarative-config.mdx index cdfdb4458..f998320b6 100644 --- a/docs/docs/configuration/declarative-config.mdx +++ b/docs/docs/configuration/declarative-config.mdx @@ -5,7 +5,7 @@ sidebarTitle: Declarative config import ConfigSchema from '/snippets/schemas/v3/index.schema.mdx' -Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/overview). +Some teams require Sourcebot to be configured via a file (where it can be stored in version control, run through CI/CD pipelines, etc.) instead of a web UI. For more information on configuring connections, see this [overview](/docs/connections/indexing-your-code). | Variable | Description | diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 875a32dbe..396c4cf36 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -10,8 +10,8 @@ The following environment variables allow you to configure your Sourcebot deploy | Variable | Default | Description | | :------- | :------ | :---------- | -| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |

Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/docs/configuration/auth/overview) for more info

| -| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |

Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/docs/configuration/auth/overview) for more info

| +| `AUTH_CREDENTIALS_LOGIN_ENABLED` | `true` |

Enables/disables authentication with basic credentials. Username and passwords are stored encrypted at rest within the postgres database. Checkout the [auth docs](/docs/configuration/auth/authentication) for more info

| +| `AUTH_EMAIL_CODE_LOGIN_ENABLED` | `false` |

Enables/disables authentication with a login code that's sent to a users email. `SMTP_CONNECTION_URL` and `EMAIL_FROM_ADDRESS` must also be set. Checkout the [auth docs](/docs/configuration/auth/authentication) for more info

| | `AUTH_SECRET` | Automatically generated at startup if no value is provided. Generated using `openssl rand -base64 33` |

Used to validate login session cookies

| | `AUTH_SESSION_MAX_AGE_SECONDS` | `2592000` (30 days) |

Relative time from now in seconds when to expire the session.

| | `AUTH_SESSION_UPDATE_AGE_SECONDS` | `86400` (1 day) |

How often the session should be updated in seconds. If set to `0`, session is updated every time.

| @@ -43,7 +43,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `SOURCEBOT_LOG_LEVEL` | `info` |

The Sourcebot logging level. Valid values are `debug`, `info`, `warn`, `error`, in order of severity.

| | `SOURCEBOT_STRUCTURED_LOGGING_ENABLED` | `false` |

Enables/disable structured JSON logging. See [this doc](/docs/configuration/structured-logging) for more info.

| | `SOURCEBOT_STRUCTURED_LOGGING_FILE` | - |

Optional file to log to if structured logging is enabled

| -| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/docs/overview#telemetry) for more info.

| +| `SOURCEBOT_TELEMETRY_DISABLED` | `false` |

Enables/disables telemetry collection in Sourcebot. See [this doc](/docs/misc/telemetry) for more info.

| | `DEFAULT_MAX_MATCH_COUNT` | `10000` |

The default maximum number of search results to return when using search in the web app.

| | `ALWAYS_INDEX_FILE_PATTERNS` | - |

A comma separated list of glob patterns matching file paths that should always be indexed, regardless of size or number of trigrams.

| | `NODE_USE_ENV_PROXY` | `0` |

Enables Node.js to automatically use `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables for network requests. Set to `1` to enable or `0` to disable. See [this doc](https://nodejs.org/en/learn/http/enterprise-network-configuration) for more info.

| diff --git a/docs/docs/configuration/idp.mdx b/docs/docs/configuration/idp.mdx index 52c342498..e99c12640 100644 --- a/docs/docs/configuration/idp.mdx +++ b/docs/docs/configuration/idp.mdx @@ -5,7 +5,7 @@ sidebarTitle: External identity providers import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + You can connect Sourcebot to various **external identity providers** to associate a Sourcebot user with one or more external service accounts (ex. Google, GitHub, etc). @@ -90,7 +90,7 @@ in the GitHub identity provider config. Metadata repository permission set to Read-only - - `"Contents" repository permissions (read)` (only needed if using the app to [authenticate a connection](/docs/connections/github#github-app)) + - `"Contents" repository permissions (read)` (only needed if using the app to [authenticate a connection](/docs/connections/github#authenticating-with-github)) Contents repository permission set to Read-only diff --git a/docs/docs/connections/ado-cloud.mdx b/docs/docs/connections/ado-cloud.mdx index b1d12d5e3..6808817b3 100644 --- a/docs/docs/connections/ado-cloud.mdx +++ b/docs/docs/connections/ado-cloud.mdx @@ -6,7 +6,7 @@ icon: https://www.svgrepo.com/show/448307/azure-devops.svg import AzureDevopsSchema from '/snippets/schemas/v3/azuredevops.schema.mdx' -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/ado-server.mdx b/docs/docs/connections/ado-server.mdx index 09a592e7f..61d243e54 100644 --- a/docs/docs/connections/ado-server.mdx +++ b/docs/docs/connections/ado-server.mdx @@ -6,7 +6,7 @@ icon: https://www.svgrepo.com/show/448307/azure-devops.svg import AzureDevopsSchema from '/snippets/schemas/v3/azuredevops.schema.mdx' -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/bitbucket-cloud.mdx b/docs/docs/connections/bitbucket-cloud.mdx index dfa620565..755b4f85f 100644 --- a/docs/docs/connections/bitbucket-cloud.mdx +++ b/docs/docs/connections/bitbucket-cloud.mdx @@ -10,7 +10,7 @@ import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' Looking for docs on Bitbucket Data Center? See [this doc](/docs/connections/bitbucket-data-center). -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index be536e9e9..c14e81bcc 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -10,7 +10,7 @@ import BitbucketSchema from '/snippets/schemas/v3/bitbucket.schema.mdx' Looking for docs on Bitbucket Cloud? See [this doc](/docs/connections/bitbucket-cloud). -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/generic-git-host.mdx b/docs/docs/connections/generic-git-host.mdx index 4ebf363b6..585bf0eb3 100644 --- a/docs/docs/connections/generic-git-host.mdx +++ b/docs/docs/connections/generic-git-host.mdx @@ -5,13 +5,13 @@ icon: git-alt import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' -Sourcebot can sync code from any Git host (by clone url). This is helpful when you want to search code that not in a [supported code host](/docs/connections/overview#platform-connection-guides). +Sourcebot can sync code from any Git host (by clone url). This is helpful when you want to search code that not in a [supported code host](/docs/connections/indexing-your-code#platform-connection-guides). -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Getting Started -To connect to a Git host, create a new [connection](/docs/connections/overview) with type `git` and specify the clone url in the `url` property. For example: +To connect to a Git host, create a new [connection](/docs/connections/indexing-your-code) with type `git` and specify the clone url in the `url` property. For example: ```json { diff --git a/docs/docs/connections/gerrit.mdx b/docs/docs/connections/gerrit.mdx index ae90104ee..0db4dace3 100644 --- a/docs/docs/connections/gerrit.mdx +++ b/docs/docs/connections/gerrit.mdx @@ -10,7 +10,7 @@ import GerritSchema from '/snippets/schemas/v3/gerrit.schema.mdx' Sourcebot can sync code from self-hosted gerrit instances. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Connecting to a Gerrit instance diff --git a/docs/docs/connections/gitea.mdx b/docs/docs/connections/gitea.mdx index 1c589c413..b21e35d51 100644 --- a/docs/docs/connections/gitea.mdx +++ b/docs/docs/connections/gitea.mdx @@ -8,7 +8,7 @@ import GiteaSchema from '/snippets/schemas/v3/gitea.schema.mdx' Sourcebot can sync code from Gitea Cloud, and self-hosted. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/github.mdx b/docs/docs/connections/github.mdx index 5f03fef96..fd5fbbb42 100644 --- a/docs/docs/connections/github.mdx +++ b/docs/docs/connections/github.mdx @@ -9,7 +9,7 @@ import LicenseKeyRequired from '/snippets/license-key-required.mdx' Sourcebot can sync code from GitHub.com, GitHub Enterprise Server, and GitHub Enterprise Cloud. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples @@ -161,7 +161,7 @@ In order to index private repositories, you'll need to authenticate with GitHub. [GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#fine-grained-personal-access-tokens) - Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/overview) config object. + Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/indexing-your-code) config object. The most common mechanism of doing this is defining an environment variable that holds the PAT: @@ -195,7 +195,7 @@ In order to index private repositories, you'll need to authenticate with GitHub. [GitHub docs](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic) - Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/overview) config object. + Next, provide the PAT via a [token](/docs/configuration/config-file#tokens) which is referenced in the `token` field in the [connection](/docs/connections/indexing-your-code) config object. The most common mechanism of doing this is defining an environment variable that holds the PAT: diff --git a/docs/docs/connections/gitlab.mdx b/docs/docs/connections/gitlab.mdx index d8013d83a..94b8d78da 100644 --- a/docs/docs/connections/gitlab.mdx +++ b/docs/docs/connections/gitlab.mdx @@ -8,7 +8,7 @@ import GitLabSchema from '/snippets/schemas/v3/gitlab.schema.mdx' Sourcebot can sync code from GitLab.com, Self Managed (CE & EE), and Dedicated. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Examples diff --git a/docs/docs/connections/overview.mdx b/docs/docs/connections/indexing-your-code.mdx similarity index 93% rename from docs/docs/connections/overview.mdx rename to docs/docs/connections/indexing-your-code.mdx index e244debeb..d9f851cf7 100644 --- a/docs/docs/connections/overview.mdx +++ b/docs/docs/connections/indexing-your-code.mdx @@ -1,6 +1,5 @@ --- -title: Overview -sidebarTitle: Overview +title: Indexing your code --- import SupportedPlatforms from '/snippets/platform-support.mdx' @@ -48,7 +47,7 @@ When a connection is first discovered, or the `resyncConnectionIntervalMs` [sett 1. Fetching the latest changes from `HEAD` (and any [additional branches](/docs/features/search/multi-branch-indexing)) from the code host. 2. Re-indexing the repository. -This is processed in a [job queue](/docs/overview#architecture), and is parallelized across multiple worker processes. Jobs will take longer to complete the first time a repository is synced, or when a diff is large. +This is processed in a [job queue](/docs/misc/architecture), and is parallelized across multiple worker processes. Jobs will take longer to complete the first time a repository is synced, or when a diff is large. On the home page, you can view the sync status of ongoing jobs: diff --git a/docs/docs/connections/local-repos.mdx b/docs/docs/connections/local-repos.mdx index 62b76c692..0be9a647b 100644 --- a/docs/docs/connections/local-repos.mdx +++ b/docs/docs/connections/local-repos.mdx @@ -7,7 +7,7 @@ import GenericGitHost from '/snippets/schemas/v3/genericGitHost.schema.mdx' Sourcebot can sync code from generic git repositories stored in a local directory. This can be helpful in scenarios where you already have a large number of repos already checked out. Local repositories are treated as **read-only**, meaning Sourcebot will **not** `git fetch` new revisions. -If you're not familiar with Sourcebot [connections](/docs/connections/overview), please read that overview first. +If you're not familiar with Sourcebot [connections](/docs/connections/indexing-your-code), please read that overview first. ## Getting Started @@ -40,7 +40,7 @@ To get Sourcebot to index these repositories: - We can now create a new git [connection](/docs/connections/overview), specifying local paths with the `file://` prefix. Glob patterns are supported. For example: + We can now create a new git [connection](/docs/connections/indexing-your-code), specifying local paths with the `file://` prefix. Glob patterns are supported. For example: ```json { diff --git a/docs/docs/deployment/docker-compose.mdx b/docs/docs/deployment/docker-compose.mdx index 15055a8cc..7899055a7 100644 --- a/docs/docs/deployment/docker-compose.mdx +++ b/docs/docs/deployment/docker-compose.mdx @@ -2,60 +2,105 @@ 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 teach you how to deploy Sourcebot using [Docker Compose](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml). This is the simplest way to get started with Sourcebot, and should only take a couple minutes. 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 may cause your Sourcebot deployment to be unstable. +- Docker & Docker Compose: Make sure both are installed and up-to-date. +- Node.js 18+: Required for the setup CLI + +For a more detailed requirements breakdown, please check out our [sizing guide](/docs/deployment/sizing-guide). + +## Option 1: Setup CLI + +The setup CLI will guide you through deploying Sourcebot and connecting it to your code hosts and LLM providers. This will create a new directory and generate a [configuration file](/docs/configuration/config-file) +and Docker Compose file for you. + +Run the following command to get started: + +``` +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 which repos to index, what language models to use, 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 +Congrats, you've deployed Sourcebot! Check out the following guides to learn more. + +#### Configuration + + + + Connect Sourcebot to your code hosts and configure which repos to index. + + + Configure the LLM providers that power Ask Sourcebot. + + + Set up SSO and configure how users sign in to your instance. + + + Right-size your deployment based on the number of repos you're indexing. + + + +#### Features + + + Search and navigate across all your repos and branches. + + + Ask questions across all your repos and branches. + + + Code context layer for all your agents. + + diff --git a/docs/docs/deployment/infrastructure/architecture.mdx b/docs/docs/deployment/infrastructure/architecture.mdx index 023d173a0..ba58e0960 100644 --- a/docs/docs/deployment/infrastructure/architecture.mdx +++ b/docs/docs/deployment/infrastructure/architecture.mdx @@ -1,5 +1,5 @@ --- title: Architecture Overview -url: /docs/overview#architecture +url: /docs/misc/architecture sidebarTitle: Architecture Overview --- diff --git a/docs/docs/deployment/sizing-guide.mdx b/docs/docs/deployment/sizing-guide.mdx index 28f26406b..ebe772ad3 100644 --- a/docs/docs/deployment/sizing-guide.mdx +++ b/docs/docs/deployment/sizing-guide.mdx @@ -5,7 +5,7 @@ title: "Sizing Guide" Sourcebot runs as a single container (vertical scaling). This guide helps you choose the right CPU, memory, and disk allocation based on the number of repositories you plan to index. -These recommendations are based on real-world deployments. Your results may vary depending on repository sizes, search patterns, and whether you use features like [multi-branch indexing](/docs/features/search/multi-branch-indexing) or [Ask Sourcebot](/docs/features/ask/overview). +These recommendations are based on real-world deployments. Your results may vary depending on repository sizes, search patterns, and whether you use features like [multi-branch indexing](/docs/features/search/multi-branch-indexing) or [Ask Sourcebot](/docs/features/ask/ask-sourcebot). ## Recommendations @@ -48,7 +48,7 @@ Lowering these values reduces peak resource usage at the cost of slower initial ## Audit log storage -Audit logging is an enterprise feature and is only available with an [enterprise license](/docs/overview#license-key). If you are not on an enterprise plan, audit logs are not stored and this section does not apply. +Audit logging is an enterprise feature and is only available with an [enterprise license](/docs/activating-a-subscription). If you are not on an enterprise plan, audit logs are not stored and this section does not apply. [Audit logs](/docs/configuration/audit-logs) are stored in the Postgres database connected to your Sourcebot deployment. Each audit record captures the action performed, the actor, the target, a timestamp, and optional metadata (e.g., request source). There are three database indexes on the audit table to support analytics and lookup queries. diff --git a/docs/docs/features/agents/overview.mdx b/docs/docs/features/agents/agents.mdx similarity index 91% rename from docs/docs/features/agents/overview.mdx rename to docs/docs/features/agents/agents.mdx index 5b3bea6f5..c14877e09 100644 --- a/docs/docs/features/agents/overview.mdx +++ b/docs/docs/features/agents/agents.mdx @@ -1,6 +1,5 @@ --- -title: "Agents Overview" -sidebarTitle: "Overview" +title: "Agents" --- import ExperimentalFeatureWarning from '/snippets/experimental-feature-warning.mdx' diff --git a/docs/docs/features/ask/overview.mdx b/docs/docs/features/ask/ask-sourcebot.mdx similarity index 92% rename from docs/docs/features/ask/overview.mdx rename to docs/docs/features/ask/ask-sourcebot.mdx index 79caf6283..3fcf81576 100644 --- a/docs/docs/features/ask/overview.mdx +++ b/docs/docs/features/ask/ask-sourcebot.mdx @@ -1,10 +1,10 @@ --- -title: Overview +title: Ask Sourcebot --- Ask Sourcebot gives you the ability to ask complex questions about your entire codebase in natural language. -It uses Sourcebot’s existing [code search](/docs/features/search/overview) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code, +It uses Sourcebot’s existing [code search](/docs/features/search/code-search) and [navigation](/docs/features/code-navigation) tools to allow reasoning models to search your code, follow code nav references, and provide an answer that’s rich with inline citations and navigable code snippets. Ask Sourcebot **uses an LLM provider you configure with Sourcebot**, ensuring you have full control over where your data is sent. @@ -19,7 +19,7 @@ Ask Sourcebot **uses an LLM provider you configure with Sourcebot**, ensuring yo Learn how to connect your language model to Sourcebot - + Learn how to index your repos so you can ask questions about them diff --git a/docs/docs/features/ask/chat-sharing.mdx b/docs/docs/features/ask/chat-sharing.mdx index bb60d8bed..e99f2a018 100644 --- a/docs/docs/features/ask/chat-sharing.mdx +++ b/docs/docs/features/ask/chat-sharing.mdx @@ -19,7 +19,7 @@ Every chat has a visibility setting that controls who can access it: ### Private (Default) - Only the chat owner can view the conversation - Other users cannot access the chat, even with the link -- You can explicitly invite specific org members to view the chat (requires [Enterprise license](/docs/license-key)) +- You can explicitly invite specific org members to view the chat (requires [Enterprise license](/docs/activating-a-subscription)) ### Public - Anyone with the link can view the conversation @@ -29,7 +29,7 @@ Every chat has a visibility setting that controls who can access it: ## Sharing with Specific Users -Sharing with specific users requires an [Enterprise license](/docs/license-key). +Sharing with specific users requires an [Enterprise License](/docs/activating-a-subscription). Invite users dialog diff --git a/docs/docs/features/ask/connectors.mdx b/docs/docs/features/ask/connectors.mdx new file mode 100644 index 000000000..d962f25a0 --- /dev/null +++ b/docs/docs/features/ask/connectors.mdx @@ -0,0 +1,65 @@ +--- +title: Connectors +--- + +Connectors let Ask Sourcebot access your apps and services so it can answer questions and take action on your behalf. + +They communicate over MCP and use the same permission boundaries as the connected user. This means a connector can only retrieve data that the user is authorized to access. + +Once [configured](#configuration), connectors make Ask Sourcebot more useful across your workflow. For example, Ask Sourcebot can help identify the root cause of a bug in your codebase, then create a Linear or Jira +issue with the findings and a link to the chat so teammates can pick up where you left off. + +## Usage + +From the Ask page, you can choose which connectors are available for each chat. + +Turn off a connector to prevent Ask Sourcebot from using it in that chat. + + + Toggle off connector + + +During a chat, Ask Sourcebot shows each connector tool it uses. + +If Ask Sourcebot needs to use a tool that requires approval, it pauses and asks for your permission before continuing. + + + Request tool permission + + +## Configuration + +An owner must add a connector before organization members can use it. + +To add a connector: +1. **Settings → Workspace → Ask Agent** +2. Click **Add connector**. +3. Enter the MCP server URL and the name the organization will see for this server. + +Most MCP servers support dynamic client registration. When this is available, Sourcebot registers with the server automatically. + +Some MCP servers require a pre-registered OAuth app instead. In that case, an owner must register Sourcebot with the connector platform first, +then enter the OAuth client ID and client secret in Sourcebot. + +
+ + OAuth client credentials dialog for a Slack connector + +
+ +## Connecting + +After an owner adds connectors for your organization, go to **Settings → Account → Ask Agent** to connect them. + +You can see all available connectors on this page. After you connect one, you can inspect the tools it provides and what each tool does. + +
+ + Account connectors page + +
+ + diff --git a/docs/docs/features/mcp-server.mdx b/docs/docs/features/mcp-server.mdx index ff0d2be26..7595a3c12 100644 --- a/docs/docs/features/mcp-server.mdx +++ b/docs/docs/features/mcp-server.mdx @@ -7,6 +7,8 @@ import LicenseKeyRequired from '/snippets/license-key-required.mdx' The Sourcebot MCP Server connects AI tools to your [Sourcebot deployment](/docs/deployment/docker-compose). This gives your agents the ability to search, read files, resolve references & definitions, and more across all of your code hosted on Sourcebot. + + ## Use cases - **Context for local agents:** Plug the MCP into coding agents like Cursor, Claude Code, or Copilot to give them context across your entire codebase, not just the open workspace. @@ -16,7 +18,7 @@ The Sourcebot MCP Server connects AI tools to your [Sourcebot deployment](/docs/ Sourcebot MCP uses a [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) transport hosted at the `/api/mcp` route. Two authorization mechanisms are supported: -- **OAuth (preferred)**: MCP clients that support OAuth 2.0 will automatically handle the authorization flow and issue a short lived access token. No API key or manual token management required. Only available with an active [Enterprise license](/docs/license-key). +- **OAuth (preferred)**: MCP clients that support OAuth 2.0 will automatically handle the authorization flow and issue a short lived access token. No API key or manual token management required. Only available with a paid [subscription](/docs/activating-a-subscription). - **API key**: Any MCP client can authorize using a Sourcebot API key passed as a `Authorization: Bearer ` header. Create one in **Settings → API Keys**. You can read more about the options in the [authorization](#authorization) section. diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index 1491274a8..2aa8c0da6 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -149,7 +149,7 @@ The button will show a spinner while the sync is in progress and display a confi # Overriding enforcement per connection -Each [connection](/docs/connections/overview) supports two flags that control permission enforcement for that connection's repositories: +Each [connection](/docs/connections/indexing-your-code) supports two flags that control permission enforcement for that connection's repositories: - **`enforcePermissions`**: Controls whether repository permissions are enforced for the connection. When `PERMISSION_SYNC_ENABLED` is false, this setting has no effect. Defaults to the value of `PERMISSION_SYNC_ENABLED`. - **`enforcePermissionsForPublicRepos`**: Controls whether repository permissions are enforced for public repositories in the connection. When true, public repositories are only visible to users with a linked account for the connection's code host. When false, public repositories are visible to all users. Has no effect when `enforcePermissions` is false. Defaults to false. diff --git a/docs/docs/features/search/overview.mdx b/docs/docs/features/search/code-search.mdx similarity index 93% rename from docs/docs/features/search/overview.mdx rename to docs/docs/features/search/code-search.mdx index f9947ac96..9a1437dce 100644 --- a/docs/docs/features/search/overview.mdx +++ b/docs/docs/features/search/code-search.mdx @@ -1,5 +1,5 @@ --- -title: Overview +title: Code Search --- Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more. @@ -19,7 +19,7 @@ Search across all your repos/branches across any code host platform. Blazingly f Learn how to self-host Sourcebot in a few simple steps.
- + Learn how to index your repos so you can ask questions about them diff --git a/docs/docs/features/search/multi-branch-indexing.mdx b/docs/docs/features/search/multi-branch-indexing.mdx index aad655915..9857ed3c0 100644 --- a/docs/docs/features/search/multi-branch-indexing.mdx +++ b/docs/docs/features/search/multi-branch-indexing.mdx @@ -15,7 +15,7 @@ By default, only the default branch of a repository is indexed and can be search Multi-branch indexing is currently limited to 64 branches and tags. Please see [this issue](https://github.com/sourcebot-dev/sourcebot/issues/461) for more details. -Multi-branch indexing is configured in the [connection](/docs/connections/overview) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example: +Multi-branch indexing is configured in the [connection](/docs/connections/indexing-your-code) using the `revisions.branches` and `revisions.tags` arrays. Glob patterns are supported. For example: ```json { diff --git a/docs/docs/features/search/search-contexts.mdx b/docs/docs/features/search/search-contexts.mdx index 3fc17fa79..60aed4070 100644 --- a/docs/docs/features/search/search-contexts.mdx +++ b/docs/docs/features/search/search-contexts.mdx @@ -6,7 +6,7 @@ sidebarTitle: Search contexts import SearchContextSchema from '/snippets/schemas/v3/searchContext.schema.mdx' import LicenseKeyRequired from '/snippets/license-key-required.mdx' - + A **search context** is a user-defined grouping of repositories that helps focus searches on specific areas of your codebase, like frontend, backend, or infrastructure code. Some example queries using search contexts: diff --git a/docs/docs/free-trial.mdx b/docs/docs/free-trial.mdx new file mode 100644 index 000000000..ddf54b6a6 --- /dev/null +++ b/docs/docs/free-trial.mdx @@ -0,0 +1,40 @@ +--- +title: Free Trial +sidebarTitle: Free trial +--- + +Sourcebot offers a **14-day free trial** of the paid plan with no credit card required. The trial is started from inside your Sourcebot deployment, so you need to deploy Sourcebot first. + + + If your organization requires a license before deploying any software, reach out through the enterprise contact form on our [pricing page](https://www.sourcebot.dev/pricing). + + +# Deploy Sourcebot for free + +Sourcebot has a **free plan** that doesn't require any registration. You can self-host Sourcebot in your own infrastructure in under a minute: + + + + The fastest way to get Sourcebot running locally or on a server. + + + Deploy Sourcebot to a Kubernetes cluster with Helm. + + + +# Start your trial + +Your Sourcebot deployment must be able to send a [Service Ping](/docs/misc/service-ping) to activate and maintain your trial. If your deployment is unable to send a service ping for 7 days it will downgrade to the free plan until a successful ping is sent. + +Once your deployment is running, sign in and follow the onboarding flow. You'll see a **Start 14-day free trial** option for Sourcebot Pro. Confirm your email on the checkout page and submit. No credit card is required. + +If you skip the trial during onboarding, you can start it later from **Settings → License**. + +Each Sourcebot deployment is eligible for one free trial. + +# After the trial ends + +- If you add a payment method during the trial, your subscription continues automatically when the 14 days are up. +- If you don't add a payment method, your deployment downgrades to the free plan. Your data is preserved and free features remain available. + +You can add a payment method anytime during the trial from **Settings → License**. For details on how the paid plan is activated and validated, see [Activating a Subscription](/docs/activating-a-subscription). diff --git a/docs/docs/license-key.mdx b/docs/docs/license-key.mdx deleted file mode 100644 index e1f565b0b..000000000 --- a/docs/docs/license-key.mdx +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: License key -sidebarTitle: License key ---- - - -If you'd like a trial license, [reach out](https://www.sourcebot.dev/contact) and we'll send one over within 24 hours - - -All core Sourcebot features are available under the [FSL license](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license). Some additional features require a license key. See the [pricing page](https://www.sourcebot.dev/pricing) for more details. - - -## Activating a license key ---- - -After purchasing a license key, you can activate it by setting the `SOURCEBOT_EE_LICENSE_KEY` environment variable. - -```bash -docker run \ - -e SOURCEBOT_EE_LICENSE_KEY= \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` - -## Feature availability ---- - -| Feature | [FSL](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) | [Enterprise](https://github.com/sourcebot-dev/sourcebot/blob/main/ee/LICENSE) | -|:---------|:-----|:----------| -| [Search](/docs/features/search/syntax-reference) | ✅ | ✅ | -| [Full code host support](/docs/connections/overview) | ✅ | ✅ | -| [MCP Server](/docs/features/mcp-server) | ✅ | ✅ | -| [Agents](/docs/features/agents/overview) | ✅ | ✅ | -| [Login with credentials](/docs/configuration/auth/overview) | ✅ | ✅ | -| [Login with email codes](/docs/configuration/auth/overview) | ✅ | ✅ | -| [Login with SSO](/docs/configuration/auth/providers#enterprise-authentication-providers) | 🛑 | ✅ | -| [Permission syncing](/docs/features/permission-syncing) | 🛑 | ✅ | -| [Code navigation](/docs/features/code-navigation) | 🛑 | ✅ | -| [Search contexts](/docs/features/search/search-contexts) | 🛑 | ✅ | -| [Audit logs](/docs/configuration/audit-logs) | 🛑 | ✅ | -| [Analytics](/docs/features/analytics) | 🛑 | ✅ | -| [MCP OAuth](/docs/features/mcp-server#oauth-2-0) | 🛑 | ✅ | -| [Multiple owners](/docs/configuration/auth/roles-and-permissions#managing-owners) | 🛑 | ✅ | - - -## Questions? - -If you have any questions regarding licensing, please [contact us](https://www.sourcebot.dev/contact). \ No newline at end of file diff --git a/docs/docs/misc/architecture.mdx b/docs/docs/misc/architecture.mdx new file mode 100644 index 000000000..ad496f6ae --- /dev/null +++ b/docs/docs/misc/architecture.mdx @@ -0,0 +1,19 @@ +--- +title: "Architecture" +--- + +Sourcebot is shipped as a single docker container that runs a collection of services using [supervisord](https://supervisord.org/): + +![architecture diagram](/images/architecture_diagram.png) + +{/*TODO: outline the different services, how Sourcebot communicates with code hosts, and the different*/} + +Sourcebot consists of the following components: +- **Web Server** : main Next.js web application serving the Sourcebot UI. +- **Backend Worker** : Node.js process that incrementally syncs with code hosts (e.g., GitHub, GitLab etc.) and asynchronously indexes configured repositories. +- **Zoekt** : the [open-source](https://github.com/sourcegraph/zoekt), trigram indexing code search engine that powers Sourcebot under the hood. +- **Postgres** : transactional database for storing business-logic data. +- **Redis Job Queue** : fast in-memory store. Used with [BullMQ](https://docs.bullmq.io/) for queuing asynchronous work. +- **`.sourcebot/` cache** : file-system cache where persistent data is written. + +You can use managed Redis / Postgres services that run outside of the Sourcebot container by providing the `REDIS_URL` and `DATABASE_URL` environment variables, respectively. See the [environment variables](/docs/configuration/environment-variables) doc for more configuration options. diff --git a/docs/docs/misc/scalability.mdx b/docs/docs/misc/scalability.mdx new file mode 100644 index 000000000..dcc001250 --- /dev/null +++ b/docs/docs/misc/scalability.mdx @@ -0,0 +1,7 @@ +--- +title: "Scalability" +--- + +One of our design philosophies for Sourcebot is to keep our infrastructure [radically simple](https://www.radicalsimpli.city/) while balancing scalability concerns. Depending on the number of repositories you have indexed and the instance you are running Sourcebot on, you may experience slow search times or other performance degradations. Our recommendation is to vertically scale your instance by increasing the number of CPU cores and memory. See the [sizing guide](/docs/deployment/sizing-guide) for detailed recommendations. + +Sourcebot does not support horizontal scaling at this time, but it is on our roadmap. If this is something your team would be interested in, please contact us at [team@sourcebot.dev](mailto:team@sourcebot.dev). diff --git a/docs/docs/misc/service-ping.mdx b/docs/docs/misc/service-ping.mdx new file mode 100644 index 000000000..467b9a206 --- /dev/null +++ b/docs/docs/misc/service-ping.mdx @@ -0,0 +1,62 @@ +--- +title: "Service Ping" +api: "GET https://deployments.sourcebot.dev/schema" +--- + + + No sensitive data (source code, AI inputs/outputs, credentials, user information, etc) is ever transmitted. + + +By default, all Sourcebot deployments will send a Service Ping, which is an HTTPS encrypted ping to `https://deployments.sourcebot.dev/ping` at port 443, every 24 hours. + +The data contained within the Service Ping is limited to: + +| Field | Type | Description | +| --- | --- | --- | +| `installId` | `string` | Random UUID generated for your deployment. | +| `hostname` | `string` | Hostname of your Sourcebot deployment. | +| `version` | `string` | Sourcebot version (e.g. `v5.3.0`). | +| `userCount` | `integer` | Total registered users. | +| `repoCount` | `integer` | Total indexed repositories. | +| `dauCount` | `integer` | Daily active users. | +| `wauCount` | `integer` | Weekly active users. | +| `mauCount` | `integer` | Monthly active users. | +| `deploymentType` | `string` | Deployment flavor (e.g. `docker`, `helm`). | +| `isTelemetryEnabled` | `boolean` | Whether anonymous product telemetry is enabled. | +| `activationCode` | `string` | Activation code, if your instance has one bound. | + + +You can fetch the schema for the Service Ping by submitting a GET request to `https://deployments.sourcebot.dev/schema` + + +```json wrap icon="wifi" Example Service Ping +{ + "method": "POST", + "url": "http://localhost:3003/ping", + "path": "/ping", + "headers": { + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "accept-language": "*", + "connection": "keep-alive", + "content-length": "253", + "content-type": "application/json", + "host": "localhost:3003", + "sec-fetch-mode": "cors", + "user-agent": "node" + }, + "query": {} +} +body: { + "installId": "524a8f59-90aa-480d-a571-92dd8bzzzzze", + "version": "v4.17.2", + "userCount": 1, + "repoCount": 3, + "dauCount": 1, + "wauCount": 1, + "mauCount": 1, + "deploymentType": "other", + "isTelemetryEnabled": true, + "activationCode": "sb_act_3a925f51...." +} +``` \ No newline at end of file diff --git a/docs/docs/misc/telemetry.mdx b/docs/docs/misc/telemetry.mdx new file mode 100644 index 000000000..7fed997d8 --- /dev/null +++ b/docs/docs/misc/telemetry.mdx @@ -0,0 +1,22 @@ +--- +title: "Telemetry" +--- + +By default, Sourcebot collects anonymized usage data through [PostHog](https://posthog.com/) to help us improve the performance and reliability of our tool. We don't collect or transmit any information related to your codebase. In addition, all events are [sanitized](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/packages/web/src/app/posthogProvider.tsx) to ensure that no sensitive details (ex. ip address, query info) leave your machine. + +The data we collect includes general usage statistics and metadata such as query performance (e.g., search duration, error rates) to monitor the application's health and functionality. This information helps us better understand how Sourcebot is used and where improvements can be made. + +If you'd like to disable all telemetry, you can do so by setting the environment variable `SOURCEBOT_TELEMETRY_DISABLED` to `true`: + +```bash +docker run \ + -e SOURCEBOT_TELEMETRY_DISABLED=true \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest +``` + +If you disabled telemetry correctly, you'll see the following log when starting Sourcebot: + +```sh +Disabling telemetry since SOURCEBOT_TELEMETRY_DISABLED was set. +``` diff --git a/docs/docs/overview.mdx b/docs/docs/overview.mdx index 3284d7b7d..8a770823d 100644 --- a/docs/docs/overview.mdx +++ b/docs/docs/overview.mdx @@ -2,221 +2,27 @@ title: "Overview" --- -[Sourcebot](https://github.com/sourcebot-dev/sourcebot) is a platform that helps humans and agents understand your codebase: - -- [Code search](/docs/features/search/overview): Search and navigate across all your repos and branches, no matter where they’re hosted -- [Ask Sourcebot](/docs/features/ask): Ask questions about your codebase and have Sourcebot provide detailed answers grounded with inline citations -- [MCP](/docs/features/mcp-server): Enrich agent context windows with code across your organization - - - Learn how to self-host Sourcebot in a few simple steps. - - - - - - **Full-featured search:** Fast indexed-based search with regex support, filters, branch search, boolean logic, and more. - - **Self-hosted:** Deploy it in minutes using our official [docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot). All of your data stays on your machine. - - **Modern design:** Light/Dark mode, vim keybindings, keyboard shortcuts, syntax highlighting, etc. - - **Scalable:** Scales to millions of lines of code. - - **Fair-source:** Core features are [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license). - - - -## Features ---- - - - Find an overview of all Sourcebot features below. For details, see the individual documentation pages. - - -### Ask Sourcebot - -[Ask Sourcebot](/docs/features/ask) gives you the ability to ask complex questions about your codebase, and have Sourcebot provide detailed answers with inline citations. - - -- **Bring your own model:** [Configure](/docs/configuration/language-model-providers) to any language model you'd like -- **Inline citations:** Every answer Sourcebot provides is grounded with inline citations directly into your codebase -- **Multi-repo:** Ask questions about any repository you have indexed on Sourcebot - - - - -### Code Search - -Search across all your repos/branches across any code host platform. Blazingly fast, and supports regular expressions, repo/language search filters, boolean logic, and more. - - -- **Regex support:** Use regular expressions to find code with precision. -- **Query language:** Scope searches to specific files, repos, languages, symbol definitions and more using a rich [query language](/docs/features/search/syntax-reference). -- **Branch search:** Specify a list of branches to search across ([docs](/docs/features/search/multi-branch-indexing)). -- **Fast & scalable:** Sourcebot uses [trigram indexing](https://en.wikipedia.org/wiki/Trigram_search), allowing it to scale to massive codebases. -- **Syntax highlighting:** Syntax highlighting support for over [100+ languages](https://github.com/sourcebot-dev/sourcebot/blob/57724689303f351c279d37f45b6406f1d5d5d5ab/packages/web/src/lib/codemirrorLanguage.ts#L125). -- **Multi-repository:** Search across all of your repositories in a single search. -- **Search suggestions:** Get search suggestions as you craft your query. -- **Filter panel:** Filter results by repository or by language. - - - - -### Code Navigation - -[Code navigation](/docs/features/code-navigation) helps you jump between symbol definitions and references quickly when browsing source code in Sourcebot. - - -- **Hover popover:** Hovering over a symbol reveals the symbol's definition signature in a inline preview. -- **Go to definition:** Navigate to a symbol's definition(s). -- **Find references:** Get all references to a symbol. -- **Cross-repository:** Sourcebot can resolve references and definitions across repositories. - - - - - -### Cross code-host support - -Connect your code from multiple code-host platforms and search across all of them from a single interface. - - -- **Auto re-syncing:** Sourcebot will periodically sync with code hosts to pull the latest changes. -- **Flexible configuration:** Sourcebot uses an expressive [JSON schema](/docs/connections/overview) config format to specify exactly what repositories to index (and what not to index). -- **Parallel indexing:** Repositories are indexed in parallel. - - - - - - - - - - - - - - - - - - - - - -### Authentication - -Sourcebot comes with built-in support for authentication via [email/password](/docs/configuration/auth/providers#email-%2F-password), [email codes](/docs/configuration/auth/providers#email-codes), and various [SSO providers](/docs/configuration/auth/providers#enterprise-authentication-providers). - - -- **Configurable auth providers:** Configure the auth providers that are available to your team. -- **SSO:** Support for various SSO providers. -- **_(coming soon)_ RBAC:** Role-based access control for managing user permissions. -- **_(coming soon)_ Code host permission syncing:** Sync permissions from GitHub, Gitlab, etc. to Sourcebot. -- **_(coming soon)_ Audit logs:** Audit logs for all actions performed on Sourcebot, such as user login, search, etc. - - - - -### Self-hosted - -Sourcebot is designed to be easily self-hosted, allowing you to deploy it onto your own infrastructure, keeping your code private and secure. - - -- **Easy deployment:** Sourcebot is shipped as a [single docker container](https://github.com/sourcebot-dev/sourcebot/pkgs/container/sourcebot) that can be deployed to a k8s cluster, a VM, or any other platform that supports docker. -- **Secure:** Your code **never** leaves your infrastructure. -- **No-vendor lock-in:** Avoid dependency on a third-party SaaS provider; you can modify, extend, or migrate your deployment as needed. - - -## Get started ---- - - - - - - +Sourcebot is an [open source](https://github.com/sourcebot-dev/sourcebot) tool that helps humans and agents understand massive codebases. + + + + Search and navigate across all your repos and branches. + + + Ask questions across all your repos and branches. + + + Code context layer for all your agents. + -## Architecture ---- - -Sourcebot is shipped as a single docker container that runs a collection of services using [supervisord](https://supervisord.org/): - -![architecture diagram](/images/architecture_diagram.png) - -{/*TODO: outline the different services, how Sourcebot communicates with code hosts, and the different*/} - -Sourcebot consists of the following components: -- **Web Server** : main Next.js web application serving the Sourcebot UI. -- **Backend Worker** : Node.js process that incrementally syncs with code hosts (e.g., GitHub, GitLab etc.) and asynchronously indexes configured repositories. -- **Zoekt** : the [open-source](https://github.com/sourcegraph/zoekt), trigram indexing code search engine that powers Sourcebot under the hood. -- **Postgres** : transactional database for storing business-logic data. -- **Redis Job Queue** : fast in-memory store. Used with [BullMQ](https://docs.bullmq.io/) for queuing asynchronous work. -- **`.sourcebot/` cache** : file-system cache where persistent data is written. - -You can use managed Redis / Postgres services that run outside of the Sourcebot container by providing the `REDIS_URL` and `DATABASE_URL` environment variables, respectively. See the [environment variables](/docs/configuration/environment-variables) doc for more configuration options. - -## Scalability ---- - -One of our design philosophies for Sourcebot is to keep our infrastructure [radically simple](https://www.radicalsimpli.city/) while balancing scalability concerns. Depending on the number of repositories you have indexed and the instance you are running Sourcebot on, you may experience slow search times or other performance degradations. Our recommendation is to vertically scale your instance by increasing the number of CPU cores and memory. See the [sizing guide](/docs/deployment/sizing-guide) for detailed recommendations. - -Sourcebot does not support horizontal scaling at this time, but it is on our roadmap. If this is something your team would be interested in, please contact us at [team@sourcebot.dev](mailto:team@sourcebot.dev). - -## License key ---- - -Sourcebot's core features are available under an [FSL licensed](https://github.com/sourcebot-dev/sourcebot/blob/main/LICENSE.md#functional-source-license-version-11-alv2-future-license) without any limits. Some [additional features](/docs/license-key#feature-availability) such as SSO and code navigation require a [license key](/docs/license-key). +Sourcebot is self-hosted within your infrastructure, and connects to any [code-host platform](/docs/connections/indexing-your-code) or [LLM provider](/docs/configuration/language-model-providers). Sensitive data never leaves your infrastructure. - - - - -## Telemetry ---- - -By default, Sourcebot collects anonymized usage data through [PostHog](https://posthog.com/) to help us improve the performance and reliability of our tool. We don't collect or transmit any information related to your codebase. In addition, all events are [sanitized](https://github.com/sourcebot-dev/sourcebot/blob/HEAD/packages/web/src/app/posthogProvider.tsx) to ensure that no sensitive details (ex. ip address, query info) leave your machine. - -The data we collect includes general usage statistics and metadata such as query performance (e.g., search duration, error rates) to monitor the application's health and functionality. This information helps us better understand how Sourcebot is used and where improvements can be made. - -If you'd like to disable all telemetry, you can do so by setting the environment variable `SOURCEBOT_TELEMETRY_DISABLED` to `true`: - -```bash -docker run \ - -e SOURCEBOT_TELEMETRY_DISABLED=true \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` - -If you disabled telemetry correctly, you'll see the following log when starting Sourcebot: - -```sh -Disabling telemetry since SOURCEBOT_TELEMETRY_DISABLED was set. -``` \ No newline at end of file + + Deploy Sourcebot in your own infrastructure. + + + Try Sourcebot for free on our hosted version. + + \ No newline at end of file diff --git a/docs/docs/upgrade/v3-to-v4-guide.mdx b/docs/docs/upgrade/v3-to-v4-guide.mdx index 47620d0cd..304306520 100644 --- a/docs/docs/upgrade/v3-to-v4-guide.mdx +++ b/docs/docs/upgrade/v3-to-v4-guide.mdx @@ -8,7 +8,7 @@ This guide will walk you through upgrading your Sourcebot deployment from v3 to Please note that the following features are no longer supported in v4: - Multi-tenancy mode -- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/docs/license-key) +- Unauthenticated access to a Sourcebot deployment - authentication is now built in by default. Unauthenticated access to a organization can be enabled with an unlimited seat [enterprise license](/docs/activating-a-subscription) ### If your deployment doesn't have authentication enabled @@ -22,7 +22,7 @@ Please note that the following features are no longer supported in v4: When you visit your new deployment you'll be presented with a sign-in page. Sourcebot now requires authentication, and all users must register and sign-in to the deployment. The first account that's registered will be made the owner. By default, you can register using basic credentials which will be stored encrypted within the postgres DB connected to Sourcebot. Check out - the [auth docs](/docs/configuration/auth/overview) to setup additional auth providers. + the [auth docs](/docs/configuration/auth/authentication) to setup additional auth providers.
diff --git a/docs/docs/upgrade/v4-to-v5-guide.mdx b/docs/docs/upgrade/v4-to-v5-guide.mdx new file mode 100644 index 000000000..527b1672c --- /dev/null +++ b/docs/docs/upgrade/v4-to-v5-guide.mdx @@ -0,0 +1,32 @@ +--- +title: V4 to V5 Guide +sidebarTitle: V4 to V5 guide +--- + +This guide will walk you through upgrading your Sourcebot deployment from v4 to v5. + + +Starting in v5, the following features require a paid plan: +- [Ask Sourcebot](/docs/features/ask/ask-sourcebot) +- [MCP server](/docs/features/mcp-server) + +If your deployment uses these features and you upgrade to v5, you'll need to activate a paid plan to keep using them. All other features available on the free plan remain on the free plan. + +You can [start a 14-day free trial](/docs/free-trial) of the paid plan directly from your deployment. No credit card is required. + + +There are no breaking changes between v4 and v5. Upgrading is as simple as pulling the latest image and restarting your deployment. + +# Staying on v4 + +If you'd rather not upgrade to v5 or activate a paid plan, you can keep using v4 indefinitely by pinning your deployment to the final v4 release: + +```bash +ghcr.io/sourcebot-dev/sourcebot:v4.17.3 +``` + +v4 will continue to work as before, with Ask Sourcebot and MCP available for free. + +## Troubleshooting + +Having troubles migrating from v4 to v5? Reach out to us on [GitHub](https://github.com/sourcebot-dev/sourcebot/issues/new/choose) and we'll try our best to help. diff --git a/docs/images/connectors_account_page.png b/docs/images/connectors_account_page.png new file mode 100644 index 000000000..df06cab0d Binary files /dev/null and b/docs/images/connectors_account_page.png differ diff --git a/docs/images/connectors_permission_request.png b/docs/images/connectors_permission_request.png new file mode 100644 index 000000000..b63081f60 Binary files /dev/null and b/docs/images/connectors_permission_request.png differ diff --git a/docs/images/connectors_static_registration.png b/docs/images/connectors_static_registration.png new file mode 100644 index 000000000..6b4bd12d4 Binary files /dev/null and b/docs/images/connectors_static_registration.png differ diff --git a/docs/images/connectors_toggle_off.png b/docs/images/connectors_toggle_off.png new file mode 100644 index 000000000..a41b86030 Binary files /dev/null and b/docs/images/connectors_toggle_off.png differ 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/docs/snippets/license-key-required.mdx b/docs/snippets/license-key-required.mdx index 74885743b..be37fc25d 100644 --- a/docs/snippets/license-key-required.mdx +++ b/docs/snippets/license-key-required.mdx @@ -1,4 +1,4 @@ -{feature} is only available with an active Enterprise license. Please add your [license key](/docs/license-key) to activate it. - \ No newline at end of file +{feature} {verb ?? "is"} only available in a paid plan. Please activate a [license key](/docs/activating-a-subscription) to use this feature. + 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 031105dbc..f0d6433b3 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 { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; +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"; @@ -57,8 +58,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..6a02c9062 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. 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..ca742bee3 --- /dev/null +++ b/packages/db/prisma/migrations/20260509000000_add_license_table/migration.sql @@ -0,0 +1,41 @@ +-- 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, + "yearlyCommittedSeats" INTEGER, + "yearlyCurrentQuarterEndsAt" TIMESTAMP(3), + "yearlyCurrentQuarterNumber" INTEGER, + "yearlyCurrentQuarterStartedAt" TIMESTAMP(3), + "yearlyOverageSeats" INTEGER, + "yearlyBillableOverageSeats" INTEGER, + "yearlyPeakSeats" INTEGER, + "yearlyTermEndsAt" TIMESTAMP(3), + "yearlyTermStartedAt" TIMESTAMP(3), + "yearlyTotalQuartersInTerm" INTEGER, + "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/migrations/20260523064558_add_repo_visit_table/migration.sql b/packages/db/prisma/migrations/20260523064558_add_repo_visit_table/migration.sql new file mode 100644 index 000000000..35cfce439 --- /dev/null +++ b/packages/db/prisma/migrations/20260523064558_add_repo_visit_table/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "RepoVisit" ( + "id" TEXT NOT NULL, + "visitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastPromotedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "repoId" INTEGER NOT NULL, + "userId" TEXT NOT NULL, + "orgId" INTEGER NOT NULL, + + CONSTRAINT "RepoVisit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RepoVisit_userId_orgId_visitedAt_idx" ON "RepoVisit"("userId", "orgId", "visitedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "RepoVisit_repoId_userId_key" ON "RepoVisit"("repoId", "userId"); + +-- AddForeignKey +ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepoVisit" ADD CONSTRAINT "RepoVisit_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260525214409_add_changelog_table/migration.sql b/packages/db/prisma/migrations/20260525214409_add_changelog_table/migration.sql new file mode 100644 index 000000000..184d6656d --- /dev/null +++ b/packages/db/prisma/migrations/20260525214409_add_changelog_table/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "ChangelogEntry" ( + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "publishedAt" TIMESTAMP(3) NOT NULL, + "summary" TEXT NOT NULL, + "version" TEXT NOT NULL, + "bodyMarkdown" TEXT NOT NULL, + "fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ChangelogEntry_pkey" PRIMARY KEY ("slug") +); + +-- CreateIndex +CREATE INDEX "ChangelogEntry_publishedAt_idx" ON "ChangelogEntry"("publishedAt"); diff --git a/packages/db/prisma/migrations/20260526013307_add_user_last_active_at/migration.sql b/packages/db/prisma/migrations/20260526013307_add_user_last_active_at/migration.sql new file mode 100644 index 000000000..c464b0826 --- /dev/null +++ b/packages/db/prisma/migrations/20260526013307_add_user_last_active_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastActiveAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql b/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql new file mode 100644 index 000000000..fb82482c0 --- /dev/null +++ b/packages/db/prisma/migrations/20260529214711_add_mcp_connectors_tables/migration.sql @@ -0,0 +1,66 @@ +-- CreateEnum +CREATE TYPE "McpServerClientInfoSource" AS ENUM ('DYNAMIC', 'STATIC'); + +-- CreateTable +CREATE TABLE "McpServer" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "sanitizedName" TEXT NOT NULL, + "serverUrl" TEXT NOT NULL, + "clientInfo" TEXT, + "clientInfoSource" "McpServerClientInfoSource" NOT NULL DEFAULT 'DYNAMIC', + "orgId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "McpServerToolCallCount" ( + "mcpServerId" TEXT NOT NULL, + "toolName" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "McpServerToolCallCount_pkey" PRIMARY KEY ("mcpServerId","toolName") +); + +-- CreateTable +CREATE TABLE "UserMcpServer" ( + "userId" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "tokens" TEXT, + "tokensExpiresAt" TIMESTAMP(3), + "codeVerifier" TEXT, + "state" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserMcpServer_pkey" PRIMARY KEY ("userId","serverId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "McpServer_serverUrl_orgId_key" ON "McpServer"("serverUrl", "orgId"); + +-- CreateIndex +CREATE UNIQUE INDEX "McpServer_orgId_sanitizedName_key" ON "McpServer"("orgId", "sanitizedName"); + +-- CreateIndex +CREATE INDEX "UserMcpServer_serverId_idx" ON "UserMcpServer"("serverId"); + +-- CreateIndex +CREATE INDEX "UserMcpServer_state_idx" ON "UserMcpServer"("state"); + +-- AddForeignKey +ALTER TABLE "McpServer" ADD CONSTRAINT "McpServer_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "McpServerToolCallCount" ADD CONSTRAINT "McpServerToolCallCount_mcpServerId_fkey" FOREIGN KEY ("mcpServerId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserMcpServer" ADD CONSTRAINT "UserMcpServer_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index c81bfd62d..e0371e56c 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -74,6 +74,7 @@ model Repo { orgId Int searchContexts SearchContext[] + visits RepoVisit[] @@unique([external_id, external_codeHostUrl, orgId]) @@index([orgId]) @@ -291,12 +292,59 @@ model Org { searchContexts SearchContext[] chats Chat[] + repoVisits RepoVisit[] + + mcpServers McpServer[] + + license License? +} + +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? + + // Yearly-only fields, mirroring `yearlyTermStatus` on the lighthouse ping + // response. All null for monthly subs and for unactivated/canceled licenses. + yearlyTermStartedAt DateTime? + yearlyTermEndsAt DateTime? + yearlyTotalQuartersInTerm Int? + yearlyCurrentQuarterNumber Int? + yearlyCurrentQuarterStartedAt DateTime? + yearlyCurrentQuarterEndsAt DateTime? + yearlyCommittedSeats Int? + yearlyOverageSeats Int? + yearlyBillableOverageSeats Int? + yearlyPeakSeats Int? + + lastSyncAt DateTime? + lastSyncErrorCode String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } enum OrgRole { OWNER MEMBER - GUEST +} + +enum McpServerClientInfoSource { + DYNAMIC + STATIC } model UserToOrg { @@ -370,6 +418,7 @@ model User { chats Chat[] sharedChats ChatAccess[] + repoVisits RepoVisit[] oauthTokens OAuthToken[] oauthAuthCodes OAuthAuthorizationCode[] @@ -380,9 +429,14 @@ model User { /// claim baked into the JWT cookie at mint time. sessionVersion Int @default(0) + userMcpServers UserMcpServer[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + /// Last time the user performed an authenticated action. + lastActiveAt DateTime? + } enum AccountPermissionSyncJobStatus { @@ -476,6 +530,27 @@ model VerificationToken { @@unique([identifier, token]) } +model RepoVisit { + id String @id @default(cuid()) + /// visitedAt is updated everytime a repo is visited. + visitedAt DateTime @default(now()) @updatedAt + // lastPromotedAt is updated only when a repo is promoted into the top k + // most recently viewed repositories. + lastPromotedAt DateTime @default(now()) + + repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade) + repoId Int + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + @@unique([repoId, userId]) + @@index([userId, orgId, visitedAt]) +} + model Chat { id String @id @default(cuid()) @@ -574,3 +649,89 @@ model OAuthToken { createdAt DateTime @default(now()) lastUsedAt DateTime? } + +/// Local cache of changelog entries fetched from the public feed at +/// `CHANGELOG_FEED_URL`. Shared across all users of an instance. +model ChangelogEntry { + slug String @id + title String + publishedAt DateTime + summary String + version String + bodyMarkdown String + + /// Updated each time the entry is upserted from the feed. + fetchedAt DateTime @default(now()) @updatedAt + + @@index([publishedAt]) +} + +/// An external MCP server endpoint, unique per org. +/// Stores the dynamic client registration (client_id/client_secret) once per org. +model McpServer { + id String @id @default(cuid()) + name String /// Org-approved display name (e.g., "Linear") + sanitizedName String /// Stable tool-name prefix (e.g., "linear") + serverUrl String /// MCP server endpoint (e.g., "https://mcp.linear.app/mcp") + + /// Dynamic client registration result (RFC 7591) or admin-provided static OAuth client credentials. + /// Encrypted JSON of OAuthClientInformation: { client_id, client_secret, client_id_issued_at, client_secret_expires_at } + /// Null for DYNAMIC rows until first user in the org triggers registration. + clientInfo String? + clientInfoSource McpServerClientInfoSource @default(DYNAMIC) + + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) + orgId Int + + userMcpServers UserMcpServer[] + toolCallCounts McpServerToolCallCount[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([serverUrl, orgId]) + @@unique([orgId, sanitizedName]) +} + +/// Lifetime tool call counters for an MCP server. +model McpServerToolCallCount { + mcpServer McpServer @relation(fields: [mcpServerId], references: [id], onDelete: Cascade) + mcpServerId String + toolName String + count Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([mcpServerId, toolName]) +} + +/// A user's personal connection to an MCP server. +/// Stores per-user OAuth tokens and ephemeral auth-flow state. +model UserMcpServer { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + serverId String + + /// OAuth tokens (access_token, refresh_token, etc.) — encrypted JSON of OAuthTokens. + tokens String? + + /// Absolute expiry time of the access token, computed at issuance from expires_in. + /// Null when no tokens are stored or the provider did not include expires_in. + tokensExpiresAt DateTime? + + /// PKCE code verifier — ephemeral, only used between redirect and callback. + codeVerifier String? + + /// OAuth state parameter — ephemeral, for CSRF protection during auth flow. + state String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([userId, serverId]) + @@index([serverId]) + @@index([state]) +} 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..4224b2fc2 --- /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/ask-sourcebot). + +## 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..d4b5c6a47 --- /dev/null +++ b/packages/setupWizard/package.json @@ -0,0 +1,32 @@ +{ + "name": "setup-sourcebot", + "version": "0.1.4", + "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..fead965c9 --- /dev/null +++ b/packages/setupWizard/src/azuredevops.ts @@ -0,0 +1,100 @@ +import { confirm, input, password, select } from '@inquirer/prompts'; +import { tabCheckbox as checkbox } from './tabCheckbox.js'; +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 locally in .env 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..4e547fc83 --- /dev/null +++ b/packages/setupWizard/src/bitbucket.ts @@ -0,0 +1,236 @@ +import { confirm, input, password, select } from '@inquirer/prompts'; +import { tabCheckbox as checkbox } from './tabCheckbox.js'; +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 locally in .env 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 locally in .env 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 locally in .env 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 locally in .env 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..050a55e51 --- /dev/null +++ b/packages/setupWizard/src/gitea.ts @@ -0,0 +1,68 @@ +import { input, password } from '@inquirer/prompts'; +import { tabCheckbox as checkbox } from './tabCheckbox.js'; +import type { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { INPUT_THEME, 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', + theme: INPUT_THEME, + 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 locally in .env 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..e83fcaec7 --- /dev/null +++ b/packages/setupWizard/src/github.ts @@ -0,0 +1,216 @@ +import { input, password } from '@inquirer/prompts'; +import { tabCheckbox as checkbox } from './tabCheckbox.js'; +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 { createSearchSelectContext, INPUT_THEME, 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', + theme: INPUT_THEME, + 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 locally in .env 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 ctx = createSearchSelectContext(); + const repos = await searchSelect({ + message: 'Repositories to index (type to search, or type owner/repo)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: 'Type to search...', + options: async (search) => { + ctx.trackSearch(search); + if (!search) { + return []; + } + ctx.setLoading(true); + try { + return await searchGitHub(apiBase, search, token, 'repo'); + } finally { + ctx.setLoading(false); + } + }, + 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 ctx = createSearchSelectContext(); + const orgs = await searchSelect({ + message: 'Organizations to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: 'Type to search...', + options: async (search) => { + ctx.trackSearch(search); + if (!search) { + return []; + } + ctx.setLoading(true); + try { + return await searchGitHub(apiBase, search, token, 'org'); + } finally { + ctx.setLoading(false); + } + }, + }); + config.orgs = orgs; + } + + if (targets.includes('users')) { + const ctx = createSearchSelectContext(); + const users = await searchSelect({ + message: 'GitHub users to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: 'Type to search...', + options: async (search) => { + ctx.trackSearch(search); + if (!search) { + return []; + } + ctx.setLoading(true); + try { + return await searchGitHub(apiBase, search, token, 'user'); + } finally { + ctx.setLoading(false); + } + }, + }); + 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..c93cf45c3 --- /dev/null +++ b/packages/setupWizard/src/gitlab.ts @@ -0,0 +1,234 @@ +import { input, password } from '@inquirer/prompts'; +import { tabCheckbox as checkbox } from './tabCheckbox.js'; +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 { createSearchSelectContext, INPUT_THEME, 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', + theme: INPUT_THEME, + 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 locally in .env 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 ctx = createSearchSelectContext(); + const groups = await searchSelect({ + message: 'Groups to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: 'Type to search...', + options: async (search) => { + ctx.trackSearch(search); + if (!search) { + return []; + } + ctx.setLoading(true); + try { + return await searchGitLab(apiBase, search, gitlabToken, 'group'); + } finally { + ctx.setLoading(false); + } + }, + }); + config.groups = groups; + } + + if (targets.includes('projects')) { + const ctx = createSearchSelectContext(); + const projects = await searchSelect({ + message: 'Projects to index (type to search, or type group/project)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: 'Type to search...', + options: async (search) => { + ctx.trackSearch(search); + if (!search) { + return []; + } + ctx.setLoading(true); + try { + return await searchGitLab(apiBase, search, gitlabToken, 'project'); + } finally { + ctx.setLoading(false); + } + }, + 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 ctx = createSearchSelectContext(); + const users = await searchSelect({ + message: 'Users to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: 'Type to search...', + options: async (search) => { + ctx.trackSearch(search); + if (!search) { + return []; + } + ctx.setLoading(true); + try { + return await searchGitLab(apiBase, search, gitlabToken, 'user'); + } finally { + ctx.setLoading(false); + } + }, + }); + 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..8d66a6c6c --- /dev/null +++ b/packages/setupWizard/src/index.ts @@ -0,0 +1,623 @@ +#!/usr/bin/env node +import { confirm, input, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import ora from 'ora'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { writeFile } from 'fs/promises'; +import { basename } from 'path'; +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, + INPUT_THEME, + note, +} from './utils.js'; + +const DOCKER_COMPOSE_BRANCH = 'main'; +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)); + } +} + +// Parses the top-level `volumes:` block of a docker-compose.yml and returns the +// declared volume names. Sufficient for our generated compose file; not a full +// YAML parser. +function parseTopLevelVolumes(composeYaml: string): string[] { + const names: string[] = []; + let inBlock = false; + for (const rawLine of composeYaml.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + if (/^volumes:\s*(#.*)?$/.test(line)) { + inBlock = true; + continue; + } + if (!inBlock) { + continue; + } + if (/^\s*$/.test(line) || /^\s+/.test(line)) { + const m = line.match(/^ {2}([A-Za-z0-9_.-]+):\s*(#.*)?$/); + if (m) { + names.push(m[1]); + } + continue; + } + // Any non-blank, non-indented line ends the top-level volumes block. + inBlock = false; + } + return names; +} + +// Mirrors Docker Compose's project-name normalization for the default case +// where the project name is derived from the working directory basename. +function dockerComposeProjectName(): string { + return basename(process.cwd()) + .toLowerCase() + .replace(/[^a-z0-9_-]/g, ''); +} + +async function listExistingDockerVolumes(expectedNames: string[]): Promise { + if (expectedNames.length === 0) { + return []; + } + return new Promise((resolve) => { + const child = spawn('docker', ['volume', 'ls', '--format', '{{.Name}}'], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + let out = ''; + child.stdout?.on('data', (chunk: Buffer) => { + out += chunk.toString(); + }); + child.on('exit', (code) => { + if (code !== 0) { + resolve([]); + return; + } + const existing = new Set(out.split('\n').map((l) => l.trim()).filter(Boolean)); + resolve(expectedNames.filter((name) => existing.has(name))); + }); + child.on('error', () => resolve([])); + }); +} + +async function removeDockerVolumes(volumes: string[]): Promise { + if (volumes.length === 0) { + return true; + } + return new Promise((resolve) => { + const child = spawn('docker', ['volume', 'rm', ...volumes], { stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + child.stderr?.on('data', (chunk: Buffer) => { + err += chunk.toString(); + }); + child.on('exit', (code) => { + if (code !== 0 && err.trim()) { + console.error(chalk.red('✗ ') + err.trim()); + } + resolve(code === 0); + }); + child.on('error', () => resolve(false)); + }); +} + +type ComposeContainer = { Name: string; Service: string; State: string }; + +function parseComposePsOutput(output: string): ComposeContainer[] { + const trimmed = output.trim(); + if (!trimmed) { + return []; + } + if (trimmed.startsWith('[')) { + try { + return JSON.parse(trimmed) as ComposeContainer[]; + } catch { + // fall through to line-based parse + } + } + const containers: ComposeContainer[] = []; + for (const line of trimmed.split('\n')) { + if (!line.trim()) { + continue; + } + try { + containers.push(JSON.parse(line) as ComposeContainer); + } catch { + // skip unparseable line + } + } + return containers; +} + +async function listComposeContainers(): Promise { + return new Promise((resolve) => { + const child = spawn('docker', ['compose', 'ps', '-a', '--format', 'json'], { + stdio: ['ignore', 'pipe', 'ignore'], + }); + let out = ''; + child.stdout?.on('data', (chunk: Buffer) => { + out += chunk.toString(); + }); + child.on('exit', (code) => { + if (code !== 0) { + resolve([]); + return; + } + resolve(parseComposePsOutput(out)); + }); + child.on('error', () => resolve([])); + }); +} + +async function runComposeCommand(args: string[], label: string): Promise { + return new Promise((resolve) => { + const child = spawn('docker', ['compose', ...args], { stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + child.stderr?.on('data', (chunk: Buffer) => { + err += chunk.toString(); + }); + child.on('exit', (code) => { + if (code !== 0 && err.trim()) { + console.error(chalk.red('✗ ') + `${label}: ` + err.trim()); + } + resolve(code === 0); + }); + child.on('error', () => resolve(false)); + }); +} + +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 setupDir = await input({ + message: 'What directory would you like to set up Sourcebot in?', + default: 'sourcebot', + theme: INPUT_THEME, + validate: (v: string) => { + if (!v?.trim()) { + return 'Directory is required'; + } + return true; + }, + }); + + if (existsSync(setupDir)) { + const overwrite = await confirm({ + message: `Directory '${setupDir}' already exists. Do you want to overwrite it?`, + default: false, + }); + if (!overwrite) { + console.log(); + console.log(chalk.red('✗ ') + 'Setup cancelled.'); + process.exit(0); + } + } else { + mkdirSync(setupDir, { recursive: true }); + } + + process.chdir(setupDir); + + const connections: Record = {}; + const allEnv: EnvVars = {}; + const localRepoIndex = new Map(); + + note( + 'Code is cloned and indexed locally on this machine. No code is ever transmitted to Sourcebot.', + ); + + // 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); + + const authUrl = await input({ + message: 'What URL will Sourcebot be hosted at?', + default: SOURCEBOT_URL, + theme: INPUT_THEME, + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + allEnv.AUTH_URL = authUrl; + + 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 TOP_LEVEL_ENV_KEYS = ['AUTH_URL']; + 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) && !TOP_LEVEL_ENV_KEYS.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)}`, + '', + '# Public URL where Sourcebot is hosted', + `AUTH_URL=${allEnv.AUTH_URL}`, + ]; + + 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; + } + + let leftDeploymentRunning = false; + + if (downloadedCompose) { + const containers = await listComposeContainers(); + const running = containers.filter((c) => c.State === 'running'); + const stopped = containers.filter((c) => c.State !== 'running'); + + if (running.length > 0) { + console.log(); + console.log(chalk.yellow('⚠ ') + 'A Sourcebot deployment is already running in this project:'); + for (const c of running) { + console.log(' ' + chalk.dim('- ') + `${c.Name} ${chalk.dim(`(${c.Service})`)}`); + } + const stop = await confirm({ + message: 'Stop and remove the running deployment? (required before any volume changes or restart can apply)', + default: true, + }); + if (stop) { + const ds = ora('Stopping deployment...').start(); + const ok = await runComposeCommand(['down'], 'docker compose down'); + if (ok) { + ds.succeed('Stopped deployment'); + } else { + ds.fail('Failed to stop deployment'); + leftDeploymentRunning = true; + } + } else { + leftDeploymentRunning = true; + } + } else if (stopped.length > 0) { + console.log(); + console.log(chalk.yellow('⚠ ') + 'Stopped containers from a previous run exist and will conflict on next start:'); + for (const c of stopped) { + console.log(' ' + chalk.dim('- ') + `${c.Name} ${chalk.dim(`(${c.Service})`)}`); + } + const remove = await confirm({ + message: 'Remove them now to prevent name conflicts when Sourcebot starts?', + default: true, + }); + if (remove) { + const rs = ora('Removing containers...').start(); + const ok = await runComposeCommand(['rm', '-f'], 'docker compose rm'); + if (ok) { + rs.succeed('Removed containers'); + } else { + rs.fail('Failed to remove containers'); + } + } + } + } + + // Volume wipe is only safe (and only succeeds) once nothing is using the volumes. + if (downloadedCompose && !leftDeploymentRunning) { + const declaredVolumes = parseTopLevelVolumes(readFileSync('docker-compose.yml', 'utf-8')); + const project = dockerComposeProjectName(); + const expectedNames = declaredVolumes.map((v) => `${project}_${v}`); + const existing = await listExistingDockerVolumes(expectedNames); + + if (existing.length > 0) { + console.log(); + console.log(chalk.yellow('⚠ ') + 'The following Docker volumes from a previous run already exist:'); + for (const v of existing) { + console.log(' ' + chalk.dim('- ') + v); + } + const wipe = await confirm({ + message: 'Wipe these volumes? This will permanently delete any existing Sourcebot data in them.', + default: false, + }); + if (wipe) { + const ws = ora('Removing volumes...').start(); + const ok = await removeDockerVolumes(existing); + if (ok) { + ws.succeed(`Removed ${existing.length} volume${existing.length === 1 ? '' : 's'}`); + } else { + ws.fail('Failed to remove one or more volumes (they may be in use by a running container)'); + } + } + } + } + + console.log(); + console.log(chalk.green('✓ ') + chalk.bold('Your Sourcebot configuration is ready!')); + + if (downloadedCompose && !leftDeploymentRunning) { + 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 (leftDeploymentRunning) { + nextSteps.push('Your new configuration was saved, but the running deployment is still using the old config.'); + nextSteps.push(''); + nextSteps.push(`${step++}. Open ${SOURCEBOT_URL} to use the current deployment as-is.`); + nextSteps.push(''); + nextSteps.push(`${step++}. To apply your new configuration, restart Sourcebot:`); + nextSteps.push(' docker compose down && docker compose up'); + note(nextSteps.join('\n'), 'Sourcebot is already running'); + return; + } + + 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 ${SOURCEBOT_URL}`); + + 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..f22c17155 --- /dev/null +++ b/packages/setupWizard/src/localRepos.ts @@ -0,0 +1,171 @@ +import { input } from '@inquirer/prompts'; +import { tabCheckbox as checkbox } from './tabCheckbox.js'; +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..50faea65f --- /dev/null +++ b/packages/setupWizard/src/models.ts @@ -0,0 +1,378 @@ +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 { INPUT_THEME, 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 locally in .env 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', + theme: INPUT_THEME, + 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 locally in .env 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 locally in .env 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', + theme: INPUT_THEME, + 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 locally in .env 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 locally in .env as GOOGLE_VERTEX_REGION)', + default: 'us-central1', + theme: INPUT_THEME, + 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 locally in .env 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/ask-sourcebot', + '', + '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/tabCheckbox.ts b/packages/setupWizard/src/tabCheckbox.ts new file mode 100644 index 000000000..f8324ea1e --- /dev/null +++ b/packages/setupWizard/src/tabCheckbox.ts @@ -0,0 +1,308 @@ +// Fork of @inquirer/checkbox that uses Tab (instead of Space) to toggle selection. +// Kept in sync with @inquirer/checkbox 4.x — only the keybinding and help text differ. + +import { + createPrompt, + isDownKey, + isEnterKey, + isNumberKey, + isTabKey, + isUpKey, + makeTheme, + Separator, + useKeypress, + useMemo, + usePagination, + usePrefix, + useState, + ValidationError, + type Theme, + type Keybinding, +} from '@inquirer/core'; +import type { PartialDeep } from '@inquirer/type'; +import { cursorHide } from '@inquirer/ansi'; +import { styleText } from 'node:util'; +import figures from '@inquirer/figures'; + +type CheckboxTheme = { + icon: { + checked: string; + unchecked: string; + cursor: string; + disabledChecked: string; + disabledUnchecked: string; + }; + style: { + disabled: (text: string) => string; + renderSelectedChoices: ( + selectedChoices: ReadonlyArray>, + allChoices: ReadonlyArray | Separator>, + ) => string; + description: (text: string) => string; + keysHelpTip: (keys: [key: string, action: string][]) => string | undefined; + }; + i18n: { disabledError: string }; + keybindings: ReadonlyArray; +}; + +type CheckboxShortcuts = { + all?: string | null; + invert?: string | null; +}; + +type Choice = { + value: Value; + name?: string; + checkedName?: string; + description?: string; + short?: string; + disabled?: boolean | string; + checked?: boolean; + type?: never; +}; + +type NormalizedChoice = { + value: Value; + name: string; + checkedName: string; + description?: string; + short: string; + disabled: boolean | string; + checked: boolean; +}; + +const checkboxTheme: CheckboxTheme = { + icon: { + checked: styleText('green', figures.circleFilled), + unchecked: figures.circle, + cursor: figures.pointer, + disabledChecked: styleText('green', figures.circleDouble), + disabledUnchecked: '-', + }, + style: { + disabled: (text: string) => styleText('dim', text), + renderSelectedChoices: (selectedChoices) => + selectedChoices.map((choice) => choice.short).join(', '), + description: (text: string) => styleText('cyan', text), + keysHelpTip: (keys) => + keys + .map(([key, action]) => `${styleText('bold', key)} ${styleText('dim', action)}`) + .join(styleText('dim', ' • ')), + }, + i18n: { disabledError: 'This option is disabled and cannot be toggled.' }, + keybindings: [], +}; + +function isSelectable(item: NormalizedChoice | Separator): item is NormalizedChoice { + return !Separator.isSeparator(item) && !item.disabled; +} + +function isNavigable(item: NormalizedChoice | Separator): item is NormalizedChoice { + return !Separator.isSeparator(item); +} + +function isChecked(item: NormalizedChoice | Separator): item is NormalizedChoice { + return !Separator.isSeparator(item) && item.checked; +} + +function toggle(item: NormalizedChoice | Separator): NormalizedChoice | Separator { + return isSelectable(item) ? { ...item, checked: !item.checked } : item; +} + +function check(checked: boolean) { + return function (item: NormalizedChoice | Separator): NormalizedChoice | Separator { + return isSelectable(item) ? { ...item, checked } : item; + }; +} + +function normalizeChoices( + choices: readonly (Separator | Value | Choice)[], +): (NormalizedChoice | Separator)[] { + return choices.map((choice) => { + if (Separator.isSeparator(choice)) { + return choice; + } + if (typeof choice !== 'object' || choice === null || !('value' in (choice as object))) { + const name = String(choice); + return { + value: choice as Value, + name, + short: name, + checkedName: name, + disabled: false, + checked: false, + }; + } + const c = choice as Choice; + const name = c.name ?? String(c.value); + const normalizedChoice: NormalizedChoice = { + value: c.value, + name, + short: c.short ?? name, + checkedName: c.checkedName ?? name, + disabled: c.disabled ?? false, + checked: c.checked ?? false, + }; + if (c.description) { + normalizedChoice.description = c.description; + } + return normalizedChoice; + }); +} + +type TabCheckboxConfig = { + message: string; + prefix?: string; + pageSize?: number; + choices: readonly (Separator | Value | Choice)[]; + loop?: boolean; + required?: boolean; + validate?: ( + choices: readonly NormalizedChoice[], + ) => boolean | string | Promise; + theme?: PartialDeep>; + shortcuts?: CheckboxShortcuts; +}; + +export const tabCheckbox = createPrompt( + (config: TabCheckboxConfig, done: (value: Value[]) => void) => { + const { pageSize = 7, loop = true, required, validate = () => true } = config; + const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts }; + const theme = makeTheme(checkboxTheme, config.theme); + const { keybindings } = theme; + const [status, setStatus] = useState<'idle' | 'done'>('idle'); + const prefix = usePrefix({ status, theme }); + const [items, setItems] = useState<(NormalizedChoice | Separator)[]>( + normalizeChoices(config.choices), + ); + const bounds = useMemo(() => { + const first = items.findIndex(isNavigable); + const last = items.findLastIndex(isNavigable); + if (first === -1) { + throw new ValidationError('[checkbox prompt] No selectable choices. All choices are disabled.'); + } + return { first, last }; + }, [items]); + const [active, setActive] = useState(bounds.first); + const [errorMsg, setError] = useState(); + + useKeypress(async (key) => { + if (isEnterKey(key)) { + const selection = items.filter(isChecked); + const isValid = await validate([...selection]); + if (required && !selection.length) { + setError('At least one choice must be selected'); + } else if (isValid === true) { + setStatus('done'); + done(selection.map((choice) => choice.value)); + } else { + setError(isValid || 'You must select a valid value'); + } + } else if (isUpKey(key, keybindings) || isDownKey(key, keybindings)) { + if (errorMsg) { + setError(undefined); + } + if ( + loop || + (isUpKey(key, keybindings) && active !== bounds.first) || + (isDownKey(key, keybindings) && active !== bounds.last) + ) { + const offset = isUpKey(key, keybindings) ? -1 : 1; + let next = active; + do { + next = (next + offset + items.length) % items.length; + } while (!isNavigable(items[next]!)); + setActive(next); + } + } else if (isTabKey(key)) { + const activeItem = items[active]; + if (activeItem && !Separator.isSeparator(activeItem)) { + if (activeItem.disabled) { + setError(theme.i18n.disabledError); + } else { + setError(undefined); + setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice))); + } + } + } else if (key.name === shortcuts.all) { + const selectAll = items.some((choice) => isSelectable(choice) && !choice.checked); + setItems(items.map(check(selectAll))); + } else if (key.name === shortcuts.invert) { + setItems(items.map(toggle)); + } else if (isNumberKey(key)) { + const selectedIndex = Number(key.name) - 1; + let selectableIndex = -1; + const position = items.findIndex((item) => { + if (Separator.isSeparator(item)) { + return false; + } + selectableIndex++; + return selectableIndex === selectedIndex; + }); + const selectedItem = items[position]; + if (selectedItem && isSelectable(selectedItem)) { + setActive(position); + setItems(items.map((choice, i) => (i === position ? toggle(choice) : choice))); + } + } + }); + + const message = theme.style.message(config.message, status); + let description: string | undefined; + const page = usePagination({ + items, + active, + renderItem({ item, isActive }) { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + const cursor = isActive ? theme.icon.cursor : ' '; + if (item.disabled) { + const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + const checkbox = item.checked ? theme.icon.disabledChecked : theme.icon.disabledUnchecked; + return theme.style.disabled(`${cursor}${checkbox} ${item.name} ${disabledLabel}`); + } + if (isActive) { + description = item.description; + } + const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked; + const name = item.checked ? item.checkedName : item.name; + const color = isActive ? theme.style.highlight : (x: string) => x; + return color(`${cursor}${checkbox} ${name}`); + }, + pageSize, + loop, + }); + + if (status === 'done') { + const selection = items.filter(isChecked); + const answer = theme.style.answer(theme.style.renderSelectedChoices(selection, items)); + return [prefix, message, answer].filter(Boolean).join(' '); + } + + const keys: [string, string][] = [ + ['↑↓', 'navigate'], + ['tab', 'select'], + ]; + if (shortcuts.all) { + keys.push([shortcuts.all, 'all']); + } + if (shortcuts.invert) { + keys.push([shortcuts.invert, 'invert']); + } + keys.push(['⏎', 'submit']); + const helpLine = theme.style.keysHelpTip(keys); + const lines = [ + [prefix, message].filter(Boolean).join(' '), + page, + ' ', + description ? theme.style.description(description) : '', + errorMsg ? theme.style.error(errorMsg) : '', + helpLine, + ] + .filter(Boolean) + .join('\n') + .trimEnd(); + return `${lines}${cursorHide}`; + }, +); diff --git a/packages/setupWizard/src/utils.ts b/packages/setupWizard/src/utils.ts new file mode 100644 index 000000000..f4e906a7b --- /dev/null +++ b/packages/setupWizard/src/utils.ts @@ -0,0 +1,133 @@ +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 const INPUT_THEME = { + style: { + defaultAnswer: (text: string) => chalk.dim(`(default: ${text})`), + }, +}; + +// Per-prompt theme + filter tracker for `searchSelect` / `multiInput` callers. +// The theme: +// - Renders selections as `a, b, c,` (with trailing comma) so the prompt +// visually invites adding more entries on the same line. +// - Suppresses the empty-results message when the filter is empty, so +// "No results" only appears once the user has actually typed something +// that returned zero matches. +// Each prompt should get its own context so the closure-captured `lastSearch` +// doesn't bleed between prompts. +export function createSearchSelectContext() { + let lastSearch = ''; + let isLoading = false; + return { + trackSearch: (search: string | undefined): void => { + lastSearch = search ?? ''; + }, + setLoading: (loading: boolean): void => { + isLoading = loading; + }, + theme: { + style: { + renderSelectedOptions: ( + selectedOptions: ReadonlyArray<{ focused?: boolean; name?: string; value: T }>, + ): string => { + if (selectedOptions.length === 0) { + return ''; + } + const items = selectedOptions.map((option) => + option.focused + ? chalk.inverse(option.name || String(option.value)) + : (option.name || String(option.value)) + ); + return items.join(', ') + ','; + }, + emptyText: (text: string) => + (lastSearch && !isLoading) ? `${chalk.blue('ℹ')} ${chalk.bold(text)}` : '', + }, + }, + }; +} + +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 { + const ctx = createSearchSelectContext(); + return searchSelect({ + message: options.message, + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + theme: ctx.theme, + placeholder: options.placeholder ?? 'Type a value and press tab to add, enter to finish', + options: async (search) => { + ctx.trackSearch(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..35fe4a65d --- /dev/null +++ b/packages/shared/src/entitlements.test.ts @@ -0,0 +1,286 @@ +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, + yearlyTermStartedAt: null, + yearlyTermEndsAt: null, + yearlyTotalQuartersInTerm: null, + yearlyCurrentQuarterNumber: null, + yearlyCurrentQuarterStartedAt: null, + yearlyCurrentQuarterEndsAt: null, + yearlyCommittedSeats: null, + yearlyOverageSeats: null, + yearlyBillableOverageSeats: null, + yearlyPeakSeats: 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..bcfdac6cd 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -2,140 +2,191 @@ 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', +]; +// @WARNING: when adding a new entitlement to this list, make sure +// lighthouse/lambda/entitlements.ts is also updated && deployed +// prior to rolling a new Sourcebot version. // eslint-disable-next-line @typescript-eslint/no-unused-vars -const entitlements = [ +const ALL_ENTITLEMENTS = [ "search-contexts", - "anonymous-access", - "multi-tenancy", "sso", "code-nav", "audit", "analytics", "permission-syncing", "github-app", - "chat-sharing", "org-management", "oauth", + "ask", + "mcp" ] 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 isValidLicenseActive = (_license: License | null): boolean => { + return ( + getValidOfflineLicense() !== null || + getValidOnlineLicense(_license) !== null + ); +} - return licenseKey.seats === SOURCEBOT_UNLIMITED_SEATS ? "self-hosted:enterprise-unlimited" : "self-hosted:enterprise"; - } else { - return "oss"; +export const isAnonymousAccessAvailable = (_license: License | null): boolean => { + const offlineKey = getValidOfflineLicense(); + if (offlineKey) { + return offlineKey.seats === undefined; } + + 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.test.ts b/packages/shared/src/env.server.test.ts index bb7c7acc3..7f9bf0bca 100644 --- a/packages/shared/src/env.server.test.ts +++ b/packages/shared/src/env.server.test.ts @@ -54,3 +54,31 @@ describe('PERMISSION_SYNC_ENABLED', () => { expect(env.PERMISSION_SYNC_ENABLED).toBe('false'); }); }); + +describe('SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS', () => { + beforeEach(() => { + vi.resetModules(); + delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + }); + + afterEach(() => { + delete process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS; + }); + + test('defaults to 60000 when not set', async () => { + const { env } = await import('./env.server.js'); + expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(60000); + }); + + test('accepts positive integers', async () => { + process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = '5000'; + const { env } = await import('./env.server.js'); + expect(env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS).toBe(5000); + }); + + test.each(['0', '-1', '1.5', '2147483648', String(Number.MAX_SAFE_INTEGER + 1)])('rejects %s', async (timeoutMs) => { + process.env.SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS = timeoutMs; + + await expect(import('./env.server.js')).rejects.toThrow(); + }); +}); diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 460afc29b..17ddedfa5 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -14,6 +14,7 @@ const booleanSchema = z.enum(["true", "false"]); // coerce helps us convert them to numbers. // @see: https://zod.dev/?id=coercion-for-primitives const numberSchema = z.coerce.number(); +const maxTimerDelayMs = 2_147_483_647; const ajv = new Ajv({ validateFormats: false, @@ -221,6 +222,10 @@ const options = { // Misc UI flags SECURITY_CARD_ENABLED: booleanSchema.default('false'), + // Changelog feed + CHANGELOG_ENABLED: booleanSchema.default('true'), + CHANGELOG_FEED_URL: z.string().url().default('https://static.sourcebot.dev/changelog/index.json'), + // EE License SOURCEBOT_EE_LICENSE_KEY: z.string().optional(), SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('true'), @@ -278,6 +283,7 @@ const options = { */ SOURCEBOT_CHAT_MODEL_TEMPERATURE: numberSchema.optional(), SOURCEBOT_CHAT_MAX_STEP_COUNT: numberSchema.default(100), + SOURCEBOT_MCP_TOOL_CALL_TIMEOUT_MS: numberSchema.int().positive().max(maxTimerDelayMs).default(60000), DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'), DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'), @@ -311,6 +317,7 @@ const options = { SOURCEBOT_ENCRYPTION_KEY: z.string(), SOURCEBOT_INSTALL_ID: z.string().default("unknown"), + SOURCEBOT_LIGHTHOUSE_URL: z.string().url().default("https://deployments.sourcebot.dev"), FALLBACK_GITHUB_CLOUD_TOKEN: z.string().optional(), FALLBACK_GITLAB_CLOUD_TOKEN: z.string().optional(), diff --git a/packages/shared/src/index.client.ts b/packages/shared/src/index.client.ts index 8e2a161dd..05a248168 100644 --- a/packages/shared/src/index.client.ts +++ b/packages/shared/src/index.client.ts @@ -4,4 +4,10 @@ export { } from "./env.client.js"; export { SOURCEBOT_VERSION, -} from "./version.js"; \ No newline at end of file +} from "./version.js"; +export { + parseVersion, + formatVersion, + compareVersions, +} from "./versionUtils.js"; +export type { Version } from "./versionUtils.js"; \ No newline at end of file diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index a1eb34204..ce905c773 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -1,18 +1,25 @@ +// 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, + isValidLicenseActive as _isValidLicenseActive, + 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 +56,8 @@ export { verifySignature, encryptOAuthToken, decryptOAuthToken, + encryptActivationCode, + decryptActivationCode, } from "./crypto.js"; export { getDBConnectionString, @@ -58,4 +67,10 @@ export { } from "./smtp.js"; export { SOURCEBOT_VERSION, -} from "./version.js"; \ No newline at end of file +} from "./version.js"; +export { + parseVersion, + formatVersion, + compareVersions, +} from "./versionUtils.js"; +export type { Version } from "./versionUtils.js"; \ No newline at end of file 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/src/versionUtils.ts b/packages/shared/src/versionUtils.ts new file mode 100644 index 000000000..84cef78bf --- /dev/null +++ b/packages/shared/src/versionUtils.ts @@ -0,0 +1,36 @@ +const SEMVER_REGEX = /^v(\d+)\.(\d+)\.(\d+)$/; + +export type Version = { + major: number; + minor: number; + patch: number; +}; + +export const parseVersion = (version: string): Version | null => { + const match = version.match(SEMVER_REGEX); + if (!match) { + return null; + } + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]), + }; +}; + +export const formatVersion = (version: Version): string => { + return `v${version.major}.${version.minor}.${version.patch}`; +}; + +/** + * Returns < 0 if `a < b`, 0 if equal, > 0 if `a > b`. + */ +export const compareVersions = (a: Version, b: Version): number => { + if (a.major !== b.major) { + return a.major - b.major; + } + if (a.minor !== b.minor) { + return a.minor - b.minor; + } + return a.patch - b.patch; +}; 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/next.config.mjs b/packages/web/next.config.mjs index 6211fcfe2..c34c126d0 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -48,6 +48,13 @@ const nextConfig = { { source: "/register", destination: "/api/ee/oauth/register", + }, + // The MCP server lives under /api/ee/mcp so it sits in the EE-licensed + // route tree, but is exposed at the stable, public /api/mcp path that + // existing MCP client configurations point at. + { + source: "/api/mcp", + destination: "/api/ee/mcp", } ]; }, diff --git a/packages/web/package.json b/packages/web/package.json index ab4274153..cd06162c7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "@ai-sdk/deepseek": "^2.0.29", "@ai-sdk/google": "^3.0.64", "@ai-sdk/google-vertex": "^4.0.111", + "@ai-sdk/mcp": "^2.0.0-beta.11", "@ai-sdk/mistral": "^3.0.30", "@ai-sdk/openai": "^3.0.53", "@ai-sdk/openai-compatible": "^2.0.41", @@ -27,6 +28,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 +74,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 +83,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 +119,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 +155,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", @@ -173,7 +176,7 @@ "react-dom": "19.2.4", "react-hook-form": "^7.53.0", "react-hotkeys-hook": "^4.5.1", - "react-icons": "^5.3.0", + "react-icons": "^5.6.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^2.1.1", "recharts": "^2.15.3", @@ -194,7 +197,7 @@ "use-stick-to-bottom": "^1.1.3", "usehooks-ts": "^3.1.0", "vscode-icons-js": "^11.6.1", - "zod": "^3.25.74", + "zod": "^3.25.76", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { @@ -205,6 +208,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/public/claude_code.svg b/packages/web/public/claude_code.svg new file mode 100644 index 000000000..853a243c6 --- /dev/null +++ b/packages/web/public/claude_code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/web/public/codex.svg b/packages/web/public/codex.svg new file mode 100644 index 000000000..c77ccfdd9 --- /dev/null +++ b/packages/web/public/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file diff --git a/packages/web/public/cursor_dark.svg b/packages/web/public/cursor_dark.svg new file mode 100644 index 000000000..10d50ca84 --- /dev/null +++ b/packages/web/public/cursor_dark.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/web/public/cursor_light.svg b/packages/web/public/cursor_light.svg new file mode 100644 index 000000000..635d3ccdc --- /dev/null +++ b/packages/web/public/cursor_light.svg @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/web/public/vscode.svg b/packages/web/public/vscode.svg new file mode 100644 index 000000000..0557c2cb3 --- /dev/null +++ b/packages/web/public/vscode.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web/public/windsurf_dark.svg b/packages/web/public/windsurf_dark.svg new file mode 100644 index 000000000..2e4e4e492 --- /dev/null +++ b/packages/web/public/windsurf_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/public/windsurf_light.svg b/packages/web/public/windsurf_light.svg new file mode 100644 index 000000000..386f8c035 --- /dev/null +++ b/packages/web/public/windsurf_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/src/__mocks__/prisma.ts b/packages/web/src/__mocks__/prisma.ts index 2a53ca69f..6093209b7 100644 --- a/packages/web/src/__mocks__/prisma.ts +++ b/packages/web/src/__mocks__/prisma.ts @@ -20,7 +20,7 @@ export const MOCK_ORG: Org = { metadata: null, memberApprovalRequired: false, inviteLinkEnabled: false, - inviteLinkId: null + inviteLinkId: null, } export const MOCK_API_KEY: ApiKey = { @@ -40,6 +40,7 @@ export const MOCK_USER_WITH_ACCOUNTS: User & { accounts: Account[] } = { updatedAt: new Date(), hashedPassword: null, emailVerified: null, + lastActiveAt: null, image: null, sessionVersion: 0, accounts: [], 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/changelogEntryDialog.tsx b/packages/web/src/app/(app)/@sidebar/components/changelogEntryDialog.tsx new file mode 100644 index 000000000..6c36c5d75 --- /dev/null +++ b/packages/web/src/app/(app)/@sidebar/components/changelogEntryDialog.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { ChangelogEntryDto } from "@/features/changelog/listEntriesApi"; +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; +import { ArrowUpRight } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import Markdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import { compareVersions, parseVersion, SOURCEBOT_VERSION } from "@sourcebot/shared/client"; + +const VIDEO_EXTENSIONS_RE = /\.(mp4|webm|ogg|mov)$/i; +const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+\-.]*:|\/\/|#)/i; + +// Allow