Skip to content

Commit 98b9475

Browse files
feat(self-host): add capability-aware setup
1 parent 3de63c9 commit 98b9475

89 files changed

Lines changed: 7443 additions & 560 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ If you prefer not to use Docker or Dev Containers. **All commands run from the r
316316
```bash
317317
bun run type-check # TypeScript across every workspace
318318
bun run lint:check # Biome lint across every workspace
319-
bun run test # Vitest across every workspace
319+
bun run test # Setup CLI Bun tests, then Vitest across every workspace
320320
```
321321
322322
### Email Template Development

.github/workflows/test-build.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ jobs:
159159
- name: Verify generated tool metadata is in sync
160160
run: bun run tool-metadata:check
161161

162+
- name: Verify integration deployment metadata is in sync
163+
run: bun run integration-catalog:check
164+
162165
- name: Verify skill projections are in sync
163166
run: bun run skills:check
164167

@@ -183,8 +186,8 @@ jobs:
183186
- name: Install ripgrep
184187
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)
185188

186-
# Named for what it does: `bun run test` is `vitest run`, with no
187-
# `--coverage`. See the Codecov note below.
189+
# Runs the setup CLI's Bun tests plus each workspace's Vitest suite,
190+
# without `--coverage`. See the Codecov note below.
188191
- name: Run tests
189192
env:
190193
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
```bash
3030
git clone https://github.com/simstudioai/sim.git && cd sim
31+
bun install
3132
bun run setup
3233
```
3334

@@ -84,6 +85,25 @@ Open [http://localhost:3000](http://localhost:3000)
8485

8586
When it finishes, open [http://localhost:3000](http://localhost:3000).
8687

88+
Reconfigure an optional capability without rerunning the full wizard:
89+
90+
```bash
91+
bun run setup status
92+
bun run setup email
93+
bun run setup storage
94+
bun run setup sandbox
95+
bun run setup jobs
96+
bun run setup cache
97+
bun run setup knowledge
98+
bun run setup llm
99+
bun run setup integration slack
100+
```
101+
102+
`bun run setup status` detects the effective local-dev, Docker Compose, or current-context
103+
Helm configuration and reports configured, missing, or invalid capabilities and OAuth
104+
integrations without printing credential values. This is separate from `bun run sim status`,
105+
which reports whether installed services are running and healthy.
106+
87107
Manage your install with `bun run sim`:
88108

89109
```bash

apps/sim/.env.example

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
9696
# CONTEXT_DEV_API_KEY_1= # Context.dev API key #1
9797
# CONTEXT_DEV_API_KEY_2= # Context.dev API key #2
9898

99+
# PDF OCR provider (Optional - defaults to local; legacy installs infer Mistral from configured credentials)
100+
# OCR_PROVIDER=local # One of: local, mistral, azure-mistral
101+
99102
# File Storage (Optional - defaults to local disk; use S3, Azure Blob, or Google Cloud Storage for production)
103+
# STORAGE_PROVIDER=local # One of: local, s3, azure, gcs. Required when cloud storage is configured
100104
# AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2
101105
# AWS_ACCESS_KEY_ID= # Omit to use the instance/IRSA credential chain
102106
# AWS_SECRET_ACCESS_KEY= # Omit to use the instance/IRSA credential chain
@@ -121,7 +125,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
121125
# TIKTOK_CLIENT_ID=
122126
# TIKTOK_CLIENT_SECRET=
123127

124-
# Azure Blob Storage takes precedence over S3 if both are configured
128+
# Azure Blob Storage
125129
# AZURE_ACCOUNT_NAME= # Azure storage account name
126130
# AZURE_ACCOUNT_KEY= # Azure storage account key
127131
# AZURE_CONNECTION_STRING= # Alternative to account name/key
@@ -134,7 +138,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
134138
# AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME= # OpenGraph preview images (falls back to AZURE_STORAGE_CONTAINER_NAME)
135139
# AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME= # Workspace logos (falls back to AZURE_STORAGE_CONTAINER_NAME)
136140

137-
# Google Cloud Storage (used when neither Azure Blob nor S3 is configured)
141+
# Google Cloud Storage
138142
# GCS_PROJECT_ID= # GCP project ID (optional — inferred from credentials/ADC when unset)
139143
# GCS_CREDENTIALS_JSON= # Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, GOOGLE_APPLICATION_CREDENTIALS)
140144
# GCS_BUCKET_NAME= # General workspace files bucket (enables GCS; all other buckets fall back to it)

apps/sim/app/api/auth/instagram/authorize/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection
55
import { parseRequest } from '@/lib/api/server'
66
import { getSession } from '@/lib/auth'
77
import { env } from '@/lib/core/config/env'
8+
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
89
import { getBaseUrl } from '@/lib/core/utils/urls'
910
import { isSameOrigin } from '@/lib/core/utils/validation'
1011
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -28,6 +29,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2829
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2930
}
3031

32+
requireConfiguredOAuthClient('instagram')
33+
3134
const clientId = env.INSTAGRAM_CLIENT_ID
3235
if (!clientId) {
3336
logger.error('INSTAGRAM_CLIENT_ID not configured')

apps/sim/app/api/auth/oauth2/authorize/route.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,11 @@ describe('OAuth2 authorize route', () => {
8181
beforeEach(() => {
8282
vi.clearAllMocks()
8383
resetDbChainMock()
84-
setEnv({ NEXT_PUBLIC_APP_URL: BASE_URL })
84+
setEnv({
85+
NEXT_PUBLIC_APP_URL: BASE_URL,
86+
GOOGLE_CLIENT_ID: 'google-client',
87+
GOOGLE_CLIENT_SECRET: 'google-secret',
88+
})
8589
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
8690
mockCheckWorkspaceAccess.mockResolvedValue({
8791
hasAccess: true,
@@ -139,6 +143,18 @@ describe('OAuth2 authorize route', () => {
139143
expect(set).toHaveProperty('credentialId', null)
140144
})
141145

146+
it('rejects an OAuth client that is not configured for the deployment', async () => {
147+
setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined })
148+
149+
const response = await GET(
150+
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
151+
)
152+
153+
expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
154+
expect(dbChainMockFns.values).not.toHaveBeenCalled()
155+
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
156+
})
157+
142158
it('redirects to login when unauthenticated', async () => {
143159
mockGetSession.mockResolvedValue(null)
144160

apps/sim/app/api/auth/oauth2/authorize/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections'
44
import { parseRequest } from '@/lib/api/server'
55
import { auth, getSession } from '@/lib/auth/auth'
6+
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
67
import { getBaseUrl } from '@/lib/core/utils/urls'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import { getCredentialActorContext } from '@/lib/credentials/access'
@@ -100,6 +101,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
100101
reconnectDisplayName = actor.credential.displayName
101102
}
102103

104+
requireConfiguredOAuthClient(providerId)
105+
103106
// Create the draft before initiating the link so it is guaranteed to exist
104107
// (and freshly clocked) when the OAuth callback's `account.create.after`
105108
// hook runs. If this throws, we never start the OAuth flow.

apps/sim/app/api/auth/shopify/authorize/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from '@/lib/api/contracts/oauth-connections'
88
import { getSession } from '@/lib/auth'
99
import { env } from '@/lib/core/config/env'
10+
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
1011
import { getBaseUrl } from '@/lib/core/utils/urls'
1112
import { isSameOrigin } from '@/lib/core/utils/validation'
1213
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -25,8 +26,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2526
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
2627
}
2728

28-
const clientId = env.SHOPIFY_CLIENT_ID
29+
requireConfiguredOAuthClient('shopify')
2930

31+
const clientId = env.SHOPIFY_CLIENT_ID
3032
if (!clientId) {
3133
logger.error('SHOPIFY_CLIENT_ID not configured')
3234
return NextResponse.json({ error: 'Shopify client ID not configured' }, { status: 500 })

apps/sim/app/api/settings/allowed-integrations/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
22
import { getSession } from '@/lib/auth'
33
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
44
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5+
import { getIntegrationAvailability } from '@/lib/integrations/availability.server'
56

67
export const GET = withRouteHandler(async () => {
78
const session = await getSession()
@@ -11,5 +12,8 @@ export const GET = withRouteHandler(async () => {
1112

1213
return NextResponse.json({
1314
allowedIntegrations: getAllowedIntegrationsFromEnv(),
15+
integrationAvailability: getIntegrationAvailability().map(
16+
({ type, state, oauthAvailable }) => ({ type, state, oauthAvailable })
17+
),
1418
})
1519
})

apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
resolveCredentialDisplay,
1414
resolveOAuthServiceForIntegration,
1515
} from '@/lib/integrations'
16+
import { getServiceAccountMetadata } from '@/lib/integrations/service-account-metadata'
1617
import { credentialProviderMatchesService } from '@/lib/oauth'
1718
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
1819
import { RESOURCE_TILE_BASE } from '@/app/workspace/[workspaceId]/components/resource-tile'
@@ -40,6 +41,7 @@ import {
4041
} from '@/blocks/registry'
4142
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
4243
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
44+
import { usePermissionConfig } from '@/hooks/use-permission-config'
4345

4446
/** Maximum number of overlapping icon tiles rendered per template row. */
4547
const TEMPLATE_CLUSTER_MAX = 3 as const
@@ -64,6 +66,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
6466
const matchingTemplates = getTemplatesForBlock(integration.type)
6567
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
6668
const oauthService = resolveOAuthServiceForIntegration(integration)
69+
const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig()
70+
const availability = integrationAvailability.get(integration.type.toLowerCase())
71+
const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true)
6772
const [oauthOpen, setOAuthOpen] = useState(false)
6873

6974
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
@@ -93,40 +98,61 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
9398
serviceName: oauthService?.serviceName,
9499
serviceIcon: oauthService?.serviceIcon,
95100
})
96-
const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden
101+
const serviceAccountMetadata = integration.oauthServiceId
102+
? getServiceAccountMetadata(integration.oauthServiceId)
103+
: undefined
104+
const serviceAccountDeploymentAvailable =
105+
availability?.state === 'ready' ||
106+
availability?.state === 'limited' ||
107+
(availability?.state === 'unavailable' &&
108+
serviceAccountMetadata?.deploymentRequirement === 'preview-gated')
109+
const hasServiceAccount =
110+
serviceAccountDeploymentAvailable &&
111+
Boolean(serviceAccountTarget) &&
112+
!serviceAccountTarget?.hidden
97113
const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account'
98114
const hasHandledConnectQueryRef = useRef(false)
99115

100116
useEffect(() => {
101-
if (hasHandledConnectQueryRef.current) return
102-
if (!connectMode) return
117+
if (hasHandledConnectQueryRef.current || !connectMode || permissionConfigLoading) return
103118

104-
let handled = false
105-
if (connectMode === CONNECT_MODE.oauth && oauthService) {
119+
if (connectMode === CONNECT_MODE.oauth && oauthService && oauthAvailable) {
106120
setOAuthOpen(true)
107-
handled = true
108121
} else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) {
109122
setServiceAccountOpen(true)
110-
handled = true
111123
}
112-
if (!handled) return
113124

114125
hasHandledConnectQueryRef.current = true
115126
void setConnectMode(null, { history: 'replace', scroll: false })
116-
}, [connectMode, oauthService, hasServiceAccount, setConnectMode])
127+
}, [
128+
connectMode,
129+
oauthService,
130+
oauthAvailable,
131+
hasServiceAccount,
132+
permissionConfigLoading,
133+
setConnectMode,
134+
])
117135

118136
const connectOptions = oauthService
119137
? [
120-
{
121-
value: CONNECT_MODE.oauth,
122-
label: 'Connect with OAuth',
123-
icon: oauthService.serviceIcon,
124-
},
125-
{
126-
value: CONNECT_MODE.serviceAccount,
127-
label: serviceAccountConnectLabel,
128-
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
129-
},
138+
...(oauthAvailable
139+
? [
140+
{
141+
value: CONNECT_MODE.oauth,
142+
label: 'Connect with OAuth',
143+
icon: oauthService.serviceIcon,
144+
},
145+
]
146+
: []),
147+
...(hasServiceAccount
148+
? [
149+
{
150+
value: CONNECT_MODE.serviceAccount,
151+
label: serviceAccountConnectLabel,
152+
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
153+
},
154+
]
155+
: []),
130156
]
131157
: []
132158

@@ -148,7 +174,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
148174
</ChipLink>
149175
<div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>
150176
{oauthService ? (
151-
hasServiceAccount ? (
177+
connectOptions.length > 1 ? (
152178
<ChipDropdown
153179
variant='primary'
154180
leftIcon={Plus}
@@ -158,10 +184,16 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
158184
onChange={handleSelectConnectOption}
159185
matchTriggerWidth={false}
160186
/>
161-
) : (
187+
) : oauthAvailable ? (
162188
<Chip variant='primary' leftIcon={Plus} onClick={() => setOAuthOpen(true)}>
163189
Add to Sim
164190
</Chip>
191+
) : hasServiceAccount ? (
192+
<Chip variant='primary' leftIcon={Plus} onClick={() => setServiceAccountOpen(true)}>
193+
{serviceAccountConnectLabel}
194+
</Chip>
195+
) : (
196+
<Chip disabled>Unavailable</Chip>
165197
)
166198
) : isChatEnabled ? (
167199
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
@@ -170,7 +202,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
170202
) : null}
171203
</div>
172204
</div>
173-
{oauthService && (
205+
{oauthService && oauthAvailable && (
174206
<ConnectOAuthModal
175207
mode='connect'
176208
origin='integrations'

0 commit comments

Comments
 (0)